From edf16e60126427f5101e75b72f046fee7c491065 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Mon, 4 Oct 2021 15:33:21 +0200 Subject: [PATCH 01/14] Remove `jsonwebtoken` and `base64url` dependencies. (#113723) --- package.json | 3 -- renovate.json5 | 7 ++-- .../fixtures/oidc/oidc_tools.ts | 41 ++++++++++++------- yarn.lock | 11 +---- 4 files changed, 31 insertions(+), 31 deletions(-) diff --git a/package.json b/package.json index ac30b5de6f486..f436a13a057e9 100644 --- a/package.json +++ b/package.json @@ -272,7 +272,6 @@ "json-stable-stringify": "^1.0.1", "json-stringify-pretty-compact": "1.2.0", "json-stringify-safe": "5.0.1", - "jsonwebtoken": "^8.5.1", "jsts": "^1.6.2", "kea": "^2.4.2", "load-json-file": "^6.2.0", @@ -554,7 +553,6 @@ "@types/jsdom": "^16.2.3", "@types/json-stable-stringify": "^1.0.32", "@types/json5": "^0.0.30", - "@types/jsonwebtoken": "^8.5.5", "@types/license-checker": "15.0.0", "@types/listr": "^0.14.0", "@types/loader-utils": "^1.1.3", @@ -662,7 +660,6 @@ "babel-plugin-styled-components": "^1.13.2", "babel-plugin-transform-react-remove-prop-types": "^0.4.24", "backport": "^5.6.6", - "base64url": "^3.0.1", "callsites": "^3.1.0", "chai": "3.5.0", "chance": "1.0.18", diff --git a/renovate.json5 b/renovate.json5 index 12a30876291da..dea7d311bae16 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -86,10 +86,9 @@ { groupName: 'platform security modules', packageNames: [ - 'broadcast-channel', - 'jsonwebtoken', '@types/jsonwebtoken', - 'node-forge', '@types/node-forge', - 'require-in-the-middle', + 'broadcast-channel', + 'node-forge', '@types/node-forge', + 'require-in-the-middle', 'tough-cookie', '@types/tough-cookie', 'xml-crypto', '@types/xml-crypto' ], diff --git a/x-pack/test/security_api_integration/fixtures/oidc/oidc_tools.ts b/x-pack/test/security_api_integration/fixtures/oidc/oidc_tools.ts index 8d078994eb0e9..3db2e2ebdce0f 100644 --- a/x-pack/test/security_api_integration/fixtures/oidc/oidc_tools.ts +++ b/x-pack/test/security_api_integration/fixtures/oidc/oidc_tools.ts @@ -5,10 +5,8 @@ * 2.0. */ -import base64url from 'base64url'; -import { createHash } from 'crypto'; +import { createHash, createSign } from 'crypto'; import fs from 'fs'; -import jwt from 'jsonwebtoken'; import url from 'url'; export function getStateAndNonce(urlWithStateAndNonce: string) { @@ -16,16 +14,20 @@ export function getStateAndNonce(urlWithStateAndNonce: string) { return { state: parsedQuery.state as string, nonce: parsedQuery.nonce as string }; } +function fromBase64(base64: string) { + return base64.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); +} + export function createTokens(userId: string, nonce: string) { - const signingKey = fs.readFileSync(require.resolve('./jwks_private.pem')); - const iat = Math.floor(Date.now() / 1000); + const idTokenHeader = fromBase64( + Buffer.from(JSON.stringify({ alg: 'RS256' })).toString('base64') + ); + const iat = Math.floor(Date.now() / 1000); const accessToken = `valid-access-token${userId}`; const accessTokenHashBuffer = createHash('sha256').update(accessToken).digest(); - - return { - accessToken, - idToken: jwt.sign( + const idTokenBody = fromBase64( + Buffer.from( JSON.stringify({ iss: 'https://test-op.elastic.co', sub: `user${userId}`, @@ -34,10 +36,19 @@ export function createTokens(userId: string, nonce: string) { exp: iat + 3600, iat, // See more details on `at_hash` at https://openid.net/specs/openid-connect-core-1_0.html#ImplicitIDToken - at_hash: base64url(accessTokenHashBuffer.slice(0, accessTokenHashBuffer.length / 2)), - }), - signingKey, - { algorithm: 'RS256' } - ), - }; + at_hash: fromBase64( + accessTokenHashBuffer.slice(0, accessTokenHashBuffer.length / 2).toString('base64') + ), + }) + ).toString('base64') + ); + + const idToken = `${idTokenHeader}.${idTokenBody}`; + + const signingKey = fs.readFileSync(require.resolve('./jwks_private.pem')); + const idTokenSignature = fromBase64( + createSign('RSA-SHA256').update(idToken).sign(signingKey, 'base64') + ); + + return { accessToken, idToken: `${idToken}.${idTokenSignature}` }; } diff --git a/yarn.lock b/yarn.lock index 14cf34cae847b..83c0691646817 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6394,13 +6394,6 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.30.tgz#44cb52f32a809734ca562e685c6473b5754a7818" integrity sha512-sqm9g7mHlPY/43fcSNrCYfOeX9zkTTK+euO5E6+CVijSMm5tTjkVdwdqRkY3ljjIAf8679vps5jKUoJBCLsMDA== -"@types/jsonwebtoken@^8.5.5": - version "8.5.5" - resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-8.5.5.tgz#da5f2f4baee88f052ef3e4db4c1a0afb46cff22c" - integrity sha512-OGqtHQ7N5/Ap/TUwO6IgHDuLiAoTmHhGpNvgkCm/F4N6pKzx/RBSfr2OXZSwC6vkfnsEdb6+7DNZVtiXiwdwFw== - dependencies: - "@types/node" "*" - "@types/keyv@*": version "3.1.1" resolved "https://registry.yarnpkg.com/@types/keyv/-/keyv-3.1.1.tgz#e45a45324fca9dab716ab1230ee249c9fb52cfa7" @@ -9235,7 +9228,7 @@ base64-js@^1.0.2, base64-js@^1.1.2, base64-js@^1.2.0, base64-js@^1.3.0, base64-j resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1" integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g== -base64url@^3.0.0, base64url@^3.0.1: +base64url@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/base64url/-/base64url-3.0.1.tgz#6399d572e2bc3f90a9a8b22d5dbb0a32d33f788d" integrity sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A== @@ -19171,7 +19164,7 @@ jsonparse@^1.2.0: resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" integrity sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA= -jsonwebtoken@^8.3.0, jsonwebtoken@^8.5.1: +jsonwebtoken@^8.3.0: version "8.5.1" resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d" integrity sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w== From 163133827b894468cb9dce4ed0a4aad493aff1f2 Mon Sep 17 00:00:00 2001 From: Kevin Lacabane Date: Mon, 4 Oct 2021 15:57:47 +0200 Subject: [PATCH 02/14] [Stack Monitoring] React migration kibana overview (#113604) * Create react Kibana template * React Kibana overview * Add breadcrumb to kibana overview * fix linting errors Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../monitoring/public/application/index.tsx | 11 +- .../pages/kibana/kibana_template.tsx | 30 +++++ .../application/pages/kibana/overview.tsx | 119 ++++++++++++++++++ 3 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 x-pack/plugins/monitoring/public/application/pages/kibana/kibana_template.tsx create mode 100644 x-pack/plugins/monitoring/public/application/pages/kibana/overview.tsx diff --git a/x-pack/plugins/monitoring/public/application/index.tsx b/x-pack/plugins/monitoring/public/application/index.tsx index dea8d18bb65b1..acdf3b0986a64 100644 --- a/x-pack/plugins/monitoring/public/application/index.tsx +++ b/x-pack/plugins/monitoring/public/application/index.tsx @@ -23,7 +23,8 @@ import { ElasticsearchOverviewPage } from './pages/elasticsearch/overview'; import { BeatsOverviewPage } from './pages/beats/overview'; import { BeatsInstancesPage } from './pages/beats/instances'; import { BeatsInstancePage } from './pages/beats/instance'; -import { CODE_PATH_ELASTICSEARCH, CODE_PATH_BEATS } from '../../common/constants'; +import { KibanaOverviewPage } from './pages/kibana/overview'; +import { CODE_PATH_ELASTICSEARCH, CODE_PATH_BEATS, CODE_PATH_KIBANA } from '../../common/constants'; import { ElasticsearchNodesPage } from './pages/elasticsearch/nodes_page'; import { ElasticsearchIndicesPage } from './pages/elasticsearch/indices_page'; import { ElasticsearchNodePage } from './pages/elasticsearch/node_page'; @@ -133,6 +134,14 @@ const MonitoringApp: React.FC<{ fetchAllClusters={false} /> + {/* Kibana Views */} + + = ({ ...props }) => { + const tabs: TabMenuItem[] = [ + { + id: 'overview', + label: i18n.translate('xpack.monitoring.kibanaNavigation.overviewLinkText', { + defaultMessage: 'Overview', + }), + route: '/kibana', + }, + { + id: 'instances', + label: i18n.translate('xpack.monitoring.kibanaNavigation.instancesLinkText', { + defaultMessage: 'Instances', + }), + route: '/kibana/instances', + }, + ]; + + return ; +}; diff --git a/x-pack/plugins/monitoring/public/application/pages/kibana/overview.tsx b/x-pack/plugins/monitoring/public/application/pages/kibana/overview.tsx new file mode 100644 index 0000000000000..2356011a3f77b --- /dev/null +++ b/x-pack/plugins/monitoring/public/application/pages/kibana/overview.tsx @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useCallback, useContext, useEffect, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { find } from 'lodash'; +import { + EuiPage, + EuiPageBody, + EuiPageContent, + EuiPanel, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; + +import { KibanaTemplate } from './kibana_template'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { GlobalStateContext } from '../../global_state_context'; +import { ComponentProps } from '../../route_init'; +// @ts-ignore +import { MonitoringTimeseriesContainer } from '../../../components/chart'; +// @ts-ignore +import { ClusterStatus } from '../../../components/kibana/cluster_status'; +import { BreadcrumbContainer } from '../../hooks/use_breadcrumbs'; +import { useCharts } from '../../hooks/use_charts'; + +const KibanaOverview = ({ data }: { data: any }) => { + const { zoomInfo, onBrush } = useCharts(); + + if (!data) return null; + + return ( + + + + + + + + + + + + + + + + + + + ); +}; + +export const KibanaOverviewPage: React.FC = ({ clusters }) => { + const globalState = useContext(GlobalStateContext); + const { services } = useKibana<{ data: any }>(); + const { generate: generateBreadcrumbs } = useContext(BreadcrumbContainer.Context); + const [data, setData] = useState(); + const clusterUuid = globalState.cluster_uuid; + const cluster = find(clusters, { + cluster_uuid: clusterUuid, + }) as any; + const ccs = globalState.ccs; + const title = i18n.translate('xpack.monitoring.kibana.overview.title', { + defaultMessage: 'Kibana', + }); + const pageTitle = i18n.translate('xpack.monitoring.kibana.overview.pageTitle', { + defaultMessage: 'Kibana overview', + }); + + useEffect(() => { + if (cluster) { + generateBreadcrumbs(cluster.cluster_name, { + inKibana: true, + }); + } + }, [cluster, generateBreadcrumbs]); + + const getPageData = useCallback(async () => { + const bounds = services.data?.query.timefilter.timefilter.getBounds(); + const url = `../api/monitoring/v1/clusters/${clusterUuid}/kibana`; + + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + ccs, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + }), + }); + + setData(response); + }, [ccs, clusterUuid, services.data?.query.timefilter.timefilter, services.http]); + + return ( + + + + ); +}; From c868cd5c812a82f72fa21b3a4cd261160c910d39 Mon Sep 17 00:00:00 2001 From: Matthias Wilhelm Date: Mon, 4 Oct 2021 16:00:08 +0200 Subject: [PATCH 03/14] [Discover] Extract fetch observable initialization to separate function (#108831) * Don't trigger autorefresh when there's no time picker - because there's no UI for that * Refactor and add test * Add doc and test * Refactor * Remove index pattern without timefield filtering Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../apps/main/services/use_saved_search.ts | 43 ++++----- .../apps/main/utils/get_fetch_observable.ts | 61 ++++++++++++ .../main/utils/get_fetch_observeable.test.ts | 95 +++++++++++++++++++ 3 files changed, 172 insertions(+), 27 deletions(-) create mode 100644 src/plugins/discover/public/application/apps/main/utils/get_fetch_observable.ts create mode 100644 src/plugins/discover/public/application/apps/main/utils/get_fetch_observeable.test.ts diff --git a/src/plugins/discover/public/application/apps/main/services/use_saved_search.ts b/src/plugins/discover/public/application/apps/main/services/use_saved_search.ts index 164dff8627790..26f95afba5a93 100644 --- a/src/plugins/discover/public/application/apps/main/services/use_saved_search.ts +++ b/src/plugins/discover/public/application/apps/main/services/use_saved_search.ts @@ -6,15 +6,14 @@ * Side Public License, v 1. */ import { useCallback, useEffect, useMemo, useRef } from 'react'; -import { BehaviorSubject, merge, Subject } from 'rxjs'; -import { debounceTime, filter, tap } from 'rxjs/operators'; +import { BehaviorSubject, Subject } from 'rxjs'; import { DiscoverServices } from '../../../../build_services'; import { DiscoverSearchSessionManager } from './discover_search_session'; import { SearchSource } from '../../../../../../data/common'; import { GetStateReturn } from './discover_state'; import { ElasticSearchHit } from '../../../doc_views/doc_views_types'; import { RequestAdapter } from '../../../../../../inspector/public'; -import { AutoRefreshDoneFn } from '../../../../../../data/public'; +import type { AutoRefreshDoneFn } from '../../../../../../data/public'; import { validateTimeRange } from '../utils/validate_time_range'; import { Chart } from '../components/chart/point_series'; import { useSingleton } from '../utils/use_singleton'; @@ -23,6 +22,7 @@ import { FetchStatus } from '../../../types'; import { fetchAll } from '../utils/fetch_all'; import { useBehaviorSubject } from '../utils/use_behavior_subject'; import { sendResetMsg } from './use_saved_search_messages'; +import { getFetch$ } from '../utils/get_fetch_observable'; export interface SavedSearchData { main$: DataMain$; @@ -134,6 +134,7 @@ export const useSavedSearch = ({ */ const refs = useRef<{ abortController?: AbortController; + autoRefreshDone?: AutoRefreshDoneFn; }>({}); /** @@ -145,29 +146,17 @@ export const useSavedSearch = ({ * handler emitted by `timefilter.getAutoRefreshFetch$()` * to notify when data completed loading and to start a new autorefresh loop */ - let autoRefreshDoneCb: AutoRefreshDoneFn | undefined; - const fetch$ = merge( + const setAutoRefreshDone = (fn: AutoRefreshDoneFn | undefined) => { + refs.current.autoRefreshDone = fn; + }; + const fetch$ = getFetch$({ + setAutoRefreshDone, + data, + main$, refetch$, - filterManager.getFetches$(), - timefilter.getFetch$(), - timefilter.getAutoRefreshFetch$().pipe( - tap((done) => { - autoRefreshDoneCb = done; - }), - filter(() => { - /** - * filter to prevent auto-refresh triggered fetch when - * loading is still ongoing - */ - const currentFetchStatus = main$.getValue().fetchStatus; - return ( - currentFetchStatus !== FetchStatus.LOADING && currentFetchStatus !== FetchStatus.PARTIAL - ); - }) - ), - data.query.queryString.getUpdates$(), - searchSessionManager.newSearchSessionIdFromURL$.pipe(filter((sessionId) => !!sessionId)) - ).pipe(debounceTime(100)); + searchSessionManager, + searchSource, + }); const subscription = fetch$.subscribe((val) => { if (!validateTimeRange(timefilter.getTime(), services.toastNotifications)) { @@ -190,8 +179,8 @@ export const useSavedSearch = ({ }).subscribe({ complete: () => { // if this function was set and is executed, another refresh fetch can be triggered - autoRefreshDoneCb?.(); - autoRefreshDoneCb = undefined; + refs.current.autoRefreshDone?.(); + refs.current.autoRefreshDone = undefined; }, }); } catch (error) { diff --git a/src/plugins/discover/public/application/apps/main/utils/get_fetch_observable.ts b/src/plugins/discover/public/application/apps/main/utils/get_fetch_observable.ts new file mode 100644 index 0000000000000..aac6196e64f6f --- /dev/null +++ b/src/plugins/discover/public/application/apps/main/utils/get_fetch_observable.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { merge } from 'rxjs'; +import { debounceTime, filter, tap } from 'rxjs/operators'; + +import { FetchStatus } from '../../../types'; +import type { + AutoRefreshDoneFn, + DataPublicPluginStart, + SearchSource, +} from '../../../../../../data/public'; +import { DataMain$, DataRefetch$ } from '../services/use_saved_search'; +import { DiscoverSearchSessionManager } from '../services/discover_search_session'; + +/** + * This function returns an observable that's used to trigger data fetching + */ +export function getFetch$({ + setAutoRefreshDone, + data, + main$, + refetch$, + searchSessionManager, +}: { + setAutoRefreshDone: (val: AutoRefreshDoneFn | undefined) => void; + data: DataPublicPluginStart; + main$: DataMain$; + refetch$: DataRefetch$; + searchSessionManager: DiscoverSearchSessionManager; + searchSource: SearchSource; +}) { + const { timefilter } = data.query.timefilter; + const { filterManager } = data.query; + return merge( + refetch$, + filterManager.getFetches$(), + timefilter.getFetch$(), + timefilter.getAutoRefreshFetch$().pipe( + tap((done) => { + setAutoRefreshDone(done); + }), + filter(() => { + const currentFetchStatus = main$.getValue().fetchStatus; + return ( + /** + * filter to prevent auto-refresh triggered fetch when + * loading is still ongoing + */ + currentFetchStatus !== FetchStatus.LOADING && currentFetchStatus !== FetchStatus.PARTIAL + ); + }) + ), + data.query.queryString.getUpdates$(), + searchSessionManager.newSearchSessionIdFromURL$.pipe(filter((sessionId) => !!sessionId)) + ).pipe(debounceTime(100)); +} diff --git a/src/plugins/discover/public/application/apps/main/utils/get_fetch_observeable.test.ts b/src/plugins/discover/public/application/apps/main/utils/get_fetch_observeable.test.ts new file mode 100644 index 0000000000000..5f728b115b2e9 --- /dev/null +++ b/src/plugins/discover/public/application/apps/main/utils/get_fetch_observeable.test.ts @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { getFetch$ } from './get_fetch_observable'; +import { FetchStatus } from '../../../types'; +import { BehaviorSubject, Subject } from 'rxjs'; +import { DataPublicPluginStart } from '../../../../../../data/public'; +import { createSearchSessionMock } from '../../../../__mocks__/search_session'; +import { DataRefetch$ } from '../services/use_saved_search'; +import { savedSearchMock, savedSearchMockWithTimeField } from '../../../../__mocks__/saved_search'; + +function createDataMock( + queryString$: Subject, + filterManager$: Subject, + timefilterFetch$: Subject, + autoRefreshFetch$: Subject +) { + return { + query: { + queryString: { + getUpdates$: () => { + return queryString$; + }, + }, + filterManager: { + getFetches$: () => { + return filterManager$; + }, + }, + timefilter: { + timefilter: { + getFetch$: () => { + return timefilterFetch$; + }, + getAutoRefreshFetch$: () => { + return autoRefreshFetch$; + }, + }, + }, + }, + } as unknown as DataPublicPluginStart; +} + +describe('getFetchObservable', () => { + test('refetch$.next should trigger fetch$.next', async (done) => { + const searchSessionManagerMock = createSearchSessionMock(); + + const main$ = new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }); + const refetch$: DataRefetch$ = new Subject(); + const fetch$ = getFetch$({ + setAutoRefreshDone: jest.fn(), + main$, + refetch$, + data: createDataMock(new Subject(), new Subject(), new Subject(), new Subject()), + searchSessionManager: searchSessionManagerMock.searchSessionManager, + searchSource: savedSearchMock.searchSource, + }); + + fetch$.subscribe(() => { + done(); + }); + refetch$.next(); + }); + test('getAutoRefreshFetch$ should trigger fetch$.next', async () => { + jest.useFakeTimers(); + const searchSessionManagerMock = createSearchSessionMock(); + const autoRefreshFetch$ = new Subject(); + + const main$ = new BehaviorSubject({ fetchStatus: FetchStatus.UNINITIALIZED }); + const refetch$: DataRefetch$ = new Subject(); + const dataMock = createDataMock(new Subject(), new Subject(), new Subject(), autoRefreshFetch$); + const setAutoRefreshDone = jest.fn(); + const fetch$ = getFetch$({ + setAutoRefreshDone, + main$, + refetch$, + data: dataMock, + searchSessionManager: searchSessionManagerMock.searchSessionManager, + searchSource: savedSearchMockWithTimeField.searchSource, + }); + + const fetchfnMock = jest.fn(); + fetch$.subscribe(() => { + fetchfnMock(); + }); + autoRefreshFetch$.next(jest.fn()); + jest.runAllTimers(); + expect(fetchfnMock).toHaveBeenCalledTimes(1); + expect(setAutoRefreshDone).toHaveBeenCalled(); + }); +}); From 1ff02e1da684a5bbd3d1179db9567b5dd2fe4a97 Mon Sep 17 00:00:00 2001 From: Dominique Clarke Date: Mon, 4 Oct 2021 10:05:01 -0400 Subject: [PATCH 04/14] [Observability] [Exploratory View] Add exploratory view multi series (#113464) * Revert "[Observability][Exploratory View] revert exploratory view multi-series (#107647)" This reverts commit 1649661ffdc79d00f9d23451790335e5d25da25f. * Revert "[Observability][Exploratory View] revert exploratory view multi-series (#107647)" This reverts commit 1649661ffdc79d00f9d23451790335e5d25da25f. * [Observability] [Exploratory View] Create multi series feature branch (#108079) * Revert "[Observability][Exploratory View] revert exploratory view multi-series (#107647)" This reverts commit 1649661ffdc79d00f9d23451790335e5d25da25f. * Revert "[Observability][Exploratory View] revert exploratory view multi-series (#107647)" This reverts commit 1649661ffdc79d00f9d23451790335e5d25da25f. * update types * update tests * [Observability] exploratory view design issues (#111028) * remove custom y axis labels for better clarity * move add series button to the bottom * disable auto apply * fix missing test * When series count changes, collapse other series. (#110894) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> * Feature/observability exploratory view multi series panels (#111555) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> * [Exploratory View] Fix date range picker on secondary series (#111700) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> * [Exploratory View] Collapse series only on add, not delete (#111790) * [Exploratory view] Remove preview panel (#111884) * [Exploratory view] implement popovers for data type and metric type (#112370) * implement popovers for data type and metric type * adjust types * add IncompleteBadge * make report metric dismissable * show date-picker even if metric is undefined * adjust styles of expanded series row * add truncation to series name * move incomplete badge and add edit pencil * add tooltip to data type badge * adjust content * lint * delete extra file * move filters row * adjust name editing behavior * adjust filter styles Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> * move cases button to top * fix types * more types :( Co-authored-by: Justin Kambic Co-authored-by: shahzad31 Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Shahzad --- packages/kbn-test/src/jest/utils/get_url.ts | 9 +- test/functional/page_objects/common_page.ts | 5 +- .../app/RumDashboard/ActionMenu/index.tsx | 22 +- .../PageLoadDistribution/index.tsx | 19 +- .../app/RumDashboard/PageViewsTrend/index.tsx | 19 +- .../analyze_data_button.test.tsx | 8 +- .../analyze_data_button.tsx | 37 +- .../apm/server/lib/rum_client/has_rum_data.ts | 5 +- .../add_data_buttons/mobile_add_data.tsx | 32 ++ .../add_data_buttons/synthetics_add_data.tsx | 32 ++ .../shared/add_data_buttons/ux_add_data.tsx | 32 ++ .../action_menu/action_menu.test.tsx | 61 ++++ .../components/action_menu/action_menu.tsx | 98 ++++++ .../components/action_menu/index.tsx | 26 ++ .../date_range_picker.tsx | 63 ++-- .../components/empty_view.tsx | 17 +- .../components/filter_label.test.tsx | 14 +- .../components/filter_label.tsx | 11 +- .../components/series_color_picker.tsx | 65 ++++ .../series_date_picker/index.tsx | 35 +- .../series_date_picker.test.tsx | 59 ++-- .../configurations/constants/constants.ts | 13 + .../configurations/constants/url_constants.ts | 4 +- .../configurations/default_configs.ts | 17 +- .../configurations/lens_attributes.test.ts | 23 +- .../configurations/lens_attributes.ts | 45 +-- .../mobile/device_distribution_config.ts | 8 +- .../mobile/distribution_config.ts | 4 +- .../mobile/kpi_over_time_config.ts | 10 +- .../rum/core_web_vitals_config.test.ts | 9 +- .../rum/core_web_vitals_config.ts | 4 +- .../rum/data_distribution_config.ts | 9 +- .../rum/kpi_over_time_config.ts | 10 +- .../synthetics/data_distribution_config.ts | 9 +- .../synthetics/kpi_over_time_config.ts | 4 +- .../test_data/sample_attribute.ts | 89 +++-- .../test_data/sample_attribute_cwv.ts | 4 +- .../test_data/sample_attribute_kpi.ts | 75 ++-- .../exploratory_view/configurations/utils.ts | 27 +- .../exploratory_view.test.tsx | 34 +- .../exploratory_view/exploratory_view.tsx | 174 +++++++--- .../header/add_to_case_action.test.tsx | 7 +- .../header/add_to_case_action.tsx | 3 +- .../exploratory_view/header/header.test.tsx | 47 +-- .../shared/exploratory_view/header/header.tsx | 87 ++--- .../last_updated.tsx | 21 +- .../exploratory_view/hooks/use_add_to_case.ts | 2 +- .../hooks/use_app_index_pattern.tsx | 2 +- .../hooks/use_discover_link.tsx | 92 +++++ .../hooks/use_lens_attributes.ts | 57 ++- .../hooks/use_series_filters.ts | 43 ++- .../hooks/use_series_storage.test.tsx | 91 ++--- .../hooks/use_series_storage.tsx | 123 ++++--- .../shared/exploratory_view/index.tsx | 4 +- .../exploratory_view/lens_embeddable.tsx | 77 +++- .../shared/exploratory_view/rtl_helpers.tsx | 61 ++-- .../columns/data_types_col.test.tsx | 62 ---- .../series_builder/columns/data_types_col.tsx | 74 ---- .../columns/date_picker_col.tsx | 39 --- .../columns/report_breakdowns.test.tsx | 74 ---- .../columns/report_breakdowns.tsx | 26 -- .../columns/report_definition_col.tsx | 101 ------ .../columns/report_filters.test.tsx | 28 -- .../series_builder/columns/report_filters.tsx | 29 -- .../columns/report_types_col.test.tsx | 79 ----- .../columns/report_types_col.tsx | 108 ------ .../series_builder/report_metric_options.tsx | 46 --- .../series_builder/series_builder.tsx | 303 ---------------- .../series_editor/chart_edit_options.tsx | 30 -- .../series_editor/columns/breakdowns.test.tsx | 22 +- .../series_editor/columns/breakdowns.tsx | 49 +-- .../series_editor/columns/chart_options.tsx | 35 -- .../columns/chart_type_select.tsx | 73 ++++ .../columns/chart_types.test.tsx | 6 +- .../columns/chart_types.tsx | 52 ++- .../columns/data_type_select.test.tsx | 45 +++ .../columns/data_type_select.tsx | 144 ++++++++ .../series_editor/columns/date_picker_col.tsx | 78 ++++- .../columns/filter_expanded.test.tsx | 48 +-- .../series_editor/columns/filter_expanded.tsx | 139 ++++---- .../columns/filter_value_btn.test.tsx | 145 ++++---- .../columns/filter_value_btn.tsx | 16 +- .../columns/incomplete_badge.tsx | 63 ++++ .../columns/operation_type_select.test.tsx | 36 +- .../columns/operation_type_select.tsx | 13 +- .../series_editor/columns/remove_series.tsx | 41 ++- .../columns/report_definition_col.test.tsx | 46 +-- .../columns/report_definition_col.tsx | 59 ++++ .../columns/report_definition_field.tsx | 50 ++- .../columns/report_type_select.tsx | 63 ++++ .../{ => columns}/selected_filters.test.tsx | 22 +- .../columns/selected_filters.tsx | 101 ++++++ .../series_editor/columns/series_actions.tsx | 139 ++++---- .../series_editor/columns/series_filter.tsx | 145 ++------ .../series_editor/columns/series_info.tsx | 37 ++ .../columns/series_name.test.tsx | 47 +++ .../series_editor/columns/series_name.tsx | 105 ++++++ .../series_editor/expanded_series_row.tsx | 95 +++++ .../series_editor/report_metric_options.tsx | 139 ++++++++ .../series_editor/selected_filters.tsx | 101 ------ .../exploratory_view/series_editor/series.tsx | 93 +++++ .../series_editor/series_editor.tsx | 328 +++++++++++------- .../shared/exploratory_view/types.ts | 17 +- .../views/add_series_button.test.tsx | 106 ++++++ .../views/add_series_button.tsx | 80 +++++ .../exploratory_view/views/series_views.tsx | 26 ++ .../exploratory_view/views/view_actions.tsx | 30 ++ .../field_value_combobox.tsx | 61 ++-- .../field_value_selection.tsx | 4 +- .../field_value_suggestions/index.test.tsx | 2 + .../shared/field_value_suggestions/index.tsx | 8 +- .../shared/field_value_suggestions/types.ts | 3 +- .../filter_value_label/filter_value_label.tsx | 18 +- .../public/components/shared/index.tsx | 3 +- .../public/hooks/use_quick_time_ranges.tsx | 2 +- x-pack/plugins/observability/public/plugin.ts | 2 + .../observability/public/routes/index.tsx | 16 +- .../translations/translations/ja-JP.json | 20 -- .../translations/translations/zh-CN.json | 20 -- .../common/charts/ping_histogram.tsx | 25 +- .../common/header/action_menu_content.tsx | 29 +- .../monitor_duration_container.tsx | 21 +- .../apps/observability/exploratory_view.ts | 82 +++++ .../apps/observability/index.ts | 3 +- 124 files changed, 3602 insertions(+), 2508 deletions(-) create mode 100644 x-pack/plugins/observability/public/components/shared/add_data_buttons/mobile_add_data.tsx create mode 100644 x-pack/plugins/observability/public/components/shared/add_data_buttons/synthetics_add_data.tsx create mode 100644 x-pack/plugins/observability/public/components/shared/add_data_buttons/ux_add_data.tsx create mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/components/action_menu/action_menu.test.tsx create mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/components/action_menu/action_menu.tsx create mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/components/action_menu/index.tsx rename x-pack/plugins/observability/public/components/shared/exploratory_view/{series_date_picker => components}/date_range_picker.tsx (58%) create mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/components/series_color_picker.tsx rename x-pack/plugins/observability/public/components/shared/exploratory_view/{ => components}/series_date_picker/index.tsx (54%) rename x-pack/plugins/observability/public/components/shared/exploratory_view/{ => components}/series_date_picker/series_date_picker.test.tsx (51%) rename x-pack/plugins/observability/public/components/shared/exploratory_view/{series_builder => header}/last_updated.tsx (55%) create mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_discover_link.tsx delete mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.test.tsx delete mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.tsx delete mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/date_picker_col.tsx delete mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.test.tsx delete mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.tsx delete mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx delete mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.test.tsx delete mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.tsx delete mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx delete mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx delete mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/report_metric_options.tsx delete mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx delete mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/chart_edit_options.tsx delete mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/chart_options.tsx create mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/chart_type_select.tsx rename x-pack/plugins/observability/public/components/shared/exploratory_view/{series_builder => series_editor}/columns/chart_types.test.tsx (85%) rename x-pack/plugins/observability/public/components/shared/exploratory_view/{series_builder => series_editor}/columns/chart_types.tsx (77%) create mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/data_type_select.test.tsx create mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/data_type_select.tsx create mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/incomplete_badge.tsx rename x-pack/plugins/observability/public/components/shared/exploratory_view/{series_builder => series_editor}/columns/operation_type_select.test.tsx (69%) rename x-pack/plugins/observability/public/components/shared/exploratory_view/{series_builder => series_editor}/columns/operation_type_select.tsx (91%) rename x-pack/plugins/observability/public/components/shared/exploratory_view/{series_builder => series_editor}/columns/report_definition_col.test.tsx (65%) create mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/report_definition_col.tsx rename x-pack/plugins/observability/public/components/shared/exploratory_view/{series_builder => series_editor}/columns/report_definition_field.tsx (69%) create mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/report_type_select.tsx rename x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/{ => columns}/selected_filters.test.tsx (59%) create mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/selected_filters.tsx create mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_info.tsx create mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_name.test.tsx create mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_name.tsx create mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/expanded_series_row.tsx create mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/report_metric_options.tsx delete mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx create mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series.tsx create mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/views/add_series_button.test.tsx create mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/views/add_series_button.tsx create mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/views/series_views.tsx create mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/views/view_actions.tsx create mode 100644 x-pack/test/observability_functional/apps/observability/exploratory_view.ts diff --git a/packages/kbn-test/src/jest/utils/get_url.ts b/packages/kbn-test/src/jest/utils/get_url.ts index 734e26c5199d7..e08695b334e1b 100644 --- a/packages/kbn-test/src/jest/utils/get_url.ts +++ b/packages/kbn-test/src/jest/utils/get_url.ts @@ -22,11 +22,6 @@ interface UrlParam { username?: string; } -interface App { - pathname?: string; - hash?: string; -} - /** * Converts a config and a pathname to a url * @param {object} config A url config @@ -46,11 +41,11 @@ interface App { * @return {string} */ -function getUrl(config: UrlParam, app: App) { +function getUrl(config: UrlParam, app: UrlParam) { return url.format(_.assign({}, config, app)); } -getUrl.noAuth = function getUrlNoAuth(config: UrlParam, app: App) { +getUrl.noAuth = function getUrlNoAuth(config: UrlParam, app: UrlParam) { config = _.pickBy(config, function (val, param) { return param !== 'auth'; }); diff --git a/test/functional/page_objects/common_page.ts b/test/functional/page_objects/common_page.ts index 853a926f4f6e8..8fe2e4139e6ca 100644 --- a/test/functional/page_objects/common_page.ts +++ b/test/functional/page_objects/common_page.ts @@ -217,8 +217,9 @@ export class CommonPageObject extends FtrService { { basePath = '', shouldLoginIfPrompted = true, - disableWelcomePrompt = true, hash = '', + search = '', + disableWelcomePrompt = true, insertTimestamp = true, } = {} ) { @@ -229,11 +230,13 @@ export class CommonPageObject extends FtrService { appUrl = getUrl.noAuth(this.config.get('servers.kibana'), { pathname: `${basePath}${appConfig.pathname}`, hash: hash || appConfig.hash, + search, }); } else { appUrl = getUrl.noAuth(this.config.get('servers.kibana'), { pathname: `${basePath}/app/${appName}`, hash, + search, }); } diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/ActionMenu/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/ActionMenu/index.tsx index 170e3a2fdad1e..593de7c3a6f70 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/ActionMenu/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/ActionMenu/index.tsx @@ -11,12 +11,12 @@ import { i18n } from '@kbn/i18n'; import { createExploratoryViewUrl, HeaderMenuPortal, - SeriesUrl, } from '../../../../../../observability/public'; import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; import { AppMountParameters } from '../../../../../../../../src/core/public'; import { InspectorHeaderLink } from '../../../shared/apm_header_action_menu/inspector_header_link'; +import { SERVICE_NAME } from '../../../../../common/elasticsearch_fieldnames'; const ANALYZE_DATA = i18n.translate('xpack.apm.analyzeDataButtonLabel', { defaultMessage: 'Analyze data', @@ -39,15 +39,22 @@ export function UXActionMenu({ services: { http }, } = useKibana(); const { urlParams } = useUrlParams(); - const { rangeTo, rangeFrom } = urlParams; + const { rangeTo, rangeFrom, serviceName } = urlParams; const uxExploratoryViewLink = createExploratoryViewUrl( { - 'ux-series': { - dataType: 'ux', - isNew: true, - time: { from: rangeFrom, to: rangeTo }, - } as unknown as SeriesUrl, + reportType: 'kpi-over-time', + allSeries: [ + { + dataType: 'ux', + name: `${serviceName}-page-views`, + time: { from: rangeFrom!, to: rangeTo! }, + reportDefinitions: { + [SERVICE_NAME]: serviceName ? [serviceName] : [], + }, + selectedMetricField: 'Records', + }, + ], }, http?.basePath.get() ); @@ -61,6 +68,7 @@ export function UXActionMenu({ {ANALYZE_MESSAGE}

}> { render(); expect((screen.getByRole('link') as HTMLAnchorElement).href).toEqual( - 'http://localhost/app/observability/exploratory-view#?sr=(apm-series:(dt:ux,isNew:!t,op:average,rdf:(service.environment:!(testEnvironment),service.name:!(testServiceName)),rt:kpi-over-time,time:(from:now-15m,to:now)))' + 'http://localhost/app/observability/exploratory-view/#?reportType=kpi-over-time&sr=!((dt:ux,mt:transaction.duration.us,n:testServiceName-response-latency,op:average,rdf:(service.environment:!(testEnvironment),service.name:!(testServiceName)),time:(from:now-15m,to:now)))' ); }); }); @@ -48,7 +48,7 @@ describe('AnalyzeDataButton', () => { render(); expect((screen.getByRole('link') as HTMLAnchorElement).href).toEqual( - 'http://localhost/app/observability/exploratory-view#?sr=(apm-series:(dt:mobile,isNew:!t,op:average,rdf:(service.environment:!(testEnvironment),service.name:!(testServiceName)),rt:kpi-over-time,time:(from:now-15m,to:now)))' + 'http://localhost/app/observability/exploratory-view/#?reportType=kpi-over-time&sr=!((dt:mobile,mt:transaction.duration.us,n:testServiceName-response-latency,op:average,rdf:(service.environment:!(testEnvironment),service.name:!(testServiceName)),time:(from:now-15m,to:now)))' ); }); }); @@ -58,7 +58,7 @@ describe('AnalyzeDataButton', () => { render(); expect((screen.getByRole('link') as HTMLAnchorElement).href).toEqual( - 'http://localhost/app/observability/exploratory-view#?sr=(apm-series:(dt:mobile,isNew:!t,op:average,rdf:(service.name:!(testServiceName)),rt:kpi-over-time,time:(from:now-15m,to:now)))' + 'http://localhost/app/observability/exploratory-view/#?reportType=kpi-over-time&sr=!((dt:mobile,mt:transaction.duration.us,n:testServiceName-response-latency,op:average,rdf:(service.environment:!(ENVIRONMENT_NOT_DEFINED),service.name:!(testServiceName)),time:(from:now-15m,to:now)))' ); }); }); @@ -68,7 +68,7 @@ describe('AnalyzeDataButton', () => { render(); expect((screen.getByRole('link') as HTMLAnchorElement).href).toEqual( - 'http://localhost/app/observability/exploratory-view#?sr=(apm-series:(dt:mobile,isNew:!t,op:average,rdf:(service.environment:!(ALL_VALUES),service.name:!(testServiceName)),rt:kpi-over-time,time:(from:now-15m,to:now)))' + 'http://localhost/app/observability/exploratory-view/#?reportType=kpi-over-time&sr=!((dt:mobile,mt:transaction.duration.us,n:testServiceName-response-latency,op:average,rdf:(service.environment:!(ALL_VALUES),service.name:!(testServiceName)),time:(from:now-15m,to:now)))' ); }); }); diff --git a/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/analyze_data_button.tsx b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/analyze_data_button.tsx index 068d7bb1c242f..a4fc964a444c9 100644 --- a/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/analyze_data_button.tsx +++ b/x-pack/plugins/apm/public/components/routing/templates/apm_service_template/analyze_data_button.tsx @@ -9,10 +9,7 @@ import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; -import { - createExploratoryViewUrl, - SeriesUrl, -} from '../../../../../../observability/public'; +import { createExploratoryViewUrl } from '../../../../../../observability/public'; import { ALL_VALUES_SELECTED } from '../../../../../../observability/public'; import { isIosAgentName, @@ -21,6 +18,7 @@ import { import { SERVICE_ENVIRONMENT, SERVICE_NAME, + TRANSACTION_DURATION, } from '../../../../../common/elasticsearch_fieldnames'; import { ENVIRONMENT_ALL, @@ -29,13 +27,11 @@ import { import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; import { useApmParams } from '../../../../hooks/use_apm_params'; -function getEnvironmentDefinition(environment?: string) { +function getEnvironmentDefinition(environment: string) { switch (environment) { case ENVIRONMENT_ALL.value: return { [SERVICE_ENVIRONMENT]: [ALL_VALUES_SELECTED] }; case ENVIRONMENT_NOT_DEFINED.value: - case undefined: - return {}; default: return { [SERVICE_ENVIRONMENT]: [environment] }; } @@ -54,21 +50,26 @@ export function AnalyzeDataButton() { if ( (isRumAgentName(agentName) || isIosAgentName(agentName)) && - canShowDashboard + rangeFrom && + canShowDashboard && + rangeTo ) { const href = createExploratoryViewUrl( { - 'apm-series': { - dataType: isRumAgentName(agentName) ? 'ux' : 'mobile', - time: { from: rangeFrom, to: rangeTo }, - reportType: 'kpi-over-time', - reportDefinitions: { - [SERVICE_NAME]: [serviceName], - ...getEnvironmentDefinition(environment), + reportType: 'kpi-over-time', + allSeries: [ + { + name: `${serviceName}-response-latency`, + selectedMetricField: TRANSACTION_DURATION, + dataType: isRumAgentName(agentName) ? 'ux' : 'mobile', + time: { from: rangeFrom, to: rangeTo }, + reportDefinitions: { + [SERVICE_NAME]: [serviceName], + ...(environment ? getEnvironmentDefinition(environment) : {}), + }, + operationType: 'average', }, - operationType: 'average', - isNew: true, - } as SeriesUrl, + ], }, basepath ); diff --git a/x-pack/plugins/apm/server/lib/rum_client/has_rum_data.ts b/x-pack/plugins/apm/server/lib/rum_client/has_rum_data.ts index ebb5c7655806a..9409e94fa9ba9 100644 --- a/x-pack/plugins/apm/server/lib/rum_client/has_rum_data.ts +++ b/x-pack/plugins/apm/server/lib/rum_client/has_rum_data.ts @@ -5,6 +5,7 @@ * 2.0. */ +import moment from 'moment'; import { SetupUX } from '../../routes/rum_client'; import { SERVICE_NAME, @@ -16,8 +17,8 @@ import { TRANSACTION_PAGE_LOAD } from '../../../common/transaction_types'; export async function hasRumData({ setup, - start, - end, + start = moment().subtract(24, 'h').valueOf(), + end = moment().valueOf(), }: { setup: SetupUX; start?: number; diff --git a/x-pack/plugins/observability/public/components/shared/add_data_buttons/mobile_add_data.tsx b/x-pack/plugins/observability/public/components/shared/add_data_buttons/mobile_add_data.tsx new file mode 100644 index 0000000000000..0e17c6277618b --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/add_data_buttons/mobile_add_data.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiHeaderLink } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { useKibana } from '../../../utils/kibana_react'; + +export function MobileAddData() { + const kibana = useKibana(); + + return ( + + {ADD_DATA_LABEL} + + ); +} + +const ADD_DATA_LABEL = i18n.translate('xpack.observability.mobile.addDataButtonLabel', { + defaultMessage: 'Add Mobile data', +}); diff --git a/x-pack/plugins/observability/public/components/shared/add_data_buttons/synthetics_add_data.tsx b/x-pack/plugins/observability/public/components/shared/add_data_buttons/synthetics_add_data.tsx new file mode 100644 index 0000000000000..af91624769e6b --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/add_data_buttons/synthetics_add_data.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiHeaderLink } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { useKibana } from '../../../utils/kibana_react'; + +export function SyntheticsAddData() { + const kibana = useKibana(); + + return ( + + {ADD_DATA_LABEL} + + ); +} + +const ADD_DATA_LABEL = i18n.translate('xpack.observability..synthetics.addDataButtonLabel', { + defaultMessage: 'Add synthetics data', +}); diff --git a/x-pack/plugins/observability/public/components/shared/add_data_buttons/ux_add_data.tsx b/x-pack/plugins/observability/public/components/shared/add_data_buttons/ux_add_data.tsx new file mode 100644 index 0000000000000..c6aa0742466f1 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/add_data_buttons/ux_add_data.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiHeaderLink } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { useKibana } from '../../../utils/kibana_react'; + +export function UXAddData() { + const kibana = useKibana(); + + return ( + + {ADD_DATA_LABEL} + + ); +} + +const ADD_DATA_LABEL = i18n.translate('xpack.observability.ux.addDataButtonLabel', { + defaultMessage: 'Add UX data', +}); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/components/action_menu/action_menu.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/components/action_menu/action_menu.test.tsx new file mode 100644 index 0000000000000..2b59628c3e8d3 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/components/action_menu/action_menu.test.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render } from '../../rtl_helpers'; +import { fireEvent, screen } from '@testing-library/dom'; +import React from 'react'; +import { sampleAttribute } from '../../configurations/test_data/sample_attribute'; +import * as pluginHook from '../../../../../hooks/use_plugin_context'; +import { TypedLensByValueInput } from '../../../../../../../lens/public'; +import { ExpViewActionMenuContent } from './action_menu'; + +jest.spyOn(pluginHook, 'usePluginContext').mockReturnValue({ + appMountParameters: { + setHeaderActionMenu: jest.fn(), + }, +} as any); + +describe('Action Menu', function () { + it('should be able to click open in lens', async function () { + const { findByText, core } = render( + + ); + + expect(await screen.findByText('Open in Lens')).toBeInTheDocument(); + + fireEvent.click(await findByText('Open in Lens')); + + expect(core.lens?.navigateToPrefilledEditor).toHaveBeenCalledTimes(1); + expect(core.lens?.navigateToPrefilledEditor).toHaveBeenCalledWith( + { + id: '', + attributes: sampleAttribute, + timeRange: { to: 'now', from: 'now-10m' }, + }, + { + openInNewTab: true, + } + ); + }); + + it('should be able to click save', async function () { + const { findByText } = render( + + ); + + expect(await screen.findByText('Save')).toBeInTheDocument(); + + fireEvent.click(await findByText('Save')); + + expect(await screen.findByText('Lens Save Modal Component')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/components/action_menu/action_menu.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/components/action_menu/action_menu.tsx new file mode 100644 index 0000000000000..08b4a3b948c57 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/components/action_menu/action_menu.tsx @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { LensEmbeddableInput, TypedLensByValueInput } from '../../../../../../../lens/public'; +import { ObservabilityAppServices } from '../../../../../application/types'; +import { useKibana } from '../../../../../../../../../src/plugins/kibana_react/public'; +import { AddToCaseAction } from '../../header/add_to_case_action'; + +export function ExpViewActionMenuContent({ + timeRange, + lensAttributes, +}: { + timeRange?: { from: string; to: string }; + lensAttributes: TypedLensByValueInput['attributes'] | null; +}) { + const kServices = useKibana().services; + + const { lens } = kServices; + + const [isSaveOpen, setIsSaveOpen] = useState(false); + + const LensSaveModalComponent = lens.SaveModalComponent; + + return ( + <> + + + + + + { + if (lensAttributes) { + lens.navigateToPrefilledEditor( + { + id: '', + timeRange, + attributes: lensAttributes, + }, + { + openInNewTab: true, + } + ); + } + }} + > + {i18n.translate('xpack.observability.expView.heading.openInLens', { + defaultMessage: 'Open in Lens', + })} + + + + { + if (lensAttributes) { + setIsSaveOpen(true); + } + }} + size="s" + > + {i18n.translate('xpack.observability.expView.heading.saveLensVisualization', { + defaultMessage: 'Save', + })} + + + + + {isSaveOpen && lensAttributes && ( + setIsSaveOpen(false)} + // if we want to do anything after the viz is saved + // right now there is no action, so an empty function + onSave={() => {}} + /> + )} + + ); +} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/components/action_menu/index.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/components/action_menu/index.tsx new file mode 100644 index 0000000000000..23500b63e900a --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/components/action_menu/index.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { ExpViewActionMenuContent } from './action_menu'; +import HeaderMenuPortal from '../../../header_menu_portal'; +import { usePluginContext } from '../../../../../hooks/use_plugin_context'; +import { TypedLensByValueInput } from '../../../../../../../lens/public'; + +interface Props { + timeRange?: { from: string; to: string }; + lensAttributes: TypedLensByValueInput['attributes'] | null; +} +export function ExpViewActionMenu(props: Props) { + const { appMountParameters } = usePluginContext(); + + return ( + + + + ); +} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/date_range_picker.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/components/date_range_picker.tsx similarity index 58% rename from x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/date_range_picker.tsx rename to x-pack/plugins/observability/public/components/shared/exploratory_view/components/date_range_picker.tsx index c30863585b3b0..aabde404aa7b4 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/date_range_picker.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/components/date_range_picker.tsx @@ -6,48 +6,48 @@ */ import React from 'react'; -import { i18n } from '@kbn/i18n'; import { EuiDatePicker, EuiDatePickerRange } from '@elastic/eui'; -import DateMath from '@elastic/datemath'; import { Moment } from 'moment'; +import DateMath from '@elastic/datemath'; +import { i18n } from '@kbn/i18n'; import { useSeriesStorage } from '../hooks/use_series_storage'; import { useUiSetting } from '../../../../../../../../src/plugins/kibana_react/public'; +import { SeriesUrl } from '../types'; +import { ReportTypes } from '../configurations/constants'; export const parseAbsoluteDate = (date: string, options = {}) => { return DateMath.parse(date, options)!; }; -export function DateRangePicker({ seriesId }: { seriesId: string }) { - const { firstSeriesId, getSeries, setSeries } = useSeriesStorage(); +export function DateRangePicker({ seriesId, series }: { seriesId: number; series: SeriesUrl }) { + const { firstSeries, setSeries, reportType } = useSeriesStorage(); const dateFormat = useUiSetting('dateFormat'); - const { - time: { from, to }, - reportType, - } = getSeries(firstSeriesId); + const seriesFrom = series.time?.from; + const seriesTo = series.time?.to; - const series = getSeries(seriesId); + const { from: mainFrom, to: mainTo } = firstSeries!.time; - const { - time: { from: seriesFrom, to: seriesTo }, - } = series; + const startDate = parseAbsoluteDate(seriesFrom ?? mainFrom)!; + const endDate = parseAbsoluteDate(seriesTo ?? mainTo, { roundUp: true })!; - const startDate = parseAbsoluteDate(seriesFrom ?? from)!; - const endDate = parseAbsoluteDate(seriesTo ?? to, { roundUp: true })!; + const getTotalDuration = () => { + const mainStartDate = parseAbsoluteDate(mainFrom)!; + const mainEndDate = parseAbsoluteDate(mainTo, { roundUp: true })!; + return mainEndDate.diff(mainStartDate, 'millisecond'); + }; - const onStartChange = (newDate: Moment) => { - if (reportType === 'kpi-over-time') { - const mainStartDate = parseAbsoluteDate(from)!; - const mainEndDate = parseAbsoluteDate(to, { roundUp: true })!; - const totalDuration = mainEndDate.diff(mainStartDate, 'millisecond'); - const newFrom = newDate.toISOString(); - const newTo = newDate.add(totalDuration, 'millisecond').toISOString(); + const onStartChange = (newStartDate: Moment) => { + if (reportType === ReportTypes.KPI) { + const totalDuration = getTotalDuration(); + const newFrom = newStartDate.toISOString(); + const newTo = newStartDate.add(totalDuration, 'millisecond').toISOString(); setSeries(seriesId, { ...series, time: { from: newFrom, to: newTo }, }); } else { - const newFrom = newDate.toISOString(); + const newFrom = newStartDate.toISOString(); setSeries(seriesId, { ...series, @@ -55,20 +55,19 @@ export function DateRangePicker({ seriesId }: { seriesId: string }) { }); } }; - const onEndChange = (newDate: Moment) => { - if (reportType === 'kpi-over-time') { - const mainStartDate = parseAbsoluteDate(from)!; - const mainEndDate = parseAbsoluteDate(to, { roundUp: true })!; - const totalDuration = mainEndDate.diff(mainStartDate, 'millisecond'); - const newTo = newDate.toISOString(); - const newFrom = newDate.subtract(totalDuration, 'millisecond').toISOString(); + + const onEndChange = (newEndDate: Moment) => { + if (reportType === ReportTypes.KPI) { + const totalDuration = getTotalDuration(); + const newTo = newEndDate.toISOString(); + const newFrom = newEndDate.subtract(totalDuration, 'millisecond').toISOString(); setSeries(seriesId, { ...series, time: { from: newFrom, to: newTo }, }); } else { - const newTo = newDate.toISOString(); + const newTo = newEndDate.toISOString(); setSeries(seriesId, { ...series, @@ -90,7 +89,7 @@ export function DateRangePicker({ seriesId }: { seriesId: string }) { aria-label={i18n.translate('xpack.observability.expView.dateRanger.startDate', { defaultMessage: 'Start date', })} - dateFormat={dateFormat} + dateFormat={dateFormat.replace('ss.SSS', 'ss')} showTimeSelect /> } @@ -104,7 +103,7 @@ export function DateRangePicker({ seriesId }: { seriesId: string }) { aria-label={i18n.translate('xpack.observability.expView.dateRanger.endDate', { defaultMessage: 'End date', })} - dateFormat={dateFormat} + dateFormat={dateFormat.replace('ss.SSS', 'ss')} showTimeSelect /> } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/components/empty_view.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/components/empty_view.tsx index 3566835b1701c..d17e451ef702c 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/components/empty_view.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/components/empty_view.tsx @@ -10,19 +10,19 @@ import { isEmpty } from 'lodash'; import { EuiFlexGroup, EuiFlexItem, EuiProgress, EuiSpacer, EuiText } from '@elastic/eui'; import styled from 'styled-components'; import { i18n } from '@kbn/i18n'; -import { LOADING_VIEW } from '../series_builder/series_builder'; -import { SeriesUrl } from '../types'; +import { LOADING_VIEW } from '../series_editor/series_editor'; +import { ReportViewType, SeriesUrl } from '../types'; export function EmptyView({ loading, - height, series, + reportType, }: { loading: boolean; - height: string; - series: SeriesUrl; + series?: SeriesUrl; + reportType: ReportViewType; }) { - const { dataType, reportType, reportDefinitions } = series ?? {}; + const { dataType, reportDefinitions } = series ?? {}; let emptyMessage = EMPTY_LABEL; @@ -45,7 +45,7 @@ export function EmptyView({ } return ( - + {loading && ( ` +const Wrapper = styled.div` text-align: center; - height: ${(props) => props.height}; position: relative; `; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/components/filter_label.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/components/filter_label.test.tsx index fe2953edd36d6..03fd23631f755 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/components/filter_label.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/components/filter_label.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { fireEvent, screen, waitFor } from '@testing-library/react'; -import { mockAppIndexPattern, mockIndexPattern, render } from '../rtl_helpers'; +import { mockAppIndexPattern, mockIndexPattern, mockUxSeries, render } from '../rtl_helpers'; import { FilterLabel } from './filter_label'; import * as useSeriesHook from '../hooks/use_series_filters'; import { buildFilterLabel } from '../../filter_value_label/filter_value_label'; @@ -27,9 +27,10 @@ describe('FilterLabel', function () { value={'elastic-co'} label={'Web Application'} negate={false} - seriesId={'kpi-over-time'} + seriesId={0} removeFilter={jest.fn()} indexPattern={mockIndexPattern} + series={mockUxSeries} /> ); @@ -51,9 +52,10 @@ describe('FilterLabel', function () { value={'elastic-co'} label={'Web Application'} negate={false} - seriesId={'kpi-over-time'} + seriesId={0} removeFilter={removeFilter} indexPattern={mockIndexPattern} + series={mockUxSeries} /> ); @@ -74,9 +76,10 @@ describe('FilterLabel', function () { value={'elastic-co'} label={'Web Application'} negate={false} - seriesId={'kpi-over-time'} + seriesId={0} removeFilter={removeFilter} indexPattern={mockIndexPattern} + series={mockUxSeries} /> ); @@ -100,9 +103,10 @@ describe('FilterLabel', function () { value={'elastic-co'} label={'Web Application'} negate={true} - seriesId={'kpi-over-time'} + seriesId={0} removeFilter={jest.fn()} indexPattern={mockIndexPattern} + series={mockUxSeries} /> ); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/components/filter_label.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/components/filter_label.tsx index a08e777c5ea71..c6254a85de9ac 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/components/filter_label.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/components/filter_label.tsx @@ -9,21 +9,24 @@ import React from 'react'; import { IndexPattern } from '../../../../../../../../src/plugins/data/public'; import { useSeriesFilters } from '../hooks/use_series_filters'; import { FilterValueLabel } from '../../filter_value_label/filter_value_label'; +import { SeriesUrl } from '../types'; interface Props { field: string; label: string; - value: string; - seriesId: string; + value: string | string[]; + seriesId: number; + series: SeriesUrl; negate: boolean; definitionFilter?: boolean; indexPattern: IndexPattern; - removeFilter: (field: string, value: string, notVal: boolean) => void; + removeFilter: (field: string, value: string | string[], notVal: boolean) => void; } export function FilterLabel({ label, seriesId, + series, field, value, negate, @@ -31,7 +34,7 @@ export function FilterLabel({ removeFilter, definitionFilter, }: Props) { - const { invertFilter } = useSeriesFilters({ seriesId }); + const { invertFilter } = useSeriesFilters({ seriesId, series }); return indexPattern ? ( { + setSeries(seriesId, { ...series, color: colorN }); + }; + + const color = + series.color ?? (theme.eui as unknown as Record)[`euiColorVis${seriesId}`]; + + const button = ( + + setIsOpen((prevState) => !prevState)} flush="both"> + + + + ); + + return ( + setIsOpen(false)}> + + + + + ); +} + +const PICK_A_COLOR_LABEL = i18n.translate( + 'xpack.observability.overview.exploratoryView.pickColor', + { + defaultMessage: 'Pick a color', + } +); + +const EDIT_SERIES_COLOR_LABEL = i18n.translate( + 'xpack.observability.overview.exploratoryView.editSeriesColor', + { + defaultMessage: 'Edit color for series', + } +); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/index.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/components/series_date_picker/index.tsx similarity index 54% rename from x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/index.tsx rename to x-pack/plugins/observability/public/components/shared/exploratory_view/components/series_date_picker/index.tsx index e21da424b58c8..e02f11dfc4954 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_date_picker/index.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/components/series_date_picker/index.tsx @@ -6,11 +6,13 @@ */ import { EuiSuperDatePicker } from '@elastic/eui'; -import React, { useEffect } from 'react'; -import { useHasData } from '../../../../hooks/use_has_data'; -import { useSeriesStorage } from '../hooks/use_series_storage'; -import { useQuickTimeRanges } from '../../../../hooks/use_quick_time_ranges'; -import { DEFAULT_TIME } from '../configurations/constants'; +import React from 'react'; + +import { useHasData } from '../../../../../hooks/use_has_data'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; +import { useQuickTimeRanges } from '../../../../../hooks/use_quick_time_ranges'; +import { SeriesUrl } from '../../types'; +import { ReportTypes } from '../../configurations/constants'; export interface TimePickerTime { from: string; @@ -22,28 +24,27 @@ export interface TimePickerQuickRange extends TimePickerTime { } interface Props { - seriesId: string; + seriesId: number; + series: SeriesUrl; } -export function SeriesDatePicker({ seriesId }: Props) { +export function SeriesDatePicker({ series, seriesId }: Props) { const { onRefreshTimeRange } = useHasData(); const commonlyUsedRanges = useQuickTimeRanges(); - const { getSeries, setSeries } = useSeriesStorage(); - - const series = getSeries(seriesId); + const { setSeries, reportType, allSeries } = useSeriesStorage(); function onTimeChange({ start, end }: { start: string; end: string }) { onRefreshTimeRange(); - setSeries(seriesId, { ...series, time: { from: start, to: end } }); - } - - useEffect(() => { - if (!series || !series.time) { - setSeries(seriesId, { ...series, time: DEFAULT_TIME }); + if (reportType === ReportTypes.KPI) { + allSeries.forEach((currSeries, seriesIndex) => { + setSeries(seriesIndex, { ...currSeries, time: { from: start, to: end } }); + }); + } else { + setSeries(seriesId, { ...series, time: { from: start, to: end } }); } - }, [series, seriesId, setSeries]); + } return ( , { initSeries }); + const { getByText } = render(, { + initSeries, + }); getByText('Last 30 minutes'); }); - it('should set defaults', async function () { - const initSeries = { - data: { - 'uptime-pings-histogram': { - reportType: 'kpi-over-time' as const, - dataType: 'synthetics' as const, - breakdown: 'monitor.status', - }, - }, - }; - const { setSeries: setSeries1 } = render( - , - { initSeries: initSeries as any } - ); - expect(setSeries1).toHaveBeenCalledTimes(1); - expect(setSeries1).toHaveBeenCalledWith('uptime-pings-histogram', { - breakdown: 'monitor.status', - dataType: 'synthetics' as const, - reportType: 'kpi-over-time' as const, - time: DEFAULT_TIME, - }); - }); - it('should set series data', async function () { const initSeries = { - data: { - 'uptime-pings-histogram': { + data: [ + { + name: 'uptime-pings-histogram', dataType: 'synthetics' as const, - reportType: 'kpi-over-time' as const, breakdown: 'monitor.status', time: { from: 'now-30m', to: 'now' }, }, - }, + ], }; const { onRefreshTimeRange } = mockUseHasData(); - const { getByTestId, setSeries } = render(, { - initSeries, - }); + const { getByTestId, setSeries } = render( + , + { + initSeries, + } + ); await waitFor(function () { fireEvent.click(getByTestId('superDatePickerToggleQuickMenuButton')); @@ -76,10 +57,10 @@ describe('SeriesDatePicker', function () { expect(onRefreshTimeRange).toHaveBeenCalledTimes(1); - expect(setSeries).toHaveBeenCalledWith('series-id', { + expect(setSeries).toHaveBeenCalledWith(0, { + name: 'uptime-pings-histogram', breakdown: 'monitor.status', dataType: 'synthetics', - reportType: 'kpi-over-time', time: { from: 'now/d', to: 'now/d' }, }); expect(setSeries).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts index ba1f2214223e3..bf5feb7d5863c 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts @@ -94,6 +94,19 @@ export const DataViewLabels: Record = { 'device-data-distribution': DEVICE_DISTRIBUTION_LABEL, }; +export enum ReportTypes { + KPI = 'kpi-over-time', + DISTRIBUTION = 'data-distribution', + CORE_WEB_VITAL = 'core-web-vitals', + DEVICE_DISTRIBUTION = 'device-data-distribution', +} + +export enum DataTypes { + SYNTHETICS = 'synthetics', + UX = 'ux', + MOBILE = 'mobile', +} + export const USE_BREAK_DOWN_COLUMN = 'USE_BREAK_DOWN_COLUMN'; export const FILTER_RECORDS = 'FILTER_RECORDS'; export const TERMS_COLUMN = 'TERMS_COLUMN'; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/url_constants.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/url_constants.ts index 6f990015fbc62..55ac75b47c056 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/url_constants.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/url_constants.ts @@ -8,10 +8,12 @@ export enum URL_KEYS { DATA_TYPE = 'dt', OPERATION_TYPE = 'op', - REPORT_TYPE = 'rt', SERIES_TYPE = 'st', BREAK_DOWN = 'bd', FILTERS = 'ft', REPORT_DEFINITIONS = 'rdf', SELECTED_METRIC = 'mt', + HIDDEN = 'h', + NAME = 'n', + COLOR = 'c', } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/default_configs.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/default_configs.ts index 574a9f6a2bc10..3f6551986527c 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/default_configs.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/default_configs.ts @@ -15,6 +15,7 @@ import { getCoreWebVitalsConfig } from './rum/core_web_vitals_config'; import { getMobileKPIConfig } from './mobile/kpi_over_time_config'; import { getMobileKPIDistributionConfig } from './mobile/distribution_config'; import { getMobileDeviceDistributionConfig } from './mobile/device_distribution_config'; +import { DataTypes, ReportTypes } from './constants'; interface Props { reportType: ReportViewType; @@ -24,24 +25,24 @@ interface Props { export const getDefaultConfigs = ({ reportType, dataType, indexPattern }: Props) => { switch (dataType) { - case 'ux': - if (reportType === 'data-distribution') { + case DataTypes.UX: + if (reportType === ReportTypes.DISTRIBUTION) { return getRumDistributionConfig({ indexPattern }); } - if (reportType === 'core-web-vitals') { + if (reportType === ReportTypes.CORE_WEB_VITAL) { return getCoreWebVitalsConfig({ indexPattern }); } return getKPITrendsLensConfig({ indexPattern }); - case 'synthetics': - if (reportType === 'data-distribution') { + case DataTypes.SYNTHETICS: + if (reportType === ReportTypes.DISTRIBUTION) { return getSyntheticsDistributionConfig({ indexPattern }); } return getSyntheticsKPIConfig({ indexPattern }); - case 'mobile': - if (reportType === 'data-distribution') { + case DataTypes.MOBILE: + if (reportType === ReportTypes.DISTRIBUTION) { return getMobileKPIDistributionConfig({ indexPattern }); } - if (reportType === 'device-data-distribution') { + if (reportType === ReportTypes.DEVICE_DISTRIBUTION) { return getMobileDeviceDistributionConfig({ indexPattern }); } return getMobileKPIConfig({ indexPattern }); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts index 706c58609b7cb..9e7c5254b511f 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.test.ts @@ -16,7 +16,7 @@ import { } from './constants/elasticsearch_fieldnames'; import { buildExistsFilter, buildPhrasesFilter } from './utils'; import { sampleAttributeKpi } from './test_data/sample_attribute_kpi'; -import { REPORT_METRIC_FIELD } from './constants'; +import { RECORDS_FIELD, REPORT_METRIC_FIELD, ReportTypes } from './constants'; describe('Lens Attribute', () => { mockAppIndexPattern(); @@ -38,6 +38,9 @@ describe('Lens Attribute', () => { indexPattern: mockIndexPattern, reportDefinitions: {}, time: { from: 'now-15m', to: 'now' }, + color: 'green', + name: 'test-series', + selectedMetricField: TRANSACTION_DURATION, }; beforeEach(() => { @@ -50,7 +53,7 @@ describe('Lens Attribute', () => { it('should return expected json for kpi report type', function () { const seriesConfigKpi = getDefaultConfigs({ - reportType: 'kpi-over-time', + reportType: ReportTypes.KPI, dataType: 'ux', indexPattern: mockIndexPattern, }); @@ -63,6 +66,9 @@ describe('Lens Attribute', () => { indexPattern: mockIndexPattern, reportDefinitions: { 'service.name': ['elastic-co'] }, time: { from: 'now-15m', to: 'now' }, + color: 'green', + name: 'test-series', + selectedMetricField: RECORDS_FIELD, }, ]); @@ -135,6 +141,9 @@ describe('Lens Attribute', () => { indexPattern: mockIndexPattern, reportDefinitions: { 'performance.metric': [LCP_FIELD] }, time: { from: 'now-15m', to: 'now' }, + color: 'green', + name: 'test-series', + selectedMetricField: TRANSACTION_DURATION, }; lnsAttr = new LensAttributes([layerConfig1]); @@ -383,7 +392,7 @@ describe('Lens Attribute', () => { palette: undefined, seriesType: 'line', xAccessor: 'x-axis-column-layer0', - yConfig: [{ forAccessor: 'y-axis-column-layer0' }], + yConfig: [{ color: 'green', forAccessor: 'y-axis-column-layer0' }], }, ], legend: { isVisible: true, position: 'right' }, @@ -403,6 +412,9 @@ describe('Lens Attribute', () => { reportDefinitions: { 'performance.metric': [LCP_FIELD] }, breakdown: USER_AGENT_NAME, time: { from: 'now-15m', to: 'now' }, + color: 'green', + name: 'test-series', + selectedMetricField: TRANSACTION_DURATION, }; lnsAttr = new LensAttributes([layerConfig1]); @@ -423,7 +435,7 @@ describe('Lens Attribute', () => { seriesType: 'line', splitAccessor: 'breakdown-column-layer0', xAccessor: 'x-axis-column-layer0', - yConfig: [{ forAccessor: 'y-axis-column-layer0' }], + yConfig: [{ color: 'green', forAccessor: 'y-axis-column-layer0' }], }, ]); @@ -589,6 +601,9 @@ describe('Lens Attribute', () => { indexPattern: mockIndexPattern, reportDefinitions: { 'performance.metric': [LCP_FIELD] }, time: { from: 'now-15m', to: 'now' }, + color: 'green', + name: 'test-series', + selectedMetricField: TRANSACTION_DURATION, }; const filters = lnsAttr.getLayerFilters(layerConfig1, 2); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts index 2778edc94838e..ec2e6b5066c87 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes.ts @@ -37,10 +37,11 @@ import { REPORT_METRIC_FIELD, RECORDS_FIELD, RECORDS_PERCENTAGE_FIELD, + ReportTypes, } from './constants'; import { ColumnFilter, SeriesConfig, UrlFilter, URLReportDefinition } from '../types'; import { PersistableFilter } from '../../../../../../lens/common'; -import { parseAbsoluteDate } from '../series_date_picker/date_range_picker'; +import { parseAbsoluteDate } from '../components/date_range_picker'; import { getDistributionInPercentageColumn } from './lens_columns/overall_column'; function getLayerReferenceName(layerId: string) { @@ -74,14 +75,6 @@ export const parseCustomFieldName = (seriesConfig: SeriesConfig, selectedMetricF timeScale = currField?.timeScale; columnLabel = currField?.label; } - } else if (metricOptions?.[0].field || metricOptions?.[0].id) { - const firstMetricOption = metricOptions?.[0]; - - selectedMetricField = firstMetricOption.field || firstMetricOption.id; - columnType = firstMetricOption.columnType; - columnFilters = firstMetricOption.columnFilters; - timeScale = firstMetricOption.timeScale; - columnLabel = firstMetricOption.label; } return { fieldName: selectedMetricField!, columnType, columnFilters, timeScale, columnLabel }; @@ -96,7 +89,9 @@ export interface LayerConfig { reportDefinitions: URLReportDefinition; time: { to: string; from: string }; indexPattern: IndexPattern; - selectedMetricField?: string; + selectedMetricField: string; + color: string; + name: string; } export class LensAttributes { @@ -467,14 +462,15 @@ export class LensAttributes { getLayerFilters(layerConfig: LayerConfig, totalLayers: number) { const { filters, - time: { from, to }, + time, seriesConfig: { baseFilters: layerFilters, reportType }, } = layerConfig; let baseFilters = ''; - if (reportType !== 'kpi-over-time' && totalLayers > 1) { + + if (reportType !== ReportTypes.KPI && totalLayers > 1 && time) { // for kpi over time, we don't need to add time range filters // since those are essentially plotted along the x-axis - baseFilters += `@timestamp >= ${from} and @timestamp <= ${to}`; + baseFilters += `@timestamp >= ${time.from} and @timestamp <= ${time.to}`; } layerFilters?.forEach((filter: PersistableFilter | ExistsFilter) => { @@ -530,7 +526,11 @@ export class LensAttributes { } getTimeShift(mainLayerConfig: LayerConfig, layerConfig: LayerConfig, index: number) { - if (index === 0 || mainLayerConfig.seriesConfig.reportType !== 'kpi-over-time') { + if ( + index === 0 || + mainLayerConfig.seriesConfig.reportType !== ReportTypes.KPI || + !layerConfig.time + ) { return null; } @@ -542,11 +542,14 @@ export class LensAttributes { time: { from }, } = layerConfig; - const inDays = parseAbsoluteDate(mainFrom).diff(parseAbsoluteDate(from), 'days'); + const inDays = Math.abs(parseAbsoluteDate(mainFrom).diff(parseAbsoluteDate(from), 'days')); if (inDays > 1) { return inDays + 'd'; } - const inHours = parseAbsoluteDate(mainFrom).diff(parseAbsoluteDate(from), 'hours'); + const inHours = Math.abs(parseAbsoluteDate(mainFrom).diff(parseAbsoluteDate(from), 'hours')); + if (inHours === 0) { + return null; + } return inHours + 'h'; } @@ -564,6 +567,8 @@ export class LensAttributes { const { sourceField } = seriesConfig.xAxisColumn; + const label = timeShift ? `${mainYAxis.label}(${timeShift})` : mainYAxis.label; + layers[layerId] = { columnOrder: [ `x-axis-column-${layerId}`, @@ -577,7 +582,7 @@ export class LensAttributes { [`x-axis-column-${layerId}`]: this.getXAxis(layerConfig, layerId), [`y-axis-column-${layerId}`]: { ...mainYAxis, - label: timeShift ? `${mainYAxis.label}(${timeShift})` : mainYAxis.label, + label, filter: { query: columnFilter, language: 'kuery' }, ...(timeShift ? { timeShift } : {}), }, @@ -621,7 +626,7 @@ export class LensAttributes { seriesType: layerConfig.seriesType || layerConfig.seriesConfig.defaultSeriesType, palette: layerConfig.seriesConfig.palette, yConfig: layerConfig.seriesConfig.yConfig || [ - { forAccessor: `y-axis-column-layer${index}` }, + { forAccessor: `y-axis-column-layer${index}`, color: layerConfig.color }, ], xAccessor: `x-axis-column-layer${index}`, ...(layerConfig.breakdown && @@ -635,7 +640,7 @@ export class LensAttributes { }; } - getJSON(): TypedLensByValueInput['attributes'] { + getJSON(refresh?: number): TypedLensByValueInput['attributes'] { const uniqueIndexPatternsIds = Array.from( new Set([...this.layerConfigs.map(({ indexPattern }) => indexPattern.id)]) ); @@ -644,7 +649,7 @@ export class LensAttributes { return { title: 'Prefilled from exploratory view app', - description: '', + description: String(refresh), visualizationType: 'lnsXY', references: [ ...uniqueIndexPatternsIds.map((patternId) => ({ diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/device_distribution_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/device_distribution_config.ts index d1612a08f5551..4e178bba7e02a 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/device_distribution_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/device_distribution_config.ts @@ -6,7 +6,7 @@ */ import { ConfigProps, SeriesConfig } from '../../types'; -import { FieldLabels, REPORT_METRIC_FIELD, USE_BREAK_DOWN_COLUMN } from '../constants'; +import { FieldLabels, REPORT_METRIC_FIELD, ReportTypes, USE_BREAK_DOWN_COLUMN } from '../constants'; import { buildPhraseFilter } from '../utils'; import { SERVICE_NAME } from '../constants/elasticsearch_fieldnames'; import { MOBILE_APP, NUMBER_OF_DEVICES } from '../constants/labels'; @@ -14,7 +14,7 @@ import { MobileFields } from './mobile_fields'; export function getMobileDeviceDistributionConfig({ indexPattern }: ConfigProps): SeriesConfig { return { - reportType: 'device-data-distribution', + reportType: ReportTypes.DEVICE_DISTRIBUTION, defaultSeriesType: 'bar', seriesTypes: ['bar', 'bar_horizontal'], xAxisColumn: { @@ -38,13 +38,13 @@ export function getMobileDeviceDistributionConfig({ indexPattern }: ConfigProps) ...MobileFields, [SERVICE_NAME]: MOBILE_APP, }, + definitionFields: [SERVICE_NAME], metricOptions: [ { - id: 'labels.device_id', field: 'labels.device_id', + id: 'labels.device_id', label: NUMBER_OF_DEVICES, }, ], - definitionFields: [SERVICE_NAME], }; } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/distribution_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/distribution_config.ts index 9b1c4c8da3e9b..1da27be4fcc95 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/distribution_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/distribution_config.ts @@ -6,7 +6,7 @@ */ import { ConfigProps, SeriesConfig } from '../../types'; -import { FieldLabels, RECORDS_FIELD, REPORT_METRIC_FIELD } from '../constants'; +import { FieldLabels, RECORDS_FIELD, REPORT_METRIC_FIELD, ReportTypes } from '../constants'; import { buildPhrasesFilter } from '../utils'; import { METRIC_SYSTEM_CPU_USAGE, @@ -21,7 +21,7 @@ import { MobileFields } from './mobile_fields'; export function getMobileKPIDistributionConfig({ indexPattern }: ConfigProps): SeriesConfig { return { - reportType: 'data-distribution', + reportType: ReportTypes.DISTRIBUTION, defaultSeriesType: 'bar', seriesTypes: ['line', 'bar'], xAxisColumn: { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/kpi_over_time_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/kpi_over_time_config.ts index 945a631078a33..3ee5b3125fcda 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/kpi_over_time_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/mobile/kpi_over_time_config.ts @@ -6,7 +6,13 @@ */ import { ConfigProps, SeriesConfig } from '../../types'; -import { FieldLabels, OPERATION_COLUMN, RECORDS_FIELD, REPORT_METRIC_FIELD } from '../constants'; +import { + FieldLabels, + OPERATION_COLUMN, + RECORDS_FIELD, + REPORT_METRIC_FIELD, + ReportTypes, +} from '../constants'; import { buildPhrasesFilter } from '../utils'; import { METRIC_SYSTEM_CPU_USAGE, @@ -26,7 +32,7 @@ import { MobileFields } from './mobile_fields'; export function getMobileKPIConfig({ indexPattern }: ConfigProps): SeriesConfig { return { - reportType: 'kpi-over-time', + reportType: ReportTypes.KPI, defaultSeriesType: 'line', seriesTypes: ['line', 'bar', 'area'], xAxisColumn: { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/core_web_vitals_config.test.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/core_web_vitals_config.test.ts index 07bb13f957e45..35e094996f6f2 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/core_web_vitals_config.test.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/core_web_vitals_config.test.ts @@ -9,7 +9,7 @@ import { mockAppIndexPattern, mockIndexPattern } from '../../rtl_helpers'; import { getDefaultConfigs } from '../default_configs'; import { LayerConfig, LensAttributes } from '../lens_attributes'; import { sampleAttributeCoreWebVital } from '../test_data/sample_attribute_cwv'; -import { SERVICE_NAME, USER_AGENT_OS } from '../constants/elasticsearch_fieldnames'; +import { LCP_FIELD, SERVICE_NAME, USER_AGENT_OS } from '../constants/elasticsearch_fieldnames'; describe('Core web vital config test', function () { mockAppIndexPattern(); @@ -24,10 +24,13 @@ describe('Core web vital config test', function () { const layerConfig: LayerConfig = { seriesConfig, + color: 'green', + name: 'test-series', + breakdown: USER_AGENT_OS, indexPattern: mockIndexPattern, - reportDefinitions: { [SERVICE_NAME]: ['elastic-co'] }, time: { from: 'now-15m', to: 'now' }, - breakdown: USER_AGENT_OS, + reportDefinitions: { [SERVICE_NAME]: ['elastic-co'] }, + selectedMetricField: LCP_FIELD, }; beforeEach(() => { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/core_web_vitals_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/core_web_vitals_config.ts index 62455df248085..e8d620388a89e 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/core_web_vitals_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/core_web_vitals_config.ts @@ -11,6 +11,7 @@ import { FieldLabels, FILTER_RECORDS, REPORT_METRIC_FIELD, + ReportTypes, USE_BREAK_DOWN_COLUMN, } from '../constants'; import { buildPhraseFilter } from '../utils'; @@ -38,7 +39,7 @@ export function getCoreWebVitalsConfig({ indexPattern }: ConfigProps): SeriesCon return { defaultSeriesType: 'bar_horizontal_percentage_stacked', - reportType: 'core-web-vitals', + reportType: ReportTypes.CORE_WEB_VITAL, seriesTypes: ['bar_horizontal_percentage_stacked'], xAxisColumn: { sourceField: USE_BREAK_DOWN_COLUMN, @@ -153,5 +154,6 @@ export function getCoreWebVitalsConfig({ indexPattern }: ConfigProps): SeriesCon { color: statusPallete[1], forAccessor: 'y-axis-column-1' }, { color: statusPallete[2], forAccessor: 'y-axis-column-2' }, ], + query: { query: 'transaction.type: "page-load"', language: 'kuery' }, }; } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/data_distribution_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/data_distribution_config.ts index f34c8db6c197d..de6f2c67b2aeb 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/data_distribution_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/data_distribution_config.ts @@ -6,7 +6,12 @@ */ import { ConfigProps, SeriesConfig } from '../../types'; -import { FieldLabels, REPORT_METRIC_FIELD, RECORDS_PERCENTAGE_FIELD } from '../constants'; +import { + FieldLabels, + REPORT_METRIC_FIELD, + RECORDS_PERCENTAGE_FIELD, + ReportTypes, +} from '../constants'; import { buildPhraseFilter } from '../utils'; import { CLIENT_GEO_COUNTRY_NAME, @@ -41,7 +46,7 @@ import { export function getRumDistributionConfig({ indexPattern }: ConfigProps): SeriesConfig { return { - reportType: 'data-distribution', + reportType: ReportTypes.DISTRIBUTION, defaultSeriesType: 'line', seriesTypes: [], xAxisColumn: { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/kpi_over_time_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/kpi_over_time_config.ts index 5899b16d12b4f..9112778eadaa7 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/kpi_over_time_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/rum/kpi_over_time_config.ts @@ -6,7 +6,13 @@ */ import { ConfigProps, SeriesConfig } from '../../types'; -import { FieldLabels, OPERATION_COLUMN, RECORDS_FIELD, REPORT_METRIC_FIELD } from '../constants'; +import { + FieldLabels, + OPERATION_COLUMN, + RECORDS_FIELD, + REPORT_METRIC_FIELD, + ReportTypes, +} from '../constants'; import { buildPhraseFilter } from '../utils'; import { CLIENT_GEO_COUNTRY_NAME, @@ -43,7 +49,7 @@ export function getKPITrendsLensConfig({ indexPattern }: ConfigProps): SeriesCon return { defaultSeriesType: 'bar_stacked', seriesTypes: [], - reportType: 'kpi-over-time', + reportType: ReportTypes.KPI, xAxisColumn: { sourceField: '@timestamp', }, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/data_distribution_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/data_distribution_config.ts index 730e742f9d8c5..da90f45d15201 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/data_distribution_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/data_distribution_config.ts @@ -6,7 +6,12 @@ */ import { ConfigProps, SeriesConfig } from '../../types'; -import { FieldLabels, REPORT_METRIC_FIELD, RECORDS_PERCENTAGE_FIELD } from '../constants'; +import { + FieldLabels, + REPORT_METRIC_FIELD, + RECORDS_PERCENTAGE_FIELD, + ReportTypes, +} from '../constants'; import { CLS_LABEL, DCL_LABEL, @@ -30,7 +35,7 @@ export function getSyntheticsDistributionConfig({ indexPattern, }: ConfigProps): SeriesConfig { return { - reportType: 'data-distribution', + reportType: ReportTypes.DISTRIBUTION, defaultSeriesType: series?.seriesType || 'line', seriesTypes: [], xAxisColumn: { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/kpi_over_time_config.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/kpi_over_time_config.ts index 4ee22181d4334..65b43a83a8fb5 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/kpi_over_time_config.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/synthetics/kpi_over_time_config.ts @@ -6,7 +6,7 @@ */ import { ConfigProps, SeriesConfig } from '../../types'; -import { FieldLabels, OPERATION_COLUMN, REPORT_METRIC_FIELD } from '../constants'; +import { FieldLabels, OPERATION_COLUMN, REPORT_METRIC_FIELD, ReportTypes } from '../constants'; import { CLS_LABEL, DCL_LABEL, @@ -30,7 +30,7 @@ const SUMMARY_DOWN = 'summary.down'; export function getSyntheticsKPIConfig({ indexPattern }: ConfigProps): SeriesConfig { return { - reportType: 'kpi-over-time', + reportType: ReportTypes.KPI, defaultSeriesType: 'bar_stacked', seriesTypes: [], xAxisColumn: { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute.ts index 596e7af4378ec..7e0ea1e575481 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute.ts @@ -5,12 +5,18 @@ * 2.0. */ export const sampleAttribute = { - title: 'Prefilled from exploratory view app', - description: '', - visualizationType: 'lnsXY', + description: 'undefined', references: [ - { id: 'apm-*', name: 'indexpattern-datasource-current-indexpattern', type: 'index-pattern' }, - { id: 'apm-*', name: 'indexpattern-datasource-layer-layer0', type: 'index-pattern' }, + { + id: 'apm-*', + name: 'indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: 'apm-*', + name: 'indexpattern-datasource-layer-layer0', + type: 'index-pattern', + }, ], state: { datasourceStates: { @@ -28,17 +34,23 @@ export const sampleAttribute = { ], columns: { 'x-axis-column-layer0': { - sourceField: 'transaction.duration.us', - label: 'Page load time', dataType: 'number', - operationType: 'range', isBucketed: true, - scale: 'interval', + label: 'Page load time', + operationType: 'range', params: { - type: 'histogram', - ranges: [{ from: 0, to: 1000, label: '' }], maxBars: 'auto', + ranges: [ + { + from: 0, + label: '', + to: 1000, + }, + ], + type: 'histogram', }, + scale: 'interval', + sourceField: 'transaction.duration.us', }, 'y-axis-column-layer0': { dataType: 'number', @@ -81,16 +93,16 @@ export const sampleAttribute = { 'y-axis-column-layer0X1': { customLabel: true, dataType: 'number', - isBucketed: false, - label: 'Part of count() / overall_sum(count())', - operationType: 'count', - scale: 'ratio', - sourceField: 'Records', filter: { language: 'kuery', query: 'transaction.type: page-load and processor.event: transaction and transaction.type : *', }, + isBucketed: false, + label: 'Part of count() / overall_sum(count())', + operationType: 'count', + scale: 'ratio', + sourceField: 'Records', }, 'y-axis-column-layer0X2': { customLabel: true, @@ -140,27 +152,52 @@ export const sampleAttribute = { }, }, }, + filters: [], + query: { + language: 'kuery', + query: 'transaction.duration.us < 60000000', + }, visualization: { - legend: { isVisible: true, position: 'right' }, - valueLabels: 'hide', - fittingFunction: 'Linear', + axisTitlesVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, curveType: 'CURVE_MONOTONE_X', - axisTitlesVisibilitySettings: { x: true, yLeft: true, yRight: true }, - tickLabelsVisibilitySettings: { x: true, yLeft: true, yRight: true }, - gridlinesVisibilitySettings: { x: true, yLeft: true, yRight: true }, - preferredSeriesType: 'line', + fittingFunction: 'Linear', + gridlinesVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, layers: [ { accessors: ['y-axis-column-layer0'], layerId: 'layer0', layerType: 'data', seriesType: 'line', - yConfig: [{ forAccessor: 'y-axis-column-layer0' }], xAccessor: 'x-axis-column-layer0', + yConfig: [ + { + color: 'green', + forAccessor: 'y-axis-column-layer0', + }, + ], }, ], + legend: { + isVisible: true, + position: 'right', + }, + preferredSeriesType: 'line', + tickLabelsVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, + valueLabels: 'hide', }, - query: { query: 'transaction.duration.us < 60000000', language: 'kuery' }, - filters: [], }, + title: 'Prefilled from exploratory view app', + visualizationType: 'lnsXY', }; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts index 56ceba8fc52de..dff3d6b3ad5ef 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_cwv.ts @@ -5,7 +5,7 @@ * 2.0. */ export const sampleAttributeCoreWebVital = { - description: '', + description: 'undefined', references: [ { id: 'apm-*', @@ -94,7 +94,7 @@ export const sampleAttributeCoreWebVital = { filters: [], query: { language: 'kuery', - query: '', + query: 'transaction.type: "page-load"', }, visualization: { axisTitlesVisibilitySettings: { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_kpi.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_kpi.ts index 72933573c410b..6ed9b4face6e3 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_kpi.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/test_data/sample_attribute_kpi.ts @@ -5,12 +5,18 @@ * 2.0. */ export const sampleAttributeKpi = { - title: 'Prefilled from exploratory view app', - description: '', - visualizationType: 'lnsXY', + description: 'undefined', references: [ - { id: 'apm-*', name: 'indexpattern-datasource-current-indexpattern', type: 'index-pattern' }, - { id: 'apm-*', name: 'indexpattern-datasource-layer-layer0', type: 'index-pattern' }, + { + id: 'apm-*', + name: 'indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: 'apm-*', + name: 'indexpattern-datasource-layer-layer0', + type: 'index-pattern', + }, ], state: { datasourceStates: { @@ -20,25 +26,27 @@ export const sampleAttributeKpi = { columnOrder: ['x-axis-column-layer0', 'y-axis-column-layer0'], columns: { 'x-axis-column-layer0': { - sourceField: '@timestamp', dataType: 'date', isBucketed: true, label: '@timestamp', operationType: 'date_histogram', - params: { interval: 'auto' }, + params: { + interval: 'auto', + }, scale: 'interval', + sourceField: '@timestamp', }, 'y-axis-column-layer0': { dataType: 'number', + filter: { + language: 'kuery', + query: 'transaction.type: page-load and processor.event: transaction', + }, isBucketed: false, label: 'Page views', operationType: 'count', scale: 'ratio', sourceField: 'Records', - filter: { - query: 'transaction.type: page-load and processor.event: transaction', - language: 'kuery', - }, }, }, incompleteColumns: {}, @@ -46,27 +54,52 @@ export const sampleAttributeKpi = { }, }, }, + filters: [], + query: { + language: 'kuery', + query: '', + }, visualization: { - legend: { isVisible: true, position: 'right' }, - valueLabels: 'hide', - fittingFunction: 'Linear', + axisTitlesVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, curveType: 'CURVE_MONOTONE_X', - axisTitlesVisibilitySettings: { x: true, yLeft: true, yRight: true }, - tickLabelsVisibilitySettings: { x: true, yLeft: true, yRight: true }, - gridlinesVisibilitySettings: { x: true, yLeft: true, yRight: true }, - preferredSeriesType: 'line', + fittingFunction: 'Linear', + gridlinesVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, layers: [ { accessors: ['y-axis-column-layer0'], layerId: 'layer0', layerType: 'data', seriesType: 'line', - yConfig: [{ forAccessor: 'y-axis-column-layer0' }], xAccessor: 'x-axis-column-layer0', + yConfig: [ + { + color: 'green', + forAccessor: 'y-axis-column-layer0', + }, + ], }, ], + legend: { + isVisible: true, + position: 'right', + }, + preferredSeriesType: 'line', + tickLabelsVisibilitySettings: { + x: true, + yLeft: true, + yRight: true, + }, + valueLabels: 'hide', }, - query: { query: '', language: 'kuery' }, - filters: [], }, + title: 'Prefilled from exploratory view app', + visualizationType: 'lnsXY', }; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts index c7d2d21581e7a..56e6cb5210356 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/utils.ts @@ -5,7 +5,7 @@ * 2.0. */ import rison, { RisonValue } from 'rison-node'; -import type { SeriesUrl, UrlFilter } from '../types'; +import type { ReportViewType, SeriesUrl, UrlFilter } from '../types'; import type { AllSeries, AllShortSeries } from '../hooks/use_series_storage'; import { IndexPattern } from '../../../../../../../../src/plugins/data/common'; import { esFilters, ExistsFilter } from '../../../../../../../../src/plugins/data/public'; @@ -16,40 +16,43 @@ export function convertToShortUrl(series: SeriesUrl) { const { operationType, seriesType, - reportType, breakdown, filters, reportDefinitions, dataType, selectedMetricField, + hidden, + name, + color, ...restSeries } = series; return { [URL_KEYS.OPERATION_TYPE]: operationType, - [URL_KEYS.REPORT_TYPE]: reportType, [URL_KEYS.SERIES_TYPE]: seriesType, [URL_KEYS.BREAK_DOWN]: breakdown, [URL_KEYS.FILTERS]: filters, [URL_KEYS.REPORT_DEFINITIONS]: reportDefinitions, [URL_KEYS.DATA_TYPE]: dataType, [URL_KEYS.SELECTED_METRIC]: selectedMetricField, + [URL_KEYS.HIDDEN]: hidden, + [URL_KEYS.NAME]: name, + [URL_KEYS.COLOR]: color, ...restSeries, }; } -export function createExploratoryViewUrl(allSeries: AllSeries, baseHref = '') { - const allSeriesIds = Object.keys(allSeries); - - const allShortSeries: AllShortSeries = {}; - - allSeriesIds.forEach((seriesKey) => { - allShortSeries[seriesKey] = convertToShortUrl(allSeries[seriesKey]); - }); +export function createExploratoryViewUrl( + { reportType, allSeries }: { reportType: ReportViewType; allSeries: AllSeries }, + baseHref = '' +) { + const allShortSeries: AllShortSeries = allSeries.map((series) => convertToShortUrl(series)); return ( baseHref + - `/app/observability/exploratory-view#?sr=${rison.encode(allShortSeries as RisonValue)}` + `/app/observability/exploratory-view/#?reportType=${reportType}&sr=${rison.encode( + allShortSeries as unknown as RisonValue + )}` ); } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.test.tsx index a3b5130e9830b..8f061fcbfbf26 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.test.tsx @@ -6,12 +6,18 @@ */ import React from 'react'; -import { screen, waitFor } from '@testing-library/dom'; +import { screen } from '@testing-library/dom'; import { render, mockAppIndexPattern } from './rtl_helpers'; import { ExploratoryView } from './exploratory_view'; import * as obsvInd from './utils/observability_index_patterns'; +import * as pluginHook from '../../../hooks/use_plugin_context'; import { createStubIndexPattern } from '../../../../../../../src/plugins/data/common/stubs'; +jest.spyOn(pluginHook, 'usePluginContext').mockReturnValue({ + appMountParameters: { + setHeaderActionMenu: jest.fn(), + }, +} as any); describe('ExploratoryView', () => { mockAppIndexPattern(); @@ -40,36 +46,22 @@ describe('ExploratoryView', () => { }); it('renders exploratory view', async () => { - render(); + render(, { initSeries: { data: [] } }); - expect(await screen.findByText(/open in lens/i)).toBeInTheDocument(); + expect(await screen.findByText(/No series found. Please add a series./i)).toBeInTheDocument(); + expect(await screen.findByText(/Hide chart/i)).toBeInTheDocument(); + expect(await screen.findByText(/Refresh/i)).toBeInTheDocument(); expect( await screen.findByRole('heading', { name: /Performance Distribution/i }) ).toBeInTheDocument(); }); it('renders lens component when there is series', async () => { - const initSeries = { - data: { - 'ux-series': { - isNew: true, - dataType: 'ux' as const, - reportType: 'data-distribution' as const, - breakdown: 'user_agent .name', - reportDefinitions: { 'service.name': ['elastic-co'] }, - time: { from: 'now-15m', to: 'now' }, - }, - }, - }; - - render(, { initSeries }); + render(); - expect(await screen.findByText(/open in lens/i)).toBeInTheDocument(); expect((await screen.findAllByText('Performance distribution'))[0]).toBeInTheDocument(); expect(await screen.findByText(/Lens Embeddable Component/i)).toBeInTheDocument(); - await waitFor(() => { - screen.getByRole('table', { name: /this table contains 1 rows\./i }); - }); + expect(screen.getByTestId('exploratoryViewSeriesPanel0')).toBeInTheDocument(); }); }); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx index af04108c56790..faf064868dec5 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx @@ -4,11 +4,12 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + import { i18n } from '@kbn/i18n'; import React, { useEffect, useRef, useState } from 'react'; -import { EuiPanel, EuiTitle } from '@elastic/eui'; +import { EuiButtonEmpty, EuiPanel, EuiResizableContainer, EuiTitle } from '@elastic/eui'; import styled from 'styled-components'; -import { isEmpty } from 'lodash'; +import { PanelDirection } from '@elastic/eui/src/components/resizable_container/types'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { ObservabilityPublicPluginsStart } from '../../../plugin'; import { ExploratoryViewHeader } from './header/header'; @@ -16,40 +17,15 @@ import { useSeriesStorage } from './hooks/use_series_storage'; import { useLensAttributes } from './hooks/use_lens_attributes'; import { TypedLensByValueInput } from '../../../../../lens/public'; import { useAppIndexPatternContext } from './hooks/use_app_index_pattern'; -import { SeriesBuilder } from './series_builder/series_builder'; -import { SeriesUrl } from './types'; +import { SeriesViews } from './views/series_views'; import { LensEmbeddable } from './lens_embeddable'; import { EmptyView } from './components/empty_view'; -export const combineTimeRanges = ( - allSeries: Record, - firstSeries?: SeriesUrl -) => { - let to: string = ''; - let from: string = ''; - if (firstSeries?.reportType === 'kpi-over-time') { - return firstSeries.time; - } - Object.values(allSeries ?? {}).forEach((series) => { - if (series.dataType && series.reportType && !isEmpty(series.reportDefinitions)) { - const seriesTo = new Date(series.time.to); - const seriesFrom = new Date(series.time.from); - if (!to || seriesTo > new Date(to)) { - to = series.time.to; - } - if (!from || seriesFrom < new Date(from)) { - from = series.time.from; - } - } - }); - return { to, from }; -}; +export type PanelId = 'seriesPanel' | 'chartPanel'; export function ExploratoryView({ saveAttributes, - multiSeries, }: { - multiSeries?: boolean; saveAttributes?: (attr: TypedLensByValueInput['attributes'] | null) => void; }) { const { @@ -69,20 +45,19 @@ export function ExploratoryView({ const { loadIndexPattern, loading } = useAppIndexPatternContext(); - const { firstSeries, firstSeriesId, allSeries } = useSeriesStorage(); + const { firstSeries, allSeries, lastRefresh, reportType } = useSeriesStorage(); const lensAttributesT = useLensAttributes(); const setHeightOffset = () => { if (seriesBuilderRef?.current && wrapperRef.current) { const headerOffset = wrapperRef.current.getBoundingClientRect().top; - const seriesOffset = seriesBuilderRef.current.getBoundingClientRect().height; - setHeight(`calc(100vh - ${seriesOffset + headerOffset + 40}px)`); + setHeight(`calc(100vh - ${headerOffset + 40}px)`); } }; useEffect(() => { - Object.values(allSeries).forEach((seriesT) => { + allSeries.forEach((seriesT) => { loadIndexPattern({ dataType: seriesT.dataType, }); @@ -96,38 +71,102 @@ export function ExploratoryView({ } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [JSON.stringify(lensAttributesT ?? {})]); + }, [JSON.stringify(lensAttributesT ?? {}), lastRefresh]); useEffect(() => { setHeightOffset(); }); + const collapseFn = useRef<(id: PanelId, direction: PanelDirection) => void>(); + + const [hiddenPanel, setHiddenPanel] = useState(''); + + const onCollapse = (panelId: string) => { + setHiddenPanel((prevState) => (panelId === prevState ? '' : panelId)); + }; + + const onChange = (panelId: PanelId) => { + onCollapse(panelId); + if (collapseFn.current) { + collapseFn.current(panelId, panelId === 'seriesPanel' ? 'right' : 'left'); + } + }; + return ( {lens ? ( <> - + - {lensAttributes ? ( - - ) : ( - + + {(EuiResizablePanel, EuiResizableButton, { togglePanel }) => { + collapseFn.current = (id, direction) => togglePanel?.(id, { direction }); + + return ( + <> + + {lensAttributes ? ( + + ) : ( + + )} + + + + {hiddenPanel === 'chartPanel' ? ( + onChange('chartPanel')} iconType="arrowDown"> + {SHOW_CHART_LABEL} + + ) : ( + onChange('chartPanel')} + iconType="arrowUp" + color="text" + > + {HIDE_CHART_LABEL} + + )} + + + + ); + }} + + {hiddenPanel === 'seriesPanel' && ( + onChange('seriesPanel')} iconType="arrowUp"> + {PREVIEW_LABEL} + )} - ) : ( -

- {i18n.translate('xpack.observability.overview.exploratoryView.lensDisabled', { - defaultMessage: - 'Lens app is not available, please enable Lens to use exploratory view.', - })} -

+

{LENS_NOT_AVAILABLE}

)}
@@ -147,4 +186,39 @@ const Wrapper = styled(EuiPanel)` margin: 0 auto; width: 100%; overflow-x: auto; + position: relative; +`; + +const ShowPreview = styled(EuiButtonEmpty)` + position: absolute; + bottom: 34px; +`; +const HideChart = styled(EuiButtonEmpty)` + position: absolute; + top: -35px; + right: 50px; `; +const ShowChart = styled(EuiButtonEmpty)` + position: absolute; + top: -10px; + right: 50px; +`; + +const HIDE_CHART_LABEL = i18n.translate('xpack.observability.overview.exploratoryView.hideChart', { + defaultMessage: 'Hide chart', +}); + +const SHOW_CHART_LABEL = i18n.translate('xpack.observability.overview.exploratoryView.showChart', { + defaultMessage: 'Show chart', +}); + +const PREVIEW_LABEL = i18n.translate('xpack.observability.overview.exploratoryView.preview', { + defaultMessage: 'Preview', +}); + +const LENS_NOT_AVAILABLE = i18n.translate( + 'xpack.observability.overview.exploratoryView.lensDisabled', + { + defaultMessage: 'Lens app is not available, please enable Lens to use exploratory view.', + } +); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.test.tsx index 619ea0d21ae15..b8f16f3e5effb 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.test.tsx @@ -23,14 +23,15 @@ describe('AddToCaseAction', function () { it('should be able to click add to case button', async function () { const initSeries = { - data: { - 'uptime-pings-histogram': { + data: [ + { + name: 'test-series', dataType: 'synthetics' as const, reportType: 'kpi-over-time' as const, breakdown: 'monitor.status', time: { from: 'now-15m', to: 'now' }, }, - }, + ], }; const { findByText, core } = render( diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.tsx index 4fa8deb2700d0..bc813a4980e78 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.tsx @@ -17,7 +17,7 @@ import { Case, SubCase } from '../../../../../../cases/common'; import { observabilityFeatureId } from '../../../../../common'; export interface AddToCaseProps { - timeRange: { from: string; to: string }; + timeRange?: { from: string; to: string }; lensAttributes: TypedLensByValueInput['attributes'] | null; } @@ -54,6 +54,7 @@ export function AddToCaseAction({ lensAttributes, timeRange }: AddToCaseProps) { return ( <> ); - getByText('Open in Lens'); - }); - - it('should be able to click open in lens', function () { - const initSeries = { - data: { - 'uptime-pings-histogram': { - dataType: 'synthetics' as const, - reportType: 'kpi-over-time' as const, - breakdown: 'monitor.status', - time: { from: 'now-15m', to: 'now' }, - }, - }, - }; - - const { getByText, core } = render( - , - { initSeries } - ); - fireEvent.click(getByText('Open in Lens')); - - expect(core?.lens?.navigateToPrefilledEditor).toHaveBeenCalledTimes(1); - expect(core?.lens?.navigateToPrefilledEditor).toHaveBeenCalledWith( - { - attributes: { title: 'Performance distribution' }, - id: '', - timeRange: { - from: 'now-15m', - to: 'now', - }, - }, - { openInNewTab: true } - ); + getByText('Refresh'); }); }); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx index 7adef4779ea94..bec8673f88b4e 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx @@ -5,44 +5,37 @@ * 2.0. */ -import React, { useState } from 'react'; +import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiBetaBadge, EuiButton, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; -import { TypedLensByValueInput, LensEmbeddableInput } from '../../../../../../lens/public'; -import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; +import { TypedLensByValueInput } from '../../../../../../lens/public'; import { DataViewLabels } from '../configurations/constants'; -import { ObservabilityAppServices } from '../../../../application/types'; import { useSeriesStorage } from '../hooks/use_series_storage'; -import { combineTimeRanges } from '../exploratory_view'; -import { AddToCaseAction } from './add_to_case_action'; +import { LastUpdated } from './last_updated'; +import { combineTimeRanges } from '../lens_embeddable'; +import { ExpViewActionMenu } from '../components/action_menu'; interface Props { - seriesId: string; + seriesId?: number; + lastUpdated?: number; lensAttributes: TypedLensByValueInput['attributes'] | null; } -export function ExploratoryViewHeader({ seriesId, lensAttributes }: Props) { - const kServices = useKibana().services; +export function ExploratoryViewHeader({ seriesId, lensAttributes, lastUpdated }: Props) { + const { getSeries, allSeries, setLastRefresh, reportType } = useSeriesStorage(); - const { lens } = kServices; + const series = seriesId ? getSeries(seriesId) : undefined; - const { getSeries, allSeries } = useSeriesStorage(); - - const series = getSeries(seriesId); - - const [isSaveOpen, setIsSaveOpen] = useState(false); - - const LensSaveModalComponent = lens.SaveModalComponent; - - const timeRange = combineTimeRanges(allSeries, series); + const timeRange = combineTimeRanges(reportType, allSeries, series); return ( <> +

- {DataViewLabels[series.reportType] ?? + {DataViewLabels[reportType] ?? i18n.translate('xpack.observability.expView.heading.label', { defaultMessage: 'Analyze data', })}{' '} @@ -58,58 +51,18 @@ export function ExploratoryViewHeader({ seriesId, lensAttributes }: Props) { - + - { - if (lensAttributes) { - lens.navigateToPrefilledEditor( - { - id: '', - timeRange, - attributes: lensAttributes, - }, - { - openInNewTab: true, - } - ); - } - }} - > - {i18n.translate('xpack.observability.expView.heading.openInLens', { - defaultMessage: 'Open in Lens', - })} - - - - { - if (lensAttributes) { - setIsSaveOpen(true); - } - }} - > - {i18n.translate('xpack.observability.expView.heading.saveLensVisualization', { - defaultMessage: 'Save', - })} + setLastRefresh(Date.now())}> + {REFRESH_LABEL} - - {isSaveOpen && lensAttributes && ( - setIsSaveOpen(false)} - onSave={() => {}} - /> - )} ); } + +const REFRESH_LABEL = i18n.translate('xpack.observability.overview.exploratoryView.refresh', { + defaultMessage: 'Refresh', +}); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/last_updated.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/last_updated.tsx similarity index 55% rename from x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/last_updated.tsx rename to x-pack/plugins/observability/public/components/shared/exploratory_view/header/last_updated.tsx index 874171de123d2..c352ec0423dd8 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/last_updated.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/last_updated.tsx @@ -8,6 +8,7 @@ import React, { useEffect, useState } from 'react'; import { EuiIcon, EuiText } from '@elastic/eui'; import moment from 'moment'; +import { FormattedMessage } from '@kbn/i18n/react'; interface Props { lastUpdated?: number; @@ -18,20 +19,34 @@ export function LastUpdated({ lastUpdated }: Props) { useEffect(() => { const interVal = setInterval(() => { setRefresh(Date.now()); - }, 1000); + }, 5000); return () => { clearInterval(interVal); }; }, []); + useEffect(() => { + setRefresh(Date.now()); + }, [lastUpdated]); + if (!lastUpdated) { return null; } + const isWarning = moment().diff(moment(lastUpdated), 'minute') > 5; + const isDanger = moment().diff(moment(lastUpdated), 'minute') > 10; + return ( - - Last Updated: {moment(lastUpdated).from(refresh)} + + + ); } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_add_to_case.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_add_to_case.ts index 5ec9e1d4ab4b5..d1e15aa916eed 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_add_to_case.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_add_to_case.ts @@ -25,7 +25,7 @@ async function addToCase( http: HttpSetup, theCase: Case | SubCase, attributes: TypedLensByValueInput['attributes'], - timeRange: { from: string; to: string } + timeRange?: { from: string; to: string } ) { const apiPath = `/api/cases/${theCase?.id}/comments`; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_app_index_pattern.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_app_index_pattern.tsx index 88818665bbe2a..83a7ac1ae17dc 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_app_index_pattern.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_app_index_pattern.tsx @@ -27,7 +27,7 @@ interface ProviderProps { } type HasAppDataState = Record; -type IndexPatternState = Record; +export type IndexPatternState = Record; type LoadingState = Record; export function IndexPatternContextProvider({ children }: ProviderProps) { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_discover_link.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_discover_link.tsx new file mode 100644 index 0000000000000..4f19a8131f669 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_discover_link.tsx @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useEffect, useState } from 'react'; +import { useKibana } from '../../../../utils/kibana_react'; +import { SeriesConfig, SeriesUrl } from '../types'; +import { useAppIndexPatternContext } from './use_app_index_pattern'; +import { buildExistsFilter, buildPhraseFilter, buildPhrasesFilter } from '../configurations/utils'; +import { getFiltersFromDefs } from './use_lens_attributes'; +import { RECORDS_FIELD, RECORDS_PERCENTAGE_FIELD } from '../configurations/constants'; + +interface UseDiscoverLink { + seriesConfig?: SeriesConfig; + series: SeriesUrl; +} + +export const useDiscoverLink = ({ series, seriesConfig }: UseDiscoverLink) => { + const kServices = useKibana().services; + const { + application: { navigateToUrl }, + } = kServices; + + const { indexPatterns } = useAppIndexPatternContext(); + + const urlGenerator = kServices.discover?.urlGenerator; + const [discoverUrl, setDiscoverUrl] = useState(''); + + useEffect(() => { + const indexPattern = indexPatterns?.[series.dataType]; + + const definitions = series.reportDefinitions ?? {}; + const filters = [...(seriesConfig?.baseFilters ?? [])]; + + const definitionFilters = getFiltersFromDefs(definitions); + + definitionFilters.forEach(({ field, values = [] }) => { + if (values.length > 1) { + filters.push(buildPhrasesFilter(field, values, indexPattern)[0]); + } else { + filters.push(buildPhraseFilter(field, values[0], indexPattern)[0]); + } + }); + + const selectedMetricField = series.selectedMetricField; + + if ( + selectedMetricField && + selectedMetricField !== RECORDS_FIELD && + selectedMetricField !== RECORDS_PERCENTAGE_FIELD + ) { + filters.push(buildExistsFilter(selectedMetricField, indexPattern)[0]); + } + + const getDiscoverUrl = async () => { + if (!urlGenerator?.createUrl) return; + + const newUrl = await urlGenerator.createUrl({ + filters, + indexPatternId: indexPattern?.id, + }); + setDiscoverUrl(newUrl); + }; + getDiscoverUrl(); + }, [ + indexPatterns, + series.dataType, + series.reportDefinitions, + series.selectedMetricField, + seriesConfig?.baseFilters, + urlGenerator, + ]); + + const onClick = useCallback( + (event: React.MouseEvent) => { + if (discoverUrl) { + event.preventDefault(); + + return navigateToUrl(discoverUrl); + } + }, + [discoverUrl, navigateToUrl] + ); + + return { + href: discoverUrl, + onClick, + }; +}; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts index 8bb265b4f6d89..ef974d54e6cdc 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_lens_attributes.ts @@ -9,12 +9,18 @@ import { useMemo } from 'react'; import { isEmpty } from 'lodash'; import { TypedLensByValueInput } from '../../../../../../lens/public'; import { LayerConfig, LensAttributes } from '../configurations/lens_attributes'; -import { useSeriesStorage } from './use_series_storage'; +import { + AllSeries, + allSeriesKey, + convertAllShortSeries, + useSeriesStorage, +} from './use_series_storage'; import { getDefaultConfigs } from '../configurations/default_configs'; import { SeriesUrl, UrlFilter } from '../types'; import { useAppIndexPatternContext } from './use_app_index_pattern'; import { ALL_VALUES_SELECTED } from '../../field_value_suggestions/field_value_combobox'; +import { useTheme } from '../../../../hooks/use_theme'; export const getFiltersFromDefs = (reportDefinitions: SeriesUrl['reportDefinitions']) => { return Object.entries(reportDefinitions ?? {}) @@ -28,41 +34,54 @@ export const getFiltersFromDefs = (reportDefinitions: SeriesUrl['reportDefinitio }; export const useLensAttributes = (): TypedLensByValueInput['attributes'] | null => { - const { allSeriesIds, allSeries } = useSeriesStorage(); + const { storage, allSeries, lastRefresh, reportType } = useSeriesStorage(); const { indexPatterns } = useAppIndexPatternContext(); + const theme = useTheme(); + return useMemo(() => { - if (isEmpty(indexPatterns) || isEmpty(allSeriesIds)) { + if (isEmpty(indexPatterns) || isEmpty(allSeries) || !reportType) { return null; } + const allSeriesT: AllSeries = convertAllShortSeries(storage.get(allSeriesKey) ?? []); + const layerConfigs: LayerConfig[] = []; - allSeriesIds.forEach((seriesIdT) => { - const seriesT = allSeries[seriesIdT]; - const indexPattern = indexPatterns?.[seriesT?.dataType]; - if (indexPattern && seriesT.reportType && !isEmpty(seriesT.reportDefinitions)) { + allSeriesT.forEach((series, seriesIndex) => { + const indexPattern = indexPatterns?.[series?.dataType]; + + if ( + indexPattern && + !isEmpty(series.reportDefinitions) && + !series.hidden && + series.selectedMetricField + ) { const seriesConfig = getDefaultConfigs({ - reportType: seriesT.reportType, - dataType: seriesT.dataType, + reportType, indexPattern, + dataType: series.dataType, }); - const filters: UrlFilter[] = (seriesT.filters ?? []).concat( - getFiltersFromDefs(seriesT.reportDefinitions) + const filters: UrlFilter[] = (series.filters ?? []).concat( + getFiltersFromDefs(series.reportDefinitions) ); + const color = `euiColorVis${seriesIndex}`; + layerConfigs.push({ filters, indexPattern, seriesConfig, - time: seriesT.time, - breakdown: seriesT.breakdown, - seriesType: seriesT.seriesType, - operationType: seriesT.operationType, - reportDefinitions: seriesT.reportDefinitions ?? {}, - selectedMetricField: seriesT.selectedMetricField, + time: series.time, + name: series.name, + breakdown: series.breakdown, + seriesType: series.seriesType, + operationType: series.operationType, + reportDefinitions: series.reportDefinitions ?? {}, + selectedMetricField: series.selectedMetricField, + color: series.color ?? (theme.eui as unknown as Record)[color], }); } }); @@ -73,6 +92,6 @@ export const useLensAttributes = (): TypedLensByValueInput['attributes'] | null const lensAttributes = new LensAttributes(layerConfigs); - return lensAttributes.getJSON(); - }, [indexPatterns, allSeriesIds, allSeries]); + return lensAttributes.getJSON(lastRefresh); + }, [indexPatterns, allSeries, reportType, storage, theme, lastRefresh]); }; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_filters.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_filters.ts index 2d2618bc46152..f2a6130cdc59d 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_filters.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_filters.ts @@ -6,18 +6,16 @@ */ import { useSeriesStorage } from './use_series_storage'; -import { UrlFilter } from '../types'; +import { SeriesUrl, UrlFilter } from '../types'; export interface UpdateFilter { field: string; - value: string; + value: string | string[]; negate?: boolean; } -export const useSeriesFilters = ({ seriesId }: { seriesId: string }) => { - const { getSeries, setSeries } = useSeriesStorage(); - - const series = getSeries(seriesId); +export const useSeriesFilters = ({ seriesId, series }: { seriesId: number; series: SeriesUrl }) => { + const { setSeries } = useSeriesStorage(); const filters = series.filters ?? []; @@ -26,10 +24,14 @@ export const useSeriesFilters = ({ seriesId }: { seriesId: string }) => { .map((filter) => { if (filter.field === field) { if (negate) { - const notValuesN = filter.notValues?.filter((val) => val !== value); + const notValuesN = filter.notValues?.filter((val) => + value instanceof Array ? !value.includes(val) : val !== value + ); return { ...filter, notValues: notValuesN }; } else { - const valuesN = filter.values?.filter((val) => val !== value); + const valuesN = filter.values?.filter((val) => + value instanceof Array ? !value.includes(val) : val !== value + ); return { ...filter, values: valuesN }; } } @@ -43,9 +45,9 @@ export const useSeriesFilters = ({ seriesId }: { seriesId: string }) => { const addFilter = ({ field, value, negate }: UpdateFilter) => { const currFilter: UrlFilter = { field }; if (negate) { - currFilter.notValues = [value]; + currFilter.notValues = value instanceof Array ? value : [value]; } else { - currFilter.values = [value]; + currFilter.values = value instanceof Array ? value : [value]; } if (filters.length === 0) { setSeries(seriesId, { ...series, filters: [currFilter] }); @@ -65,13 +67,26 @@ export const useSeriesFilters = ({ seriesId }: { seriesId: string }) => { const currNotValues = currFilter.notValues ?? []; const currValues = currFilter.values ?? []; - const notValues = currNotValues.filter((val) => val !== value); - const values = currValues.filter((val) => val !== value); + const notValues = currNotValues.filter((val) => + value instanceof Array ? !value.includes(val) : val !== value + ); + + const values = currValues.filter((val) => + value instanceof Array ? !value.includes(val) : val !== value + ); if (negate) { - notValues.push(value); + if (value instanceof Array) { + notValues.push(...value); + } else { + notValues.push(value); + } } else { - values.push(value); + if (value instanceof Array) { + values.push(...value); + } else { + values.push(value); + } } currFilter.notValues = notValues.length > 0 ? notValues : undefined; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_storage.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_storage.test.tsx index c32acc47abd1b..ce6d7bd94d8e4 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_storage.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_storage.test.tsx @@ -6,37 +6,39 @@ */ import React, { useEffect } from 'react'; - -import { UrlStorageContextProvider, useSeriesStorage } from './use_series_storage'; +import { Route, Router } from 'react-router-dom'; import { render } from '@testing-library/react'; +import { UrlStorageContextProvider, useSeriesStorage } from './use_series_storage'; +import { getHistoryFromUrl } from '../rtl_helpers'; -const mockSingleSeries = { - 'performance-distribution': { - reportType: 'data-distribution', +const mockSingleSeries = [ + { + name: 'performance-distribution', dataType: 'ux', breakdown: 'user_agent.name', time: { from: 'now-15m', to: 'now' }, }, -}; +]; -const mockMultipleSeries = { - 'performance-distribution': { - reportType: 'data-distribution', +const mockMultipleSeries = [ + { + name: 'performance-distribution', dataType: 'ux', breakdown: 'user_agent.name', time: { from: 'now-15m', to: 'now' }, }, - 'kpi-over-time': { - reportType: 'kpi-over-time', + { + name: 'kpi-over-time', dataType: 'synthetics', breakdown: 'user_agent.name', time: { from: 'now-15m', to: 'now' }, }, -}; +]; -describe('userSeries', function () { +describe('userSeriesStorage', function () { function setupTestComponent(seriesData: any) { const setData = jest.fn(); + function TestComponent() { const data = useSeriesStorage(); @@ -48,11 +50,20 @@ describe('userSeries', function () { } render( - - - + + + (key === 'sr' ? seriesData : null)), + set: jest.fn(), + }} + > + + + + ); return setData; @@ -63,22 +74,20 @@ describe('userSeries', function () { expect(setData).toHaveBeenCalledTimes(2); expect(setData).toHaveBeenLastCalledWith( expect.objectContaining({ - allSeries: { - 'performance-distribution': { - breakdown: 'user_agent.name', + allSeries: [ + { + name: 'performance-distribution', dataType: 'ux', - reportType: 'data-distribution', + breakdown: 'user_agent.name', time: { from: 'now-15m', to: 'now' }, }, - }, - allSeriesIds: ['performance-distribution'], + ], firstSeries: { - breakdown: 'user_agent.name', + name: 'performance-distribution', dataType: 'ux', - reportType: 'data-distribution', + breakdown: 'user_agent.name', time: { from: 'now-15m', to: 'now' }, }, - firstSeriesId: 'performance-distribution', }) ); }); @@ -89,42 +98,38 @@ describe('userSeries', function () { expect(setData).toHaveBeenCalledTimes(2); expect(setData).toHaveBeenLastCalledWith( expect.objectContaining({ - allSeries: { - 'performance-distribution': { - breakdown: 'user_agent.name', + allSeries: [ + { + name: 'performance-distribution', dataType: 'ux', - reportType: 'data-distribution', + breakdown: 'user_agent.name', time: { from: 'now-15m', to: 'now' }, }, - 'kpi-over-time': { - reportType: 'kpi-over-time', + { + name: 'kpi-over-time', dataType: 'synthetics', breakdown: 'user_agent.name', time: { from: 'now-15m', to: 'now' }, }, - }, - allSeriesIds: ['performance-distribution', 'kpi-over-time'], + ], firstSeries: { - breakdown: 'user_agent.name', + name: 'performance-distribution', dataType: 'ux', - reportType: 'data-distribution', + breakdown: 'user_agent.name', time: { from: 'now-15m', to: 'now' }, }, - firstSeriesId: 'performance-distribution', }) ); }); it('should return expected result when there are no series', function () { - const setData = setupTestComponent({}); + const setData = setupTestComponent([]); - expect(setData).toHaveBeenCalledTimes(2); + expect(setData).toHaveBeenCalledTimes(1); expect(setData).toHaveBeenLastCalledWith( expect.objectContaining({ - allSeries: {}, - allSeriesIds: [], + allSeries: [], firstSeries: undefined, - firstSeriesId: undefined, }) ); }); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_storage.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_storage.tsx index a47a124d14b4d..d9a5adc822140 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_storage.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_series_storage.tsx @@ -22,13 +22,17 @@ import { OperationType, SeriesType } from '../../../../../../lens/public'; import { URL_KEYS } from '../configurations/constants/url_constants'; export interface SeriesContextValue { - firstSeries: SeriesUrl; - firstSeriesId: string; - allSeriesIds: string[]; + firstSeries?: SeriesUrl; + lastRefresh: number; + setLastRefresh: (val: number) => void; + applyChanges: () => void; allSeries: AllSeries; - setSeries: (seriesIdN: string, newValue: SeriesUrl) => void; - getSeries: (seriesId: string) => SeriesUrl; - removeSeries: (seriesId: string) => void; + setSeries: (seriesIndex: number, newValue: SeriesUrl) => void; + getSeries: (seriesIndex: number) => SeriesUrl | undefined; + removeSeries: (seriesIndex: number) => void; + setReportType: (reportType: string) => void; + storage: IKbnUrlStateStorage | ISessionStorageStateStorage; + reportType: ReportViewType; } export const UrlStorageContext = createContext({} as SeriesContextValue); @@ -36,72 +40,87 @@ interface ProviderProps { storage: IKbnUrlStateStorage | ISessionStorageStateStorage; } -function convertAllShortSeries(allShortSeries: AllShortSeries) { - const allSeriesIds = Object.keys(allShortSeries); - const allSeriesN: AllSeries = {}; - allSeriesIds.forEach((seriesKey) => { - allSeriesN[seriesKey] = convertFromShortUrl(allShortSeries[seriesKey]); - }); - - return allSeriesN; +export function convertAllShortSeries(allShortSeries: AllShortSeries) { + return (allShortSeries ?? []).map((shortSeries) => convertFromShortUrl(shortSeries)); } +export const allSeriesKey = 'sr'; +const reportTypeKey = 'reportType'; + export function UrlStorageContextProvider({ children, storage, }: ProviderProps & { children: JSX.Element }) { - const allSeriesKey = 'sr'; - - const [allShortSeries, setAllShortSeries] = useState( - () => storage.get(allSeriesKey) ?? {} - ); const [allSeries, setAllSeries] = useState(() => - convertAllShortSeries(storage.get(allSeriesKey) ?? {}) + convertAllShortSeries(storage.get(allSeriesKey) ?? []) ); - const [firstSeriesId, setFirstSeriesId] = useState(''); + + const [lastRefresh, setLastRefresh] = useState(() => Date.now()); + + const [reportType, setReportType] = useState( + () => (storage as IKbnUrlStateStorage).get(reportTypeKey) ?? '' + ); + const [firstSeries, setFirstSeries] = useState(); useEffect(() => { - const allSeriesIds = Object.keys(allShortSeries); - const allSeriesN: AllSeries = convertAllShortSeries(allShortSeries ?? {}); + const firstSeriesT = allSeries?.[0]; - setAllSeries(allSeriesN); - setFirstSeriesId(allSeriesIds?.[0]); - setFirstSeries(allSeriesN?.[allSeriesIds?.[0]]); - (storage as IKbnUrlStateStorage).set(allSeriesKey, allShortSeries); - }, [allShortSeries, storage]); + setFirstSeries(firstSeriesT); + }, [allSeries, storage]); - const setSeries = (seriesIdN: string, newValue: SeriesUrl) => { - setAllShortSeries((prevState) => { - prevState[seriesIdN] = convertToShortUrl(newValue); - return { ...prevState }; - }); - }; + const setSeries = useCallback((seriesIndex: number, newValue: SeriesUrl) => { + setAllSeries((prevAllSeries) => { + const newStateRest = prevAllSeries.map((series, index) => { + if (index === seriesIndex) { + return newValue; + } + return series; + }); + + if (prevAllSeries.length === seriesIndex) { + return [...newStateRest, newValue]; + } - const removeSeries = (seriesIdN: string) => { - setAllShortSeries((prevState) => { - delete prevState[seriesIdN]; - return { ...prevState }; + return [...newStateRest]; }); - }; + }, []); - const allSeriesIds = Object.keys(allShortSeries); + useEffect(() => { + (storage as IKbnUrlStateStorage).set(reportTypeKey, reportType); + }, [reportType, storage]); + + const removeSeries = useCallback((seriesIndex: number) => { + setAllSeries((prevAllSeries) => + prevAllSeries.filter((seriesT, index) => index !== seriesIndex) + ); + }, []); const getSeries = useCallback( - (seriesId?: string) => { - return seriesId ? allSeries?.[seriesId] ?? {} : ({} as SeriesUrl); + (seriesIndex: number) => { + return allSeries[seriesIndex]; }, [allSeries] ); + const applyChanges = useCallback(() => { + const allShortSeries = allSeries.map((series) => convertToShortUrl(series)); + + (storage as IKbnUrlStateStorage).set(allSeriesKey, allShortSeries); + setLastRefresh(Date.now()); + }, [allSeries, storage]); + const value = { + applyChanges, storage, getSeries, setSeries, removeSeries, - firstSeriesId, allSeries, - allSeriesIds, + lastRefresh, + setLastRefresh, + setReportType, + reportType: storage.get(reportTypeKey) as ReportViewType, firstSeries: firstSeries!, }; return {children}; @@ -112,10 +131,9 @@ export function useSeriesStorage() { } function convertFromShortUrl(newValue: ShortUrlSeries): SeriesUrl { - const { dt, op, st, rt, bd, ft, time, rdf, mt, ...restSeries } = newValue; + const { dt, op, st, bd, ft, time, rdf, mt, h, n, c, ...restSeries } = newValue; return { operationType: op, - reportType: rt!, seriesType: st, breakdown: bd, filters: ft!, @@ -123,26 +141,31 @@ function convertFromShortUrl(newValue: ShortUrlSeries): SeriesUrl { reportDefinitions: rdf, dataType: dt!, selectedMetricField: mt, + hidden: h, + name: n, + color: c, ...restSeries, }; } interface ShortUrlSeries { [URL_KEYS.OPERATION_TYPE]?: OperationType; - [URL_KEYS.REPORT_TYPE]?: ReportViewType; [URL_KEYS.DATA_TYPE]?: AppDataType; [URL_KEYS.SERIES_TYPE]?: SeriesType; [URL_KEYS.BREAK_DOWN]?: string; [URL_KEYS.FILTERS]?: UrlFilter[]; [URL_KEYS.REPORT_DEFINITIONS]?: URLReportDefinition; [URL_KEYS.SELECTED_METRIC]?: string; + [URL_KEYS.HIDDEN]?: boolean; + [URL_KEYS.NAME]: string; + [URL_KEYS.COLOR]?: string; time?: { to: string; from: string; }; } -export type AllShortSeries = Record; -export type AllSeries = Record; +export type AllShortSeries = ShortUrlSeries[]; +export type AllSeries = SeriesUrl[]; -export const NEW_SERIES_KEY = 'new-series-key'; +export const NEW_SERIES_KEY = 'new-series'; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/index.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/index.tsx index e55752ceb62ba..3de29b02853e8 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/index.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/index.tsx @@ -25,11 +25,9 @@ import { TypedLensByValueInput } from '../../../../../lens/public'; export function ExploratoryViewPage({ saveAttributes, - multiSeries = false, useSessionStorage = false, }: { useSessionStorage?: boolean; - multiSeries?: boolean; saveAttributes?: (attr: TypedLensByValueInput['attributes'] | null) => void; }) { useTrackPageview({ app: 'observability-overview', path: 'exploratory-view' }); @@ -61,7 +59,7 @@ export function ExploratoryViewPage({ - + diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/lens_embeddable.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/lens_embeddable.tsx index 4cb586fe94ceb..9e4d9486dc155 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/lens_embeddable.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/lens_embeddable.tsx @@ -7,16 +7,51 @@ import { i18n } from '@kbn/i18n'; import React, { Dispatch, SetStateAction, useCallback } from 'react'; -import { combineTimeRanges } from './exploratory_view'; +import styled from 'styled-components'; +import { isEmpty } from 'lodash'; import { TypedLensByValueInput } from '../../../../../lens/public'; import { useSeriesStorage } from './hooks/use_series_storage'; import { ObservabilityPublicPluginsStart } from '../../../plugin'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { ReportViewType, SeriesUrl } from './types'; +import { ReportTypes } from './configurations/constants'; interface Props { lensAttributes: TypedLensByValueInput['attributes']; setLastUpdated: Dispatch>; } +export const combineTimeRanges = ( + reportType: ReportViewType, + allSeries: SeriesUrl[], + firstSeries?: SeriesUrl +) => { + let to: string = ''; + let from: string = ''; + + if (reportType === ReportTypes.KPI) { + return firstSeries?.time; + } + + allSeries.forEach((series) => { + if ( + series.dataType && + series.selectedMetricField && + !isEmpty(series.reportDefinitions) && + series.time + ) { + const seriesTo = new Date(series.time.to); + const seriesFrom = new Date(series.time.from); + if (!to || seriesTo > new Date(to)) { + to = series.time.to; + } + if (!from || seriesFrom < new Date(from)) { + from = series.time.from; + } + } + }); + + return { to, from }; +}; export function LensEmbeddable(props: Props) { const { lensAttributes, setLastUpdated } = props; @@ -27,9 +62,11 @@ export function LensEmbeddable(props: Props) { const LensComponent = lens?.EmbeddableComponent; - const { firstSeriesId, firstSeries: series, setSeries, allSeries } = useSeriesStorage(); + const { firstSeries, setSeries, allSeries, reportType } = useSeriesStorage(); - const timeRange = combineTimeRanges(allSeries, series); + const firstSeriesId = 0; + + const timeRange = firstSeries ? combineTimeRanges(reportType, allSeries, firstSeries) : null; const onLensLoad = useCallback(() => { setLastUpdated(Date.now()); @@ -37,9 +74,9 @@ export function LensEmbeddable(props: Props) { const onBrushEnd = useCallback( ({ range }: { range: number[] }) => { - if (series?.reportType !== 'data-distribution') { + if (reportType !== 'data-distribution' && firstSeries) { setSeries(firstSeriesId, { - ...series, + ...firstSeries, time: { from: new Date(range[0]).toISOString(), to: new Date(range[1]).toISOString(), @@ -53,16 +90,30 @@ export function LensEmbeddable(props: Props) { ); } }, - [notifications?.toasts, series, firstSeriesId, setSeries] + [reportType, setSeries, firstSeries, notifications?.toasts] ); + if (timeRange === null || !firstSeries) { + return null; + } + return ( - + + + ); } + +const LensWrapper = styled.div` + height: 100%; + + &&& > div { + height: 100%; + } +`; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx index a577a8df3e3d9..48a22f91eb7f6 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx @@ -10,7 +10,7 @@ import React, { ReactElement } from 'react'; import { stringify } from 'query-string'; // eslint-disable-next-line import/no-extraneous-dependencies import { render as reactTestLibRender, RenderOptions } from '@testing-library/react'; -import { Router } from 'react-router-dom'; +import { Route, Router } from 'react-router-dom'; import { createMemoryHistory, History } from 'history'; import { CoreStart } from 'kibana/public'; import { I18nProvider } from '@kbn/i18n/react'; @@ -24,7 +24,7 @@ import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/ import { lensPluginMock } from '../../../../../lens/public/mocks'; import * as useAppIndexPatternHook from './hooks/use_app_index_pattern'; import { IndexPatternContextProvider } from './hooks/use_app_index_pattern'; -import { AllSeries, UrlStorageContext } from './hooks/use_series_storage'; +import { AllSeries, SeriesContextValue, UrlStorageContext } from './hooks/use_series_storage'; import * as fetcherHook from '../../../hooks/use_fetcher'; import * as useSeriesFilterHook from './hooks/use_series_filters'; @@ -35,10 +35,12 @@ import indexPatternData from './configurations/test_data/test_index_pattern.json // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { setIndexPatterns } from '../../../../../../../src/plugins/data/public/services'; import { IndexPattern, IndexPatternsContract } from '../../../../../../../src/plugins/data/common'; + +import { AppDataType, SeriesUrl, UrlFilter } from './types'; import { createStubIndexPattern } from '../../../../../../../src/plugins/data/common/stubs'; -import { AppDataType, UrlFilter } from './types'; import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; import { ListItem } from '../../../hooks/use_values_list'; +import { TRANSACTION_DURATION } from './configurations/constants/elasticsearch_fieldnames'; import { casesPluginMock } from '../../../../../cases/public/mocks'; interface KibanaProps { @@ -157,9 +159,11 @@ export function MockRouter({ }: MockRouterProps) { return ( - - {children} - + + + {children} + + ); } @@ -172,7 +176,7 @@ export function render( core: customCore, kibanaProps, renderOptions, - url, + url = '/app/observability/exploratory-view/', initSeries = {}, }: RenderRouterOptions = {} ) { @@ -202,7 +206,7 @@ export function render( }; } -const getHistoryFromUrl = (url: Url) => { +export const getHistoryFromUrl = (url: Url) => { if (typeof url === 'string') { return createMemoryHistory({ initialEntries: [url], @@ -251,6 +255,15 @@ export const mockUseValuesList = (values?: ListItem[]) => { return { spy, onRefreshTimeRange }; }; +export const mockUxSeries = { + name: 'performance-distribution', + dataType: 'ux', + breakdown: 'user_agent.name', + time: { from: 'now-15m', to: 'now' }, + reportDefinitions: { 'service.name': ['elastic-co'] }, + selectedMetricField: TRANSACTION_DURATION, +} as SeriesUrl; + function mockSeriesStorageContext({ data, filters, @@ -260,34 +273,34 @@ function mockSeriesStorageContext({ filters?: UrlFilter[]; breakdown?: string; }) { - const mockDataSeries = data || { - 'performance-distribution': { - reportType: 'data-distribution', - dataType: 'ux', - breakdown: breakdown || 'user_agent.name', - time: { from: 'now-15m', to: 'now' }, - ...(filters ? { filters } : {}), - }, + const testSeries = { + ...mockUxSeries, + breakdown: breakdown || 'user_agent.name', + ...(filters ? { filters } : {}), }; - const allSeriesIds = Object.keys(mockDataSeries); - const firstSeriesId = allSeriesIds?.[0]; - const series = mockDataSeries[firstSeriesId]; + const mockDataSeries = data || [testSeries]; const removeSeries = jest.fn(); const setSeries = jest.fn(); - const getSeries = jest.fn().mockReturnValue(series); + const getSeries = jest.fn().mockReturnValue(testSeries); return { - firstSeriesId, - allSeriesIds, removeSeries, setSeries, getSeries, - firstSeries: mockDataSeries[firstSeriesId], + autoApply: true, + reportType: 'data-distribution', + lastRefresh: Date.now(), + setLastRefresh: jest.fn(), + setAutoApply: jest.fn(), + applyChanges: jest.fn(), + firstSeries: mockDataSeries[0], allSeries: mockDataSeries, - }; + setReportType: jest.fn(), + storage: { get: jest.fn().mockReturnValue(mockDataSeries) } as any, + } as SeriesContextValue; } export function mockUseSeriesFilter() { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.test.tsx deleted file mode 100644 index b10702ebded57..0000000000000 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.test.tsx +++ /dev/null @@ -1,62 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { fireEvent, screen } from '@testing-library/react'; -import { mockAppIndexPattern, render } from '../../rtl_helpers'; -import { dataTypes, DataTypesCol } from './data_types_col'; - -describe('DataTypesCol', function () { - const seriesId = 'test-series-id'; - - mockAppIndexPattern(); - - it('should render properly', function () { - const { getByText } = render(); - - dataTypes.forEach(({ label }) => { - getByText(label); - }); - }); - - it('should set series on change', function () { - const { setSeries } = render(); - - fireEvent.click(screen.getByText(/user experience \(rum\)/i)); - - expect(setSeries).toHaveBeenCalledTimes(1); - expect(setSeries).toHaveBeenCalledWith(seriesId, { - dataType: 'ux', - isNew: true, - time: { - from: 'now-15m', - to: 'now', - }, - }); - }); - - it('should set series on change on already selected', function () { - const initSeries = { - data: { - [seriesId]: { - dataType: 'synthetics' as const, - reportType: 'kpi-over-time' as const, - breakdown: 'monitor.status', - time: { from: 'now-15m', to: 'now' }, - }, - }, - }; - - render(, { initSeries }); - - const button = screen.getByRole('button', { - name: /Synthetic Monitoring/i, - }); - - expect(button.classList).toContain('euiButton--fill'); - }); -}); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.tsx deleted file mode 100644 index f386f62d9ed73..0000000000000 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/data_types_col.tsx +++ /dev/null @@ -1,74 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import styled from 'styled-components'; -import { AppDataType } from '../../types'; -import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; -import { useSeriesStorage } from '../../hooks/use_series_storage'; - -export const dataTypes: Array<{ id: AppDataType; label: string }> = [ - { id: 'synthetics', label: 'Synthetic Monitoring' }, - { id: 'ux', label: 'User Experience (RUM)' }, - { id: 'mobile', label: 'Mobile Experience' }, - // { id: 'infra_logs', label: 'Logs' }, - // { id: 'infra_metrics', label: 'Metrics' }, - // { id: 'apm', label: 'APM' }, -]; - -export function DataTypesCol({ seriesId }: { seriesId: string }) { - const { getSeries, setSeries, removeSeries } = useSeriesStorage(); - - const series = getSeries(seriesId); - const { loading } = useAppIndexPatternContext(); - - const onDataTypeChange = (dataType?: AppDataType) => { - if (!dataType) { - removeSeries(seriesId); - } else { - setSeries(seriesId || `${dataType}-series`, { - dataType, - isNew: true, - time: series.time, - } as any); - } - }; - - const selectedDataType = series.dataType; - - return ( - - {dataTypes.map(({ id: dataTypeId, label }) => ( - - - - ))} - - ); -} - -const FlexGroup = styled(EuiFlexGroup)` - width: 100%; -`; - -const Button = styled(EuiButton)` - will-change: transform; -`; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/date_picker_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/date_picker_col.tsx deleted file mode 100644 index 6be78084ae195..0000000000000 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/date_picker_col.tsx +++ /dev/null @@ -1,39 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import styled from 'styled-components'; -import { SeriesDatePicker } from '../../series_date_picker'; -import { DateRangePicker } from '../../series_date_picker/date_range_picker'; -import { useSeriesStorage } from '../../hooks/use_series_storage'; - -interface Props { - seriesId: string; -} -export function DatePickerCol({ seriesId }: Props) { - const { firstSeriesId, getSeries } = useSeriesStorage(); - const { reportType } = getSeries(firstSeriesId); - - return ( - - {firstSeriesId === seriesId || reportType !== 'kpi-over-time' ? ( - - ) : ( - - )} - - ); -} - -const Wrapper = styled.div` - .euiSuperDatePicker__flexWrapper { - width: 100%; - > .euiFlexItem { - margin-right: 0px; - } - } -`; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.test.tsx deleted file mode 100644 index a5e5ad3900ded..0000000000000 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.test.tsx +++ /dev/null @@ -1,74 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { fireEvent, screen } from '@testing-library/react'; -import { getDefaultConfigs } from '../../configurations/default_configs'; -import { mockIndexPattern, render } from '../../rtl_helpers'; -import { ReportBreakdowns } from './report_breakdowns'; -import { USER_AGENT_OS } from '../../configurations/constants/elasticsearch_fieldnames'; - -describe('Series Builder ReportBreakdowns', function () { - const seriesId = 'test-series-id'; - const dataViewSeries = getDefaultConfigs({ - reportType: 'data-distribution', - dataType: 'ux', - indexPattern: mockIndexPattern, - }); - - it('should render properly', function () { - render(); - - screen.getByText('Select an option: , is selected'); - screen.getAllByText('Browser family'); - }); - - it('should set new series breakdown on change', function () { - const { setSeries } = render( - - ); - - const btn = screen.getByRole('button', { - name: /select an option: Browser family , is selected/i, - hidden: true, - }); - - fireEvent.click(btn); - - fireEvent.click(screen.getByText(/operating system/i)); - - expect(setSeries).toHaveBeenCalledTimes(1); - expect(setSeries).toHaveBeenCalledWith(seriesId, { - breakdown: USER_AGENT_OS, - dataType: 'ux', - reportType: 'data-distribution', - time: { from: 'now-15m', to: 'now' }, - }); - }); - it('should set undefined on new series on no select breakdown', function () { - const { setSeries } = render( - - ); - - const btn = screen.getByRole('button', { - name: /select an option: Browser family , is selected/i, - hidden: true, - }); - - fireEvent.click(btn); - - fireEvent.click(screen.getByText(/no breakdown/i)); - - expect(setSeries).toHaveBeenCalledTimes(1); - expect(setSeries).toHaveBeenCalledWith(seriesId, { - breakdown: undefined, - dataType: 'ux', - reportType: 'data-distribution', - time: { from: 'now-15m', to: 'now' }, - }); - }); -}); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.tsx deleted file mode 100644 index fa2d01691ce1d..0000000000000 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_breakdowns.tsx +++ /dev/null @@ -1,26 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { Breakdowns } from '../../series_editor/columns/breakdowns'; -import { SeriesConfig } from '../../types'; - -export function ReportBreakdowns({ - seriesId, - seriesConfig, -}: { - seriesConfig: SeriesConfig; - seriesId: string; -}) { - return ( - - ); -} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx deleted file mode 100644 index 7962bf2b924f7..0000000000000 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.tsx +++ /dev/null @@ -1,101 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule } from '@elastic/eui'; -import styled from 'styled-components'; -import { useSeriesStorage } from '../../hooks/use_series_storage'; -import { ReportMetricOptions } from '../report_metric_options'; -import { SeriesConfig } from '../../types'; -import { SeriesChartTypesSelect } from './chart_types'; -import { OperationTypeSelect } from './operation_type_select'; -import { DatePickerCol } from './date_picker_col'; -import { parseCustomFieldName } from '../../configurations/lens_attributes'; -import { ReportDefinitionField } from './report_definition_field'; - -function getColumnType(seriesConfig: SeriesConfig, selectedMetricField?: string) { - const { columnType } = parseCustomFieldName(seriesConfig, selectedMetricField); - - return columnType; -} - -export function ReportDefinitionCol({ - seriesConfig, - seriesId, -}: { - seriesConfig: SeriesConfig; - seriesId: string; -}) { - const { getSeries, setSeries } = useSeriesStorage(); - - const series = getSeries(seriesId); - - const { reportDefinitions: selectedReportDefinitions = {}, selectedMetricField } = series ?? {}; - - const { definitionFields, defaultSeriesType, hasOperationType, yAxisColumns, metricOptions } = - seriesConfig; - - const onChange = (field: string, value?: string[]) => { - if (!value?.[0]) { - delete selectedReportDefinitions[field]; - setSeries(seriesId, { - ...series, - reportDefinitions: { ...selectedReportDefinitions }, - }); - } else { - setSeries(seriesId, { - ...series, - reportDefinitions: { ...selectedReportDefinitions, [field]: value }, - }); - } - }; - - const columnType = getColumnType(seriesConfig, selectedMetricField); - - return ( - - - - - - {definitionFields.map((field) => ( - - - - ))} - {metricOptions && ( - - - - )} - {(hasOperationType || columnType === 'operation') && ( - - - - )} - - - - - ); -} - -const FlexGroup = styled(EuiFlexGroup)` - width: 100%; -`; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.test.tsx deleted file mode 100644 index 0b183b5f20c03..0000000000000 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.test.tsx +++ /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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { screen } from '@testing-library/react'; -import { ReportFilters } from './report_filters'; -import { getDefaultConfigs } from '../../configurations/default_configs'; -import { mockIndexPattern, render } from '../../rtl_helpers'; - -describe('Series Builder ReportFilters', function () { - const seriesId = 'test-series-id'; - - const dataViewSeries = getDefaultConfigs({ - reportType: 'data-distribution', - indexPattern: mockIndexPattern, - dataType: 'ux', - }); - - it('should render properly', function () { - render(); - - screen.getByText('Add filter'); - }); -}); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.tsx deleted file mode 100644 index d5938c5387e8f..0000000000000 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_filters.tsx +++ /dev/null @@ -1,29 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { SeriesFilter } from '../../series_editor/columns/series_filter'; -import { SeriesConfig } from '../../types'; - -export function ReportFilters({ - seriesConfig, - seriesId, -}: { - seriesConfig: SeriesConfig; - seriesId: string; -}) { - return ( - - ); -} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx deleted file mode 100644 index 12ae8560453c9..0000000000000 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.test.tsx +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { fireEvent, screen } from '@testing-library/react'; -import { mockAppIndexPattern, render } from '../../rtl_helpers'; -import { ReportTypesCol, SELECTED_DATA_TYPE_FOR_REPORT } from './report_types_col'; -import { ReportTypes } from '../series_builder'; -import { DEFAULT_TIME } from '../../configurations/constants'; - -describe('ReportTypesCol', function () { - const seriesId = 'performance-distribution'; - - mockAppIndexPattern(); - - it('should render properly', function () { - render(); - screen.getByText('Performance distribution'); - screen.getByText('KPI over time'); - }); - - it('should display empty message', function () { - render(); - screen.getByText(SELECTED_DATA_TYPE_FOR_REPORT); - }); - - it('should set series on change', function () { - const { setSeries } = render( - - ); - - fireEvent.click(screen.getByText(/KPI over time/i)); - - expect(setSeries).toHaveBeenCalledWith(seriesId, { - dataType: 'ux', - selectedMetricField: undefined, - reportType: 'kpi-over-time', - time: { from: 'now-15m', to: 'now' }, - }); - expect(setSeries).toHaveBeenCalledTimes(1); - }); - - it('should set selected as filled', function () { - const initSeries = { - data: { - [seriesId]: { - dataType: 'synthetics' as const, - reportType: 'kpi-over-time' as const, - breakdown: 'monitor.status', - time: { from: 'now-15m', to: 'now' }, - isNew: true, - }, - }, - }; - - const { setSeries } = render( - , - { initSeries } - ); - - const button = screen.getByRole('button', { - name: /KPI over time/i, - }); - - expect(button.classList).toContain('euiButton--fill'); - fireEvent.click(button); - - // undefined on click selected - expect(setSeries).toHaveBeenCalledWith(seriesId, { - dataType: 'synthetics', - time: DEFAULT_TIME, - isNew: true, - }); - }); -}); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx deleted file mode 100644 index c4eebbfaca3eb..0000000000000 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_types_col.tsx +++ /dev/null @@ -1,108 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { map } from 'lodash'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; -import styled from 'styled-components'; -import { ReportViewType, SeriesUrl } from '../../types'; -import { useSeriesStorage } from '../../hooks/use_series_storage'; -import { DEFAULT_TIME } from '../../configurations/constants'; -import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; -import { ReportTypeItem } from '../series_builder'; - -interface Props { - seriesId: string; - reportTypes: ReportTypeItem[]; -} - -export function ReportTypesCol({ seriesId, reportTypes }: Props) { - const { setSeries, getSeries, firstSeries, firstSeriesId } = useSeriesStorage(); - - const { reportType: selectedReportType, ...restSeries } = getSeries(seriesId); - - const { loading, hasData } = useAppIndexPatternContext(restSeries.dataType); - - if (!restSeries.dataType) { - return ( - - ); - } - - if (!loading && !hasData) { - return ( - - ); - } - - const disabledReportTypes: ReportViewType[] = map( - reportTypes.filter( - ({ reportType }) => firstSeriesId !== seriesId && reportType !== firstSeries.reportType - ), - 'reportType' - ); - - return reportTypes?.length > 0 ? ( - - {reportTypes.map(({ reportType, label }) => ( - - - - ))} - - ) : ( - {SELECTED_DATA_TYPE_FOR_REPORT} - ); -} - -export const SELECTED_DATA_TYPE_FOR_REPORT = i18n.translate( - 'xpack.observability.expView.reportType.noDataType', - { defaultMessage: 'No data type selected.' } -); - -const FlexGroup = styled(EuiFlexGroup)` - width: 100%; -`; - -const Button = styled(EuiButton)` - will-change: transform; -`; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/report_metric_options.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/report_metric_options.tsx deleted file mode 100644 index a2a3e34c21834..0000000000000 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/report_metric_options.tsx +++ /dev/null @@ -1,46 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { EuiSuperSelect } from '@elastic/eui'; -import { useSeriesStorage } from '../hooks/use_series_storage'; -import { SeriesConfig } from '../types'; - -interface Props { - seriesId: string; - defaultValue?: string; - options: SeriesConfig['metricOptions']; -} - -export function ReportMetricOptions({ seriesId, options: opts }: Props) { - const { getSeries, setSeries } = useSeriesStorage(); - - const series = getSeries(seriesId); - - const onChange = (value: string) => { - setSeries(seriesId, { - ...series, - selectedMetricField: value, - }); - }; - - const options = opts ?? []; - - return ( - ({ - value: fd || id, - inputDisplay: label, - }))} - valueOfSelected={series.selectedMetricField || options?.[0].field || options?.[0].id} - onChange={(value) => onChange(value)} - /> - ); -} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx deleted file mode 100644 index 684cf3a210a51..0000000000000 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/series_builder.tsx +++ /dev/null @@ -1,303 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { RefObject, useEffect, useState } from 'react'; -import { isEmpty } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { - EuiBasicTable, - EuiButton, - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, - EuiSwitch, -} from '@elastic/eui'; -import { rgba } from 'polished'; -import { AppDataType, SeriesConfig, ReportViewType, SeriesUrl } from '../types'; -import { DataTypesCol } from './columns/data_types_col'; -import { ReportTypesCol } from './columns/report_types_col'; -import { ReportDefinitionCol } from './columns/report_definition_col'; -import { ReportFilters } from './columns/report_filters'; -import { ReportBreakdowns } from './columns/report_breakdowns'; -import { NEW_SERIES_KEY, useSeriesStorage } from '../hooks/use_series_storage'; -import { useAppIndexPatternContext } from '../hooks/use_app_index_pattern'; -import { getDefaultConfigs } from '../configurations/default_configs'; -import { SeriesEditor } from '../series_editor/series_editor'; -import { SeriesActions } from '../series_editor/columns/series_actions'; -import { euiStyled } from '../../../../../../../../src/plugins/kibana_react/common'; -import { LastUpdated } from './last_updated'; -import { - CORE_WEB_VITALS_LABEL, - DEVICE_DISTRIBUTION_LABEL, - KPI_OVER_TIME_LABEL, - PERF_DIST_LABEL, -} from '../configurations/constants/labels'; - -export interface ReportTypeItem { - id: string; - reportType: ReportViewType; - label: string; -} - -export const ReportTypes: Record = { - synthetics: [ - { id: 'kpi', reportType: 'kpi-over-time', label: KPI_OVER_TIME_LABEL }, - { id: 'dist', reportType: 'data-distribution', label: PERF_DIST_LABEL }, - ], - ux: [ - { id: 'kpi', reportType: 'kpi-over-time', label: KPI_OVER_TIME_LABEL }, - { id: 'dist', reportType: 'data-distribution', label: PERF_DIST_LABEL }, - { id: 'cwv', reportType: 'core-web-vitals', label: CORE_WEB_VITALS_LABEL }, - ], - mobile: [ - { id: 'kpi', reportType: 'kpi-over-time', label: KPI_OVER_TIME_LABEL }, - { id: 'dist', reportType: 'data-distribution', label: PERF_DIST_LABEL }, - { id: 'mdd', reportType: 'device-data-distribution', label: DEVICE_DISTRIBUTION_LABEL }, - ], - apm: [], - infra_logs: [], - infra_metrics: [], -}; - -interface BuilderItem { - id: string; - series: SeriesUrl; - seriesConfig?: SeriesConfig; -} - -export function SeriesBuilder({ - seriesBuilderRef, - lastUpdated, - multiSeries, -}: { - seriesBuilderRef: RefObject; - lastUpdated?: number; - multiSeries?: boolean; -}) { - const [editorItems, setEditorItems] = useState([]); - const { getSeries, allSeries, allSeriesIds, setSeries, removeSeries } = useSeriesStorage(); - - const { loading, indexPatterns } = useAppIndexPatternContext(); - - useEffect(() => { - const getDataViewSeries = (dataType: AppDataType, reportType: SeriesUrl['reportType']) => { - if (indexPatterns?.[dataType]) { - return getDefaultConfigs({ - dataType, - indexPattern: indexPatterns[dataType], - reportType: reportType!, - }); - } - }; - - const seriesToEdit: BuilderItem[] = - allSeriesIds - .filter((sId) => { - return allSeries?.[sId]?.isNew; - }) - .map((sId) => { - const series = getSeries(sId); - const seriesConfig = getDataViewSeries(series.dataType, series.reportType); - - return { id: sId, series, seriesConfig }; - }) ?? []; - const initSeries: BuilderItem[] = [{ id: 'series-id', series: {} as SeriesUrl }]; - setEditorItems(multiSeries || seriesToEdit.length > 0 ? seriesToEdit : initSeries); - }, [allSeries, allSeriesIds, getSeries, indexPatterns, loading, multiSeries]); - - const columns = [ - { - name: i18n.translate('xpack.observability.expView.seriesBuilder.dataType', { - defaultMessage: 'Data Type', - }), - field: 'id', - width: '15%', - render: (seriesId: string) => , - }, - { - name: i18n.translate('xpack.observability.expView.seriesBuilder.report', { - defaultMessage: 'Report', - }), - width: '15%', - field: 'id', - render: (seriesId: string, { series: { dataType } }: BuilderItem) => ( - - ), - }, - { - name: i18n.translate('xpack.observability.expView.seriesBuilder.definition', { - defaultMessage: 'Definition', - }), - width: '30%', - field: 'id', - render: ( - seriesId: string, - { series: { dataType, reportType }, seriesConfig }: BuilderItem - ) => { - if (dataType && seriesConfig) { - return loading ? ( - LOADING_VIEW - ) : reportType ? ( - - ) : ( - SELECT_REPORT_TYPE - ); - } - - return null; - }, - }, - { - name: i18n.translate('xpack.observability.expView.seriesBuilder.filters', { - defaultMessage: 'Filters', - }), - width: '20%', - field: 'id', - render: (seriesId: string, { series: { reportType }, seriesConfig }: BuilderItem) => - reportType && seriesConfig ? ( - - ) : null, - }, - { - name: i18n.translate('xpack.observability.expView.seriesBuilder.breakdown', { - defaultMessage: 'Breakdowns', - }), - width: '20%', - field: 'id', - render: (seriesId: string, { series: { reportType }, seriesConfig }: BuilderItem) => - reportType && seriesConfig ? ( - - ) : null, - }, - ...(multiSeries - ? [ - { - name: i18n.translate('xpack.observability.expView.seriesBuilder.actions', { - defaultMessage: 'Actions', - }), - align: 'center' as const, - width: '10%', - field: 'id', - render: (seriesId: string, item: BuilderItem) => ( - - ), - }, - ] - : []), - ]; - - const applySeries = () => { - editorItems.forEach(({ series, id: seriesId }) => { - const { reportType, reportDefinitions, isNew, ...restSeries } = series; - - if (reportType && !isEmpty(reportDefinitions)) { - const reportDefId = Object.values(reportDefinitions ?? {})[0]; - const newSeriesId = `${reportDefId}-${reportType}`; - - const newSeriesN: SeriesUrl = { - ...restSeries, - reportType, - reportDefinitions, - }; - - setSeries(newSeriesId, newSeriesN); - removeSeries(seriesId); - } - }); - }; - - const addSeries = () => { - const prevSeries = allSeries?.[allSeriesIds?.[0]]; - setSeries( - `${NEW_SERIES_KEY}-${editorItems.length + 1}`, - prevSeries - ? ({ isNew: true, time: prevSeries.time } as SeriesUrl) - : ({ isNew: true } as SeriesUrl) - ); - }; - - return ( - - {multiSeries && ( - - - - - - {}} - compressed - /> - - - applySeries()} isDisabled={true} size="s"> - {i18n.translate('xpack.observability.expView.seriesBuilder.apply', { - defaultMessage: 'Apply changes', - })} - - - - addSeries()} size="s"> - {i18n.translate('xpack.observability.expView.seriesBuilder.addSeries', { - defaultMessage: 'Add Series', - })} - - - - )} -
- {multiSeries && } - {editorItems.length > 0 && ( - - )} - -
-
- ); -} - -const Wrapper = euiStyled.div` - max-height: 50vh; - overflow-y: scroll; - overflow-x: clip; - &::-webkit-scrollbar { - height: ${({ theme }) => theme.eui.euiScrollBar}; - width: ${({ theme }) => theme.eui.euiScrollBar}; - } - &::-webkit-scrollbar-thumb { - background-clip: content-box; - background-color: ${({ theme }) => rgba(theme.eui.euiColorDarkShade, 0.5)}; - border: ${({ theme }) => theme.eui.euiScrollBarCorner} solid transparent; - } - &::-webkit-scrollbar-corner, - &::-webkit-scrollbar-track { - background-color: transparent; - } -`; - -export const LOADING_VIEW = i18n.translate( - 'xpack.observability.expView.seriesBuilder.loadingView', - { - defaultMessage: 'Loading view ...', - } -); - -export const SELECT_REPORT_TYPE = i18n.translate( - 'xpack.observability.expView.seriesBuilder.selectReportType', - { - defaultMessage: 'No report type selected', - } -); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/chart_edit_options.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/chart_edit_options.tsx deleted file mode 100644 index 207a53e13f1ad..0000000000000 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/chart_edit_options.tsx +++ /dev/null @@ -1,30 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { Breakdowns } from './columns/breakdowns'; -import { SeriesConfig } from '../types'; -import { ChartOptions } from './columns/chart_options'; - -interface Props { - seriesConfig: SeriesConfig; - seriesId: string; - breakdownFields: string[]; -} -export function ChartEditOptions({ seriesConfig, seriesId, breakdownFields }: Props) { - return ( - - - - - - - - - ); -} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.test.tsx index 84568e1c5068a..21b766227a562 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; import { Breakdowns } from './breakdowns'; -import { mockIndexPattern, render } from '../../rtl_helpers'; +import { mockIndexPattern, mockUxSeries, render } from '../../rtl_helpers'; import { getDefaultConfigs } from '../../configurations/default_configs'; import { USER_AGENT_OS } from '../../configurations/constants/elasticsearch_fieldnames'; @@ -20,13 +20,7 @@ describe('Breakdowns', function () { }); it('should render properly', async function () { - render( - - ); + render(); screen.getAllByText('Browser family'); }); @@ -36,9 +30,9 @@ describe('Breakdowns', function () { const { setSeries } = render( , { initSeries } ); @@ -49,10 +43,14 @@ describe('Breakdowns', function () { fireEvent.click(screen.getByText('Browser family')); - expect(setSeries).toHaveBeenCalledWith('series-id', { + expect(setSeries).toHaveBeenCalledWith(0, { breakdown: 'user_agent.name', dataType: 'ux', - reportType: 'data-distribution', + name: 'performance-distribution', + reportDefinitions: { + 'service.name': ['elastic-co'], + }, + selectedMetricField: 'transaction.duration.us', time: { from: 'now-15m', to: 'now' }, }); expect(setSeries).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.tsx index 2237935d466ad..6003ddbf0290f 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/breakdowns.tsx @@ -10,18 +10,16 @@ import { EuiSuperSelect } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useSeriesStorage } from '../../hooks/use_series_storage'; import { USE_BREAK_DOWN_COLUMN } from '../../configurations/constants'; -import { SeriesConfig } from '../../types'; +import { SeriesConfig, SeriesUrl } from '../../types'; interface Props { - seriesId: string; - breakdowns: string[]; - seriesConfig: SeriesConfig; + seriesId: number; + series: SeriesUrl; + seriesConfig?: SeriesConfig; } -export function Breakdowns({ seriesConfig, seriesId, breakdowns = [] }: Props) { - const { setSeries, getSeries } = useSeriesStorage(); - - const series = getSeries(seriesId); +export function Breakdowns({ seriesConfig, seriesId, series }: Props) { + const { setSeries } = useSeriesStorage(); const selectedBreakdown = series.breakdown; const NO_BREAKDOWN = 'no_breakdown'; @@ -40,9 +38,13 @@ export function Breakdowns({ seriesConfig, seriesId, breakdowns = [] }: Props) { } }; + if (!seriesConfig) { + return null; + } + const hasUseBreakdownColumn = seriesConfig.xAxisColumn.sourceField === USE_BREAK_DOWN_COLUMN; - const items = breakdowns.map((breakdown) => ({ + const items = seriesConfig.breakdownFields.map((breakdown) => ({ id: breakdown, label: seriesConfig.labels[breakdown], })); @@ -50,14 +52,12 @@ export function Breakdowns({ seriesConfig, seriesId, breakdowns = [] }: Props) { if (!hasUseBreakdownColumn) { items.push({ id: NO_BREAKDOWN, - label: i18n.translate('xpack.observability.exp.breakDownFilter.noBreakdown', { - defaultMessage: 'No breakdown', - }), + label: NO_BREAK_DOWN_LABEL, }); } const options = items.map(({ id, label }) => ({ - inputDisplay: id === NO_BREAKDOWN ? label : {label}, + inputDisplay: label, value: id, dropdownDisplay: label, })); @@ -66,15 +66,18 @@ export function Breakdowns({ seriesConfig, seriesId, breakdowns = [] }: Props) { selectedBreakdown || (hasUseBreakdownColumn ? options[0].value : NO_BREAKDOWN); return ( -
- onOptionChange(value)} - data-test-subj={'seriesBreakdown'} - /> -
+ onOptionChange(value)} + data-test-subj={'seriesBreakdown'} + /> ); } + +export const NO_BREAK_DOWN_LABEL = i18n.translate( + 'xpack.observability.exp.breakDownFilter.noBreakdown', + { + defaultMessage: 'No breakdown', + } +); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/chart_options.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/chart_options.tsx deleted file mode 100644 index f2a6377fd9b71..0000000000000 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/chart_options.tsx +++ /dev/null @@ -1,35 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { SeriesConfig } from '../../types'; -import { OperationTypeSelect } from '../../series_builder/columns/operation_type_select'; -import { SeriesChartTypesSelect } from '../../series_builder/columns/chart_types'; - -interface Props { - seriesConfig: SeriesConfig; - seriesId: string; -} - -export function ChartOptions({ seriesConfig, seriesId }: Props) { - return ( - - - - - {seriesConfig.hasOperationType && ( - - - - )} - - ); -} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/chart_type_select.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/chart_type_select.tsx new file mode 100644 index 0000000000000..6f88de5cc2afc --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/chart_type_select.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { EuiPopover, EuiToolTip, EuiButtonEmpty, EuiIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useKibana } from '../../../../../../../../../src/plugins/kibana_react/public'; +import { ObservabilityPublicPluginsStart } from '../../../../../plugin'; +import { SeriesUrl, useFetcher } from '../../../../../index'; +import { SeriesConfig } from '../../types'; +import { SeriesChartTypesSelect } from './chart_types'; + +interface Props { + seriesId: number; + series: SeriesUrl; + seriesConfig: SeriesConfig; +} + +export function SeriesChartTypes({ seriesId, series, seriesConfig }: Props) { + const seriesType = series?.seriesType ?? seriesConfig.defaultSeriesType; + + const { + services: { lens }, + } = useKibana(); + + const { data = [] } = useFetcher(() => lens.getXyVisTypes(), [lens]); + + const icon = (data ?? []).find(({ id }) => id === seriesType)?.icon; + + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + return ( + setIsPopoverOpen(false)} + button={ + + setIsPopoverOpen((prevState) => !prevState)} + flush="both" + > + {icon && ( + id === seriesType)?.icon!} size="l" /> + )} + + + } + > + + + ); +} + +const EDIT_CHART_TYPE_LABEL = i18n.translate( + 'xpack.observability.expView.seriesEditor.editChartSeriesLabel', + { + defaultMessage: 'Edit chart type for series', + } +); + +const CHART_TYPE_LABEL = i18n.translate('xpack.observability.expView.chartTypes.label', { + defaultMessage: 'Chart type', +}); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/chart_types.test.tsx similarity index 85% rename from x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.test.tsx rename to x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/chart_types.test.tsx index c054853d9c877..8f196b8a05dda 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/chart_types.test.tsx @@ -7,12 +7,12 @@ import React from 'react'; import { fireEvent, screen, waitFor } from '@testing-library/react'; -import { render } from '../../rtl_helpers'; +import { mockUxSeries, render } from '../../rtl_helpers'; import { SeriesChartTypesSelect, XYChartTypesSelect } from './chart_types'; describe.skip('SeriesChartTypesSelect', function () { it('should render properly', async function () { - render(); + render(); await waitFor(() => { screen.getByText(/chart type/i); @@ -21,7 +21,7 @@ describe.skip('SeriesChartTypesSelect', function () { it('should call set series on change', async function () { const { setSeries } = render( - + ); await waitFor(() => { diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/chart_types.tsx similarity index 77% rename from x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.tsx rename to x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/chart_types.tsx index 50c2f91e6067d..27d846502dbe6 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/chart_types.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/chart_types.tsx @@ -6,11 +6,11 @@ */ import React from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSuperSelect } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiIcon, EuiSuperSelect } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useKibana } from '../../../../../../../../../src/plugins/kibana_react/public'; import { ObservabilityPublicPluginsStart } from '../../../../../plugin'; -import { useFetcher } from '../../../../..'; +import { SeriesUrl, useFetcher } from '../../../../..'; import { useSeriesStorage } from '../../hooks/use_series_storage'; import { SeriesType } from '../../../../../../../lens/public'; @@ -20,16 +20,14 @@ const CHART_TYPE_LABEL = i18n.translate('xpack.observability.expView.chartTypes. export function SeriesChartTypesSelect({ seriesId, - seriesTypes, + series, defaultChartType, }: { - seriesId: string; - seriesTypes?: SeriesType[]; + seriesId: number; + series: SeriesUrl; defaultChartType: SeriesType; }) { - const { getSeries, setSeries } = useSeriesStorage(); - - const series = getSeries(seriesId); + const { setSeries } = useSeriesStorage(); const seriesType = series?.seriesType ?? defaultChartType; @@ -42,17 +40,15 @@ export function SeriesChartTypesSelect({ onChange={onChange} value={seriesType} excludeChartTypes={['bar_percentage_stacked']} - includeChartTypes={ - seriesTypes || [ - 'bar', - 'bar_horizontal', - 'line', - 'area', - 'bar_stacked', - 'area_stacked', - 'bar_horizontal_percentage_stacked', - ] - } + includeChartTypes={[ + 'bar', + 'bar_horizontal', + 'line', + 'area', + 'bar_stacked', + 'area_stacked', + 'bar_horizontal_percentage_stacked', + ]} label={CHART_TYPE_LABEL} /> ); @@ -105,14 +101,14 @@ export function XYChartTypesSelect({ }); return ( - + + + ); } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/data_type_select.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/data_type_select.test.tsx new file mode 100644 index 0000000000000..fc96ad0741ec5 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/data_type_select.test.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { fireEvent, screen } from '@testing-library/react'; +import { mockAppIndexPattern, mockUxSeries, render } from '../../rtl_helpers'; +import { DataTypesLabels, DataTypesSelect } from './data_type_select'; +import { DataTypes } from '../../configurations/constants'; + +describe('DataTypeSelect', function () { + const seriesId = 0; + + mockAppIndexPattern(); + + it('should render properly', function () { + render(); + }); + + it('should set series on change', async function () { + const seriesWithoutDataType = { + ...mockUxSeries, + dataType: undefined, + }; + const { setSeries } = render( + + ); + + fireEvent.click(await screen.findByText('Select data type')); + fireEvent.click(await screen.findByText(DataTypesLabels[DataTypes.SYNTHETICS])); + + expect(setSeries).toHaveBeenCalledTimes(1); + expect(setSeries).toHaveBeenCalledWith(seriesId, { + dataType: 'synthetics', + name: 'synthetics-series-1', + time: { + from: 'now-15m', + to: 'now', + }, + }); + }); +}); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/data_type_select.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/data_type_select.tsx new file mode 100644 index 0000000000000..71fd147e8e264 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/data_type_select.tsx @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { + EuiButton, + EuiPopover, + EuiListGroup, + EuiListGroupItem, + EuiBadge, + EuiToolTip, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; +import { AppDataType, SeriesUrl } from '../../types'; +import { DataTypes, ReportTypes } from '../../configurations/constants'; + +interface Props { + seriesId: number; + series: Omit & { + dataType?: SeriesUrl['dataType']; + }; +} + +export const DataTypesLabels = { + [DataTypes.UX]: i18n.translate('xpack.observability.overview.exploratoryView.uxLabel', { + defaultMessage: 'User experience (RUM)', + }), + + [DataTypes.SYNTHETICS]: i18n.translate( + 'xpack.observability.overview.exploratoryView.syntheticsLabel', + { + defaultMessage: 'Synthetics monitoring', + } + ), + + [DataTypes.MOBILE]: i18n.translate( + 'xpack.observability.overview.exploratoryView.mobileExperienceLabel', + { + defaultMessage: 'Mobile experience', + } + ), +}; + +export const dataTypes: Array<{ id: AppDataType; label: string }> = [ + { + id: DataTypes.SYNTHETICS, + label: DataTypesLabels[DataTypes.SYNTHETICS], + }, + { + id: DataTypes.UX, + label: DataTypesLabels[DataTypes.UX], + }, + { + id: DataTypes.MOBILE, + label: DataTypesLabels[DataTypes.MOBILE], + }, +]; + +const SELECT_DATA_TYPE = 'SELECT_DATA_TYPE'; + +export function DataTypesSelect({ seriesId, series }: Props) { + const { setSeries, reportType } = useSeriesStorage(); + const [showOptions, setShowOptions] = useState(false); + + const onDataTypeChange = (dataType: AppDataType) => { + if (String(dataType) !== SELECT_DATA_TYPE) { + setSeries(seriesId, { + dataType, + time: series.time, + name: `${dataType}-series-${seriesId + 1}`, + }); + } + }; + + const options = dataTypes + .filter(({ id }) => { + if (reportType === ReportTypes.DEVICE_DISTRIBUTION) { + return id === DataTypes.MOBILE; + } + if (reportType === ReportTypes.CORE_WEB_VITAL) { + return id === DataTypes.UX; + } + return true; + }) + .map(({ id, label }) => ({ + value: id, + inputDisplay: label, + })); + + return ( + <> + {!series.dataType && ( + setShowOptions((prevState) => !prevState)} + fill + size="s" + > + {SELECT_DATA_TYPE_LABEL} + + } + isOpen={showOptions} + closePopover={() => setShowOptions((prevState) => !prevState)} + > + + {options.map((option) => ( + onDataTypeChange(option.value)} + label={option.inputDisplay} + /> + ))} + + + )} + {series.dataType && ( + + {DataTypesLabels[series.dataType as DataTypes]} + + )} + + ); +} + +const SELECT_DATA_TYPE_LABEL = i18n.translate( + 'xpack.observability.overview.exploratoryView.selectDataType', + { + defaultMessage: 'Select data type', + } +); + +const SELECT_DATA_TYPE_TOOLTIP = i18n.translate( + 'xpack.observability.overview.exploratoryView.selectDataTypeTooltip', + { + defaultMessage: 'Data type cannot be edited.', + } +); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/date_picker_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/date_picker_col.tsx index 41e83f407af2b..b01010e4b81f9 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/date_picker_col.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/date_picker_col.tsx @@ -6,24 +6,80 @@ */ import React from 'react'; -import { SeriesDatePicker } from '../../series_date_picker'; +import styled from 'styled-components'; +import { i18n } from '@kbn/i18n'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { useSeriesStorage } from '../../hooks/use_series_storage'; -import { DateRangePicker } from '../../series_date_picker/date_range_picker'; +import { DateRangePicker } from '../../components/date_range_picker'; +import { SeriesDatePicker } from '../../components/series_date_picker'; +import { AppDataType, SeriesUrl } from '../../types'; +import { ReportTypes } from '../../configurations/constants'; +import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; +import { SyntheticsAddData } from '../../../add_data_buttons/synthetics_add_data'; +import { MobileAddData } from '../../../add_data_buttons/mobile_add_data'; +import { UXAddData } from '../../../add_data_buttons/ux_add_data'; interface Props { - seriesId: string; + seriesId: number; + series: SeriesUrl; } -export function DatePickerCol({ seriesId }: Props) { - const { firstSeriesId, getSeries } = useSeriesStorage(); - const { reportType } = getSeries(firstSeriesId); + +const AddDataComponents: Record = { + mobile: MobileAddData, + ux: UXAddData, + synthetics: SyntheticsAddData, + apm: null, + infra_logs: null, + infra_metrics: null, +}; + +export function DatePickerCol({ seriesId, series }: Props) { + const { reportType } = useSeriesStorage(); + + const { hasAppData } = useAppIndexPatternContext(); + + if (!series.dataType) { + return null; + } + + const AddDataButton = AddDataComponents[series.dataType]; + if (hasAppData[series.dataType] === false && AddDataButton !== null) { + return ( + + + + {i18n.translate('xpack.observability.overview.exploratoryView.noDataAvailable', { + defaultMessage: 'No {dataType} data available.', + values: { + dataType: series.dataType, + }, + })} + + + + + + + ); + } return ( -
- {firstSeriesId === seriesId || reportType !== 'kpi-over-time' ? ( - + + {seriesId === 0 || reportType !== ReportTypes.KPI ? ( + ) : ( - + )} -
+ ); } + +const Wrapper = styled.div` + width: 100%; + .euiSuperDatePicker__flexWrapper { + width: 100%; + > .euiFlexItem { + margin-right: 0; + } + } +`; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx index 90a039f6b44d0..a88e2eadd10c9 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.test.tsx @@ -8,20 +8,24 @@ import React from 'react'; import { fireEvent, screen, waitFor } from '@testing-library/react'; import { FilterExpanded } from './filter_expanded'; -import { mockAppIndexPattern, mockUseValuesList, render } from '../../rtl_helpers'; +import { mockUxSeries, mockAppIndexPattern, mockUseValuesList, render } from '../../rtl_helpers'; import { USER_AGENT_NAME } from '../../configurations/constants/elasticsearch_fieldnames'; describe('FilterExpanded', function () { - it('should render properly', async function () { - const initSeries = { filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }; + const filters = [{ field: USER_AGENT_NAME, values: ['Chrome'] }]; + + const mockSeries = { ...mockUxSeries, filters }; + + it('render', async () => { + const initSeries = { filters }; mockAppIndexPattern(); render( , { initSeries } @@ -33,15 +37,14 @@ describe('FilterExpanded', function () { }); it('should call go back on click', async function () { - const initSeries = { filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }; - const goBack = jest.fn(); + const initSeries = { filters }; render( , { initSeries } @@ -49,28 +52,23 @@ describe('FilterExpanded', function () { await waitFor(() => { fireEvent.click(screen.getByText('Browser Family')); - - expect(goBack).toHaveBeenCalledTimes(1); - expect(goBack).toHaveBeenCalledWith(); }); }); - it('should call useValuesList on load', async function () { - const initSeries = { filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }; + it('calls useValuesList on load', async () => { + const initSeries = { filters }; const { spy } = mockUseValuesList([ { label: 'Chrome', count: 10 }, { label: 'Firefox', count: 5 }, ]); - const goBack = jest.fn(); - render( , { initSeries } @@ -87,8 +85,8 @@ describe('FilterExpanded', function () { }); }); - it('should filter display values', async function () { - const initSeries = { filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }; + it('filters display values', async () => { + const initSeries = { filters }; mockUseValuesList([ { label: 'Chrome', count: 10 }, @@ -97,18 +95,20 @@ describe('FilterExpanded', function () { render( , { initSeries } ); - expect(screen.getByText('Firefox')).toBeTruthy(); - await waitFor(() => { + fireEvent.click(screen.getByText('Browser Family')); + + expect(screen.queryByText('Firefox')).toBeTruthy(); + fireEvent.input(screen.getByRole('searchbox'), { target: { value: 'ch' } }); expect(screen.queryByText('Firefox')).toBeFalsy(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.tsx index 84c326f62f89d..693b79c6dc831 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_expanded.tsx @@ -6,7 +6,14 @@ */ import React, { useState, Fragment } from 'react'; -import { EuiFieldSearch, EuiSpacer, EuiButtonEmpty, EuiFilterGroup, EuiText } from '@elastic/eui'; +import { + EuiFieldSearch, + EuiSpacer, + EuiFilterGroup, + EuiText, + EuiPopover, + EuiFilterButton, +} from '@elastic/eui'; import styled from 'styled-components'; import { rgba } from 'polished'; import { i18n } from '@kbn/i18n'; @@ -14,8 +21,7 @@ import { QueryDslQueryContainer } from '@elastic/elasticsearch/api/types'; import { map } from 'lodash'; import { ExistsFilter, isExistsFilter } from '@kbn/es-query'; import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; -import { useSeriesStorage } from '../../hooks/use_series_storage'; -import { SeriesConfig, UrlFilter } from '../../types'; +import { SeriesConfig, SeriesUrl, UrlFilter } from '../../types'; import { FilterValueButton } from './filter_value_btn'; import { useValuesList } from '../../../../../hooks/use_values_list'; import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; @@ -23,31 +29,33 @@ import { ESFilter } from '../../../../../../../../../src/core/types/elasticsearc import { PersistableFilter } from '../../../../../../../lens/common'; interface Props { - seriesId: string; + seriesId: number; + series: SeriesUrl; label: string; field: string; isNegated?: boolean; - goBack: () => void; nestedField?: string; filters: SeriesConfig['baseFilters']; } +export interface NestedFilterOpen { + value: string; + negate: boolean; +} + export function FilterExpanded({ seriesId, + series, field, label, - goBack, nestedField, isNegated, filters: defaultFilters, }: Props) { const [value, setValue] = useState(''); - const [isOpen, setIsOpen] = useState({ value: '', negate: false }); - - const { getSeries } = useSeriesStorage(); - - const series = getSeries(seriesId); + const [isOpen, setIsOpen] = useState(false); + const [isNestedOpen, setIsNestedOpen] = useState({ value: '', negate: false }); const queryFilters: ESFilter[] = []; @@ -80,62 +88,71 @@ export function FilterExpanded({ ); return ( - - goBack()}> - {label} - - { - setValue(evt.target.value); - }} - placeholder={i18n.translate('xpack.observability.filters.expanded.search', { - defaultMessage: 'Search for {label}', - values: { label }, - })} - /> - - - {displayValues.length === 0 && !loading && ( - - {i18n.translate('xpack.observability.filters.expanded.noFilter', { - defaultMessage: 'No filters found.', - })} - - )} - {displayValues.map((opt) => ( - - - {isNegated !== false && ( + setIsOpen((prevState) => !prevState)} iconType="arrowDown"> + {label} + + } + isOpen={isOpen} + closePopover={() => setIsOpen(false)} + > + + { + setValue(evt.target.value); + }} + placeholder={i18n.translate('xpack.observability.filters.expanded.search', { + defaultMessage: 'Search for {label}', + values: { label }, + })} + /> + + + {displayValues.length === 0 && !loading && ( + + {i18n.translate('xpack.observability.filters.expanded.noFilter', { + defaultMessage: 'No filters found.', + })} + + )} + {displayValues.map((opt) => ( + + + {isNegated !== false && ( + + )} - )} - - - - - ))} - - + + + + ))} + + + ); } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.test.tsx index a9609abc70d69..764a27fd663f5 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { fireEvent, screen, waitFor } from '@testing-library/react'; import { FilterValueButton } from './filter_value_btn'; -import { mockUseSeriesFilter, mockUseValuesList, render } from '../../rtl_helpers'; +import { mockUxSeries, mockUseSeriesFilter, mockUseValuesList, render } from '../../rtl_helpers'; import { USER_AGENT_NAME, USER_AGENT_VERSION, @@ -19,84 +19,98 @@ describe('FilterValueButton', function () { render( ); - screen.getByText('Chrome'); + await waitFor(() => { + expect(screen.getByText('Chrome')).toBeInTheDocument(); + }); }); - it('should render display negate state', async function () { - render( - - ); + describe('when negate is true', () => { + it('displays negate stats', async () => { + render( + + ); - await waitFor(() => { - screen.getByText('Not Chrome'); - screen.getByTitle('Not Chrome'); - const btn = screen.getByRole('button'); - expect(btn.classList).toContain('euiButtonEmpty--danger'); + await waitFor(() => { + expect(screen.getByText('Not Chrome')).toBeInTheDocument(); + expect(screen.getByTitle('Not Chrome')).toBeInTheDocument(); + const btn = screen.getByRole('button'); + expect(btn.classList).toContain('euiButtonEmpty--danger'); + }); }); - }); - it('should call set filter on click', async function () { - const { setFilter, removeFilter } = mockUseSeriesFilter(); + it('calls setFilter on click', async () => { + const { setFilter, removeFilter } = mockUseSeriesFilter(); - render( - - ); + render( + + ); - await waitFor(() => { fireEvent.click(screen.getByText('Not Chrome')); - expect(removeFilter).toHaveBeenCalledTimes(0); - expect(setFilter).toHaveBeenCalledTimes(1); - expect(setFilter).toHaveBeenCalledWith({ - field: 'user_agent.name', - negate: true, - value: 'Chrome', + + await waitFor(() => { + expect(removeFilter).toHaveBeenCalledTimes(0); + expect(setFilter).toHaveBeenCalledTimes(1); + + expect(setFilter).toHaveBeenCalledWith({ + field: 'user_agent.name', + negate: true, + value: 'Chrome', + }); }); }); }); - it('should remove filter on click if already selected', async function () { - const { removeFilter } = mockUseSeriesFilter(); + describe('when selected', () => { + it('removes the filter on click', async () => { + const { removeFilter } = mockUseSeriesFilter(); + + render( + + ); - render( - - ); - await waitFor(() => { fireEvent.click(screen.getByText('Chrome')); - expect(removeFilter).toHaveBeenCalledWith({ - field: 'user_agent.name', - negate: false, - value: 'Chrome', + + await waitFor(() => { + expect(removeFilter).toHaveBeenCalledWith({ + field: 'user_agent.name', + negate: false, + value: 'Chrome', + }); }); }); }); @@ -107,12 +121,13 @@ describe('FilterValueButton', function () { render( ); @@ -134,13 +149,14 @@ describe('FilterValueButton', function () { render( ); @@ -167,13 +183,14 @@ describe('FilterValueButton', function () { render( ); @@ -203,13 +220,14 @@ describe('FilterValueButton', function () { render( ); @@ -229,13 +247,14 @@ describe('FilterValueButton', function () { render( ); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx index bf4ca6eb83d94..11f29c0233ef5 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/filter_value_btn.tsx @@ -5,13 +5,15 @@ * 2.0. */ import { i18n } from '@kbn/i18n'; + import React, { useMemo } from 'react'; import { EuiFilterButton, hexToRgb } from '@elastic/eui'; import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; -import { useSeriesStorage } from '../../hooks/use_series_storage'; import { useSeriesFilters } from '../../hooks/use_series_filters'; import { euiStyled } from '../../../../../../../../../src/plugins/kibana_react/common'; import FieldValueSuggestions from '../../../field_value_suggestions'; +import { SeriesUrl } from '../../types'; +import { NestedFilterOpen } from './filter_expanded'; interface Props { value: string; @@ -19,12 +21,13 @@ interface Props { allSelectedValues?: string[]; negate: boolean; nestedField?: string; - seriesId: string; + seriesId: number; + series: SeriesUrl; isNestedOpen: { value: string; negate: boolean; }; - setIsNestedOpen: (val: { value: string; negate: boolean }) => void; + setIsNestedOpen: (val: NestedFilterOpen) => void; } export function FilterValueButton({ @@ -34,16 +37,13 @@ export function FilterValueButton({ field, negate, seriesId, + series, nestedField, allSelectedValues, }: Props) { - const { getSeries } = useSeriesStorage(); - - const series = getSeries(seriesId); - const { indexPatterns } = useAppIndexPatternContext(series.dataType); - const { setFilter, removeFilter } = useSeriesFilters({ seriesId }); + const { setFilter, removeFilter } = useSeriesFilters({ seriesId, series }); const hasActiveFilters = (allSelectedValues ?? []).includes(value); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/incomplete_badge.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/incomplete_badge.tsx new file mode 100644 index 0000000000000..4e1c385921908 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/incomplete_badge.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { isEmpty } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { EuiBadge } from '@elastic/eui'; +import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; +import { SeriesConfig, SeriesUrl } from '../../types'; + +interface Props { + series: SeriesUrl; + seriesConfig?: SeriesConfig; +} + +export function IncompleteBadge({ seriesConfig, series }: Props) { + const { loading } = useAppIndexPatternContext(); + + if (!seriesConfig) { + return null; + } + const { dataType, reportDefinitions, selectedMetricField } = series; + const { definitionFields, labels } = seriesConfig; + const isIncomplete = + (!dataType || isEmpty(reportDefinitions) || !selectedMetricField) && !loading; + + const incompleteDefinition = isEmpty(reportDefinitions) + ? i18n.translate('xpack.observability.overview.exploratoryView.missingReportDefinition', { + defaultMessage: 'Missing {reportDefinition}', + values: { reportDefinition: labels?.[definitionFields[0]] }, + }) + : ''; + + let incompleteMessage = !selectedMetricField ? MISSING_REPORT_METRIC_LABEL : incompleteDefinition; + + if (!dataType) { + incompleteMessage = MISSING_DATA_TYPE_LABEL; + } + + if (!isIncomplete) { + return null; + } + + return {incompleteMessage}; +} + +const MISSING_REPORT_METRIC_LABEL = i18n.translate( + 'xpack.observability.overview.exploratoryView.missingReportMetric', + { + defaultMessage: 'Missing report metric', + } +); + +const MISSING_DATA_TYPE_LABEL = i18n.translate( + 'xpack.observability.overview.exploratoryView.missingDataType', + { + defaultMessage: 'Missing data type', + } +); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/operation_type_select.test.tsx similarity index 69% rename from x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.test.tsx rename to x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/operation_type_select.test.tsx index 516f04e3812ba..ced4d3af057ff 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/operation_type_select.test.tsx @@ -7,62 +7,66 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; -import { render } from '../../rtl_helpers'; +import { mockUxSeries, render } from '../../rtl_helpers'; import { OperationTypeSelect } from './operation_type_select'; describe('OperationTypeSelect', function () { it('should render properly', function () { - render(); + render(); screen.getByText('Select an option: , is selected'); }); it('should display selected value', function () { const initSeries = { - data: { - 'performance-distribution': { + data: [ + { + name: 'performance-distribution', dataType: 'ux' as const, - reportType: 'kpi-over-time' as const, operationType: 'median' as const, time: { from: 'now-15m', to: 'now' }, }, - }, + ], }; - render(, { initSeries }); + render(, { + initSeries, + }); screen.getByText('Median'); }); it('should call set series on change', function () { const initSeries = { - data: { - 'series-id': { + data: [ + { + name: 'performance-distribution', dataType: 'ux' as const, - reportType: 'kpi-over-time' as const, operationType: 'median' as const, time: { from: 'now-15m', to: 'now' }, }, - }, + ], }; - const { setSeries } = render(, { initSeries }); + const { setSeries } = render(, { + initSeries, + }); fireEvent.click(screen.getByTestId('operationTypeSelect')); - expect(setSeries).toHaveBeenCalledWith('series-id', { + expect(setSeries).toHaveBeenCalledWith(0, { operationType: 'median', dataType: 'ux', - reportType: 'kpi-over-time', time: { from: 'now-15m', to: 'now' }, + name: 'performance-distribution', }); fireEvent.click(screen.getByText('95th Percentile')); - expect(setSeries).toHaveBeenCalledWith('series-id', { + expect(setSeries).toHaveBeenCalledWith(0, { operationType: '95th', dataType: 'ux', - reportType: 'kpi-over-time', time: { from: 'now-15m', to: 'now' }, + name: 'performance-distribution', }); }); }); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/operation_type_select.tsx similarity index 91% rename from x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.tsx rename to x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/operation_type_select.tsx index fce1383f30f34..4c10c9311704d 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/operation_type_select.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/operation_type_select.tsx @@ -11,17 +11,18 @@ import { EuiSuperSelect } from '@elastic/eui'; import { useSeriesStorage } from '../../hooks/use_series_storage'; import { OperationType } from '../../../../../../../lens/public'; +import { SeriesUrl } from '../../types'; export function OperationTypeSelect({ seriesId, + series, defaultOperationType, }: { - seriesId: string; + seriesId: number; + series: SeriesUrl; defaultOperationType?: OperationType; }) { - const { getSeries, setSeries } = useSeriesStorage(); - - const series = getSeries(seriesId); + const { setSeries } = useSeriesStorage(); const operationType = series?.operationType; @@ -83,11 +84,7 @@ export function OperationTypeSelect({ return ( { removeSeries(seriesId); }; + + const isDisabled = seriesId === 0 && allSeries.length > 1; + return ( - + + + ); } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/report_definition_col.test.tsx similarity index 65% rename from x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.test.tsx rename to x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/report_definition_col.test.tsx index 3d156e0ee9c2b..544a294e021e2 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_col.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/report_definition_col.test.tsx @@ -12,14 +12,14 @@ import { mockAppIndexPattern, mockIndexPattern, mockUseValuesList, + mockUxSeries, render, } from '../../rtl_helpers'; import { ReportDefinitionCol } from './report_definition_col'; -import { SERVICE_NAME } from '../../configurations/constants/elasticsearch_fieldnames'; describe('Series Builder ReportDefinitionCol', function () { mockAppIndexPattern(); - const seriesId = 'test-series-id'; + const seriesId = 0; const seriesConfig = getDefaultConfigs({ reportType: 'data-distribution', @@ -27,36 +27,24 @@ describe('Series Builder ReportDefinitionCol', function () { dataType: 'ux', }); - const initSeries = { - data: { - [seriesId]: { - dataType: 'ux' as const, - reportType: 'data-distribution' as const, - time: { from: 'now-30d', to: 'now' }, - reportDefinitions: { [SERVICE_NAME]: ['elastic-co'] }, - }, - }, - }; - mockUseValuesList([{ label: 'elastic-co', count: 10 }]); - it('should render properly', async function () { - render(, { - initSeries, - }); + it('renders', async () => { + render( + + ); await waitFor(() => { - screen.getByText('Web Application'); - screen.getByText('Environment'); - screen.getByText('Select an option: Page load time, is selected'); - screen.getByText('Page load time'); + expect(screen.getByText('Web Application')).toBeInTheDocument(); + expect(screen.getByText('Environment')).toBeInTheDocument(); + expect(screen.getByText('Search Environment')).toBeInTheDocument(); }); }); it('should render selected report definitions', async function () { - render(, { - initSeries, - }); + render( + + ); expect(await screen.findByText('elastic-co')).toBeInTheDocument(); @@ -65,8 +53,7 @@ describe('Series Builder ReportDefinitionCol', function () { it('should be able to remove selected definition', async function () { const { setSeries } = render( - , - { initSeries } + ); expect( @@ -80,11 +67,14 @@ describe('Series Builder ReportDefinitionCol', function () { fireEvent.click(removeBtn); expect(setSeries).toHaveBeenCalledTimes(1); + expect(setSeries).toHaveBeenCalledWith(seriesId, { dataType: 'ux', + name: 'performance-distribution', + breakdown: 'user_agent.name', reportDefinitions: {}, - reportType: 'data-distribution', - time: { from: 'now-30d', to: 'now' }, + selectedMetricField: 'transaction.duration.us', + time: { from: 'now-15m', to: 'now' }, }); }); }); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/report_definition_col.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/report_definition_col.tsx new file mode 100644 index 0000000000000..fbd7c34303d94 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/report_definition_col.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; +import { SeriesConfig, SeriesUrl } from '../../types'; +import { ReportDefinitionField } from './report_definition_field'; + +export function ReportDefinitionCol({ + seriesId, + series, + seriesConfig, +}: { + seriesId: number; + series: SeriesUrl; + seriesConfig: SeriesConfig; +}) { + const { setSeries } = useSeriesStorage(); + + const { reportDefinitions: selectedReportDefinitions = {} } = series; + + const { definitionFields } = seriesConfig; + + const onChange = (field: string, value?: string[]) => { + if (!value?.[0]) { + delete selectedReportDefinitions[field]; + setSeries(seriesId, { + ...series, + reportDefinitions: { ...selectedReportDefinitions }, + }); + } else { + setSeries(seriesId, { + ...series, + reportDefinitions: { ...selectedReportDefinitions, [field]: value }, + }); + } + }; + + return ( + + {definitionFields.map((field) => ( + + + + ))} + + ); +} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_field.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/report_definition_field.tsx similarity index 69% rename from x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_field.tsx rename to x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/report_definition_field.tsx index 8a83b5c2a8cb0..3651b4b7f075b 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_builder/columns/report_definition_field.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/report_definition_field.tsx @@ -6,30 +6,25 @@ */ import React, { useMemo } from 'react'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { isEmpty } from 'lodash'; import { ExistsFilter } from '@kbn/es-query'; import FieldValueSuggestions from '../../../field_value_suggestions'; -import { useSeriesStorage } from '../../hooks/use_series_storage'; import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; import { ESFilter } from '../../../../../../../../../src/core/types/elasticsearch'; import { PersistableFilter } from '../../../../../../../lens/common'; import { buildPhrasesFilter } from '../../configurations/utils'; -import { SeriesConfig } from '../../types'; +import { SeriesConfig, SeriesUrl } from '../../types'; import { ALL_VALUES_SELECTED } from '../../../field_value_suggestions/field_value_combobox'; interface Props { - seriesId: string; + seriesId: number; + series: SeriesUrl; field: string; seriesConfig: SeriesConfig; onChange: (field: string, value?: string[]) => void; } -export function ReportDefinitionField({ seriesId, field, seriesConfig, onChange }: Props) { - const { getSeries } = useSeriesStorage(); - - const series = getSeries(seriesId); - +export function ReportDefinitionField({ series, field, seriesConfig, onChange }: Props) { const { indexPattern } = useAppIndexPatternContext(series.dataType); const { reportDefinitions: selectedReportDefinitions = {} } = series; @@ -64,23 +59,26 @@ export function ReportDefinitionField({ seriesId, field, seriesConfig, onChange // eslint-disable-next-line react-hooks/exhaustive-deps }, [JSON.stringify(selectedReportDefinitions), JSON.stringify(baseFilters)]); + if (!indexPattern) { + return null; + } + return ( - - - {indexPattern && ( - onChange(field, val)} - filters={queryFilters} - time={series.time} - fullWidth={true} - allowAllValuesSelection={true} - /> - )} - - + onChange(field, val)} + filters={queryFilters} + time={series.time} + fullWidth={true} + asCombobox={true} + allowExclusions={false} + allowAllValuesSelection={true} + usePrependLabel={false} + compressed={false} + required={isEmpty(selectedReportDefinitions)} + /> ); } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/report_type_select.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/report_type_select.tsx new file mode 100644 index 0000000000000..31a8c7cb7bfae --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/report_type_select.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiSuperSelect } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; +import { ReportViewType } from '../../types'; +import { + CORE_WEB_VITALS_LABEL, + DEVICE_DISTRIBUTION_LABEL, + KPI_OVER_TIME_LABEL, + PERF_DIST_LABEL, +} from '../../configurations/constants/labels'; + +const SELECT_REPORT_TYPE = 'SELECT_REPORT_TYPE'; + +export const reportTypesList: Array<{ + reportType: ReportViewType | typeof SELECT_REPORT_TYPE; + label: string; +}> = [ + { + reportType: SELECT_REPORT_TYPE, + label: i18n.translate('xpack.observability.expView.reportType.selectLabel', { + defaultMessage: 'Select report type', + }), + }, + { reportType: 'kpi-over-time', label: KPI_OVER_TIME_LABEL }, + { reportType: 'data-distribution', label: PERF_DIST_LABEL }, + { reportType: 'core-web-vitals', label: CORE_WEB_VITALS_LABEL }, + { reportType: 'device-data-distribution', label: DEVICE_DISTRIBUTION_LABEL }, +]; + +export function ReportTypesSelect() { + const { setReportType, reportType: selectedReportType, allSeries } = useSeriesStorage(); + + const onReportTypeChange = (reportType: ReportViewType) => { + setReportType(reportType); + }; + + const options = reportTypesList + .filter(({ reportType }) => (selectedReportType ? reportType !== SELECT_REPORT_TYPE : true)) + .map(({ reportType, label }) => ({ + value: reportType, + inputDisplay: reportType === SELECT_REPORT_TYPE ? label : {label}, + dropdownDisplay: label, + })); + + return ( + onReportTypeChange(value as ReportViewType)} + style={{ minWidth: 200 }} + isInvalid={!selectedReportType && allSeries.length > 0} + disabled={allSeries.length > 0} + /> + ); +} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/selected_filters.test.tsx similarity index 59% rename from x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.test.tsx rename to x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/selected_filters.test.tsx index eb76772a66c7e..64291f84f7662 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/selected_filters.test.tsx @@ -7,10 +7,10 @@ import React from 'react'; import { screen, waitFor } from '@testing-library/react'; -import { mockAppIndexPattern, mockIndexPattern, render } from '../rtl_helpers'; +import { mockAppIndexPattern, mockIndexPattern, mockUxSeries, render } from '../../rtl_helpers'; import { SelectedFilters } from './selected_filters'; -import { getDefaultConfigs } from '../configurations/default_configs'; -import { USER_AGENT_NAME } from '../configurations/constants/elasticsearch_fieldnames'; +import { getDefaultConfigs } from '../../configurations/default_configs'; +import { USER_AGENT_NAME } from '../../configurations/constants/elasticsearch_fieldnames'; describe('SelectedFilters', function () { mockAppIndexPattern(); @@ -22,11 +22,19 @@ describe('SelectedFilters', function () { }); it('should render properly', async function () { - const initSeries = { filters: [{ field: USER_AGENT_NAME, values: ['Chrome'] }] }; + const filters = [{ field: USER_AGENT_NAME, values: ['Chrome'] }]; + const initSeries = { filters }; - render(, { - initSeries, - }); + render( + , + { + initSeries, + } + ); await waitFor(() => { screen.getByText('Chrome'); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/selected_filters.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/selected_filters.tsx new file mode 100644 index 0000000000000..3327ecf1fc9b6 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/selected_filters.tsx @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { Fragment } from 'react'; +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FilterLabel } from '../../components/filter_label'; +import { SeriesConfig, SeriesUrl, UrlFilter } from '../../types'; +import { useAppIndexPatternContext } from '../../hooks/use_app_index_pattern'; +import { useSeriesFilters } from '../../hooks/use_series_filters'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; + +interface Props { + seriesId: number; + series: SeriesUrl; + seriesConfig: SeriesConfig; +} +export function SelectedFilters({ seriesId, series, seriesConfig }: Props) { + const { setSeries } = useSeriesStorage(); + + const { labels } = seriesConfig; + + const filters: UrlFilter[] = series.filters ?? []; + + const { removeFilter } = useSeriesFilters({ seriesId, series }); + + const { indexPattern } = useAppIndexPatternContext(series.dataType); + + if (filters.length === 0 || !indexPattern) { + return null; + } + + return ( + <> + + {filters.map(({ field, values, notValues }) => ( + + {(values ?? []).length > 0 && ( + + { + values?.forEach((val) => { + removeFilter({ field, value: val, negate: false }); + }); + }} + negate={false} + indexPattern={indexPattern} + /> + + )} + {(notValues ?? []).length > 0 && ( + + { + values?.forEach((val) => { + removeFilter({ field, value: val, negate: false }); + }); + }} + indexPattern={indexPattern} + /> + + )} + + ))} + + {(series.filters ?? []).length > 0 && ( + + { + setSeries(seriesId, { ...series, filters: undefined }); + }} + size="xs" + > + {i18n.translate('xpack.observability.expView.seriesEditor.clearFilter', { + defaultMessage: 'Clear filters', + })} + + + )} + + + + ); +} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_actions.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_actions.tsx index 51ebe6c6bd9d5..37b5b1571f84d 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_actions.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_actions.tsx @@ -6,98 +6,113 @@ */ import React from 'react'; -import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { isEmpty } from 'lodash'; import { RemoveSeries } from './remove_series'; import { useSeriesStorage } from '../../hooks/use_series_storage'; -import { SeriesUrl } from '../../types'; +import { SeriesConfig, SeriesUrl } from '../../types'; +import { useDiscoverLink } from '../../hooks/use_discover_link'; interface Props { - seriesId: string; - editorMode?: boolean; + seriesId: number; + series: SeriesUrl; + seriesConfig?: SeriesConfig; + onEditClick?: () => void; } -export function SeriesActions({ seriesId, editorMode = false }: Props) { - const { getSeries, setSeries, allSeriesIds, removeSeries } = useSeriesStorage(); - const series = getSeries(seriesId); - const onEdit = () => { - setSeries(seriesId, { ...series, isNew: true }); - }; +export function SeriesActions({ seriesId, series, seriesConfig, onEditClick }: Props) { + const { setSeries, allSeries } = useSeriesStorage(); + + const { href: discoverHref } = useDiscoverLink({ series, seriesConfig }); const copySeries = () => { - let copySeriesId: string = `${seriesId}-copy`; - if (allSeriesIds.includes(copySeriesId)) { - copySeriesId = copySeriesId + allSeriesIds.length; + let copySeriesId: string = `${series.name}-copy`; + if (allSeries.find(({ name }) => name === copySeriesId)) { + copySeriesId = copySeriesId + allSeries.length; } - setSeries(copySeriesId, series); + setSeries(allSeries.length, { ...series, name: copySeriesId }); }; - const { reportType, reportDefinitions, isNew, ...restSeries } = series; - const isSaveAble = reportType && !isEmpty(reportDefinitions); - - const saveSeries = () => { - if (isSaveAble) { - const reportDefId = Object.values(reportDefinitions ?? {})[0]; - let newSeriesId = `${reportDefId}-${reportType}`; - - if (allSeriesIds.includes(newSeriesId)) { - newSeriesId = `${newSeriesId}-${allSeriesIds.length}`; - } - const newSeriesN: SeriesUrl = { - ...restSeries, - reportType, - reportDefinitions, - }; - - setSeries(newSeriesId, newSeriesN); - removeSeries(seriesId); + const toggleSeries = () => { + if (series.hidden) { + setSeries(seriesId, { ...series, hidden: undefined }); + } else { + setSeries(seriesId, { ...series, hidden: true }); } }; return ( - - {!editorMode && ( - + + + + + + + + - - )} - {editorMode && ( - + + + + + - - )} - {editorMode && ( - + + + + + - - )} + + ); } + +const EDIT_SERIES_LABEL = i18n.translate('xpack.observability.seriesEditor.edit', { + defaultMessage: 'Edit series', +}); + +const HIDE_SERIES_LABEL = i18n.translate('xpack.observability.seriesEditor.hide', { + defaultMessage: 'Hide series', +}); + +const COPY_SERIES_LABEL = i18n.translate('xpack.observability.seriesEditor.clone', { + defaultMessage: 'Copy series', +}); + +const VIEW_SAMPLE_DOCUMENTS_LABEL = i18n.translate( + 'xpack.observability.seriesEditor.sampleDocuments', + { + defaultMessage: 'View sample documents in new tab', + } +); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx index 02144c6929b38..5b576d9da0172 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_filter.tsx @@ -5,29 +5,17 @@ * 2.0. */ -import { i18n } from '@kbn/i18n'; -import React, { useState, Fragment } from 'react'; -import { - EuiButton, - EuiPopover, - EuiSpacer, - EuiButtonEmpty, - EuiFlexItem, - EuiFlexGroup, -} from '@elastic/eui'; +import React from 'react'; +import { EuiFilterGroup, EuiSpacer } from '@elastic/eui'; import { FilterExpanded } from './filter_expanded'; -import { SeriesConfig } from '../../types'; +import { SeriesConfig, SeriesUrl } from '../../types'; import { FieldLabels } from '../../configurations/constants/constants'; -import { SelectedFilters } from '../selected_filters'; -import { useSeriesStorage } from '../../hooks/use_series_storage'; +import { SelectedFilters } from './selected_filters'; interface Props { - seriesId: string; - filterFields: SeriesConfig['filterFields']; - baseFilters: SeriesConfig['baseFilters']; + seriesId: number; seriesConfig: SeriesConfig; - isNew?: boolean; - labels?: Record; + series: SeriesUrl; } export interface Field { @@ -37,119 +25,38 @@ export interface Field { isNegated?: boolean; } -export function SeriesFilter({ - seriesConfig, - isNew, - seriesId, - filterFields = [], - baseFilters, - labels, -}: Props) { - const [isPopoverVisible, setIsPopoverVisible] = useState(false); - - const [selectedField, setSelectedField] = useState(); - - const options: Field[] = filterFields.map((field) => { +export function SeriesFilter({ series, seriesConfig, seriesId }: Props) { + const options: Field[] = seriesConfig.filterFields.map((field) => { if (typeof field === 'string') { - return { label: labels?.[field] ?? FieldLabels[field], field }; + return { label: seriesConfig.labels?.[field] ?? FieldLabels[field], field }; } return { field: field.field, nested: field.nested, isNegated: field.isNegated, - label: labels?.[field.field] ?? FieldLabels[field.field], + label: seriesConfig.labels?.[field.field] ?? FieldLabels[field.field], }; }); - const { setSeries, getSeries } = useSeriesStorage(); - const urlSeries = getSeries(seriesId); - - const button = ( - { - setIsPopoverVisible((prevState) => !prevState); - }} - size="s" - > - {i18n.translate('xpack.observability.expView.seriesEditor.addFilter', { - defaultMessage: 'Add filter', - })} - - ); - - const mainPanel = ( + return ( <> + + {options.map((opt) => ( + + ))} + - {options.map((opt) => ( - - { - setSelectedField(opt); - }} - > - {opt.label} - - - - ))} + ); - - const childPanel = selectedField ? ( - { - setSelectedField(undefined); - }} - filters={baseFilters} - /> - ) : null; - - const closePopover = () => { - setIsPopoverVisible(false); - setSelectedField(undefined); - }; - - return ( - - - - - {!selectedField ? mainPanel : childPanel} - - - {(urlSeries.filters ?? []).length > 0 && ( - - { - setSeries(seriesId, { ...urlSeries, filters: undefined }); - }} - size="s" - > - {i18n.translate('xpack.observability.expView.seriesEditor.clearFilter', { - defaultMessage: 'Clear filters', - })} - - - )} - - ); } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_info.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_info.tsx new file mode 100644 index 0000000000000..4c2e57e780550 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_info.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { SeriesConfig, SeriesUrl } from '../../types'; +import { SeriesColorPicker } from '../../components/series_color_picker'; +import { SeriesChartTypes } from './chart_type_select'; + +interface Props { + seriesId: number; + series: SeriesUrl; + seriesConfig?: SeriesConfig; +} + +export function SeriesInfo({ seriesId, series, seriesConfig }: Props) { + if (!seriesConfig) { + return null; + } + + return ( + + + + + + + + + ); + + return null; +} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_name.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_name.test.tsx new file mode 100644 index 0000000000000..ccad461209313 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_name.test.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { mockUxSeries, render } from '../../rtl_helpers'; +import { SeriesName } from './series_name'; + +describe.skip('SeriesChartTypesSelect', function () { + it('should render properly', async function () { + render(); + + expect(screen.getByText(mockUxSeries.name)).toBeInTheDocument(); + }); + + it('should display input when editing name', async function () { + render(); + + let input = screen.queryByLabelText(mockUxSeries.name); + + // read only + expect(input).not.toBeInTheDocument(); + + const editButton = screen.getByRole('button'); + // toggle editing + fireEvent.click(editButton); + + await waitFor(() => { + input = screen.getByLabelText(mockUxSeries.name); + + expect(input).toBeInTheDocument(); + }); + + // toggle readonly + fireEvent.click(editButton); + + await waitFor(() => { + input = screen.getByLabelText(mockUxSeries.name); + + expect(input).not.toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_name.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_name.tsx new file mode 100644 index 0000000000000..cff30a2b35059 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/series_name.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, ChangeEvent, useEffect, useRef } from 'react'; +import styled from 'styled-components'; +import { i18n } from '@kbn/i18n'; +import { + EuiFieldText, + EuiText, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiOutsideClickDetector, +} from '@elastic/eui'; +import { useSeriesStorage } from '../../hooks/use_series_storage'; +import { SeriesUrl } from '../../types'; + +interface Props { + seriesId: number; + series: SeriesUrl; +} + +export const StyledText = styled(EuiText)` + &.euiText.euiText--constrainedWidth { + max-width: 200px; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } +`; + +export function SeriesName({ series, seriesId }: Props) { + const { setSeries } = useSeriesStorage(); + + const [value, setValue] = useState(series.name); + const [isEditingEnabled, setIsEditingEnabled] = useState(false); + const inputRef = useRef(null); + const buttonRef = useRef(null); + + const onChange = (e: ChangeEvent) => { + setValue(e.target.value); + }; + + const onSave = () => { + if (value !== series.name) { + setSeries(seriesId, { ...series, name: value }); + } + }; + + const onOutsideClick = (event: Event) => { + if (event.target !== buttonRef.current) { + setIsEditingEnabled(false); + } + }; + + useEffect(() => { + setValue(series.name); + }, [series.name]); + + useEffect(() => { + if (isEditingEnabled && inputRef.current) { + inputRef.current.focus(); + } + }, [isEditingEnabled, inputRef]); + + return ( + + {isEditingEnabled ? ( + + + + + + ) : ( + + {value} + + )} + + setIsEditingEnabled(!isEditingEnabled)} + iconType="pencil" + aria-label={i18n.translate('xpack.observability.expView.seriesEditor.editName', { + defaultMessage: 'Edit name', + })} + color="text" + buttonRef={buttonRef} + /> + + + ); +} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/expanded_series_row.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/expanded_series_row.tsx new file mode 100644 index 0000000000000..9f4de1b6dd519 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/expanded_series_row.tsx @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; + +import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiHorizontalRule } from '@elastic/eui'; +import { SeriesConfig, SeriesUrl } from '../types'; +import { ReportDefinitionCol } from './columns/report_definition_col'; +import { OperationTypeSelect } from './columns/operation_type_select'; +import { parseCustomFieldName } from '../configurations/lens_attributes'; +import { SeriesFilter } from './columns/series_filter'; +import { DatePickerCol } from './columns/date_picker_col'; +import { Breakdowns } from './columns/breakdowns'; + +function getColumnType(seriesConfig: SeriesConfig, selectedMetricField?: string) { + const { columnType } = parseCustomFieldName(seriesConfig, selectedMetricField); + + return columnType; +} + +interface Props { + seriesId: number; + series: SeriesUrl; + seriesConfig?: SeriesConfig; +} +export function ExpandedSeriesRow(seriesProps: Props) { + const { seriesConfig, series, seriesId } = seriesProps; + + if (!seriesConfig) { + return null; + } + + const { selectedMetricField } = series ?? {}; + + const { hasOperationType, yAxisColumns } = seriesConfig; + + const columnType = getColumnType(seriesConfig, selectedMetricField); + + return ( +
+ + + + + + + + + + + + + + + + + + + + + {(hasOperationType || columnType === 'operation') && ( + + + + + + )} + +
+ ); +} + +const BREAKDOWNS_LABEL = i18n.translate('xpack.observability.expView.seriesBuilder.breakdowns', { + defaultMessage: 'Breakdowns', +}); + +const FILTERS_LABEL = i18n.translate('xpack.observability.expView.seriesBuilder.selectFilters', { + defaultMessage: 'Filters', +}); + +const OPERATION_LABEL = i18n.translate('xpack.observability.expView.seriesBuilder.operation', { + defaultMessage: 'Operation', +}); + +const DATE_LABEL = i18n.translate('xpack.observability.expView.seriesBuilder.date', { + defaultMessage: 'Date', +}); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/report_metric_options.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/report_metric_options.tsx new file mode 100644 index 0000000000000..496e7a10f9c44 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/report_metric_options.tsx @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { + EuiToolTip, + EuiPopover, + EuiButton, + EuiListGroup, + EuiListGroupItem, + EuiBadge, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useSeriesStorage } from '../hooks/use_series_storage'; +import { SeriesConfig, SeriesUrl } from '../types'; +import { useAppIndexPatternContext } from '../hooks/use_app_index_pattern'; +import { RECORDS_FIELD, RECORDS_PERCENTAGE_FIELD } from '../configurations/constants'; + +interface Props { + seriesId: number; + series: SeriesUrl; + defaultValue?: string; + seriesConfig?: SeriesConfig; +} + +export function ReportMetricOptions({ seriesId, series, seriesConfig }: Props) { + const { setSeries } = useSeriesStorage(); + const [showOptions, setShowOptions] = useState(false); + const metricOptions = seriesConfig?.metricOptions; + + const { indexPatterns } = useAppIndexPatternContext(); + + const onChange = (value?: string) => { + setSeries(seriesId, { + ...series, + selectedMetricField: value, + }); + }; + + if (!series.dataType) { + return null; + } + + const indexPattern = indexPatterns?.[series.dataType]; + + const options = (metricOptions ?? []).map(({ label, field, id }) => { + let disabled = false; + + if (field !== RECORDS_FIELD && field !== RECORDS_PERCENTAGE_FIELD && field) { + disabled = !Boolean(indexPattern?.getFieldByName(field)); + } + return { + disabled, + value: field || id, + dropdownDisplay: disabled ? ( + {field}, + }} + /> + } + > + {label} + + ) : ( + label + ), + inputDisplay: label, + }; + }); + + return ( + <> + {!series.selectedMetricField && ( + setShowOptions((prevState) => !prevState)} + fill + size="s" + > + {SELECT_REPORT_METRIC_LABEL} + + } + isOpen={showOptions} + closePopover={() => setShowOptions((prevState) => !prevState)} + > + + {options.map((option) => ( + onChange(option.value)} + label={option.dropdownDisplay} + isDisabled={option.disabled} + /> + ))} + + + )} + {series.selectedMetricField && ( + onChange(undefined)} + iconOnClickAriaLabel={REMOVE_REPORT_METRIC_LABEL} + > + { + seriesConfig?.metricOptions?.find((option) => option.id === series.selectedMetricField) + ?.label + } + + )} + + ); +} + +const SELECT_REPORT_METRIC_LABEL = i18n.translate( + 'xpack.observability.expView.seriesEditor.selectReportMetric', + { + defaultMessage: 'Select report metric', + } +); + +const REMOVE_REPORT_METRIC_LABEL = i18n.translate( + 'xpack.observability.expView.seriesEditor.removeReportMetric', + { + defaultMessage: 'Remove report metric', + } +); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx deleted file mode 100644 index 5d2ce6ba84951..0000000000000 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/selected_filters.tsx +++ /dev/null @@ -1,101 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { Fragment } from 'react'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { useSeriesStorage } from '../hooks/use_series_storage'; -import { FilterLabel } from '../components/filter_label'; -import { SeriesConfig, UrlFilter } from '../types'; -import { useAppIndexPatternContext } from '../hooks/use_app_index_pattern'; -import { useSeriesFilters } from '../hooks/use_series_filters'; -import { getFiltersFromDefs } from '../hooks/use_lens_attributes'; - -interface Props { - seriesId: string; - seriesConfig: SeriesConfig; - isNew?: boolean; -} -export function SelectedFilters({ seriesId, isNew, seriesConfig }: Props) { - const { getSeries } = useSeriesStorage(); - - const series = getSeries(seriesId); - - const { reportDefinitions = {} } = series; - - const { labels } = seriesConfig; - - const filters: UrlFilter[] = series.filters ?? []; - - let definitionFilters: UrlFilter[] = getFiltersFromDefs(reportDefinitions); - - // we don't want to display report definition filters in new series view - if (isNew) { - definitionFilters = []; - } - - const { removeFilter } = useSeriesFilters({ seriesId }); - - const { indexPattern } = useAppIndexPatternContext(series.dataType); - - return (filters.length > 0 || definitionFilters.length > 0) && indexPattern ? ( - - - {filters.map(({ field, values, notValues }) => ( - - {(values ?? []).map((val) => ( - - removeFilter({ field, value: val, negate: false })} - negate={false} - indexPattern={indexPattern} - /> - - ))} - {(notValues ?? []).map((val) => ( - - removeFilter({ field, value: val, negate: true })} - indexPattern={indexPattern} - /> - - ))} - - ))} - - {definitionFilters.map(({ field, values }) => ( - - {(values ?? []).map((val) => ( - - { - // FIXME handle this use case - }} - negate={false} - definitionFilter={true} - indexPattern={indexPattern} - /> - - ))} - - ))} - - - ) : null; -} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series.tsx new file mode 100644 index 0000000000000..ea47ccd0b0426 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series.tsx @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import styled from 'styled-components'; +import { EuiFlexItem, EuiFlexGroup, EuiPanel, EuiAccordion, EuiSpacer } from '@elastic/eui'; +import { BuilderItem } from '../types'; +import { SeriesActions } from './columns/series_actions'; +import { SeriesInfo } from './columns/series_info'; +import { DataTypesSelect } from './columns/data_type_select'; +import { IncompleteBadge } from './columns/incomplete_badge'; +import { ExpandedSeriesRow } from './expanded_series_row'; +import { SeriesName } from './columns/series_name'; +import { ReportMetricOptions } from './report_metric_options'; + +const StyledAccordion = styled(EuiAccordion)` + .euiAccordion__button { + width: auto; + flex-grow: 0; + } + + .euiAccordion__optionalAction { + flex-grow: 1; + flex-shrink: 1; + } +`; + +interface Props { + item: BuilderItem; + isExpanded: boolean; + toggleExpanded: () => void; +} + +export function Series({ item, isExpanded, toggleExpanded }: Props) { + const { id } = item; + const seriesProps = { + ...item, + seriesId: id, + }; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + } + > + + + + + + + ); +} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx index c3cc8484d1751..d13857b5e9663 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/series_editor.tsx @@ -5,134 +5,226 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiBasicTable, EuiIcon, EuiSpacer, EuiText } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { SeriesFilter } from './columns/series_filter'; -import { SeriesConfig } from '../types'; -import { NEW_SERIES_KEY, useSeriesStorage } from '../hooks/use_series_storage'; +import { + EuiSpacer, + EuiFormRow, + EuiFlexItem, + EuiFlexGroup, + EuiButtonEmpty, + EuiHorizontalRule, +} from '@elastic/eui'; +import { rgba } from 'polished'; +import { euiStyled } from './../../../../../../../../src/plugins/kibana_react/common'; +import { AppDataType, ReportViewType, BuilderItem } from '../types'; +import { SeriesContextValue, useSeriesStorage } from '../hooks/use_series_storage'; +import { IndexPatternState, useAppIndexPatternContext } from '../hooks/use_app_index_pattern'; import { getDefaultConfigs } from '../configurations/default_configs'; -import { DatePickerCol } from './columns/date_picker_col'; -import { useAppIndexPatternContext } from '../hooks/use_app_index_pattern'; -import { SeriesActions } from './columns/series_actions'; -import { ChartEditOptions } from './chart_edit_options'; +import { ReportTypesSelect } from './columns/report_type_select'; +import { ViewActions } from '../views/view_actions'; +import { Series } from './series'; -interface EditItem { - seriesConfig: SeriesConfig; +export interface ReportTypeItem { id: string; + reportType: ReportViewType; + label: string; } -export function SeriesEditor() { - const { allSeries, allSeriesIds } = useSeriesStorage(); - - const columns = [ - { - name: i18n.translate('xpack.observability.expView.seriesEditor.name', { - defaultMessage: 'Name', - }), - field: 'id', - width: '15%', - render: (seriesId: string) => ( - - {' '} - {seriesId === NEW_SERIES_KEY ? 'series-preview' : seriesId} - - ), - }, - { - name: i18n.translate('xpack.observability.expView.seriesEditor.filters', { - defaultMessage: 'Filters', - }), - field: 'defaultFilters', - width: '15%', - render: (seriesId: string, { seriesConfig, id }: EditItem) => ( - - ), - }, - { - name: i18n.translate('xpack.observability.expView.seriesEditor.breakdowns', { - defaultMessage: 'Breakdowns', - }), - field: 'id', - width: '25%', - render: (seriesId: string, { seriesConfig, id }: EditItem) => ( - - ), - }, - { - name: ( -
- -
- ), - width: '20%', - field: 'id', - align: 'right' as const, - render: (seriesId: string, item: EditItem) => , - }, - { - name: i18n.translate('xpack.observability.expView.seriesEditor.actions', { - defaultMessage: 'Actions', - }), - align: 'center' as const, - width: '10%', - field: 'id', - render: (seriesId: string, item: EditItem) => , - }, - ]; - - const { indexPatterns } = useAppIndexPatternContext(); - const items: EditItem[] = []; - - allSeriesIds.forEach((seriesKey) => { - const series = allSeries[seriesKey]; - if (series?.reportType && indexPatterns[series.dataType] && !series.isNew) { - items.push({ - id: seriesKey, - seriesConfig: getDefaultConfigs({ - indexPattern: indexPatterns[series.dataType], - reportType: series.reportType, - dataType: series.dataType, - }), +type ExpandedRowMap = Record; + +export const getSeriesToEdit = ({ + indexPatterns, + allSeries, + reportType, +}: { + allSeries: SeriesContextValue['allSeries']; + indexPatterns: IndexPatternState; + reportType: ReportViewType; +}): BuilderItem[] => { + const getDataViewSeries = (dataType: AppDataType) => { + if (indexPatterns?.[dataType]) { + return getDefaultConfigs({ + dataType, + reportType, + indexPattern: indexPatterns[dataType], }); } + }; + + return allSeries.map((series, seriesIndex) => { + const seriesConfig = getDataViewSeries(series.dataType)!; + + return { id: seriesIndex, series, seriesConfig }; }); +}; - if (items.length === 0 && allSeriesIds.length > 0) { - return null; - } +export const SeriesEditor = React.memo(function () { + const [editorItems, setEditorItems] = useState([]); + + const { getSeries, allSeries, reportType, removeSeries } = useSeriesStorage(); + + const { loading, indexPatterns } = useAppIndexPatternContext(); + + const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState>({}); + + const [{ prevCount, curCount }, setSeriesCount] = useState<{ + prevCount?: number; + curCount: number; + }>({ + curCount: allSeries.length, + }); + + useEffect(() => { + setSeriesCount((oldParams) => ({ prevCount: oldParams.curCount, curCount: allSeries.length })); + if (typeof prevCount !== 'undefined' && !isNaN(prevCount) && prevCount < curCount) { + setItemIdToExpandedRowMap({}); + } + }, [allSeries.length, curCount, prevCount]); + + useEffect(() => { + const newExpandRows: ExpandedRowMap = {}; + + setEditorItems((prevState) => { + const newEditorItems = getSeriesToEdit({ + reportType, + allSeries, + indexPatterns, + }); + + newEditorItems.forEach(({ series, id }) => { + const prevSeriesItem = prevState.find(({ id: prevId }) => prevId === id); + if ( + prevSeriesItem && + series.selectedMetricField && + prevSeriesItem.series.selectedMetricField !== series.selectedMetricField + ) { + newExpandRows[id] = true; + } + }); + return [...newEditorItems]; + }); + + setItemIdToExpandedRowMap((prevState) => { + return { ...prevState, ...newExpandRows }; + }); + }, [allSeries, getSeries, indexPatterns, loading, reportType]); + + const toggleDetails = (item: BuilderItem) => { + const itemIdToExpandedRowMapValues = { ...itemIdToExpandedRowMap }; + if (itemIdToExpandedRowMapValues[item.id]) { + delete itemIdToExpandedRowMapValues[item.id]; + } else { + itemIdToExpandedRowMapValues[item.id] = true; + } + setItemIdToExpandedRowMap(itemIdToExpandedRowMapValues); + }; + + const resetView = () => { + const totalSeries = allSeries.length; + for (let i = totalSeries; i >= 0; i--) { + removeSeries(i); + } + setEditorItems([]); + setItemIdToExpandedRowMap({}); + }; return ( - <> - - - - + +
+ + + + + + + {reportType && ( + + resetView()} color="text"> + {RESET_LABEL} + + + )} + + + + + + + {editorItems.map((item) => ( +
+ toggleDetails(item)} + isExpanded={itemIdToExpandedRowMap[item.id]} + /> + +
+ ))} + +
+
); -} +}); + +const Wrapper = euiStyled.div` + &::-webkit-scrollbar { + height: ${({ theme }) => theme.eui.euiScrollBar}; + width: ${({ theme }) => theme.eui.euiScrollBar}; + } + &::-webkit-scrollbar-thumb { + background-clip: content-box; + background-color: ${({ theme }) => rgba(theme.eui.euiColorDarkShade, 0.5)}; + border: ${({ theme }) => theme.eui.euiScrollBarCorner} solid transparent; + } + &::-webkit-scrollbar-corner, + &::-webkit-scrollbar-track { + background-color: transparent; + } + + &&& { + .euiTableRow-isExpandedRow .euiTableRowCell { + border-top: none; + background-color: #FFFFFF; + border-bottom: 2px solid #d3dae6; + border-right: 2px solid rgb(211, 218, 230); + border-left: 2px solid rgb(211, 218, 230); + } + + .isExpanded { + border-right: 2px solid rgb(211, 218, 230); + border-left: 2px solid rgb(211, 218, 230); + .euiTableRowCell { + border-bottom: none; + } + } + .isIncomplete .euiTableRowCell { + background-color: rgba(254, 197, 20, 0.1); + } + } +`; + +export const LOADING_VIEW = i18n.translate( + 'xpack.observability.expView.seriesBuilder.loadingView', + { + defaultMessage: 'Loading view ...', + } +); + +export const SELECT_REPORT_TYPE = i18n.translate( + 'xpack.observability.expView.seriesBuilder.selectReportType', + { + defaultMessage: 'No report type selected', + } +); + +export const RESET_LABEL = i18n.translate('xpack.observability.expView.seriesBuilder.reset', { + defaultMessage: 'Reset', +}); + +export const REPORT_TYPE_LABEL = i18n.translate( + 'xpack.observability.expView.seriesBuilder.reportType', + { + defaultMessage: 'Report type', + } +); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts index 9817899412ce3..f3592a749a2c0 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/types.ts @@ -6,7 +6,7 @@ */ import { PaletteOutput } from 'src/plugins/charts/public'; -import { ExistsFilter } from '@kbn/es-query'; +import { ExistsFilter, PhraseFilter } from '@kbn/es-query'; import { LastValueIndexPatternColumn, DateHistogramIndexPatternColumn, @@ -42,7 +42,7 @@ export interface MetricOption { field?: string; label: string; description?: string; - columnType?: 'range' | 'operation' | 'FILTER_RECORDS' | 'TERMS_COLUMN'; + columnType?: 'range' | 'operation' | 'FILTER_RECORDS' | 'TERMS_COLUMN' | 'unique_count'; columnFilters?: ColumnFilter[]; timeScale?: string; } @@ -55,7 +55,7 @@ export interface SeriesConfig { defaultSeriesType: SeriesType; filterFields: Array; seriesTypes: SeriesType[]; - baseFilters?: PersistableFilter[] | ExistsFilter[]; + baseFilters?: Array; definitionFields: string[]; metricOptions?: MetricOption[]; labels: Record; @@ -69,6 +69,7 @@ export interface SeriesConfig { export type URLReportDefinition = Record; export interface SeriesUrl { + name: string; time: { to: string; from: string; @@ -76,12 +77,12 @@ export interface SeriesUrl { breakdown?: string; filters?: UrlFilter[]; seriesType?: SeriesType; - reportType: ReportViewType; operationType?: OperationType; dataType: AppDataType; reportDefinitions?: URLReportDefinition; selectedMetricField?: string; - isNew?: boolean; + hidden?: boolean; + color?: string; } export interface UrlFilter { @@ -116,3 +117,9 @@ export interface FieldFormat { params: FieldFormatParams; }; } + +export interface BuilderItem { + id: number; + series: SeriesUrl; + seriesConfig?: SeriesConfig; +} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/views/add_series_button.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/views/add_series_button.test.tsx new file mode 100644 index 0000000000000..978296a295efc --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/views/add_series_button.test.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, waitFor, fireEvent } from '@testing-library/dom'; +import { render } from '../rtl_helpers'; +import { AddSeriesButton } from './add_series_button'; +import { DEFAULT_TIME, ReportTypes } from '../configurations/constants'; +import * as hooks from '../hooks/use_series_storage'; + +const setSeries = jest.fn(); + +describe('AddSeriesButton', () => { + beforeEach(() => { + jest.spyOn(hooks, 'useSeriesStorage').mockReturnValue({ + ...jest.requireActual('../hooks/use_series_storage'), + allSeries: [], + setSeries, + reportType: ReportTypes.KPI, + }); + setSeries.mockClear(); + }); + + it('renders AddSeriesButton', async () => { + render(); + + expect(screen.getByText(/Add series/i)).toBeInTheDocument(); + }); + + it('calls setSeries when AddSeries Button is clicked', async () => { + const { rerender } = render(); + let addSeriesButton = screen.getByText(/Add series/i); + + fireEvent.click(addSeriesButton); + + await waitFor(() => { + expect(setSeries).toBeCalledTimes(1); + expect(setSeries).toBeCalledWith(0, { name: 'new-series-1', time: DEFAULT_TIME }); + }); + + jest.clearAllMocks(); + jest.spyOn(hooks, 'useSeriesStorage').mockReturnValue({ + ...jest.requireActual('../hooks/use_series_storage'), + allSeries: new Array(1), + setSeries, + reportType: ReportTypes.KPI, + }); + + rerender(); + + addSeriesButton = screen.getByText(/Add series/i); + + fireEvent.click(addSeriesButton); + + await waitFor(() => { + expect(setSeries).toBeCalledTimes(1); + expect(setSeries).toBeCalledWith(1, { name: 'new-series-2', time: DEFAULT_TIME }); + }); + }); + + it.each([ReportTypes.DEVICE_DISTRIBUTION, ReportTypes.CORE_WEB_VITAL])( + 'does not allow adding more than 1 series for core web vitals or device distribution', + async (reportType) => { + jest.clearAllMocks(); + jest.spyOn(hooks, 'useSeriesStorage').mockReturnValue({ + ...jest.requireActual('../hooks/use_series_storage'), + allSeries: new Array(1), // mock array of length 1 + setSeries, + reportType, + }); + + render(); + const addSeriesButton = screen.getByText(/Add series/i); + expect(addSeriesButton.closest('button')).toBeDisabled(); + + fireEvent.click(addSeriesButton); + + await waitFor(() => { + expect(setSeries).toBeCalledTimes(0); + }); + } + ); + + it('does not allow adding a series when the report type is undefined', async () => { + jest.clearAllMocks(); + jest.spyOn(hooks, 'useSeriesStorage').mockReturnValue({ + ...jest.requireActual('../hooks/use_series_storage'), + allSeries: [], + setSeries, + }); + + render(); + const addSeriesButton = screen.getByText(/Add series/i); + expect(addSeriesButton.closest('button')).toBeDisabled(); + + fireEvent.click(addSeriesButton); + + await waitFor(() => { + expect(setSeries).toBeCalledTimes(0); + }); + }); +}); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/views/add_series_button.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/views/add_series_button.tsx new file mode 100644 index 0000000000000..71b16c9c0e682 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/views/add_series_button.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, useState } from 'react'; + +import { EuiToolTip, EuiButton } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { SeriesUrl, BuilderItem } from '../types'; +import { getSeriesToEdit } from '../series_editor/series_editor'; +import { NEW_SERIES_KEY, useSeriesStorage } from '../hooks/use_series_storage'; +import { useAppIndexPatternContext } from '../hooks/use_app_index_pattern'; +import { DEFAULT_TIME, ReportTypes } from '../configurations/constants'; + +export function AddSeriesButton() { + const [editorItems, setEditorItems] = useState([]); + const { getSeries, allSeries, setSeries, reportType } = useSeriesStorage(); + + const { loading, indexPatterns } = useAppIndexPatternContext(); + + useEffect(() => { + setEditorItems(getSeriesToEdit({ allSeries, indexPatterns, reportType })); + }, [allSeries, getSeries, indexPatterns, loading, reportType]); + + const addSeries = () => { + const prevSeries = allSeries?.[0]; + const name = `${NEW_SERIES_KEY}-${editorItems.length + 1}`; + const nextSeries = { name } as SeriesUrl; + + const nextSeriesId = allSeries.length; + + if (reportType === 'data-distribution') { + setSeries(nextSeriesId, { + ...nextSeries, + time: prevSeries?.time || DEFAULT_TIME, + } as SeriesUrl); + } else { + setSeries( + nextSeriesId, + prevSeries ? nextSeries : ({ ...nextSeries, time: DEFAULT_TIME } as SeriesUrl) + ); + } + }; + + const isAddDisabled = + !reportType || + ((reportType === ReportTypes.CORE_WEB_VITAL || + reportType === ReportTypes.DEVICE_DISTRIBUTION) && + allSeries.length > 0); + + return ( + + addSeries()} + isDisabled={isAddDisabled} + iconType="plusInCircle" + size="s" + > + {i18n.translate('xpack.observability.expView.seriesBuilder.addSeries', { + defaultMessage: 'Add series', + })} + + + ); +} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/views/series_views.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/views/series_views.tsx new file mode 100644 index 0000000000000..00fbc8c0e522f --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/views/series_views.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { RefObject } from 'react'; + +import { SeriesEditor } from '../series_editor/series_editor'; +import { AddSeriesButton } from './add_series_button'; +import { PanelId } from '../exploratory_view'; + +export function SeriesViews({ + seriesBuilderRef, +}: { + seriesBuilderRef: RefObject; + onSeriesPanelCollapse: (panel: PanelId) => void; +}) { + return ( +
+ + +
+ ); +} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/views/view_actions.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/views/view_actions.tsx new file mode 100644 index 0000000000000..f4416ef60441d --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/views/view_actions.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { isEqual } from 'lodash'; +import { allSeriesKey, convertAllShortSeries, useSeriesStorage } from '../hooks/use_series_storage'; + +export function ViewActions() { + const { allSeries, storage, applyChanges } = useSeriesStorage(); + + const noChanges = isEqual(allSeries, convertAllShortSeries(storage.get(allSeriesKey) ?? [])); + + return ( + + + applyChanges()} isDisabled={noChanges} fill size="s"> + {i18n.translate('xpack.observability.expView.seriesBuilder.apply', { + defaultMessage: 'Apply changes', + })} + + + + ); +} diff --git a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_combobox.tsx b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_combobox.tsx index fc562fa80e26d..0735df53888aa 100644 --- a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_combobox.tsx +++ b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_combobox.tsx @@ -6,15 +6,24 @@ */ import React, { useEffect, useState } from 'react'; -import { union } from 'lodash'; -import { EuiComboBox, EuiFormControlLayout, EuiComboBoxOptionOption } from '@elastic/eui'; +import { union, isEmpty } from 'lodash'; +import { + EuiComboBox, + EuiFormControlLayout, + EuiComboBoxOptionOption, + EuiFormRow, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import styled from 'styled-components'; import { FieldValueSelectionProps } from './types'; export const ALL_VALUES_SELECTED = 'ALL_VALUES'; const formatOptions = (values?: string[], allowAllValuesSelection?: boolean) => { const uniqueValues = Array.from( - new Set(allowAllValuesSelection ? ['ALL_VALUES', ...(values ?? [])] : values) + new Set( + allowAllValuesSelection && (values ?? []).length > 0 + ? ['ALL_VALUES', ...(values ?? [])] + : values + ) ); return (uniqueValues ?? []).map((label) => ({ @@ -30,7 +39,9 @@ export function FieldValueCombobox({ loading, values, setQuery, + usePrependLabel = true, compressed = true, + required = true, allowAllValuesSelection, onChange: onSelectionChange, }: FieldValueSelectionProps) { @@ -54,29 +65,35 @@ export function FieldValueCombobox({ onSelectionChange(selectedValuesN.map(({ label: lbl }) => lbl)); }; - return ( + const comboBox = ( + { + setQuery(searchVal); + }} + options={options} + selectedOptions={options.filter((opt) => selectedValue?.includes(opt.label))} + onChange={onChange} + isInvalid={required && isEmpty(selectedValue)} + /> + ); + + return usePrependLabel ? ( - { - setQuery(searchVal); - }} - options={options} - selectedOptions={options.filter((opt) => selectedValue?.includes(opt.label))} - onChange={onChange} - /> + {comboBox} + ) : ( + + {comboBox} + ); } diff --git a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_selection.tsx b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_selection.tsx index aca29c4723688..dfcd917cf534b 100644 --- a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_selection.tsx +++ b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_selection.tsx @@ -70,8 +70,8 @@ export function FieldValueSelection({ values = [], selectedValue, excludedValue, - compressed = true, allowExclusions = true, + compressed = true, onChange: onSelectionChange, }: FieldValueSelectionProps) { const [options, setOptions] = useState(() => @@ -174,8 +174,8 @@ export function FieldValueSelection({ }} options={options} onChange={onChange} - isLoading={loading && !query && options.length === 0} allowExclusions={allowExclusions} + isLoading={loading && !query && options.length === 0} > {(list, search) => (
diff --git a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/index.test.tsx b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/index.test.tsx index 556a8e7052347..6671c43dd8c7b 100644 --- a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/index.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/index.test.tsx @@ -95,6 +95,7 @@ describe('FieldValueSuggestions', () => { selectedValue={[]} filters={[]} asCombobox={false} + allowExclusions={true} /> ); @@ -119,6 +120,7 @@ describe('FieldValueSuggestions', () => { excludedValue={['Pak']} filters={[]} asCombobox={false} + allowExclusions={true} /> ); diff --git a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/index.tsx b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/index.tsx index 3de158ba0622f..1c5da15dd33df 100644 --- a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/index.tsx +++ b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/index.tsx @@ -28,9 +28,11 @@ export function FieldValueSuggestions({ singleSelection, compressed, asFilterButton, + usePrependLabel, allowAllValuesSelection, + required, + allowExclusions = true, cardinalityField, - allowExclusions, asCombobox = true, onChange: onSelectionChange, }: FieldValueSuggestionsProps) { @@ -67,8 +69,10 @@ export function FieldValueSuggestions({ width={width} compressed={compressed} asFilterButton={asFilterButton} - allowAllValuesSelection={allowAllValuesSelection} + usePrependLabel={usePrependLabel} allowExclusions={allowExclusions} + allowAllValuesSelection={allowAllValuesSelection} + required={required} /> ); } diff --git a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/types.ts b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/types.ts index 046f98748cdf2..b6de2bafdd852 100644 --- a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/types.ts +++ b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/types.ts @@ -23,10 +23,11 @@ interface CommonProps { compressed?: boolean; asFilterButton?: boolean; showCount?: boolean; + usePrependLabel?: boolean; + allowExclusions?: boolean; allowAllValuesSelection?: boolean; cardinalityField?: string; required?: boolean; - allowExclusions?: boolean; } export type FieldValueSuggestionsProps = CommonProps & { diff --git a/x-pack/plugins/observability/public/components/shared/filter_value_label/filter_value_label.tsx b/x-pack/plugins/observability/public/components/shared/filter_value_label/filter_value_label.tsx index 01d727071770d..9e7b96b02206f 100644 --- a/x-pack/plugins/observability/public/components/shared/filter_value_label/filter_value_label.tsx +++ b/x-pack/plugins/observability/public/components/shared/filter_value_label/filter_value_label.tsx @@ -18,21 +18,25 @@ export function buildFilterLabel({ negate, }: { label: string; - value: string; + value: string | string[]; negate: boolean; field: string; indexPattern: IndexPattern; }) { const indexField = indexPattern.getFieldByName(field)!; - const filter = esFilters.buildPhraseFilter(indexField, value, indexPattern); + const filter = + value instanceof Array && value.length > 1 + ? esFilters.buildPhrasesFilter(indexField, value, indexPattern) + : esFilters.buildPhraseFilter(indexField, value as string, indexPattern); - filter.meta.value = value; + filter.meta.type = value instanceof Array && value.length > 1 ? 'phrases' : 'phrase'; + + filter.meta.value = value as string; filter.meta.key = label; filter.meta.alias = null; filter.meta.negate = negate; filter.meta.disabled = false; - filter.meta.type = 'phrase'; return filter; } @@ -40,10 +44,10 @@ export function buildFilterLabel({ interface Props { field: string; label: string; - value: string; + value: string | string[]; negate: boolean; - removeFilter: (field: string, value: string, notVal: boolean) => void; - invertFilter: (val: { field: string; value: string; negate: boolean }) => void; + removeFilter: (field: string, value: string | string[], notVal: boolean) => void; + invertFilter: (val: { field: string; value: string | string[]; negate: boolean }) => void; indexPattern: IndexPattern; allowExclusion?: boolean; } diff --git a/x-pack/plugins/observability/public/components/shared/index.tsx b/x-pack/plugins/observability/public/components/shared/index.tsx index 9d557a40b7987..afc053604fcdf 100644 --- a/x-pack/plugins/observability/public/components/shared/index.tsx +++ b/x-pack/plugins/observability/public/components/shared/index.tsx @@ -6,6 +6,7 @@ */ import React, { lazy, Suspense } from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; import type { CoreVitalProps, HeaderMenuPortalProps } from './types'; import type { FieldValueSuggestionsProps } from './field_value_suggestions/types'; @@ -26,7 +27,7 @@ const HeaderMenuPortalLazy = lazy(() => import('./header_menu_portal')); export function HeaderMenuPortal(props: HeaderMenuPortalProps) { return ( - + }> ); diff --git a/x-pack/plugins/observability/public/hooks/use_quick_time_ranges.tsx b/x-pack/plugins/observability/public/hooks/use_quick_time_ranges.tsx index 82a0fc39b8519..198b4092b0ed6 100644 --- a/x-pack/plugins/observability/public/hooks/use_quick_time_ranges.tsx +++ b/x-pack/plugins/observability/public/hooks/use_quick_time_ranges.tsx @@ -7,7 +7,7 @@ import { useUiSetting } from '../../../../../src/plugins/kibana_react/public'; import { UI_SETTINGS } from '../../../../../src/plugins/data/common'; -import { TimePickerQuickRange } from '../components/shared/exploratory_view/series_date_picker'; +import { TimePickerQuickRange } from '../components/shared/exploratory_view/components/series_date_picker'; export function useQuickTimeRanges() { const timePickerQuickRanges = useUiSetting( diff --git a/x-pack/plugins/observability/public/plugin.ts b/x-pack/plugins/observability/public/plugin.ts index 118f0783f9688..10843bbd1d5b5 100644 --- a/x-pack/plugins/observability/public/plugin.ts +++ b/x-pack/plugins/observability/public/plugin.ts @@ -24,6 +24,7 @@ import type { DataPublicPluginSetup, DataPublicPluginStart, } from '../../../../src/plugins/data/public'; +import type { DiscoverStart } from '../../../../src/plugins/discover/public'; import type { EmbeddableStart } from '../../../../src/plugins/embeddable/public'; import type { HomePublicPluginSetup, @@ -58,6 +59,7 @@ export interface ObservabilityPublicPluginsStart { triggersActionsUi: TriggersAndActionsUIPublicPluginStart; data: DataPublicPluginStart; lens: LensPublicStart; + discover: DiscoverStart; } export type ObservabilityPublicStart = ReturnType; diff --git a/x-pack/plugins/observability/public/routes/index.tsx b/x-pack/plugins/observability/public/routes/index.tsx index 00e487da7f9b7..ff03379e39963 100644 --- a/x-pack/plugins/observability/public/routes/index.tsx +++ b/x-pack/plugins/observability/public/routes/index.tsx @@ -99,7 +99,7 @@ export const routes = { }), }, }, - '/exploratory-view': { + '/exploratory-view/': { handler: () => { return ; }, @@ -112,18 +112,4 @@ export const routes = { }), }, }, - // enable this to test multi series architecture - // '/exploratory-view/multi': { - // handler: () => { - // return ; - // }, - // params: { - // query: t.partial({ - // rangeFrom: t.string, - // rangeTo: t.string, - // refreshPaused: jsonRt.pipe(t.boolean), - // refreshInterval: jsonRt.pipe(t.number), - // }), - // }, - // }, }; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index c2d46fa5762d2..5da17d8a746a0 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -18947,36 +18947,19 @@ "xpack.observability.expView.operationType.95thPercentile": "95パーセンタイル", "xpack.observability.expView.operationType.99thPercentile": "99パーセンタイル", "xpack.observability.expView.operationType.average": "平均", - "xpack.observability.expView.operationType.label": "計算", "xpack.observability.expView.operationType.median": "中央", "xpack.observability.expView.operationType.sum": "合計", - "xpack.observability.expView.reportType.noDataType": "データ型が選択されていません。", "xpack.observability.expView.reportType.selectDataType": "ビジュアライゼーションを作成するデータ型を選択します。", - "xpack.observability.expView.seriesBuilder.actions": "アクション", "xpack.observability.expView.seriesBuilder.addSeries": "数列を追加", "xpack.observability.expView.seriesBuilder.apply": "変更を適用", - "xpack.observability.expView.seriesBuilder.autoApply": "自動適用", - "xpack.observability.expView.seriesBuilder.breakdown": "内訳", - "xpack.observability.expView.seriesBuilder.dataType": "データ型", - "xpack.observability.expView.seriesBuilder.definition": "定義", "xpack.observability.expView.seriesBuilder.emptyReportDefinition": "ビジュアライゼーションを作成するレポート定義を選択します。", "xpack.observability.expView.seriesBuilder.emptyview": "表示する情報がありません。", - "xpack.observability.expView.seriesBuilder.filters": "フィルター", "xpack.observability.expView.seriesBuilder.loadingView": "ビューを読み込んでいます...", - "xpack.observability.expView.seriesBuilder.report": "レポート", - "xpack.observability.expView.seriesBuilder.selectDataType": "データ型が選択されていません", "xpack.observability.expView.seriesBuilder.selectReportType": "レポートタイプが選択されていません", "xpack.observability.expView.seriesBuilder.selectReportType.empty": "レポートタイプを選択すると、ビジュアライゼーションを作成します。", - "xpack.observability.expView.seriesEditor.actions": "アクション", - "xpack.observability.expView.seriesEditor.addFilter": "フィルターを追加します", - "xpack.observability.expView.seriesEditor.breakdowns": "内訳", "xpack.observability.expView.seriesEditor.clearFilter": "フィルターを消去", - "xpack.observability.expView.seriesEditor.filters": "フィルター", - "xpack.observability.expView.seriesEditor.name": "名前", "xpack.observability.expView.seriesEditor.notFound": "系列が見つかりません。系列を追加してください。", "xpack.observability.expView.seriesEditor.removeSeries": "クリックすると、系列を削除します", - "xpack.observability.expView.seriesEditor.seriesNotFound": "系列が見つかりません。系列を追加してください。", - "xpack.observability.expView.seriesEditor.time": "時間", "xpack.observability.featureCatalogueDescription": "専用UIで、ログ、メトリック、アプリケーショントレース、システム可用性を連結します。", "xpack.observability.featureCatalogueTitle": "オブザーバビリティ", "xpack.observability.featureRegistry.linkObservabilityTitle": "ケース", @@ -19038,7 +19021,6 @@ "xpack.observability.overview.ux.title": "ユーザーエクスペリエンス", "xpack.observability.overviewLinkTitle": "概要", "xpack.observability.pageLayout.sideNavTitle": "オブザーバビリティ", - "xpack.observability.reportTypeCol.nodata": "利用可能なデータがありません", "xpack.observability.resources.documentation": "ドキュメント", "xpack.observability.resources.forum": "ディスカッションフォーラム", "xpack.observability.resources.quick_start": "クイックスタートビデオ", @@ -19054,8 +19036,6 @@ "xpack.observability.section.apps.uptime.title": "アップタイム", "xpack.observability.section.errorPanel": "データの取得時にエラーが発生しました。再試行してください", "xpack.observability.seriesEditor.clone": "系列をコピー", - "xpack.observability.seriesEditor.edit": "系列を編集", - "xpack.observability.seriesEditor.save": "系列を保存", "xpack.observability.transactionRateLabel": "{value} tpm", "xpack.observability.ux.coreVitals.average": "平均", "xpack.observability.ux.coreVitals.averageMessage": " {bad}未満", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index e3f53a34449ef..6d5b94f49cb6f 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -19220,36 +19220,19 @@ "xpack.observability.expView.operationType.95thPercentile": "第 95 个百分位", "xpack.observability.expView.operationType.99thPercentile": "第 99 个百分位", "xpack.observability.expView.operationType.average": "平均值", - "xpack.observability.expView.operationType.label": "计算", "xpack.observability.expView.operationType.median": "中值", "xpack.observability.expView.operationType.sum": "求和", - "xpack.observability.expView.reportType.noDataType": "未选择任何数据类型。", "xpack.observability.expView.reportType.selectDataType": "选择数据类型以创建可视化。", - "xpack.observability.expView.seriesBuilder.actions": "操作", "xpack.observability.expView.seriesBuilder.addSeries": "添加序列", "xpack.observability.expView.seriesBuilder.apply": "应用更改", - "xpack.observability.expView.seriesBuilder.autoApply": "自动应用", - "xpack.observability.expView.seriesBuilder.breakdown": "分解", - "xpack.observability.expView.seriesBuilder.dataType": "数据类型", - "xpack.observability.expView.seriesBuilder.definition": "定义", "xpack.observability.expView.seriesBuilder.emptyReportDefinition": "选择报告定义以创建可视化。", "xpack.observability.expView.seriesBuilder.emptyview": "没有可显示的内容。", - "xpack.observability.expView.seriesBuilder.filters": "筛选", "xpack.observability.expView.seriesBuilder.loadingView": "正在加载视图......", - "xpack.observability.expView.seriesBuilder.report": "报告", - "xpack.observability.expView.seriesBuilder.selectDataType": "未选择任何数据类型", "xpack.observability.expView.seriesBuilder.selectReportType": "未选择任何报告类型", "xpack.observability.expView.seriesBuilder.selectReportType.empty": "选择报告类型以创建可视化。", - "xpack.observability.expView.seriesEditor.actions": "操作", - "xpack.observability.expView.seriesEditor.addFilter": "添加筛选", - "xpack.observability.expView.seriesEditor.breakdowns": "分解", "xpack.observability.expView.seriesEditor.clearFilter": "清除筛选", - "xpack.observability.expView.seriesEditor.filters": "筛选", - "xpack.observability.expView.seriesEditor.name": "名称", "xpack.observability.expView.seriesEditor.notFound": "未找到任何序列。请添加序列。", "xpack.observability.expView.seriesEditor.removeSeries": "单击移除序列", - "xpack.observability.expView.seriesEditor.seriesNotFound": "未找到任何序列。请添加序列。", - "xpack.observability.expView.seriesEditor.time": "时间", "xpack.observability.featureCatalogueDescription": "通过专用 UI 整合您的日志、指标、应用程序跟踪和系统可用性。", "xpack.observability.featureCatalogueTitle": "可观测性", "xpack.observability.featureRegistry.linkObservabilityTitle": "案例", @@ -19311,7 +19294,6 @@ "xpack.observability.overview.ux.title": "用户体验", "xpack.observability.overviewLinkTitle": "概览", "xpack.observability.pageLayout.sideNavTitle": "可观测性", - "xpack.observability.reportTypeCol.nodata": "没有可用数据", "xpack.observability.resources.documentation": "文档", "xpack.observability.resources.forum": "讨论论坛", "xpack.observability.resources.quick_start": "快速入门视频", @@ -19327,8 +19309,6 @@ "xpack.observability.section.apps.uptime.title": "运行时间", "xpack.observability.section.errorPanel": "尝试提取数据时发生错误。请重试", "xpack.observability.seriesEditor.clone": "复制序列", - "xpack.observability.seriesEditor.edit": "编辑序列", - "xpack.observability.seriesEditor.save": "保存序列", "xpack.observability.transactionRateLabel": "{value} tpm", "xpack.observability.ux.coreVitals.average": "平均值", "xpack.observability.ux.coreVitals.averageMessage": " 且小于 {bad}", diff --git a/x-pack/plugins/uptime/public/components/common/charts/ping_histogram.tsx b/x-pack/plugins/uptime/public/components/common/charts/ping_histogram.tsx index 1a53a2c9b64a0..aa981071b7ee2 100644 --- a/x-pack/plugins/uptime/public/components/common/charts/ping_histogram.tsx +++ b/x-pack/plugins/uptime/public/components/common/charts/ping_histogram.tsx @@ -22,6 +22,7 @@ import React, { useContext } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import numeral from '@elastic/numeral'; import moment from 'moment'; +import { useSelector } from 'react-redux'; import { getChartDateLabel } from '../../../lib/helper'; import { ChartWrapper } from './chart_wrapper'; import { UptimeThemeContext } from '../../../contexts'; @@ -32,6 +33,7 @@ import { getDateRangeFromChartElement } from './utils'; import { STATUS_DOWN_LABEL, STATUS_UP_LABEL } from '../translations'; import { createExploratoryViewUrl } from '../../../../../observability/public'; import { useUptimeSettingsContext } from '../../../contexts/uptime_settings_context'; +import { monitorStatusSelector } from '../../../state/selectors'; export interface PingHistogramComponentProps { /** @@ -73,6 +75,8 @@ export const PingHistogramComponent: React.FC = ({ const monitorId = useMonitorId(); + const selectedMonitor = useSelector(monitorStatusSelector); + const { basePath } = useUptimeSettingsContext(); const [getUrlParams, updateUrlParams] = useUrlParams(); @@ -189,12 +193,21 @@ export const PingHistogramComponent: React.FC = ({ const pingHistogramExploratoryViewLink = createExploratoryViewUrl( { - 'pings-over-time': { - dataType: 'synthetics', - reportType: 'kpi-over-time', - time: { from: dateRangeStart, to: dateRangeEnd }, - ...(monitorId ? { filters: [{ field: 'monitor.id', values: [monitorId] }] } : {}), - }, + reportType: 'kpi-over-time', + allSeries: [ + { + name: `${monitorId}-pings`, + dataType: 'synthetics', + selectedMetricField: 'summary.up', + time: { from: dateRangeStart, to: dateRangeEnd }, + reportDefinitions: { + 'monitor.name': + monitorId && selectedMonitor?.monitor?.name + ? [selectedMonitor.monitor.name] + : ['ALL_VALUES'], + }, + }, + ], }, basePath ); diff --git a/x-pack/plugins/uptime/public/components/common/header/action_menu_content.tsx b/x-pack/plugins/uptime/public/components/common/header/action_menu_content.tsx index ef5e10394739a..c459fe46da975 100644 --- a/x-pack/plugins/uptime/public/components/common/header/action_menu_content.tsx +++ b/x-pack/plugins/uptime/public/components/common/header/action_menu_content.tsx @@ -10,13 +10,15 @@ import { EuiHeaderLinks, EuiToolTip, EuiHeaderLink } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { useHistory } from 'react-router-dom'; -import { createExploratoryViewUrl, SeriesUrl } from '../../../../../observability/public'; +import { useSelector } from 'react-redux'; +import { createExploratoryViewUrl } from '../../../../../observability/public'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { useUptimeSettingsContext } from '../../../contexts/uptime_settings_context'; import { useGetUrlParams } from '../../../hooks'; import { ToggleAlertFlyoutButton } from '../../overview/alerts/alerts_containers'; import { SETTINGS_ROUTE } from '../../../../common/constants'; import { stringifyUrlParams } from '../../../lib/helper/stringify_url_params'; +import { monitorStatusSelector } from '../../../state/selectors'; const ADD_DATA_LABEL = i18n.translate('xpack.uptime.addDataButtonLabel', { defaultMessage: 'Add data', @@ -38,13 +40,28 @@ export function ActionMenuContent(): React.ReactElement { const { dateRangeStart, dateRangeEnd } = params; const history = useHistory(); + const selectedMonitor = useSelector(monitorStatusSelector); + + const monitorId = selectedMonitor?.monitor?.id; + const syntheticExploratoryViewLink = createExploratoryViewUrl( { - 'synthetics-series': { - dataType: 'synthetics', - isNew: true, - time: { from: dateRangeStart, to: dateRangeEnd }, - } as unknown as SeriesUrl, + reportType: 'kpi-over-time', + allSeries: [ + { + dataType: 'synthetics', + seriesType: 'area_stacked', + selectedMetricField: 'monitor.duration.us', + time: { from: dateRangeStart, to: dateRangeEnd }, + breakdown: monitorId ? 'observer.geo.name' : 'monitor.type', + reportDefinitions: { + 'monitor.name': selectedMonitor?.monitor?.name + ? [selectedMonitor?.monitor?.name] + : ['ALL_VALUES'], + }, + name: monitorId ? `${monitorId}-response-duration` : 'All monitors response duration', + }, + ], }, basePath ); diff --git a/x-pack/plugins/uptime/public/components/monitor/monitor_duration/monitor_duration_container.tsx b/x-pack/plugins/uptime/public/components/monitor/monitor_duration/monitor_duration_container.tsx index cbfba4ffcb239..35eab80c15967 100644 --- a/x-pack/plugins/uptime/public/components/monitor/monitor_duration/monitor_duration_container.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/monitor_duration/monitor_duration_container.tsx @@ -51,16 +51,19 @@ export const MonitorDuration: React.FC = ({ monitorId }) => { const exploratoryViewLink = createExploratoryViewUrl( { - [`monitor-duration`]: { - reportType: 'kpi-over-time', - time: { from: dateRangeStart, to: dateRangeEnd }, - reportDefinitions: { - 'monitor.id': [monitorId] as string[], + reportType: 'kpi-over-time', + allSeries: [ + { + name: `${monitorId}-response-duration`, + time: { from: dateRangeStart, to: dateRangeEnd }, + reportDefinitions: { + 'monitor.id': [monitorId] as string[], + }, + breakdown: 'observer.geo.name', + operationType: 'average', + dataType: 'synthetics', }, - breakdown: 'observer.geo.name', - operationType: 'average', - dataType: 'synthetics', - }, + ], }, basePath ); diff --git a/x-pack/test/observability_functional/apps/observability/exploratory_view.ts b/x-pack/test/observability_functional/apps/observability/exploratory_view.ts new file mode 100644 index 0000000000000..8f27f20ce30e6 --- /dev/null +++ b/x-pack/test/observability_functional/apps/observability/exploratory_view.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import Path from 'path'; +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects(['observability', 'common', 'header']); + const esArchiver = getService('esArchiver'); + const find = getService('find'); + + const testSubjects = getService('testSubjects'); + + const rangeFrom = '2021-01-17T16%3A46%3A15.338Z'; + const rangeTo = '2021-01-19T17%3A01%3A32.309Z'; + + // Failing: See https://github.com/elastic/kibana/issues/106934 + describe.skip('ExploratoryView', () => { + before(async () => { + await esArchiver.loadIfNeeded( + Path.join('x-pack/test/apm_api_integration/common/fixtures/es_archiver', '8.0.0') + ); + + await esArchiver.loadIfNeeded( + Path.join('x-pack/test/apm_api_integration/common/fixtures/es_archiver', 'rum_8.0.0') + ); + + await esArchiver.loadIfNeeded( + Path.join('x-pack/test/apm_api_integration/common/fixtures/es_archiver', 'rum_test_data') + ); + + await PageObjects.common.navigateToApp('ux', { + search: `?rangeFrom=${rangeFrom}&rangeTo=${rangeTo}`, + }); + await PageObjects.header.waitUntilLoadingHasFinished(); + }); + + after(async () => { + await esArchiver.unload( + Path.join('x-pack/test/apm_api_integration/common/fixtures/es_archiver', '8.0.0') + ); + + await esArchiver.unload( + Path.join('x-pack/test/apm_api_integration/common/fixtures/es_archiver', 'rum_8.0.0') + ); + }); + + it('should able to open exploratory view from ux app', async () => { + await testSubjects.exists('uxAnalyzeBtn'); + await testSubjects.click('uxAnalyzeBtn'); + expect(await find.existsByCssSelector('.euiBasicTable')).to.eql(true); + }); + + it('renders lens visualization', async () => { + expect(await testSubjects.exists('lnsVisualizationContainer')).to.eql(true); + + expect( + await find.existsByCssSelector('div[data-title="Prefilled from exploratory view app"]') + ).to.eql(true); + + expect((await find.byCssSelector('dd')).getVisibleText()).to.eql(true); + }); + + it('can do a breakdown per series', async () => { + await testSubjects.click('seriesBreakdown'); + + expect(await find.existsByCssSelector('[id="user_agent.name"]')).to.eql(true); + + await find.clickByCssSelector('[id="user_agent.name"]'); + + await PageObjects.header.waitUntilLoadingHasFinished(); + + expect(await find.existsByCssSelector('[title="Chrome Mobile iOS"]')).to.eql(true); + expect(await find.existsByCssSelector('[title="Mobile Safari"]')).to.eql(true); + }); + }); +} diff --git a/x-pack/test/observability_functional/apps/observability/index.ts b/x-pack/test/observability_functional/apps/observability/index.ts index 019fb0994715e..b163d4d6bb8d5 100644 --- a/x-pack/test/observability_functional/apps/observability/index.ts +++ b/x-pack/test/observability_functional/apps/observability/index.ts @@ -8,9 +8,10 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { - describe('Observability specs', function () { + describe('ObservabilityApp', function () { this.tags('ciGroup6'); loadTestFile(require.resolve('./feature_controls')); + loadTestFile(require.resolve('./exploratory_view')); loadTestFile(require.resolve('./alerts')); loadTestFile(require.resolve('./alerts/workflow_status')); loadTestFile(require.resolve('./alerts/pagination')); From 8c89daedba4a9ca4b361bc72f9d00a6094d3c4bf Mon Sep 17 00:00:00 2001 From: ymao1 Date: Mon, 4 Oct 2021 10:06:07 -0400 Subject: [PATCH 05/14] Adding range filter to ownerId aggregation (#113557) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../monitoring/workload_statistics.test.ts | 27 +++++++++++++++---- .../server/monitoring/workload_statistics.ts | 21 ++++++++++++--- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/task_manager/server/monitoring/workload_statistics.test.ts b/x-pack/plugins/task_manager/server/monitoring/workload_statistics.test.ts index 9c697be985155..9628e2807627a 100644 --- a/x-pack/plugins/task_manager/server/monitoring/workload_statistics.test.ts +++ b/x-pack/plugins/task_manager/server/monitoring/workload_statistics.test.ts @@ -65,7 +65,9 @@ describe('Workload Statistics Aggregator', () => { doc_count: 13, }, ownerIds: { - value: 1, + ownerIds: { + value: 1, + }, }, // The `FiltersAggregate` doesn't cover the case of a nested `AggregationsAggregationContainer`, in which `FiltersAggregate` // would not have a `buckets` property, but rather a keyed property that's inferred from the request. @@ -127,8 +129,19 @@ describe('Workload Statistics Aggregator', () => { missing: { field: 'task.schedule' }, }, ownerIds: { - cardinality: { - field: 'task.ownerId', + filter: { + range: { + 'task.startedAt': { + gte: 'now-1w/w', + }, + }, + }, + aggs: { + ownerIds: { + cardinality: { + field: 'task.ownerId', + }, + }, }, }, idleTasks: { @@ -264,7 +277,9 @@ describe('Workload Statistics Aggregator', () => { doc_count: 13, }, ownerIds: { - value: 1, + ownerIds: { + value: 1, + }, }, // The `FiltersAggregate` doesn't cover the case of a nested `AggregationsAggregationContainer`, in which `FiltersAggregate` // would not have a `buckets` property, but rather a keyed property that's inferred from the request. @@ -605,7 +620,9 @@ describe('Workload Statistics Aggregator', () => { doc_count: 13, }, ownerIds: { - value: 3, + ownerIds: { + value: 3, + }, }, // The `FiltersAggregate` doesn't cover the case of a nested `AggregationContainer`, in which `FiltersAggregate` // would not have a `buckets` property, but rather a keyed property that's inferred from the request. diff --git a/x-pack/plugins/task_manager/server/monitoring/workload_statistics.ts b/x-pack/plugins/task_manager/server/monitoring/workload_statistics.ts index b833e4ed57530..9ac528cfd1ced 100644 --- a/x-pack/plugins/task_manager/server/monitoring/workload_statistics.ts +++ b/x-pack/plugins/task_manager/server/monitoring/workload_statistics.ts @@ -147,8 +147,19 @@ export function createWorkloadAggregator( missing: { field: 'task.schedule' }, }, ownerIds: { - cardinality: { - field: 'task.ownerId', + filter: { + range: { + 'task.startedAt': { + gte: 'now-1w/w', + }, + }, + }, + aggs: { + ownerIds: { + cardinality: { + field: 'task.ownerId', + }, + }, }, }, idleTasks: { @@ -213,7 +224,7 @@ export function createWorkloadAggregator( const taskTypes = aggregations.taskType.buckets; const nonRecurring = aggregations.nonRecurringTasks.doc_count; - const ownerIds = aggregations.ownerIds.value; + const ownerIds = aggregations.ownerIds.ownerIds.value; const { overdue: { @@ -448,7 +459,9 @@ export interface WorkloadAggregationResponse { doc_count: number; }; ownerIds: { - value: number; + ownerIds: { + value: number; + }; }; [otherAggs: string]: estypes.AggregationsAggregate; } From 69bee186c27aca043ab7393d91e4ad608ae6b35c Mon Sep 17 00:00:00 2001 From: "Joey F. Poon" Date: Mon, 4 Oct 2021 09:48:01 -0500 Subject: [PATCH 06/14] [Security Solution] create task for auto restarting failed OLM transforms (#113686) --- .../security_solution/common/constants.ts | 21 +- .../common/endpoint/constants.ts | 7 +- .../management/pages/endpoint_hosts/mocks.ts | 8 +- .../pages/endpoint_hosts/store/middleware.ts | 6 +- .../store/mock_endpoint_result_list.ts | 4 +- .../management/pages/endpoint_hosts/types.ts | 20 +- .../pages/endpoint_hosts/view/index.test.tsx | 9 +- .../pages/endpoint_hosts/view/index.tsx | 2 +- .../check_metadata_transforms_task.test.ts | 250 ++++++++++++++++++ .../check_metadata_transforms_task.ts | 214 +++++++++++++++ .../server/endpoint/lib/metadata/index.ts | 8 + .../security_solution/server/plugin.ts | 12 + 12 files changed, 526 insertions(+), 35 deletions(-) create mode 100644 x-pack/plugins/security_solution/server/endpoint/lib/metadata/check_metadata_transforms_task.test.ts create mode 100644 x-pack/plugins/security_solution/server/endpoint/lib/metadata/check_metadata_transforms_task.ts create mode 100644 x-pack/plugins/security_solution/server/endpoint/lib/metadata/index.ts diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 4316b1c033ec6..e91f74320c026 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -7,7 +7,7 @@ import type { TransformConfigSchema } from './transforms/types'; import { ENABLE_CASE_CONNECTOR } from '../../cases/common'; -import { metadataTransformPattern } from './endpoint/constants'; +import { METADATA_TRANSFORMS_PATTERN } from './endpoint/constants'; export const APP_ID = 'securitySolution'; export const CASES_FEATURE_ID = 'securitySolutionCases'; @@ -331,6 +331,23 @@ export const showAllOthersBucket: string[] = [ */ export const ELASTIC_NAME = 'estc'; -export const TRANSFORM_STATS_URL = `/api/transform/transforms/${metadataTransformPattern}-*/_stats`; +export const METADATA_TRANSFORM_STATS_URL = `/api/transform/transforms/${METADATA_TRANSFORMS_PATTERN}/_stats`; export const RISKY_HOSTS_INDEX = 'ml_host_risk_score_latest'; + +export const TRANSFORM_STATES = { + ABORTING: 'aborting', + FAILED: 'failed', + INDEXING: 'indexing', + STARTED: 'started', + STOPPED: 'stopped', + STOPPING: 'stopping', + WAITING: 'waiting', +}; + +export const WARNING_TRANSFORM_STATES = new Set([ + TRANSFORM_STATES.ABORTING, + TRANSFORM_STATES.FAILED, + TRANSFORM_STATES.STOPPED, + TRANSFORM_STATES.STOPPING, +]); diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index a38266c414e6b..c7949299c68db 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -20,10 +20,13 @@ export const metadataCurrentIndexPattern = 'metrics-endpoint.metadata_current_*' /** The metadata Transform Name prefix with NO (package) version) */ export const metadataTransformPrefix = 'endpoint.metadata_current-default'; -/** The metadata Transform Name prefix with NO namespace and NO (package) version) */ -export const metadataTransformPattern = 'endpoint.metadata_current-*'; +// metadata transforms pattern for matching all metadata transform ids +export const METADATA_TRANSFORMS_PATTERN = 'endpoint.metadata_*'; +// united metadata transform id export const METADATA_UNITED_TRANSFORM = 'endpoint.metadata_united-default'; + +// united metadata transform destination index export const METADATA_UNITED_INDEX = '.metrics-endpoint.metadata_united_default'; export const policyIndexPattern = 'metrics-endpoint.policy-*'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts index cf3f53b5b2ea9..010fe48f29418 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/mocks.ts @@ -37,8 +37,8 @@ import { PendingActionsHttpMockInterface, pendingActionsHttpMock, } from '../../../common/lib/endpoint_pending_actions/mocks'; -import { TRANSFORM_STATS_URL } from '../../../../common/constants'; -import { TransformStatsResponse, TRANSFORM_STATE } from './types'; +import { METADATA_TRANSFORM_STATS_URL, TRANSFORM_STATES } from '../../../../common/constants'; +import { TransformStatsResponse } from './types'; type EndpointMetadataHttpMocksInterface = ResponseProvidersInterface<{ metadataList: () => HostResultList; @@ -238,14 +238,14 @@ export const failedTransformStateMock = { count: 1, transforms: [ { - state: TRANSFORM_STATE.FAILED, + state: TRANSFORM_STATES.FAILED, }, ], }; export const transformsHttpMocks = httpHandlerMockFactory([ { id: 'metadataTransformStats', - path: TRANSFORM_STATS_URL, + path: METADATA_TRANSFORM_STATS_URL, method: 'get', handler: () => failedTransformStateMock, }, diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts index 84cf3513d5d3a..7a45ff06c496b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts @@ -78,7 +78,7 @@ import { resolvePathVariables } from '../../../../common/utils/resolve_path_vari import { EndpointPackageInfoStateChanged } from './action'; import { fetchPendingActionsByAgentId } from '../../../../common/lib/endpoint_pending_actions'; import { getIsInvalidDateRange } from '../utils'; -import { TRANSFORM_STATS_URL } from '../../../../../common/constants'; +import { METADATA_TRANSFORM_STATS_URL } from '../../../../../common/constants'; type EndpointPageStore = ImmutableMiddlewareAPI; @@ -785,7 +785,9 @@ export async function handleLoadMetadataTransformStats(http: HttpStart, store: E }); try { - const transformStatsResponse: TransformStatsResponse = await http.get(TRANSFORM_STATS_URL); + const transformStatsResponse: TransformStatsResponse = await http.get( + METADATA_TRANSFORM_STATS_URL + ); dispatch({ type: 'metadataTransformStatsChanged', diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/mock_endpoint_result_list.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/mock_endpoint_result_list.ts index 8e8e5a61221a9..2e3de427e6960 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/mock_endpoint_result_list.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/mock_endpoint_result_list.ts @@ -30,7 +30,7 @@ import { import { GetPolicyListResponse } from '../../policy/types'; import { pendingActionsResponseMock } from '../../../../common/lib/endpoint_pending_actions/mocks'; import { ACTION_STATUS_ROUTE } from '../../../../../common/endpoint/constants'; -import { TRANSFORM_STATS_URL } from '../../../../../common/constants'; +import { METADATA_TRANSFORM_STATS_URL } from '../../../../../common/constants'; import { TransformStats, TransformStatsResponse } from '../types'; const generator = new EndpointDocGenerator('seed'); @@ -163,7 +163,7 @@ const endpointListApiPathHandlerMocks = ({ return pendingActionsResponseMock(); }, - [TRANSFORM_STATS_URL]: (): TransformStatsResponse => ({ + [METADATA_TRANSFORM_STATS_URL]: (): TransformStatsResponse => ({ count: transforms.length, transforms, }), diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts index dd0bc79f1ba52..0fa96fe00fd2c 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts @@ -22,6 +22,7 @@ import { ServerApiError } from '../../../common/types'; import { GetPackagesResponse } from '../../../../../fleet/common'; import { IIndexPattern } from '../../../../../../../src/plugins/data/public'; import { AsyncResourceState } from '../../state'; +import { TRANSFORM_STATES } from '../../../../common/constants'; export interface EndpointState { /** list of host **/ @@ -143,24 +144,7 @@ export interface EndpointIndexUIQueryParams { admin_query?: string; } -export const TRANSFORM_STATE = { - ABORTING: 'aborting', - FAILED: 'failed', - INDEXING: 'indexing', - STARTED: 'started', - STOPPED: 'stopped', - STOPPING: 'stopping', - WAITING: 'waiting', -}; - -export const WARNING_TRANSFORM_STATES = new Set([ - TRANSFORM_STATE.ABORTING, - TRANSFORM_STATE.FAILED, - TRANSFORM_STATE.STOPPED, - TRANSFORM_STATE.STOPPING, -]); - -const transformStates = Object.values(TRANSFORM_STATE); +const transformStates = Object.values(TRANSFORM_STATES); export type TransformState = typeof transformStates[number]; export interface TransformStats { 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 33c45e6e2f548..b2c438659b771 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 @@ -46,8 +46,9 @@ import { APP_PATH, MANAGEMENT_PATH, DEFAULT_TIMEPICKER_QUICK_RANGES, + TRANSFORM_STATES, } from '../../../../../common/constants'; -import { TransformStats, TRANSFORM_STATE } from '../types'; +import { TransformStats } from '../types'; import { metadataTransformPrefix } from '../../../../../common/endpoint/constants'; // not sure why this can't be imported from '../../../../common/mock/formatted_relative'; @@ -1403,7 +1404,7 @@ describe('when on the endpoint list page', () => { const transforms: TransformStats[] = [ { id: `${metadataTransformPrefix}-0.20.0`, - state: TRANSFORM_STATE.STARTED, + state: TRANSFORM_STATES.STARTED, } as TransformStats, ]; setEndpointListApiMockImplementation(coreStart.http, { transforms }); @@ -1414,7 +1415,7 @@ describe('when on the endpoint list page', () => { it('is not displayed when non-relevant transform is failing', () => { const transforms: TransformStats[] = [ - { id: 'not-metadata', state: TRANSFORM_STATE.FAILED } as TransformStats, + { id: 'not-metadata', state: TRANSFORM_STATES.FAILED } as TransformStats, ]; setEndpointListApiMockImplementation(coreStart.http, { transforms }); render(); @@ -1426,7 +1427,7 @@ describe('when on the endpoint list page', () => { const transforms: TransformStats[] = [ { id: `${metadataTransformPrefix}-0.20.0`, - state: TRANSFORM_STATE.FAILED, + state: TRANSFORM_STATES.FAILED, } as TransformStats, ]; setEndpointListApiMockImplementation(coreStart.http, { transforms }); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index e71474321c868..7845409353898 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -58,8 +58,8 @@ import { LinkToApp } from '../../../../common/components/endpoint/link_to_app'; import { TableRowActions } from './components/table_row_actions'; import { EndpointAgentStatus } from './components/endpoint_agent_status'; import { CallOut } from '../../../../common/components/callouts'; -import { WARNING_TRANSFORM_STATES } from '../types'; import { metadataTransformPrefix } from '../../../../../common/endpoint/constants'; +import { WARNING_TRANSFORM_STATES } from '../../../../../common/constants'; const MAX_PAGINATED_ITEM = 9999; const TRANSFORM_URL = '/data/transform'; diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/metadata/check_metadata_transforms_task.test.ts b/x-pack/plugins/security_solution/server/endpoint/lib/metadata/check_metadata_transforms_task.test.ts new file mode 100644 index 0000000000000..0510743fdf05b --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/lib/metadata/check_metadata_transforms_task.test.ts @@ -0,0 +1,250 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { ApiResponse } from '@elastic/elasticsearch'; +import { TransformGetTransformStatsResponse } from '@elastic/elasticsearch/api/types'; +import { + CheckMetadataTransformsTask, + TYPE, + VERSION, + BASE_NEXT_ATTEMPT_DELAY, +} from './check_metadata_transforms_task'; +import { createMockEndpointAppContext } from '../../mocks'; +import { coreMock } from '../../../../../../../src/core/server/mocks'; +import { taskManagerMock } from '../../../../../task_manager/server/mocks'; +import { TaskManagerSetupContract, TaskStatus } from '../../../../../task_manager/server'; +import { CoreSetup } from '../../../../../../../src/core/server'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ElasticsearchClientMock } from '../../../../../../../src/core/server/elasticsearch/client/mocks'; +import { TRANSFORM_STATES } from '../../../../common/constants'; +import { METADATA_TRANSFORMS_PATTERN } from '../../../../common/endpoint/constants'; +import { RunResult } from '../../../../../task_manager/server/task'; + +const MOCK_TASK_INSTANCE = { + id: `${TYPE}:${VERSION}`, + runAt: new Date(), + attempts: 0, + ownerId: '', + status: TaskStatus.Running, + startedAt: new Date(), + scheduledAt: new Date(), + retryAt: new Date(), + params: {}, + state: {}, + taskType: TYPE, +}; +const failedTransformId = 'failing-transform'; +const goodTransformId = 'good-transform'; + +describe('check metadata transforms task', () => { + const { createSetup: coreSetupMock } = coreMock; + const { createSetup: tmSetupMock, createStart: tmStartMock } = taskManagerMock; + + let mockTask: CheckMetadataTransformsTask; + let mockCore: CoreSetup; + let mockTaskManagerSetup: jest.Mocked; + beforeAll(() => { + mockCore = coreSetupMock(); + mockTaskManagerSetup = tmSetupMock(); + mockTask = new CheckMetadataTransformsTask({ + endpointAppContext: createMockEndpointAppContext(), + core: mockCore, + taskManager: mockTaskManagerSetup, + }); + }); + + describe('task lifecycle', () => { + it('should create task', () => { + expect(mockTask).toBeInstanceOf(CheckMetadataTransformsTask); + }); + + it('should register task', () => { + expect(mockTaskManagerSetup.registerTaskDefinitions).toHaveBeenCalled(); + }); + + it('should schedule task', async () => { + const mockTaskManagerStart = tmStartMock(); + await mockTask.start({ taskManager: mockTaskManagerStart }); + expect(mockTaskManagerStart.ensureScheduled).toHaveBeenCalled(); + }); + }); + + describe('task logic', () => { + let esClient: ElasticsearchClientMock; + beforeEach(async () => { + const [{ elasticsearch }] = await mockCore.getStartServices(); + esClient = elasticsearch.client.asInternalUser as ElasticsearchClientMock; + }); + + const runTask = async (taskInstance = MOCK_TASK_INSTANCE) => { + const mockTaskManagerStart = tmStartMock(); + await mockTask.start({ taskManager: mockTaskManagerStart }); + const createTaskRunner = + mockTaskManagerSetup.registerTaskDefinitions.mock.calls[0][0][TYPE].createTaskRunner; + const taskRunner = createTaskRunner({ taskInstance }); + return taskRunner.run(); + }; + + const buildFailedStatsResponse = () => + ({ + body: { + transforms: [ + { + id: goodTransformId, + state: TRANSFORM_STATES.STARTED, + }, + { + id: failedTransformId, + state: TRANSFORM_STATES.FAILED, + }, + ], + }, + } as unknown as ApiResponse); + + it('should stop task if transform stats response fails', async () => { + esClient.transform.getTransformStats.mockRejectedValue({}); + await runTask(); + expect(esClient.transform.getTransformStats).toHaveBeenCalledWith({ + transform_id: METADATA_TRANSFORMS_PATTERN, + }); + expect(esClient.transform.stopTransform).not.toHaveBeenCalled(); + expect(esClient.transform.startTransform).not.toHaveBeenCalled(); + }); + + it('should attempt transform restart if failing state', async () => { + const transformStatsResponseMock = buildFailedStatsResponse(); + esClient.transform.getTransformStats.mockResolvedValue(transformStatsResponseMock); + + const taskResponse = (await runTask()) as RunResult; + + expect(esClient.transform.getTransformStats).toHaveBeenCalledWith({ + transform_id: METADATA_TRANSFORMS_PATTERN, + }); + expect(esClient.transform.stopTransform).toHaveBeenCalledWith({ + transform_id: failedTransformId, + allow_no_match: true, + wait_for_completion: true, + force: true, + }); + expect(esClient.transform.startTransform).toHaveBeenCalledWith({ + transform_id: failedTransformId, + }); + expect(taskResponse?.state?.attempts).toEqual({ + [goodTransformId]: 0, + [failedTransformId]: 0, + }); + }); + + it('should correctly track transform restart attempts', async () => { + const transformStatsResponseMock = buildFailedStatsResponse(); + esClient.transform.getTransformStats.mockResolvedValue(transformStatsResponseMock); + + esClient.transform.stopTransform.mockRejectedValueOnce({}); + let taskResponse = (await runTask()) as RunResult; + expect(taskResponse?.state?.attempts).toEqual({ + [goodTransformId]: 0, + [failedTransformId]: 1, + }); + + esClient.transform.startTransform.mockRejectedValueOnce({}); + taskResponse = (await runTask({ + ...MOCK_TASK_INSTANCE, + state: taskResponse.state, + })) as RunResult; + expect(taskResponse?.state?.attempts).toEqual({ + [goodTransformId]: 0, + [failedTransformId]: 2, + }); + + taskResponse = (await runTask({ + ...MOCK_TASK_INSTANCE, + state: taskResponse.state, + })) as RunResult; + expect(taskResponse?.state?.attempts).toEqual({ + [goodTransformId]: 0, + [failedTransformId]: 0, + }); + }); + + it('should correctly back off subsequent restart attempts', async () => { + let transformStatsResponseMock = buildFailedStatsResponse(); + esClient.transform.getTransformStats.mockResolvedValue(transformStatsResponseMock); + + esClient.transform.stopTransform.mockRejectedValueOnce({}); + let taskStartedAt = new Date(); + let taskResponse = (await runTask()) as RunResult; + let delay = BASE_NEXT_ATTEMPT_DELAY * 60000; + let expectedRunAt = taskStartedAt.getTime() + delay; + expect(taskResponse?.runAt?.getTime()).toBeGreaterThanOrEqual(expectedRunAt); + // we don't have the exact timestamp it uses so give a buffer + let expectedRunAtUpperBound = expectedRunAt + 1000; + expect(taskResponse?.runAt?.getTime()).toBeLessThanOrEqual(expectedRunAtUpperBound); + + esClient.transform.startTransform.mockRejectedValueOnce({}); + taskStartedAt = new Date(); + taskResponse = (await runTask({ + ...MOCK_TASK_INSTANCE, + state: taskResponse.state, + })) as RunResult; + // should be exponential on second+ attempt + delay = BASE_NEXT_ATTEMPT_DELAY ** 2 * 60000; + expectedRunAt = taskStartedAt.getTime() + delay; + expect(taskResponse?.runAt?.getTime()).toBeGreaterThanOrEqual(expectedRunAt); + // we don't have the exact timestamp it uses so give a buffer + expectedRunAtUpperBound = expectedRunAt + 1000; + expect(taskResponse?.runAt?.getTime()).toBeLessThanOrEqual(expectedRunAtUpperBound); + + esClient.transform.stopTransform.mockRejectedValueOnce({}); + taskStartedAt = new Date(); + taskResponse = (await runTask({ + ...MOCK_TASK_INSTANCE, + state: taskResponse.state, + })) as RunResult; + // should be exponential on second+ attempt + delay = BASE_NEXT_ATTEMPT_DELAY ** 3 * 60000; + expectedRunAt = taskStartedAt.getTime() + delay; + expect(taskResponse?.runAt?.getTime()).toBeGreaterThanOrEqual(expectedRunAt); + // we don't have the exact timestamp it uses so give a buffer + expectedRunAtUpperBound = expectedRunAt + 1000; + expect(taskResponse?.runAt?.getTime()).toBeLessThanOrEqual(expectedRunAtUpperBound); + + taskStartedAt = new Date(); + taskResponse = (await runTask({ + ...MOCK_TASK_INSTANCE, + state: taskResponse.state, + })) as RunResult; + // back to base delay after success + delay = BASE_NEXT_ATTEMPT_DELAY * 60000; + expectedRunAt = taskStartedAt.getTime() + delay; + expect(taskResponse?.runAt?.getTime()).toBeGreaterThanOrEqual(expectedRunAt); + // we don't have the exact timestamp it uses so give a buffer + expectedRunAtUpperBound = expectedRunAt + 1000; + expect(taskResponse?.runAt?.getTime()).toBeLessThanOrEqual(expectedRunAtUpperBound); + + transformStatsResponseMock = { + body: { + transforms: [ + { + id: goodTransformId, + state: TRANSFORM_STATES.STARTED, + }, + { + id: failedTransformId, + state: TRANSFORM_STATES.STARTED, + }, + ], + }, + } as unknown as ApiResponse; + esClient.transform.getTransformStats.mockResolvedValue(transformStatsResponseMock); + taskResponse = (await runTask({ + ...MOCK_TASK_INSTANCE, + state: taskResponse.state, + })) as RunResult; + // no more explicit runAt after subsequent success + expect(taskResponse?.runAt).toBeUndefined(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/metadata/check_metadata_transforms_task.ts b/x-pack/plugins/security_solution/server/endpoint/lib/metadata/check_metadata_transforms_task.ts new file mode 100644 index 0000000000000..68f149bcc64c4 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/lib/metadata/check_metadata_transforms_task.ts @@ -0,0 +1,214 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { ApiResponse } from '@elastic/elasticsearch'; +import { + TransformGetTransformStatsResponse, + TransformGetTransformStatsTransformStats, +} from '@elastic/elasticsearch/api/types'; +import { CoreSetup, ElasticsearchClient, Logger } from 'src/core/server'; +import { + ConcreteTaskInstance, + TaskManagerSetupContract, + TaskManagerStartContract, + throwUnrecoverableError, +} from '../../../../../task_manager/server'; +import { EndpointAppContext } from '../../types'; +import { METADATA_TRANSFORMS_PATTERN } from '../../../../common/endpoint/constants'; +import { WARNING_TRANSFORM_STATES } from '../../../../common/constants'; +import { wrapErrorIfNeeded } from '../../utils'; + +const SCOPE = ['securitySolution']; +const INTERVAL = '2h'; +const TIMEOUT = '4m'; +export const TYPE = 'endpoint:metadata-check-transforms-task'; +export const VERSION = '0.0.1'; +const MAX_ATTEMPTS = 5; +export const BASE_NEXT_ATTEMPT_DELAY = 5; // minutes + +export interface CheckMetadataTransformsTaskSetupContract { + endpointAppContext: EndpointAppContext; + core: CoreSetup; + taskManager: TaskManagerSetupContract; +} + +export interface CheckMetadataTransformsTaskStartContract { + taskManager: TaskManagerStartContract; +} + +export class CheckMetadataTransformsTask { + private logger: Logger; + private wasStarted: boolean = false; + + constructor(setupContract: CheckMetadataTransformsTaskSetupContract) { + const { endpointAppContext, core, taskManager } = setupContract; + this.logger = endpointAppContext.logFactory.get(this.getTaskId()); + taskManager.registerTaskDefinitions({ + [TYPE]: { + title: 'Security Solution Endpoint Metadata Periodic Tasks', + timeout: TIMEOUT, + createTaskRunner: ({ taskInstance }: { taskInstance: ConcreteTaskInstance }) => { + return { + run: async () => { + return this.runTask(taskInstance, core); + }, + cancel: async () => {}, + }; + }, + }, + }); + } + + public start = async ({ taskManager }: CheckMetadataTransformsTaskStartContract) => { + if (!taskManager) { + this.logger.error('missing required service during start'); + return; + } + + this.wasStarted = true; + + try { + await taskManager.ensureScheduled({ + id: this.getTaskId(), + taskType: TYPE, + scope: SCOPE, + schedule: { + interval: INTERVAL, + }, + state: { + attempts: {}, + }, + params: { version: VERSION }, + }); + } catch (e) { + this.logger.debug(`Error scheduling task, received ${e.message}`); + } + }; + + private runTask = async (taskInstance: ConcreteTaskInstance, core: CoreSetup) => { + // if task was not `.start()`'d yet, then exit + if (!this.wasStarted) { + this.logger.debug('[runTask()] Aborted. MetadataTask not started yet'); + return; + } + + // Check that this task is current + if (taskInstance.id !== this.getTaskId()) { + // old task, die + throwUnrecoverableError(new Error('Outdated task version')); + } + + const [{ elasticsearch }] = await core.getStartServices(); + const esClient = elasticsearch.client.asInternalUser; + + let transformStatsResponse: ApiResponse; + try { + transformStatsResponse = await esClient?.transform.getTransformStats({ + transform_id: METADATA_TRANSFORMS_PATTERN, + }); + } catch (e) { + const err = wrapErrorIfNeeded(e); + const errMessage = `failed to get transform stats with error: ${err}`; + this.logger.error(errMessage); + + return; + } + + const { transforms } = transformStatsResponse.body; + if (!transforms.length) { + this.logger.info('no OLM metadata transforms found'); + return; + } + + let didAttemptRestart: boolean = false; + let highestAttempt: number = 0; + const attempts = { ...taskInstance.state.attempts }; + + for (const transform of transforms) { + const restartedTransform = await this.restartTransform( + esClient, + transform, + attempts[transform.id] + ); + if (restartedTransform.didAttemptRestart) { + didAttemptRestart = true; + } + attempts[transform.id] = restartedTransform.attempts; + highestAttempt = Math.max(attempts[transform.id], highestAttempt); + } + + // after a restart attempt run next check sooner with exponential backoff + let runAt: Date | undefined; + if (didAttemptRestart) { + const delay = BASE_NEXT_ATTEMPT_DELAY ** Math.max(highestAttempt, 1) * 60000; + runAt = new Date(new Date().getTime() + delay); + } + + const nextState = { attempts }; + const nextTask = runAt ? { state: nextState, runAt } : { state: nextState }; + return nextTask; + }; + + private restartTransform = async ( + esClient: ElasticsearchClient, + transform: TransformGetTransformStatsTransformStats, + currentAttempts: number = 0 + ) => { + let attempts = currentAttempts; + let didAttemptRestart = false; + + if (!WARNING_TRANSFORM_STATES.has(transform.state)) { + return { + attempts, + didAttemptRestart, + }; + } + + if (attempts > MAX_ATTEMPTS) { + this.logger.warn( + `transform ${transform.id} has failed to restart ${attempts} times. stopping auto restart attempts.` + ); + return { + attempts, + didAttemptRestart, + }; + } + + try { + this.logger.info(`failed transform detected with id: ${transform.id}. attempting restart.`); + await esClient.transform.stopTransform({ + transform_id: transform.id, + allow_no_match: true, + wait_for_completion: true, + force: true, + }); + await esClient.transform.startTransform({ + transform_id: transform.id, + }); + + // restart succeeded, reset attempt count + attempts = 0; + } catch (e) { + const err = wrapErrorIfNeeded(e); + const errMessage = `failed to restart transform ${transform.id} with error: ${err}`; + this.logger.error(errMessage); + + // restart failed, increment attempt count + attempts = attempts + 1; + } finally { + didAttemptRestart = true; + } + + return { + attempts, + didAttemptRestart, + }; + }; + + private getTaskId = (): string => { + return `${TYPE}:${VERSION}`; + }; +} diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/metadata/index.ts b/x-pack/plugins/security_solution/server/endpoint/lib/metadata/index.ts new file mode 100644 index 0000000000000..6f5d6f5a4121b --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/lib/metadata/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './check_metadata_transforms_task'; diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 391beb3c40121..f69565cacceb5 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -59,6 +59,7 @@ import { initRoutes } from './routes'; import { isAlertExecutor } from './lib/detection_engine/signals/types'; import { signalRulesAlertType } from './lib/detection_engine/signals/signal_rule_alert_type'; import { ManifestTask } from './endpoint/lib/artifacts'; +import { CheckMetadataTransformsTask } from './endpoint/lib/metadata'; import { initSavedObjects } from './saved_objects'; import { AppClientFactory } from './client'; import { createConfig, ConfigType } from './config'; @@ -157,6 +158,7 @@ export class Plugin implements IPlugin; private telemetryUsageCounter?: UsageCounter; @@ -363,6 +365,12 @@ export class Plugin implements IPlugin Date: Mon, 4 Oct 2021 10:52:05 -0400 Subject: [PATCH 07/14] [Fleet] Fix how we get the default output in the Fleet UI (#113620) --- .../fleet_server_on_prem_instructions.tsx | 6 ++---- .../public/components/settings_flyout/index.tsx | 5 ++--- .../fleet/public/hooks/use_request/outputs.ts | 17 +++++++++++++++++ 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/fleet_server_on_prem_instructions.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/fleet_server_on_prem_instructions.tsx index 1d43f90b80def..a8cab77af447c 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/fleet_server_on_prem_instructions.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/fleet_server_on_prem_instructions.tsx @@ -31,7 +31,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { DownloadStep } from '../../../../components'; import { useStartServices, - useGetOutputs, + useDefaultOutput, sendGenerateServiceToken, usePlatform, PLATFORM_OPTIONS, @@ -242,7 +242,7 @@ export const FleetServerCommandStep = ({ }; export const useFleetServerInstructions = (policyId?: string) => { - const outputsRequest = useGetOutputs(); + const { output, refresh: refreshOutputs } = useDefaultOutput(); const { notifications } = useStartServices(); const [serviceToken, setServiceToken] = useState(); const [isLoadingServiceToken, setIsLoadingServiceToken] = useState(false); @@ -250,9 +250,7 @@ export const useFleetServerInstructions = (policyId?: string) => { const [deploymentMode, setDeploymentMode] = useState('production'); const { data: settings, resendRequest: refreshSettings } = useGetSettings(); const fleetServerHost = settings?.item.fleet_server_hosts?.[0]; - const output = outputsRequest.data?.items?.[0]; const esHost = output?.hosts?.[0]; - const refreshOutputs = outputsRequest.resendRequest; const installCommand = useMemo((): string => { if (!serviceToken || !esHost) { diff --git a/x-pack/plugins/fleet/public/components/settings_flyout/index.tsx b/x-pack/plugins/fleet/public/components/settings_flyout/index.tsx index e42733bbd2da0..9bedfca0d3bca 100644 --- a/x-pack/plugins/fleet/public/components/settings_flyout/index.tsx +++ b/x-pack/plugins/fleet/public/components/settings_flyout/index.tsx @@ -36,7 +36,7 @@ import { useGetSettings, useInput, sendPutSettings, - useGetOutputs, + useDefaultOutput, sendPutOutput, } from '../../hooks'; import { isDiffPathProtocol, normalizeHostsForAgents } from '../../../common'; @@ -258,8 +258,7 @@ export const SettingFlyout: React.FunctionComponent = ({ onClose }) => { const settingsRequest = useGetSettings(); const settings = settingsRequest?.data?.item; - const outputsRequest = useGetOutputs(); - const output = outputsRequest.data?.items?.[0]; + const { output } = useDefaultOutput(); const { inputs, submit, validate, isLoading } = useSettingsForm(output?.id, onClose); const [isConfirmModalVisible, setConfirmModalVisible] = React.useState(false); diff --git a/x-pack/plugins/fleet/public/hooks/use_request/outputs.ts b/x-pack/plugins/fleet/public/hooks/use_request/outputs.ts index 0fcaa262cf321..2d623da505c65 100644 --- a/x-pack/plugins/fleet/public/hooks/use_request/outputs.ts +++ b/x-pack/plugins/fleet/public/hooks/use_request/outputs.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { useMemo, useCallback } from 'react'; + import { outputRoutesService } from '../../services'; import type { PutOutputRequest, GetOutputsResponse } from '../../types'; @@ -17,6 +19,21 @@ export function useGetOutputs() { }); } +export function useDefaultOutput() { + const outputsRequest = useGetOutputs(); + const output = useMemo(() => { + return outputsRequest.data?.items.find((o) => o.is_default); + }, [outputsRequest.data]); + + const refresh = useCallback(() => { + return outputsRequest.resendRequest(); + }, [outputsRequest]); + + return useMemo(() => { + return { output, refresh }; + }, [output, refresh]); +} + export function sendPutOutput(outputId: string, body: PutOutputRequest['body']) { return sendRequest({ method: 'put', From c558f26dd13e7949edf19071d9eaa15d5dd05bad Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Mon, 4 Oct 2021 17:55:49 +0300 Subject: [PATCH 08/14] [TSVB] Update the series and metrics Ids that are numbers to strings (#113619) * [TSVB] Update the series and metrics Ids that are numbers to strings * Minor changes * Adds a unit test to TSVB plugin to test this case Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../timeseries/public/vis_state.test.ts | 126 ++++++++++++++++++ .../public/legacy/vis_update_state.js | 28 ++++ .../public/legacy/vis_update_state.test.js | 83 ++++++++++++ 3 files changed, 237 insertions(+) create mode 100644 src/plugins/vis_types/timeseries/public/vis_state.test.ts diff --git a/src/plugins/vis_types/timeseries/public/vis_state.test.ts b/src/plugins/vis_types/timeseries/public/vis_state.test.ts new file mode 100644 index 0000000000000..82e52a0493391 --- /dev/null +++ b/src/plugins/vis_types/timeseries/public/vis_state.test.ts @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import { updateOldState } from '../../../visualizations/public'; + +/** + * The reason we add this test is to ensure that `convertNumIdsToStringsForTSVB` of the updateOldState runs correctly + * for the TSVB vis state. As the `updateOldState` runs on the visualizations plugin. a change to our objects structure can + * result to forget this case. + * Just for reference the `convertNumIdsToStringsForTSVB` finds and converts the series and metrics ids that have only digits to strings + * by adding an x prefix. Number ids are never been generated from the editor, only programmatically. + * See https://github.com/elastic/kibana/issues/113601. + */ +describe('TimeseriesVisState', () => { + test('should format the TSVB visState correctly', () => { + const visState = { + title: 'test', + type: 'metrics', + aggs: [], + params: { + time_range_mode: 'entire_time_range', + id: '0ecc58b1-30ba-43b9-aa3f-9ac32b482497', + type: 'timeseries', + series: [ + { + id: '1', + color: '#68BC00', + split_mode: 'terms', + palette: { + type: 'palette', + name: 'default', + }, + metrics: [ + { + id: '10', + type: 'count', + }, + ], + separate_axis: 0, + axis_position: 'right', + formatter: 'default', + chart_type: 'line', + line_width: 1, + point_size: 1, + fill: 0.5, + stacked: 'none', + terms_field: 'Cancelled', + }, + ], + time_field: '', + use_kibana_indexes: true, + interval: '', + axis_position: 'left', + axis_formatter: 'number', + axis_scale: 'normal', + show_legend: 1, + truncate_legend: 1, + max_lines_legend: 1, + show_grid: 1, + tooltip_mode: 'show_all', + drop_last_bucket: 0, + isModelInvalid: false, + index_pattern: { + id: '665cd2c0-21d6-11ec-b42f-f7077c64d21b', + }, + }, + }; + const newVisState = updateOldState(visState); + expect(newVisState).toEqual({ + aggs: [], + params: { + axis_formatter: 'number', + axis_position: 'left', + axis_scale: 'normal', + drop_last_bucket: 0, + id: '0ecc58b1-30ba-43b9-aa3f-9ac32b482497', + index_pattern: { + id: '665cd2c0-21d6-11ec-b42f-f7077c64d21b', + }, + interval: '', + isModelInvalid: false, + max_lines_legend: 1, + series: [ + { + axis_position: 'right', + chart_type: 'line', + color: '#68BC00', + fill: 0.5, + formatter: 'default', + id: 'x1', + line_width: 1, + metrics: [ + { + id: 'x10', + type: 'count', + }, + ], + palette: { + name: 'default', + type: 'palette', + }, + point_size: 1, + separate_axis: 0, + split_mode: 'terms', + stacked: 'none', + terms_field: 'Cancelled', + }, + ], + show_grid: 1, + show_legend: 1, + time_field: '', + time_range_mode: 'entire_time_range', + tooltip_mode: 'show_all', + truncate_legend: 1, + type: 'timeseries', + use_kibana_indexes: true, + }, + title: 'test', + type: 'metrics', + }); + }); +}); diff --git a/src/plugins/visualizations/public/legacy/vis_update_state.js b/src/plugins/visualizations/public/legacy/vis_update_state.js index d0ebe00b1a6f0..db6a9f2beb776 100644 --- a/src/plugins/visualizations/public/legacy/vis_update_state.js +++ b/src/plugins/visualizations/public/legacy/vis_update_state.js @@ -136,6 +136,30 @@ function convertSeriesParams(visState) { ]; } +/** + * This function is responsible for updating old TSVB visStates. + * Specifically, it identifies if the series and metrics ids are numbers + * and convert them to string with an x prefix. Number ids are never been generated + * from the editor, only programmatically. See https://github.com/elastic/kibana/issues/113601. + */ +function convertNumIdsToStringsForTSVB(visState) { + if (visState.params.series) { + visState.params.series.forEach((s) => { + const seriesId = s.id; + const metrics = s.metrics; + if (!isNaN(seriesId)) { + s.id = `x${seriesId}`; + } + metrics?.forEach((m) => { + const metricId = m.id; + if (!isNaN(metricId)) { + m.id = `x${metricId}`; + } + }); + }); + } +} + /** * This function is responsible for updating old visStates - the actual saved object * object - into the format, that will be required by the current Kibana version. @@ -155,6 +179,10 @@ export const updateOldState = (visState) => { convertSeriesParams(newState); } + if (visState.params && visState.type === 'metrics') { + convertNumIdsToStringsForTSVB(newState); + } + if (visState.type === 'gauge' && visState.fontSize) { delete newState.fontSize; set(newState, 'gauge.style.fontSize', visState.fontSize); diff --git a/src/plugins/visualizations/public/legacy/vis_update_state.test.js b/src/plugins/visualizations/public/legacy/vis_update_state.test.js index 3b0d732df2d1a..a7c2df506d313 100644 --- a/src/plugins/visualizations/public/legacy/vis_update_state.test.js +++ b/src/plugins/visualizations/public/legacy/vis_update_state.test.js @@ -93,4 +93,87 @@ describe('updateOldState', () => { expect(state.params.showMeticsAtAllLevels).toBe(undefined); }); }); + + describe('TSVB ids conversion', () => { + it('should update the seriesId from number to string with x prefix', () => { + const oldState = { + type: 'metrics', + params: { + series: [ + { + id: '10', + }, + { + id: 'ABC', + }, + { + id: 1, + }, + ], + }, + }; + const state = updateOldState(oldState); + expect(state.params.series).toEqual([ + { + id: 'x10', + }, + { + id: 'ABC', + }, + { + id: 'x1', + }, + ]); + }); + it('should update the metrics ids from number to string with x prefix', () => { + const oldState = { + type: 'metrics', + params: { + series: [ + { + id: '10', + metrics: [ + { + id: '1000', + }, + { + id: '74a66e70-ac44-11eb-9865-6b616e971cf8', + }, + ], + }, + { + id: 'ABC', + metrics: [ + { + id: null, + }, + ], + }, + ], + }, + }; + const state = updateOldState(oldState); + expect(state.params.series).toEqual([ + { + id: 'x10', + metrics: [ + { + id: 'x1000', + }, + { + id: '74a66e70-ac44-11eb-9865-6b616e971cf8', + }, + ], + }, + { + id: 'ABC', + metrics: [ + { + id: 'xnull', + }, + ], + }, + ]); + }); + }); }); From 252278a433850ed7775debcd1c74b8e63ef286ba Mon Sep 17 00:00:00 2001 From: Brian Seeders Date: Mon, 4 Oct 2021 10:57:32 -0400 Subject: [PATCH 09/14] [buildkite] Fix packer cache issues (#113769) --- .buildkite/scripts/common/env.sh | 4 ++-- .buildkite/scripts/packer_cache.sh | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.buildkite/scripts/common/env.sh b/.buildkite/scripts/common/env.sh index 89121581c75d1..ac80a66d33fa0 100755 --- a/.buildkite/scripts/common/env.sh +++ b/.buildkite/scripts/common/env.sh @@ -56,8 +56,8 @@ else fi # These are for backwards-compatibility -export GIT_COMMIT="$BUILDKITE_COMMIT" -export GIT_BRANCH="$BUILDKITE_BRANCH" +export GIT_COMMIT="${BUILDKITE_COMMIT:-}" +export GIT_BRANCH="${BUILDKITE_BRANCH:-}" export FLEET_PACKAGE_REGISTRY_PORT=6104 export TEST_CORS_SERVER_PORT=6105 diff --git a/.buildkite/scripts/packer_cache.sh b/.buildkite/scripts/packer_cache.sh index 45d3dc439ff4d..617ea79c827b0 100755 --- a/.buildkite/scripts/packer_cache.sh +++ b/.buildkite/scripts/packer_cache.sh @@ -2,6 +2,7 @@ set -euo pipefail +source .buildkite/scripts/common/util.sh source .buildkite/scripts/common/env.sh source .buildkite/scripts/common/setup_node.sh From 3d7e04b799b11407e2689cde532a7f82297b4874 Mon Sep 17 00:00:00 2001 From: Domenico Andreoli Date: Mon, 4 Oct 2021 17:04:24 +0200 Subject: [PATCH 10/14] [Security] Add EQL rule test in CCS config (#112852) --- .../event_correlation_rule.spec.ts | 55 ++ .../security_solution/cypress/objects/rule.ts | 23 + .../cypress/tasks/api_calls/rules.ts | 31 +- .../es_archives/linux_process/data.json | 135 +++ .../es_archives/linux_process/mappings.json | 935 ++++++++++++++++++ 5 files changed, 1178 insertions(+), 1 deletion(-) create mode 100644 x-pack/plugins/security_solution/cypress/ccs_integration/detection_rules/event_correlation_rule.spec.ts create mode 100644 x-pack/test/security_solution_cypress/es_archives/linux_process/data.json create mode 100644 x-pack/test/security_solution_cypress/es_archives/linux_process/mappings.json diff --git a/x-pack/plugins/security_solution/cypress/ccs_integration/detection_rules/event_correlation_rule.spec.ts b/x-pack/plugins/security_solution/cypress/ccs_integration/detection_rules/event_correlation_rule.spec.ts new file mode 100644 index 0000000000000..c20e6cf6b6370 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/ccs_integration/detection_rules/event_correlation_rule.spec.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { esArchiverCCSLoad } from '../../tasks/es_archiver'; +import { getCCSEqlRule } from '../../objects/rule'; + +import { ALERT_DATA_GRID, NUMBER_OF_ALERTS } from '../../screens/alerts'; + +import { + filterByCustomRules, + goToRuleDetails, + waitForRulesTableToBeLoaded, +} from '../../tasks/alerts_detection_rules'; +import { createSignalsIndex, createEventCorrelationRule } from '../../tasks/api_calls/rules'; +import { cleanKibana } from '../../tasks/common'; +import { waitForAlertsToPopulate, waitForTheRuleToBeExecuted } from '../../tasks/create_new_rule'; +import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; + +import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../urls/navigation'; + +describe('Detection rules', function () { + const expectedNumberOfAlerts = '1 alert'; + + beforeEach('Reset signals index', function () { + cleanKibana(); + createSignalsIndex(); + }); + + it('EQL rule on remote indices generates alerts', function () { + esArchiverCCSLoad('linux_process'); + this.rule = getCCSEqlRule(); + createEventCorrelationRule(this.rule); + + loginAndWaitForPageWithoutDateRange(DETECTIONS_RULE_MANAGEMENT_URL); + waitForRulesTableToBeLoaded(); + filterByCustomRules(); + goToRuleDetails(); + waitForTheRuleToBeExecuted(); + waitForAlertsToPopulate(); + + cy.get(NUMBER_OF_ALERTS).should('have.text', expectedNumberOfAlerts); + cy.get(ALERT_DATA_GRID) + .invoke('text') + .then((text) => { + cy.log('ALERT_DATA_GRID', text); + expect(text).contains(this.rule.name); + expect(text).contains(this.rule.severity.toLowerCase()); + expect(text).contains(this.rule.riskScore); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/objects/rule.ts b/x-pack/plugins/security_solution/cypress/objects/rule.ts index 173bfa524e66e..db76bfc3cf4df 100644 --- a/x-pack/plugins/security_solution/cypress/objects/rule.ts +++ b/x-pack/plugins/security_solution/cypress/objects/rule.ts @@ -72,6 +72,10 @@ export interface OverrideRule extends CustomRule { timestampOverride: string; } +export interface EventCorrelationRule extends CustomRule { + language: string; +} + export interface ThreatIndicatorRule extends CustomRule { indicatorIndexPattern: string[]; indicatorMappingField: string; @@ -326,6 +330,25 @@ export const getEqlRule = (): CustomRule => ({ maxSignals: 100, }); +export const getCCSEqlRule = (): EventCorrelationRule => ({ + customQuery: 'any where process.name == "run-parts"', + name: 'New EQL Rule', + index: [`${ccsRemoteName}:run-parts`], + description: 'New EQL rule description.', + severity: 'High', + riskScore: '17', + tags: ['test', 'newRule'], + referenceUrls: ['http://example.com/', 'https://example.com/'], + falsePositivesExamples: ['False1', 'False2'], + mitre: [getMitre1(), getMitre2()], + note: '# test markdown', + runsEvery: getRunsEvery(), + lookBack: getLookBack(), + timeline: getTimeline(), + maxSignals: 100, + language: 'eql', +}); + export const getEqlSequenceRule = (): CustomRule => ({ customQuery: 'sequence with maxspan=30s\ diff --git a/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts b/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts index 33bd8a06b9985..130467cde053d 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/api_calls/rules.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { CustomRule, ThreatIndicatorRule } from '../../objects/rule'; +import { CustomRule, EventCorrelationRule, ThreatIndicatorRule } from '../../objects/rule'; export const createCustomRule = (rule: CustomRule, ruleId = 'rule_testing', interval = '100m') => cy.request({ @@ -29,6 +29,27 @@ export const createCustomRule = (rule: CustomRule, ruleId = 'rule_testing', inte failOnStatusCode: false, }); +export const createEventCorrelationRule = (rule: EventCorrelationRule, ruleId = 'rule_testing') => + cy.request({ + method: 'POST', + url: 'api/detection_engine/rules', + body: { + rule_id: ruleId, + risk_score: parseInt(rule.riskScore, 10), + description: rule.description, + interval: `${rule.runsEvery.interval}${rule.runsEvery.type}`, + from: `now-${rule.lookBack.interval}${rule.lookBack.type}`, + name: rule.name, + severity: rule.severity.toLocaleLowerCase(), + type: 'eql', + index: rule.index, + query: rule.customQuery, + language: 'eql', + enabled: true, + }, + headers: { 'kbn-xsrf': 'cypress-creds' }, + }); + export const createCustomIndicatorRule = (rule: ThreatIndicatorRule, ruleId = 'rule_testing') => cy.request({ method: 'POST', @@ -107,6 +128,14 @@ export const deleteCustomRule = (ruleId = '1') => { }); }; +export const createSignalsIndex = () => { + cy.request({ + method: 'POST', + url: 'api/detection_engine/index', + headers: { 'kbn-xsrf': 'cypress-creds' }, + }); +}; + export const removeSignalsIndex = () => { cy.request({ url: '/api/detection_engine/index', failOnStatusCode: false }).then((response) => { if (response.status === 200) { diff --git a/x-pack/test/security_solution_cypress/es_archives/linux_process/data.json b/x-pack/test/security_solution_cypress/es_archives/linux_process/data.json new file mode 100644 index 0000000000000..ed29f3fe3e4e1 --- /dev/null +++ b/x-pack/test/security_solution_cypress/es_archives/linux_process/data.json @@ -0,0 +1,135 @@ +{ + "type": "doc", + "value": { + "id": "qxnqn3sBBf0WZxoXk7tg", + "index": "run-parts", + "source": { + "@timestamp": "2021-09-01T05:52:29.9451497Z", + "agent": { + "id": "cda623db-f791-4869-a63d-5b8352dfaa56", + "type": "endpoint", + "version": "7.14.0" + }, + "data_stream": { + "dataset": "endpoint.events.process", + "namespace": "default", + "type": "logs" + }, + "ecs": { + "version": "1.6.0" + }, + "elastic": { + "agent": { + "id": "cda623db-f791-4869-a63d-5b8352dfaa56" + } + }, + "event": { + "action": "exec", + "agent_id_status": "verified", + "category": [ + "process" + ], + "created": "2021-09-01T05:52:29.9451497Z", + "dataset": "endpoint.events.process", + "id": "MGwI0NpfzFKkX6gW+++++CVd", + "ingested": "2021-09-01T05:52:35.677424686Z", + "kind": "event", + "module": "endpoint", + "sequence": 3523, + "type": [ + "start" + ] + }, + "group": { + "Ext": { + "real": { + "id": 0, + "name": "root" + } + }, + "id": 0, + "name": "root" + }, + "host": { + "architecture": "x86_64", + "hostname": "localhost", + "id": "f5c59e5f0c963f828782bc413653d324", + "ip": [ + "127.0.0.1", + "::1" + ], + "mac": [ + "00:16:3e:10:96:79" + ], + "name": "localhost", + "os": { + "Ext": { + "variant": "Debian" + }, + "family": "debian", + "full": "Debian 10", + "kernel": "4.19.0-17-amd64 #1 SMP Debian 4.19.194-3 (2021-07-18)", + "name": "Linux", + "platform": "debian", + "version": "10" + } + }, + "message": "Endpoint process event", + "process": { + "Ext": { + "ancestry": [ + "Y2ZhNjk5ZGItYzI5My00ODY5LWI2OGMtNWI4MzE0ZGZhYTU2LTEzNTAtMTMyNzQ5NDkxNDkuOTM2Njk1MDAw", + "Y2ZhNjk5ZGItYzI5My00ODY5LWI2OGMtNWI4MzE0ZGZhYTU2LTEzNTAtMTMyNzQ5NDkxNDkuOTMwNzYyMTAw", + "Y2ZhNjk5ZGItYzI5My00ODY5LWI2OGMtNWI4MzE0ZGZhYTU2LTEzNDktMTMyNzQ5NDkxNDkuOTI4OTI0ODAw", + "Y2ZhNjk5ZGItYzI5My00ODY5LWI2OGMtNWI4MzE0ZGZhYTU2LTEzNDktMTMyNzQ5NDkxNDkuOTI3NDgwMzAw", + "Y2ZhNjk5ZGItYzI5My00ODY5LWI2OGMtNWI4MzE0ZGZhYTU2LTEzNDEtMTMyNzQ5NDkxNDYuNTI3ODA5NTAw", + "Y2ZhNjk5ZGItYzI5My00ODY5LWI2OGMtNWI4MzE0ZGZhYTU2LTEzNDEtMTMyNzQ5NDkxNDYuNTIzNzEzOTAw", + "Y2ZhNjk5ZGItYzI5My00ODY5LWI2OGMtNWI4MzE0ZGZhYTU2LTczOC0xMzI3NDk0ODg3OS4yNzgyMjQwMDA=", + "Y2ZhNjk5ZGItYzI5My00ODY5LWI2OGMtNWI4MzE0ZGZhYTU2LTczOC0xMzI3NDk0ODg3OS4yNTQ1MTUzMDA=", + "Y2ZhNjk5ZGItYzI5My00ODY5LWI2OGMtNWI4MzE0ZGZhYTU2LTEtMTMyNzQ5NDg4NjkuMA==" + ] + }, + "args": [ + "run-parts", + "--lsbsysinit", + "/etc/update-motd.d" + ], + "args_count": 3, + "command_line": "run-parts --lsbsysinit /etc/update-motd.d", + "entity_id": "Y2ZhNjk5ZGItYzI5My00ODY5LWI2OGMtNWI4MzE0ZGZhYTU2LTEzNTAtMTMyNzQ5NDkxNDkuOTQ1MTQ5NzAw", + "executable": "/usr/bin/run-parts", + "hash": { + "md5": "c83b0578484bf5267893d795b55928bd", + "sha1": "46b6e74e28e5daf69c1dd0f18a8e911ae2922dda", + "sha256": "3346b4d47c637a8c02cb6865eee42d2a5aa9c4e46c6371a9143621348d27420f" + }, + "name": "run-parts", + "parent": { + "args": [ + "sh", + "-c", + "/usr/bin/env -i PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin run-parts --lsbsysinit /etc/update-motd.d > /run/motd.dynamic.new" + ], + "args_count": 0, + "command_line": "sh -c /usr/bin/env -i PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin run-parts --lsbsysinit /etc/update-motd.d > /run/motd.dynamic.new", + "entity_id": "Y2ZhNjk5ZGItYzI5My00ODY5LWI2OGMtNWI4MzE0ZGZhYTU2LTEzNTAtMTMyNzQ5NDkxNDkuOTM2Njk1MDAw", + "executable": "/", + "name": "", + "pid": 1349 + }, + "pid": 1350 + }, + "user": { + "Ext": { + "real": { + "id": 0, + "name": "root" + } + }, + "id": 0, + "name": "root" + } + }, + "type": "_doc" + } +} diff --git a/x-pack/test/security_solution_cypress/es_archives/linux_process/mappings.json b/x-pack/test/security_solution_cypress/es_archives/linux_process/mappings.json new file mode 100644 index 0000000000000..d244defbdab0b --- /dev/null +++ b/x-pack/test/security_solution_cypress/es_archives/linux_process/mappings.json @@ -0,0 +1,935 @@ +{ + "type": "index", + "value": { + "aliases": { + }, + "index": "run-parts", + "mappings": { + "_data_stream_timestamp": { + "enabled": true + }, + "_meta": { + "managed": true, + "managed_by": "ingest-manager", + "package": { + "name": "endpoint" + } + }, + "date_detection": false, + "dynamic": "false", + "dynamic_templates": [ + { + "strings_as_keyword": { + "mapping": { + "ignore_above": 1024, + "type": "keyword" + }, + "match_mapping_type": "string" + } + } + ], + "properties": { + "@timestamp": { + "type": "date" + }, + "agent": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "data_stream": { + "properties": { + "dataset": { + "type": "constant_keyword", + "value": "endpoint.events.process" + }, + "namespace": { + "type": "constant_keyword", + "value": "default" + }, + "type": { + "type": "constant_keyword", + "value": "logs" + } + } + }, + "destination": { + "properties": { + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "ecs": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "event": { + "properties": { + "action": { + "ignore_above": 1024, + "type": "keyword" + }, + "agent_id_status": { + "ignore_above": 1024, + "type": "keyword" + }, + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "code": { + "ignore_above": 1024, + "type": "keyword" + }, + "created": { + "type": "date" + }, + "dataset": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "ingested": { + "type": "date" + }, + "kind": { + "ignore_above": 1024, + "type": "keyword" + }, + "module": { + "ignore_above": 1024, + "type": "keyword" + }, + "outcome": { + "ignore_above": 1024, + "type": "keyword" + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "sequence": { + "type": "long" + }, + "severity": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "group": { + "properties": { + "Ext": { + "properties": { + "real": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "host": { + "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "Ext": { + "properties": { + "variant": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "caseless": { + "ignore_above": 1024, + "normalizer": "lowercase", + "type": "keyword" + }, + "text": { + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "caseless": { + "ignore_above": 1024, + "normalizer": "lowercase", + "type": "keyword" + }, + "text": { + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + } + } + }, + "message": { + "type": "text" + }, + "package": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "process": { + "properties": { + "Ext": { + "properties": { + "ancestry": { + "ignore_above": 1024, + "type": "keyword" + }, + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "authentication_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + }, + "type": "nested" + }, + "defense_evasions": { + "ignore_above": 1024, + "type": "keyword" + }, + "dll": { + "properties": { + "Ext": { + "properties": { + "mapped_address": { + "type": "unsigned_long" + }, + "mapped_size": { + "type": "unsigned_long" + } + } + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "protection": { + "ignore_above": 1024, + "type": "keyword" + }, + "session": { + "ignore_above": 1024, + "type": "keyword" + }, + "token": { + "properties": { + "elevation": { + "type": "boolean" + }, + "elevation_level": { + "ignore_above": 1024, + "type": "keyword" + }, + "elevation_type": { + "ignore_above": 1024, + "type": "keyword" + }, + "integrity_level_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "args_count": { + "type": "long" + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "command_line": { + "fields": { + "caseless": { + "ignore_above": 1024, + "normalizer": "lowercase", + "type": "keyword" + }, + "text": { + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "entity_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "fields": { + "caseless": { + "ignore_above": 1024, + "normalizer": "lowercase", + "type": "keyword" + }, + "text": { + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "exit_code": { + "type": "long" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "fields": { + "caseless": { + "ignore_above": 1024, + "normalizer": "lowercase", + "type": "keyword" + }, + "text": { + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "parent": { + "properties": { + "Ext": { + "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + }, + "type": "nested" + }, + "protection": { + "ignore_above": 1024, + "type": "keyword" + }, + "real": { + "properties": { + "pid": { + "type": "long" + } + } + }, + "user": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "args_count": { + "type": "long" + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "command_line": { + "fields": { + "caseless": { + "ignore_above": 1024, + "normalizer": "lowercase", + "type": "keyword" + }, + "text": { + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "entity_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "fields": { + "caseless": { + "ignore_above": 1024, + "normalizer": "lowercase", + "type": "keyword" + }, + "text": { + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "exit_code": { + "type": "long" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "fields": { + "caseless": { + "ignore_above": 1024, + "normalizer": "lowercase", + "type": "keyword" + }, + "text": { + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "pe": { + "properties": { + "company": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "file_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "imphash": { + "ignore_above": 1024, + "type": "keyword" + }, + "original_file_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pgid": { + "type": "long" + }, + "pid": { + "type": "long" + }, + "ppid": { + "type": "long" + }, + "thread": { + "properties": { + "id": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "title": { + "fields": { + "text": { + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "working_directory": { + "fields": { + "caseless": { + "ignore_above": 1024, + "normalizer": "lowercase", + "type": "keyword" + }, + "text": { + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pe": { + "properties": { + "company": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "file_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "imphash": { + "ignore_above": 1024, + "type": "keyword" + }, + "original_file_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pgid": { + "type": "long" + }, + "pid": { + "type": "long" + }, + "ppid": { + "type": "long" + }, + "thread": { + "properties": { + "id": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "title": { + "fields": { + "text": { + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "working_directory": { + "fields": { + "caseless": { + "ignore_above": 1024, + "normalizer": "lowercase", + "type": "keyword" + }, + "text": { + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "source": { + "properties": { + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "user": { + "properties": { + "Ext": { + "properties": { + "real": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "Ext": { + "properties": { + "real": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "settings": { + "index": { + "number_of_replicas": "0", + "number_of_shards": "1" + } + } + } +} From 10ca6f42d6910352bb592ccc1d6ee8faf714488d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Mon, 4 Oct 2021 11:11:08 -0400 Subject: [PATCH 11/14] [APM] Show APM Server stand-alone mode in Kibana Upgrade Assistant (cloud-only) (#113567) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../server/deprecations/deprecations.test.ts | 70 ++++++++++++++++++ .../plugins/apm/server/deprecations/index.ts | 74 +++++++++++++++++++ x-pack/plugins/apm/server/plugin.ts | 7 ++ 3 files changed, 151 insertions(+) create mode 100644 x-pack/plugins/apm/server/deprecations/deprecations.test.ts create mode 100644 x-pack/plugins/apm/server/deprecations/index.ts diff --git a/x-pack/plugins/apm/server/deprecations/deprecations.test.ts b/x-pack/plugins/apm/server/deprecations/deprecations.test.ts new file mode 100644 index 0000000000000..d706146faf212 --- /dev/null +++ b/x-pack/plugins/apm/server/deprecations/deprecations.test.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { GetDeprecationsContext } from '../../../../../src/core/server'; +import { CloudSetup } from '../../../cloud/server'; +import { getDeprecations } from './'; +import { APMRouteHandlerResources } from '../'; +import { AgentPolicy } from '../../../fleet/common'; + +const deprecationContext = { + esClient: {}, + savedObjectsClient: {}, +} as GetDeprecationsContext; + +describe('getDeprecations', () => { + describe('when fleet is disabled', () => { + it('returns no deprecations', async () => { + const deprecationsCallback = getDeprecations({}); + const deprecations = await deprecationsCallback(deprecationContext); + expect(deprecations).toEqual([]); + }); + }); + + describe('when running on cloud with legacy apm-server', () => { + it('returns deprecations', async () => { + const deprecationsCallback = getDeprecations({ + cloudSetup: { isCloudEnabled: true } as unknown as CloudSetup, + fleet: { + start: () => ({ + agentPolicyService: { get: () => undefined }, + }), + } as unknown as APMRouteHandlerResources['plugins']['fleet'], + }); + const deprecations = await deprecationsCallback(deprecationContext); + expect(deprecations).not.toEqual([]); + }); + }); + + describe('when running on cloud with fleet', () => { + it('returns no deprecations', async () => { + const deprecationsCallback = getDeprecations({ + cloudSetup: { isCloudEnabled: true } as unknown as CloudSetup, + fleet: { + start: () => ({ + agentPolicyService: { get: () => ({ id: 'foo' } as AgentPolicy) }, + }), + } as unknown as APMRouteHandlerResources['plugins']['fleet'], + }); + const deprecations = await deprecationsCallback(deprecationContext); + expect(deprecations).toEqual([]); + }); + }); + + describe('when running on prem', () => { + it('returns no deprecations', async () => { + const deprecationsCallback = getDeprecations({ + cloudSetup: { isCloudEnabled: false } as unknown as CloudSetup, + fleet: { + start: () => ({ agentPolicyService: { get: () => undefined } }), + } as unknown as APMRouteHandlerResources['plugins']['fleet'], + }); + const deprecations = await deprecationsCallback(deprecationContext); + expect(deprecations).toEqual([]); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/deprecations/index.ts b/x-pack/plugins/apm/server/deprecations/index.ts new file mode 100644 index 0000000000000..b592a2bf13268 --- /dev/null +++ b/x-pack/plugins/apm/server/deprecations/index.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { GetDeprecationsContext, DeprecationsDetails } from 'src/core/server'; +import { i18n } from '@kbn/i18n'; +import { isEmpty } from 'lodash'; +import { CloudSetup } from '../../../cloud/server'; +import { getCloudAgentPolicy } from '../lib/fleet/get_cloud_apm_package_policy'; +import { APMRouteHandlerResources } from '../'; + +export function getDeprecations({ + cloudSetup, + fleet, +}: { + cloudSetup?: CloudSetup; + fleet?: APMRouteHandlerResources['plugins']['fleet']; +}) { + return async ({ + savedObjectsClient, + }: GetDeprecationsContext): Promise => { + const deprecations: DeprecationsDetails[] = []; + if (!fleet) { + return deprecations; + } + + const fleetPluginStart = await fleet.start(); + const cloudAgentPolicy = await getCloudAgentPolicy({ + fleetPluginStart, + savedObjectsClient, + }); + + const isCloudEnabled = !!cloudSetup?.isCloudEnabled; + + const hasCloudAgentPolicy = !isEmpty(cloudAgentPolicy); + + if (isCloudEnabled && !hasCloudAgentPolicy) { + deprecations.push({ + title: i18n.translate('xpack.apm.deprecations.legacyModeTitle', { + defaultMessage: 'APM Server running in legacy mode', + }), + message: i18n.translate('xpack.apm.deprecations.message', { + defaultMessage: + 'Running the APM Server binary directly is considered a legacy option and is deprecated since 7.16. Switch to APM Server managed by an Elastic Agent instead. Read our documentation to learn more.', + }), + documentationUrl: + 'https://www.elastic.co/guide/en/apm/server/current/apm-integration.html', + level: 'warning', + correctiveActions: { + manualSteps: [ + i18n.translate('xpack.apm.deprecations.steps.apm', { + defaultMessage: 'Navigate to Observability/APM', + }), + i18n.translate('xpack.apm.deprecations.steps.settings', { + defaultMessage: 'Click on "Settings"', + }), + i18n.translate('xpack.apm.deprecations.steps.schema', { + defaultMessage: 'Select "Schema" tab', + }), + i18n.translate('xpack.apm.deprecations.steps.switch', { + defaultMessage: + 'Click "Switch to data streams". You will be guided through the process', + }), + ], + }, + }); + } + + return deprecations; + }; +} diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index 56185d846562f..2296227de2a33 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -51,6 +51,7 @@ import { TRANSACTION_TYPE, } from '../common/elasticsearch_fieldnames'; import { tutorialProvider } from './tutorial'; +import { getDeprecations } from './deprecations'; export class APMPlugin implements @@ -222,6 +223,12 @@ export class APMPlugin ); })(); }); + core.deprecations.registerDeprecations({ + getDeprecations: getDeprecations({ + cloudSetup: plugins.cloud, + fleet: resourcePlugins.fleet, + }), + }); return { config$: mergedConfig$, From 4693c3812e5decfefcb6bc8835702e141a5f3ed6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Mon, 4 Oct 2021 16:19:03 +0100 Subject: [PATCH 12/14] =?UTF-8?q?[console]=C2=A0Deprecate=20"proxyFilter"?= =?UTF-8?q?=20and=20"proxyConfig"=20on=208.x=20(#113555)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugins/console/common/constants/index.ts | 9 ++ .../console/common/constants/plugin.ts | 9 ++ src/plugins/console/kibana.json | 4 +- src/plugins/console/server/config.ts | 87 ++++++++++----- src/plugins/console/server/index.ts | 9 +- src/plugins/console/server/plugin.ts | 22 +++- .../api/console/proxy/create_handler.ts | 44 +++++--- .../server/routes/api/console/proxy/mocks.ts | 32 ++++-- .../routes/api/console/proxy/params.test.ts | 104 ++++++++++-------- src/plugins/console/server/routes/index.ts | 6 +- 10 files changed, 214 insertions(+), 112 deletions(-) create mode 100644 src/plugins/console/common/constants/index.ts create mode 100644 src/plugins/console/common/constants/plugin.ts diff --git a/src/plugins/console/common/constants/index.ts b/src/plugins/console/common/constants/index.ts new file mode 100644 index 0000000000000..0a8dac9b7fff3 --- /dev/null +++ b/src/plugins/console/common/constants/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { MAJOR_VERSION } from './plugin'; diff --git a/src/plugins/console/common/constants/plugin.ts b/src/plugins/console/common/constants/plugin.ts new file mode 100644 index 0000000000000..cd301ec296395 --- /dev/null +++ b/src/plugins/console/common/constants/plugin.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const MAJOR_VERSION = '8.0.0'; diff --git a/src/plugins/console/kibana.json b/src/plugins/console/kibana.json index 69c7176ff6a47..e2345514d76b9 100644 --- a/src/plugins/console/kibana.json +++ b/src/plugins/console/kibana.json @@ -1,12 +1,14 @@ { "id": "console", - "version": "kibana", + "version": "8.0.0", + "kibanaVersion": "kibana", "server": true, "ui": true, "owner": { "name": "Stack Management", "githubTeam": "kibana-stack-management" }, + "configPath": ["console"], "requiredPlugins": ["devTools", "share"], "optionalPlugins": ["usageCollection", "home"], "requiredBundles": ["esUiShared", "kibanaReact", "kibanaUtils", "home"] diff --git a/src/plugins/console/server/config.ts b/src/plugins/console/server/config.ts index 4e42e3c21d2ad..6d667fed081e8 100644 --- a/src/plugins/console/server/config.ts +++ b/src/plugins/console/server/config.ts @@ -6,37 +6,70 @@ * Side Public License, v 1. */ +import { SemVer } from 'semver'; import { schema, TypeOf } from '@kbn/config-schema'; +import { PluginConfigDescriptor } from 'kibana/server'; -export type ConfigType = TypeOf; +import { MAJOR_VERSION } from '../common/constants'; -export const config = schema.object( - { - enabled: schema.boolean({ defaultValue: true }), - proxyFilter: schema.arrayOf(schema.string(), { defaultValue: ['.*'] }), - ssl: schema.object({ verify: schema.boolean({ defaultValue: false }) }, {}), - proxyConfig: schema.arrayOf( - schema.object({ - match: schema.object({ - protocol: schema.string({ defaultValue: '*' }), - host: schema.string({ defaultValue: '*' }), - port: schema.string({ defaultValue: '*' }), - path: schema.string({ defaultValue: '*' }), - }), - - timeout: schema.number(), - ssl: schema.object( - { - verify: schema.boolean(), - ca: schema.arrayOf(schema.string()), - cert: schema.string(), - key: schema.string(), - }, - { defaultValue: undefined } - ), +const kibanaVersion = new SemVer(MAJOR_VERSION); + +const baseSettings = { + enabled: schema.boolean({ defaultValue: true }), + ssl: schema.object({ verify: schema.boolean({ defaultValue: false }) }, {}), +}; + +// Settings only available in 7.x +const deprecatedSettings = { + proxyFilter: schema.arrayOf(schema.string(), { defaultValue: ['.*'] }), + proxyConfig: schema.arrayOf( + schema.object({ + match: schema.object({ + protocol: schema.string({ defaultValue: '*' }), + host: schema.string({ defaultValue: '*' }), + port: schema.string({ defaultValue: '*' }), + path: schema.string({ defaultValue: '*' }), }), - { defaultValue: [] } - ), + + timeout: schema.number(), + ssl: schema.object( + { + verify: schema.boolean(), + ca: schema.arrayOf(schema.string()), + cert: schema.string(), + key: schema.string(), + }, + { defaultValue: undefined } + ), + }), + { defaultValue: [] } + ), +}; + +const configSchema = schema.object( + { + ...baseSettings, }, { defaultValue: undefined } ); + +const configSchema7x = schema.object( + { + ...baseSettings, + ...deprecatedSettings, + }, + { defaultValue: undefined } +); + +export type ConfigType = TypeOf; +export type ConfigType7x = TypeOf; + +export const config: PluginConfigDescriptor = { + schema: kibanaVersion.major < 8 ? configSchema7x : configSchema, + deprecations: ({ deprecate, unused }) => [ + deprecate('enabled', '8.0.0'), + deprecate('proxyFilter', '8.0.0'), + deprecate('proxyConfig', '8.0.0'), + unused('ssl'), + ], +}; diff --git a/src/plugins/console/server/index.ts b/src/plugins/console/server/index.ts index cd05652c62838..6ae518f5dc796 100644 --- a/src/plugins/console/server/index.ts +++ b/src/plugins/console/server/index.ts @@ -6,16 +6,11 @@ * Side Public License, v 1. */ -import { PluginConfigDescriptor, PluginInitializerContext } from 'kibana/server'; +import { PluginInitializerContext } from 'kibana/server'; -import { ConfigType, config as configSchema } from './config'; import { ConsoleServerPlugin } from './plugin'; export { ConsoleSetup, ConsoleStart } from './types'; +export { config } from './config'; export const plugin = (ctx: PluginInitializerContext) => new ConsoleServerPlugin(ctx); - -export const config: PluginConfigDescriptor = { - deprecations: ({ deprecate, unused, rename }) => [deprecate('enabled', '8.0.0'), unused('ssl')], - schema: configSchema, -}; diff --git a/src/plugins/console/server/plugin.ts b/src/plugins/console/server/plugin.ts index a5f1ca6107600..613337b286fbf 100644 --- a/src/plugins/console/server/plugin.ts +++ b/src/plugins/console/server/plugin.ts @@ -7,10 +7,11 @@ */ import { CoreSetup, Logger, Plugin, PluginInitializerContext } from 'kibana/server'; +import { SemVer } from 'semver'; import { ProxyConfigCollection } from './lib'; import { SpecDefinitionsService, EsLegacyConfigService } from './services'; -import { ConfigType } from './config'; +import { ConfigType, ConfigType7x } from './config'; import { registerRoutes } from './routes'; @@ -23,7 +24,7 @@ export class ConsoleServerPlugin implements Plugin { esLegacyConfigService = new EsLegacyConfigService(); - constructor(private readonly ctx: PluginInitializerContext) { + constructor(private readonly ctx: PluginInitializerContext) { this.log = this.ctx.logger.get(); } @@ -34,10 +35,17 @@ export class ConsoleServerPlugin implements Plugin { save: true, }, })); - + const kibanaVersion = new SemVer(this.ctx.env.packageInfo.version); const config = this.ctx.config.get(); const globalConfig = this.ctx.config.legacy.get(); - const proxyPathFilters = config.proxyFilter.map((str: string) => new RegExp(str)); + + let pathFilters: RegExp[] | undefined; + let proxyConfigCollection: ProxyConfigCollection | undefined; + if (kibanaVersion.major < 8) { + // "pathFilters" and "proxyConfig" are only used in 7.x + pathFilters = (config as ConfigType7x).proxyFilter.map((str: string) => new RegExp(str)); + proxyConfigCollection = new ProxyConfigCollection((config as ConfigType7x).proxyConfig); + } this.esLegacyConfigService.setup(elasticsearch.legacy.config$); @@ -51,7 +59,6 @@ export class ConsoleServerPlugin implements Plugin { specDefinitionService: this.specDefinitionsService, }, proxy: { - proxyConfigCollection: new ProxyConfigCollection(config.proxyConfig), readLegacyESConfig: async (): Promise => { const legacyConfig = await this.esLegacyConfigService.readConfig(); return { @@ -59,8 +66,11 @@ export class ConsoleServerPlugin implements Plugin { ...legacyConfig, }; }, - pathFilters: proxyPathFilters, + // Deprecated settings (only used in 7.x): + proxyConfigCollection, + pathFilters, }, + kibanaVersion, }); return { diff --git a/src/plugins/console/server/routes/api/console/proxy/create_handler.ts b/src/plugins/console/server/routes/api/console/proxy/create_handler.ts index 8ca5720d559ce..9ece066246e4a 100644 --- a/src/plugins/console/server/routes/api/console/proxy/create_handler.ts +++ b/src/plugins/console/server/routes/api/console/proxy/create_handler.ts @@ -9,6 +9,7 @@ import { Agent, IncomingMessage } from 'http'; import * as url from 'url'; import { pick, trimStart, trimEnd } from 'lodash'; +import { SemVer } from 'semver'; import { KibanaRequest, RequestHandler } from 'kibana/server'; @@ -58,17 +59,22 @@ function filterHeaders(originalHeaders: object, headersToKeep: string[]): object function getRequestConfig( headers: object, esConfig: ESConfigForProxy, - proxyConfigCollection: ProxyConfigCollection, - uri: string + uri: string, + kibanaVersion: SemVer, + proxyConfigCollection?: ProxyConfigCollection ): { agent: Agent; timeout: number; headers: object; rejectUnauthorized?: boolean } { const filteredHeaders = filterHeaders(headers, esConfig.requestHeadersWhitelist); const newHeaders = setHeaders(filteredHeaders, esConfig.customHeaders); - if (proxyConfigCollection.hasConfig()) { - return { - ...proxyConfigCollection.configForUri(uri), - headers: newHeaders, - }; + if (kibanaVersion.major < 8) { + // In 7.x we still support the proxyConfig setting defined in kibana.yml + // From 8.x we don't support it anymore so we don't try to read it here. + if (proxyConfigCollection!.hasConfig()) { + return { + ...proxyConfigCollection!.configForUri(uri), + headers: newHeaders, + }; + } } return { @@ -106,18 +112,23 @@ export const createHandler = ({ log, proxy: { readLegacyESConfig, pathFilters, proxyConfigCollection }, + kibanaVersion, }: RouteDependencies): RequestHandler => async (ctx, request, response) => { const { body, query } = request; const { path, method } = query; - if (!pathFilters.some((re) => re.test(path))) { - return response.forbidden({ - body: `Error connecting to '${path}':\n\nUnable to send requests to that path.`, - headers: { - 'Content-Type': 'text/plain', - }, - }); + if (kibanaVersion.major < 8) { + // The "console.proxyFilter" setting in kibana.yaml has been deprecated in 8.x + // We only read it on the 7.x branch + if (!pathFilters!.some((re) => re.test(path))) { + return response.forbidden({ + body: `Error connecting to '${path}':\n\nUnable to send requests to that path.`, + headers: { + 'Content-Type': 'text/plain', + }, + }); + } } const legacyConfig = await readLegacyESConfig(); @@ -134,8 +145,9 @@ export const createHandler = const { timeout, agent, headers, rejectUnauthorized } = getRequestConfig( request.headers, legacyConfig, - proxyConfigCollection, - uri.toString() + uri.toString(), + kibanaVersion, + proxyConfigCollection ); const requestHeaders = { diff --git a/src/plugins/console/server/routes/api/console/proxy/mocks.ts b/src/plugins/console/server/routes/api/console/proxy/mocks.ts index 010e35ab505af..d06ca90adf556 100644 --- a/src/plugins/console/server/routes/api/console/proxy/mocks.ts +++ b/src/plugins/console/server/routes/api/console/proxy/mocks.ts @@ -5,28 +5,41 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ +import { SemVer } from 'semver'; jest.mock('../../../../lib/proxy_request', () => ({ proxyRequest: jest.fn(), })); import { duration } from 'moment'; +import { MAJOR_VERSION } from '../../../../../common/constants'; import { ProxyConfigCollection } from '../../../../lib'; import { RouteDependencies, ProxyDependencies } from '../../../../routes'; import { EsLegacyConfigService, SpecDefinitionsService } from '../../../../services'; import { coreMock, httpServiceMock } from '../../../../../../../core/server/mocks'; -const defaultProxyValue = Object.freeze({ - readLegacyESConfig: async () => ({ - requestTimeout: duration(30000), - customHeaders: {}, - requestHeadersWhitelist: [], - hosts: ['http://localhost:9200'], - }), - pathFilters: [/.*/], - proxyConfigCollection: new ProxyConfigCollection([]), +const kibanaVersion = new SemVer(MAJOR_VERSION); + +const readLegacyESConfig = async () => ({ + requestTimeout: duration(30000), + customHeaders: {}, + requestHeadersWhitelist: [], + hosts: ['http://localhost:9200'], +}); + +let defaultProxyValue = Object.freeze({ + readLegacyESConfig, }); +if (kibanaVersion.major < 8) { + // In 7.x we still support the "pathFilter" and "proxyConfig" kibana.yml settings + defaultProxyValue = Object.freeze({ + readLegacyESConfig, + pathFilters: [/.*/], + proxyConfigCollection: new ProxyConfigCollection([]), + }); +} + interface MockDepsArgument extends Partial> { proxy?: Partial; } @@ -51,5 +64,6 @@ export const getProxyRouteHandlerDeps = ({ } : defaultProxyValue, log, + kibanaVersion, }; }; diff --git a/src/plugins/console/server/routes/api/console/proxy/params.test.ts b/src/plugins/console/server/routes/api/console/proxy/params.test.ts index e08d2f8adecbf..edefb2f11f1f1 100644 --- a/src/plugins/console/server/routes/api/console/proxy/params.test.ts +++ b/src/plugins/console/server/routes/api/console/proxy/params.test.ts @@ -5,14 +5,17 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ +import { SemVer } from 'semver'; import { kibanaResponseFactory } from '../../../../../../../core/server'; -import { getProxyRouteHandlerDeps } from './mocks'; -import { createResponseStub } from './stubs'; +import { getProxyRouteHandlerDeps } from './mocks'; // import need to come first +import { createResponseStub } from './stubs'; // import needs to come first +import { MAJOR_VERSION } from '../../../../../common/constants'; import * as requestModule from '../../../../lib/proxy_request'; - import { createHandler } from './create_handler'; +const kibanaVersion = new SemVer(MAJOR_VERSION); + describe('Console Proxy Route', () => { let handler: ReturnType; @@ -21,58 +24,71 @@ describe('Console Proxy Route', () => { }); describe('params', () => { - describe('pathFilters', () => { - describe('no matches', () => { - it('rejects with 403', async () => { - handler = createHandler( - getProxyRouteHandlerDeps({ proxy: { pathFilters: [/^\/foo\//, /^\/bar\//] } }) - ); + if (kibanaVersion.major < 8) { + describe('pathFilters', () => { + describe('no matches', () => { + it('rejects with 403', async () => { + handler = createHandler( + getProxyRouteHandlerDeps({ + proxy: { pathFilters: [/^\/foo\//, /^\/bar\//] }, + }) + ); - const { status } = await handler( - {} as any, - { query: { method: 'POST', path: '/baz/id' } } as any, - kibanaResponseFactory - ); + const { status } = await handler( + {} as any, + { query: { method: 'POST', path: '/baz/id' } } as any, + kibanaResponseFactory + ); - expect(status).toBe(403); + expect(status).toBe(403); + }); }); - }); - describe('one match', () => { - it('allows the request', async () => { - handler = createHandler( - getProxyRouteHandlerDeps({ proxy: { pathFilters: [/^\/foo\//, /^\/bar\//] } }) - ); - (requestModule.proxyRequest as jest.Mock).mockResolvedValue(createResponseStub('foo')); + describe('one match', () => { + it('allows the request', async () => { + handler = createHandler( + getProxyRouteHandlerDeps({ + proxy: { pathFilters: [/^\/foo\//, /^\/bar\//] }, + }) + ); + + (requestModule.proxyRequest as jest.Mock).mockResolvedValue(createResponseStub('foo')); - const { status } = await handler( - {} as any, - { headers: {}, query: { method: 'POST', path: '/foo/id' } } as any, - kibanaResponseFactory - ); + const { status } = await handler( + {} as any, + { headers: {}, query: { method: 'POST', path: '/foo/id' } } as any, + kibanaResponseFactory + ); - expect(status).toBe(200); - expect((requestModule.proxyRequest as jest.Mock).mock.calls.length).toBe(1); + expect(status).toBe(200); + expect((requestModule.proxyRequest as jest.Mock).mock.calls.length).toBe(1); + }); }); - }); - describe('all match', () => { - it('allows the request', async () => { - handler = createHandler( - getProxyRouteHandlerDeps({ proxy: { pathFilters: [/^\/foo\//] } }) - ); - (requestModule.proxyRequest as jest.Mock).mockResolvedValue(createResponseStub('foo')); + describe('all match', () => { + it('allows the request', async () => { + handler = createHandler( + getProxyRouteHandlerDeps({ proxy: { pathFilters: [/^\/foo\//] } }) + ); + + (requestModule.proxyRequest as jest.Mock).mockResolvedValue(createResponseStub('foo')); - const { status } = await handler( - {} as any, - { headers: {}, query: { method: 'GET', path: '/foo/id' } } as any, - kibanaResponseFactory - ); + const { status } = await handler( + {} as any, + { headers: {}, query: { method: 'GET', path: '/foo/id' } } as any, + kibanaResponseFactory + ); - expect(status).toBe(200); - expect((requestModule.proxyRequest as jest.Mock).mock.calls.length).toBe(1); + expect(status).toBe(200); + expect((requestModule.proxyRequest as jest.Mock).mock.calls.length).toBe(1); + }); }); }); - }); + } else { + // jest requires to have at least one test in the file + test('dummy required test', () => { + expect(true).toBe(true); + }); + } }); }); diff --git a/src/plugins/console/server/routes/index.ts b/src/plugins/console/server/routes/index.ts index 2c46547f92f1b..3911e8cfabc60 100644 --- a/src/plugins/console/server/routes/index.ts +++ b/src/plugins/console/server/routes/index.ts @@ -7,6 +7,7 @@ */ import { IRouter, Logger } from 'kibana/server'; +import { SemVer } from 'semver'; import { EsLegacyConfigService, SpecDefinitionsService } from '../services'; import { ESConfigForProxy } from '../types'; @@ -18,8 +19,8 @@ import { registerSpecDefinitionsRoute } from './api/console/spec_definitions'; export interface ProxyDependencies { readLegacyESConfig: () => Promise; - pathFilters: RegExp[]; - proxyConfigCollection: ProxyConfigCollection; + pathFilters?: RegExp[]; // Only present in 7.x + proxyConfigCollection?: ProxyConfigCollection; // Only present in 7.x } export interface RouteDependencies { @@ -30,6 +31,7 @@ export interface RouteDependencies { esLegacyConfigService: EsLegacyConfigService; specDefinitionService: SpecDefinitionsService; }; + kibanaVersion: SemVer; } export const registerRoutes = (dependencies: RouteDependencies) => { From 3d0da7f0f69aa37db96db7489566c8bd2198be58 Mon Sep 17 00:00:00 2001 From: Sandra G Date: Mon, 4 Oct 2021 11:37:19 -0400 Subject: [PATCH 13/14] [Stack Monitoring] Migrate Index Views to React (#113660) * index views * fix type Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../monitoring/public/application/index.tsx | 17 +++ .../elasticsearch/index_advanced_page.tsx | 77 +++++++++++++ .../pages/elasticsearch/index_page.tsx | 106 ++++++++++++++++++ .../pages/elasticsearch/item_template.tsx | 36 ++++++ .../pages/elasticsearch/node_page.tsx | 56 +++++---- .../elasticsearch/index/index_react.js | 70 ++++++++++++ .../components/cluster_view_react.js | 5 +- 7 files changed, 335 insertions(+), 32 deletions(-) create mode 100644 x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_advanced_page.tsx create mode 100644 x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_page.tsx create mode 100644 x-pack/plugins/monitoring/public/application/pages/elasticsearch/item_template.tsx create mode 100644 x-pack/plugins/monitoring/public/components/elasticsearch/index/index_react.js diff --git a/x-pack/plugins/monitoring/public/application/index.tsx b/x-pack/plugins/monitoring/public/application/index.tsx index acdf3b0986a64..a958e6061215d 100644 --- a/x-pack/plugins/monitoring/public/application/index.tsx +++ b/x-pack/plugins/monitoring/public/application/index.tsx @@ -27,6 +27,8 @@ import { KibanaOverviewPage } from './pages/kibana/overview'; import { CODE_PATH_ELASTICSEARCH, CODE_PATH_BEATS, CODE_PATH_KIBANA } from '../../common/constants'; import { ElasticsearchNodesPage } from './pages/elasticsearch/nodes_page'; import { ElasticsearchIndicesPage } from './pages/elasticsearch/indices_page'; +import { ElasticsearchIndexPage } from './pages/elasticsearch/index_page'; +import { ElasticsearchIndexAdvancedPage } from './pages/elasticsearch/index_advanced_page'; import { ElasticsearchNodePage } from './pages/elasticsearch/node_page'; import { MonitoringTimeContainer } from './hooks/use_monitoring_time'; import { BreadcrumbContainer } from './hooks/use_breadcrumbs'; @@ -84,6 +86,21 @@ const MonitoringApp: React.FC<{ /> {/* ElasticSearch Views */} + + + + + = ({ clusters }) => { + const globalState = useContext(GlobalStateContext); + const { services } = useKibana<{ data: any }>(); + const { index }: { index: string } = useParams(); + const { zoomInfo, onBrush } = useCharts(); + const clusterUuid = globalState.cluster_uuid; + const [data, setData] = useState({} as any); + + const title = i18n.translate('xpack.monitoring.elasticsearch.index.advanced.title', { + defaultMessage: 'Elasticsearch - Indices - {indexName} - Advanced', + values: { + indexName: index, + }, + }); + + const getPageData = useCallback(async () => { + const bounds = services.data?.query.timefilter.timefilter.getBounds(); + const url = `../api/monitoring/v1/clusters/${clusterUuid}/elasticsearch/indices/${index}`; + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + is_advanced: true, + }), + }); + setData(response); + }, [clusterUuid, services.data?.query.timefilter.timefilter, services.http, index]); + + return ( + + ( + + {flyoutComponent} + + {bottomBarComponent} + + )} + /> + + ); +}; diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_page.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_page.tsx new file mode 100644 index 0000000000000..b23f9c71a98bf --- /dev/null +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_page.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useContext, useState, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { useParams } from 'react-router-dom'; +import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; +import { GlobalStateContext } from '../../global_state_context'; +// @ts-ignore +import { IndexReact } from '../../../components/elasticsearch/index/index_react'; +import { ComponentProps } from '../../route_init'; +import { SetupModeRenderer } from '../../setup_mode/setup_mode_renderer'; +import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; +import { useCharts } from '../../hooks/use_charts'; +import { ItemTemplate } from './item_template'; +// @ts-ignore +import { indicesByNodes } from '../../../components/elasticsearch/shard_allocation/transformers/indices_by_nodes'; +// @ts-ignore +import { labels } from '../../../components/elasticsearch/shard_allocation/lib/labels'; + +interface SetupModeProps { + setupMode: any; + flyoutComponent: any; + bottomBarComponent: any; +} + +export const ElasticsearchIndexPage: React.FC = ({ clusters }) => { + const globalState = useContext(GlobalStateContext); + const { services } = useKibana<{ data: any }>(); + const { index }: { index: string } = useParams(); + const { zoomInfo, onBrush } = useCharts(); + const clusterUuid = globalState.cluster_uuid; + const [data, setData] = useState({} as any); + const [indexLabel, setIndexLabel] = useState(labels.index as any); + const [nodesByIndicesData, setNodesByIndicesData] = useState([]); + + const title = i18n.translate('xpack.monitoring.elasticsearch.index.overview.title', { + defaultMessage: 'Elasticsearch - Indices - {indexName} - Overview', + values: { + indexName: index, + }, + }); + + const pageTitle = i18n.translate('xpack.monitoring.elasticsearch.index.overview.pageTitle', { + defaultMessage: 'Index: {indexName}', + values: { + indexName: index, + }, + }); + + const getPageData = useCallback(async () => { + const bounds = services.data?.query.timefilter.timefilter.getBounds(); + const url = `../api/monitoring/v1/clusters/${clusterUuid}/elasticsearch/indices/${index}`; + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + is_advanced: false, + }), + }); + setData(response); + const transformer = indicesByNodes(); + setNodesByIndicesData(transformer(response.shards, response.nodes)); + + const shards = response.shards; + if (shards.some((shard: any) => shard.state === 'UNASSIGNED')) { + setIndexLabel(labels.indexWithUnassigned); + } + }, [clusterUuid, services.data?.query.timefilter.timefilter, services.http, index]); + + return ( + + ( + + {flyoutComponent} + + {bottomBarComponent} + + )} + /> + + ); +}; diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/item_template.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/item_template.tsx new file mode 100644 index 0000000000000..1f06ba18bf102 --- /dev/null +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/item_template.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { PageTemplate } from '../page_template'; +import { TabMenuItem, PageTemplateProps } from '../page_template'; + +interface ItemTemplateProps extends PageTemplateProps { + id: string; + pageType: string; +} +export const ItemTemplate: React.FC = (props) => { + const { pageType, id, ...rest } = props; + const tabs: TabMenuItem[] = [ + { + id: 'overview', + label: i18n.translate('xpack.monitoring.esItemNavigation.overviewLinkText', { + defaultMessage: 'Overview', + }), + route: `/elasticsearch/${pageType}/${id}`, + }, + { + id: 'advanced', + label: i18n.translate('xpack.monitoring.esItemNavigation.advancedLinkText', { + defaultMessage: 'Advanced', + }), + route: `/elasticsearch/${pageType}/${id}/advanced`, + }, + ]; + + return ; +}; diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/node_page.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/node_page.tsx index ffbde2efcac6b..9b3a67f612e5c 100644 --- a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/node_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/node_page.tsx @@ -7,8 +7,7 @@ import React, { useContext, useState, useCallback } from 'react'; import { useParams } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; -import { find } from 'lodash'; -import { ElasticsearchTemplate } from './elasticsearch_template'; +import { ItemTemplate } from './item_template'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { GlobalStateContext } from '../../global_state_context'; import { NodeReact } from '../../../components/elasticsearch'; @@ -18,6 +17,8 @@ import { SetupModeContext } from '../../../components/setup_mode/setup_mode_cont import { useLocalStorage } from '../../hooks/use_local_storage'; import { useCharts } from '../../hooks/use_charts'; import { nodesByIndices } from '../../../components/elasticsearch/shard_allocation/transformers/nodes_by_indices'; +// @ts-ignore +import { labels } from '../../../components/elasticsearch/shard_allocation/lib/labels'; interface SetupModeProps { setupMode: any; @@ -38,9 +39,6 @@ export const ElasticsearchNodePage: React.FC = ({ clusters }) => const clusterUuid = globalState.cluster_uuid; const ccs = globalState.ccs; - const cluster = find(clusters, { - cluster_uuid: clusterUuid, - }); const [data, setData] = useState({} as any); const [nodesByIndicesData, setNodesByIndicesData] = useState([]); @@ -92,33 +90,33 @@ export const ElasticsearchNodePage: React.FC = ({ clusters }) => }, [showSystemIndices, setShowSystemIndices]); return ( - -
- ( - - {flyoutComponent} - - {bottomBarComponent} - - )} - /> -
-
+ ( + + {flyoutComponent} + + {bottomBarComponent} + + )} + /> + ); }; diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/index/index_react.js b/x-pack/plugins/monitoring/public/components/elasticsearch/index/index_react.js new file mode 100644 index 0000000000000..70bac52a0926c --- /dev/null +++ b/x-pack/plugins/monitoring/public/components/elasticsearch/index/index_react.js @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { + EuiPage, + EuiPageContent, + EuiPageBody, + EuiPanel, + EuiSpacer, + EuiFlexGrid, + EuiFlexItem, +} from '@elastic/eui'; +import { IndexDetailStatus } from '../index_detail_status'; +import { MonitoringTimeseriesContainer } from '../../chart'; +import { ShardAllocationReact } from '../shard_allocation/shard_allocation_react'; +import { Logs } from '../../logs'; +import { AlertsCallout } from '../../../alerts/callout'; + +export const IndexReact = ({ + indexSummary, + metrics, + clusterUuid, + indexUuid, + logs, + alerts, + ...props +}) => { + const metricsToShow = [ + metrics.index_mem, + metrics.index_size, + metrics.index_search_request_rate, + metrics.index_request_rate, + metrics.index_segment_count, + metrics.index_document_count, + ]; + + return ( + + + + + + + + + + + {metricsToShow.map((metric, index) => ( + + + + + ))} + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/shard_allocation/components/cluster_view_react.js b/x-pack/plugins/monitoring/public/components/elasticsearch/shard_allocation/components/cluster_view_react.js index 987ca467931f4..2d0c4b59df4b8 100644 --- a/x-pack/plugins/monitoring/public/components/elasticsearch/shard_allocation/components/cluster_view_react.js +++ b/x-pack/plugins/monitoring/public/components/elasticsearch/shard_allocation/components/cluster_view_react.js @@ -8,13 +8,12 @@ import React from 'react'; import { TableHeadReact } from './table_head_react'; import { TableBody } from './table_body'; -import { labels } from '../lib/labels'; export const ClusterViewReact = (props) => { return ( @@ -22,7 +21,7 @@ export const ClusterViewReact = (props) => { filter={props.filter} totalCount={props.totalCount} rows={props.nodesByIndices} - cols={labels.node.length} + cols={props.labels.length} shardStats={props.shardStats} />
From 59b15df115f5f0ed8f6d6a54c862594534dcd13a Mon Sep 17 00:00:00 2001 From: Sergi Massaneda Date: Mon, 4 Oct 2021 17:49:14 +0200 Subject: [PATCH 14/14] fix priority reset bug (#113626) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../jira/jira_params.test.tsx | 18 ++++++++++++++++++ .../builtin_action_types/jira/jira_params.tsx | 4 ++-- .../jira/use_get_fields_by_issue_type.tsx | 2 +- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.test.tsx index a05db00f141ab..812c234e80d9e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.test.tsx @@ -95,6 +95,10 @@ describe('JiraParamsFields renders', () => { description: { allowedValues: [], defaultValue: {} }, }, }; + const useGetFieldsByIssueTypeResponseLoading = { + isLoading: true, + fields: {}, + }; beforeEach(() => { jest.clearAllMocks(); @@ -421,5 +425,19 @@ describe('JiraParamsFields renders', () => { expect(editAction.mock.calls[0][1].incident.priority).toEqual('Medium'); expect(editAction.mock.calls[1][1].incident.priority).toEqual(null); }); + + test('Preserve priority when the issue type fields are loading and hasPriority becomes stale', () => { + useGetFieldsByIssueTypeMock + .mockReturnValueOnce(useGetFieldsByIssueTypeResponseLoading) + .mockReturnValue(useGetFieldsByIssueTypeResponse); + const wrapper = mount(); + + expect(editAction).not.toBeCalled(); + + wrapper.setProps({ ...defaultProps }); // just to force component call useGetFieldsByIssueType again + + expect(editAction).toBeCalledTimes(1); + expect(editAction.mock.calls[0][1].incident.priority).toEqual('Medium'); + }); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx index 834892f2bf374..32390c163cf2a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/jira_params.tsx @@ -147,11 +147,11 @@ const JiraParamsFields: React.FunctionComponent { - if (!hasPriority && incident.priority != null) { + if (!isLoadingFields && !hasPriority && incident.priority != null) { editSubActionProperty('priority', null); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [hasPriority]); + }, [hasPriority, isLoadingFields]); const labelOptions = useMemo( () => (incident.labels ? incident.labels.map((label: string) => ({ label })) : []), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/use_get_fields_by_issue_type.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/use_get_fields_by_issue_type.tsx index 38be618119c4a..61db73c129db6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/use_get_fields_by_issue_type.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/jira/use_get_fields_by_issue_type.tsx @@ -62,8 +62,8 @@ export const useGetFieldsByIssueType = ({ }); if (!didCancel) { - setIsLoading(false); setFields(res.data ?? {}); + setIsLoading(false); if (res.status && res.status === 'error') { toastNotifications.addDanger({ title: i18n.FIELDS_API_ERROR,