From 585b3f7e3d7af5d62c85dbdb6f04af24517a3921 Mon Sep 17 00:00:00 2001 From: Nathan L Smith Date: Tue, 30 Jun 2020 14:03:05 -0500 Subject: [PATCH 01/72] Add Jest configuration and README to observability plugin (#70340) Also clean up the coverage configuration in the APM jest config. --- x-pack/plugins/apm/jest.config.js | 5 +-- x-pack/plugins/observability/README.md | 27 ++++++++++++++ x-pack/plugins/observability/jest.config.js | 40 +++++++++++++++++++++ 3 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 x-pack/plugins/observability/README.md create mode 100644 x-pack/plugins/observability/jest.config.js diff --git a/x-pack/plugins/apm/jest.config.js b/x-pack/plugins/apm/jest.config.js index 2f9d8a37376d9..5be8ad141ffd0 100644 --- a/x-pack/plugins/apm/jest.config.js +++ b/x-pack/plugins/apm/jest.config.js @@ -29,16 +29,13 @@ module.exports = { roots: [`${rootDir}/common`, `${rootDir}/public`, `${rootDir}/server`], collectCoverage: true, collectCoverageFrom: [ + ...jestConfig.collectCoverageFrom, '**/*.{js,mjs,jsx,ts,tsx}', - '!**/{__test__,__snapshots__,__examples__,integration_tests,tests}/**', '!**/*.stories.{js,mjs,ts,tsx}', - '!**/*.test.{js,mjs,ts,tsx}', '!**/dev_docs/**', '!**/e2e/**', - '!**/scripts/**', '!**/target/**', '!**/typings/**', - '!**/mocks/**', ], coverageDirectory: `${rootDir}/target/coverage/jest`, coverageReporters: ['html'], diff --git a/x-pack/plugins/observability/README.md b/x-pack/plugins/observability/README.md new file mode 100644 index 0000000000000..0ef0543c2922e --- /dev/null +++ b/x-pack/plugins/observability/README.md @@ -0,0 +1,27 @@ +# Observability plugin + +This plugin provides shared components and services for use across observability solutions, as well as the observability landing page UI. + +## Unit testing + +Note: Run the following commands from `kibana/x-pack/plugins/observability`. + +### Run unit tests + +```bash +npx jest --watch +``` + +### Update snapshots + +```bash +npx jest --updateSnapshot +``` + +### Coverage + +HTML coverage report can be found in target/coverage/jest after tests have run. + +```bash +open target/coverage/jest/index.html +``` diff --git a/x-pack/plugins/observability/jest.config.js b/x-pack/plugins/observability/jest.config.js new file mode 100644 index 0000000000000..cbf9a86360b89 --- /dev/null +++ b/x-pack/plugins/observability/jest.config.js @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// This is an APM-specific Jest configuration which overrides the x-pack +// configuration. It's intended for use in development and does not run in CI, +// which runs the entire x-pack suite. Run `npx jest`. + +require('../../../src/setup_node_env'); + +const { createJestConfig } = require('../../dev-tools/jest/create_jest_config'); +const { resolve } = require('path'); + +const rootDir = resolve(__dirname, '.'); +const xPackKibanaDirectory = resolve(__dirname, '../..'); +const kibanaDirectory = resolve(__dirname, '../../..'); + +const jestConfig = createJestConfig({ + kibanaDirectory, + rootDir, + xPackKibanaDirectory, +}); + +module.exports = { + ...jestConfig, + reporters: ['default'], + roots: [`${rootDir}/common`, `${rootDir}/public`, `${rootDir}/server`], + collectCoverage: true, + collectCoverageFrom: [ + ...jestConfig.collectCoverageFrom, + '**/*.{js,mjs,jsx,ts,tsx}', + '!**/*.stories.{js,mjs,ts,tsx}', + '!**/target/**', + '!**/typings/**', + ], + coverageDirectory: `${rootDir}/target/coverage/jest`, + coverageReporters: ['html'], +}; From 56aac44ac361afb1afaa1f34409f7bbf332e830f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Tue, 30 Jun 2020 20:14:59 +0100 Subject: [PATCH 02/72] [API Integration Tests] usageApi service to expose the private telemetry API (#70057) Co-authored-by: Elastic Machine --- x-pack/test/api_integration/services/index.ts | 2 +- .../api_integration/services/usage_api.js | 28 ---------- .../api_integration/services/usage_api.ts | 52 +++++++++++++++++++ .../reporting/usage.ts | 2 +- .../reporting_api_integration/services.ts | 2 + 5 files changed, 56 insertions(+), 30 deletions(-) delete mode 100644 x-pack/test/api_integration/services/usage_api.js create mode 100644 x-pack/test/api_integration/services/usage_api.ts diff --git a/x-pack/test/api_integration/services/index.ts b/x-pack/test/api_integration/services/index.ts index d135c43e2302c..75cc2b451ea2e 100644 --- a/x-pack/test/api_integration/services/index.ts +++ b/x-pack/test/api_integration/services/index.ts @@ -13,7 +13,7 @@ import { LegacyEsProvider } from './legacy_es'; import { EsSupertestWithoutAuthProvider } from './es_supertest_without_auth'; // @ts-ignore not ts yet import { SupertestWithoutAuthProvider } from './supertest_without_auth'; -// @ts-ignore not ts yet + import { UsageAPIProvider } from './usage_api'; import { InfraOpsGraphQLClientProvider, diff --git a/x-pack/test/api_integration/services/usage_api.js b/x-pack/test/api_integration/services/usage_api.js deleted file mode 100644 index 57ae1d152f70c..0000000000000 --- a/x-pack/test/api_integration/services/usage_api.js +++ /dev/null @@ -1,28 +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. - */ - -export function UsageAPIProvider({ getService }) { - const supertest = getService('supertest'); - const supertestNoAuth = getService('supertestWithoutAuth'); - - return { - async getUsageStatsNoAuth() { - const { body } = await supertestNoAuth - .get('/api/stats?extended=true') - .set('kbn-xsrf', 'xxx') - .expect(401); - return body.usage; - }, - - async getUsageStats() { - const { body } = await supertest - .get('/api/stats?extended=true') - .set('kbn-xsrf', 'xxx') - .expect(200); - return body.usage; - }, - }; -} diff --git a/x-pack/test/api_integration/services/usage_api.ts b/x-pack/test/api_integration/services/usage_api.ts new file mode 100644 index 0000000000000..ef6f10f2d4f46 --- /dev/null +++ b/x-pack/test/api_integration/services/usage_api.ts @@ -0,0 +1,52 @@ +/* + * 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 { FtrProviderContext } from '../ftr_provider_context'; +import { TelemetryCollectionManagerPlugin } from '../../../../src/plugins/telemetry_collection_manager/server/plugin'; + +export function UsageAPIProvider({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const supertestNoAuth = getService('supertestWithoutAuth'); + + return { + async getUsageStatsNoAuth(): Promise { + const { body } = await supertestNoAuth + .get('/api/stats?extended=true') + .set('kbn-xsrf', 'xxx') + .expect(401); + return body.usage; + }, + + /** + * Public stats API: it returns the usage in camelCase format + */ + async getUsageStats() { + const { body } = await supertest + .get('/api/stats?extended=true') + .set('kbn-xsrf', 'xxx') + .expect(200); + return body.usage; + }, + + /** + * Retrieve the stats via the private telemetry API: + * It returns the usage in as a string encrypted blob or the plain payload if `unencrypted: false` + * + * @param payload Request parameters to retrieve the telemetry stats + */ + async getTelemetryStats(payload: { + unencrypted?: boolean; + timeRange: { min: Date; max: Date }; + }): Promise { + const { body } = await supertest + .post('/api/telemetry/v2/clusters/_stats') + .set('kbn-xsrf', 'xxx') + .send(payload) + .expect(200); + return body; + }, + }; +} diff --git a/x-pack/test/reporting_api_integration/reporting/usage.ts b/x-pack/test/reporting_api_integration/reporting/usage.ts index 25bebaa6abcb2..24e68b3917d6c 100644 --- a/x-pack/test/reporting_api_integration/reporting/usage.ts +++ b/x-pack/test/reporting_api_integration/reporting/usage.ts @@ -19,7 +19,7 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); const reportingAPI = getService('reportingAPI'); - const usageAPI = getService('usageAPI' as any); // NOTE Usage API service is not Typescript + const usageAPI = getService('usageAPI'); describe('reporting usage', () => { before(async () => { diff --git a/x-pack/test/reporting_api_integration/services.ts b/x-pack/test/reporting_api_integration/services.ts index 85f5a98c69b2e..f98d8246fc01e 100644 --- a/x-pack/test/reporting_api_integration/services.ts +++ b/x-pack/test/reporting_api_integration/services.ts @@ -9,6 +9,7 @@ import * as Rx from 'rxjs'; import { filter, first, mapTo, switchMap, timeout } from 'rxjs/operators'; import { indexTimestamp } from '../../plugins/reporting/server/lib/store/index_timestamp'; import { services as xpackServices } from '../functional/services'; +import { services as apiIntegrationServices } from '../api_integration/services'; import { FtrProviderContext } from './ftr_provider_context'; interface PDFAppCounts { @@ -185,5 +186,6 @@ export function ReportingAPIProvider({ getService }: FtrProviderContext) { export const services = { ...xpackServices, + usageAPI: apiIntegrationServices.usageAPI, reportingAPI: ReportingAPIProvider, }; From a07526484a20a0e792a38f8defdc19912e012293 Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Tue, 30 Jun 2020 15:25:16 -0400 Subject: [PATCH 03/72] [SECURITY] Bug overview link (#70214) * fix link bug on overview page * Rename Signal to Alert in selection of event in timeline * review I * fix i18n --- .../alerts_histogram_panel/index.test.tsx | 61 ++++++++++++++++--- .../alerts_histogram_panel/index.tsx | 17 ++++-- .../public/common/components/link_to/index.ts | 17 +++--- .../pages/endpoint_hosts/view/index.test.tsx | 2 + .../pages/policy/view/policy_details.test.tsx | 2 + .../pages/policy/view/policy_list.test.tsx | 2 + .../overview_network/index.test.tsx | 49 ++++++++++++++- .../components/overview_network/index.tsx | 10 ++- .../timeline/search_or_filter/pick_events.tsx | 2 +- .../timeline/search_or_filter/translations.ts | 6 +- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 12 files changed, 136 insertions(+), 34 deletions(-) diff --git a/x-pack/plugins/security_solution/public/alerts/components/alerts_histogram_panel/index.test.tsx b/x-pack/plugins/security_solution/public/alerts/components/alerts_histogram_panel/index.test.tsx index db783e6cc2f88..2923446b8322d 100644 --- a/x-pack/plugins/security_solution/public/alerts/components/alerts_histogram_panel/index.test.tsx +++ b/x-pack/plugins/security_solution/public/alerts/components/alerts_histogram_panel/index.test.tsx @@ -18,21 +18,62 @@ jest.mock('react-router-dom', () => { }; }); -jest.mock('../../../common/lib/kibana'); +const mockNavigateToApp = jest.fn(); +jest.mock('../../../common/lib/kibana', () => { + const original = jest.requireActual('../../../common/lib/kibana'); + + return { + ...original, + useKibana: () => ({ + services: { + application: { + navigateToApp: mockNavigateToApp, + getUrlForApp: jest.fn(), + }, + }, + }), + useUiSetting$: jest.fn().mockReturnValue([]), + useGetUserSavedObjectPermissions: jest.fn(), + }; +}); jest.mock('../../../common/components/navigation/use_get_url_search'); describe('AlertsHistogramPanel', () => { + const defaultProps = { + from: 0, + signalIndexName: 'signalIndexName', + setQuery: jest.fn(), + to: 1, + updateDateRange: jest.fn(), + }; + it('renders correctly', () => { - const wrapper = shallow( - - ); + const wrapper = shallow(); expect(wrapper.find('[id="detections-histogram"]')).toBeTruthy(); }); + + describe('Button view alerts', () => { + it('renders correctly', () => { + const props = { ...defaultProps, showLinkToAlerts: true }; + const wrapper = shallow(); + + expect( + wrapper.find('[data-test-subj="alerts-histogram-panel-go-to-alerts-page"]') + ).toBeTruthy(); + }); + + it('when click we call navigateToApp to make sure to navigate to right page', () => { + const props = { ...defaultProps, showLinkToAlerts: true }; + const wrapper = shallow(); + + wrapper + .find('[data-test-subj="alerts-histogram-panel-go-to-alerts-page"]') + .simulate('click', { + preventDefault: jest.fn(), + }); + + expect(mockNavigateToApp).toBeCalledWith('securitySolution:alerts', { path: '' }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/alerts/components/alerts_histogram_panel/index.tsx b/x-pack/plugins/security_solution/public/alerts/components/alerts_histogram_panel/index.tsx index e6eb8afc1658f..b6db6eb93d77f 100644 --- a/x-pack/plugins/security_solution/public/alerts/components/alerts_histogram_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/alerts/components/alerts_histogram_panel/index.tsx @@ -7,12 +7,11 @@ import { Position } from '@elastic/charts'; import { EuiFlexGroup, EuiFlexItem, EuiSelect, EuiPanel } from '@elastic/eui'; import numeral from '@elastic/numeral'; import React, { memo, useCallback, useMemo, useState, useEffect } from 'react'; -import { useHistory } from 'react-router-dom'; import styled from 'styled-components'; import { isEmpty } from 'lodash/fp'; import uuid from 'uuid'; -import { DEFAULT_NUMBER_FORMAT } from '../../../../common/constants'; +import { DEFAULT_NUMBER_FORMAT, APP_ID } from '../../../../common/constants'; import { UpdateDateRange } from '../../../common/components/charts/common'; import { LegendItem } from '../../../common/components/charts/draggable_legend_item'; import { escapeDataProviderId } from '../../../common/components/drag_and_drop/helpers'; @@ -103,7 +102,6 @@ export const AlertsHistogramPanel = memo( title = i18n.HISTOGRAM_HEADER, updateDateRange, }) => { - const history = useHistory(); // create a unique, but stable (across re-renders) query id const uniqueQueryId = useMemo(() => `${DETECTIONS_HISTOGRAM_ID}-${uuid.v4()}`, []); const [isInitialLoading, setIsInitialLoading] = useState(true); @@ -124,6 +122,7 @@ export const AlertsHistogramPanel = memo( signalIndexName ); const kibana = useKibana(); + const { navigateToApp } = kibana.services.application; const { formatUrl, search: urlSearch } = useFormatUrl(SecurityPageName.alerts); const totalAlerts = useMemo( @@ -147,9 +146,11 @@ export const AlertsHistogramPanel = memo( const goToDetectionEngine = useCallback( (ev) => { ev.preventDefault(); - history.push(getDetectionEngineUrl(urlSearch)); + navigateToApp(`${APP_ID}:${SecurityPageName.alerts}`, { + path: getDetectionEngineUrl(urlSearch), + }); }, - [history, urlSearch] + [navigateToApp, urlSearch] ); const formattedAlertsData = useMemo(() => formatAlertsData(alertsData), [alertsData]); @@ -240,7 +241,11 @@ export const AlertsHistogramPanel = memo( if (showLinkToAlerts) { return ( - + {i18n.VIEW_ALERTS} diff --git a/x-pack/plugins/security_solution/public/common/components/link_to/index.ts b/x-pack/plugins/security_solution/public/common/components/link_to/index.ts index 140fa0e460172..c6e58d4206958 100644 --- a/x-pack/plugins/security_solution/public/common/components/link_to/index.ts +++ b/x-pack/plugins/security_solution/public/common/components/link_to/index.ts @@ -6,10 +6,11 @@ import { isEmpty } from 'lodash/fp'; import { useCallback } from 'react'; -import { useHistory } from 'react-router-dom'; import { SecurityPageName } from '../../../app/types'; import { useGetUrlSearch } from '../navigation/use_get_url_search'; import { navTabs } from '../../../app/home/home_navigations'; +import { APP_ID } from '../../../../common/constants'; +import { useKibana } from '../../lib/kibana'; export { getDetectionEngineUrl } from './redirect_to_detection_engine'; export { getAppOverviewUrl } from './redirect_to_overview'; @@ -24,19 +25,19 @@ export { } from './redirect_to_case'; export const useFormatUrl = (page: SecurityPageName) => { - const history = useHistory(); + const { getUrlForApp } = useKibana().services.application; const search = useGetUrlSearch(navTabs[page]); const formatUrl = useCallback( (path: string) => { const pathArr = path.split('?'); - return history.createHref({ - pathname: pathArr[0], - search: isEmpty(pathArr[1]) - ? search - : `${pathArr[1]}${isEmpty(search) ? '' : `&${search}`}`, + const formattedPath = `${pathArr[0]}${ + isEmpty(pathArr[1]) ? search : `${pathArr[1]}${isEmpty(search) ? '' : `&${search}`}` + }`; + return getUrlForApp(`${APP_ID}:${page}`, { + path: formattedPath, }); }, - [history, search] + [getUrlForApp, page, search] ); return { formatUrl, search }; }; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx index 073e2a07457ff..9766cd6abd2b1 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx @@ -20,6 +20,8 @@ import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_da import { AppAction } from '../../../../common/store/actions'; import { POLICY_STATUS_TO_HEALTH_COLOR, POLICY_STATUS_TO_TEXT } from './host_constants'; +jest.mock('../../../../common/components/link_to'); + describe('when on the hosts page', () => { const docGenerator = new EndpointDocGenerator(); let render: () => ReturnType; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx index 984639f0f599d..5ecd47cbe8eef 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx @@ -13,6 +13,8 @@ import { AppContextTestRender, createAppRootMockRenderer } from '../../../../com import { getPolicyDetailPath, getPoliciesPath } from '../../../common/routing'; import { apiPathMockResponseProviders } from '../store/policy_list/test_mock_utils'; +jest.mock('../../../../common/components/link_to'); + describe('Policy Details', () => { type FindReactWrapperResponse = ReturnType['find']>; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.test.tsx index 32de3c93ac98f..db622ceb87b63 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.test.tsx @@ -12,6 +12,8 @@ import { mockPolicyResultList } from '../store/policy_list/mock_policy_result_li import { AppContextTestRender, createAppRootMockRenderer } from '../../../../common/mock/endpoint'; import { AppAction } from '../../../../common/store/actions'; +jest.mock('../../../../common/components/link_to'); + describe('when on the policies page', () => { let render: () => ReturnType; let history: AppContextTestRender['history']; diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_network/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_network/index.test.tsx index 7a9834ee3ea9a..42c80b6b115bd 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_network/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_network/index.test.tsx @@ -24,7 +24,24 @@ import { GetOverviewHostQuery } from '../../../graphql/types'; import { wait } from '../../../common/lib/helpers'; jest.mock('../../../common/components/link_to'); -jest.mock('../../../common/lib/kibana'); +const mockNavigateToApp = jest.fn(); +jest.mock('../../../common/lib/kibana', () => { + const original = jest.requireActual('../../../common/lib/kibana'); + + return { + ...original, + useKibana: () => ({ + services: { + application: { + navigateToApp: mockNavigateToApp, + getUrlForApp: jest.fn(), + }, + }, + }), + useUiSetting$: jest.fn().mockReturnValue([]), + useGetUserSavedObjectPermissions: jest.fn(), + }; +}); const startDate = 1579553397080; const endDate = 1579639797080; @@ -143,4 +160,34 @@ describe('OverviewNetwork', () => { 'Showing: 9 events' ); }); + + it('it renders View Network', () => { + const wrapper = mount( + + + + + + ); + + expect(wrapper.find('[data-test-subj="overview-network-go-to-network-page"]')).toBeTruthy(); + }); + + it('when click on View Network we call navigateToApp to make sure to navigate to right page', () => { + const wrapper = mount( + + + + + + ); + + wrapper + .find('[data-test-subj="overview-network-go-to-network-page"] button') + .simulate('click', { + preventDefault: jest.fn(), + }); + + expect(mockNavigateToApp).toBeCalledWith('securitySolution:network', { path: '' }); + }); }); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_network/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_network/index.tsx index 31544eaa2d3b0..a3760863bcb62 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_network/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_network/index.tsx @@ -51,14 +51,14 @@ const OverviewNetworkComponent: React.FC = ({ startDate, setQuery, }) => { - const { formatUrl, search: urlSearch } = useFormatUrl(SecurityPageName.hosts); + const { formatUrl, search: urlSearch } = useFormatUrl(SecurityPageName.network); const { navigateToApp } = useKibana().services.application; const [defaultNumberFormat] = useUiSetting$(DEFAULT_NUMBER_FORMAT); const goToNetwork = useCallback( (ev) => { ev.preventDefault(); - navigateToApp(`${APP_ID}:${SecurityPageName.hosts}`, { + navigateToApp(`${APP_ID}:${SecurityPageName.network}`, { path: getNetworkUrl(urlSearch), }); }, @@ -67,7 +67,11 @@ const OverviewNetworkComponent: React.FC = ({ const networkPageButton = useMemo( () => ( - + {i18n.SIGNAL_EVENT}, + inputDisplay: {i18n.ALERT_EVENT}, }, ]; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/translations.ts index 769bcedb7aae3..7271c599302c5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/translations.ts @@ -84,9 +84,9 @@ export const RAW_EVENT = i18n.translate( } ); -export const SIGNAL_EVENT = i18n.translate( - 'xpack.securitySolution.timeline.searchOrFilter.eventTypeSignalEvent', +export const ALERT_EVENT = i18n.translate( + 'xpack.securitySolution.timeline.searchOrFilter.eventTypeAlertEvent', { - defaultMessage: 'Signal events', + defaultMessage: 'Alert events', } ); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 8cf2cff945948..0b466f351d7db 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -14739,7 +14739,6 @@ "xpack.securitySolution.timeline.rangePicker.oneYear": "1 年", "xpack.securitySolution.timeline.searchOrFilter.eventTypeAllEvent": "すべてのイベント", "xpack.securitySolution.timeline.searchOrFilter.eventTypeRawEvent": "未加工イベント", - "xpack.securitySolution.timeline.searchOrFilter.eventTypeSignalEvent": "シグナルイベント", "xpack.securitySolution.timeline.searchOrFilter.filterDescription": "上のデータプロバイダーからのイベントは、隣接の KQL でフィルターされます", "xpack.securitySolution.timeline.searchOrFilter.filterKqlPlaceholder": "イベントをフィルター", "xpack.securitySolution.timeline.searchOrFilter.filterKqlSelectedText": "フィルター", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index fb5d1050e50c5..52c01d292cacc 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -14745,7 +14745,6 @@ "xpack.securitySolution.timeline.rangePicker.oneYear": "1 年", "xpack.securitySolution.timeline.searchOrFilter.eventTypeAllEvent": "所有事件", "xpack.securitySolution.timeline.searchOrFilter.eventTypeRawEvent": "原始事件", - "xpack.securitySolution.timeline.searchOrFilter.eventTypeSignalEvent": "信号事件", "xpack.securitySolution.timeline.searchOrFilter.filterDescription": "来自上述数据提供程序的事件按相邻 KQL 进行筛选", "xpack.securitySolution.timeline.searchOrFilter.filterKqlPlaceholder": "筛选事件", "xpack.securitySolution.timeline.searchOrFilter.filterKqlSelectedText": "筛选", From ad5ccfd7dfadbf5a79a6ecb82266d8d81c049ff9 Mon Sep 17 00:00:00 2001 From: Matthias Wilhelm Date: Tue, 30 Jun 2020 21:28:21 +0200 Subject: [PATCH 04/72] [Discover] Remove column from sorting array when removed from table (#65990) --- .../public/application/angular/discover.js | 6 +++++- test/functional/apps/discover/_discover.js | 15 +++++++++++++++ test/functional/page_objects/discover_page.ts | 4 ++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/plugins/discover/public/application/angular/discover.js b/src/plugins/discover/public/application/angular/discover.js index 05ca748c777bf..9b8b32b51cfd8 100644 --- a/src/plugins/discover/public/application/angular/discover.js +++ b/src/plugins/discover/public/application/angular/discover.js @@ -1011,7 +1011,11 @@ function discoverController( $scope.indexPattern.popularizeField(columnName, 1); } const columns = columnActions.removeColumn($scope.state.columns, columnName); - setAppState({ columns }); + // The state's sort property is an array of [sortByColumn,sortDirection] + const sort = $scope.state.sort.length + ? $scope.state.sort.filter((subArr) => subArr[0] !== columnName) + : []; + setAppState({ columns, sort }); }; $scope.moveColumn = function moveColumn(columnName, newIndex) { diff --git a/test/functional/apps/discover/_discover.js b/test/functional/apps/discover/_discover.js index de9606f3d02ed..906f0b83e99e7 100644 --- a/test/functional/apps/discover/_discover.js +++ b/test/functional/apps/discover/_discover.js @@ -20,6 +20,7 @@ import expect from '@kbn/expect'; export default function ({ getService, getPageObjects }) { + const browser = getService('browser'); const log = getService('log'); const retry = getService('retry'); const esArchiver = getService('esArchiver'); @@ -268,5 +269,19 @@ export default function ({ getService, getPageObjects }) { expect(toastMessage).to.be('Invalid time range'); }); }); + + describe('managing fields', function () { + it('should add a field, sort by it, remove it and also sorting by it', async function () { + await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.discover.clickFieldListItemAdd('_score'); + await PageObjects.discover.clickFieldSort('_score'); + const currentUrlWithScore = await browser.getCurrentUrl(); + expect(currentUrlWithScore).to.contain('_score'); + await PageObjects.discover.clickFieldListItemAdd('_score'); + const currentUrlWithoutScore = await browser.getCurrentUrl(); + expect(currentUrlWithoutScore).not.to.contain('_score'); + }); + }); }); } diff --git a/test/functional/page_objects/discover_page.ts b/test/functional/page_objects/discover_page.ts index 9ba3c9c1c2c88..7e083d41895b6 100644 --- a/test/functional/page_objects/discover_page.ts +++ b/test/functional/page_objects/discover_page.ts @@ -242,6 +242,10 @@ export function DiscoverPageProvider({ getService, getPageObjects }: FtrProvider return await testSubjects.click(`field-${field}`); } + public async clickFieldSort(field: string) { + return await testSubjects.click(`docTableHeaderFieldSort_${field}`); + } + public async clickFieldListItemAdd(field: string) { await testSubjects.moveMouseTo(`field-${field}`); await testSubjects.click(`fieldToggle-${field}`); From 432f93a1a56f6cb2968b34369c054831eced30ab Mon Sep 17 00:00:00 2001 From: "Devin W. Hurley" Date: Tue, 30 Jun 2020 16:43:49 -0400 Subject: [PATCH 05/72] [SECURITY SOLUTION] [Detections] Increase lookback when gap is detected (#68339) * add POC logic to modify the 'from' param in the search * fixes formatting for appending gap diff to from * computes new max signals based on how many intervals of rule runs were missed when gap in consecutive rule runs is detected * adds logging, fixes bug where we could end up with negative values for diff, adds calculatedFrom to the search after query * remove console.log and for some reason two eslint disables were added so i removed one of them * rename variables, add test based on log message - need to figure out a better way to test this * remove unused import * fully re-worked the algorithm for searching discrete time periods, still need search_after because a user could submit a rule with a custom maxSignals so that would still serve a purpose. This needs heavy refactoring though, and tests. * updated loop to include maxSignals per time interval tuple, this way we guarantee maxSignals per full rule interval. Needs some refactoring though. * move logic into utils function, utils function still needs refactoring * adds unit tests and cleans up new util function for determining time intervals for searching to occur * more code cleanup * remove more logging statements * fix type errors * updates unit tests and fixes bug where search result would return 0 hits but we were accessing property on non-existent hit item * fix rebase conflict * fixes a bug where a negative gap could exist if a rule ran before the lookback time, also fixes a bug where the search and bulk loop would return false when successful. * gap is a duration, not a number. * remove logging variable * remove logging function from test * fix type import from rebase with master * updates missed test when rebased with master, removes unused import * modify log statements to include meta information for logged rule events, adds tests * remove unnecessary ts-ignores * indentation on stringify * adds a test to ensure we are parsing the elapsed time correctly --- .../signals/filter_events_with_list.test.ts | 13 +- .../signals/filter_events_with_list.ts | 7 +- .../signals/search_after_bulk_create.test.ts | 172 ++++++++++++- .../signals/search_after_bulk_create.ts | 237 ++++++++++-------- .../signals/signal_rule_alert_type.ts | 4 + .../signals/single_bulk_create.ts | 1 + .../detection_engine/signals/utils.test.ts | 94 +++++++ .../lib/detection_engine/signals/utils.ts | 149 ++++++++++- 8 files changed, 563 insertions(+), 114 deletions(-) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.test.ts index bb56926390af9..9eebb91c32652 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.test.ts @@ -6,6 +6,7 @@ import uuid from 'uuid'; import { filterEventsAgainstList } from './filter_events_with_list'; +import { buildRuleMessageFactory } from './rule_messages'; import { mockLogger, repeatedSearchResultsWithSortId } from './__mocks__/es_results'; import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; @@ -13,7 +14,12 @@ import { getListItemResponseMock } from '../../../../../lists/common/schemas/res import { listMock } from '../../../../../lists/server/mocks'; const someGuids = Array.from({ length: 13 }).map((x) => uuid.v4()); - +const buildRuleMessage = buildRuleMessageFactory({ + id: 'fake id', + ruleId: 'fake rule id', + index: 'fakeindex', + name: 'fake name', +}); describe('filterEventsAgainstList', () => { let listClient = listMock.getListClient(); beforeEach(() => { @@ -33,6 +39,7 @@ describe('filterEventsAgainstList', () => { '3.3.3.3', '7.7.7.7', ]), + buildRuleMessage, }); expect(res.hits.hits.length).toEqual(4); }); @@ -57,6 +64,7 @@ describe('filterEventsAgainstList', () => { listClient, exceptionsList: [exceptionItem], eventSearchResult: repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3)), + buildRuleMessage, }); expect(res.hits.hits.length).toEqual(4); }); @@ -91,6 +99,7 @@ describe('filterEventsAgainstList', () => { '3.3.3.3', '7.7.7.7', ]), + buildRuleMessage, }); expect((listClient.getListItemByValues as jest.Mock).mock.calls[0][0].type).toEqual('ip'); expect((listClient.getListItemByValues as jest.Mock).mock.calls[0][0].listId).toEqual( @@ -118,6 +127,7 @@ describe('filterEventsAgainstList', () => { listClient, exceptionsList: [exceptionItem], eventSearchResult: repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3)), + buildRuleMessage, }); expect(res.hits.hits.length).toEqual(0); }); @@ -152,6 +162,7 @@ describe('filterEventsAgainstList', () => { '3.3.3.3', '7.7.7.7', ]), + buildRuleMessage, }); expect((listClient.getListItemByValues as jest.Mock).mock.calls[0][0].type).toEqual('ip'); expect((listClient.getListItemByValues as jest.Mock).mock.calls[0][0].listId).toEqual( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.ts index 1a2f648eb8562..27e038eb7adf6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.ts @@ -8,6 +8,7 @@ import { Logger } from 'src/core/server'; import { ListClient } from '../../../../../lists/server'; import { SignalSearchResponse, SearchTypes } from './types'; +import { BuildRuleMessage } from './rule_messages'; import { entriesList, EntryList, @@ -19,6 +20,7 @@ interface FilterEventsAgainstList { exceptionsList: ExceptionListItemSchema[]; logger: Logger; eventSearchResult: SignalSearchResponse; + buildRuleMessage: BuildRuleMessage; } export const filterEventsAgainstList = async ({ @@ -26,9 +28,12 @@ export const filterEventsAgainstList = async ({ exceptionsList, logger, eventSearchResult, + buildRuleMessage, }: FilterEventsAgainstList): Promise => { try { + logger.debug(buildRuleMessage(`exceptionsList: ${JSON.stringify(exceptionsList, null, 2)}`)); if (exceptionsList == null || exceptionsList.length === 0) { + logger.debug(buildRuleMessage('about to return original search result')); return eventSearchResult; } @@ -86,7 +91,7 @@ export const filterEventsAgainstList = async ({ return false; }); const diff = eventSearchResult.hits.hits.length - filteredEvents.length; - logger.debug(`Lists filtered out ${diff} events`); + logger.debug(buildRuleMessage(`Lists filtered out ${diff} events`)); return filteredEvents; }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts index 1923f43c47b92..17935f64d5e14 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts @@ -4,14 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ +import moment from 'moment'; import { sampleRuleAlertParams, sampleEmptyDocSearchResults, sampleRuleGuid, mockLogger, repeatedSearchResultsWithSortId, + sampleDocSearchResultsNoSortIdNoHits, } from './__mocks__/es_results'; import { searchAfterAndBulkCreate } from './search_after_bulk_create'; +import { buildRuleMessageFactory } from './rule_messages'; import { DEFAULT_SIGNALS_INDEX } from '../../../../common/constants'; import { alertsMock, AlertServicesMock } from '../../../../../alerts/server/mocks'; import uuid from 'uuid'; @@ -19,6 +22,13 @@ import { getListItemResponseMock } from '../../../../../lists/common/schemas/res import { listMock } from '../../../../../lists/server/mocks'; import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; +const buildRuleMessage = buildRuleMessageFactory({ + id: 'fake id', + ruleId: 'fake rule id', + index: 'fakeindex', + name: 'fake name', +}); + describe('searchAfterAndBulkCreate', () => { let mockService: AlertServicesMock; let inputIndexPattern: string[] = []; @@ -94,7 +104,9 @@ describe('searchAfterAndBulkCreate', () => { }, }, ], - }); + }) + .mockResolvedValueOnce(sampleDocSearchResultsNoSortIdNoHits()); + const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.entries = [ { @@ -110,6 +122,8 @@ describe('searchAfterAndBulkCreate', () => { const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ ruleParams: sampleParams, + gap: null, + previousStartedAt: new Date(), listClient, exceptionsList: [exceptionItem], services: mockService, @@ -130,13 +144,139 @@ describe('searchAfterAndBulkCreate', () => { refresh: false, tags: ['some fake tag 1', 'some fake tag 2'], throttle: 'no_actions', + buildRuleMessage, }); expect(success).toEqual(true); - expect(mockService.callCluster).toHaveBeenCalledTimes(8); + expect(mockService.callCluster).toHaveBeenCalledTimes(9); expect(createdSignalsCount).toEqual(4); expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); }); + test('should return success with number of searches less than max signals with gap', async () => { + const sampleParams = sampleRuleAlertParams(30); + mockService.callCluster + .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(0, 3))) + .mockResolvedValueOnce({ + took: 100, + errors: false, + items: [ + { + fakeItemValue: 'fakeItemKey', + }, + { + create: { + status: 201, + }, + }, + ], + }) + .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(3, 6))) + .mockResolvedValueOnce({ + took: 100, + errors: false, + items: [ + { + fakeItemValue: 'fakeItemKey', + }, + { + create: { + status: 201, + }, + }, + ], + }) + .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(6, 9))) + .mockResolvedValueOnce({ + took: 100, + errors: false, + items: [ + { + fakeItemValue: 'fakeItemKey', + }, + { + create: { + status: 201, + }, + }, + ], + }) + .mockResolvedValueOnce(sampleDocSearchResultsNoSortIdNoHits()) + .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(9, 12))) + .mockResolvedValueOnce({ + took: 100, + errors: false, + items: [ + { + fakeItemValue: 'fakeItemKey', + }, + { + create: { + status: 201, + }, + }, + ], + }) + .mockResolvedValueOnce(repeatedSearchResultsWithSortId(4, 1, someGuids.slice(0, 3))) + .mockResolvedValueOnce({ + took: 100, + errors: false, + items: [ + { + fakeItemValue: 'fakeItemKey', + }, + { + create: { + status: 201, + }, + }, + ], + }) + .mockResolvedValueOnce(sampleDocSearchResultsNoSortIdNoHits()); + + const exceptionItem = getExceptionListItemSchemaMock(); + exceptionItem.entries = [ + { + field: 'source.ip', + operator: 'included', + type: 'list', + list: { + id: 'ci-badguys.txt', + type: 'ip', + }, + }, + ]; + const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ + ruleParams: sampleParams, + gap: moment.duration(2, 'm'), + previousStartedAt: moment().subtract(10, 'm').toDate(), + listClient, + exceptionsList: [exceptionItem], + services: mockService, + logger: mockLogger, + id: sampleRuleGuid, + inputIndexPattern, + signalsIndex: DEFAULT_SIGNALS_INDEX, + name: 'rule-name', + actions: [], + createdAt: '2020-01-28T15:58:34.810Z', + updatedAt: '2020-01-28T15:59:14.004Z', + createdBy: 'elastic', + updatedBy: 'elastic', + interval: '5m', + enabled: true, + pageSize: 1, + filter: undefined, + refresh: false, + tags: ['some fake tag 1', 'some fake tag 2'], + throttle: 'no_actions', + buildRuleMessage, + }); + expect(success).toEqual(true); + expect(mockService.callCluster).toHaveBeenCalledTimes(12); + expect(createdSignalsCount).toEqual(5); + expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); + }); + test('should return success when no search results are in the allowlist', async () => { const sampleParams = sampleRuleAlertParams(30); mockService.callCluster @@ -169,7 +309,9 @@ describe('searchAfterAndBulkCreate', () => { }, }, ], - }); + }) + .mockResolvedValueOnce(sampleDocSearchResultsNoSortIdNoHits()); + const exceptionItem = getExceptionListItemSchemaMock(); exceptionItem.entries = [ { @@ -184,6 +326,8 @@ describe('searchAfterAndBulkCreate', () => { ]; const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ ruleParams: sampleParams, + gap: null, + previousStartedAt: new Date(), listClient, exceptionsList: [exceptionItem], services: mockService, @@ -204,9 +348,10 @@ describe('searchAfterAndBulkCreate', () => { refresh: false, tags: ['some fake tag 1', 'some fake tag 2'], throttle: 'no_actions', + buildRuleMessage, }); expect(success).toEqual(true); - expect(mockService.callCluster).toHaveBeenCalledTimes(2); + expect(mockService.callCluster).toHaveBeenCalledTimes(3); expect(createdSignalsCount).toEqual(4); // should not create any signals because all events were in the allowlist expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); }); @@ -243,7 +388,8 @@ describe('searchAfterAndBulkCreate', () => { }, }, ], - }); + }) + .mockResolvedValueOnce(sampleDocSearchResultsNoSortIdNoHits()); listClient.getListItemByValues = jest.fn(({ value }) => Promise.resolve( @@ -255,6 +401,8 @@ describe('searchAfterAndBulkCreate', () => { ); const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ ruleParams: sampleParams, + gap: null, + previousStartedAt: new Date(), listClient, exceptionsList: [], services: mockService, @@ -275,9 +423,10 @@ describe('searchAfterAndBulkCreate', () => { refresh: false, tags: ['some fake tag 1', 'some fake tag 2'], throttle: 'no_actions', + buildRuleMessage, }); expect(success).toEqual(true); - expect(mockService.callCluster).toHaveBeenCalledTimes(2); + expect(mockService.callCluster).toHaveBeenCalledTimes(3); expect(createdSignalsCount).toEqual(4); // should not create any signals because all events were in the allowlist expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); }); @@ -302,6 +451,8 @@ describe('searchAfterAndBulkCreate', () => { const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ listClient, exceptionsList: [exceptionItem], + gap: null, + previousStartedAt: new Date(), ruleParams: sampleParams, services: mockService, logger: mockLogger, @@ -321,6 +472,7 @@ describe('searchAfterAndBulkCreate', () => { refresh: false, tags: ['some fake tag 1', 'some fake tag 2'], throttle: 'no_actions', + buildRuleMessage, }); expect(mockLogger.error).toHaveBeenCalled(); expect(success).toEqual(false); @@ -341,7 +493,7 @@ describe('searchAfterAndBulkCreate', () => { }, }, ]; - const sampleParams = sampleRuleAlertParams(); + const sampleParams = sampleRuleAlertParams(30); mockService.callCluster.mockResolvedValueOnce(sampleEmptyDocSearchResults()); listClient.getListItemByValues = jest.fn(({ value }) => Promise.resolve( @@ -354,6 +506,8 @@ describe('searchAfterAndBulkCreate', () => { const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ listClient, exceptionsList: [exceptionItem], + gap: null, + previousStartedAt: new Date(), ruleParams: sampleParams, services: mockService, logger: mockLogger, @@ -373,6 +527,7 @@ describe('searchAfterAndBulkCreate', () => { refresh: false, tags: ['some fake tag 1', 'some fake tag 2'], throttle: 'no_actions', + buildRuleMessage, }); expect(success).toEqual(true); expect(createdSignalsCount).toEqual(0); @@ -422,6 +577,8 @@ describe('searchAfterAndBulkCreate', () => { const { success, createdSignalsCount, lastLookBackDate } = await searchAfterAndBulkCreate({ listClient, exceptionsList: [exceptionItem], + gap: null, + previousStartedAt: new Date(), ruleParams: sampleParams, services: mockService, logger: mockLogger, @@ -441,6 +598,7 @@ describe('searchAfterAndBulkCreate', () => { refresh: false, tags: ['some fake tag 1', 'some fake tag 2'], throttle: 'no_actions', + buildRuleMessage, }); expect(success).toEqual(false); expect(createdSignalsCount).toEqual(0); // should not create signals if search threw error diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts index 7475257121552..f3025ead69a05 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts @@ -3,6 +3,9 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +/* eslint-disable complexity */ + +import moment from 'moment'; import { AlertServices } from '../../../../../alerts/server'; import { ListClient } from '../../../../../lists/server'; @@ -11,11 +14,15 @@ import { RuleTypeParams, RefreshTypes } from '../types'; import { Logger } from '../../../../../../../src/core/server'; import { singleSearchAfter } from './single_search_after'; import { singleBulkCreate } from './single_bulk_create'; +import { BuildRuleMessage } from './rule_messages'; import { SignalSearchResponse } from './types'; import { filterEventsAgainstList } from './filter_events_with_list'; import { ExceptionListItemSchema } from '../../../../../lists/common/schemas'; +import { getSignalTimeTuples } from './utils'; interface SearchAfterAndBulkCreateParams { + gap: moment.Duration | null; + previousStartedAt: Date | null | undefined; ruleParams: RuleTypeParams; services: AlertServices; listClient: ListClient | undefined; // TODO: undefined is for temporary development, remove before merged @@ -37,6 +44,7 @@ interface SearchAfterAndBulkCreateParams { refresh: RefreshTypes; tags: string[]; throttle: string; + buildRuleMessage: BuildRuleMessage; } export interface SearchAfterAndBulkCreateReturnType { @@ -49,6 +57,8 @@ export interface SearchAfterAndBulkCreateReturnType { // search_after through documents and re-index using bulk endpoint. export const searchAfterAndBulkCreate = async ({ + gap, + previousStartedAt, ruleParams, exceptionsList, services, @@ -70,6 +80,7 @@ export const searchAfterAndBulkCreate = async ({ refresh, tags, throttle, + buildRuleMessage, }: SearchAfterAndBulkCreateParams): Promise => { const toReturn: SearchAfterAndBulkCreateReturnType = { success: false, @@ -96,119 +107,137 @@ export const searchAfterAndBulkCreate = async ({ we only want 500. So maxResults will help us control how many times we perform a search_after */ - let maxResults = ruleParams.maxSignals; - // Get + const totalToFromTuples = getSignalTimeTuples({ + logger, + ruleParamsFrom: ruleParams.from, + ruleParamsTo: ruleParams.to, + ruleParamsMaxSignals: ruleParams.maxSignals, + gap, + previousStartedAt, + interval, + buildRuleMessage, + }); + const useSortIds = totalToFromTuples.length <= 1; + logger.debug(buildRuleMessage(`totalToFromTuples: ${totalToFromTuples.length}`)); + while (totalToFromTuples.length > 0) { + const tuple = totalToFromTuples.pop(); + if (tuple == null || tuple.to == null || tuple.from == null) { + logger.error(buildRuleMessage(`[-] malformed date tuple`)); + toReturn.success = false; + return toReturn; + } + searchResultSize = 0; + while (searchResultSize < tuple.maxSignals) { + try { + logger.debug(buildRuleMessage(`sortIds: ${sortId}`)); + const { + searchResult, + searchDuration, + }: { searchResult: SignalSearchResponse; searchDuration: string } = await singleSearchAfter( + { + searchAfterSortId: useSortIds ? sortId : undefined, + index: inputIndexPattern, + from: tuple.from.toISOString(), + to: tuple.to.toISOString(), + services, + logger, + filter, + pageSize: tuple.maxSignals < pageSize ? Math.ceil(tuple.maxSignals) : pageSize, // maximum number of docs to receive per search result. + } + ); + toReturn.searchAfterTimes.push(searchDuration); - while (searchResultSize < maxResults) { - try { - logger.debug(`sortIds: ${sortId}`); - const { - // @ts-ignore https://github.com/microsoft/TypeScript/issues/35546 - searchResult, - searchDuration, - }: { searchResult: SignalSearchResponse; searchDuration: string } = await singleSearchAfter({ - searchAfterSortId: sortId, - index: inputIndexPattern, - from: ruleParams.from, - to: ruleParams.to, - services, - logger, - filter, - pageSize, // maximum number of docs to receive per search result. - }); - toReturn.searchAfterTimes.push(searchDuration); - toReturn.lastLookBackDate = - searchResult.hits.hits.length > 0 - ? new Date( - searchResult.hits.hits[searchResult.hits.hits.length - 1]?._source['@timestamp'] - ) - : null; - const totalHits = - typeof searchResult.hits.total === 'number' - ? searchResult.hits.total - : searchResult.hits.total.value; - logger.debug(`totalHits: ${totalHits}`); + const totalHits = + typeof searchResult.hits.total === 'number' + ? searchResult.hits.total + : searchResult.hits.total.value; + logger.debug(buildRuleMessage(`totalHits: ${totalHits}`)); + logger.debug( + buildRuleMessage(`searchResult.hit.hits.length: ${searchResult.hits.hits.length}`) + ); + if (totalHits === 0) { + toReturn.success = true; + break; + } + toReturn.lastLookBackDate = + searchResult.hits.hits.length > 0 + ? new Date( + searchResult.hits.hits[searchResult.hits.hits.length - 1]?._source['@timestamp'] + ) + : null; + searchResultSize += searchResult.hits.hits.length; - // re-calculate maxResults to ensure if our search results - // are less than max signals, we are not attempting to - // create more signals than there are total search results. - maxResults = Math.min(totalHits, ruleParams.maxSignals); - searchResultSize += searchResult.hits.hits.length; - if (searchResult.hits.hits.length === 0) { - toReturn.success = true; - return toReturn; - } + // filter out the search results that match with the values found in the list. + // the resulting set are valid signals that are not on the allowlist. + const filteredEvents: SignalSearchResponse = + listClient != null + ? await filterEventsAgainstList({ + listClient, + exceptionsList, + logger, + eventSearchResult: searchResult, + buildRuleMessage, + }) + : searchResult; + if (filteredEvents.hits.total === 0 || filteredEvents.hits.hits.length === 0) { + // everything in the events were allowed, so no need to generate signals + toReturn.success = true; + break; + } - // filter out the search results that match with the values found in the list. - // the resulting set are valid signals that are not on the allowlist. - const filteredEvents: SignalSearchResponse = - listClient != null - ? await filterEventsAgainstList({ - listClient, - exceptionsList, - logger, - eventSearchResult: searchResult, - }) - : searchResult; + const { + bulkCreateDuration: bulkDuration, + createdItemsCount: createdCount, + } = await singleBulkCreate({ + filteredEvents, + ruleParams, + services, + logger, + id, + signalsIndex, + actions, + name, + createdAt, + createdBy, + updatedAt, + updatedBy, + interval, + enabled, + refresh, + tags, + throttle, + }); + logger.debug(buildRuleMessage(`created ${createdCount} signals`)); + toReturn.createdSignalsCount += createdCount; + if (bulkDuration) { + toReturn.bulkCreateTimes.push(bulkDuration); + } - if (filteredEvents.hits.hits.length === 0) { - // everything in the events were allowed, so no need to generate signals - toReturn.success = true; - return toReturn; - } - - // cap max signals created to be no more than maxSignals - if (toReturn.createdSignalsCount + filteredEvents.hits.hits.length > ruleParams.maxSignals) { - const tempSignalsToIndex = filteredEvents.hits.hits.slice( - 0, - ruleParams.maxSignals - toReturn.createdSignalsCount + logger.debug( + buildRuleMessage(`filteredEvents.hits.hits: ${filteredEvents.hits.hits.length}`) ); - filteredEvents.hits.hits = tempSignalsToIndex; - } - logger.debug('next bulk index'); - const { - bulkCreateDuration: bulkDuration, - createdItemsCount: createdCount, - } = await singleBulkCreate({ - filteredEvents, - ruleParams, - services, - logger, - id, - signalsIndex, - actions, - name, - createdAt, - createdBy, - updatedAt, - updatedBy, - interval, - enabled, - refresh, - tags, - throttle, - }); - logger.debug('finished next bulk index'); - logger.debug(`created ${createdCount} signals`); - toReturn.createdSignalsCount += createdCount; - if (bulkDuration) { - toReturn.bulkCreateTimes.push(bulkDuration); - } - - if (filteredEvents.hits.hits[0].sort == null) { - logger.debug('sortIds was empty on search'); - toReturn.success = true; - return toReturn; // no more search results + if (useSortIds && filteredEvents.hits.hits[0].sort == null) { + logger.debug(buildRuleMessage('sortIds was empty on search')); + toReturn.success = true; + break; + } else if ( + useSortIds && + filteredEvents.hits.hits !== null && + filteredEvents.hits.hits[0].sort !== null + ) { + sortId = filteredEvents.hits.hits[0].sort + ? filteredEvents.hits.hits[0].sort[0] + : undefined; + } + } catch (exc) { + logger.error(buildRuleMessage(`[-] search_after and bulk threw an error ${exc}`)); + toReturn.success = false; + return toReturn; } - sortId = filteredEvents.hits.hits[0].sort[0]; - } catch (exc) { - logger.error(`[-] search_after and bulk threw an error ${exc}`); - toReturn.success = false; - return toReturn; } } - logger.debug(`[+] completed bulk index of ${toReturn.createdSignalsCount}`); + logger.debug(buildRuleMessage(`[+] completed bulk index of ${toReturn.createdSignalsCount}`)); toReturn.success = true; return toReturn; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts index 1bf27dc6e26b2..143c300698b5f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -121,6 +121,7 @@ export const signalRulesAlertType = ({ }); logger.debug(buildRuleMessage('[+] Starting Signal Rule execution')); + logger.debug(buildRuleMessage(`interval: ${interval}`)); await ruleStatusService.goingToRun(); const gap = getGapBetweenRuns({ previousStartedAt, interval, from, to }); @@ -235,6 +236,8 @@ export const signalRulesAlertType = ({ }); result = await searchAfterAndBulkCreate({ + gap, + previousStartedAt, listClient, exceptionsList: exceptionItems ?? [], ruleParams: params, @@ -256,6 +259,7 @@ export const signalRulesAlertType = ({ refresh, tags, throttle, + buildRuleMessage, }); } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts index 6f4d01ea73a79..3d4e7384714eb 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts @@ -84,6 +84,7 @@ export const singleBulkCreate = async ({ }: SingleBulkCreateParams): Promise => { filteredEvents.hits.hits = filterDuplicateRules(id, filteredEvents); if (filteredEvents.hits.hits.length === 0) { + logger.debug(`all events were duplicates`); return { success: true, createdItemsCount: 0 }; } // index documents after creating an ID based on the diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts index 24c2d24ee972e..162cf42be170e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts @@ -10,6 +10,7 @@ import sinon from 'sinon'; import { alertsMock, AlertServicesMock } from '../../../../../alerts/server/mocks'; import { listMock } from '../../../../../lists/server/mocks'; import { EntriesArray } from '../../../../common/detection_engine/lists_common_deps'; +import { buildRuleMessageFactory } from './rule_messages'; import * as featureFlags from '../feature_flags'; @@ -22,6 +23,7 @@ import { errorAggregator, getListsClient, hasLargeValueList, + getSignalTimeTuples, } from './utils'; import { BulkResponseErrorAggregation } from './types'; import { @@ -29,8 +31,16 @@ import { sampleEmptyBulkResponse, sampleBulkError, sampleBulkErrorItem, + mockLogger, } from './__mocks__/es_results'; +const buildRuleMessage = buildRuleMessageFactory({ + id: 'fake id', + ruleId: 'fake rule id', + index: 'fakeindex', + name: 'fake name', +}); + describe('utils', () => { const anchor = '2020-01-01T06:06:06.666Z'; const unix = moment(anchor).valueOf(); @@ -638,4 +648,88 @@ describe('utils', () => { expect(hasLists).toBeFalsy(); }); }); + describe('getSignalTimeTuples', () => { + test('should return a single tuple if no gap', () => { + const someTuples = getSignalTimeTuples({ + logger: mockLogger, + gap: null, + previousStartedAt: moment().subtract(30, 's').toDate(), + interval: '30s', + ruleParamsFrom: 'now-30s', + ruleParamsTo: 'now', + ruleParamsMaxSignals: 20, + buildRuleMessage, + }); + const someTuple = someTuples[0]; + expect(moment(someTuple.to).diff(moment(someTuple.from), 's')).toEqual(30); + }); + + test('should return two tuples if gap and previouslyStartedAt', () => { + const someTuples = getSignalTimeTuples({ + logger: mockLogger, + gap: moment.duration(10, 's'), + previousStartedAt: moment().subtract(65, 's').toDate(), + interval: '50s', + ruleParamsFrom: 'now-55s', + ruleParamsTo: 'now', + ruleParamsMaxSignals: 20, + buildRuleMessage, + }); + const someTuple = someTuples[1]; + expect(moment(someTuple.to).diff(moment(someTuple.from), 's')).toEqual(10); + }); + + test('should return five tuples when give long gap', () => { + const someTuples = getSignalTimeTuples({ + logger: mockLogger, + gap: moment.duration(65, 's'), // 64 is 5 times the interval + lookback, which will trigger max lookback + previousStartedAt: moment().subtract(65, 's').toDate(), + interval: '10s', + ruleParamsFrom: 'now-13s', + ruleParamsTo: 'now', + ruleParamsMaxSignals: 20, + buildRuleMessage, + }); + expect(someTuples.length).toEqual(5); + someTuples.forEach((item, index) => { + if (index === 0) { + return; + } + expect(moment(item.to).diff(moment(item.from), 's')).toEqual(10); + }); + }); + + // this tests if calculatedFrom in utils.ts:320 parses an int and not a float + // if we don't parse as an int, then dateMath.parse will fail + // as it doesn't support parsing `now-67.549`, it only supports ints like `now-67`. + test('should return five tuples when given a gap with a decimal to ensure no parsing errors', () => { + const someTuples = getSignalTimeTuples({ + logger: mockLogger, + gap: moment.duration(67549, 'ms'), // 64 is 5 times the interval + lookback, which will trigger max lookback + previousStartedAt: moment().subtract(67549, 'ms').toDate(), + interval: '10s', + ruleParamsFrom: 'now-13s', + ruleParamsTo: 'now', + ruleParamsMaxSignals: 20, + buildRuleMessage, + }); + expect(someTuples.length).toEqual(5); + }); + + test('should return single tuples when give a negative gap (rule ran sooner than expected)', () => { + const someTuples = getSignalTimeTuples({ + logger: mockLogger, + gap: moment.duration(-15, 's'), // 64 is 5 times the interval + lookback, which will trigger max lookback + previousStartedAt: moment().subtract(-15, 's').toDate(), + interval: '10s', + ruleParamsFrom: 'now-13s', + ruleParamsTo: 'now', + ruleParamsMaxSignals: 20, + buildRuleMessage, + }); + expect(someTuples.length).toEqual(1); + const someTuple = someTuples[0]; + expect(moment(someTuple.to).diff(moment(someTuple.from), 's')).toEqual(13); + }); + }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts index e431e65fad623..59c23e7ae09fe 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts @@ -7,13 +7,14 @@ import { createHash } from 'crypto'; import moment from 'moment'; import dateMath from '@elastic/datemath'; -import { SavedObjectsClientContract } from '../../../../../../../src/core/server'; +import { Logger, SavedObjectsClientContract } from '../../../../../../../src/core/server'; import { AlertServices, parseDuration } from '../../../../../alerts/server'; import { ExceptionListClient, ListClient, ListPluginSetup } from '../../../../../lists/server'; import { EntriesArray, ExceptionListItemSchema } from '../../../../../lists/common/schemas'; import { ListArrayOrUndefined } from '../../../../common/detection_engine/schemas/types/lists'; import { hasListsFeature } from '../feature_flags'; import { BulkResponse, BulkResponseErrorAggregation } from './types'; +import { BuildRuleMessage } from './rule_messages'; interface SortExceptionsReturn { exceptionsWithValueLists: ExceptionListItemSchema[]; @@ -248,3 +249,149 @@ export const errorAggregator = ( return accum; }, Object.create(null)); }; + +/** + * Determines the number of time intervals to search if gap is present + * along with new maxSignals per time interval. + * @param logger Logger + * @param ruleParamsFrom string representing the rules 'from' property + * @param ruleParamsTo string representing the rules 'to' property + * @param ruleParamsMaxSignals int representing the maxSignals property on the rule (usually unmodified at 100) + * @param gap moment.Duration representing a gap in since the last time the rule ran + * @param previousStartedAt Date at which the rule last ran + * @param interval string the interval which the rule runs + * @param buildRuleMessage function provides meta information for logged event + */ +export const getSignalTimeTuples = ({ + logger, + ruleParamsFrom, + ruleParamsTo, + ruleParamsMaxSignals, + gap, + previousStartedAt, + interval, + buildRuleMessage, +}: { + logger: Logger; + ruleParamsFrom: string; + ruleParamsTo: string; + ruleParamsMaxSignals: number; + gap: moment.Duration | null; + previousStartedAt: Date | null | undefined; + interval: string; + buildRuleMessage: BuildRuleMessage; +}): Array<{ + to: moment.Moment | undefined; + from: moment.Moment | undefined; + maxSignals: number; +}> => { + type unitType = 's' | 'm' | 'h'; + const isValidUnit = (unit: string): unit is unitType => ['s', 'm', 'h'].includes(unit); + let totalToFromTuples: Array<{ + to: moment.Moment | undefined; + from: moment.Moment | undefined; + maxSignals: number; + }> = []; + if (gap != null && gap.valueOf() > 0 && previousStartedAt != null) { + const fromUnit = ruleParamsFrom[ruleParamsFrom.length - 1]; + if (isValidUnit(fromUnit)) { + const unit = fromUnit; // only seconds (s), minutes (m) or hours (h) + const shorthandMap = { + s: { + momentString: 'seconds', + asFn: (duration: moment.Duration) => duration.asSeconds(), + }, + m: { + momentString: 'minutes', + asFn: (duration: moment.Duration) => duration.asMinutes(), + }, + h: { + momentString: 'hours', + asFn: (duration: moment.Duration) => duration.asHours(), + }, + }; + + /* + we need the total duration from now until the last time the rule ran. + the next few lines can be summed up as calculating + "how many second | minutes | hours have passed since the last time this ran?" + */ + const nowToGapDiff = moment.duration(moment().diff(previousStartedAt)); + const calculatedFrom = `now-${ + parseInt(shorthandMap[unit].asFn(nowToGapDiff).toString(), 10) + unit + }`; + logger.debug(buildRuleMessage(`calculatedFrom: ${calculatedFrom}`)); + + const intervalMoment = moment.duration(parseInt(interval, 10), unit); + logger.debug(buildRuleMessage(`intervalMoment: ${shorthandMap[unit].asFn(intervalMoment)}`)); + const calculatedFromAsMoment = dateMath.parse(calculatedFrom); + if (calculatedFromAsMoment != null && intervalMoment != null) { + const dateMathRuleParamsFrom = dateMath.parse(ruleParamsFrom); + const momentUnit = shorthandMap[unit].momentString as moment.DurationInputArg2; + const gapDiffInUnits = calculatedFromAsMoment.diff(dateMathRuleParamsFrom, momentUnit); + + const ratio = Math.abs(gapDiffInUnits / shorthandMap[unit].asFn(intervalMoment)); + + // maxCatchup is to ensure we are not trying to catch up too far back. + // This allows for a maximum of 4 consecutive rule execution misses + // to be included in the number of signals generated. + const maxCatchup = ratio < 4 ? ratio : 4; + logger.debug(buildRuleMessage(`maxCatchup: ${ratio}`)); + + let tempTo = dateMath.parse(ruleParamsFrom); + if (tempTo == null) { + // return an error + throw new Error('dateMath parse failed'); + } + + let beforeMutatedFrom: moment.Moment | undefined; + while (totalToFromTuples.length < maxCatchup) { + // if maxCatchup is less than 1, we calculate the 'from' differently + // and maxSignals becomes some less amount of maxSignals + // in order to maintain maxSignals per full rule interval. + if (maxCatchup > 0 && maxCatchup < 1) { + totalToFromTuples.push({ + to: tempTo.clone(), + from: tempTo.clone().subtract(Math.abs(gapDiffInUnits), momentUnit), + maxSignals: ruleParamsMaxSignals * maxCatchup, + }); + break; + } + const beforeMutatedTo = tempTo.clone(); + + // moment.subtract mutates the moment so we need to clone again.. + beforeMutatedFrom = tempTo.clone().subtract(intervalMoment, momentUnit); + const tuple = { + to: beforeMutatedTo, + from: beforeMutatedFrom, + maxSignals: ruleParamsMaxSignals, + }; + totalToFromTuples = [...totalToFromTuples, tuple]; + tempTo = beforeMutatedFrom; + } + totalToFromTuples = [ + { + to: dateMath.parse(ruleParamsTo), + from: dateMath.parse(ruleParamsFrom), + maxSignals: ruleParamsMaxSignals, + }, + ...totalToFromTuples, + ]; + } else { + logger.debug(buildRuleMessage('calculatedFromMoment was null or intervalMoment was null')); + } + } + } else { + totalToFromTuples = [ + { + to: dateMath.parse(ruleParamsTo), + from: dateMath.parse(ruleParamsFrom), + maxSignals: ruleParamsMaxSignals, + }, + ]; + } + logger.debug( + buildRuleMessage(`totalToFromTuples: ${JSON.stringify(totalToFromTuples, null, 4)}`) + ); + return totalToFromTuples; +}; From aa52102edb9b21d1b7eb0d0423ec4a539ed5109e Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 30 Jun 2020 23:46:21 +0300 Subject: [PATCH 06/72] [SIEM][Timeline] Reset fields based on timeline (#70209) --- .../alerts/components/alerts_table/index.tsx | 1 + .../components/alerts_viewer/alerts_table.tsx | 1 + .../navigation/events_query_tab_body.tsx | 10 +++++++ .../components/fields_browser/header.tsx | 27 ++++--------------- .../components/manage_timeline/index.tsx | 5 ++++ 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/x-pack/plugins/security_solution/public/alerts/components/alerts_table/index.tsx b/x-pack/plugins/security_solution/public/alerts/components/alerts_table/index.tsx index ec088c111e3bb..98bb6434ddafd 100644 --- a/x-pack/plugins/security_solution/public/alerts/components/alerts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/alerts/components/alerts_table/index.tsx @@ -333,6 +333,7 @@ export const AlertsTableComponent: React.FC = ({ initializeTimeline({ id: timelineId, documentType: i18n.ALERTS_DOCUMENT_TYPE, + defaultModel: alertsDefaultModel, footerText: i18n.TOTAL_COUNT_OF_ALERTS, loadingText: i18n.LOADING_ALERTS, title: i18n.ALERTS_TABLE_TITLE, diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_viewer/alerts_table.tsx b/x-pack/plugins/security_solution/public/common/components/alerts_viewer/alerts_table.tsx index 6d5471404ab4d..6783fcbd17582 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_viewer/alerts_table.tsx +++ b/x-pack/plugins/security_solution/public/common/components/alerts_viewer/alerts_table.tsx @@ -75,6 +75,7 @@ const AlertsTableComponent: React.FC = ({ initializeTimeline({ id: timelineId, documentType: i18n.ALERTS_DOCUMENT_TYPE, + defaultModel: alertsDefaultModel, footerText: i18n.TOTAL_COUNT_OF_ALERTS, title: i18n.ALERTS_TABLE_TITLE, unit: i18n.UNIT, diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx index 574e2ec4ae250..64cfacaeaf6dc 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx @@ -17,6 +17,7 @@ import { import { MatrixHistogramContainer } from '../../../common/components/matrix_histogram'; import * as i18n from '../translations'; import { HistogramType } from '../../../graphql/types'; +import { useManageTimeline } from '../../../timelines/components/manage_timeline'; const EVENTS_HISTOGRAM_ID = 'eventsOverTimeQuery'; @@ -55,6 +56,15 @@ export const EventsQueryTabBody = ({ setQuery, startDate, }: HostsComponentsQueryProps) => { + const { initializeTimeline } = useManageTimeline(); + + useEffect(() => { + initializeTimeline({ + id: TimelineId.hostsPageEvents, + defaultModel: eventsDefaultModel, + }); + }, [initializeTimeline]); + useEffect(() => { return () => { if (deleteQuery) { diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/header.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/header.tsx index eab06ef50b3b5..9ad460db4c7b7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/header.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/header.tsx @@ -12,14 +12,10 @@ import { EuiText, EuiTitle, } from '@elastic/eui'; -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback } from 'react'; import styled from 'styled-components'; import { BrowserFields } from '../../../common/containers/source'; -import { alertsHeaders } from '../../../alerts/components/alerts_table/default_config'; -import { alertsHeaders as externalAlertsHeaders } from '../../../common/components/alerts_viewer/default_headers'; -import { defaultHeaders as eventsDefaultHeaders } from '../../../common/components/events_viewer/default_headers'; -import { defaultHeaders } from '../timeline/body/column_headers/default_headers'; import { OnUpdateColumns } from '../timeline/events'; import { getFieldBrowserSearchInputClassName, getFieldCount, SEARCH_INPUT_WIDTH } from './helpers'; @@ -100,26 +96,13 @@ const TitleRow = React.memo<{ isEventViewer?: boolean; onOutsideClick: () => void; onUpdateColumns: OnUpdateColumns; -}>(({ id, isEventViewer, onOutsideClick, onUpdateColumns }) => { +}>(({ id, onOutsideClick, onUpdateColumns }) => { const { getManageTimelineById } = useManageTimeline(); - const documentType = useMemo(() => getManageTimelineById(id).documentType, [ - getManageTimelineById, - id, - ]); const handleResetColumns = useCallback(() => { - let resetDefaultHeaders = defaultHeaders; - if (isEventViewer) { - if (documentType.toLocaleLowerCase() === 'externalAlerts') { - resetDefaultHeaders = externalAlertsHeaders; - } else if (documentType.toLocaleLowerCase() === 'alerts') { - resetDefaultHeaders = alertsHeaders; - } else { - resetDefaultHeaders = eventsDefaultHeaders; - } - } - onUpdateColumns(resetDefaultHeaders); + const timeline = getManageTimelineById(id); + onUpdateColumns(timeline.defaultModel.columns); onOutsideClick(); - }, [isEventViewer, onOutsideClick, onUpdateColumns, documentType]); + }, [id, onUpdateColumns, onOutsideClick, getManageTimelineById]); return ( Date: Tue, 30 Jun 2020 17:06:57 -0400 Subject: [PATCH 07/72] [eslint][ts] Enable prefer-ts-expect-error (#70022) Co-authored-by: Elastic Machine --- packages/eslint-config-kibana/typescript.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/eslint-config-kibana/typescript.js b/packages/eslint-config-kibana/typescript.js index a55ca9391011d..270614ed84b69 100644 --- a/packages/eslint-config-kibana/typescript.js +++ b/packages/eslint-config-kibana/typescript.js @@ -124,6 +124,7 @@ module.exports = { }], '@typescript-eslint/no-var-requires': 'error', '@typescript-eslint/unified-signatures': 'error', + '@typescript-eslint/prefer-ts-expect-error': 'warn', 'constructor-super': 'error', 'dot-notation': 'error', 'eqeqeq': ['error', 'always', {'null': 'ignore'}], From 8903d3427e93fc0e5f4d0fa32ee46bb1a7fc5584 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Tue, 30 Jun 2020 17:23:56 -0400 Subject: [PATCH 08/72] [Ingest Manager] Fix agent ack after input format change (#70335) --- .../server/services/agents/acks.test.ts | 16 +++++++----- .../server/services/agents/acks.ts | 5 ++-- .../api_integration/apis/fleet/agent_flow.ts | 26 ++++++++++++++----- 3 files changed, 32 insertions(+), 15 deletions(-) diff --git a/x-pack/plugins/ingest_manager/server/services/agents/acks.test.ts b/x-pack/plugins/ingest_manager/server/services/agents/acks.test.ts index abc4518e70573..6ea59c9a76a49 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/acks.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/acks.test.ts @@ -139,9 +139,11 @@ describe('test agent acks services', () => { name: 'system-1', type: 'logs', use_output: 'default', - package: { - name: 'system', - version: '0.3.0', + meta: { + package: { + name: 'system', + version: '0.3.0', + }, }, dataset: { namespace: 'default', @@ -279,9 +281,11 @@ describe('test agent acks services', () => { name: 'system-1', type: 'logs', use_output: 'default', - package: { - name: 'system', - version: '0.3.0', + meta: { + package: { + name: 'system', + version: '0.3.0', + }, }, dataset: { namespace: 'default', diff --git a/x-pack/plugins/ingest_manager/server/services/agents/acks.ts b/x-pack/plugins/ingest_manager/server/services/agents/acks.ts index e391e81ebd0a6..c59bac6a5469a 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/acks.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/acks.ts @@ -92,8 +92,9 @@ function getLatestConfigIfUpdated(agent: Agent, actions: AgentAction[]) { function buildUpdateAgentConfig(agentId: string, config: FullAgentConfig) { const packages = config.inputs.reduce((acc, input) => { - if (input.package && input.package.name && acc.indexOf(input.package.name) < 0) { - return [input.package.name, ...acc]; + const packageName = input.meta?.package?.name; + if (packageName && acc.indexOf(packageName) < 0) { + return [packageName, ...acc]; } return acc; }, []); diff --git a/x-pack/test/api_integration/apis/fleet/agent_flow.ts b/x-pack/test/api_integration/apis/fleet/agent_flow.ts index 400db995576ae..71057b81d1b09 100644 --- a/x-pack/test/api_integration/apis/fleet/agent_flow.ts +++ b/x-pack/test/api_integration/apis/fleet/agent_flow.ts @@ -30,7 +30,8 @@ export default function (providerContext: FtrProviderContext) { it('should work', async () => { const kibanaVersionAccessor = kibanaServer.version; const kibanaVersion = await kibanaVersionAccessor.get(); - // 1. Get enrollment token + + // Get enrollment token const { body: enrollmentApiKeysResponse } = await supertest .get(`/api/ingest_manager/fleet/enrollment-api-keys`) .expect(200); @@ -44,7 +45,7 @@ export default function (providerContext: FtrProviderContext) { expect(enrollmentApiKeyResponse.item).to.have.key('api_key'); const enrollmentAPIToken = enrollmentApiKeyResponse.item.api_key; - // 2. Enroll agent + // Enroll agent const { body: enrollmentResponse } = await supertestWithoutAuth .post(`/api/ingest_manager/fleet/agents/enroll`) .set('kbn-xsrf', 'xxx') @@ -63,7 +64,7 @@ export default function (providerContext: FtrProviderContext) { const agentAccessAPIKey = enrollmentResponse.item.access_api_key; - // 3. agent checkin + // Agent checkin const { body: checkinApiResponse } = await supertestWithoutAuth .post(`/api/ingest_manager/fleet/agents/${enrollmentResponse.item.id}/checkin`) .set('kbn-xsrf', 'xx') @@ -79,7 +80,7 @@ export default function (providerContext: FtrProviderContext) { const configChangeAction = checkinApiResponse.actions[0]; const defaultOutputApiKey = configChangeAction.data.config.outputs.default.api_key; - // 4. ack actions + // Ack actions const { body: ackApiResponse } = await supertestWithoutAuth .post(`/api/ingest_manager/fleet/agents/${enrollmentResponse.item.id}/acks`) .set('Authorization', `ApiKey ${agentAccessAPIKey}`) @@ -101,7 +102,7 @@ export default function (providerContext: FtrProviderContext) { .expect(200); expect(ackApiResponse.success).to.eql(true); - // 4. second agent checkin + // Second agent checkin const { body: secondCheckinApiResponse } = await supertestWithoutAuth .post(`/api/ingest_manager/fleet/agents/${enrollmentResponse.item.id}/checkin`) .set('kbn-xsrf', 'xx') @@ -113,14 +114,25 @@ export default function (providerContext: FtrProviderContext) { expect(secondCheckinApiResponse.success).to.eql(true); expect(secondCheckinApiResponse.actions).length(0); - // 5. unenroll agent + // Get agent + const { body: getAgentApiResponse } = await supertest + .get(`/api/ingest_manager/fleet/agents/${enrollmentResponse.item.id}`) + .expect(200); + + expect(getAgentApiResponse.success).to.eql(true); + expect(getAgentApiResponse.item.packages).to.contain( + 'system', + "Agent should run the 'system' package" + ); + + // Unenroll agent const { body: unenrollResponse } = await supertest .post(`/api/ingest_manager/fleet/agents/${enrollmentResponse.item.id}/unenroll`) .set('kbn-xsrf', 'xx') .expect(200); expect(unenrollResponse.success).to.eql(true); - // 6. Checkin after unenrollment + // Checkin after unenrollment await supertestWithoutAuth .post(`/api/ingest_manager/fleet/agents/${enrollmentResponse.item.id}/checkin`) .set('kbn-xsrf', 'xx') From 893525c74cd850ef21c2f36c5288cd85de9bf804 Mon Sep 17 00:00:00 2001 From: Robert Austin Date: Tue, 30 Jun 2020 17:32:44 -0400 Subject: [PATCH 09/72] Resolver refactoring (#70312) * remove unused piece of state * Move related event total calculation to selector * rename xScale * remove `let` * Move `dispatch` call out of HTTP try-catch --- .../public/resolver/store/data/reducer.ts | 1 - .../public/resolver/store/data/selectors.ts | 43 ++++++ .../public/resolver/store/middleware/index.ts | 14 +- .../public/resolver/store/selectors.ts | 8 ++ .../public/resolver/types.ts | 8 +- .../public/resolver/view/map.tsx | 2 +- .../resolver/view/process_event_dot.tsx | 132 ++++++++---------- 7 files changed, 118 insertions(+), 90 deletions(-) diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts index 45bf214005872..19b743374b8ed 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts @@ -9,7 +9,6 @@ import { DataState } from '../../types'; import { ResolverAction } from '../actions'; const initialState: DataState = { - relatedEventsStats: new Map(), relatedEvents: new Map(), relatedEventsReady: new Map(), }; diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts index e45101e97e6c1..9c47c765457e3 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts @@ -301,3 +301,46 @@ export function databaseDocumentIDToAbort(state: DataState): string | null { return null; } } + +/** + * `ResolverNodeStats` for a process (`ResolverEvent`) + */ +const relatedEventStatsForProcess: ( + state: DataState +) => (event: ResolverEvent) => ResolverNodeStats | null = createSelector( + relatedEventsStats, + (statsMap) => { + if (!statsMap) { + return () => null; + } + return (event: ResolverEvent) => { + const nodeStats = statsMap.get(uniquePidForProcess(event)); + if (!nodeStats) { + return null; + } + return nodeStats; + }; + } +); + +/** + * The sum of all related event categories for a process. + */ +export const relatedEventTotalForProcess: ( + state: DataState +) => (event: ResolverEvent) => number | null = createSelector( + relatedEventStatsForProcess, + (statsForProcess) => { + return (event: ResolverEvent) => { + const stats = statsForProcess(event); + if (!stats) { + return null; + } + let total = 0; + for (const value of Object.values(stats.events.byCategory)) { + total += value; + } + return total; + }; + } +); diff --git a/x-pack/plugins/security_solution/public/resolver/store/middleware/index.ts b/x-pack/plugins/security_solution/public/resolver/store/middleware/index.ts index 194b50256c631..398e855a1f5d4 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/middleware/index.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/middleware/index.ts @@ -43,7 +43,7 @@ export const resolverMiddlewareFactory: MiddlewareFactory = (context) => { action.type === 'appDetectedMissingEventData' ) { const entityIdToFetchFor = action.payload; - let result: ResolverRelatedEvents; + let result: ResolverRelatedEvents | undefined; try { result = await context.services.http.get( `/api/endpoint/resolver/${entityIdToFetchFor}/events`, @@ -51,16 +51,18 @@ export const resolverMiddlewareFactory: MiddlewareFactory = (context) => { query: { events: 100 }, } ); + } catch { + api.dispatch({ + type: 'serverFailedToReturnRelatedEventData', + payload: action.payload, + }); + } + if (result) { api.dispatch({ type: 'serverReturnedRelatedEventData', payload: result, }); - } catch (e) { - api.dispatch({ - type: 'serverFailedToReturnRelatedEventData', - payload: action.payload, - }); } } }; diff --git a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts index 55e0072c5227f..e54193ab394a5 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts @@ -188,6 +188,14 @@ const indexedProcessNodesAndEdgeLineSegments = composeSelectors( dataSelectors.visibleProcessNodePositionsAndEdgeLineSegments ); +/** + * Total count of related events for a process. + */ +export const relatedEventTotalForProcess = composeSelectors( + dataStateSelector, + dataSelectors.relatedEventTotalForProcess +); + /** * Return the visible edge lines and process nodes based on the camera position at `time`. * The bounding box represents what the camera can see. The camera position is a function of time because it can be diff --git a/x-pack/plugins/security_solution/public/resolver/types.ts b/x-pack/plugins/security_solution/public/resolver/types.ts index fe5b2276603a8..5dd9a944b88ea 100644 --- a/x-pack/plugins/security_solution/public/resolver/types.ts +++ b/x-pack/plugins/security_solution/public/resolver/types.ts @@ -7,12 +7,7 @@ import { Store } from 'redux'; import { BBox } from 'rbush'; import { ResolverAction } from './store/actions'; -import { - ResolverEvent, - ResolverNodeStats, - ResolverRelatedEvents, - ResolverTree, -} from '../../common/endpoint/types'; +import { ResolverEvent, ResolverRelatedEvents, ResolverTree } from '../../common/endpoint/types'; /** * Redux state for the Resolver feature. Properties on this interface are populated via multiple reducers using redux's `combineReducers`. @@ -176,7 +171,6 @@ export interface VisibleEntites { * State for `data` reducer which handles receiving Resolver data from the backend. */ export interface DataState { - readonly relatedEventsStats: Map; readonly relatedEvents: Map; readonly relatedEventsReady: Map; /** diff --git a/x-pack/plugins/security_solution/public/resolver/view/map.tsx b/x-pack/plugins/security_solution/public/resolver/view/map.tsx index 9022932c1594f..3fc62fc318284 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/map.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/map.tsx @@ -107,7 +107,7 @@ export const ResolverMap = React.memo(function ({ projectionMatrix={projectionMatrix} event={processEvent} adjacentNodeMap={adjacentNodeMap} - relatedEventsStats={ + relatedEventsStatsForProcess={ relatedEventsStats ? relatedEventsStats.get(entityId(processEvent)) : undefined } isProcessTerminated={terminatedProcesses.has(processEntityId)} diff --git a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx index 9df9ed84f3010..6442735abc8cd 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx @@ -4,14 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ +/* eslint-disable react/display-name */ + import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; import { i18n } from '@kbn/i18n'; import { htmlIdGenerator, EuiButton, EuiI18nNumber, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { useSelector } from 'react-redux'; import { useHistory } from 'react-router-dom'; // eslint-disable-next-line import/no-nodejs-modules import querystring from 'querystring'; +import { useSelector } from 'react-redux'; import { NodeSubMenu, subMenuAssets } from './submenu'; import { applyMatrix3 } from '../models/vector2'; import { Vector2, Matrix3, AdjacentProcessMap } from '../types'; @@ -23,7 +25,7 @@ import * as selectors from '../store/selectors'; import { CrumbInfo } from './panels/panel_content_utilities'; /** - * A map of all known event types (in ugly schema format) to beautifully i18n'd display names + * A record of all known event types (in schema format) to translations */ export const displayNameRecord = { application: i18n.translate( @@ -177,11 +179,11 @@ type EventDisplayName = typeof displayNameRecord[keyof typeof displayNameRecord] typeof unknownEventTypeMessage; /** - * Take a gross `schemaName` and return a beautiful translated one. + * Take a `schemaName` and return a translation. */ -const getDisplayName: (schemaName: string) => EventDisplayName = function nameInSchemaToDisplayName( - schemaName -) { +const schemaNameTranslation: ( + schemaName: string +) => EventDisplayName = function nameInSchemaToDisplayName(schemaName) { if (schemaName in displayNameRecord) { return displayNameRecord[schemaName as keyof typeof displayNameRecord]; } @@ -232,7 +234,7 @@ const StyledDescriptionText = styled.div` /** * An artifact that represents a process node and the things associated with it in the Resolver */ -const ProcessEventDotComponents = React.memo( +const UnstyledProcessEventDot = React.memo( ({ className, position, @@ -241,7 +243,7 @@ const ProcessEventDotComponents = React.memo( adjacentNodeMap, isProcessTerminated, isProcessOrigin, - relatedEventsStats, + relatedEventsStatsForProcess, }: { /** * A `className` string provided by `styled` @@ -276,14 +278,14 @@ const ProcessEventDotComponents = React.memo( * to provide the user some visibility regarding the contents thereof. * Statistics for the number of related events and alerts for this process node */ - relatedEventsStats?: ResolverNodeStats; + relatedEventsStatsForProcess?: ResolverNodeStats; }) => { /** * Convert the position, which is in 'world' coordinates, to screen coordinates. */ const [left, top] = applyMatrix3(position, projectionMatrix); - const [magFactorX] = projectionMatrix; + const [xScale] = projectionMatrix; // Node (html id=) IDs const selfId = adjacentNodeMap.self; @@ -293,25 +295,14 @@ const ProcessEventDotComponents = React.memo( // Entity ID of self const selfEntityId = eventModel.entityId(event); - const isShowingEventActions = magFactorX > 0.8; - const isShowingDescriptionText = magFactorX >= 0.55; + const isShowingEventActions = xScale > 0.8; + const isShowingDescriptionText = xScale >= 0.55; /** * As the resolver zooms and buttons and text change visibility, we look to keep the overall container properly vertically aligned */ - const actionalButtonsBaseTopOffset = 5; - let actionableButtonsTopOffset; - switch (true) { - case isShowingEventActions: - actionableButtonsTopOffset = actionalButtonsBaseTopOffset + 3.5 * magFactorX; - break; - case isShowingDescriptionText: - actionableButtonsTopOffset = actionalButtonsBaseTopOffset + magFactorX; - break; - default: - actionableButtonsTopOffset = actionalButtonsBaseTopOffset + 21 * magFactorX; - break; - } + const actionableButtonsTopOffset = + (isShowingEventActions ? 3.5 : isShowingDescriptionText ? 1 : 21) * xScale + 5; /** * The `left` and `top` values represent the 'center' point of the process node. @@ -326,26 +317,24 @@ const ProcessEventDotComponents = React.memo( /** * As the scale changes and button visibility toggles on the graph, these offsets help scale to keep the nodes centered on the edge */ - const nodeXOffsetValue = isShowingEventActions - ? -0.147413 - : -0.147413 - (magFactorX - 0.5) * 0.08; + const nodeXOffsetValue = isShowingEventActions ? -0.147413 : -0.147413 - (xScale - 0.5) * 0.08; const nodeYOffsetValue = isShowingEventActions ? -0.53684 - : -0.53684 + (-magFactorX * 0.2 * (1 - magFactorX)) / magFactorX; + : -0.53684 + (-xScale * 0.2 * (1 - xScale)) / xScale; - const processNodeViewXOffset = nodeXOffsetValue * logicalProcessNodeViewWidth * magFactorX; - const processNodeViewYOffset = nodeYOffsetValue * logicalProcessNodeViewHeight * magFactorX; + const processNodeViewXOffset = nodeXOffsetValue * logicalProcessNodeViewWidth * xScale; + const processNodeViewYOffset = nodeYOffsetValue * logicalProcessNodeViewHeight * xScale; const nodeViewportStyle = useMemo( () => ({ left: `${left + processNodeViewXOffset}px`, top: `${top + processNodeViewYOffset}px`, // Width of symbol viewport scaled to fit - width: `${logicalProcessNodeViewWidth * magFactorX}px`, + width: `${logicalProcessNodeViewWidth * xScale}px`, // Height according to symbol viewbox AR - height: `${logicalProcessNodeViewHeight * magFactorX}px`, + height: `${logicalProcessNodeViewHeight * xScale}px`, }), - [left, magFactorX, processNodeViewXOffset, processNodeViewYOffset, top] + [left, xScale, processNodeViewXOffset, processNodeViewYOffset, top] ); /** @@ -354,7 +343,7 @@ const ProcessEventDotComponents = React.memo( * 18.75 : The smallest readable font size at which labels/descriptions can be read. Font size will not scale below this. * 12.5 : A 'slope' at which the font size will scale w.r.t. to zoom level otherwise */ - const scaledTypeSize = calculateResolverFontSize(magFactorX, 18.75, 12.5); + const scaledTypeSize = calculateResolverFontSize(xScale, 18.75, 12.5); const markerBaseSize = 15; const markerSize = markerBaseSize; @@ -465,47 +454,42 @@ const ProcessEventDotComponents = React.memo( * e.g. "10 DNS", "230 File" */ - const [relatedEventOptions, grandTotal] = useMemo(() => { + const relatedEventOptions = useMemo(() => { const relatedStatsList = []; - if (!relatedEventsStats) { + if (!relatedEventsStatsForProcess) { // Return an empty set of options if there are no stats to report - return [[], 0]; + return []; } - let runningTotal = 0; // If we have entries to show, map them into options to display in the selectable list - for (const category in relatedEventsStats.events.byCategory) { - if (Object.hasOwnProperty.call(relatedEventsStats.events.byCategory, category)) { - const total = relatedEventsStats.events.byCategory[category]; - runningTotal += total; - const displayName = getDisplayName(category); - relatedStatsList.push({ - prefix: , - optionTitle: `${displayName}`, - action: () => { - dispatch({ - type: 'userSelectedRelatedEventCategory', - payload: { - subject: event, - category, - }, - }); - - pushToQueryParams({ crumbId: selfEntityId, crumbEvent: category }); - }, - }); - } - } - return [relatedStatsList, runningTotal]; - }, [relatedEventsStats, dispatch, event, pushToQueryParams, selfEntityId]); - const relatedEventStatusOrOptions = (() => { - if (!relatedEventsStats) { - return subMenuAssets.initialMenuStatus; + for (const [category, total] of Object.entries( + relatedEventsStatsForProcess.events.byCategory + )) { + relatedStatsList.push({ + prefix: , + optionTitle: schemaNameTranslation(category), + action: () => { + dispatch({ + type: 'userSelectedRelatedEventCategory', + payload: { + subject: event, + category, + }, + }); + + pushToQueryParams({ crumbId: selfEntityId, crumbEvent: category }); + }, + }); } + return relatedStatsList; + }, [relatedEventsStatsForProcess, dispatch, event, pushToQueryParams, selfEntityId]); - return relatedEventOptions; - })(); + const relatedEventStatusOrOptions = !relatedEventsStatsForProcess + ? subMenuAssets.initialMenuStatus + : relatedEventOptions; + + const grandTotal: number | null = useSelector(selectors.relatedEventTotalForProcess)(event); /* eslint-disable jsx-a11y/click-events-have-key-events */ /** @@ -586,7 +570,7 @@ const ProcessEventDotComponents = React.memo( {descriptionText}
= 2 ? 'euiButton' : 'euiButton euiButton--small'} + className={xScale >= 2 ? 'euiButton' : 'euiButton euiButton--small'} data-test-subject="nodeLabel" id={labelId} onClick={handleClick} @@ -605,8 +589,8 @@ const ProcessEventDotComponents = React.memo( id={labelId} size="s" style={{ - maxHeight: `${Math.min(26 + magFactorX * 3, 32)}px`, - maxWidth: `${isShowingEventActions ? 400 : 210 * magFactorX}px`, + maxHeight: `${Math.min(26 + xScale * 3, 32)}px`, + maxWidth: `${isShowingEventActions ? 400 : 210 * xScale}px`, }} tabIndex={-1} title={eventModel.eventName(event)} @@ -630,7 +614,7 @@ const ProcessEventDotComponents = React.memo( }} > - {grandTotal > 0 && ( + {grandTotal !== null && grandTotal > 0 && ( Date: Tue, 30 Jun 2020 16:34:38 -0500 Subject: [PATCH 10/72] [Metrics UI] Add context.reason and alertOnNoData to Inventory alerts (#70260) --- .../metrics_and_groupby_toolbar_items.tsx | 2 +- .../infra/common/snapshot_metric_i18n.ts | 208 ++++++++++++++++++ .../inventory/components/expression.tsx | 28 ++- .../infra/public/alerting/inventory/index.ts | 6 +- .../components/toolbars/toolbar_wrapper.tsx | 203 ----------------- .../{metric_threshold => common}/messages.ts | 0 .../infra/server/lib/alerting/common/types.ts | 33 +++ .../evaluate_condition.ts | 17 +- .../inventory_metric_threshold_executor.ts | 71 ++++-- ...r_inventory_metric_threshold_alert_type.ts | 1 + .../inventory_metric_threshold/types.ts | 19 +- .../metric_threshold/lib/evaluate_alert.ts | 2 +- .../metric_threshold_executor.ts | 6 +- .../lib/alerting/metric_threshold/types.ts | 20 +- 14 files changed, 352 insertions(+), 264 deletions(-) create mode 100644 x-pack/plugins/infra/common/snapshot_metric_i18n.ts rename x-pack/plugins/infra/server/lib/alerting/{metric_threshold => common}/messages.ts (100%) create mode 100644 x-pack/plugins/infra/server/lib/alerting/common/types.ts diff --git a/x-pack/plugins/infra/common/inventory_models/shared/components/metrics_and_groupby_toolbar_items.tsx b/x-pack/plugins/infra/common/inventory_models/shared/components/metrics_and_groupby_toolbar_items.tsx index 9ddf422871d18..a66421fe2fd0e 100644 --- a/x-pack/plugins/infra/common/inventory_models/shared/components/metrics_and_groupby_toolbar_items.tsx +++ b/x-pack/plugins/infra/common/inventory_models/shared/components/metrics_and_groupby_toolbar_items.tsx @@ -6,6 +6,7 @@ import React, { useMemo } from 'react'; import { EuiFlexItem } from '@elastic/eui'; +import { toMetricOpt } from '../../../snapshot_metric_i18n'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { WaffleSortControls } from '../../../../public/pages/metrics/inventory_view/components/waffle/waffle_sort_controls'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths @@ -16,7 +17,6 @@ import { WaffleMetricControls } from '../../../../public/pages/metrics/inventory import { WaffleGroupByControls } from '../../../../public/pages/metrics/inventory_view/components/waffle/waffle_group_by_controls'; import { toGroupByOpt, - toMetricOpt, // eslint-disable-next-line @kbn/eslint/no-restricted-paths } from '../../../../public/pages/metrics/inventory_view/components/toolbars/toolbar_wrapper'; import { SnapshotMetricType } from '../../types'; diff --git a/x-pack/plugins/infra/common/snapshot_metric_i18n.ts b/x-pack/plugins/infra/common/snapshot_metric_i18n.ts new file mode 100644 index 0000000000000..412c60fd9a1a7 --- /dev/null +++ b/x-pack/plugins/infra/common/snapshot_metric_i18n.ts @@ -0,0 +1,208 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; +import { SnapshotMetricType } from './inventory_models/types'; + +const Translations = { + CPUUsage: i18n.translate('xpack.infra.waffle.metricOptions.cpuUsageText', { + defaultMessage: 'CPU usage', + }), + + MemoryUsage: i18n.translate('xpack.infra.waffle.metricOptions.memoryUsageText', { + defaultMessage: 'Memory usage', + }), + + InboundTraffic: i18n.translate('xpack.infra.waffle.metricOptions.inboundTrafficText', { + defaultMessage: 'Inbound traffic', + }), + + OutboundTraffic: i18n.translate('xpack.infra.waffle.metricOptions.outboundTrafficText', { + defaultMessage: 'Outbound traffic', + }), + + LogRate: i18n.translate('xpack.infra.waffle.metricOptions.hostLogRateText', { + defaultMessage: 'Log rate', + }), + + Load: i18n.translate('xpack.infra.waffle.metricOptions.loadText', { + defaultMessage: 'Load', + }), + + Count: i18n.translate('xpack.infra.waffle.metricOptions.countText', { + defaultMessage: 'Count', + }), + DiskIOReadBytes: i18n.translate('xpack.infra.waffle.metricOptions.diskIOReadBytes', { + defaultMessage: 'Disk Reads', + }), + DiskIOWriteBytes: i18n.translate('xpack.infra.waffle.metricOptions.diskIOWriteBytes', { + defaultMessage: 'Disk Writes', + }), + s3BucketSize: i18n.translate('xpack.infra.waffle.metricOptions.s3BucketSize', { + defaultMessage: 'Bucket Size', + }), + s3TotalRequests: i18n.translate('xpack.infra.waffle.metricOptions.s3TotalRequests', { + defaultMessage: 'Total Requests', + }), + s3NumberOfObjects: i18n.translate('xpack.infra.waffle.metricOptions.s3NumberOfObjects', { + defaultMessage: 'Number of Objects', + }), + s3DownloadBytes: i18n.translate('xpack.infra.waffle.metricOptions.s3DownloadBytes', { + defaultMessage: 'Downloads (Bytes)', + }), + s3UploadBytes: i18n.translate('xpack.infra.waffle.metricOptions.s3UploadBytes', { + defaultMessage: 'Uploads (Bytes)', + }), + rdsConnections: i18n.translate('xpack.infra.waffle.metricOptions.rdsConnections', { + defaultMessage: 'Connections', + }), + rdsQueriesExecuted: i18n.translate('xpack.infra.waffle.metricOptions.rdsQueriesExecuted', { + defaultMessage: 'Queries Executed', + }), + rdsActiveTransactions: i18n.translate('xpack.infra.waffle.metricOptions.rdsActiveTransactions', { + defaultMessage: 'Active Transactions', + }), + rdsLatency: i18n.translate('xpack.infra.waffle.metricOptions.rdsLatency', { + defaultMessage: 'Latency', + }), + sqsMessagesVisible: i18n.translate('xpack.infra.waffle.metricOptions.sqsMessagesVisible', { + defaultMessage: 'Messages Available', + }), + sqsMessagesDelayed: i18n.translate('xpack.infra.waffle.metricOptions.sqsMessagesDelayed', { + defaultMessage: 'Messages Delayed', + }), + sqsMessagesSent: i18n.translate('xpack.infra.waffle.metricOptions.sqsMessagesSent', { + defaultMessage: 'Messages Added', + }), + sqsMessagesEmpty: i18n.translate('xpack.infra.waffle.metricOptions.sqsMessagesEmpty', { + defaultMessage: 'Messages Returned Empty', + }), + sqsOldestMessage: i18n.translate('xpack.infra.waffle.metricOptions.sqsOldestMessage', { + defaultMessage: 'Oldest Message', + }), +}; + +export const toMetricOpt = ( + metric: SnapshotMetricType +): { text: string; value: SnapshotMetricType } | undefined => { + switch (metric) { + case 'cpu': + return { + text: Translations.CPUUsage, + value: 'cpu', + }; + case 'memory': + return { + text: Translations.MemoryUsage, + value: 'memory', + }; + case 'rx': + return { + text: Translations.InboundTraffic, + value: 'rx', + }; + case 'tx': + return { + text: Translations.OutboundTraffic, + value: 'tx', + }; + case 'logRate': + return { + text: Translations.LogRate, + value: 'logRate', + }; + case 'load': + return { + text: Translations.Load, + value: 'load', + }; + + case 'count': + return { + text: Translations.Count, + value: 'count', + }; + case 'diskIOReadBytes': + return { + text: Translations.DiskIOReadBytes, + value: 'diskIOReadBytes', + }; + case 'diskIOWriteBytes': + return { + text: Translations.DiskIOWriteBytes, + value: 'diskIOWriteBytes', + }; + case 's3BucketSize': + return { + text: Translations.s3BucketSize, + value: 's3BucketSize', + }; + case 's3TotalRequests': + return { + text: Translations.s3TotalRequests, + value: 's3TotalRequests', + }; + case 's3NumberOfObjects': + return { + text: Translations.s3NumberOfObjects, + value: 's3NumberOfObjects', + }; + case 's3DownloadBytes': + return { + text: Translations.s3DownloadBytes, + value: 's3DownloadBytes', + }; + case 's3UploadBytes': + return { + text: Translations.s3UploadBytes, + value: 's3UploadBytes', + }; + case 'rdsConnections': + return { + text: Translations.rdsConnections, + value: 'rdsConnections', + }; + case 'rdsQueriesExecuted': + return { + text: Translations.rdsQueriesExecuted, + value: 'rdsQueriesExecuted', + }; + case 'rdsActiveTransactions': + return { + text: Translations.rdsActiveTransactions, + value: 'rdsActiveTransactions', + }; + case 'rdsLatency': + return { + text: Translations.rdsLatency, + value: 'rdsLatency', + }; + case 'sqsMessagesVisible': + return { + text: Translations.sqsMessagesVisible, + value: 'sqsMessagesVisible', + }; + case 'sqsMessagesDelayed': + return { + text: Translations.sqsMessagesDelayed, + value: 'sqsMessagesDelayed', + }; + case 'sqsMessagesSent': + return { + text: Translations.sqsMessagesSent, + value: 'sqsMessagesSent', + }; + case 'sqsMessagesEmpty': + return { + text: Translations.sqsMessagesEmpty, + value: 'sqsMessagesEmpty', + }; + case 'sqsOldestMessage': + return { + text: Translations.sqsOldestMessage, + value: 'sqsOldestMessage', + }; + } +}; diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx index 7df52ad56aef6..d0b4045949d3e 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/expression.tsx @@ -16,9 +16,13 @@ import { EuiFormRow, EuiButtonEmpty, EuiFieldSearch, + EuiCheckbox, + EuiToolTip, + EuiIcon, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import { toMetricOpt } from '../../../../common/snapshot_metric_i18n'; import { AlertPreview } from '../../common'; import { METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID } from '../../../../common/alerting/metrics'; import { @@ -38,7 +42,6 @@ import { AlertsContextValue } from '../../../../../triggers_actions_ui/public/ap // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { MetricsExplorerKueryBar } from '../../../pages/metrics/metrics_explorer/components/kuery_bar'; import { useSourceViaHttp } from '../../../containers/source/use_source_via_http'; -import { toMetricOpt } from '../../../pages/metrics/inventory_view/components/toolbars/toolbar_wrapper'; import { sqsMetricTypes } from '../../../../common/inventory_models/aws_sqs/toolbar_items'; import { ec2MetricTypes } from '../../../../common/inventory_models/aws_ec2/toolbar_items'; import { s3MetricTypes } from '../../../../common/inventory_models/aws_s3/toolbar_items'; @@ -73,6 +76,7 @@ interface Props { filterQuery?: string; filterQueryText?: string; sourceId?: string; + alertOnNoData?: boolean; }; alertInterval: string; alertsContext: AlertsContextValue; @@ -306,6 +310,28 @@ export const Expressions: React.FC = (props) => {
+ + + {i18n.translate('xpack.infra.metrics.alertFlyout.alertOnNoData', { + defaultMessage: "Alert me if there's no data", + })}{' '} + + + + + } + checked={alertParams.alertOnNoData} + onChange={(e) => setAlertParams('alertOnNoData', e.target.checked)} + /> + { ); }; -const ToolbarTranslations = { - CPUUsage: i18n.translate('xpack.infra.waffle.metricOptions.cpuUsageText', { - defaultMessage: 'CPU usage', - }), - - MemoryUsage: i18n.translate('xpack.infra.waffle.metricOptions.memoryUsageText', { - defaultMessage: 'Memory usage', - }), - - InboundTraffic: i18n.translate('xpack.infra.waffle.metricOptions.inboundTrafficText', { - defaultMessage: 'Inbound traffic', - }), - - OutboundTraffic: i18n.translate('xpack.infra.waffle.metricOptions.outboundTrafficText', { - defaultMessage: 'Outbound traffic', - }), - - LogRate: i18n.translate('xpack.infra.waffle.metricOptions.hostLogRateText', { - defaultMessage: 'Log rate', - }), - - Load: i18n.translate('xpack.infra.waffle.metricOptions.loadText', { - defaultMessage: 'Load', - }), - - Count: i18n.translate('xpack.infra.waffle.metricOptions.countText', { - defaultMessage: 'Count', - }), - DiskIOReadBytes: i18n.translate('xpack.infra.waffle.metricOptions.diskIOReadBytes', { - defaultMessage: 'Disk Reads', - }), - DiskIOWriteBytes: i18n.translate('xpack.infra.waffle.metricOptions.diskIOWriteBytes', { - defaultMessage: 'Disk Writes', - }), - s3BucketSize: i18n.translate('xpack.infra.waffle.metricOptions.s3BucketSize', { - defaultMessage: 'Bucket Size', - }), - s3TotalRequests: i18n.translate('xpack.infra.waffle.metricOptions.s3TotalRequests', { - defaultMessage: 'Total Requests', - }), - s3NumberOfObjects: i18n.translate('xpack.infra.waffle.metricOptions.s3NumberOfObjects', { - defaultMessage: 'Number of Objects', - }), - s3DownloadBytes: i18n.translate('xpack.infra.waffle.metricOptions.s3DownloadBytes', { - defaultMessage: 'Downloads (Bytes)', - }), - s3UploadBytes: i18n.translate('xpack.infra.waffle.metricOptions.s3UploadBytes', { - defaultMessage: 'Uploads (Bytes)', - }), - rdsConnections: i18n.translate('xpack.infra.waffle.metricOptions.rdsConnections', { - defaultMessage: 'Connections', - }), - rdsQueriesExecuted: i18n.translate('xpack.infra.waffle.metricOptions.rdsQueriesExecuted', { - defaultMessage: 'Queries Executed', - }), - rdsActiveTransactions: i18n.translate('xpack.infra.waffle.metricOptions.rdsActiveTransactions', { - defaultMessage: 'Active Transactions', - }), - rdsLatency: i18n.translate('xpack.infra.waffle.metricOptions.rdsLatency', { - defaultMessage: 'Latency', - }), - sqsMessagesVisible: i18n.translate('xpack.infra.waffle.metricOptions.sqsMessagesVisible', { - defaultMessage: 'Messages Available', - }), - sqsMessagesDelayed: i18n.translate('xpack.infra.waffle.metricOptions.sqsMessagesDelayed', { - defaultMessage: 'Messages Delayed', - }), - sqsMessagesSent: i18n.translate('xpack.infra.waffle.metricOptions.sqsMessagesSent', { - defaultMessage: 'Messages Added', - }), - sqsMessagesEmpty: i18n.translate('xpack.infra.waffle.metricOptions.sqsMessagesEmpty', { - defaultMessage: 'Messages Returned Empty', - }), - sqsOldestMessage: i18n.translate('xpack.infra.waffle.metricOptions.sqsOldestMessage', { - defaultMessage: 'Oldest Message', - }), -}; - export const toGroupByOpt = (field: string) => ({ text: fieldToName(field), field, }); - -export const toMetricOpt = ( - metric: SnapshotMetricType -): { text: string; value: SnapshotMetricType } | undefined => { - switch (metric) { - case 'cpu': - return { - text: ToolbarTranslations.CPUUsage, - value: 'cpu', - }; - case 'memory': - return { - text: ToolbarTranslations.MemoryUsage, - value: 'memory', - }; - case 'rx': - return { - text: ToolbarTranslations.InboundTraffic, - value: 'rx', - }; - case 'tx': - return { - text: ToolbarTranslations.OutboundTraffic, - value: 'tx', - }; - case 'logRate': - return { - text: ToolbarTranslations.LogRate, - value: 'logRate', - }; - case 'load': - return { - text: ToolbarTranslations.Load, - value: 'load', - }; - - case 'count': - return { - text: ToolbarTranslations.Count, - value: 'count', - }; - case 'diskIOReadBytes': - return { - text: ToolbarTranslations.DiskIOReadBytes, - value: 'diskIOReadBytes', - }; - case 'diskIOWriteBytes': - return { - text: ToolbarTranslations.DiskIOWriteBytes, - value: 'diskIOWriteBytes', - }; - case 's3BucketSize': - return { - text: ToolbarTranslations.s3BucketSize, - value: 's3BucketSize', - }; - case 's3TotalRequests': - return { - text: ToolbarTranslations.s3TotalRequests, - value: 's3TotalRequests', - }; - case 's3NumberOfObjects': - return { - text: ToolbarTranslations.s3NumberOfObjects, - value: 's3NumberOfObjects', - }; - case 's3DownloadBytes': - return { - text: ToolbarTranslations.s3DownloadBytes, - value: 's3DownloadBytes', - }; - case 's3UploadBytes': - return { - text: ToolbarTranslations.s3UploadBytes, - value: 's3UploadBytes', - }; - case 'rdsConnections': - return { - text: ToolbarTranslations.rdsConnections, - value: 'rdsConnections', - }; - case 'rdsQueriesExecuted': - return { - text: ToolbarTranslations.rdsQueriesExecuted, - value: 'rdsQueriesExecuted', - }; - case 'rdsActiveTransactions': - return { - text: ToolbarTranslations.rdsActiveTransactions, - value: 'rdsActiveTransactions', - }; - case 'rdsLatency': - return { - text: ToolbarTranslations.rdsLatency, - value: 'rdsLatency', - }; - case 'sqsMessagesVisible': - return { - text: ToolbarTranslations.sqsMessagesVisible, - value: 'sqsMessagesVisible', - }; - case 'sqsMessagesDelayed': - return { - text: ToolbarTranslations.sqsMessagesDelayed, - value: 'sqsMessagesDelayed', - }; - case 'sqsMessagesSent': - return { - text: ToolbarTranslations.sqsMessagesSent, - value: 'sqsMessagesSent', - }; - case 'sqsMessagesEmpty': - return { - text: ToolbarTranslations.sqsMessagesEmpty, - value: 'sqsMessagesEmpty', - }; - case 'sqsOldestMessage': - return { - text: ToolbarTranslations.sqsOldestMessage, - value: 'sqsOldestMessage', - }; - } -}; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/messages.ts b/x-pack/plugins/infra/server/lib/alerting/common/messages.ts similarity index 100% rename from x-pack/plugins/infra/server/lib/alerting/metric_threshold/messages.ts rename to x-pack/plugins/infra/server/lib/alerting/common/messages.ts diff --git a/x-pack/plugins/infra/server/lib/alerting/common/types.ts b/x-pack/plugins/infra/server/lib/alerting/common/types.ts new file mode 100644 index 0000000000000..8bb087781dc7a --- /dev/null +++ b/x-pack/plugins/infra/server/lib/alerting/common/types.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export enum Comparator { + GT = '>', + LT = '<', + GT_OR_EQ = '>=', + LT_OR_EQ = '<=', + BETWEEN = 'between', + OUTSIDE_RANGE = 'outside', +} + +export enum Aggregators { + COUNT = 'count', + AVERAGE = 'avg', + SUM = 'sum', + MIN = 'min', + MAX = 'max', + RATE = 'rate', + CARDINALITY = 'cardinality', + P95 = 'p95', + P99 = 'p99', +} + +export enum AlertStates { + OK, + ALERT, + NO_DATA, + ERROR, +} diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts index c55f50e229b69..ddc51d19021e7 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts @@ -17,13 +17,12 @@ import { InventoryItemType, SnapshotMetricType } from '../../../../common/invent import { InfraTimerangeInput } from '../../../../common/http_api/snapshot_api'; import { InfraSourceConfiguration } from '../../sources'; -interface ConditionResult { +type ConditionResult = InventoryMetricConditions & { shouldFire: boolean | boolean[]; - currentValue?: number | null; - metric: string; + currentValue: number; isNoData: boolean; isError: boolean; -} +}; export const evaluateCondition = async ( condition: InventoryMetricConditions, @@ -59,19 +58,25 @@ export const evaluateCondition = async ( const comparisonFunction = comparatorMap[comparator]; return mapValues(currentValues, (value) => ({ + ...condition, shouldFire: value !== undefined && value !== null && (Array.isArray(value) ? value.map((v) => comparisonFunction(Number(v), threshold)) : comparisonFunction(value, threshold)), - metric, isNoData: value === null, isError: value === undefined, - ...(!Array.isArray(value) ? { currentValue: value } : {}), + currentValue: getCurrentValue(value), })); }; +const getCurrentValue: (value: any) => number = (value) => { + if (Array.isArray(value)) return getCurrentValue(last(value)); + if (value !== null) return Number(value); + return NaN; +}; + const getData = async ( callCluster: AlertServices['callCluster'], nodeType: InventoryItemType, diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts index 9029b51568212..445911878111f 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts @@ -5,12 +5,20 @@ */ import { first, get } from 'lodash'; import { i18n } from '@kbn/i18n'; +import moment from 'moment'; +import { toMetricOpt } from '../../../../common/snapshot_metric_i18n'; import { AlertStates, InventoryMetricConditions } from './types'; import { AlertExecutorOptions } from '../../../../../alerts/server'; import { InventoryItemType, SnapshotMetricType } from '../../../../common/inventory_models/types'; import { InfraBackendLibs } from '../../infra_types'; import { METRIC_FORMATTERS } from '../../../../common/formatters/snapshot_metric_formats'; import { createFormatter } from '../../../../common/formatters'; +import { + buildErrorAlertReason, + buildFiredAlertReason, + buildNoDataAlertReason, + stateToAlertMessage, +} from '../common/messages'; import { evaluateCondition } from './evaluate_condition'; interface InventoryMetricThresholdParams { @@ -18,13 +26,20 @@ interface InventoryMetricThresholdParams { filterQuery: string | undefined; nodeType: InventoryItemType; sourceId?: string; + alertOnNoData?: boolean; } export const createInventoryMetricThresholdExecutor = ( libs: InfraBackendLibs, alertId: string ) => async ({ services, params }: AlertExecutorOptions) => { - const { criteria, filterQuery, sourceId, nodeType } = params as InventoryMetricThresholdParams; + const { + criteria, + filterQuery, + sourceId, + nodeType, + alertOnNoData, + } = params as InventoryMetricThresholdParams; const source = await libs.sources.getSourceConfiguration( services.savedObjectsClient, @@ -48,26 +63,56 @@ export const createInventoryMetricThresholdExecutor = ( const isNoData = results.some((result) => result[item].isNoData); const isError = results.some((result) => result[item].isError); - if (shouldAlertFire) { + const nextState = isError + ? AlertStates.ERROR + : isNoData + ? AlertStates.NO_DATA + : shouldAlertFire + ? AlertStates.ALERT + : AlertStates.OK; + + let reason; + if (nextState === AlertStates.ALERT) { + reason = results + .map((result) => { + if (!result[item]) return ''; + const resultWithVerboseMetricName = { + ...result[item], + metric: toMetricOpt(result[item].metric)?.text || result[item].metric, + }; + return buildFiredAlertReason(resultWithVerboseMetricName); + }) + .join('\n'); + } + if (alertOnNoData) { + if (nextState === AlertStates.NO_DATA) { + reason = results + .filter((result) => result[item].isNoData) + .map((result) => buildNoDataAlertReason(result[item])) + .join('\n'); + } else if (nextState === AlertStates.ERROR) { + reason = results + .filter((result) => result[item].isError) + .map((result) => buildErrorAlertReason(result[item].metric)) + .join('\n'); + } + } + if (reason) { alertInstance.scheduleActions(FIRED_ACTIONS.id, { group: item, - item, - valueOf: mapToConditionsLookup(results, (result) => + alertState: stateToAlertMessage[nextState], + reason, + timestamp: moment().toISOString(), + value: mapToConditionsLookup(results, (result) => formatMetric(result[item].metric, result[item].currentValue) ), - thresholdOf: mapToConditionsLookup(criteria, (c) => c.threshold), - metricOf: mapToConditionsLookup(criteria, (c) => c.metric), + threshold: mapToConditionsLookup(criteria, (c) => c.threshold), + metric: mapToConditionsLookup(criteria, (c) => c.metric), }); } alertInstance.replaceState({ - alertState: isError - ? AlertStates.ERROR - : isNoData - ? AlertStates.NO_DATA - : shouldAlertFire - ? AlertStates.ALERT - : AlertStates.OK, + alertState: nextState, }); } }; diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts index e23dfed448c57..d7c4165d5a870 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts @@ -35,6 +35,7 @@ export const registerMetricInventoryThresholdAlertType = (libs: InfraBackendLibs schema.string({ validate: validateIsStringElasticsearchJSONFilter }) ), sourceId: schema.string(), + alertOnNoData: schema.maybe(schema.boolean()), }, { unknowns: 'allow' } ), diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/types.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/types.ts index ec1caad30a4d7..86c77e6d7459a 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/types.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/types.ts @@ -5,24 +5,11 @@ */ import { Unit } from '@elastic/datemath'; import { SnapshotMetricType } from '../../../../common/inventory_models/types'; +import { Comparator, AlertStates } from '../common/types'; -export const METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID = 'metrics.alert.inventory.threshold'; - -export enum Comparator { - GT = '>', - LT = '<', - GT_OR_EQ = '>=', - LT_OR_EQ = '<=', - BETWEEN = 'between', - OUTSIDE_RANGE = 'outside', -} +export { Comparator, AlertStates }; -export enum AlertStates { - OK, - ALERT, - NO_DATA, - ERROR, -} +export const METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID = 'metrics.alert.inventory.threshold'; export interface InventoryMetricConditions { metric: SnapshotMetricType; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts index 49b191c4e85c9..66318f3da01c6 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts @@ -15,8 +15,8 @@ import { InfraDatabaseSearchResponse } from '../../../adapters/framework/adapter import { createAfterKeyHandler } from '../../../../utils/create_afterkey_handler'; import { AlertServices, AlertExecutorOptions } from '../../../../../../alerts/server'; import { getAllCompositeData } from '../../../../utils/get_all_composite_data'; +import { DOCUMENT_COUNT_I18N } from '../../common/messages'; import { MetricExpressionParams, Comparator, Aggregators } from '../types'; -import { DOCUMENT_COUNT_I18N } from '../messages'; import { getElasticsearchMetricQuery } from './metric_query'; interface Aggregation { 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 d80347595a3ca..5782277e4f469 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 @@ -8,14 +8,14 @@ import { i18n } from '@kbn/i18n'; import moment from 'moment'; import { AlertExecutorOptions } from '../../../../../alerts/server'; import { InfraBackendLibs } from '../../infra_types'; -import { AlertStates } from './types'; -import { evaluateAlert } from './lib/evaluate_alert'; import { buildErrorAlertReason, buildFiredAlertReason, buildNoDataAlertReason, stateToAlertMessage, -} from './messages'; +} from '../common/messages'; +import { AlertStates } from './types'; +import { evaluateAlert } from './lib/evaluate_alert'; export const createMetricThresholdExecutor = (libs: InfraBackendLibs, alertId: string) => async function (options: AlertExecutorOptions) { diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts index 48391925d9910..62ab94b7d8c83 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/types.ts @@ -3,18 +3,11 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - import { Unit } from '@elastic/datemath'; -export const METRIC_THRESHOLD_ALERT_TYPE_ID = 'metrics.alert.threshold'; +import { Comparator, AlertStates } from '../common/types'; -export enum Comparator { - GT = '>', - LT = '<', - GT_OR_EQ = '>=', - LT_OR_EQ = '<=', - BETWEEN = 'between', - OUTSIDE_RANGE = 'outside', -} +export { Comparator, AlertStates }; +export const METRIC_THRESHOLD_ALERT_TYPE_ID = 'metrics.alert.threshold'; export enum Aggregators { COUNT = 'count', @@ -28,13 +21,6 @@ export enum Aggregators { P99 = 'p99', } -export enum AlertStates { - OK, - ALERT, - NO_DATA, - ERROR, -} - interface BaseMetricExpressionParams { timeSize: number; timeUnit: Unit; From 175bdb588fa3181e488d111a7d4954c3913a3d87 Mon Sep 17 00:00:00 2001 From: Tre Date: Tue, 30 Jun 2020 17:00:45 -0600 Subject: [PATCH 11/72] [QA] [Code Coverage] Integrate with Team Assignment Pipeline and Add Research and Development Indexes and Cluster (#69348) Co-authored-by: Elastic Machine --- .ci/Jenkinsfile_coverage | 2 +- .github/CODEOWNERS | 1 + .../ingest_coverage/__tests__/ingest.test.js | 37 --------- .../__tests__/ingest_helpers.test.js | 75 +++++++++++++++++ .../__tests__/transforms.test.js | 38 ++++++--- .../ingest_coverage/constants.js | 13 +++ .../code_coverage/ingest_coverage/ingest.js | 81 ++++++++++++++----- .../ingest_coverage/ingest_helpers.js | 28 +++++++ .../integration_tests/ingest_coverage.test.js | 47 +++++------ .../coverage-summary-NO-total.json | 0 .../code_coverage/ingest_coverage/process.js | 6 +- .../shell_scripts/ingest_coverage.sh | 10 ++- vars/kibanaCoverage.groovy | 12 +-- 13 files changed, 244 insertions(+), 106 deletions(-) delete mode 100644 src/dev/code_coverage/ingest_coverage/__tests__/ingest.test.js create mode 100644 src/dev/code_coverage/ingest_coverage/__tests__/ingest_helpers.test.js delete mode 100644 src/dev/code_coverage/ingest_coverage/integration_tests/mocks/jest-combined/coverage-summary-NO-total.json diff --git a/.ci/Jenkinsfile_coverage b/.ci/Jenkinsfile_coverage index 650ef94e1d3da..d6600256bab7b 100644 --- a/.ci/Jenkinsfile_coverage +++ b/.ci/Jenkinsfile_coverage @@ -28,7 +28,7 @@ def handleIngestion(timestamp) { kibanaCoverage.collectVcsInfo("### Collect VCS Info") kibanaCoverage.generateReports("### Merge coverage reports") kibanaCoverage.uploadCombinedReports() - kibanaCoverage.ingest(timestamp, '### Injest && Upload') + kibanaCoverage.ingest(env.JOB_NAME, BUILD_NUMBER, BUILD_URL, timestamp, '### Ingest && Upload') kibanaCoverage.uploadCoverageStaticSite(timestamp) } diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 47f9942162f75..a94180e60e05e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -132,6 +132,7 @@ # Quality Assurance /src/dev/code_coverage @elastic/kibana-qa +/vars/*Coverage.groovy @elastic/kibana-qa /test/functional/services/common @elastic/kibana-qa /test/functional/services/lib @elastic/kibana-qa /test/functional/services/remote @elastic/kibana-qa diff --git a/src/dev/code_coverage/ingest_coverage/__tests__/ingest.test.js b/src/dev/code_coverage/ingest_coverage/__tests__/ingest.test.js deleted file mode 100644 index ad5b4da0873b9..0000000000000 --- a/src/dev/code_coverage/ingest_coverage/__tests__/ingest.test.js +++ /dev/null @@ -1,37 +0,0 @@ -/* - * 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 { maybeTeamAssign } from '../ingest'; -import { COVERAGE_INDEX, TOTALS_INDEX } from '../constants'; - -describe(`Ingest fns`, () => { - describe(`maybeTeamAssign fn`, () => { - describe(`against the coverage index`, () => { - it(`should have the pipeline prop`, () => { - expect(maybeTeamAssign(COVERAGE_INDEX, {})).to.have.property('pipeline'); - }); - }); - describe(`against the totals index`, () => { - it(`should not have the pipeline prop`, () => { - expect(maybeTeamAssign(TOTALS_INDEX, {})).not.to.have.property('pipeline'); - }); - }); - }); -}); diff --git a/src/dev/code_coverage/ingest_coverage/__tests__/ingest_helpers.test.js b/src/dev/code_coverage/ingest_coverage/__tests__/ingest_helpers.test.js new file mode 100644 index 0000000000000..7ca7279e0d64c --- /dev/null +++ b/src/dev/code_coverage/ingest_coverage/__tests__/ingest_helpers.test.js @@ -0,0 +1,75 @@ +/* + * 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 { maybeTeamAssign, whichIndex } from '../ingest_helpers'; +import { + TOTALS_INDEX, + RESEARCH_TOTALS_INDEX, + RESEARCH_COVERAGE_INDEX, + // COVERAGE_INDEX, +} from '../constants'; + +describe(`Ingest Helper fns`, () => { + describe(`whichIndex`, () => { + describe(`against the research job`, () => { + const whichIndexAgainstResearchJob = whichIndex(true); + describe(`against the totals index`, () => { + const isTotal = true; + it(`should return the Research Totals Index`, () => { + const actual = whichIndexAgainstResearchJob(isTotal); + expect(actual).to.be(RESEARCH_TOTALS_INDEX); + }); + }); + describe(`against the coverage index`, () => { + it(`should return the Research Totals Index`, () => { + const isTotal = false; + const actual = whichIndexAgainstResearchJob(isTotal); + expect(actual).to.be(RESEARCH_COVERAGE_INDEX); + }); + }); + }); + describe(`against the "prod" job`, () => { + const whichIndexAgainstProdJob = whichIndex(false); + describe(`against the totals index`, () => { + const isTotal = true; + it(`should return the "Prod" Totals Index`, () => { + const actual = whichIndexAgainstProdJob(isTotal); + expect(actual).to.be(TOTALS_INDEX); + }); + }); + }); + }); + describe(`maybeTeamAssign`, () => { + describe(`against a coverage index`, () => { + it(`should have the pipeline prop`, () => { + const actual = maybeTeamAssign(true, { a: 'blah' }); + expect(actual).to.have.property('pipeline'); + }); + }); + describe(`against a totals index`, () => { + describe(`for "prod"`, () => { + it(`should not have the pipeline prop`, () => { + const actual = maybeTeamAssign(false, { b: 'blah' }); + expect(actual).not.to.have.property('pipeline'); + }); + }); + }); + }); +}); diff --git a/src/dev/code_coverage/ingest_coverage/__tests__/transforms.test.js b/src/dev/code_coverage/ingest_coverage/__tests__/transforms.test.js index 8c982b792ed3b..2fd1d5cbe8d48 100644 --- a/src/dev/code_coverage/ingest_coverage/__tests__/transforms.test.js +++ b/src/dev/code_coverage/ingest_coverage/__tests__/transforms.test.js @@ -32,17 +32,33 @@ describe(`Transform fn`, () => { }); }); describe(`coveredFilePath`, () => { - it(`should remove the jenkins workspace path`, () => { - const obj = { - staticSiteUrl: - '/var/lib/jenkins/workspace/elastic+kibana+code-coverage/kibana/x-pack/plugins/reporting/server/browsers/extract/unzip.js', - COVERAGE_INGESTION_KIBANA_ROOT: - '/var/lib/jenkins/workspace/elastic+kibana+code-coverage/kibana', - }; - expect(coveredFilePath(obj)).to.have.property( - 'coveredFilePath', - 'x-pack/plugins/reporting/server/browsers/extract/unzip.js' - ); + describe(`in the code-coverage job`, () => { + it(`should remove the jenkins workspace path`, () => { + const obj = { + staticSiteUrl: + '/var/lib/jenkins/workspace/elastic+kibana+code-coverage/kibana/x-pack/plugins/reporting/server/browsers/extract/unzip.js', + COVERAGE_INGESTION_KIBANA_ROOT: + '/var/lib/jenkins/workspace/elastic+kibana+code-coverage/kibana', + }; + expect(coveredFilePath(obj)).to.have.property( + 'coveredFilePath', + 'x-pack/plugins/reporting/server/browsers/extract/unzip.js' + ); + }); + }); + describe(`in the qa research job`, () => { + it(`should remove the jenkins workspace path`, () => { + const obj = { + staticSiteUrl: + '/var/lib/jenkins/workspace/elastic+kibana+qa-research/kibana/x-pack/plugins/reporting/server/browsers/extract/unzip.js', + COVERAGE_INGESTION_KIBANA_ROOT: + '/var/lib/jenkins/workspace/elastic+kibana+qa-research/kibana', + }; + expect(coveredFilePath(obj)).to.have.property( + 'coveredFilePath', + 'x-pack/plugins/reporting/server/browsers/extract/unzip.js' + ); + }); }); }); describe(`itemizeVcs`, () => { diff --git a/src/dev/code_coverage/ingest_coverage/constants.js b/src/dev/code_coverage/ingest_coverage/constants.js index a7303f0778d1c..ddee7106f4490 100644 --- a/src/dev/code_coverage/ingest_coverage/constants.js +++ b/src/dev/code_coverage/ingest_coverage/constants.js @@ -18,4 +18,17 @@ */ export const COVERAGE_INDEX = process.env.COVERAGE_INDEX || 'kibana_code_coverage'; + export const TOTALS_INDEX = process.env.TOTALS_INDEX || `kibana_total_code_coverage`; + +export const RESEARCH_COVERAGE_INDEX = + process.env.RESEARCH_COVERAGE_INDEX || 'qa_research_code_coverage'; + +export const RESEARCH_TOTALS_INDEX = + process.env.RESEARCH_TOTALS_INDEX || `qa_research_total_code_coverage`; + +export const TEAM_ASSIGNMENT_PIPELINE_NAME = process.env.PIPELINE_NAME || 'team_assignment'; + +export const CODE_COVERAGE_CI_JOB_NAME = 'elastic+kibana+code-coverage'; +export const RESEARCH_CI_JOB_NAME = 'elastic+kibana+qa-research'; +export const CI_JOB_NAME = process.env.COVERAGE_JOB_NAME || RESEARCH_CI_JOB_NAME; diff --git a/src/dev/code_coverage/ingest_coverage/ingest.js b/src/dev/code_coverage/ingest_coverage/ingest.js index d6c55a9a655b8..43f0663ad0359 100644 --- a/src/dev/code_coverage/ingest_coverage/ingest.js +++ b/src/dev/code_coverage/ingest_coverage/ingest.js @@ -19,40 +19,77 @@ const { Client } = require('@elastic/elasticsearch'); import { createFailError } from '@kbn/dev-utils'; -import { COVERAGE_INDEX, TOTALS_INDEX } from './constants'; -import { errMsg, redact } from './ingest_helpers'; -import { noop } from './utils'; +import { RESEARCH_CI_JOB_NAME, TEAM_ASSIGNMENT_PIPELINE_NAME } from './constants'; +import { errMsg, redact, whichIndex } from './ingest_helpers'; +import { pretty, green } from './utils'; import { right, left } from './either'; const node = process.env.ES_HOST || 'http://localhost:9200'; + const client = new Client({ node }); -const pipeline = process.env.PIPELINE_NAME || 'team_assignment'; -const redacted = redact(node); +const redactedEsHostUrl = redact(node); +const parse = JSON.parse.bind(null); +const isResearchJob = process.env.COVERAGE_JOB_NAME === RESEARCH_CI_JOB_NAME ? true : false; export const ingest = (log) => async (body) => { - const index = body.isTotal ? TOTALS_INDEX : COVERAGE_INDEX; - const maybeWithPipeline = maybeTeamAssign(index, body); - const withIndex = { index, body: maybeWithPipeline }; - const dontSend = noop; - - log.verbose(withIndex); - - process.env.NODE_ENV === 'integration_test' - ? left(null) - : right(withIndex).fold(dontSend, async function doSend(finalPayload) { - await send(index, redacted, finalPayload); - }); + const isTotal = !!body.isTotal; + const index = whichIndex(isResearchJob)(isTotal); + const isACoverageIndex = isTotal ? false : true; + + const stringified = pretty(body); + const pipeline = TEAM_ASSIGNMENT_PIPELINE_NAME; + + const finalPayload = isACoverageIndex + ? { index, body: stringified, pipeline } + : { index, body: stringified }; + + const justLog = dontSendButLog(log); + const doSendToIndex = doSend(index); + const doSendRedacted = doSendToIndex(redactedEsHostUrl)(log)(client); + + eitherSendOrNot(finalPayload).fold(justLog, doSendRedacted); }; -async function send(idx, redacted, requestBody) { +function doSend(index) { + return (redactedEsHostUrl) => (log) => (client) => async (payload) => { + const logF = logSend(true)(redactedEsHostUrl)(log); + await send(logF, index, redactedEsHostUrl, client, payload); + }; +} + +function dontSendButLog(log) { + return (payload) => { + logSend(false)(null)(log)(payload); + }; +} + +async function send(logF, idx, redactedEsHostUrl, client, requestBody) { try { await client.index(requestBody); + logF(requestBody); } catch (e) { - throw createFailError(errMsg(idx, redacted, requestBody, e)); + const { body } = requestBody; + const parsed = parse(body); + throw createFailError(errMsg(idx, redactedEsHostUrl, parsed, e)); } } -export function maybeTeamAssign(index, body) { - const payload = index === TOTALS_INDEX ? body : { ...body, pipeline }; - return payload; +const sendMsg = (actuallySent, redactedEsHostUrl, payload) => { + const { index, body } = payload; + return `### ${actuallySent ? 'Sent' : 'Fake Sent'}: +${redactedEsHostUrl ? `\t### ES Host: ${redactedEsHostUrl}` : ''} +\t### Index: ${green(index)} +\t### payload.body: ${body} +${process.env.NODE_ENV === 'integration_test' ? `ingest-pipe=>${payload.pipeline}` : ''} +`; +}; + +function logSend(actuallySent) { + return (redactedEsHostUrl) => (log) => (payload) => { + log.verbose(sendMsg(actuallySent, redactedEsHostUrl, payload)); + }; +} + +function eitherSendOrNot(payload) { + return process.env.NODE_ENV === 'integration_test' ? left(payload) : right(payload); } diff --git a/src/dev/code_coverage/ingest_coverage/ingest_helpers.js b/src/dev/code_coverage/ingest_coverage/ingest_helpers.js index 11e5755bb0282..86bcf03977082 100644 --- a/src/dev/code_coverage/ingest_coverage/ingest_helpers.js +++ b/src/dev/code_coverage/ingest_coverage/ingest_helpers.js @@ -20,6 +20,13 @@ import { always, pretty } from './utils'; import chalk from 'chalk'; import { fromNullable } from './either'; +import { + COVERAGE_INDEX, + RESEARCH_COVERAGE_INDEX, + RESEARCH_TOTALS_INDEX, + TEAM_ASSIGNMENT_PIPELINE_NAME, + TOTALS_INDEX, +} from './constants'; export function errMsg(index, redacted, body, e) { const orig = fromNullable(e.body).fold( @@ -38,6 +45,9 @@ ${orig} ### Troubleshooting Hint: ${red('Perhaps the coverage data was not merged properly?\n')} + +### Error.meta (stringified): +${pretty(e.meta)} `; } @@ -59,3 +69,21 @@ function color(whichColor) { return chalk[whichColor].bgWhiteBright(x); }; } + +export function maybeTeamAssign(isACoverageIndex, body) { + const doAddTeam = isACoverageIndex ? true : false; + const payload = doAddTeam ? { ...body, pipeline: TEAM_ASSIGNMENT_PIPELINE_NAME } : body; + return payload; +} + +export function whichIndex(isResearchJob) { + return (isTotal) => + isTotal ? whichTotalsIndex(isResearchJob) : whichCoverageIndex(isResearchJob); +} +function whichTotalsIndex(isResearchJob) { + return isResearchJob ? RESEARCH_TOTALS_INDEX : TOTALS_INDEX; +} + +function whichCoverageIndex(isResearchJob) { + return isResearchJob ? RESEARCH_COVERAGE_INDEX : COVERAGE_INDEX; +} diff --git a/src/dev/code_coverage/ingest_coverage/integration_tests/ingest_coverage.test.js b/src/dev/code_coverage/ingest_coverage/integration_tests/ingest_coverage.test.js index 013adc8b6b0af..2a65839f85ac3 100644 --- a/src/dev/code_coverage/ingest_coverage/integration_tests/ingest_coverage.test.js +++ b/src/dev/code_coverage/ingest_coverage/integration_tests/ingest_coverage.test.js @@ -47,7 +47,7 @@ describe('Ingesting coverage', () => { describe(`staticSiteUrl`, () => { let actualUrl = ''; - const siteUrlRegex = /staticSiteUrl:\s*(.+,)/; + const siteUrlRegex = /"staticSiteUrl":\s*(.+,)/; beforeAll(async () => { const opts = [...verboseArgs, resolved]; @@ -70,8 +70,8 @@ describe('Ingesting coverage', () => { }); describe(`vcsInfo`, () => { - let vcsInfo; describe(`without a commit msg in the vcs info file`, () => { + let vcsInfo; const args = [ 'scripts/ingest_coverage.js', '--verbose', @@ -93,9 +93,6 @@ describe('Ingesting coverage', () => { }); }); describe(`team assignment`, () => { - let shouldNotHavePipelineOut = ''; - let shouldIndeedHavePipelineOut = ''; - const args = [ 'scripts/ingest_coverage.js', '--verbose', @@ -104,28 +101,26 @@ describe('Ingesting coverage', () => { '--path', ]; - const teamAssignRE = /pipeline:/; - - beforeAll(async () => { - const summaryPath = 'jest-combined/coverage-summary-just-total.json'; - const resolved = resolve(MOCKS_DIR, summaryPath); - const opts = [...args, resolved]; - const { stdout } = await execa(process.execPath, opts, { cwd: ROOT_DIR, env }); - shouldNotHavePipelineOut = stdout; - }); - beforeAll(async () => { - const summaryPath = 'jest-combined/coverage-summary-manual-mix.json'; - const resolved = resolve(MOCKS_DIR, summaryPath); - const opts = [...args, resolved]; - const { stdout } = await execa(process.execPath, opts, { cwd: ROOT_DIR, env }); - shouldIndeedHavePipelineOut = stdout; - }); - - it(`should not occur when going to the totals index`, () => { - expect(teamAssignRE.test(shouldNotHavePipelineOut)).to.not.be.ok(); + it(`should not occur when going to the totals index`, async () => { + const teamAssignRE = /"pipeline":/; + const shouldNotHavePipelineOut = await prokJustTotalOrNot(true, args); + const actual = teamAssignRE.test(shouldNotHavePipelineOut); + expect(actual).to.not.be.ok(); }); - it(`should indeed occur when going to the coverage index`, () => { - expect(teamAssignRE.test(shouldIndeedHavePipelineOut)).to.be.ok(); + it(`should indeed occur when going to the coverage index`, async () => { + const shouldIndeedHavePipelineOut = await prokJustTotalOrNot(false, args); + const onlyForTestingRe = /ingest-pipe=>team_assignment/; + const actual = onlyForTestingRe.test(shouldIndeedHavePipelineOut); + expect(actual).to.be.ok(); }); }); }); +async function prokJustTotalOrNot(isTotal, args) { + const justTotalPath = 'jest-combined/coverage-summary-just-total.json'; + const notJustTotalPath = 'jest-combined/coverage-summary-manual-mix.json'; + + const resolved = resolve(MOCKS_DIR, isTotal ? justTotalPath : notJustTotalPath); + const opts = [...args, resolved]; + const { stdout } = await execa(process.execPath, opts, { cwd: ROOT_DIR, env }); + return stdout; +} diff --git a/src/dev/code_coverage/ingest_coverage/integration_tests/mocks/jest-combined/coverage-summary-NO-total.json b/src/dev/code_coverage/ingest_coverage/integration_tests/mocks/jest-combined/coverage-summary-NO-total.json deleted file mode 100644 index e69de29bb2d1d..0000000000000 diff --git a/src/dev/code_coverage/ingest_coverage/process.js b/src/dev/code_coverage/ingest_coverage/process.js index 6b9c8f09febfe..85a42cfffa6e2 100644 --- a/src/dev/code_coverage/ingest_coverage/process.js +++ b/src/dev/code_coverage/ingest_coverage/process.js @@ -36,13 +36,17 @@ import { import { resolve } from 'path'; import { createReadStream } from 'fs'; import readline from 'readline'; +import * as moment from 'moment'; const ROOT = '../../../..'; const COVERAGE_INGESTION_KIBANA_ROOT = process.env.COVERAGE_INGESTION_KIBANA_ROOT || resolve(__dirname, ROOT); const ms = process.env.DELAY || 0; const staticSiteUrlBase = process.env.STATIC_SITE_URL_BASE || 'https://kibana-coverage.elastic.dev'; -const addPrePopulatedTimeStamp = addTimeStamp(process.env.TIME_STAMP); +const format = 'YYYY-MM-DDTHH:mm:SS'; +// eslint-disable-next-line import/namespace +const formatted = `${moment.utc().format(format)}Z`; +const addPrePopulatedTimeStamp = addTimeStamp(process.env.TIME_STAMP || formatted); const preamble = pipe(statsAndstaticSiteUrl, rootDirAndOrigPath, buildId, addPrePopulatedTimeStamp); const addTestRunnerAndStaticSiteUrl = pipe(testRunner, staticSite(staticSiteUrlBase)); diff --git a/src/dev/code_coverage/shell_scripts/ingest_coverage.sh b/src/dev/code_coverage/shell_scripts/ingest_coverage.sh index 2dae75484d68f..d3cf31fc0f427 100644 --- a/src/dev/code_coverage/shell_scripts/ingest_coverage.sh +++ b/src/dev/code_coverage/shell_scripts/ingest_coverage.sh @@ -3,11 +3,14 @@ echo "### Ingesting Code Coverage" echo "" +COVERAGE_JOB_NAME=$1 +export COVERAGE_JOB_NAME +echo "### debug COVERAGE_JOB_NAME: ${COVERAGE_JOB_NAME}" -BUILD_ID=$1 +BUILD_ID=$2 export BUILD_ID -CI_RUN_URL=$2 +CI_RUN_URL=$3 export CI_RUN_URL echo "### debug CI_RUN_URL: ${CI_RUN_URL}" @@ -17,6 +20,9 @@ export ES_HOST STATIC_SITE_URL_BASE='https://kibana-coverage.elastic.dev' export STATIC_SITE_URL_BASE +DELAY=100 +export DELAY + for x in jest functional; do echo "### Ingesting coverage for ${x}" diff --git a/vars/kibanaCoverage.groovy b/vars/kibanaCoverage.groovy index 66b16566418b5..e511d7a8fc15e 100644 --- a/vars/kibanaCoverage.groovy +++ b/vars/kibanaCoverage.groovy @@ -125,31 +125,31 @@ def uploadCombinedReports() { ) } -def ingestData(buildNum, buildUrl, title) { +def ingestData(jobName, buildNum, buildUrl, title) { kibanaPipeline.bash(""" source src/dev/ci_setup/setup_env.sh yarn kbn bootstrap --prefer-offline # Using existing target/kibana-coverage folder - . src/dev/code_coverage/shell_scripts/ingest_coverage.sh ${buildNum} ${buildUrl} + . src/dev/code_coverage/shell_scripts/ingest_coverage.sh '${jobName}' ${buildNum} '${buildUrl}' """, title) } -def ingestWithVault(buildNum, buildUrl, title) { +def ingestWithVault(jobName, buildNum, buildUrl, title) { def vaultSecret = 'secret/kibana-issues/prod/coverage/elasticsearch' withVaultSecret(secret: vaultSecret, secret_field: 'host', variable_name: 'HOST_FROM_VAULT') { withVaultSecret(secret: vaultSecret, secret_field: 'username', variable_name: 'USER_FROM_VAULT') { withVaultSecret(secret: vaultSecret, secret_field: 'password', variable_name: 'PASS_FROM_VAULT') { - ingestData(buildNum, buildUrl, title) + ingestData(jobName, buildNum, buildUrl, title) } } } } -def ingest(timestamp, title) { +def ingest(jobName, buildNumber, buildUrl, timestamp, title) { withEnv([ "TIME_STAMP=${timestamp}", ]) { - ingestWithVault(BUILD_NUMBER, BUILD_URL, title) + ingestWithVault(jobName, buildNumber, buildUrl, title) } } From f428b2dabeafbc269c713f809bb3d59901040179 Mon Sep 17 00:00:00 2001 From: Tre Date: Tue, 30 Jun 2020 17:07:24 -0600 Subject: [PATCH 12/72] [QA][Code Coverage] Drop catchError and use try / catch instead, (#69198) Co-authored-by: Elastic Machine --- .ci/Jenkinsfile_coverage | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ci/Jenkinsfile_coverage b/.ci/Jenkinsfile_coverage index d6600256bab7b..bd55bd73966ff 100644 --- a/.ci/Jenkinsfile_coverage +++ b/.ci/Jenkinsfile_coverage @@ -34,7 +34,7 @@ def handleIngestion(timestamp) { def handleFail() { def buildStatus = buildUtils.getBuildStatus() - if(params.NOTIFY_ON_FAILURE && buildStatus != 'SUCCESS' && buildStatus != 'ABORTED') { + if(params.NOTIFY_ON_FAILURE && buildStatus != 'SUCCESS' && buildStatus != 'ABORTED' && buildStatus != 'UNSTABLE') { slackNotifications.sendFailedBuild( channel: '#kibana-qa', username: 'Kibana QA' From 9af75fa98ba5c212390b37e199bd5ebfa90692f3 Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Tue, 30 Jun 2020 21:05:14 -0400 Subject: [PATCH 13/72] fix bug to add timeline to case (#70343) --- .../timeline/properties/helpers.test.tsx | 2 +- .../timeline/properties/helpers.tsx | 20 ++++++++++--------- .../timeline/properties/index.test.tsx | 7 ++++++- .../components/timeline/properties/index.tsx | 20 +++++++++---------- 4 files changed, 28 insertions(+), 21 deletions(-) diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx index aec09a95b4b19..887c2e1e825f8 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx @@ -17,7 +17,7 @@ jest.mock('../../../../common/lib/kibana', () => { useKibana: jest.fn().mockReturnValue({ services: { application: { - navigateToApp: jest.fn(), + navigateToApp: () => Promise.resolve(), capabilities: { siem: { crud: true, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx index 21140d668d716..7b5e9c0c4c949 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx @@ -157,19 +157,21 @@ export const NewCase = React.memo( const handleClick = useCallback(() => { onClosePopover(); - dispatch( - setInsertTimeline({ - graphEventId, - timelineId, - timelineSavedObjectId: savedObjectId, - timelineTitle: timelineTitle.length > 0 ? timelineTitle : i18n.UNTITLED_TIMELINE, - }) - ); + dispatch(showTimeline({ id: TimelineId.active, show: false })); navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { path: getCreateCaseUrl(), - }); + }).then(() => + dispatch( + setInsertTimeline({ + graphEventId, + timelineId, + timelineSavedObjectId: savedObjectId, + timelineTitle: timelineTitle.length > 0 ? timelineTitle : i18n.UNTITLED_TIMELINE, + }) + ) + ); }, [ dispatch, graphEventId, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx index cd089d10d5d4c..3a28c26a16c9a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx @@ -25,7 +25,7 @@ import { act } from 'react-dom/test-utils'; jest.mock('../../../../common/components/link_to'); -const mockNavigateToApp = jest.fn(); +const mockNavigateToApp = jest.fn().mockImplementation(() => Promise.resolve()); jest.mock('../../../../common/lib/kibana', () => { const original = jest.requireActual('../../../../common/lib/kibana'); @@ -369,6 +369,11 @@ describe('Properties', () => { ); wrapper.find('[data-test-subj="settings-gear"]').at(0).simulate('click'); wrapper.find('[data-test-subj="attach-timeline-case"]').first().simulate('click'); + + await act(async () => { + await Promise.resolve({}); + }); + expect(mockNavigateToApp).toBeCalledWith('securitySolution:case', { path: '/create' }); expect(mockDispatch).toBeCalledWith( setInsertTimeline({ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx index 40462fa0d09da..b3567151c74b3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx @@ -125,18 +125,18 @@ export const Properties = React.memo( (id: string) => { onCloseCaseModal(); - dispatch( - setInsertTimeline({ - graphEventId, - timelineId, - timelineSavedObjectId: currentTimeline.savedObjectId, - timelineTitle: title.length > 0 ? title : i18n.UNTITLED_TIMELINE, - }) - ); - navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { path: getCaseDetailsUrl({ id }), - }); + }).then(() => + dispatch( + setInsertTimeline({ + graphEventId, + timelineId, + timelineSavedObjectId: currentTimeline.savedObjectId, + timelineTitle: title.length > 0 ? title : i18n.UNTITLED_TIMELINE, + }) + ) + ); }, [currentTimeline, dispatch, graphEventId, navigateToApp, onCloseCaseModal, timelineId, title] ); From c1dc53c6fbeb75688b742470e6f84a3cea9f8138 Mon Sep 17 00:00:00 2001 From: Brian Seeders Date: Tue, 30 Jun 2020 21:29:37 -0400 Subject: [PATCH 14/72] skip flaky suite (#70386) --- .../overview/monitor_list/__tests__/monitor_list.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/monitor_list.test.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/monitor_list.test.tsx index 29e24a83cfa15..7d09f4161fcac 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/monitor_list.test.tsx +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/monitor_list.test.tsx @@ -14,7 +14,8 @@ import { MonitorListComponent, noItemsMessage } from '../monitor_list'; import { renderWithRouter, shallowWithRouter } from '../../../../lib'; import * as redux from 'react-redux'; -describe('MonitorList component', () => { +// Failing: See https://github.com/elastic/kibana/issues/70386 +describe.skip('MonitorList component', () => { let result: MonitorSummaryResult; let localStorageMock: any; From e1665e8b27987997896f38c2b734cf3eaf1b2332 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Wed, 1 Jul 2020 09:57:23 +0200 Subject: [PATCH 15/72] [Lens] Multiple y axes (#69911) --- .../editor_frame/config_panel/layer_panel.tsx | 2 +- .../__snapshots__/to_expression.test.ts.snap | 1 + .../__snapshots__/xy_expression.test.tsx.snap | 383 ++++++++++++++++-- .../axes_configuration.test.ts | 295 ++++++++++++++ .../xy_visualization/axes_configuration.ts | 106 +++++ .../lens/public/xy_visualization/index.ts | 4 +- .../public/xy_visualization/to_expression.ts | 15 + .../lens/public/xy_visualization/types.ts | 52 ++- .../xy_visualization/xy_config_panel.tsx | 77 +++- .../xy_visualization/xy_expression.test.tsx | 289 ++++++++----- .../public/xy_visualization/xy_expression.tsx | 73 ++-- .../public/xy_visualization/xy_suggestions.ts | 12 +- .../xy_visualization/xy_visualization.tsx | 18 +- 13 files changed, 1141 insertions(+), 186 deletions(-) create mode 100644 x-pack/plugins/lens/public/xy_visualization/axes_configuration.test.ts create mode 100644 x-pack/plugins/lens/public/xy_visualization/axes_configuration.ts diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index bd501db2b752a..36d5bfd965e26 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -186,7 +186,7 @@ export function LayerPanel( }, ]; - if (activeVisualization.renderDimensionEditor) { + if (activeVisualization.renderDimensionEditor && group.enableDimensionEditor) { tabs.push({ id: 'visualization', name: i18n.translate('xpack.lens.editorFrame.formatStyleLabel', { diff --git a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap index 6b68679bfd4ec..c037aecde558b 100644 --- a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap +++ b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap @@ -38,6 +38,7 @@ Object { "xScaleType": Array [ "linear", ], + "yConfig": Array [], "yScaleType": Array [ "linear", ], diff --git a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap index fc5ed7480dd1f..48c70e0a4a05b 100644 --- a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap +++ b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap @@ -12,6 +12,11 @@ exports[`xy_expression XYChart component it renders area 1`] = ` showLegend={false} showLegendExtra={false} theme={Object {}} + tooltip={ + Object { + "headerFormatter": [Function], + } + } /> + + + + + + + { + const tables: Record = { + first: { + type: 'kibana_datatable', + rows: [ + { + xAccessorId: 1585758120000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + yAccessorId2: 1, + yAccessorId3: 1, + yAccessorId4: 4, + }, + { + xAccessorId: 1585758360000, + splitAccessorId: "Women's Accessories", + yAccessorId: 1, + yAccessorId2: 1, + yAccessorId3: 1, + yAccessorId4: 4, + }, + { + xAccessorId: 1585758360000, + splitAccessorId: "Women's Clothing", + yAccessorId: 1, + yAccessorId2: 1, + yAccessorId3: 1, + yAccessorId4: 4, + }, + { + xAccessorId: 1585759380000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + yAccessorId2: 1, + yAccessorId3: 1, + yAccessorId4: 4, + }, + { + xAccessorId: 1585759380000, + splitAccessorId: "Men's Shoes", + yAccessorId: 1, + yAccessorId2: 1, + yAccessorId3: 1, + yAccessorId4: 4, + }, + { + xAccessorId: 1585759380000, + splitAccessorId: "Women's Clothing", + yAccessorId: 1, + yAccessorId2: 1, + yAccessorId3: 1, + yAccessorId4: 4, + }, + { + xAccessorId: 1585760700000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + yAccessorId2: 1, + yAccessorId3: 1, + yAccessorId4: 4, + }, + { + xAccessorId: 1585760760000, + splitAccessorId: "Men's Clothing", + yAccessorId: 1, + yAccessorId2: 1, + yAccessorId3: 1, + yAccessorId4: 4, + }, + { + xAccessorId: 1585760760000, + splitAccessorId: "Men's Shoes", + yAccessorId: 1, + yAccessorId2: 1, + yAccessorId3: 1, + yAccessorId4: 4, + }, + { + xAccessorId: 1585761120000, + splitAccessorId: "Men's Shoes", + yAccessorId: 1, + yAccessorId2: 1, + yAccessorId3: 1, + yAccessorId4: 4, + }, + ], + columns: [ + { + id: 'xAccessorId', + name: 'order_date per minute', + meta: { + type: 'date_histogram', + indexPatternId: 'indexPatternId', + aggConfigParams: { + field: 'order_date', + timeRange: { from: '2020-04-01T16:14:16.246Z', to: '2020-04-01T17:15:41.263Z' }, + useNormalizedEsInterval: true, + scaleMetricValues: false, + interval: '1m', + drop_partials: false, + min_doc_count: 0, + extended_bounds: {}, + }, + }, + formatHint: { id: 'date', params: { pattern: 'HH:mm' } }, + }, + { + id: 'splitAccessorId', + name: 'Top values of category.keyword', + meta: { + type: 'terms', + indexPatternId: 'indexPatternId', + aggConfigParams: { + field: 'category.keyword', + orderBy: 'yAccessorId', + order: 'desc', + size: 3, + otherBucket: false, + otherBucketLabel: 'Other', + missingBucket: false, + missingBucketLabel: 'Missing', + }, + }, + formatHint: { + id: 'terms', + params: { + id: 'string', + otherBucketLabel: 'Other', + missingBucketLabel: 'Missing', + parsedUrl: { + origin: 'http://localhost:5601', + pathname: '/jiy/app/kibana', + basePath: '/jiy', + }, + }, + }, + }, + { + id: 'yAccessorId', + name: 'Count of records', + meta: { + type: 'count', + indexPatternId: 'indexPatternId', + aggConfigParams: {}, + }, + formatHint: { id: 'number' }, + }, + { + id: 'yAccessorId2', + name: 'Other column', + meta: { + type: 'average', + indexPatternId: 'indexPatternId', + aggConfigParams: {}, + }, + formatHint: { id: 'bytes' }, + }, + { + id: 'yAccessorId3', + name: 'Other column', + meta: { + type: 'average', + indexPatternId: 'indexPatternId', + aggConfigParams: {}, + }, + formatHint: { id: 'currency' }, + }, + { + id: 'yAccessorId4', + name: 'Other column', + meta: { + type: 'average', + indexPatternId: 'indexPatternId', + aggConfigParams: {}, + }, + formatHint: { id: 'currency' }, + }, + ], + }, + }; + + const sampleLayer: LayerArgs = { + layerId: 'first', + seriesType: 'line', + xAccessor: 'c', + accessors: ['yAccessorId'], + splitAccessor: 'd', + columnToLabel: '{"a": "Label A", "b": "Label B", "d": "Label D"}', + xScaleType: 'ordinal', + yScaleType: 'linear', + isHistogram: false, + }; + + it('should map auto series to left axis', () => { + const formatFactory = jest.fn(); + const groups = getAxesConfiguration([sampleLayer], tables, formatFactory, false); + expect(groups.length).toEqual(1); + expect(groups[0].position).toEqual('left'); + expect(groups[0].series[0].accessor).toEqual('yAccessorId'); + expect(groups[0].series[0].layer).toEqual('first'); + }); + + it('should map auto series to right axis if formatters do not match', () => { + const formatFactory = jest.fn(); + const twoSeriesLayer = { ...sampleLayer, accessors: ['yAccessorId', 'yAccessorId2'] }; + const groups = getAxesConfiguration([twoSeriesLayer], tables, formatFactory, false); + expect(groups.length).toEqual(2); + expect(groups[0].position).toEqual('left'); + expect(groups[1].position).toEqual('right'); + expect(groups[0].series[0].accessor).toEqual('yAccessorId'); + expect(groups[1].series[0].accessor).toEqual('yAccessorId2'); + }); + + it('should map auto series to left if left and right are already filled with non-matching series', () => { + const formatFactory = jest.fn(); + const threeSeriesLayer = { + ...sampleLayer, + accessors: ['yAccessorId', 'yAccessorId2', 'yAccessorId3'], + }; + const groups = getAxesConfiguration([threeSeriesLayer], tables, formatFactory, false); + expect(groups.length).toEqual(2); + expect(groups[0].position).toEqual('left'); + expect(groups[1].position).toEqual('right'); + expect(groups[0].series[0].accessor).toEqual('yAccessorId'); + expect(groups[0].series[1].accessor).toEqual('yAccessorId3'); + expect(groups[1].series[0].accessor).toEqual('yAccessorId2'); + }); + + it('should map right series to right axis', () => { + const formatFactory = jest.fn(); + const groups = getAxesConfiguration( + [{ ...sampleLayer, yConfig: [{ forAccessor: 'yAccessorId', axisMode: 'right' }] }], + tables, + formatFactory, + false + ); + expect(groups.length).toEqual(1); + expect(groups[0].position).toEqual('right'); + expect(groups[0].series[0].accessor).toEqual('yAccessorId'); + expect(groups[0].series[0].layer).toEqual('first'); + }); + + it('should map series with matching formatters to same axis', () => { + const formatFactory = jest.fn(); + const groups = getAxesConfiguration( + [ + { + ...sampleLayer, + accessors: ['yAccessorId', 'yAccessorId3', 'yAccessorId4'], + yConfig: [{ forAccessor: 'yAccessorId', axisMode: 'right' }], + }, + ], + tables, + formatFactory, + false + ); + expect(groups.length).toEqual(2); + expect(groups[0].position).toEqual('left'); + expect(groups[0].series[0].accessor).toEqual('yAccessorId3'); + expect(groups[0].series[1].accessor).toEqual('yAccessorId4'); + expect(groups[1].position).toEqual('right'); + expect(groups[1].series[0].accessor).toEqual('yAccessorId'); + expect(formatFactory).toHaveBeenCalledWith({ id: 'number' }); + expect(formatFactory).toHaveBeenCalledWith({ id: 'currency' }); + }); + + it('should create one formatter per series group', () => { + const formatFactory = jest.fn(); + getAxesConfiguration( + [ + { + ...sampleLayer, + accessors: ['yAccessorId', 'yAccessorId3', 'yAccessorId4'], + yConfig: [{ forAccessor: 'yAccessorId', axisMode: 'right' }], + }, + ], + tables, + formatFactory, + false + ); + expect(formatFactory).toHaveBeenCalledTimes(2); + expect(formatFactory).toHaveBeenCalledWith({ id: 'number' }); + expect(formatFactory).toHaveBeenCalledWith({ id: 'currency' }); + }); +}); diff --git a/x-pack/plugins/lens/public/xy_visualization/axes_configuration.ts b/x-pack/plugins/lens/public/xy_visualization/axes_configuration.ts new file mode 100644 index 0000000000000..7d1d3389bb916 --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/axes_configuration.ts @@ -0,0 +1,106 @@ +/* + * 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 { LayerConfig } from './types'; +import { + KibanaDatatable, + SerializedFieldFormat, +} from '../../../../../src/plugins/expressions/public'; +import { IFieldFormat } from '../../../../../src/plugins/data/public'; + +interface FormattedMetric { + layer: string; + accessor: string; + fieldFormat: SerializedFieldFormat; +} + +type GroupsConfiguration = Array<{ + groupId: string; + position: 'left' | 'right' | 'bottom' | 'top'; + formatter: IFieldFormat; + series: Array<{ layer: string; accessor: string }>; +}>; + +export function isFormatterCompatible( + formatter1: SerializedFieldFormat, + formatter2: SerializedFieldFormat +) { + return formatter1.id === formatter2.id; +} + +export function getAxesConfiguration( + layers: LayerConfig[], + tables: Record, + formatFactory: (mapping: SerializedFieldFormat) => IFieldFormat, + shouldRotate: boolean +): GroupsConfiguration { + const series: { auto: FormattedMetric[]; left: FormattedMetric[]; right: FormattedMetric[] } = { + auto: [], + left: [], + right: [], + }; + + layers.forEach((layer) => { + const table = tables[layer.layerId]; + layer.accessors.forEach((accessor) => { + const mode = + layer.yConfig?.find((yAxisConfig) => yAxisConfig.forAccessor === accessor)?.axisMode || + 'auto'; + const formatter: SerializedFieldFormat = table.columns.find( + (column) => column.id === accessor + )?.formatHint || { id: 'number' }; + series[mode].push({ + layer: layer.layerId, + accessor, + fieldFormat: formatter, + }); + }); + }); + + series.auto.forEach((currentSeries) => { + if ( + series.left.length === 0 || + series.left.every((leftSeries) => + isFormatterCompatible(leftSeries.fieldFormat, currentSeries.fieldFormat) + ) + ) { + series.left.push(currentSeries); + } else if ( + series.right.length === 0 || + series.right.every((rightSeries) => + isFormatterCompatible(rightSeries.fieldFormat, currentSeries.fieldFormat) + ) + ) { + series.right.push(currentSeries); + } else if (series.right.length >= series.left.length) { + series.left.push(currentSeries); + } else { + series.right.push(currentSeries); + } + }); + + const axisGroups: GroupsConfiguration = []; + + if (series.left.length > 0) { + axisGroups.push({ + groupId: 'left', + position: shouldRotate ? 'bottom' : 'left', + formatter: formatFactory(series.left[0].fieldFormat), + series: series.left.map(({ fieldFormat, ...currentSeries }) => currentSeries), + }); + } + + if (series.right.length > 0) { + axisGroups.push({ + groupId: 'right', + position: shouldRotate ? 'top' : 'right', + formatter: formatFactory(series.right[0].fieldFormat), + series: series.right.map(({ fieldFormat, ...currentSeries }) => currentSeries), + }); + } + + return axisGroups; +} diff --git a/x-pack/plugins/lens/public/xy_visualization/index.ts b/x-pack/plugins/lens/public/xy_visualization/index.ts index cd25cb5729511..88a60089f6a24 100644 --- a/x-pack/plugins/lens/public/xy_visualization/index.ts +++ b/x-pack/plugins/lens/public/xy_visualization/index.ts @@ -11,7 +11,7 @@ import { ExpressionsSetup } from '../../../../../src/plugins/expressions/public' import { UI_SETTINGS } from '../../../../../src/plugins/data/public'; import { xyVisualization } from './xy_visualization'; import { xyChart, getXyChartRenderer } from './xy_expression'; -import { legendConfig, xConfig, layerConfig } from './types'; +import { legendConfig, layerConfig, yAxisConfig } from './types'; import { EditorFrameSetup, FormatFactory } from '../types'; export interface XyVisualizationPluginSetupPlugins { @@ -37,7 +37,7 @@ export class XyVisualization { { expressions, formatFactory, editorFrame }: XyVisualizationPluginSetupPlugins ) { expressions.registerFunction(() => legendConfig); - expressions.registerFunction(() => xConfig); + expressions.registerFunction(() => yAxisConfig); expressions.registerFunction(() => layerConfig); expressions.registerFunction(() => xyChart); diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts index e02d135d9a455..6ec22270d8b18 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -179,6 +179,21 @@ export const buildExpression = ( ], isHistogram: [isHistogramDimension], splitAccessor: layer.splitAccessor ? [layer.splitAccessor] : [], + yConfig: layer.yConfig + ? layer.yConfig.map((yConfig) => ({ + type: 'expression', + chain: [ + { + type: 'function', + function: 'lens_xy_yConfig', + arguments: { + forAccessor: [yConfig.forAccessor], + axisMode: [yConfig.axisMode], + }, + }, + ], + })) + : [], seriesType: [layer.seriesType], accessors: layer.accessors, columnToLabel: [JSON.stringify(columnToLabel)], diff --git a/x-pack/plugins/lens/public/xy_visualization/types.ts b/x-pack/plugins/lens/public/xy_visualization/types.ts index 7a5837d382c7b..e62c5f60a58e1 100644 --- a/x-pack/plugins/lens/public/xy_visualization/types.ts +++ b/x-pack/plugins/lens/public/xy_visualization/types.ts @@ -77,37 +77,33 @@ const axisConfig: { [key in keyof AxisConfig]: ArgumentType } = }, }; -export interface YState extends AxisConfig { - accessors: string[]; -} - -export interface XConfig extends AxisConfig { - accessor: string; -} +type YConfigResult = YConfig & { type: 'lens_xy_yConfig' }; -type XConfigResult = XConfig & { type: 'lens_xy_xConfig' }; - -export const xConfig: ExpressionFunctionDefinition< - 'lens_xy_xConfig', +export const yAxisConfig: ExpressionFunctionDefinition< + 'lens_xy_yConfig', null, - XConfig, - XConfigResult + YConfig, + YConfigResult > = { - name: 'lens_xy_xConfig', + name: 'lens_xy_yConfig', aliases: [], - type: 'lens_xy_xConfig', - help: `Configure the xy chart's x axis`, + type: 'lens_xy_yConfig', + help: `Configure the behavior of a xy chart's y axis metric`, inputTypes: ['null'], args: { - ...axisConfig, - accessor: { + forAccessor: { types: ['string'], - help: 'The column to display on the x axis.', + help: 'The accessor this configuration is for', + }, + axisMode: { + types: ['string'], + options: ['auto', 'left', 'right'], + help: 'The axis mode of the metric', }, }, - fn: function fn(input: unknown, args: XConfig) { + fn: function fn(input: unknown, args: YConfig) { return { - type: 'lens_xy_xConfig', + type: 'lens_xy_yConfig', ...args, }; }, @@ -166,6 +162,12 @@ export const layerConfig: ExpressionFunctionDefinition< help: 'The columns to display on the y axis.', multi: true, }, + yConfig: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + types: ['lens_xy_yConfig' as any], + help: 'Additional configuration for y axes', + multi: true, + }, columnToLabel: { types: ['string'], help: 'JSON key-value pairs of column ID to label', @@ -188,11 +190,19 @@ export type SeriesType = | 'bar_horizontal_stacked' | 'area_stacked'; +export type YAxisMode = 'auto' | 'left' | 'right'; + +export interface YConfig { + forAccessor: string; + axisMode?: YAxisMode; +} + export interface LayerConfig { hide?: boolean; layerId: string; xAccessor?: string; accessors: string[]; + yConfig?: YConfig[]; seriesType: SeriesType; splitAccessor?: string; } diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx index 0ea44e469f8dd..3e73cd256bdbf 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx @@ -4,12 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import _ from 'lodash'; import React from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiButtonGroup, EuiFormRow } from '@elastic/eui'; -import { State, SeriesType, visualizationTypes } from './types'; -import { VisualizationLayerWidgetProps } from '../types'; +import { EuiButtonGroup, EuiFormRow, htmlIdGenerator } from '@elastic/eui'; +import { State, SeriesType, visualizationTypes, YAxisMode } from './types'; +import { VisualizationDimensionEditorProps, VisualizationLayerWidgetProps } from '../types'; import { isHorizontalChart, isHorizontalSeries } from './state_helpers'; import { trackUiEvent } from '../lens_ui_telemetry'; @@ -68,3 +67,73 @@ export function LayerContextMenu(props: VisualizationLayerWidgetProps) { ); } + +const idPrefix = htmlIdGenerator()(); + +export function DimensionEditor({ + state, + setState, + layerId, + accessor, +}: VisualizationDimensionEditorProps) { + const index = state.layers.findIndex((l) => l.layerId === layerId); + const layer = state.layers[index]; + const axisMode = + (layer.yConfig && + layer.yConfig?.find((yAxisConfig) => yAxisConfig.forAccessor === accessor)?.axisMode) || + 'auto'; + return ( + + { + const newMode = id.replace(idPrefix, '') as YAxisMode; + const newYAxisConfigs = [...(layer.yConfig || [])]; + const existingIndex = newYAxisConfigs.findIndex( + (yAxisConfig) => yAxisConfig.forAccessor === accessor + ); + if (existingIndex !== -1) { + newYAxisConfigs[existingIndex].axisMode = newMode; + } else { + newYAxisConfigs.push({ + forAccessor: accessor, + axisMode: newMode, + }); + } + setState(updateLayer(state, { ...layer, yConfig: newYAxisConfigs }, index)); + }} + /> + + ); +} diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx index b2d9f6acfc9f5..34f2a9111253b 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx @@ -280,6 +280,58 @@ describe('xy_expression', () => { let getFormatSpy: jest.Mock; let convertSpy: jest.Mock; + const dataWithoutFormats: LensMultiTable = { + type: 'lens_multitable', + tables: { + first: { + type: 'kibana_datatable', + columns: [ + { id: 'a', name: 'a' }, + { id: 'b', name: 'b' }, + { id: 'c', name: 'c' }, + { id: 'd', name: 'd' }, + ], + rows: [ + { a: 1, b: 2, c: 'I', d: 'Row 1' }, + { a: 1, b: 5, c: 'J', d: 'Row 2' }, + ], + }, + }, + }; + const dataWithFormats: LensMultiTable = { + type: 'lens_multitable', + tables: { + first: { + type: 'kibana_datatable', + columns: [ + { id: 'a', name: 'a' }, + { id: 'b', name: 'b' }, + { id: 'c', name: 'c' }, + { id: 'd', name: 'd', formatHint: { id: 'custom' } }, + ], + rows: [ + { a: 1, b: 2, c: 'I', d: 'Row 1' }, + { a: 1, b: 5, c: 'J', d: 'Row 2' }, + ], + }, + }, + }; + + const getRenderedComponent = (data: LensMultiTable, args: XYArgs) => { + return shallow( + + ); + }; + beforeEach(() => { convertSpy = jest.fn((x) => x); getFormatSpy = jest.fn(); @@ -302,7 +354,9 @@ describe('xy_expression', () => { /> ); expect(component).toMatchSnapshot(); - expect(component.find(LineSeries)).toHaveLength(1); + expect(component.find(LineSeries)).toHaveLength(2); + expect(component.find(LineSeries).at(0).prop('yAccessors')).toEqual(['a']); + expect(component.find(LineSeries).at(1).prop('yAccessors')).toEqual(['b']); }); describe('date range', () => { @@ -559,7 +613,9 @@ describe('xy_expression', () => { /> ); expect(component).toMatchSnapshot(); - expect(component.find(BarSeries)).toHaveLength(1); + expect(component.find(BarSeries)).toHaveLength(2); + expect(component.find(BarSeries).at(0).prop('yAccessors')).toEqual(['a']); + expect(component.find(BarSeries).at(1).prop('yAccessors')).toEqual(['b']); }); test('it renders area', () => { @@ -577,7 +633,9 @@ describe('xy_expression', () => { /> ); expect(component).toMatchSnapshot(); - expect(component.find(AreaSeries)).toHaveLength(1); + expect(component.find(AreaSeries)).toHaveLength(2); + expect(component.find(AreaSeries).at(0).prop('yAccessors')).toEqual(['a']); + expect(component.find(AreaSeries).at(1).prop('yAccessors')).toEqual(['b']); }); test('it renders horizontal bar', () => { @@ -595,7 +653,9 @@ describe('xy_expression', () => { /> ); expect(component).toMatchSnapshot(); - expect(component.find(BarSeries)).toHaveLength(1); + expect(component.find(BarSeries)).toHaveLength(2); + expect(component.find(BarSeries).at(0).prop('yAccessors')).toEqual(['a']); + expect(component.find(BarSeries).at(1).prop('yAccessors')).toEqual(['b']); expect(component.find(Settings).prop('rotation')).toEqual(90); }); @@ -705,8 +765,9 @@ describe('xy_expression', () => { /> ); expect(component).toMatchSnapshot(); - expect(component.find(BarSeries)).toHaveLength(1); - expect(component.find(BarSeries).prop('stackAccessors')).toHaveLength(1); + expect(component.find(BarSeries)).toHaveLength(2); + expect(component.find(BarSeries).at(0).prop('stackAccessors')).toHaveLength(1); + expect(component.find(BarSeries).at(1).prop('stackAccessors')).toHaveLength(1); }); test('it renders stacked area', () => { @@ -724,8 +785,9 @@ describe('xy_expression', () => { /> ); expect(component).toMatchSnapshot(); - expect(component.find(AreaSeries)).toHaveLength(1); - expect(component.find(AreaSeries).prop('stackAccessors')).toHaveLength(1); + expect(component.find(AreaSeries)).toHaveLength(2); + expect(component.find(AreaSeries).at(0).prop('stackAccessors')).toHaveLength(1); + expect(component.find(AreaSeries).at(1).prop('stackAccessors')).toHaveLength(1); }); test('it renders stacked horizontal bar', () => { @@ -746,8 +808,9 @@ describe('xy_expression', () => { /> ); expect(component).toMatchSnapshot(); - expect(component.find(BarSeries)).toHaveLength(1); - expect(component.find(BarSeries).prop('stackAccessors')).toHaveLength(1); + expect(component.find(BarSeries)).toHaveLength(2); + expect(component.find(BarSeries).at(0).prop('stackAccessors')).toHaveLength(1); + expect(component.find(BarSeries).at(1).prop('stackAccessors')).toHaveLength(1); expect(component.find(Settings).prop('rotation')).toEqual(90); }); @@ -765,7 +828,8 @@ describe('xy_expression', () => { onSelectRange={onSelectRange} /> ); - expect(component.find(LineSeries).prop('timeZone')).toEqual('CEST'); + expect(component.find(LineSeries).at(0).prop('timeZone')).toEqual('CEST'); + expect(component.find(LineSeries).at(1).prop('timeZone')).toEqual('CEST'); }); test('it applies histogram mode to the series for single series', () => { @@ -784,7 +848,8 @@ describe('xy_expression', () => { onSelectRange={onSelectRange} /> ); - expect(component.find(BarSeries).prop('enableHistogramMode')).toEqual(true); + expect(component.find(BarSeries).at(0).prop('enableHistogramMode')).toEqual(true); + expect(component.find(BarSeries).at(1).prop('enableHistogramMode')).toEqual(true); }); test('it applies histogram mode to the series for stacked series', () => { @@ -810,7 +875,8 @@ describe('xy_expression', () => { onSelectRange={onSelectRange} /> ); - expect(component.find(BarSeries).prop('enableHistogramMode')).toEqual(true); + expect(component.find(BarSeries).at(0).prop('enableHistogramMode')).toEqual(true); + expect(component.find(BarSeries).at(1).prop('enableHistogramMode')).toEqual(true); }); test('it does not apply histogram mode for splitted series', () => { @@ -830,47 +896,104 @@ describe('xy_expression', () => { onSelectRange={onSelectRange} /> ); - expect(component.find(BarSeries).prop('enableHistogramMode')).toEqual(false); + expect(component.find(BarSeries).at(0).prop('enableHistogramMode')).toEqual(false); + expect(component.find(BarSeries).at(1).prop('enableHistogramMode')).toEqual(false); }); - describe('provides correct series naming', () => { - const dataWithoutFormats: LensMultiTable = { - type: 'lens_multitable', - tables: { - first: { - type: 'kibana_datatable', - columns: [ - { id: 'a', name: 'a' }, - { id: 'b', name: 'b' }, - { id: 'c', name: 'c' }, - { id: 'd', name: 'd' }, - ], - rows: [ - { a: 1, b: 2, c: 'I', d: 'Row 1' }, - { a: 1, b: 5, c: 'J', d: 'Row 2' }, - ], - }, - }, - }; - const dataWithFormats: LensMultiTable = { - type: 'lens_multitable', - tables: { - first: { - type: 'kibana_datatable', - columns: [ - { id: 'a', name: 'a' }, - { id: 'b', name: 'b' }, - { id: 'c', name: 'c' }, - { id: 'd', name: 'd', formatHint: { id: 'custom' } }, - ], - rows: [ - { a: 1, b: 2, c: 'I', d: 'Row 1' }, - { a: 1, b: 5, c: 'J', d: 'Row 2' }, - ], - }, - }, - }; + describe('y axes', () => { + test('single axis if possible', () => { + const args = createArgsWithLayers(); + + const component = getRenderedComponent(dataWithoutFormats, args); + const axes = component.find(Axis); + expect(axes).toHaveLength(2); + }); + + test('multiple axes because of config', () => { + const args = createArgsWithLayers(); + const newArgs = { + ...args, + layers: [ + { + ...args.layers[0], + accessors: ['a', 'b'], + yConfig: [ + { + forAccessor: 'a', + axisMode: 'left', + }, + { + forAccessor: 'b', + axisMode: 'right', + }, + ], + }, + ], + } as XYArgs; + const component = getRenderedComponent(dataWithoutFormats, newArgs); + const axes = component.find(Axis); + expect(axes).toHaveLength(3); + expect(component.find(LineSeries).at(0).prop('groupId')).toEqual( + axes.at(1).prop('groupId') + ); + expect(component.find(LineSeries).at(1).prop('groupId')).toEqual( + axes.at(2).prop('groupId') + ); + }); + + test('multiple axes because of incompatible formatters', () => { + const args = createArgsWithLayers(); + const newArgs = { + ...args, + layers: [ + { + ...args.layers[0], + accessors: ['c', 'd'], + }, + ], + } as XYArgs; + + const component = getRenderedComponent(dataWithFormats, newArgs); + const axes = component.find(Axis); + expect(axes).toHaveLength(3); + expect(component.find(LineSeries).at(0).prop('groupId')).toEqual( + axes.at(1).prop('groupId') + ); + expect(component.find(LineSeries).at(1).prop('groupId')).toEqual( + axes.at(2).prop('groupId') + ); + }); + + test('single axis despite different formatters if enforced', () => { + const args = createArgsWithLayers(); + const newArgs = { + ...args, + layers: [ + { + ...args.layers[0], + accessors: ['c', 'd'], + yConfig: [ + { + forAccessor: 'c', + axisMode: 'left', + }, + { + forAccessor: 'd', + axisMode: 'left', + }, + ], + }, + ], + } as XYArgs; + + const component = getRenderedComponent(dataWithoutFormats, newArgs); + const axes = component.find(Axis); + expect(axes).toHaveLength(2); + }); + }); + + describe('provides correct series naming', () => { const nameFnArgs = { seriesKeys: [], key: '', @@ -879,21 +1002,6 @@ describe('xy_expression', () => { splitAccessors: new Map(), }; - const getRenderedComponent = (data: LensMultiTable, args: XYArgs) => { - return shallow( - - ); - }; - test('simplest xy chart without human-readable name', () => { const args = createArgsWithLayers(); const newArgs = { @@ -973,13 +1081,14 @@ describe('xy_expression', () => { }; const component = getRenderedComponent(dataWithoutFormats, newArgs); - const nameFn = component.find(LineSeries).prop('name') as SeriesNameFn; + const nameFn1 = component.find(LineSeries).at(0).prop('name') as SeriesNameFn; + const nameFn2 = component.find(LineSeries).at(1).prop('name') as SeriesNameFn; // This accessor has a human-readable name - expect(nameFn({ ...nameFnArgs, seriesKeys: ['a'] }, false)).toEqual('Label A'); + expect(nameFn1({ ...nameFnArgs, seriesKeys: ['a'] }, false)).toEqual('Label A'); // This accessor does not - expect(nameFn({ ...nameFnArgs, seriesKeys: ['b'] }, false)).toEqual(''); - expect(nameFn({ ...nameFnArgs, seriesKeys: ['nonsense'] }, false)).toEqual(''); + expect(nameFn2({ ...nameFnArgs, seriesKeys: ['b'] }, false)).toEqual(''); + expect(nameFn1({ ...nameFnArgs, seriesKeys: ['nonsense'] }, false)).toEqual(''); }); test('split series without formatting and single y accessor', () => { @@ -1039,9 +1148,13 @@ describe('xy_expression', () => { }; const component = getRenderedComponent(dataWithoutFormats, newArgs); - const nameFn = component.find(LineSeries).prop('name') as SeriesNameFn; + const nameFn1 = component.find(LineSeries).at(0).prop('name') as SeriesNameFn; + const nameFn2 = component.find(LineSeries).at(0).prop('name') as SeriesNameFn; - expect(nameFn({ ...nameFnArgs, seriesKeys: ['split1', 'b'] }, false)).toEqual( + expect(nameFn1({ ...nameFnArgs, seriesKeys: ['split1', 'a'] }, false)).toEqual( + 'split1 - Label A' + ); + expect(nameFn2({ ...nameFnArgs, seriesKeys: ['split1', 'b'] }, false)).toEqual( 'split1 - Label B' ); }); @@ -1061,13 +1174,14 @@ describe('xy_expression', () => { }; const component = getRenderedComponent(dataWithFormats, newArgs); - const nameFn = component.find(LineSeries).prop('name') as SeriesNameFn; + const nameFn1 = component.find(LineSeries).at(0).prop('name') as SeriesNameFn; + const nameFn2 = component.find(LineSeries).at(1).prop('name') as SeriesNameFn; convertSpy.mockReturnValueOnce('formatted1').mockReturnValueOnce('formatted2'); - expect(nameFn({ ...nameFnArgs, seriesKeys: ['split1', 'a'] }, false)).toEqual( + expect(nameFn1({ ...nameFnArgs, seriesKeys: ['split1', 'a'] }, false)).toEqual( 'formatted1 - Label A' ); - expect(nameFn({ ...nameFnArgs, seriesKeys: ['split1', 'b'] }, false)).toEqual( + expect(nameFn2({ ...nameFnArgs, seriesKeys: ['split1', 'b'] }, false)).toEqual( 'formatted2 - Label B' ); }); @@ -1088,7 +1202,8 @@ describe('xy_expression', () => { onSelectRange={onSelectRange} /> ); - expect(component.find(LineSeries).prop('xScaleType')).toEqual(ScaleType.Ordinal); + expect(component.find(LineSeries).at(0).prop('xScaleType')).toEqual(ScaleType.Ordinal); + expect(component.find(LineSeries).at(1).prop('xScaleType')).toEqual(ScaleType.Ordinal); }); test('it set the scale of the y axis according to the args prop', () => { @@ -1106,7 +1221,8 @@ describe('xy_expression', () => { onSelectRange={onSelectRange} /> ); - expect(component.find(LineSeries).prop('yScaleType')).toEqual(ScaleType.Sqrt); + expect(component.find(LineSeries).at(0).prop('yScaleType')).toEqual(ScaleType.Sqrt); + expect(component.find(LineSeries).at(1).prop('yScaleType')).toEqual(ScaleType.Sqrt); }); test('it gets the formatter for the x axis', () => { @@ -1128,25 +1244,6 @@ describe('xy_expression', () => { expect(getFormatSpy).toHaveBeenCalledWith({ id: 'string' }); }); - test('it gets a default formatter for y if there are multiple y accessors', () => { - const { data, args } = sampleArgs(); - - shallow( - - ); - - expect(getFormatSpy).toHaveBeenCalledWith({ id: 'number' }); - }); - test('it gets the formatter for the y axis if there is only one accessor', () => { const { data, args } = sampleArgs(); diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx index 003036b211f03..17ed04aa0e9c4 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx @@ -40,6 +40,7 @@ import { isHorizontalChart } from './state_helpers'; import { parseInterval } from '../../../../../src/plugins/data/common'; import { EmptyPlaceholder } from '../shared_components'; import { desanitizeFilterContext } from '../utils'; +import { getAxesConfiguration } from './axes_configuration'; type InferPropType = T extends React.FunctionComponent ? P : T; type SeriesSpec = InferPropType & @@ -213,23 +214,19 @@ export function XYChart({ ); const xAxisFormatter = formatFactory(xAxisColumn && xAxisColumn.formatHint); - // use default number formatter for y axis and use formatting hint if there is just a single y column - let yAxisFormatter = formatFactory({ id: 'number' }); - if (filteredLayers.length === 1 && filteredLayers[0].accessors.length === 1) { - const firstYAxisColumn = Object.values(data.tables)[0].columns.find( - ({ id }) => id === filteredLayers[0].accessors[0] - ); - if (firstYAxisColumn && firstYAxisColumn.formatHint) { - yAxisFormatter = formatFactory(firstYAxisColumn.formatHint); - } - } - const chartHasMoreThanOneSeries = filteredLayers.length > 1 || filteredLayers.some((layer) => layer.accessors.length > 1) || filteredLayers.some((layer) => layer.splitAccessor); const shouldRotate = isHorizontalChart(filteredLayers); + const yAxesConfiguration = getAxesConfiguration( + filteredLayers, + data.tables, + formatFactory, + shouldRotate + ); + const xTitle = (xAxisColumn && xAxisColumn.name) || args.xTitle; function calculateMinInterval() { @@ -279,6 +276,9 @@ export function XYChart({ legendPosition={legend.position} showLegendExtra={false} theme={chartTheme} + tooltip={{ + headerFormatter: (d) => xAxisFormatter.convert(d.value), + }} rotation={shouldRotate ? 90 : 0} xDomain={xDomain} onBrushEnd={({ x }) => { @@ -368,18 +368,30 @@ export function XYChart({ tickFormat={(d) => xAxisFormatter.convert(d)} /> - yAxisFormatter.convert(d)} - /> + {yAxesConfiguration.map((axis, index) => ( + + data.tables[series.layer].columns.find((column) => column.id === series.accessor) + ?.name + ) + .filter((name) => Boolean(name))[0] || args.yTitle + } + showGridLines={false} + hide={filteredLayers[0].hide} + tickFormat={(d) => axis.formatter.convert(d)} + /> + ))} - {filteredLayers.map( - ( - { + {filteredLayers.flatMap((layer, layerIndex) => + layer.accessors.map((accessor, accessorIndex) => { + const { splitAccessor, seriesType, accessors, @@ -389,9 +401,7 @@ export function XYChart({ yScaleType, xScaleType, isHistogram, - }, - index - ) => { + } = layer; const columnToLabelMap: Record = columnToLabel ? JSON.parse(columnToLabel) : {}; @@ -407,19 +417,22 @@ export function XYChart({ !( splitAccessor && typeof row[splitAccessor] === 'undefined' && - accessors.every((accessor) => typeof row[accessor] === 'undefined') + typeof row[accessor] === 'undefined' ) ); const seriesProps: SeriesSpec = { splitSeriesAccessors: splitAccessor ? [splitAccessor] : [], stackAccessors: seriesType.includes('stacked') ? [xAccessor as string] : [], - id: splitAccessor || accessors.join(','), + id: `${splitAccessor}-${accessor}`, xAccessor, - yAccessors: accessors, + yAccessors: [accessor], data: rows, xScaleType, yScaleType, + groupId: yAxesConfiguration.find((axisConfiguration) => + axisConfiguration.series.find((currentSeries) => currentSeries.accessor === accessor) + )?.groupId, enableHistogramMode: isHistogram && (seriesType.includes('stacked') || !splitAccessor), timeZone, name(d) { @@ -451,6 +464,8 @@ export function XYChart({ }, }; + const index = `${layerIndex}-${accessorIndex}`; + switch (seriesType) { case 'line': return ; @@ -462,7 +477,7 @@ export function XYChart({ default: return ; } - } + }) )} ); diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts index ffbd3b7e2c1f2..9d0ebbb389c07 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts @@ -14,7 +14,7 @@ import { TableSuggestion, TableChangeType, } from '../types'; -import { State, SeriesType, XYState, visualizationTypes } from './types'; +import { State, SeriesType, XYState, visualizationTypes, LayerConfig } from './types'; import { getIconForSeries } from './state_helpers'; const columnSortOrder = { @@ -379,13 +379,19 @@ function buildSuggestion({ changeType: TableChangeType; keptLayerIds: string[]; }) { + const existingLayer: LayerConfig | {} = getExistingLayer(currentState, layerId) || {}; + const accessors = yValues.map((col) => col.columnId); const newLayer = { - ...(getExistingLayer(currentState, layerId) || {}), + ...existingLayer, layerId, seriesType, xAccessor: xValue.columnId, splitAccessor: splitBy?.columnId, - accessors: yValues.map((col) => col.columnId), + accessors, + yConfig: + 'yConfig' in existingLayer && existingLayer.yConfig + ? existingLayer.yConfig.filter(({ forAccessor }) => accessors.indexOf(forAccessor) !== -1) + : undefined, }; const keptLayers = currentState 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 ffacfbf8555eb..474ea5c5b08cd 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_visualization.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_visualization.tsx @@ -11,13 +11,13 @@ import { Position } from '@elastic/charts'; import { I18nProvider } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { getSuggestions } from './xy_suggestions'; -import { LayerContextMenu } from './xy_config_panel'; +import { DimensionEditor, LayerContextMenu } from './xy_config_panel'; 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'; import chartMixedSVG from '../assets/chart_mixed_xy.svg'; import { isHorizontalChart } from './state_helpers'; +import { toExpression, toPreviewExpression } from './to_expression'; const defaultIcon = chartBarStackedSVG; const defaultSeriesType = 'bar_stacked'; @@ -187,6 +187,7 @@ export const xyVisualization: Visualization = { supportsMoreColumns: true, required: true, dataTestSubj: 'lnsXY_yDimensionPanel', + enableDimensionEditor: true, }, { groupId: 'breakdown', @@ -239,6 +240,10 @@ export const xyVisualization: Visualization = { newLayer.accessors = newLayer.accessors.filter((a) => a !== columnId); } + if (newLayer.yConfig) { + newLayer.yConfig = newLayer.yConfig.filter(({ forAccessor }) => forAccessor !== columnId); + } + return { ...prevState, layers: prevState.layers.map((l) => (l.layerId === layerId ? newLayer : l)), @@ -259,6 +264,15 @@ export const xyVisualization: Visualization = { ); }, + renderDimensionEditor(domElement, props) { + render( + + + , + domElement + ); + }, + toExpression, toPreviewExpression, }; From 6e268898f9f5592def9200c6d92c33bb6d75fd4d Mon Sep 17 00:00:00 2001 From: Thomas Watson Date: Wed, 1 Jul 2020 10:01:32 +0200 Subject: [PATCH 16/72] chore: add missing mjs extension (#70326) --- src/dev/jest/config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dev/jest/config.js b/src/dev/jest/config.js index 391a52b7f0397..e11668ab57f55 100644 --- a/src/dev/jest/config.js +++ b/src/dev/jest/config.js @@ -84,7 +84,7 @@ export default { moduleFileExtensions: ['js', 'mjs', 'json', 'ts', 'tsx', 'node'], modulePathIgnorePatterns: ['__fixtures__/', 'target/'], testEnvironment: 'jest-environment-jsdom-thirteen', - testMatch: ['**/*.test.{js,ts,tsx}'], + testMatch: ['**/*.test.{js,mjs,ts,tsx}'], testPathIgnorePatterns: [ '/packages/kbn-ui-framework/(dist|doc_site|generator-kui)/', '/packages/kbn-pm/dist/', From a49f5cec64024ceb1807b3e4a71ff6c642b9bf40 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Wed, 1 Jul 2020 10:07:59 +0200 Subject: [PATCH 17/72] [Lens] Move chart switcher over (#70182) --- .../editor_frame/config_panel/_index.scss | 1 - .../config_panel/config_panel.tsx | 18 +-------- .../chart_switch.scss} | 5 +-- .../chart_switch.test.tsx | 0 .../chart_switch.tsx | 10 ++--- .../editor_frame/workspace_panel/index.ts | 7 ++++ .../workspace_panel.test.tsx | 20 +++++----- .../{ => workspace_panel}/workspace_panel.tsx | 27 +++++++------ .../workspace_panel_wrapper.test.tsx | 14 +++++-- .../workspace_panel_wrapper.tsx | 38 ++++++++++++++++--- 10 files changed, 83 insertions(+), 57 deletions(-) rename x-pack/plugins/lens/public/editor_frame_service/editor_frame/{config_panel/_chart_switch.scss => workspace_panel/chart_switch.scss} (86%) rename x-pack/plugins/lens/public/editor_frame_service/editor_frame/{config_panel => workspace_panel}/chart_switch.test.tsx (100%) rename x-pack/plugins/lens/public/editor_frame_service/editor_frame/{config_panel => workspace_panel}/chart_switch.tsx (98%) create mode 100644 x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/index.ts rename x-pack/plugins/lens/public/editor_frame_service/editor_frame/{ => workspace_panel}/workspace_panel.test.tsx (97%) rename x-pack/plugins/lens/public/editor_frame_service/editor_frame/{ => workspace_panel}/workspace_panel.tsx (91%) rename x-pack/plugins/lens/public/editor_frame_service/editor_frame/{ => workspace_panel}/workspace_panel_wrapper.test.tsx (82%) rename x-pack/plugins/lens/public/editor_frame_service/editor_frame/{ => workspace_panel}/workspace_panel_wrapper.tsx (69%) diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_index.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_index.scss index 8f09a358dd5e4..5b968abd0c061 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_index.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_index.scss @@ -1,4 +1,3 @@ -@import 'chart_switch'; @import 'config_panel'; @import 'dimension_popover'; @import 'layer_panel'; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx index e53e465c18950..7f4a48fa2fda2 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx @@ -8,7 +8,6 @@ import React, { useMemo, memo } from 'react'; import { EuiFlexItem, EuiToolTip, EuiButton, EuiForm } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { Visualization } from '../../../types'; -import { ChartSwitch } from './chart_switch'; import { LayerPanel } from './layer_panel'; import { trackUiEvent } from '../../../lens_ui_telemetry'; import { generateId } from '../../../id_generator'; @@ -20,21 +19,8 @@ export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: Config const { visualizationState } = props; return ( - <> - - {activeVisualization && visualizationState && ( - - )} - + activeVisualization && + visualizationState && ); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_chart_switch.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss similarity index 86% rename from x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_chart_switch.scss rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss index d7efab2405f3f..ae4a7861b1d90 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_chart_switch.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss @@ -1,6 +1,4 @@ .lnsChartSwitch__header { - padding: $euiSizeS 0; - > * { display: flex; align-items: center; @@ -9,7 +7,8 @@ .lnsChartSwitch__triggerButton { @include euiTitle('xs'); - line-height: $euiSizeXXL; + background-color: $euiColorEmptyShade; + border-color: $euiColorLightShade; } .lnsChartSwitch__summaryIcon { 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/workspace_panel/chart_switch.test.tsx similarity index 100% rename from x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.test.tsx rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.test.tsx 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/workspace_panel/chart_switch.tsx similarity index 98% rename from x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/chart_switch.tsx rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx index e212cb70d1855..4c5a44ecc695e 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/workspace_panel/chart_switch.tsx @@ -11,7 +11,7 @@ import { EuiPopoverTitle, EuiKeyPadMenu, EuiKeyPadMenuItem, - EuiButtonEmpty, + EuiButton, } from '@elastic/eui'; import { flatten } from 'lodash'; import { i18n } from '@kbn/i18n'; @@ -72,6 +72,8 @@ function VisualizationSummary(props: Props) { ); } +import './chart_switch.scss'; + export function ChartSwitch(props: Props) { const [flyoutOpen, setFlyoutOpen] = useState(false); @@ -198,20 +200,18 @@ export function ChartSwitch(props: Props) { ownFocus initialFocus=".lnsChartSwitch__popoverPanel" panelClassName="lnsChartSwitch__popoverPanel" - anchorClassName="eui-textTruncate" panelPaddingSize="s" button={ - setFlyoutOpen(!flyoutOpen)} data-test-subj="lnsChartSwitchPopover" - flush="left" iconSide="right" iconType="arrowDown" color="text" > - + } isOpen={flyoutOpen} closePopover={() => setFlyoutOpen(false)} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/index.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/index.ts new file mode 100644 index 0000000000000..d23afd4129cbe --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { WorkspacePanel } from './workspace_panel'; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx similarity index 97% rename from x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.test.tsx rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx index 49d12e9f41440..a9c638df8cad1 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.test.tsx @@ -6,19 +6,19 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; -import { ReactExpressionRendererProps } from '../../../../../../src/plugins/expressions/public'; -import { FramePublicAPI, TableSuggestion, Visualization } from '../../types'; +import { ReactExpressionRendererProps } from '../../../../../../../src/plugins/expressions/public'; +import { FramePublicAPI, TableSuggestion, Visualization } from '../../../types'; import { createMockVisualization, createMockDatasource, createExpressionRendererMock, DatasourceMock, createMockFramePublicAPI, -} from '../mocks'; +} from '../../mocks'; import { InnerWorkspacePanel, WorkspacePanelProps } from './workspace_panel'; import { mountWithIntl as mount } from 'test_utils/enzyme_helpers'; import { ReactWrapper } from 'enzyme'; -import { DragDrop, ChildDragDropProvider } from '../../drag_drop'; +import { DragDrop, ChildDragDropProvider } from '../../../drag_drop'; import { Ast } from '@kbn/interpreter/common'; import { coreMock } from 'src/core/public/mocks'; import { @@ -26,12 +26,12 @@ import { esFilters, IFieldType, IIndexPattern, -} from '../../../../../../src/plugins/data/public'; -import { TriggerId, UiActionsStart } from '../../../../../../src/plugins/ui_actions/public'; -import { uiActionsPluginMock } from '../../../../../../src/plugins/ui_actions/public/mocks'; -import { TriggerContract } from '../../../../../../src/plugins/ui_actions/public/triggers'; -import { VIS_EVENT_TO_TRIGGER } from '../../../../../../src/plugins/visualizations/public/embeddable'; -import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks'; +} from '../../../../../../../src/plugins/data/public'; +import { TriggerId, UiActionsStart } from '../../../../../../../src/plugins/ui_actions/public'; +import { uiActionsPluginMock } from '../../../../../../../src/plugins/ui_actions/public/mocks'; +import { TriggerContract } from '../../../../../../../src/plugins/ui_actions/public/triggers'; +import { VIS_EVENT_TO_TRIGGER } from '../../../../../../../src/plugins/visualizations/public/embeddable'; +import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; describe('workspace_panel', () => { let mockVisualization: jest.Mocked; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx similarity index 91% rename from x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.tsx rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx index 670afe28293a4..beb6952556067 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx @@ -20,23 +20,23 @@ import { CoreStart, CoreSetup } from 'kibana/public'; import { ExpressionRendererEvent, ReactExpressionRendererType, -} from '../../../../../../src/plugins/expressions/public'; -import { Action } from './state_management'; +} from '../../../../../../../src/plugins/expressions/public'; +import { Action } from '../state_management'; import { Datasource, Visualization, FramePublicAPI, isLensBrushEvent, isLensFilterEvent, -} from '../../types'; -import { DragDrop, DragContext } from '../../drag_drop'; -import { getSuggestions, switchToSuggestion } from './suggestion_helpers'; -import { buildExpression } from './expression_helpers'; -import { debouncedComponent } from '../../debounced_component'; -import { trackUiEvent } from '../../lens_ui_telemetry'; -import { UiActionsStart } from '../../../../../../src/plugins/ui_actions/public'; -import { VIS_EVENT_TO_TRIGGER } from '../../../../../../src/plugins/visualizations/public'; -import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public'; +} from '../../../types'; +import { DragDrop, DragContext } from '../../../drag_drop'; +import { getSuggestions, switchToSuggestion } from '../suggestion_helpers'; +import { buildExpression } from '../expression_helpers'; +import { debouncedComponent } from '../../../debounced_component'; +import { trackUiEvent } from '../../../lens_ui_telemetry'; +import { UiActionsStart } from '../../../../../../../src/plugins/ui_actions/public'; +import { VIS_EVENT_TO_TRIGGER } from '../../../../../../../src/plugins/visualizations/public'; +import { DataPublicPluginStart } from '../../../../../../../src/plugins/data/public'; import { WorkspacePanelWrapper } from './workspace_panel_wrapper'; export interface WorkspacePanelProps { @@ -300,7 +300,10 @@ export function InnerWorkspacePanel({ dispatch={dispatch} emptyExpression={expression === null} visualizationState={visualizationState} - activeVisualization={activeVisualization} + visualizationId={activeVisualizationId} + datasourceStates={datasourceStates} + datasourceMap={datasourceMap} + visualizationMap={visualizationMap} > { dispatch={jest.fn()} framePublicAPI={mockFrameAPI} visualizationState={{}} - activeVisualization={mockVisualization} + visualizationId="myVis" + visualizationMap={{ myVis: mockVisualization }} + datasourceMap={{}} + datasourceStates={{}} emptyExpression={false} > @@ -51,7 +54,10 @@ describe('workspace_panel_wrapper', () => { framePublicAPI={mockFrameAPI} visualizationState={visState} children={} - activeVisualization={{ ...mockVisualization, renderToolbar: renderToolbarMock }} + visualizationId="myVis" + visualizationMap={{ myVis: { ...mockVisualization, renderToolbar: renderToolbarMock } }} + datasourceMap={{}} + datasourceStates={{}} emptyExpression={false} /> ); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx similarity index 69% rename from x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel_wrapper.tsx rename to x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx index 17461b9fc274f..60c31e5d090e5 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx @@ -14,29 +14,43 @@ import { EuiFlexGroup, EuiFlexItem, } from '@elastic/eui'; -import { FramePublicAPI, Visualization } from '../../types'; -import { NativeRenderer } from '../../native_renderer'; -import { Action } from './state_management'; +import { Datasource, FramePublicAPI, Visualization } from '../../../types'; +import { NativeRenderer } from '../../../native_renderer'; +import { Action } from '../state_management'; +import { ChartSwitch } from './chart_switch'; export interface WorkspacePanelWrapperProps { children: React.ReactNode | React.ReactNode[]; framePublicAPI: FramePublicAPI; visualizationState: unknown; - activeVisualization: Visualization | null; dispatch: (action: Action) => void; emptyExpression: boolean; title?: string; + visualizationMap: Record; + visualizationId: string | null; + datasourceMap: Record; + datasourceStates: Record< + string, + { + isLoading: boolean; + state: unknown; + } + >; } export function WorkspacePanelWrapper({ children, framePublicAPI, visualizationState, - activeVisualization, dispatch, title, emptyExpression, + visualizationId, + visualizationMap, + datasourceMap, + datasourceStates, }: WorkspacePanelWrapperProps) { + const activeVisualization = visualizationId ? visualizationMap[visualizationId] : null; const setVisualizationState = useCallback( (newState: unknown) => { if (!activeVisualization) { @@ -52,7 +66,19 @@ export function WorkspacePanelWrapper({ [dispatch] ); return ( - + + + + {activeVisualization && activeVisualization.renderToolbar && ( Date: Wed, 1 Jul 2020 10:08:37 +0200 Subject: [PATCH 18/72] [Lens] Add "no data" popover (#69147) --- ...na-plugin-plugins-data-public.searchbar.md | 4 +- src/plugins/data/public/public.api.md | 4 +- .../no_data_popover.test.tsx | 95 ++++++++++++++++++ .../ui/query_string_input/no_data_popover.tsx | 96 +++++++++++++++++++ .../query_string_input/query_bar_top_row.tsx | 12 ++- .../ui/search_bar/create_search_bar.tsx | 1 + .../data/public/ui/search_bar/search_bar.tsx | 2 + test/functional/page_objects/time_picker.ts | 7 ++ .../lens/public/app_plugin/app.test.tsx | 1 + x-pack/plugins/lens/public/app_plugin/app.tsx | 21 ++++ .../editor_frame/data_panel_wrapper.tsx | 2 + .../editor_frame/editor_frame.test.tsx | 1 + .../editor_frame/editor_frame.tsx | 2 + .../editor_frame/state_management.test.ts | 1 + .../editor_frame_service/service.test.tsx | 2 + .../public/editor_frame_service/service.tsx | 6 +- .../__mocks__/loader.ts | 1 + .../datapanel.test.tsx | 7 +- .../indexpattern_datasource/datapanel.tsx | 6 +- .../dimension_panel/dimension_panel.test.tsx | 2 + .../indexpattern.test.ts | 5 + .../indexpattern_suggestions.test.tsx | 8 ++ .../layerpanel.test.tsx | 1 + .../indexpattern_datasource/loader.test.ts | 50 +++++++++- .../public/indexpattern_datasource/loader.ts | 18 ++++ .../definitions/date_histogram.test.tsx | 1 + .../operations/definitions/terms.test.tsx | 1 + .../operations/operations.test.ts | 1 + .../state_helpers.test.ts | 8 ++ .../public/indexpattern_datasource/types.ts | 1 + x-pack/plugins/lens/public/types.ts | 2 + .../apps/lens/persistent_context.ts | 6 +- .../test/functional/page_objects/lens_page.ts | 5 +- 33 files changed, 359 insertions(+), 21 deletions(-) create mode 100644 src/plugins/data/public/ui/query_string_input/no_data_popover.test.tsx create mode 100644 src/plugins/data/public/ui/query_string_input/no_data_popover.tsx diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md index fc141b8c89c18..498691c06285d 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.searchbar.md @@ -7,7 +7,7 @@ Signature: ```typescript -SearchBar: React.ComponentClass, "query" | "isLoading" | "filters" | "onRefresh" | "onRefreshChange" | "refreshInterval" | "indexPatterns" | "dataTestSubj" | "customSubmitButton" | "screenTitle" | "showQueryBar" | "showQueryInput" | "showFilterBar" | "showDatePicker" | "showAutoRefreshOnly" | "isRefreshPaused" | "dateRangeFrom" | "dateRangeTo" | "showSaveQuery" | "savedQuery" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "onClearSavedQuery" | "timeHistory" | "onFiltersUpdated">, any> & { - WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; +SearchBar: React.ComponentClass, "query" | "isLoading" | "filters" | "onRefresh" | "onRefreshChange" | "refreshInterval" | "indexPatterns" | "dataTestSubj" | "customSubmitButton" | "screenTitle" | "showQueryBar" | "showQueryInput" | "showFilterBar" | "showDatePicker" | "showAutoRefreshOnly" | "isRefreshPaused" | "dateRangeFrom" | "dateRangeTo" | "showSaveQuery" | "savedQuery" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "onClearSavedQuery" | "indicateNoData" | "timeHistory" | "onFiltersUpdated">, any> & { + WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; } ``` diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index f2c7a907cda1d..f19611bc1d526 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -1748,8 +1748,8 @@ export const search: { // Warning: (ae-missing-release-tag) "SearchBar" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const SearchBar: React.ComponentClass, "query" | "isLoading" | "filters" | "onRefresh" | "onRefreshChange" | "refreshInterval" | "indexPatterns" | "dataTestSubj" | "customSubmitButton" | "screenTitle" | "showQueryBar" | "showQueryInput" | "showFilterBar" | "showDatePicker" | "showAutoRefreshOnly" | "isRefreshPaused" | "dateRangeFrom" | "dateRangeTo" | "showSaveQuery" | "savedQuery" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "onClearSavedQuery" | "timeHistory" | "onFiltersUpdated">, any> & { - WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; +export const SearchBar: React.ComponentClass, "query" | "isLoading" | "filters" | "onRefresh" | "onRefreshChange" | "refreshInterval" | "indexPatterns" | "dataTestSubj" | "customSubmitButton" | "screenTitle" | "showQueryBar" | "showQueryInput" | "showFilterBar" | "showDatePicker" | "showAutoRefreshOnly" | "isRefreshPaused" | "dateRangeFrom" | "dateRangeTo" | "showSaveQuery" | "savedQuery" | "onQueryChange" | "onQuerySubmit" | "onSaved" | "onSavedQueryUpdated" | "onClearSavedQuery" | "indicateNoData" | "timeHistory" | "onFiltersUpdated">, any> & { + WrappedComponent: React.ComponentType & ReactIntl.InjectedIntlProps>; }; // Warning: (ae-forgotten-export) The symbol "SearchBarOwnProps" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/public/ui/query_string_input/no_data_popover.test.tsx b/src/plugins/data/public/ui/query_string_input/no_data_popover.test.tsx new file mode 100644 index 0000000000000..27f924d98e6eb --- /dev/null +++ b/src/plugins/data/public/ui/query_string_input/no_data_popover.test.tsx @@ -0,0 +1,95 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { mountWithIntl as mount } from 'test_utils/enzyme_helpers'; +import { NoDataPopover } from './no_data_popover'; +import { EuiTourStep } from '@elastic/eui'; +import { act } from 'react-dom/test-utils'; + +describe('NoDataPopover', () => { + const createMockStorage = () => ({ + get: jest.fn(), + set: jest.fn(), + remove: jest.fn(), + clear: jest.fn(), + }); + + it('should hide popover if showNoDataPopover is set to false', () => { + const Child = () => ; + const instance = mount( + + + + ); + expect(instance.find(EuiTourStep).prop('isStepOpen')).toBe(false); + expect(instance.find(EuiTourStep).find(Child)).toHaveLength(1); + }); + + it('should hide popover if showNoDataPopover is set to true, but local storage flag is set', () => { + const child = ; + const storage = createMockStorage(); + storage.get.mockReturnValue(true); + const instance = mount( + + {child} + + ); + expect(instance.find(EuiTourStep).prop('isStepOpen')).toBe(false); + }); + + it('should render popover if showNoDataPopover is set to true and local storage flag is not set', () => { + const child = ; + const instance = mount( + + {child} + + ); + expect(instance.find(EuiTourStep).prop('isStepOpen')).toBe(true); + }); + + it('should hide popover if it is closed', async () => { + const props = { + children: , + showNoDataPopover: true, + storage: createMockStorage(), + }; + const instance = mount(); + act(() => { + instance.find(EuiTourStep).prop('closePopover')!(); + }); + instance.setProps({ ...props }); + expect(instance.find(EuiTourStep).prop('isStepOpen')).toBe(false); + }); + + it('should set local storage flag and hide on closing with button', () => { + const props = { + children: , + showNoDataPopover: true, + storage: createMockStorage(), + }; + const instance = mount(); + act(() => { + instance.find(EuiTourStep).prop('footerAction')!.props.onClick(); + }); + instance.setProps({ ...props }); + expect(props.storage.set).toHaveBeenCalledWith(expect.any(String), true); + expect(instance.find(EuiTourStep).prop('isStepOpen')).toBe(false); + }); +}); diff --git a/src/plugins/data/public/ui/query_string_input/no_data_popover.tsx b/src/plugins/data/public/ui/query_string_input/no_data_popover.tsx new file mode 100644 index 0000000000000..302477a5fff5e --- /dev/null +++ b/src/plugins/data/public/ui/query_string_input/no_data_popover.tsx @@ -0,0 +1,96 @@ +/* + * 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 { ReactElement, useEffect, useState } from 'react'; +import React from 'react'; +import { EuiButtonEmpty, EuiText, EuiTourStep } from '@elastic/eui'; +import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; +import { i18n } from '@kbn/i18n'; + +const NO_DATA_POPOVER_STORAGE_KEY = 'data.noDataPopover'; + +export function NoDataPopover({ + showNoDataPopover, + storage, + children, +}: { + showNoDataPopover?: boolean; + storage: IStorageWrapper; + children: ReactElement; +}) { + const [noDataPopoverDismissed, setNoDataPopoverDismissed] = useState(() => + Boolean(storage.get(NO_DATA_POPOVER_STORAGE_KEY)) + ); + const [noDataPopoverVisible, setNoDataPopoverVisible] = useState(false); + + useEffect(() => { + if (showNoDataPopover && !noDataPopoverDismissed) { + setNoDataPopoverVisible(true); + } + }, [noDataPopoverDismissed, showNoDataPopover]); + + return ( + {}} + closePopover={() => { + setNoDataPopoverVisible(false); + }} + content={ + +

+ {i18n.translate('data.noDataPopover.content', { + defaultMessage: + "This time range doesn't contain any data. Increase or adjust the time range to see more fields and create charts", + })} +

+
+ } + minWidth={300} + anchorPosition="downCenter" + step={1} + stepsTotal={1} + isStepOpen={noDataPopoverVisible} + subtitle={i18n.translate('data.noDataPopover.title', { defaultMessage: 'Tip' })} + title="" + footerAction={ + { + storage.set(NO_DATA_POPOVER_STORAGE_KEY, true); + setNoDataPopoverDismissed(true); + setNoDataPopoverVisible(false); + }} + > + {i18n.translate('data.noDataPopover.dismissAction', { + defaultMessage: "Don't show again", + })} + + } + > +
{ + setNoDataPopoverVisible(false); + }} + > + {children} +
+
+ ); +} diff --git a/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx b/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx index f65bf97e391e2..4b0dc579c39ce 100644 --- a/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx +++ b/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx @@ -40,6 +40,7 @@ import { useKibana, toMountPoint } from '../../../../kibana_react/public'; import { QueryStringInput } from './query_string_input'; import { doesKueryExpressionHaveLuceneSyntaxError, UI_SETTINGS } from '../../../common'; import { PersistedLog, getQueryLog } from '../../query'; +import { NoDataPopover } from './no_data_popover'; interface Props { query?: Query; @@ -63,6 +64,7 @@ interface Props { customSubmitButton?: any; isDirty: boolean; timeHistory?: TimeHistoryContract; + indicateNoData?: boolean; } export function QueryBarTopRow(props: Props) { @@ -230,10 +232,12 @@ export function QueryBarTopRow(props: Props) { } return ( - - {renderDatePicker()} - {button} - + + + {renderDatePicker()} + {button} + + ); } diff --git a/src/plugins/data/public/ui/search_bar/create_search_bar.tsx b/src/plugins/data/public/ui/search_bar/create_search_bar.tsx index 81e84e3198072..a0df7604f23aa 100644 --- a/src/plugins/data/public/ui/search_bar/create_search_bar.tsx +++ b/src/plugins/data/public/ui/search_bar/create_search_bar.tsx @@ -198,6 +198,7 @@ export function createSearchBar({ core, storage, data }: StatefulSearchBarDeps) showSaveQuery={props.showSaveQuery} screenTitle={props.screenTitle} indexPatterns={props.indexPatterns} + indicateNoData={props.indicateNoData} timeHistory={data.query.timefilter.history} dateRangeFrom={timeRange.from} dateRangeTo={timeRange.to} diff --git a/src/plugins/data/public/ui/search_bar/search_bar.tsx b/src/plugins/data/public/ui/search_bar/search_bar.tsx index a5ac227559115..2f740cc476087 100644 --- a/src/plugins/data/public/ui/search_bar/search_bar.tsx +++ b/src/plugins/data/public/ui/search_bar/search_bar.tsx @@ -75,6 +75,7 @@ export interface SearchBarOwnProps { onClearSavedQuery?: () => void; onRefresh?: (payload: { dateRange: TimeRange }) => void; + indicateNoData?: boolean; } export type SearchBarProps = SearchBarOwnProps & SearchBarInjectedDeps; @@ -402,6 +403,7 @@ class SearchBarUI extends Component { this.props.customSubmitButton ? this.props.customSubmitButton : undefined } dataTestSubj={this.props.dataTestSubj} + indicateNoData={this.props.indicateNoData} /> ); } diff --git a/test/functional/page_objects/time_picker.ts b/test/functional/page_objects/time_picker.ts index 20ae89fc1a8d0..7ef291c8c7005 100644 --- a/test/functional/page_objects/time_picker.ts +++ b/test/functional/page_objects/time_picker.ts @@ -52,6 +52,13 @@ export function TimePickerProvider({ getService, getPageObjects }: FtrProviderCo await this.setAbsoluteRange(this.defaultStartTime, this.defaultEndTime); } + async ensureHiddenNoDataPopover() { + const isVisible = await testSubjects.exists('noDataPopoverDismissButton'); + if (isVisible) { + await testSubjects.click('noDataPopoverDismissButton'); + } + } + /** * the provides a quicker way to set the timepicker to the default range, saves a few seconds */ diff --git a/x-pack/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/plugins/lens/public/app_plugin/app.test.tsx index cd6fbf96d6750..3bd12a87456a0 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -226,6 +226,7 @@ describe('Lens App', () => { "query": "", }, "savedQuery": undefined, + "showNoDataPopover": [Function], }, ], ] diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index 0ab547bed6d37..9b8b9a8531cf0 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -40,6 +40,7 @@ import { } from '../../../../../src/plugins/data/public'; interface State { + indicateNoData: boolean; isLoading: boolean; isSaveModalVisible: boolean; indexPatternsForTopNav: IndexPatternInstance[]; @@ -97,9 +98,27 @@ export function App({ toDate: currentRange.to, }, filters: [], + indicateNoData: false, }; }); + const showNoDataPopover = useCallback(() => { + setState((prevState) => ({ ...prevState, indicateNoData: true })); + }, [setState]); + + useEffect(() => { + if (state.indicateNoData) { + setState((prevState) => ({ ...prevState, indicateNoData: false })); + } + }, [ + setState, + state.indicateNoData, + state.query, + state.filters, + state.dateRange, + state.indexPatternsForTopNav, + ]); + const { lastKnownDoc } = state; const isSaveable = @@ -458,6 +477,7 @@ export function App({ query={state.query} dateRangeFrom={state.dateRange.fromDate} dateRangeTo={state.dateRange.toDate} + indicateNoData={state.indicateNoData} /> @@ -472,6 +492,7 @@ export function App({ savedQuery: state.savedQuery, doc: state.persistedDoc, onError, + showNoDataPopover, onChange: ({ filterableIndexPatterns, doc }) => { if (!_.isEqual(state.persistedDoc, doc)) { setState((s) => ({ ...s, lastKnownDoc: doc })); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx index afb2719f28e89..0f74abe97c418 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/data_panel_wrapper.tsx @@ -19,6 +19,7 @@ interface DataPanelWrapperProps { activeDatasource: string | null; datasourceIsLoading: boolean; dispatch: (action: Action) => void; + showNoDataPopover: () => void; core: DatasourceDataPanelProps['core']; query: Query; dateRange: FramePublicAPI['dateRange']; @@ -46,6 +47,7 @@ export const DataPanelWrapper = memo((props: DataPanelWrapperProps) => { query: props.query, dateRange: props.dateRange, filters: props.filters, + showNoDataPopover: props.showNoDataPopover, }; const [showDatasourceSwitcher, setDatasourceSwitcher] = useState(false); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx index ff9e24f95d1e2..ad4f6e74c9e92 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx @@ -56,6 +56,7 @@ function getDefaultProps() { data: dataPluginMock.createStartContract(), expressions: expressionsPluginMock.createStartContract(), }, + showNoDataPopover: jest.fn(), }; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx index af3d0ed068d2f..bcceb1222ce03 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.tsx @@ -48,6 +48,7 @@ export interface EditorFrameProps { filterableIndexPatterns: DatasourceMetaData['filterableIndexPatterns']; doc: Document; }) => void; + showNoDataPopover: () => void; } export function EditorFrame(props: EditorFrameProps) { @@ -255,6 +256,7 @@ export function EditorFrame(props: EditorFrameProps) { query={props.query} dateRange={props.dateRange} filters={props.filters} + showNoDataPopover={props.showNoDataPopover} /> } configPanel={ diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts index e1151b92aac51..969467b5789ec 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_management.test.ts @@ -35,6 +35,7 @@ describe('editor_frame state management', () => { dateRange: { fromDate: 'now-7d', toDate: 'now' }, query: { query: '', language: 'lucene' }, filters: [], + showNoDataPopover: jest.fn(), }; }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/service.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/service.test.tsx index fbd65c5044d51..7b1d091c1c8fe 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/service.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/service.test.tsx @@ -51,6 +51,7 @@ describe('editor_frame service', () => { dateRange: { fromDate: '', toDate: '' }, query: { query: '', language: 'lucene' }, filters: [], + showNoDataPopover: jest.fn(), }); instance.unmount(); })() @@ -70,6 +71,7 @@ describe('editor_frame service', () => { dateRange: { fromDate: '', toDate: '' }, query: { query: '', language: 'lucene' }, filters: [], + showNoDataPopover: jest.fn(), }); instance.unmount(); diff --git a/x-pack/plugins/lens/public/editor_frame_service/service.tsx b/x-pack/plugins/lens/public/editor_frame_service/service.tsx index f57acf3bef62d..47339373b6d1a 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/service.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/service.tsx @@ -102,7 +102,10 @@ export class EditorFrameService { ]); return { - mount: (element, { doc, onError, dateRange, query, filters, savedQuery, onChange }) => { + mount: ( + element, + { doc, onError, dateRange, query, filters, savedQuery, onChange, showNoDataPopover } + ) => { domElement = element; const firstDatasourceId = Object.keys(resolvedDatasources)[0]; const firstVisualizationId = Object.keys(resolvedVisualizations)[0]; @@ -127,6 +130,7 @@ export class EditorFrameService { filters={filters} savedQuery={savedQuery} onChange={onChange} + showNoDataPopover={showNoDataPopover} /> , domElement diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/__mocks__/loader.ts b/x-pack/plugins/lens/public/indexpattern_datasource/__mocks__/loader.ts index f2fedda1fa353..ca5fe706985f8 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/__mocks__/loader.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/__mocks__/loader.ts @@ -19,6 +19,7 @@ export function loadInitialState() { [restricted.id]: restricted, }, layers: {}, + isFirstExistenceFetch: false, }; return result; } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx index 7653dab2c9b84..f70df855fe0cb 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.test.tsx @@ -204,12 +204,15 @@ const initialState: IndexPatternPrivateState = { ], }, }, + isFirstExistenceFetch: false, }; const dslQuery = { bool: { must: [{ match_all: {} }], filter: [], should: [], must_not: [] } }; describe('IndexPattern Data Panel', () => { - let defaultProps: Parameters[0]; + let defaultProps: Parameters[0] & { + showNoDataPopover: () => void; + }; let core: ReturnType; beforeEach(() => { @@ -229,6 +232,7 @@ describe('IndexPattern Data Panel', () => { }, query: { query: '', language: 'lucene' }, filters: [], + showNoDataPopover: jest.fn(), }; }); @@ -301,6 +305,7 @@ describe('IndexPattern Data Panel', () => { state: { indexPatternRefs: [], existingFields: {}, + isFirstExistenceFetch: false, currentIndexPatternId: 'a', indexPatterns: { a: { id: 'a', title: 'aaa', timeFieldName: 'atime', fields: [] }, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx index b72f87e243dcd..87fbf81fceba0 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx @@ -82,6 +82,7 @@ export function IndexPatternDataPanel({ filters, dateRange, changeIndexPattern, + showNoDataPopover, }: Props) { const { indexPatternRefs, indexPatterns, currentIndexPatternId } = state; const onChangeIndexPattern = useCallback( @@ -116,6 +117,9 @@ export function IndexPatternDataPanel({ syncExistingFields({ dateRange, setState, + isFirstExistenceFetch: state.isFirstExistenceFetch, + currentIndexPatternTitle: indexPatterns[currentIndexPatternId].title, + showNoDataPopover, indexPatterns: indexPatternList, fetchJson: core.http.post, dslQuery, @@ -210,7 +214,7 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ core, data, existingFields, -}: Pick> & { +}: Omit & { data: DataPublicPluginStart; currentIndexPatternId: string; indexPatternRefs: IndexPatternRef[]; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx index ee9b6778650ef..a1c084f83e447 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx @@ -79,6 +79,7 @@ describe('IndexPatternDimensionEditorPanel', () => { indexPatternRefs: [], indexPatterns: expectedIndexPatterns, currentIndexPatternId: '1', + isFirstExistenceFetch: false, existingFields: { 'my-fake-index-pattern': { timestamp: true, @@ -1257,6 +1258,7 @@ describe('IndexPatternDimensionEditorPanel', () => { }, }, currentIndexPatternId: '1', + isFirstExistenceFetch: false, layers: { myLayer: { indexPatternId: 'foo', 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 a69d7c055eaa7..6a79ce450cd9a 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.test.ts @@ -127,6 +127,7 @@ function stateFromPersistedState( indexPatterns: expectedIndexPatterns, indexPatternRefs: [], existingFields: {}, + isFirstExistenceFetch: false, }; } @@ -401,6 +402,7 @@ describe('IndexPattern Data Source', () => { }, }, currentIndexPatternId: '1', + isFirstExistenceFetch: false, }; expect(indexPatternDatasource.insertLayer(state, 'newLayer')).toEqual({ ...state, @@ -421,6 +423,7 @@ describe('IndexPattern Data Source', () => { const state = { indexPatternRefs: [], existingFields: {}, + isFirstExistenceFetch: false, indexPatterns: expectedIndexPatterns, layers: { first: { @@ -455,6 +458,7 @@ describe('IndexPattern Data Source', () => { indexPatternDatasource.getLayers({ indexPatternRefs: [], existingFields: {}, + isFirstExistenceFetch: false, indexPatterns: expectedIndexPatterns, layers: { first: { @@ -480,6 +484,7 @@ describe('IndexPattern Data Source', () => { indexPatternDatasource.getMetaData({ indexPatternRefs: [], existingFields: {}, + isFirstExistenceFetch: false, indexPatterns: expectedIndexPatterns, layers: { first: { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx index 87d91b56d2a5c..b6246c6e91e7e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx @@ -146,6 +146,7 @@ function testInitialState(): IndexPatternPrivateState { }, }, }, + isFirstExistenceFetch: false, }; } @@ -304,6 +305,7 @@ describe('IndexPattern Data Source suggestions', () => { indexPatternRefs: [], existingFields: {}, currentIndexPatternId: '1', + isFirstExistenceFetch: false, indexPatterns: { 1: { id: '1', @@ -508,6 +510,7 @@ describe('IndexPattern Data Source suggestions', () => { indexPatternRefs: [], existingFields: {}, currentIndexPatternId: '1', + isFirstExistenceFetch: false, indexPatterns: { 1: { id: '1', @@ -1046,6 +1049,7 @@ describe('IndexPattern Data Source suggestions', () => { it('returns no suggestions if there are no columns', () => { expect( getDatasourceSuggestionsFromCurrentState({ + isFirstExistenceFetch: false, indexPatternRefs: [], existingFields: {}, indexPatterns: expectedIndexPatterns, @@ -1351,6 +1355,7 @@ describe('IndexPattern Data Source suggestions', () => { ], }, }, + isFirstExistenceFetch: false, layers: { first: { ...initialState.layers.first, @@ -1470,6 +1475,7 @@ describe('IndexPattern Data Source suggestions', () => { ], }, }, + isFirstExistenceFetch: false, layers: { first: { ...initialState.layers.first, @@ -1523,6 +1529,7 @@ describe('IndexPattern Data Source suggestions', () => { ], }, }, + isFirstExistenceFetch: false, layers: { first: { ...initialState.layers.first, @@ -1553,6 +1560,7 @@ describe('IndexPattern Data Source suggestions', () => { existingFields: {}, currentIndexPatternId: '1', indexPatterns: expectedIndexPatterns, + isFirstExistenceFetch: false, layers: { first: { ...initialState.layers.first, diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.test.tsx index 9cbd624b42d3e..f9a74ee477d57 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.test.tsx @@ -22,6 +22,7 @@ const initialState: IndexPatternPrivateState = { ], existingFields: {}, currentIndexPatternId: '1', + isFirstExistenceFetch: false, layers: { first: { indexPatternId: '1', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts index e8c8c5762bb83..5776691fbcc7f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SavedObjectsClientContract } from 'kibana/public'; +import { HttpHandler, SavedObjectsClientContract } from 'kibana/public'; import _ from 'lodash'; import { loadInitialState, @@ -429,6 +429,7 @@ describe('loader', () => { indexPatterns: {}, existingFields: {}, layers: {}, + isFirstExistenceFetch: false, }; const storage = createMockStorage({ indexPatternId: 'b' }); @@ -463,6 +464,7 @@ describe('loader', () => { existingFields: {}, indexPatterns: {}, layers: {}, + isFirstExistenceFetch: false, }; const storage = createMockStorage({ indexPatternId: 'b' }); @@ -520,6 +522,7 @@ describe('loader', () => { indexPatternId: 'a', }, }, + isFirstExistenceFetch: false, }; const storage = createMockStorage({ indexPatternId: 'a' }); @@ -588,6 +591,7 @@ describe('loader', () => { indexPatternId: 'a', }, }, + isFirstExistenceFetch: false, }; const storage = createMockStorage({ indexPatternId: 'b' }); @@ -625,7 +629,7 @@ describe('loader', () => { it('should call once for each index pattern', async () => { const setState = jest.fn(); - const fetchJson = jest.fn((path: string) => { + const fetchJson = (jest.fn((path: string) => { const indexPatternTitle = _.last(path.split('/')); return { indexPatternTitle, @@ -633,15 +637,17 @@ describe('loader', () => { (fieldName) => `${indexPatternTitle}_${fieldName}` ), }; - }); + }) as unknown) as HttpHandler; await syncExistingFields({ dateRange: { fromDate: '1900-01-01', toDate: '2000-01-01' }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - fetchJson: fetchJson as any, + fetchJson, indexPatterns: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], setState, dslQuery, + showNoDataPopover: jest.fn(), + currentIndexPatternTitle: 'abc', + isFirstExistenceFetch: false, }); expect(fetchJson).toHaveBeenCalledTimes(3); @@ -655,6 +661,7 @@ describe('loader', () => { expect(newState).toEqual({ foo: 'bar', + isFirstExistenceFetch: false, existingFields: { a: { a_field_1: true, a_field_2: true }, b: { b_field_1: true, b_field_2: true }, @@ -662,5 +669,38 @@ describe('loader', () => { }, }); }); + + it('should call showNoDataPopover callback if current index pattern returns no fields', async () => { + const setState = jest.fn(); + const showNoDataPopover = jest.fn(); + const fetchJson = (jest.fn((path: string) => { + const indexPatternTitle = _.last(path.split('/')); + return { + indexPatternTitle, + existingFieldNames: + indexPatternTitle === 'a' + ? ['field_1', 'field_2'].map((fieldName) => `${indexPatternTitle}_${fieldName}`) + : [], + }; + }) as unknown) as HttpHandler; + + const args = { + dateRange: { fromDate: '1900-01-01', toDate: '2000-01-01' }, + fetchJson, + indexPatterns: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + setState, + dslQuery, + showNoDataPopover: jest.fn(), + currentIndexPatternTitle: 'abc', + isFirstExistenceFetch: false, + }; + + await syncExistingFields(args); + + expect(showNoDataPopover).not.toHaveBeenCalled(); + + await syncExistingFields({ ...args, isFirstExistenceFetch: true }); + expect(showNoDataPopover).not.toHaveBeenCalled(); + }); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts index 6c57988dfc7b6..101f536993365 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts @@ -119,6 +119,7 @@ export async function loadInitialState({ indexPatternRefs, indexPatterns, existingFields: {}, + isFirstExistenceFetch: true, }; } @@ -128,6 +129,7 @@ export async function loadInitialState({ indexPatterns, layers: {}, existingFields: {}, + isFirstExistenceFetch: true, }; } @@ -238,13 +240,19 @@ export async function syncExistingFields({ dateRange, fetchJson, setState, + isFirstExistenceFetch, + currentIndexPatternTitle, dslQuery, + showNoDataPopover, }: { dateRange: DateRange; indexPatterns: Array<{ id: string; timeFieldName?: string | null }>; fetchJson: HttpSetup['post']; setState: SetState; + isFirstExistenceFetch: boolean; + currentIndexPatternTitle: string; dslQuery: object; + showNoDataPopover: () => void; }) { const emptinessInfo = await Promise.all( indexPatterns.map((pattern) => { @@ -264,8 +272,18 @@ export async function syncExistingFields({ }) ); + if (isFirstExistenceFetch) { + const fieldsCurrentIndexPattern = emptinessInfo.find( + (info) => info.indexPatternTitle === currentIndexPatternTitle + ); + if (fieldsCurrentIndexPattern && fieldsCurrentIndexPattern.existingFieldNames.length === 0) { + showNoDataPopover(); + } + } + setState((state) => ({ ...state, + isFirstExistenceFetch: false, existingFields: emptinessInfo.reduce((acc, info) => { acc[info.indexPatternTitle] = booleanMap(info.existingFieldNames); return acc; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx index d0c7af42114e3..1a094a36f68e3 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/date_histogram.test.tsx @@ -51,6 +51,7 @@ describe('date_histogram', () => { indexPatternRefs: [], existingFields: {}, currentIndexPatternId: '1', + isFirstExistenceFetch: false, indexPatterns: { 1: { id: '1', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms.test.tsx index 1e1d83a0a5c4c..d7f00e185a5bb 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms.test.tsx @@ -34,6 +34,7 @@ describe('terms', () => { indexPatterns: {}, existingFields: {}, currentIndexPatternId: '1', + isFirstExistenceFetch: false, layers: { first: { indexPatternId: '1', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts index a73f6e13d94c5..1a37e5e4cf6a4 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/operations.test.ts @@ -147,6 +147,7 @@ describe('getOperationTypesForField', () => { indexPatternRefs: [], existingFields: {}, currentIndexPatternId: '1', + isFirstExistenceFetch: false, indexPatterns: expectedIndexPatterns, layers: { first: { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/state_helpers.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/state_helpers.test.ts index 65a2401fd689a..d778749ef3940 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/state_helpers.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/state_helpers.test.ts @@ -42,6 +42,7 @@ describe('state_helpers', () => { existingFields: {}, indexPatterns: {}, currentIndexPatternId: '1', + isFirstExistenceFetch: false, layers: { first: { indexPatternId: '1', @@ -95,6 +96,7 @@ describe('state_helpers', () => { existingFields: {}, indexPatterns: {}, currentIndexPatternId: '1', + isFirstExistenceFetch: false, layers: { first: { indexPatternId: '1', @@ -145,6 +147,7 @@ describe('state_helpers', () => { existingFields: {}, indexPatterns: {}, currentIndexPatternId: '1', + isFirstExistenceFetch: false, layers: { first: { indexPatternId: '1', @@ -185,6 +188,7 @@ describe('state_helpers', () => { existingFields: {}, indexPatterns: {}, currentIndexPatternId: '1', + isFirstExistenceFetch: false, layers: { first: { indexPatternId: '1', @@ -218,6 +222,7 @@ describe('state_helpers', () => { existingFields: {}, indexPatterns: {}, currentIndexPatternId: '1', + isFirstExistenceFetch: false, layers: { first: { indexPatternId: '1', @@ -279,6 +284,7 @@ describe('state_helpers', () => { existingFields: {}, indexPatterns: {}, currentIndexPatternId: '1', + isFirstExistenceFetch: false, layers: { first: { indexPatternId: '1', @@ -331,6 +337,7 @@ describe('state_helpers', () => { existingFields: {}, indexPatterns: {}, currentIndexPatternId: '1', + isFirstExistenceFetch: false, layers: { first: { indexPatternId: '1', @@ -410,6 +417,7 @@ describe('state_helpers', () => { existingFields: {}, indexPatterns: {}, currentIndexPatternId: '1', + isFirstExistenceFetch: false, layers: { first: { indexPatternId: '1', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/types.ts b/x-pack/plugins/lens/public/indexpattern_datasource/types.ts index 35a82d8774130..b7beb67196add 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/types.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/types.ts @@ -51,6 +51,7 @@ export type IndexPatternPrivateState = IndexPatternPersistedState & { * indexPatternId -> fieldName -> boolean */ existingFields: Record>; + isFirstExistenceFetch: boolean; }; export interface IndexPatternRef { diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index d451e312446bd..c7bda65cd1327 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -47,6 +47,7 @@ export interface EditorFrameProps { filterableIndexPatterns: DatasourceMetaData['filterableIndexPatterns']; doc: Document; }) => void; + showNoDataPopover: () => void; } export interface EditorFrameInstance { mount: (element: Element, props: EditorFrameProps) => void; @@ -186,6 +187,7 @@ export interface DatasourceDataPanelProps { state: T; dragDropContext: DragContextState; setState: StateSetter; + showNoDataPopover: () => void; core: Pick; query: Query; dateRange: DateRange; diff --git a/x-pack/test/functional/apps/lens/persistent_context.ts b/x-pack/test/functional/apps/lens/persistent_context.ts index 00d9208772798..b980116c581da 100644 --- a/x-pack/test/functional/apps/lens/persistent_context.ts +++ b/x-pack/test/functional/apps/lens/persistent_context.ts @@ -10,7 +10,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; // eslint-disable-next-line import/no-default-export export default function ({ getService, getPageObjects }: FtrProviderContext) { - const PageObjects = getPageObjects(['visualize', 'header', 'timePicker']); + const PageObjects = getPageObjects(['visualize', 'lens', 'header', 'timePicker']); const browser = getService('browser'); const filterBar = getService('filterBar'); const appsMenu = getService('appsMenu'); @@ -19,7 +19,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should carry over time range and pinned filters to discover', async () => { await PageObjects.visualize.navigateToNewVisualization(); await PageObjects.visualize.clickVisType('lens'); - await PageObjects.timePicker.setAbsoluteRange( + await PageObjects.lens.goToTimeRange( 'Sep 06, 2015 @ 06:31:44.000', 'Sep 18, 2025 @ 06:31:44.000' ); @@ -33,7 +33,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('should remember time range and pinned filters from discover', async () => { - await PageObjects.timePicker.setAbsoluteRange( + await PageObjects.lens.goToTimeRange( 'Sep 07, 2015 @ 06:31:44.000', 'Sep 19, 2025 @ 06:31:44.000' ); diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index bae11e1ea8a90..ce621d4471d0f 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -38,10 +38,11 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont * Move the date filter to the specified time range, defaults to * a range that has data in our dataset. */ - goToTimeRange(fromTime?: string, toTime?: string) { + async goToTimeRange(fromTime?: string, toTime?: string) { + await PageObjects.timePicker.ensureHiddenNoDataPopover(); fromTime = fromTime || PageObjects.timePicker.defaultStartTime; toTime = toTime || PageObjects.timePicker.defaultEndTime; - return PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); }, /** From 22a41c51e53f851bc67e5e4f63e393a838f6f8aa Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Wed, 1 Jul 2020 10:54:22 +0200 Subject: [PATCH 19/72] [Discover] Deangularization context error message refactoring (#70090) Co-authored-by: Elastic Machine --- .../application/angular/context_app.html | 42 ++---------- .../context_error_message.test.tsx | 54 ++++++++++++++++ .../context_error_message.tsx | 64 +++++++++++++++++++ .../context_error_message_directive.ts | 26 ++++++++ .../components/context_error_message/index.ts | 21 ++++++ .../discover/public/get_inner_angular.ts | 2 + 6 files changed, 171 insertions(+), 38 deletions(-) create mode 100644 src/plugins/discover/public/application/components/context_error_message/context_error_message.test.tsx create mode 100644 src/plugins/discover/public/application/components/context_error_message/context_error_message.tsx create mode 100644 src/plugins/discover/public/application/components/context_error_message/context_error_message_directive.ts create mode 100644 src/plugins/discover/public/application/components/context_error_message/index.ts diff --git a/src/plugins/discover/public/application/angular/context_app.html b/src/plugins/discover/public/application/angular/context_app.html index 9c37fd3bfc5be..6adcaeeae94f5 100644 --- a/src/plugins/discover/public/application/angular/context_app.html +++ b/src/plugins/discover/public/application/angular/context_app.html @@ -12,44 +12,10 @@ -
-
-
- - -
- -
-
-
-
- -
-
-
-
+ +
{ + component = mountWithIntl(); + expect(findTestSubject(component, 'contextErrorMessageTitle').length).toBe(0); + }); + + it('ContextErrorMessage does not render on success loading', () => { + component = mountWithIntl(); + expect(findTestSubject(component, 'contextErrorMessageTitle').length).toBe(0); + }); + + it('ContextErrorMessage renders just the title if the reason is not specifically handled', () => { + component = mountWithIntl(); + expect(findTestSubject(component, 'contextErrorMessageTitle').length).toBe(1); + expect(findTestSubject(component, 'contextErrorMessageBody').text()).toBe(''); + }); + + it('ContextErrorMessage renders the reason for unknown errors', () => { + component = mountWithIntl( + + ); + expect(findTestSubject(component, 'contextErrorMessageTitle').length).toBe(1); + expect(findTestSubject(component, 'contextErrorMessageBody').length).toBe(1); + }); +}); diff --git a/src/plugins/discover/public/application/components/context_error_message/context_error_message.tsx b/src/plugins/discover/public/application/components/context_error_message/context_error_message.tsx new file mode 100644 index 0000000000000..f73496c2eeada --- /dev/null +++ b/src/plugins/discover/public/application/components/context_error_message/context_error_message.tsx @@ -0,0 +1,64 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { EuiCallOut, EuiText } from '@elastic/eui'; +import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; +// @ts-ignore +import { FAILURE_REASONS, LOADING_STATUS } from '../../angular/context/query'; + +export interface ContextErrorMessageProps { + /** + * the status of the loading action + */ + status: string; + /** + * the reason of the error + */ + reason?: string; +} + +export function ContextErrorMessage({ status, reason }: ContextErrorMessageProps) { + if (status !== LOADING_STATUS.FAILED) { + return null; + } + return ( + + + } + color="danger" + iconType="alert" + data-test-subj="contextErrorMessageTitle" + > + + {reason === FAILURE_REASONS.UNKNOWN && ( + + )} + + + + ); +} diff --git a/src/plugins/discover/public/application/components/context_error_message/context_error_message_directive.ts b/src/plugins/discover/public/application/components/context_error_message/context_error_message_directive.ts new file mode 100644 index 0000000000000..925d560761a84 --- /dev/null +++ b/src/plugins/discover/public/application/components/context_error_message/context_error_message_directive.ts @@ -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. + */ +import { ContextErrorMessage } from './context_error_message'; + +export function createContextErrorMessageDirective(reactDirective: any) { + return reactDirective(ContextErrorMessage, [ + ['status', { watchDepth: 'reference' }], + ['reason', { watchDepth: 'reference' }], + ]); +} diff --git a/src/plugins/discover/public/application/components/context_error_message/index.ts b/src/plugins/discover/public/application/components/context_error_message/index.ts new file mode 100644 index 0000000000000..f20f2ccf8afa0 --- /dev/null +++ b/src/plugins/discover/public/application/components/context_error_message/index.ts @@ -0,0 +1,21 @@ +/* + * 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 { ContextErrorMessage } from './context_error_message'; +export { createContextErrorMessageDirective } from './context_error_message_directive'; diff --git a/src/plugins/discover/public/get_inner_angular.ts b/src/plugins/discover/public/get_inner_angular.ts index 05513eef93624..0b3c2fad8d45b 100644 --- a/src/plugins/discover/public/get_inner_angular.ts +++ b/src/plugins/discover/public/get_inner_angular.ts @@ -61,6 +61,7 @@ import { createDiscoverSidebarDirective } from './application/components/sidebar import { createHitsCounterDirective } from '././application/components/hits_counter'; import { createLoadingSpinnerDirective } from '././application/components/loading_spinner/loading_spinner'; import { createTimechartHeaderDirective } from './application/components/timechart_header'; +import { createContextErrorMessageDirective } from './application/components/context_error_message'; import { DiscoverStartPlugins } from './plugin'; import { getScopedHistory } from './kibana_services'; import { createSkipBottomButtonDirective } from './application/components/skip_bottom_button'; @@ -160,6 +161,7 @@ export function initializeInnerAngularModule( .directive('hitsCounter', createHitsCounterDirective) .directive('loadingSpinner', createLoadingSpinnerDirective) .directive('timechartHeader', createTimechartHeaderDirective) + .directive('contextErrorMessage', createContextErrorMessageDirective) .service('debounce', ['$timeout', DebounceProviderTimeout]); } From 5c98b11d73c926733df0ab01c9fb44f6254734d0 Mon Sep 17 00:00:00 2001 From: Peter Pisljar Date: Wed, 1 Jul 2020 10:56:05 +0200 Subject: [PATCH 20/72] expressions indexPattern function (#70315) --- .../index_patterns/expressions/index.ts | 20 ++++++ .../expressions/load_index_pattern.test.ts | 39 ++++++++++++ .../expressions/load_index_pattern.ts | 62 +++++++++++++++++++ src/plugins/data/public/plugin.ts | 2 + 4 files changed, 123 insertions(+) create mode 100644 src/plugins/data/public/index_patterns/expressions/index.ts create mode 100644 src/plugins/data/public/index_patterns/expressions/load_index_pattern.test.ts create mode 100644 src/plugins/data/public/index_patterns/expressions/load_index_pattern.ts diff --git a/src/plugins/data/public/index_patterns/expressions/index.ts b/src/plugins/data/public/index_patterns/expressions/index.ts new file mode 100644 index 0000000000000..fa37e3b216ac9 --- /dev/null +++ b/src/plugins/data/public/index_patterns/expressions/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export * from './load_index_pattern'; diff --git a/src/plugins/data/public/index_patterns/expressions/load_index_pattern.test.ts b/src/plugins/data/public/index_patterns/expressions/load_index_pattern.test.ts new file mode 100644 index 0000000000000..378ceb376f5f1 --- /dev/null +++ b/src/plugins/data/public/index_patterns/expressions/load_index_pattern.test.ts @@ -0,0 +1,39 @@ +/* + * 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 { indexPatternLoad } from './load_index_pattern'; + +jest.mock('../../services', () => ({ + getIndexPatterns: () => ({ + get: (id: string) => ({ + toSpec: () => ({ + title: 'value', + }), + }), + }), +})); + +describe('indexPattern expression function', () => { + test('returns serialized index pattern', async () => { + const indexPatternDefinition = indexPatternLoad(); + const result = await indexPatternDefinition.fn(null, { id: '1' }, {} as any); + expect(result.type).toEqual('index_pattern'); + expect(result.value.title).toEqual('value'); + }); +}); diff --git a/src/plugins/data/public/index_patterns/expressions/load_index_pattern.ts b/src/plugins/data/public/index_patterns/expressions/load_index_pattern.ts new file mode 100644 index 0000000000000..901d6aac7fbff --- /dev/null +++ b/src/plugins/data/public/index_patterns/expressions/load_index_pattern.ts @@ -0,0 +1,62 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ExpressionFunctionDefinition } from '../../../../../plugins/expressions/public'; +import { getIndexPatterns } from '../../services'; +import { IndexPatternSpec } from '../../../common/index_patterns'; + +const name = 'indexPatternLoad'; + +type Input = null; +type Output = Promise<{ type: 'index_pattern'; value: IndexPatternSpec }>; + +interface Arguments { + id: string; +} + +export const indexPatternLoad = (): ExpressionFunctionDefinition< + typeof name, + Input, + Arguments, + Output +> => ({ + name, + type: 'index_pattern', + inputTypes: ['null'], + help: i18n.translate('data.functions.indexPatternLoad.help', { + defaultMessage: 'Loads an index pattern', + }), + args: { + id: { + types: ['string'], + required: true, + help: i18n.translate('data.functions.indexPatternLoad.id.help', { + defaultMessage: 'index pattern id to load', + }), + }, + }, + async fn(input, args) { + const indexPatterns = getIndexPatterns(); + + const indexPattern = await indexPatterns.get(args.id); + + return { type: 'index_pattern', value: indexPattern.toSpec() }; + }, +}); diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts index d5929cb9cd564..ec71794fde87d 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -82,6 +82,7 @@ import { ValueClickActionContext, } from './actions/value_click_action'; import { SavedObjectsClientPublicToCommon } from './index_patterns'; +import { indexPatternLoad } from './index_patterns/expressions/load_index_pattern'; declare module '../../ui_actions/public' { export interface ActionContextMapping { @@ -126,6 +127,7 @@ export class DataPublicPlugin implements Plugin Date: Wed, 1 Jul 2020 04:06:56 -0500 Subject: [PATCH 21/72] Initial work on uptime homepage API (#70135) Co-authored-by: Shahzad --- .../common/runtime_types/ping/histogram.ts | 1 + x-pack/plugins/uptime/kibana.json | 2 +- x-pack/plugins/uptime/public/apps/plugin.ts | 13 ++++ .../public/apps/uptime_overview_fetcher.ts | 62 +++++++++++++++++++ .../plugins/uptime/public/state/api/ping.ts | 2 + .../__tests__/get_ping_histogram.test.ts | 3 +- .../server/lib/requests/get_ping_histogram.ts | 29 ++++++--- .../rest_api/pings/get_ping_histogram.ts | 4 +- 8 files changed, 105 insertions(+), 11 deletions(-) create mode 100644 x-pack/plugins/uptime/public/apps/uptime_overview_fetcher.ts diff --git a/x-pack/plugins/uptime/common/runtime_types/ping/histogram.ts b/x-pack/plugins/uptime/common/runtime_types/ping/histogram.ts index 209770a19f4aa..47e4dd52299b1 100644 --- a/x-pack/plugins/uptime/common/runtime_types/ping/histogram.ts +++ b/x-pack/plugins/uptime/common/runtime_types/ping/histogram.ts @@ -21,6 +21,7 @@ export interface GetPingHistogramParams { dateEnd: string; filters?: string; monitorId?: string; + bucketSize?: string; } export interface HistogramResult { diff --git a/x-pack/plugins/uptime/kibana.json b/x-pack/plugins/uptime/kibana.json index 5fbd6129fd18f..152839836ad99 100644 --- a/x-pack/plugins/uptime/kibana.json +++ b/x-pack/plugins/uptime/kibana.json @@ -2,7 +2,7 @@ "configPath": ["xpack", "uptime"], "id": "uptime", "kibanaVersion": "kibana", - "optionalPlugins": ["capabilities", "data", "home"], + "optionalPlugins": ["capabilities", "data", "home", "observability"], "requiredPlugins": [ "alerts", "embeddable", diff --git a/x-pack/plugins/uptime/public/apps/plugin.ts b/x-pack/plugins/uptime/public/apps/plugin.ts index 26810a9b1cda3..9af4dea9dbb44 100644 --- a/x-pack/plugins/uptime/public/apps/plugin.ts +++ b/x-pack/plugins/uptime/public/apps/plugin.ts @@ -27,10 +27,14 @@ import { } from '../../../../../src/plugins/data/public'; import { alertTypeInitializers } from '../lib/alert_types'; import { kibanaService } from '../state/kibana_service'; +import { fetchIndexStatus } from '../state/api'; +import { ObservabilityPluginSetup } from '../../../observability/public'; +import { fetchUptimeOverviewData } from './uptime_overview_fetcher'; export interface ClientPluginsSetup { data: DataPublicPluginSetup; home: HomePublicPluginSetup; + observability: ObservabilityPluginSetup; triggers_actions_ui: TriggersAndActionsUIPublicPluginSetup; } @@ -63,6 +67,15 @@ export class UptimePlugin }); } + plugins.observability.dashboard.register({ + appName: 'uptime', + hasData: async () => { + const status = await fetchIndexStatus(); + return status.docCount > 0; + }, + fetchData: fetchUptimeOverviewData, + }); + core.application.register({ appRoute: '/app/uptime#/', id: PLUGIN.ID, diff --git a/x-pack/plugins/uptime/public/apps/uptime_overview_fetcher.ts b/x-pack/plugins/uptime/public/apps/uptime_overview_fetcher.ts new file mode 100644 index 0000000000000..8467714e9661e --- /dev/null +++ b/x-pack/plugins/uptime/public/apps/uptime_overview_fetcher.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fetchPingHistogram, fetchSnapshotCount } from '../state/api'; +import { UptimeFetchDataResponse } from '../../../observability/public/typings/fetch_data_response'; + +export async function fetchUptimeOverviewData({ + startTime, + endTime, + bucketSize, +}: { + startTime: string; + endTime: string; + bucketSize: string; +}) { + const snapshot = await fetchSnapshotCount({ + dateRangeStart: startTime, + dateRangeEnd: endTime, + }); + + const pings = await fetchPingHistogram({ dateStart: startTime, dateEnd: endTime, bucketSize }); + + const response: UptimeFetchDataResponse = { + title: 'Uptime', + appLink: '/app/uptime#/', + stats: { + monitors: { + type: 'number', + label: 'Monitors', + value: snapshot.total, + }, + up: { + type: 'number', + label: 'Up', + value: snapshot.up, + }, + down: { + type: 'number', + label: 'Down', + value: snapshot.down, + }, + }, + series: { + up: { + label: 'Up', + coordinates: pings.histogram.map((p) => { + return { x: p.x!, y: p.upCount || 0 }; + }), + }, + down: { + label: 'Down', + coordinates: pings.histogram.map((p) => { + return { x: p.x!, y: p.downCount || 0 }; + }), + }, + }, + }; + return response; +} diff --git a/x-pack/plugins/uptime/public/state/api/ping.ts b/x-pack/plugins/uptime/public/state/api/ping.ts index a2937c9c794dd..2d6a69064f277 100644 --- a/x-pack/plugins/uptime/public/state/api/ping.ts +++ b/x-pack/plugins/uptime/public/state/api/ping.ts @@ -25,12 +25,14 @@ export const fetchPingHistogram: APIFn dateStart, dateEnd, filters, + bucketSize, }) => { const queryParams = { dateStart, dateEnd, monitorId, filters, + bucketSize, }; return await apiService.get(API_URLS.PING_HISTOGRAM, queryParams); diff --git a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_ping_histogram.test.ts b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_ping_histogram.test.ts index 9042186145eb7..11c7511dec370 100644 --- a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_ping_histogram.test.ts +++ b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_ping_histogram.test.ts @@ -62,7 +62,6 @@ describe('getPingHistogram', () => { dynamicSettings: DYNAMIC_SETTINGS_DEFAULTS, from: 'now-15m', to: 'now', - filters: null, }); expect(mockEsClient).toHaveBeenCalledTimes(1); @@ -81,7 +80,7 @@ describe('getPingHistogram', () => { dynamicSettings: DYNAMIC_SETTINGS_DEFAULTS, from: 'now-15m', to: 'now', - filters: null, + filters: '', }); expect(mockEsClient).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/uptime/server/lib/requests/get_ping_histogram.ts b/x-pack/plugins/uptime/server/lib/requests/get_ping_histogram.ts index 863eff82c360e..a74b55c24e227 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_ping_histogram.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_ping_histogram.ts @@ -15,15 +15,17 @@ export interface GetPingHistogramParams { /** @member dateRangeEnd timestamp bounds */ to: string; /** @member filters user-defined filters */ - filters?: string | null; + filters?: string; /** @member monitorId optional limit to monitorId */ - monitorId?: string | null; + monitorId?: string; + + bucketSize?: string; } export const getPingHistogram: UMElasticsearchQueryFn< GetPingHistogramParams, HistogramResult -> = async ({ callES, dynamicSettings, from, to, filters, monitorId }) => { +> = async ({ callES, dynamicSettings, from, to, filters, monitorId, bucketSize }) => { const boolFilters = filters ? JSON.parse(filters) : null; const additionalFilters = []; if (monitorId) { @@ -34,6 +36,22 @@ export const getPingHistogram: UMElasticsearchQueryFn< } const filter = getFilterClause(from, to, additionalFilters); + const seriesHistogram: any = {}; + + if (bucketSize) { + seriesHistogram.date_histogram = { + field: '@timestamp', + fixed_interval: bucketSize, + missing: 0, + }; + } else { + seriesHistogram.auto_date_histogram = { + field: '@timestamp', + buckets: QUERY.DEFAULT_BUCKET_COUNT, + missing: 0, + }; + } + const params = { index: dynamicSettings.heartbeatIndices, body: { @@ -45,10 +63,7 @@ export const getPingHistogram: UMElasticsearchQueryFn< size: 0, aggs: { timeseries: { - auto_date_histogram: { - field: '@timestamp', - buckets: QUERY.DEFAULT_BUCKET_COUNT, - }, + ...seriesHistogram, aggs: { down: { filter: { diff --git a/x-pack/plugins/uptime/server/rest_api/pings/get_ping_histogram.ts b/x-pack/plugins/uptime/server/rest_api/pings/get_ping_histogram.ts index a589997889069..4ac50d0e78c4c 100644 --- a/x-pack/plugins/uptime/server/rest_api/pings/get_ping_histogram.ts +++ b/x-pack/plugins/uptime/server/rest_api/pings/get_ping_histogram.ts @@ -18,10 +18,11 @@ export const createGetPingHistogramRoute: UMRestApiRouteFactory = (libs: UMServe dateEnd: schema.string(), monitorId: schema.maybe(schema.string()), filters: schema.maybe(schema.string()), + bucketSize: schema.maybe(schema.string()), }), }, handler: async ({ callES, dynamicSettings }, _context, request, response): Promise => { - const { dateStart, dateEnd, monitorId, filters } = request.query; + const { dateStart, dateEnd, monitorId, filters, bucketSize } = request.query; const result = await libs.requests.getPingHistogram({ callES, @@ -30,6 +31,7 @@ export const createGetPingHistogramRoute: UMRestApiRouteFactory = (libs: UMServe to: dateEnd, monitorId, filters, + bucketSize, }); return response.ok({ From 8a6a55097da187f07ca8c6f298f8586b4f1fe435 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Wed, 1 Jul 2020 03:16:23 -0700 Subject: [PATCH 22/72] Enable "Explore underlying data" actions for Lens visualizations (#70047) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 💡 rename folder to "explore_data" * style: 💄 check for "share" plugin in more semantic way "explore data" actions use Discover URL generator, which is registered in "share" plugin, which is optional plugin, so we check for its existance, because otherwise URL generator is not available. * refactor: 💡 move KibanaURL to a separate file * feat: 🎸 add "Explore underlying data" in-chart action * fix: 🐛 fix imports after refactor * feat: 🎸 add start.filtersFromContext to embeddable plugin * feat: 🎸 add type checkers to data plugin * feat: 🎸 better handle empty filters in Discover URL generator * feat: 🎸 implement .getUrl() method of explore data in-chart act * feat: 🎸 add embeddable.filtersAndTimeRangeFromContext() * feat: 🎸 improve getUrl() method of explore data action * test: 💍 update test mock * fix possible stale hashHistory.location in discover * style: 💄 ensureHashHistoryLocation -> syncHistoryLocations * docs: ✏️ update autogenerated docs * test: 💍 add in-chart "Explore underlying data" unit tests * test: 💍 add in-chart "Explore underlying data" functional tests * test: 💍 clean-up custom time range after panel action tests * chore: 🤖 fix embeddable plugin mocks * chore: 🤖 fix another mock * test: 💍 add support for new action to pie chart service * feat: 🎸 enable "Explore underlying data" action for Lens vis * test: 💍 make tests green again * refactor: 💡 rename trigger contexts * chore: 🤖 fix TypeScript errors Co-authored-by: Anton Dosov Co-authored-by: Elastic Machine --- .../create_filters_from_range_select.ts | 4 +-- .../create_filters_from_value_click.test.ts | 4 +-- .../create_filters_from_value_click.ts | 4 +-- .../public/actions/select_range_action.ts | 4 +-- .../data/public/actions/value_click_action.ts | 4 +-- src/plugins/embeddable/public/index.ts | 4 +-- .../public/lib/triggers/triggers.ts | 12 ++++----- src/plugins/ui_actions/public/types.ts | 6 ++--- .../dashboard_hello_world_drilldown/index.tsx | 7 ++--- .../dashboard_to_discover_drilldown/types.ts | 7 ++--- .../dashboard_to_url_drilldown/index.tsx | 7 ++--- .../drilldown.test.tsx | 8 +++--- .../dashboard_to_dashboard_drilldown/types.ts | 8 +++--- .../abstract_explore_data_action.ts | 7 +++-- .../explore_data_chart_action.test.ts | 21 ++++++++++----- .../explore_data/explore_data_chart_action.ts | 8 +++--- .../explore_data_context_menu_action.test.ts | 13 ++++++--- .../explore_data_context_menu_action.ts | 2 +- .../public/actions/explore_data/shared.ts | 27 +++++-------------- 19 files changed, 74 insertions(+), 83 deletions(-) diff --git a/src/plugins/data/public/actions/filters/create_filters_from_range_select.ts b/src/plugins/data/public/actions/filters/create_filters_from_range_select.ts index 409614ca9c380..a0eb49d773f3d 100644 --- a/src/plugins/data/public/actions/filters/create_filters_from_range_select.ts +++ b/src/plugins/data/public/actions/filters/create_filters_from_range_select.ts @@ -22,9 +22,9 @@ import moment from 'moment'; import { esFilters, IFieldType, RangeFilterParams } from '../../../public'; import { getIndexPatterns } from '../../../public/services'; import { deserializeAggConfig } from '../../search/expressions/utils'; -import { RangeSelectTriggerContext } from '../../../../embeddable/public'; +import { RangeSelectContext } from '../../../../embeddable/public'; -export async function createFiltersFromRangeSelectAction(event: RangeSelectTriggerContext['data']) { +export async function createFiltersFromRangeSelectAction(event: RangeSelectContext['data']) { const column: Record = event.table.columns[event.column]; if (!column || !column.meta) { diff --git a/src/plugins/data/public/actions/filters/create_filters_from_value_click.test.ts b/src/plugins/data/public/actions/filters/create_filters_from_value_click.test.ts index a0e285c20d776..3e38477a908b8 100644 --- a/src/plugins/data/public/actions/filters/create_filters_from_value_click.test.ts +++ b/src/plugins/data/public/actions/filters/create_filters_from_value_click.test.ts @@ -27,7 +27,7 @@ import { dataPluginMock } from '../../../public/mocks'; import { setIndexPatterns } from '../../../public/services'; import { mockDataServices } from '../../../public/search/aggs/test_helpers'; import { createFiltersFromValueClickAction } from './create_filters_from_value_click'; -import { ValueClickTriggerContext } from '../../../../embeddable/public'; +import { ValueClickContext } from '../../../../embeddable/public'; const mockField = { name: 'bytes', @@ -39,7 +39,7 @@ const mockField = { }; describe('createFiltersFromValueClick', () => { - let dataPoints: ValueClickTriggerContext['data']['data']; + let dataPoints: ValueClickContext['data']['data']; beforeEach(() => { dataPoints = [ diff --git a/src/plugins/data/public/actions/filters/create_filters_from_value_click.ts b/src/plugins/data/public/actions/filters/create_filters_from_value_click.ts index 2fdd746535519..1974b9f776748 100644 --- a/src/plugins/data/public/actions/filters/create_filters_from_value_click.ts +++ b/src/plugins/data/public/actions/filters/create_filters_from_value_click.ts @@ -21,7 +21,7 @@ import { KibanaDatatable } from '../../../../../plugins/expressions/public'; import { deserializeAggConfig } from '../../search/expressions'; import { esFilters, Filter } from '../../../public'; import { getIndexPatterns } from '../../../public/services'; -import { ValueClickTriggerContext } from '../../../../embeddable/public'; +import { ValueClickContext } from '../../../../embeddable/public'; /** * For terms aggregations on `__other__` buckets, this assembles a list of applicable filter @@ -114,7 +114,7 @@ const createFilter = async ( export const createFiltersFromValueClickAction = async ({ data, negate, -}: ValueClickTriggerContext['data']) => { +}: ValueClickContext['data']) => { const filters: Filter[] = []; await Promise.all( diff --git a/src/plugins/data/public/actions/select_range_action.ts b/src/plugins/data/public/actions/select_range_action.ts index 18853f7e292f6..49766143b5588 100644 --- a/src/plugins/data/public/actions/select_range_action.ts +++ b/src/plugins/data/public/actions/select_range_action.ts @@ -24,12 +24,12 @@ import { ActionByType, } from '../../../../plugins/ui_actions/public'; import { createFiltersFromRangeSelectAction } from './filters/create_filters_from_range_select'; -import { RangeSelectTriggerContext } from '../../../embeddable/public'; +import { RangeSelectContext } from '../../../embeddable/public'; import { FilterManager, TimefilterContract, esFilters } from '..'; export const ACTION_SELECT_RANGE = 'ACTION_SELECT_RANGE'; -export type SelectRangeActionContext = RangeSelectTriggerContext; +export type SelectRangeActionContext = RangeSelectContext; async function isCompatible(context: SelectRangeActionContext) { try { diff --git a/src/plugins/data/public/actions/value_click_action.ts b/src/plugins/data/public/actions/value_click_action.ts index 5d4f1f5f1d6db..dd74a7ee507f3 100644 --- a/src/plugins/data/public/actions/value_click_action.ts +++ b/src/plugins/data/public/actions/value_click_action.ts @@ -27,12 +27,12 @@ import { import { getOverlays, getIndexPatterns } from '../services'; import { applyFiltersPopover } from '../ui/apply_filters'; import { createFiltersFromValueClickAction } from './filters/create_filters_from_value_click'; -import { ValueClickTriggerContext } from '../../../embeddable/public'; +import { ValueClickContext } from '../../../embeddable/public'; import { Filter, FilterManager, TimefilterContract, esFilters } from '..'; export const ACTION_VALUE_CLICK = 'ACTION_VALUE_CLICK'; -export type ValueClickActionContext = ValueClickTriggerContext; +export type ValueClickActionContext = ValueClickContext; async function isCompatible(context: ValueClickActionContext) { try { diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index f19974942c43d..6960550b59d1c 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -48,8 +48,8 @@ export { EmbeddableOutput, EmbeddablePanel, EmbeddableRoot, - ValueClickTriggerContext, - RangeSelectTriggerContext, + ValueClickContext, + RangeSelectContext, ErrorEmbeddable, IContainer, IEmbeddable, diff --git a/src/plugins/embeddable/public/lib/triggers/triggers.ts b/src/plugins/embeddable/public/lib/triggers/triggers.ts index 5bb96a708b7ac..ccba5cf771088 100644 --- a/src/plugins/embeddable/public/lib/triggers/triggers.ts +++ b/src/plugins/embeddable/public/lib/triggers/triggers.ts @@ -25,7 +25,7 @@ export interface EmbeddableContext { embeddable: IEmbeddable; } -export interface ValueClickTriggerContext { +export interface ValueClickContext { embeddable?: T; data: { data: Array<{ @@ -39,7 +39,7 @@ export interface ValueClickTriggerContext { }; } -export interface RangeSelectTriggerContext { +export interface RangeSelectContext { embeddable?: T; data: { table: KibanaDatatable; @@ -50,16 +50,16 @@ export interface RangeSelectTriggerContext } export type ChartActionContext = - | ValueClickTriggerContext - | RangeSelectTriggerContext; + | ValueClickContext + | RangeSelectContext; export const isValueClickTriggerContext = ( context: ChartActionContext -): context is ValueClickTriggerContext => context.data && 'data' in context.data; +): context is ValueClickContext => context.data && 'data' in context.data; export const isRangeSelectTriggerContext = ( context: ChartActionContext -): context is RangeSelectTriggerContext => context.data && 'range' in context.data; +): context is RangeSelectContext => context.data && 'range' in context.data; export const CONTEXT_MENU_TRIGGER = 'CONTEXT_MENU_TRIGGER'; export const contextMenuTrigger: Trigger<'CONTEXT_MENU_TRIGGER'> = { diff --git a/src/plugins/ui_actions/public/types.ts b/src/plugins/ui_actions/public/types.ts index 85c87306cc4f9..9fcd8a32881df 100644 --- a/src/plugins/ui_actions/public/types.ts +++ b/src/plugins/ui_actions/public/types.ts @@ -22,7 +22,7 @@ import { TriggerInternal } from './triggers/trigger_internal'; import { Filter } from '../../data/public'; import { SELECT_RANGE_TRIGGER, VALUE_CLICK_TRIGGER, APPLY_FILTER_TRIGGER } from './triggers'; import { IEmbeddable } from '../../embeddable/public'; -import { RangeSelectTriggerContext, ValueClickTriggerContext } from '../../embeddable/public'; +import { RangeSelectContext, ValueClickContext } from '../../embeddable/public'; export type TriggerRegistry = Map>; export type ActionRegistry = Map; @@ -37,8 +37,8 @@ export type TriggerContext = BaseContext; export interface TriggerContextMapping { [DEFAULT_TRIGGER]: TriggerContext; - [SELECT_RANGE_TRIGGER]: RangeSelectTriggerContext; - [VALUE_CLICK_TRIGGER]: ValueClickTriggerContext; + [SELECT_RANGE_TRIGGER]: RangeSelectContext; + [VALUE_CLICK_TRIGGER]: ValueClickContext; [APPLY_FILTER_TRIGGER]: { embeddable: IEmbeddable; filters: Filter[]; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/index.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/index.tsx index bfe853241ae1d..2598d66c4976f 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/index.tsx +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_hello_world_drilldown/index.tsx @@ -8,13 +8,10 @@ import React from 'react'; import { EuiFormRow, EuiFieldText } from '@elastic/eui'; import { reactToUiComponent } from '../../../../../src/plugins/kibana_react/public'; import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../plugins/ui_actions_enhanced/public'; -import { - RangeSelectTriggerContext, - ValueClickTriggerContext, -} from '../../../../../src/plugins/embeddable/public'; +import { ChartActionContext } from '../../../../../src/plugins/embeddable/public'; import { CollectConfigProps } from '../../../../../src/plugins/kibana_utils/public'; -export type ActionContext = RangeSelectTriggerContext | ValueClickTriggerContext; +export type ActionContext = ChartActionContext; export interface Config { name: string; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/types.ts b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/types.ts index 5dfc250a56d28..d8147827ed473 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/types.ts +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_discover_drilldown/types.ts @@ -4,13 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - RangeSelectTriggerContext, - ValueClickTriggerContext, -} from '../../../../../src/plugins/embeddable/public'; +import { ChartActionContext } from '../../../../../src/plugins/embeddable/public'; import { CollectConfigProps as CollectConfigPropsBase } from '../../../../../src/plugins/kibana_utils/public'; -export type ActionContext = RangeSelectTriggerContext | ValueClickTriggerContext; +export type ActionContext = ChartActionContext; export interface Config { /** diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx index 5e4ba54864461..037e017097e53 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx @@ -8,10 +8,7 @@ import React from 'react'; import { EuiFormRow, EuiSwitch, EuiFieldText, EuiCallOut, EuiSpacer } from '@elastic/eui'; import { reactToUiComponent } from '../../../../../src/plugins/kibana_react/public'; import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../plugins/ui_actions_enhanced/public'; -import { - RangeSelectTriggerContext, - ValueClickTriggerContext, -} from '../../../../../src/plugins/embeddable/public'; +import { ChartActionContext } from '../../../../../src/plugins/embeddable/public'; import { CollectConfigProps as CollectConfigPropsBase } from '../../../../../src/plugins/kibana_utils/public'; function isValidUrl(url: string) { @@ -23,7 +20,7 @@ function isValidUrl(url: string) { } } -export type ActionContext = RangeSelectTriggerContext | ValueClickTriggerContext; +export type ActionContext = ChartActionContext; export interface Config { url: string; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx index 6ce7dccd3a3ec..52b232afa9410 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx @@ -22,8 +22,8 @@ import { createDashboardUrlGenerator } from '../../../../../../../src/plugins/da import { UrlGeneratorsService } from '../../../../../../../src/plugins/share/public/url_generators'; import { VisualizeEmbeddableContract } from '../../../../../../../src/plugins/visualizations/public'; import { - RangeSelectTriggerContext, - ValueClickTriggerContext, + RangeSelectContext, + ValueClickContext, } from '../../../../../../../src/plugins/embeddable/public'; import { StartDependencies } from '../../../plugin'; import { SavedObjectLoader } from '../../../../../../../src/plugins/saved_objects/public'; @@ -136,8 +136,8 @@ describe('.execute() & getHref', () => { const context = ({ data: { ...(useRangeEvent - ? ({ range: {} } as RangeSelectTriggerContext['data']) - : ({ data: [] } as ValueClickTriggerContext['data'])), + ? ({ range: {} } as RangeSelectContext['data']) + : ({ data: [] } as ValueClickContext['data'])), timeFieldName: 'order_date', }, embeddable: { diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts index 1fbff0a7269e2..6be2e2a77269f 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts @@ -5,14 +5,14 @@ */ import { - ValueClickTriggerContext, - RangeSelectTriggerContext, + ValueClickContext, + RangeSelectContext, IEmbeddable, } from '../../../../../../../src/plugins/embeddable/public'; export type ActionContext = - | ValueClickTriggerContext - | RangeSelectTriggerContext; + | ValueClickContext + | RangeSelectContext; export interface Config { dashboardId?: string; diff --git a/x-pack/plugins/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts b/x-pack/plugins/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts index 620cabe652778..59359fb35f544 100644 --- a/x-pack/plugins/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts +++ b/x-pack/plugins/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts @@ -43,14 +43,13 @@ export abstract class AbstractExploreDataAction { if (!embeddable) return false; if (!this.params.start().plugins.discover.urlGenerator) return false; - if (!shared.isVisualizeEmbeddable(embeddable)) return false; - if (!shared.getIndexPattern(embeddable)) return false; + if (!shared.hasExactlyOneIndexPattern(embeddable)) return false; if (embeddable.getInput().viewMode !== ViewMode.VIEW) return false; return true; } public async execute(context: Context): Promise { - if (!shared.isVisualizeEmbeddable(context.embeddable)) return; + if (!shared.hasExactlyOneIndexPattern(context.embeddable)) return; const { core } = this.params.start(); const { appName, appPath } = await this.getUrl(context); @@ -63,7 +62,7 @@ export abstract class AbstractExploreDataAction { const { embeddable } = context; - if (!shared.isVisualizeEmbeddable(embeddable)) { + if (!shared.hasExactlyOneIndexPattern(embeddable)) { throw new Error(`Embeddable not supported for "${this.getDisplayName(context)}" action.`); } diff --git a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.test.ts b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.test.ts index a273f0d50e45e..0d22f0a36d418 100644 --- a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.test.ts +++ b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.test.ts @@ -10,8 +10,8 @@ import { coreMock } from '../../../../../../src/core/public/mocks'; import { UrlGeneratorContract } from '../../../../../../src/plugins/share/public'; import { EmbeddableStart, - RangeSelectTriggerContext, - ValueClickTriggerContext, + RangeSelectContext, + ValueClickContext, ChartActionContext, } from '../../../../../../src/plugins/embeddable/public'; import { i18n } from '@kbn/i18n'; @@ -85,8 +85,8 @@ const setup = ({ useRangeEvent = false }: { useRangeEvent?: boolean } = {}) => { const data: ChartActionContext['data'] = { ...(useRangeEvent - ? ({ range: {} } as RangeSelectTriggerContext['data']) - : ({ data: [] } as ValueClickTriggerContext['data'])), + ? ({ range: {} } as RangeSelectContext['data']) + : ({ data: [] } as ValueClickContext['data'])), timeFieldName: 'order_date', }; @@ -139,9 +139,16 @@ describe('"Explore underlying data" panel action', () => { expect(isCompatible).toBe(false); }); - test('returns false if embeddable is not Visualize embeddable', async () => { - const { action, embeddable, context } = setup(); - (embeddable as any).type = 'NOT_VISUALIZE_EMBEDDABLE'; + test('returns false if embeddable has more than one index pattern', async () => { + const { action, output, context } = setup(); + output.indexPatterns = [ + { + id: 'index-ptr-foo', + }, + { + id: 'index-ptr-bar', + }, + ]; const isCompatible = await action.isCompatible(context); diff --git a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.ts b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.ts index 359f14959c6a6..658a6bcb3cf4d 100644 --- a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.ts +++ b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.ts @@ -6,8 +6,8 @@ import { Action } from '../../../../../../src/plugins/ui_actions/public'; import { - ValueClickTriggerContext, - RangeSelectTriggerContext, + ValueClickContext, + RangeSelectContext, } from '../../../../../../src/plugins/embeddable/public'; import { DiscoverUrlGeneratorState } from '../../../../../../src/plugins/discover/public'; import { isTimeRange, isQuery, isFilters } from '../../../../../../src/plugins/data/public'; @@ -15,7 +15,7 @@ import { KibanaURL } from './kibana_url'; import * as shared from './shared'; import { AbstractExploreDataAction } from './abstract_explore_data_action'; -export type ExploreDataChartActionContext = ValueClickTriggerContext | RangeSelectTriggerContext; +export type ExploreDataChartActionContext = ValueClickContext | RangeSelectContext; export const ACTION_EXPLORE_DATA_CHART = 'ACTION_EXPLORE_DATA_CHART'; @@ -49,7 +49,7 @@ export class ExploreDataChartAction extends AbstractExploreDataAction { expect(isCompatible).toBe(false); }); - test('returns false if embeddable is not Visualize embeddable', async () => { - const { action, embeddable, context } = setup(); - (embeddable as any).type = 'NOT_VISUALIZE_EMBEDDABLE'; + test('returns false if embeddable has more than one index pattern', async () => { + const { action, output, context } = setup(); + output.indexPatterns = [ + { + id: 'index-ptr-foo', + }, + { + id: 'index-ptr-bar', + }, + ]; const isCompatible = await action.isCompatible(context); diff --git a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.ts b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.ts index 6691089f875d8..8b79211a914cc 100644 --- a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.ts +++ b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.ts @@ -38,7 +38,7 @@ export class ExploreDataContextMenuAction extends AbstractExploreDataAction } => { if (!output || typeof output !== 'object') return false; return Array.isArray((output as any).indexPatterns); }; -export const isVisualizeEmbeddable = ( - embeddable?: IEmbeddable -): embeddable is VisualizeEmbeddableContract => - embeddable && embeddable?.type === VISUALIZE_EMBEDDABLE_TYPE ? true : false; - -/** - * @returns Returns empty string if no index pattern ID found. - */ -export const getIndexPattern = (embeddable?: IEmbeddable): string => { - if (!embeddable) return ''; +export const getIndexPatterns = (embeddable?: IEmbeddable): string[] => { + if (!embeddable) return []; const output = embeddable.getOutput(); - if (isOutputWithIndexPatterns(output) && output.indexPatterns.length > 0) { - return output.indexPatterns[0].id; - } - - return ''; + return isOutputWithIndexPatterns(output) ? output.indexPatterns.map(({ id }) => id) : []; }; + +export const hasExactlyOneIndexPattern = (embeddable?: IEmbeddable): boolean => + getIndexPatterns(embeddable).length === 1; From e70bc819987f064c3f224adc479808b73a0dd66a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20St=C3=BCrmer?= Date: Wed, 1 Jul 2020 14:05:29 +0200 Subject: [PATCH 23/72] [Logs UI] Avoid CCS-incompatible index name resolution (#70179) This fixes #70048 by avoiding a CCS-incompatible ES API call when determining the existence of log indices. --- .../http_api/log_sources/get_log_source_status.ts | 2 +- .../public/containers/logs/log_source/log_source.ts | 6 ------ .../infra/public/pages/logs/stream/page_content.tsx | 4 ++-- .../infra/public/pages/logs/stream/page_providers.tsx | 4 ++-- .../plugins/infra/server/routes/log_sources/status.ts | 11 +++++------ 5 files changed, 10 insertions(+), 17 deletions(-) diff --git a/x-pack/plugins/infra/common/http_api/log_sources/get_log_source_status.ts b/x-pack/plugins/infra/common/http_api/log_sources/get_log_source_status.ts index ae872cee9aa56..b522d86987283 100644 --- a/x-pack/plugins/infra/common/http_api/log_sources/get_log_source_status.ts +++ b/x-pack/plugins/infra/common/http_api/log_sources/get_log_source_status.ts @@ -42,7 +42,7 @@ export type LogIndexField = rt.TypeOf; const logSourceStatusRT = rt.strict({ logIndexFields: rt.array(logIndexFieldRT), - logIndexNames: rt.array(rt.string), + logIndicesExist: rt.boolean, }); export type LogSourceStatus = rt.TypeOf; diff --git a/x-pack/plugins/infra/public/containers/logs/log_source/log_source.ts b/x-pack/plugins/infra/public/containers/logs/log_source/log_source.ts index 80aab6237518f..b45ea0a042f49 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_source/log_source.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_source/log_source.ts @@ -78,11 +78,6 @@ export const useLogSource = ({ [sourceId, fetch] ); - /* eslint-disable-next-line react-hooks/exhaustive-deps */ - const logIndicesExist = useMemo(() => (sourceStatus?.logIndexNames?.length ?? 0) > 0, [ - sourceStatus, - ]); - const derivedIndexPattern = useMemo( () => ({ fields: sourceStatus?.logIndexFields ?? [], @@ -160,7 +155,6 @@ export const useLogSource = ({ loadSourceFailureMessage, loadSourceConfiguration, loadSourceStatus, - logIndicesExist, sourceConfiguration, sourceId, sourceStatus, diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page_content.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page_content.tsx index 40ac5c74a6836..b2a4ce65ab2b6 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page_content.tsx @@ -18,14 +18,14 @@ export const StreamPageContent: React.FunctionComponent = () => { isUninitialized, loadSource, loadSourceFailureMessage, - logIndicesExist, + sourceStatus, } = useLogSourceContext(); if (isLoading || isUninitialized) { return ; } else if (hasFailedLoadingSource) { return ; - } else if (logIndicesExist) { + } else if (sourceStatus?.logIndicesExist) { return ; } else { return ; diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page_providers.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page_providers.tsx index 428a7d3fdfe4b..82c21f663bc96 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page_providers.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page_providers.tsx @@ -104,10 +104,10 @@ const LogHighlightsStateProvider: React.FC = ({ children }) => { }; export const LogsPageProviders: React.FunctionComponent = ({ children }) => { - const { logIndicesExist } = useLogSourceContext(); + const { sourceStatus } = useLogSourceContext(); // The providers assume the source is loaded, so short-circuit them otherwise - if (!logIndicesExist) { + if (!sourceStatus?.logIndicesExist) { return <>{children}; } diff --git a/x-pack/plugins/infra/server/routes/log_sources/status.ts b/x-pack/plugins/infra/server/routes/log_sources/status.ts index cdd053d2bb10a..4cd85ecfe23c1 100644 --- a/x-pack/plugins/infra/server/routes/log_sources/status.ts +++ b/x-pack/plugins/infra/server/routes/log_sources/status.ts @@ -31,17 +31,16 @@ export const initLogSourceStatusRoutes = ({ const { sourceId } = request.params; try { - const logIndexNames = await sourceStatus.getLogIndexNames(requestContext, sourceId); - const logIndexFields = - logIndexNames.length > 0 - ? await fields.getFields(requestContext, sourceId, InfraIndexType.LOGS) - : []; + const logIndicesExist = await sourceStatus.hasLogIndices(requestContext, sourceId); + const logIndexFields = logIndicesExist + ? await fields.getFields(requestContext, sourceId, InfraIndexType.LOGS) + : []; return response.ok({ body: getLogSourceStatusSuccessResponsePayloadRT.encode({ data: { + logIndicesExist, logIndexFields, - logIndexNames, }, }), }); From 518e88cf287ee8fdfefc51ae5040765371d8065b Mon Sep 17 00:00:00 2001 From: Shahzad Date: Wed, 1 Jul 2020 14:20:02 +0200 Subject: [PATCH 24/72] update (#70424) --- x-pack/plugins/uptime/public/apps/uptime_overview_fetcher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/uptime/public/apps/uptime_overview_fetcher.ts b/x-pack/plugins/uptime/public/apps/uptime_overview_fetcher.ts index 8467714e9661e..bede391537ec5 100644 --- a/x-pack/plugins/uptime/public/apps/uptime_overview_fetcher.ts +++ b/x-pack/plugins/uptime/public/apps/uptime_overview_fetcher.ts @@ -5,7 +5,7 @@ */ import { fetchPingHistogram, fetchSnapshotCount } from '../state/api'; -import { UptimeFetchDataResponse } from '../../../observability/public/typings/fetch_data_response'; +import { UptimeFetchDataResponse } from '../../../observability/public'; export async function fetchUptimeOverviewData({ startTime, From 1d1051b1e988e80599f0375d8efad39113537a57 Mon Sep 17 00:00:00 2001 From: Jason Rhodes Date: Wed, 1 Jul 2020 10:04:21 -0400 Subject: [PATCH 25/72] Changes observability plugin codeowner (#70439) The observability plugin is now code-owned by a new observability-ui GH team to avoid pinging 4 separate teams for mandatory reviews when that plugin changes. --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a94180e60e05e..bec0a0a33bad2 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -84,7 +84,7 @@ /x-pack/plugins/infra/ @elastic/logs-metrics-ui /x-pack/plugins/ingest_manager/ @elastic/ingest-management /x-pack/legacy/plugins/ingest_manager/ @elastic/ingest-management -/x-pack/plugins/observability/ @elastic/logs-metrics-ui @elastic/apm-ui @elastic/uptime @elastic/ingest-management +/x-pack/plugins/observability/ @elastic/observability-ui /x-pack/legacy/plugins/monitoring/ @elastic/stack-monitoring-ui /x-pack/plugins/monitoring/ @elastic/stack-monitoring-ui /x-pack/plugins/uptime @elastic/uptime From 7ed1fe05d7935689871939ff68acd34fe5c02e3f Mon Sep 17 00:00:00 2001 From: Josh Dover Date: Wed, 1 Jul 2020 08:05:59 -0600 Subject: [PATCH 26/72] Move logger configuration integration test to jest (#70378) --- .../logging/integration_tests/logging.test.ts | 161 ++++++++++++++++++ .../plugins/core_logging/kibana.json | 7 - .../plugins/core_logging/server/.gitignore | 1 - .../plugins/core_logging/server/index.ts | 23 --- .../plugins/core_logging/server/plugin.ts | 118 ------------- .../plugins/core_logging/tsconfig.json | 13 -- .../test_suites/core_plugins/index.ts | 1 - .../test_suites/core_plugins/logging.ts | 146 ---------------- 8 files changed, 161 insertions(+), 309 deletions(-) delete mode 100644 test/plugin_functional/plugins/core_logging/kibana.json delete mode 100644 test/plugin_functional/plugins/core_logging/server/.gitignore delete mode 100644 test/plugin_functional/plugins/core_logging/server/index.ts delete mode 100644 test/plugin_functional/plugins/core_logging/server/plugin.ts delete mode 100644 test/plugin_functional/plugins/core_logging/tsconfig.json delete mode 100644 test/plugin_functional/test_suites/core_plugins/logging.ts diff --git a/src/core/server/logging/integration_tests/logging.test.ts b/src/core/server/logging/integration_tests/logging.test.ts index b88f5ba2c2b60..a80939a25ae65 100644 --- a/src/core/server/logging/integration_tests/logging.test.ts +++ b/src/core/server/logging/integration_tests/logging.test.ts @@ -18,6 +18,9 @@ */ import * as kbnTestServer from '../../../../test_utils/kbn_server'; +import { InternalCoreSetup } from '../../internal_types'; +import { LoggerContextConfigInput } from '../logging_config'; +import { Subject } from 'rxjs'; function createRoot() { return kbnTestServer.createRoot({ @@ -111,4 +114,162 @@ describe('logging service', () => { expect(mockConsoleLog).toHaveBeenCalledTimes(0); }); }); + + describe('custom context configuration', () => { + const CUSTOM_LOGGING_CONFIG: LoggerContextConfigInput = { + appenders: { + customJsonConsole: { + kind: 'console', + layout: { + kind: 'json', + }, + }, + customPatternConsole: { + kind: 'console', + layout: { + kind: 'pattern', + pattern: 'CUSTOM - PATTERN [%logger][%level] %message', + }, + }, + }, + + loggers: [ + { context: 'debug_json', appenders: ['customJsonConsole'], level: 'debug' }, + { context: 'debug_pattern', appenders: ['customPatternConsole'], level: 'debug' }, + { context: 'info_json', appenders: ['customJsonConsole'], level: 'info' }, + { context: 'info_pattern', appenders: ['customPatternConsole'], level: 'info' }, + { + context: 'all', + appenders: ['customJsonConsole', 'customPatternConsole'], + level: 'debug', + }, + ], + }; + + let root: ReturnType; + let setup: InternalCoreSetup; + let mockConsoleLog: jest.SpyInstance; + const loggingConfig$ = new Subject(); + const setContextConfig = (enable: boolean) => + enable ? loggingConfig$.next(CUSTOM_LOGGING_CONFIG) : loggingConfig$.next({}); + beforeAll(async () => { + mockConsoleLog = jest.spyOn(global.console, 'log'); + root = kbnTestServer.createRoot(); + + setup = await root.setup(); + setup.logging.configure(['plugins', 'myplugin'], loggingConfig$); + }, 30000); + + beforeEach(() => { + mockConsoleLog.mockClear(); + }); + + afterAll(async () => { + mockConsoleLog.mockRestore(); + await root.shutdown(); + }); + + it('does not write to custom appenders when not configured', async () => { + const logger = root.logger.get('plugins.myplugin.debug_pattern'); + setContextConfig(false); + logger.info('log1'); + setContextConfig(true); + logger.debug('log2'); + logger.info('log3'); + setContextConfig(false); + logger.info('log4'); + expect(mockConsoleLog).toHaveBeenCalledTimes(2); + expect(mockConsoleLog).toHaveBeenCalledWith( + 'CUSTOM - PATTERN [plugins.myplugin.debug_pattern][DEBUG] log2' + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + 'CUSTOM - PATTERN [plugins.myplugin.debug_pattern][INFO ] log3' + ); + }); + + it('writes debug_json context to custom JSON appender', async () => { + setContextConfig(true); + const logger = root.logger.get('plugins.myplugin.debug_json'); + logger.debug('log1'); + logger.info('log2'); + expect(mockConsoleLog).toHaveBeenCalledTimes(2); + + const [firstCall, secondCall] = mockConsoleLog.mock.calls.map(([jsonString]) => + JSON.parse(jsonString) + ); + expect(firstCall).toMatchObject({ + level: 'DEBUG', + context: 'plugins.myplugin.debug_json', + message: 'log1', + }); + expect(secondCall).toMatchObject({ + level: 'INFO', + context: 'plugins.myplugin.debug_json', + message: 'log2', + }); + }); + + it('writes info_json context to custom JSON appender', async () => { + setContextConfig(true); + const logger = root.logger.get('plugins.myplugin.info_json'); + logger.debug('i should not be logged!'); + logger.info('log2'); + + expect(mockConsoleLog).toHaveBeenCalledTimes(1); + expect(JSON.parse(mockConsoleLog.mock.calls[0][0])).toMatchObject({ + level: 'INFO', + context: 'plugins.myplugin.info_json', + message: 'log2', + }); + }); + + it('writes debug_pattern context to custom pattern appender', async () => { + setContextConfig(true); + const logger = root.logger.get('plugins.myplugin.debug_pattern'); + logger.debug('log1'); + logger.info('log2'); + + expect(mockConsoleLog).toHaveBeenCalledTimes(2); + expect(mockConsoleLog).toHaveBeenCalledWith( + 'CUSTOM - PATTERN [plugins.myplugin.debug_pattern][DEBUG] log1' + ); + expect(mockConsoleLog).toHaveBeenCalledWith( + 'CUSTOM - PATTERN [plugins.myplugin.debug_pattern][INFO ] log2' + ); + }); + + it('writes info_pattern context to custom pattern appender', async () => { + setContextConfig(true); + const logger = root.logger.get('plugins.myplugin.info_pattern'); + logger.debug('i should not be logged!'); + logger.info('log2'); + expect(mockConsoleLog).toHaveBeenCalledTimes(1); + expect(mockConsoleLog).toHaveBeenCalledWith( + 'CUSTOM - PATTERN [plugins.myplugin.info_pattern][INFO ] log2' + ); + }); + + it('writes all context to both appenders', async () => { + setContextConfig(true); + const logger = root.logger.get('plugins.myplugin.all'); + logger.debug('log1'); + logger.info('log2'); + + expect(mockConsoleLog).toHaveBeenCalledTimes(4); + const logs = mockConsoleLog.mock.calls.map(([jsonString]) => jsonString); + + expect(JSON.parse(logs[0])).toMatchObject({ + level: 'DEBUG', + context: 'plugins.myplugin.all', + message: 'log1', + }); + expect(logs[1]).toEqual('CUSTOM - PATTERN [plugins.myplugin.all][DEBUG] log1'); + expect(JSON.parse(logs[2])).toMatchObject({ + level: 'INFO', + context: 'plugins.myplugin.all', + message: 'log2', + }); + expect(logs[3]).toEqual('CUSTOM - PATTERN [plugins.myplugin.all][INFO ] log2'); + }); + }); }); diff --git a/test/plugin_functional/plugins/core_logging/kibana.json b/test/plugin_functional/plugins/core_logging/kibana.json deleted file mode 100644 index 3289c2c627b9a..0000000000000 --- a/test/plugin_functional/plugins/core_logging/kibana.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "id": "core_logging", - "version": "0.0.1", - "kibanaVersion": "kibana", - "configPath": ["core_logging"], - "server": true -} diff --git a/test/plugin_functional/plugins/core_logging/server/.gitignore b/test/plugin_functional/plugins/core_logging/server/.gitignore deleted file mode 100644 index 9a3d281179193..0000000000000 --- a/test/plugin_functional/plugins/core_logging/server/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/*debug.log diff --git a/test/plugin_functional/plugins/core_logging/server/index.ts b/test/plugin_functional/plugins/core_logging/server/index.ts deleted file mode 100644 index ca1d9da95b495..0000000000000 --- a/test/plugin_functional/plugins/core_logging/server/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import type { PluginInitializerContext } from '../../../../../src/core/server'; -import { CoreLoggingPlugin } from './plugin'; - -export const plugin = (init: PluginInitializerContext) => new CoreLoggingPlugin(init); diff --git a/test/plugin_functional/plugins/core_logging/server/plugin.ts b/test/plugin_functional/plugins/core_logging/server/plugin.ts deleted file mode 100644 index a7820a0f67525..0000000000000 --- a/test/plugin_functional/plugins/core_logging/server/plugin.ts +++ /dev/null @@ -1,118 +0,0 @@ -/* - * 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 { resolve } from 'path'; -import { Subject } from 'rxjs'; -import { schema } from '@kbn/config-schema'; -import type { - PluginInitializerContext, - Plugin, - CoreSetup, - LoggerContextConfigInput, - Logger, -} from '../../../../../src/core/server'; - -const CUSTOM_LOGGING_CONFIG: LoggerContextConfigInput = { - appenders: { - customJsonFile: { - kind: 'file', - path: resolve(__dirname, 'json_debug.log'), // use 'debug.log' suffix so file watcher does not restart server - layout: { - kind: 'json', - }, - }, - customPatternFile: { - kind: 'file', - path: resolve(__dirname, 'pattern_debug.log'), - layout: { - kind: 'pattern', - pattern: 'CUSTOM - PATTERN [%logger][%level] %message', - }, - }, - }, - - loggers: [ - { context: 'debug_json', appenders: ['customJsonFile'], level: 'debug' }, - { context: 'debug_pattern', appenders: ['customPatternFile'], level: 'debug' }, - { context: 'info_json', appenders: ['customJsonFile'], level: 'info' }, - { context: 'info_pattern', appenders: ['customPatternFile'], level: 'info' }, - { context: 'all', appenders: ['customJsonFile', 'customPatternFile'], level: 'debug' }, - ], -}; - -export class CoreLoggingPlugin implements Plugin { - private readonly logger: Logger; - - constructor(init: PluginInitializerContext) { - this.logger = init.logger.get(); - } - - public setup(core: CoreSetup) { - const loggingConfig$ = new Subject(); - core.logging.configure(loggingConfig$); - - const router = core.http.createRouter(); - - // Expose a route that allows our test suite to write logs as this plugin - router.post( - { - path: '/internal/core-logging/write-log', - validate: { - body: schema.object({ - level: schema.oneOf([schema.literal('debug'), schema.literal('info')]), - message: schema.string(), - context: schema.arrayOf(schema.string()), - }), - }, - }, - (ctx, req, res) => { - const { level, message, context } = req.body; - const logger = this.logger.get(...context); - - if (level === 'debug') { - logger.debug(message); - } else if (level === 'info') { - logger.info(message); - } - - return res.ok(); - } - ); - - // Expose a route to toggle on and off the custom config - router.post( - { - path: '/internal/core-logging/update-config', - validate: { body: schema.object({ enableCustomConfig: schema.boolean() }) }, - }, - (ctx, req, res) => { - if (req.body.enableCustomConfig) { - loggingConfig$.next(CUSTOM_LOGGING_CONFIG); - } else { - loggingConfig$.next({}); - } - - return res.ok({ body: `Updated config: ${req.body.enableCustomConfig}` }); - } - ); - } - - public start() {} - public stop() {} -} diff --git a/test/plugin_functional/plugins/core_logging/tsconfig.json b/test/plugin_functional/plugins/core_logging/tsconfig.json deleted file mode 100644 index 7389eb6ce159b..0000000000000 --- a/test/plugin_functional/plugins/core_logging/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": "../../../../tsconfig.json", - "compilerOptions": { - "outDir": "./target", - "skipLibCheck": true - }, - "include": [ - "index.ts", - "server/**/*.ts", - "../../../../typings/**/*", - ], - "exclude": [] -} diff --git a/test/plugin_functional/test_suites/core_plugins/index.ts b/test/plugin_functional/test_suites/core_plugins/index.ts index 8f7c2267d34b4..8f54ec6c0f4cd 100644 --- a/test/plugin_functional/test_suites/core_plugins/index.ts +++ b/test/plugin_functional/test_suites/core_plugins/index.ts @@ -30,6 +30,5 @@ export default function ({ loadTestFile }: PluginFunctionalProviderContext) { loadTestFile(require.resolve('./application_leave_confirm')); loadTestFile(require.resolve('./application_status')); loadTestFile(require.resolve('./rendering')); - loadTestFile(require.resolve('./logging')); }); } diff --git a/test/plugin_functional/test_suites/core_plugins/logging.ts b/test/plugin_functional/test_suites/core_plugins/logging.ts deleted file mode 100644 index 9fdaa6ce834ea..0000000000000 --- a/test/plugin_functional/test_suites/core_plugins/logging.ts +++ /dev/null @@ -1,146 +0,0 @@ -/* - * 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 { resolve } from 'path'; -import fs from 'fs'; -import expect from '@kbn/expect'; -import { PluginFunctionalProviderContext } from '../../services'; - -// eslint-disable-next-line import/no-default-export -export default function ({ getService }: PluginFunctionalProviderContext) { - const supertest = getService('supertest'); - - describe('plugin logging', function describeIndexTests() { - const LOG_FILE_DIRECTORY = resolve(__dirname, '..', '..', 'plugins', 'core_logging', 'server'); - const JSON_FILE_PATH = resolve(LOG_FILE_DIRECTORY, 'json_debug.log'); - const PATTERN_FILE_PATH = resolve(LOG_FILE_DIRECTORY, 'pattern_debug.log'); - - beforeEach(async () => { - // "touch" each file to ensure it exists and is empty before each test - await fs.promises.writeFile(JSON_FILE_PATH, ''); - await fs.promises.writeFile(PATTERN_FILE_PATH, ''); - }); - - async function readLines(path: string) { - const contents = await fs.promises.readFile(path, { encoding: 'utf8' }); - return contents.trim().split('\n'); - } - - async function readJsonLines() { - return (await readLines(JSON_FILE_PATH)) - .filter((line) => line.length > 0) - .map((line) => JSON.parse(line)) - .map(({ level, message, context }) => ({ level, message, context })); - } - - function writeLog(context: string[], level: string, message: string) { - return supertest - .post('/internal/core-logging/write-log') - .set('kbn-xsrf', 'anything') - .send({ context, level, message }) - .expect(200); - } - - function setContextConfig(enable: boolean) { - return supertest - .post('/internal/core-logging/update-config') - .set('kbn-xsrf', 'anything') - .send({ enableCustomConfig: enable }) - .expect(200); - } - - it('does not write to custom appenders when not configured', async () => { - await setContextConfig(false); - await writeLog(['debug_json'], 'info', 'i go to the default appender!'); - expect(await readJsonLines()).to.eql([]); - }); - - it('writes debug_json context to custom JSON appender', async () => { - await setContextConfig(true); - await writeLog(['debug_json'], 'debug', 'log1'); - await writeLog(['debug_json'], 'info', 'log2'); - expect(await readJsonLines()).to.eql([ - { - level: 'DEBUG', - context: 'plugins.core_logging.debug_json', - message: 'log1', - }, - { - level: 'INFO', - context: 'plugins.core_logging.debug_json', - message: 'log2', - }, - ]); - }); - - it('writes info_json context to custom JSON appender', async () => { - await setContextConfig(true); - await writeLog(['info_json'], 'debug', 'i should not be logged!'); - await writeLog(['info_json'], 'info', 'log2'); - expect(await readJsonLines()).to.eql([ - { - level: 'INFO', - context: 'plugins.core_logging.info_json', - message: 'log2', - }, - ]); - }); - - it('writes debug_pattern context to custom pattern appender', async () => { - await setContextConfig(true); - await writeLog(['debug_pattern'], 'debug', 'log1'); - await writeLog(['debug_pattern'], 'info', 'log2'); - expect(await readLines(PATTERN_FILE_PATH)).to.eql([ - 'CUSTOM - PATTERN [plugins.core_logging.debug_pattern][DEBUG] log1', - 'CUSTOM - PATTERN [plugins.core_logging.debug_pattern][INFO ] log2', - ]); - }); - - it('writes info_pattern context to custom pattern appender', async () => { - await setContextConfig(true); - await writeLog(['info_pattern'], 'debug', 'i should not be logged!'); - await writeLog(['info_pattern'], 'info', 'log2'); - expect(await readLines(PATTERN_FILE_PATH)).to.eql([ - 'CUSTOM - PATTERN [plugins.core_logging.info_pattern][INFO ] log2', - ]); - }); - - it('writes all context to both appenders', async () => { - await setContextConfig(true); - await writeLog(['all'], 'debug', 'log1'); - await writeLog(['all'], 'info', 'log2'); - expect(await readJsonLines()).to.eql([ - { - level: 'DEBUG', - context: 'plugins.core_logging.all', - message: 'log1', - }, - { - level: 'INFO', - context: 'plugins.core_logging.all', - message: 'log2', - }, - ]); - expect(await readLines(PATTERN_FILE_PATH)).to.eql([ - 'CUSTOM - PATTERN [plugins.core_logging.all][DEBUG] log1', - 'CUSTOM - PATTERN [plugins.core_logging.all][INFO ] log2', - ]); - }); - }); -} From 2212beba6847051bd42f759ec2530d7c9521e64d Mon Sep 17 00:00:00 2001 From: Ahmad Bamieh Date: Wed, 1 Jul 2020 18:48:41 +0300 Subject: [PATCH 27/72] [i18n] integrate new translations + new i18n check #70193 (#70423) Co-authored-by: Elastic Machine --- src/dev/i18n/integrate_locale_files.test.ts | 3 +- src/dev/i18n/integrate_locale_files.ts | 21 +- src/dev/i18n/tasks/check_compatibility.ts | 4 +- src/dev/i18n/utils.js | 22 + src/dev/run_i18n_check.ts | 5 +- src/dev/run_i18n_integrate.ts | 5 +- .../translations/translations/ja-JP.json | 1963 ++-------------- .../translations/translations/zh-CN.json | 1970 ++--------------- 8 files changed, 516 insertions(+), 3477 deletions(-) diff --git a/src/dev/i18n/integrate_locale_files.test.ts b/src/dev/i18n/integrate_locale_files.test.ts index 7ff1d87f1bc55..3bd3dc61c044f 100644 --- a/src/dev/i18n/integrate_locale_files.test.ts +++ b/src/dev/i18n/integrate_locale_files.test.ts @@ -21,7 +21,7 @@ import { mockMakeDirAsync, mockWriteFileAsync } from './integrate_locale_files.t import path from 'path'; import { integrateLocaleFiles, verifyMessages } from './integrate_locale_files'; -// @ts-ignore +// @ts-expect-error import { normalizePath } from './utils'; const localePath = path.resolve(__dirname, '__fixtures__', 'integrate_locale_files', 'fr.json'); @@ -36,6 +36,7 @@ const defaultIntegrateOptions = { sourceFileName: localePath, dryRun: false, ignoreIncompatible: false, + ignoreMalformed: false, ignoreMissing: false, ignoreUnused: false, config: { diff --git a/src/dev/i18n/integrate_locale_files.ts b/src/dev/i18n/integrate_locale_files.ts index d8ccccca15559..f9cd6dd1971c7 100644 --- a/src/dev/i18n/integrate_locale_files.ts +++ b/src/dev/i18n/integrate_locale_files.ts @@ -31,7 +31,8 @@ import { normalizePath, readFileAsync, writeFileAsync, - // @ts-ignore + verifyICUMessage, + // @ts-expect-error } from './utils'; import { I18nConfig } from './config'; @@ -41,6 +42,7 @@ export interface IntegrateOptions { sourceFileName: string; targetFileName?: string; dryRun: boolean; + ignoreMalformed: boolean; ignoreIncompatible: boolean; ignoreUnused: boolean; ignoreMissing: boolean; @@ -105,6 +107,23 @@ export function verifyMessages( } } + for (const messageId of localizedMessagesIds) { + const defaultMessage = defaultMessagesMap.get(messageId); + if (defaultMessage) { + try { + const message = localizedMessagesMap.get(messageId)!; + verifyICUMessage(message); + } catch (err) { + if (options.ignoreMalformed) { + localizedMessagesMap.delete(messageId); + options.log.warning(`Malformed translation ignored (${messageId}): ${err}`); + } else { + errorMessage += `\nMalformed translation (${messageId}): ${err}\n`; + } + } + } + } + if (errorMessage) { throw createFailError(errorMessage); } diff --git a/src/dev/i18n/tasks/check_compatibility.ts b/src/dev/i18n/tasks/check_compatibility.ts index 5900bf5aff252..afaf3cd875a8a 100644 --- a/src/dev/i18n/tasks/check_compatibility.ts +++ b/src/dev/i18n/tasks/check_compatibility.ts @@ -22,13 +22,14 @@ import { integrateLocaleFiles, I18nConfig } from '..'; export interface I18nFlags { fix: boolean; + ignoreMalformed: boolean; ignoreIncompatible: boolean; ignoreUnused: boolean; ignoreMissing: boolean; } export function checkCompatibility(config: I18nConfig, flags: I18nFlags, log: ToolingLog) { - const { fix, ignoreIncompatible, ignoreUnused, ignoreMissing } = flags; + const { fix, ignoreIncompatible, ignoreUnused, ignoreMalformed, ignoreMissing } = flags; return config.translations.map((translationsPath) => ({ task: async ({ messages }: { messages: Map }) => { // If `fix` is set we should try apply all possible fixes and override translations file. @@ -37,6 +38,7 @@ export function checkCompatibility(config: I18nConfig, flags: I18nFlags, log: To ignoreIncompatible: fix || ignoreIncompatible, ignoreUnused: fix || ignoreUnused, ignoreMissing: fix || ignoreMissing, + ignoreMalformed: fix || ignoreMalformed, sourceFileName: translationsPath, targetFileName: fix ? translationsPath : undefined, config, diff --git a/src/dev/i18n/utils.js b/src/dev/i18n/utils.js index 1d1c3118e0852..11a002fdbf4a8 100644 --- a/src/dev/i18n/utils.js +++ b/src/dev/i18n/utils.js @@ -208,6 +208,28 @@ export function checkValuesProperty(prefixedValuesKeys, defaultMessage, messageI } } +/** + * Verifies valid ICU message. + * @param message ICU message. + * @param messageId ICU message id + * @returns {undefined} + */ +export function verifyICUMessage(message) { + try { + parser.parse(message); + } catch (error) { + if (error.name === 'SyntaxError') { + const errorWithContext = createParserErrorMessage(message, { + loc: { + line: error.location.start.line, + column: error.location.start.column - 1, + }, + message: error.message, + }); + throw errorWithContext; + } + } +} /** * Extracts value references from the ICU message. * @param message ICU message. diff --git a/src/dev/run_i18n_check.ts b/src/dev/run_i18n_check.ts index 97ea988b1de3a..70eeedac2b8b6 100644 --- a/src/dev/run_i18n_check.ts +++ b/src/dev/run_i18n_check.ts @@ -36,6 +36,7 @@ run( async ({ flags: { 'ignore-incompatible': ignoreIncompatible, + 'ignore-malformed': ignoreMalformed, 'ignore-missing': ignoreMissing, 'ignore-unused': ignoreUnused, 'include-config': includeConfig, @@ -48,12 +49,13 @@ run( fix && (ignoreIncompatible !== undefined || ignoreUnused !== undefined || + ignoreMalformed !== undefined || ignoreMissing !== undefined) ) { throw createFailError( `${chalk.white.bgRed( ' I18N ERROR ' - )} none of the --ignore-incompatible, --ignore-unused or --ignore-missing is allowed when --fix is set.` + )} none of the --ignore-incompatible, --ignore-malformed, --ignore-unused or --ignore-missing is allowed when --fix is set.` ); } @@ -99,6 +101,7 @@ run( checkCompatibility( config, { + ignoreMalformed: !!ignoreMalformed, ignoreIncompatible: !!ignoreIncompatible, ignoreUnused: !!ignoreUnused, ignoreMissing: !!ignoreMissing, diff --git a/src/dev/run_i18n_integrate.ts b/src/dev/run_i18n_integrate.ts index 23d66fae9f26e..25c3ea32783aa 100644 --- a/src/dev/run_i18n_integrate.ts +++ b/src/dev/run_i18n_integrate.ts @@ -31,6 +31,7 @@ run( 'ignore-incompatible': ignoreIncompatible = false, 'ignore-missing': ignoreMissing = false, 'ignore-unused': ignoreUnused = false, + 'ignore-malformed': ignoreMalformed = false, 'include-config': includeConfig, path, source, @@ -66,12 +67,13 @@ run( typeof ignoreIncompatible !== 'boolean' || typeof ignoreUnused !== 'boolean' || typeof ignoreMissing !== 'boolean' || + typeof ignoreMalformed !== 'boolean' || typeof dryRun !== 'boolean' ) { throw createFailError( `${chalk.white.bgRed( ' I18N ERROR ' - )} --ignore-incompatible, --ignore-unused, --ignore-missing, and --dry-run can't have values` + )} --ignore-incompatible, --ignore-unused, --ignore-malformed, --ignore-missing, and --dry-run can't have values` ); } @@ -97,6 +99,7 @@ run( ignoreIncompatible, ignoreUnused, ignoreMissing, + ignoreMalformed, config, log, }); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 0b466f351d7db..b2533f1bc8c19 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -87,7 +87,6 @@ "advancedSettings.categoryNames.notificationsLabel": "通知", "advancedSettings.categoryNames.reportingLabel": "レポート", "advancedSettings.categoryNames.searchLabel": "検索", - "advancedSettings.categoryNames.securitySolutionLabel": "Security Solution", "advancedSettings.categoryNames.timelionLabel": "Timelion", "advancedSettings.categoryNames.visualizationsLabel": "可視化", "advancedSettings.categorySearchLabel": "カテゴリー", @@ -124,6 +123,119 @@ "advancedSettings.searchBar.unableToParseQueryErrorMessage": "クエリをパースできません", "advancedSettings.searchBarAriaLabel": "高度な設定を検索", "advancedSettings.voiceAnnouncement.searchResultScreenReaderMessage": "{query} を検索しました。{sectionLenght, plural, one {# セクション} other {# セクション}}に{optionLenght, plural, one {# オプション} other {# オプション}}があります。", + "apmOss.tutorial.apmAgents.statusCheck.btnLabel": "エージェントステータスを確認", + "apmOss.tutorial.apmAgents.statusCheck.errorMessage": "エージェントからまだデータを受け取っていません", + "apmOss.tutorial.apmAgents.statusCheck.successMessage": "1 つまたは複数のエージェントからデータを受け取りました", + "apmOss.tutorial.apmAgents.statusCheck.text": "アプリケーションが実行されていてエージェントがデータを送信していることを確認してください。", + "apmOss.tutorial.apmAgents.statusCheck.title": "エージェントステータス", + "apmOss.tutorial.apmAgents.title": "APM エージェント", + "apmOss.tutorial.apmServer.callOut.message": "ご使用の APM Server を 7.0 以上に更新してあることを確認してください。 Kibana の管理セクションにある移行アシスタントで 6.x データを移行することもできます。", + "apmOss.tutorial.apmServer.callOut.title": "重要:7.0 以上に更新中", + "apmOss.tutorial.apmServer.statusCheck.btnLabel": "APM Server ステータスを確認", + "apmOss.tutorial.apmServer.statusCheck.errorMessage": "APM Server が検出されました。7.0 以上に更新され、動作中であることを確認してください。", + "apmOss.tutorial.apmServer.statusCheck.successMessage": "APM Server が正しくセットアップされました", + "apmOss.tutorial.apmServer.statusCheck.text": "APM エージェントの導入を開始する前に、APM Server が動作していることを確認してください。", + "apmOss.tutorial.apmServer.statusCheck.title": "APM Server ステータス", + "apmOss.tutorial.apmServer.title": "APM Server", + "apmOss.tutorial.djangoClient.configure.commands.addAgentComment": "インストールされたアプリにエージェントを追加します", + "apmOss.tutorial.djangoClient.configure.commands.addTracingMiddlewareComment": "パフォーマンスメトリックを送信するには、追跡ミドルウェアを追加します。", + "apmOss.tutorial.djangoClient.configure.commands.allowedCharactersComment": "a-z、A-Z、0-9、-、_、スペース", + "apmOss.tutorial.djangoClient.configure.commands.setCustomApmServerUrlComment": "カスタム APM Server URL (デフォルト: {defaultApmServerUrl})", + "apmOss.tutorial.djangoClient.configure.commands.setRequiredServiceNameComment": "必要なサーバー名を設定します。使用できる文字:", + "apmOss.tutorial.djangoClient.configure.commands.useIfApmServerRequiresTokenComment": "APM Server にトークンが必要な場合に使います", + "apmOss.tutorial.djangoClient.configure.textPost": "高度な用途に関しては [ドキュメンテーション]({documentationLink}) をご覧ください。", + "apmOss.tutorial.djangoClient.configure.textPre": "エージェントとは、アプリケーションプロセス内で実行されるライブラリです。APM サービスは「SERVICE_NAME」に基づいてプログラムで作成されます。", + "apmOss.tutorial.djangoClient.configure.title": "エージェントの構成", + "apmOss.tutorial.djangoClient.install.textPre": "Python 用の APM エージェントを依存関係としてインストールします。", + "apmOss.tutorial.djangoClient.install.title": "APM エージェントのインストール", + "apmOss.tutorial.dotNetClient.configureAgent.textPost": "エージェントに「IConfiguration」インスタンスが渡されていない場合、(例: 非 ASP.NET Core アプリケーションの場合)、エージェントを環境変数で構成することもできます。\n 高度な用途に関しては [ドキュメンテーション]({documentationLink}) をご覧ください。", + "apmOss.tutorial.dotNetClient.configureAgent.title": "appsettings.json ファイルの例:", + "apmOss.tutorial.dotNetClient.configureApplication.textPost": "「IConfiguration」インスタンスを渡すのは任意であり、これにより、エージェントはこの「IConfiguration」インスタンス (例: 「appsettings.json」ファイル) から構成を読み込みます。", + "apmOss.tutorial.dotNetClient.configureApplication.textPre": "「Elastic.Apm.NetCoreAll」パッケージの ASP.NET Core の場合、「Startup.cs」ファイル内の「Configure」メソドの「UseElasticApm」メソドを呼び出します。", + "apmOss.tutorial.dotNetClient.configureApplication.title": "エージェントをアプリケーションに追加", + "apmOss.tutorial.dotNetClient.download.textPre": "[NuGet]({allNuGetPackagesLink}) から .NET アプリケーションにエージェントパッケージを追加してください。用途の異なる複数の NuGet パッケージがあります。\n\nEntity Framework Core の ASP.NET Core アプリケーションの場合は、[Elastic.Apm.NetCoreAll]({netCoreAllApmPackageLink}) パッケージをダウンロードしてください。このパッケージは、自動的にすべてのエージェントコンポーネントをアプリケーションに追加します。\n\n 依存性を最低限に抑えたい場合、ASP.NET Core の監視のみに [Elastic.Apm.AspNetCore]({aspNetCorePackageLink}) パッケージ、または Entity Framework Core の監視のみに [Elastic.Apm.EfCore]({efCorePackageLink}) パッケージを使用することができます。\n\n 手動インストルメンテーションのみにパブリック Agent API を使用する場合は、[Elastic.Apm]({elasticApmPackageLink}) パッケージを使用してください。", + "apmOss.tutorial.dotNetClient.download.title": "APM エージェントのダウンロード", + "apmOss.tutorial.downloadServer.title": "APM Server をダウンロードして展開します", + "apmOss.tutorial.downloadServerRpm": "32 ビットパッケージをお探しですか?[ダウンロードページ]({downloadPageLink}) をご覧ください。", + "apmOss.tutorial.downloadServerTitle": "32 ビットパッケージをお探しですか?[ダウンロードページ]({downloadPageLink}) をご覧ください。", + "apmOss.tutorial.editConfig.textPre": "Elastic Stack の X-Pack セキュアバージョンをご使用の場合、「apm-server.yml」構成ファイルで認証情報を指定する必要があります。", + "apmOss.tutorial.editConfig.title": "構成を編集する", + "apmOss.tutorial.flaskClient.configure.commands.allowedCharactersComment": "a-z、A-Z、0-9、-、_、スペース", + "apmOss.tutorial.flaskClient.configure.commands.configureElasticApmComment": "またはアプリケーションの設定で ELASTIC_APM を使用するよう構成します。", + "apmOss.tutorial.flaskClient.configure.commands.initializeUsingEnvironmentVariablesComment": "環境変数を使用して初期化します", + "apmOss.tutorial.flaskClient.configure.commands.setCustomApmServerUrlComment": "カスタム APM Server URL (デフォルト: {defaultApmServerUrl})", + "apmOss.tutorial.flaskClient.configure.commands.setRequiredServiceNameComment": "必要なサーバー名を設定します。使用できる文字:", + "apmOss.tutorial.flaskClient.configure.commands.useIfApmServerRequiresTokenComment": "APM Server にトークンが必要な場合に使います", + "apmOss.tutorial.flaskClient.configure.textPost": "高度な用途に関しては [ドキュメンテーション]({documentationLink}) をご覧ください。", + "apmOss.tutorial.flaskClient.configure.textPre": "エージェントとは、アプリケーションプロセス内で実行されるライブラリです。APM サービスは「SERVICE_NAME」に基づいてプログラムで作成されます。", + "apmOss.tutorial.flaskClient.configure.title": "エージェントの構成", + "apmOss.tutorial.flaskClient.install.textPre": "Python 用の APM エージェントを依存関係としてインストールします。", + "apmOss.tutorial.flaskClient.install.title": "APM エージェントのインストール", + "apmOss.tutorial.goClient.configure.commands.initializeUsingEnvironmentVariablesComment": "環境変数を使用して初期化します:", + "apmOss.tutorial.goClient.configure.commands.setCustomApmServerUrlComment": "カスタム APM Server URL (デフォルト: {defaultApmServerUrl})", + "apmOss.tutorial.goClient.configure.commands.setServiceNameComment": "サービス名を設定します。使用できる文字は # a-z、A-Z、0-9、-、_、スペースです。", + "apmOss.tutorial.goClient.configure.commands.usedExecutableNameComment": "ELASTIC_APM_SERVICE_NAME が指定されていない場合、実行可能な名前が使用されます。", + "apmOss.tutorial.goClient.configure.commands.useIfApmRequiresTokenComment": "APM Server にトークンが必要な場合に使います", + "apmOss.tutorial.goClient.configure.textPost": "高度な構成に関しては [ドキュメンテーション]({documentationLink}) をご覧ください。", + "apmOss.tutorial.goClient.configure.textPre": "エージェントとは、アプリケーションプロセス内で実行されるライブラリです。APM サービスは実行ファイル名または「ELASTIC_APM_SERVICE_NAME」環境変数に基づいてプログラムで作成されます。", + "apmOss.tutorial.goClient.configure.title": "エージェントの構成", + "apmOss.tutorial.goClient.install.textPre": "Go の APM エージェントパッケージをインストールします。", + "apmOss.tutorial.goClient.install.title": "APM エージェントのインストール", + "apmOss.tutorial.goClient.instrument.textPost": "Go のソースコードのインストルメンテーションの詳細ガイドは、[ドキュメンテーション]({documentationLink}) をご覧ください。", + "apmOss.tutorial.goClient.instrument.textPre": "提供されたインストルメンテーションモジュールの 1 つ、またはトレーサー API を直接使用して、Go アプリケーションにインストルメンテーションを設定します。", + "apmOss.tutorial.goClient.instrument.title": "アプリケーションのインストルメンテーション", + "apmOss.tutorial.introduction": "アプリケーション内から詳細なパフォーマンスメトリックやエラーを収集します。", + "apmOss.tutorial.javaClient.download.textPre": "[Maven Central]({mavenCentralLink}) からエージェントをダウンロードします。アプリケーションにエージェントを依存関係として「追加しない」でください。", + "apmOss.tutorial.javaClient.download.title": "APM エージェントのダウンロード", + "apmOss.tutorial.javaClient.startApplication.textPost": "構成オプションと高度な用途に関しては、[ドキュメンテーション]({documentationLink}) をご覧ください。", + "apmOss.tutorial.javaClient.startApplication.textPre": "「-javaagent」フラグを追加してエージェントをシステムプロパティで構成します。\n\n * 必要なサービス名を設定します (使用可能な文字は a-z、A-Z、0-9、-、_、スペースです)\n * カスタム APM Server URL (デフォルト: {customApmServerUrl})\n * アプリケーションのベースパッケージを設定します", + "apmOss.tutorial.javaClient.startApplication.title": "javaagent フラグでアプリケーションを起動", + "apmOss.tutorial.jsClient.enableRealUserMonitoring.textPre": "デフォルトでは、APM Server を実行すると RUM サポートは無効になります。RUM サポートを有効にする手順については、[ドキュメンテーション]({documentationLink}) をご覧ください。", + "apmOss.tutorial.jsClient.enableRealUserMonitoring.title": "APMサーバーのリアルユーザー監視サポートを有効にする", + "apmOss.tutorial.jsClient.installDependency.commands.setCustomApmServerUrlComment": "カスタム APM Server URL (デフォルト: {defaultApmServerUrl})", + "apmOss.tutorial.jsClient.installDependency.commands.setRequiredServiceNameComment": "必要なサービス名を設定します (使用可能な文字は a-z、A-Z、0-9、-、_、スペースです)", + "apmOss.tutorial.jsClient.installDependency.commands.setServiceVersionComment": "サービスバージョンを設定します (ソースマップ機能に必要)", + "apmOss.tutorial.jsClient.installDependency.textPost": "React や Angular などのフレームワーク統合には、カスタム依存関係があります。詳細は [統合ドキュメント]({docLink}) をご覧ください。", + "apmOss.tutorial.jsClient.installDependency.textPre": "「npm install @elastic/apm-rum --save」でエージェントをアプリケーションへの依存関係としてインストールできます。\n\nその後で以下のようにアプリケーションでエージェントを初期化して構成できます。", + "apmOss.tutorial.jsClient.installDependency.title": "エージェントを依存関係としてセットアップ", + "apmOss.tutorial.jsClient.scriptTags.textPre": "または、スクリプトタグを使用してエージェントのセットアップと構成ができます。` を追加