diff --git a/.ci/es-snapshots/Jenkinsfile_verify_es b/.ci/es-snapshots/Jenkinsfile_verify_es index ade79f27e10e9..2655ca1b48c18 100644 --- a/.ci/es-snapshots/Jenkinsfile_verify_es +++ b/.ci/es-snapshots/Jenkinsfile_verify_es @@ -21,6 +21,7 @@ def SNAPSHOT_MANIFEST = "https://storage.googleapis.com/kibana-ci-es-snapshots-d kibanaPipeline(timeoutMinutes: 120) { catchErrors { + retryable.enable(2) withEnv(["ES_SNAPSHOT_MANIFEST=${SNAPSHOT_MANIFEST}"]) { parallel([ 'kibana-intake-agent': workers.intake('kibana-intake', './test/scripts/jenkins_unit.sh'), diff --git a/docs/discover/images/autorefresh-interval.png b/docs/discover/images/autorefresh-interval.png new file mode 100644 index 0000000000000..a9f72a1cb73cd Binary files /dev/null and b/docs/discover/images/autorefresh-interval.png differ diff --git a/docs/discover/search.asciidoc b/docs/discover/search.asciidoc index 21ae4560fba94..9fe35f0302760 100644 --- a/docs/discover/search.asciidoc +++ b/docs/discover/search.asciidoc @@ -1,25 +1,53 @@ [[search]] == Searching your data -You can search the indices that match the current <> by entering -your search criteria in the Query bar. By default you can use Kibana's <> -which features autocomplete and a simple, easy to use syntax. Kibana's legacy query -language (based on Lucene https://lucene.apache.org/core/2_9_4/queryparsersyntax.html[query syntax]) -is still available for the time being under the options menu in the Query Bar. When this -legacy query language is selected, the full JSON-based {ref}/query-dsl.html[Elasticsearch Query DSL] -can also be used. - -When you submit a search request, the histogram, Documents table, and Fields -list are updated to reflect the search results. The total number of hits -(matching documents) is shown in the toolbar. The Documents table shows the -first five hundred hits. By default, the hits are listed in reverse -chronological order, with the newest documents shown first. You can reverse -the sort order by clicking the Time column header. You can also sort the table -by the values in any indexed field. For more information, see <>. - -To search your data, enter your search criteria in the Query bar and -press *Enter* or click *Search* image:images/search-button.jpg[] to submit -the request to Elasticsearch. +Many Kibana apps embed a query bar for real-time search, including +*Discover*, *Visualize*, and *Dashboard*. + +[float] +=== Search your data + +To search the indices that match the current <>, +enter your search criteria in the query bar. By default, you'll use +{kib}'s <> (KQL), which +features autocomplete and a simple, easy-to-use syntax. If you prefer to use +{kib}'s legacy query +language, based on the +Lucene https://lucene.apache.org/core/2_9_4/queryparsersyntax.html[query syntax], +you can switch to it from the KQL popup in the query bar. When you enable the +legacy query language, you can use the full +JSON-based {ref}/query-dsl.html[Elasticsearch Query DSL]. + + +[float] +[[autorefresh]] +=== Refresh search results +As more documents are added to the indices you're searching, the search results +shown in *Discover*, and used to display visualizations, get stale. Using the +time filter, you can +configure a refresh interval to periodically resubmit your searches to +retrieve the latest results. + +[role="screenshot"] +image::images/autorefresh-interval.png[] + +You can also manually refresh the search results by +clicking the *Refresh* button. + +[float] +=== Searching large amounts of data + +Sometimes you want to search through large amounts of data no matter how long +the search takes. While this might not happen often, there are times +that long-running queries are required. Consider a threat hunting scenario +where you need to search through years of data. + +If you run a query, and the run time gets close to the +timeout, you're presented the option to ignore the timeout. This enables you to +run queries with large amounts of data to completion. + +By default, a query times out after 30 seconds. +The timeout is in place to avoid unintentional load on the cluster. + include::kuery.asciidoc[] @@ -160,31 +188,3 @@ To completely delete a query: image::discover/images/saved-query-management-component-delete-query-button.png["Example of the saved query management popover when a query is hovered over and we are about to delete a query",width="80%"] You can import, export, and delete saved queries from <>. - -[[select-pattern]] -=== Change the indices you're searching -When you submit a search request, the indices that match the currently-selected -index pattern are searched. -To change the indices you are searching, click the index pattern and select a -different <>. - -[[autorefresh]] -=== Refresh the search results -As more documents are added to the indices you're searching, the search results -shown in Discover and used to display visualizations get stale. You can -configure a refresh interval to periodically resubmit your searches to -retrieve the latest results. - -. Click image:images/time-filter-calendar.png[]. - -. In the *Refresh every* field, enter the refresh rate, then select the interval - from the dropdown. - -. Click *Start*. -+ -image::images/autorefresh-intervals.png[] - -To disable auto refresh, click *Stop*. - -If auto refresh is not enabled, click *Refresh* to manually refresh the search -results. diff --git a/docs/images/autorefresh-intervals.png b/docs/images/autorefresh-intervals.png index a79ae2f1f6c46..49be46fefd4aa 100644 Binary files a/docs/images/autorefresh-intervals.png and b/docs/images/autorefresh-intervals.png differ diff --git a/docs/user/discover.asciidoc b/docs/user/discover.asciidoc index 2547b38a22616..7bac80237a26e 100644 --- a/docs/user/discover.asciidoc +++ b/docs/user/discover.asciidoc @@ -21,6 +21,7 @@ image::images/Discover-Start.png[Discover] [float] +[[select-pattern]] === Set up your index pattern The first thing to do in *Discover* is to select an <>, which diff --git a/docs/user/reporting/development/pdf-integration.asciidoc b/docs/user/reporting/development/pdf-integration.asciidoc index af5ba5be1636e..e9f32de41baab 100644 --- a/docs/user/reporting/development/pdf-integration.asciidoc +++ b/docs/user/reporting/development/pdf-integration.asciidoc @@ -63,3 +63,5 @@ If there are multiple visualizations, the `data-shared-items-count` attribute sh many Visualizations to look for. Reporting will look at every element with the `data-shared-item` attribute and use the corresponding `data-render-complete` attribute and `renderComplete` events to listen for rendering to complete. When rendering is complete for a visualization the `data-render-complete` attribute should be set to "true" and it should dispatch a custom DOM `renderComplete` event. + +If the reporting job uses multiple URLs, before looking for any of the `data-shared-item` or `data-shared-items-count` attributes, it waits for a `data-shared-page` attribute that specifies which page is being loaded. diff --git a/src/core/server/elasticsearch/elasticsearch_service.mock.ts b/src/core/server/elasticsearch/elasticsearch_service.mock.ts index da8846f6dddbb..a7d78b56ff3fd 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.mock.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.mock.ts @@ -18,6 +18,7 @@ */ import { BehaviorSubject } from 'rxjs'; +import { Client } from 'elasticsearch'; import { IClusterClient, ICustomClusterClient } from './cluster_client'; import { IScopedClusterClient } from './scoped_cluster_client'; import { ElasticsearchConfig } from './elasticsearch_config'; @@ -130,6 +131,55 @@ const createMock = () => { return mocked; }; +const createElasticsearchClientMock = () => { + const mocked: jest.Mocked = { + cat: {} as any, + cluster: {} as any, + indices: {} as any, + ingest: {} as any, + nodes: {} as any, + snapshot: {} as any, + tasks: {} as any, + bulk: jest.fn(), + clearScroll: jest.fn(), + count: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + deleteByQuery: jest.fn(), + deleteScript: jest.fn(), + deleteTemplate: jest.fn(), + exists: jest.fn(), + explain: jest.fn(), + fieldStats: jest.fn(), + get: jest.fn(), + getScript: jest.fn(), + getSource: jest.fn(), + getTemplate: jest.fn(), + index: jest.fn(), + info: jest.fn(), + mget: jest.fn(), + msearch: jest.fn(), + msearchTemplate: jest.fn(), + mtermvectors: jest.fn(), + ping: jest.fn(), + putScript: jest.fn(), + putTemplate: jest.fn(), + reindex: jest.fn(), + reindexRethrottle: jest.fn(), + renderSearchTemplate: jest.fn(), + scroll: jest.fn(), + search: jest.fn(), + searchShards: jest.fn(), + searchTemplate: jest.fn(), + suggest: jest.fn(), + termvectors: jest.fn(), + update: jest.fn(), + updateByQuery: jest.fn(), + close: jest.fn(), + }; + return mocked; +}; + export const elasticsearchServiceMock = { create: createMock, createInternalSetup: createInternalSetupContractMock, @@ -138,4 +188,5 @@ export const elasticsearchServiceMock = { createClusterClient: createClusterClientMock, createCustomClusterClient: createCustomClusterClientMock, createScopedClusterClient: createScopedClusterClientMock, + createElasticsearchClient: createElasticsearchClientMock, }; diff --git a/src/core/server/http/integration_tests/core_service.test.mocks.ts b/src/core/server/http/integration_tests/core_service.test.mocks.ts index 6fa3357168027..b3adda8dd22d1 100644 --- a/src/core/server/http/integration_tests/core_service.test.mocks.ts +++ b/src/core/server/http/integration_tests/core_service.test.mocks.ts @@ -24,3 +24,14 @@ jest.doMock('../../elasticsearch/scoped_cluster_client', () => ({ return elasticsearchServiceMock.createScopedClusterClient(); }), })); + +jest.doMock('elasticsearch', () => { + const realES = jest.requireActual('elasticsearch'); + return { + ...realES, + // eslint-disable-next-line object-shorthand + Client: function() { + return elasticsearchServiceMock.createElasticsearchClient(); + }, + }; +}); diff --git a/src/core/server/http/integration_tests/core_services.test.ts b/src/core/server/http/integration_tests/core_services.test.ts index 7b1630a7de0be..5726486a0930a 100644 --- a/src/core/server/http/integration_tests/core_services.test.ts +++ b/src/core/server/http/integration_tests/core_services.test.ts @@ -43,7 +43,7 @@ describe('http service', () => { describe('auth', () => { let root: ReturnType; beforeEach(async () => { - root = kbnTestServer.createRoot({ migrations: { skip: true } }); + root = kbnTestServer.createRoot(); }, 30000); afterEach(async () => { @@ -192,7 +192,7 @@ describe('http service', () => { let root: ReturnType; beforeEach(async () => { - root = kbnTestServer.createRoot({ migrations: { skip: true } }); + root = kbnTestServer.createRoot(); }, 30000); afterEach(async () => { @@ -326,7 +326,7 @@ describe('http service', () => { describe('#basePath()', () => { let root: ReturnType; beforeEach(async () => { - root = kbnTestServer.createRoot({ migrations: { skip: true } }); + root = kbnTestServer.createRoot(); }, 30000); afterEach(async () => await root.shutdown()); @@ -355,7 +355,7 @@ describe('http service', () => { describe('elasticsearch', () => { let root: ReturnType; beforeEach(async () => { - root = kbnTestServer.createRoot({ migrations: { skip: true } }); + root = kbnTestServer.createRoot(); }, 30000); afterEach(async () => { diff --git a/vars/prChanges.groovy b/vars/prChanges.groovy index d7f46ee7be23e..4b9b20a945f76 100644 --- a/vars/prChanges.groovy +++ b/vars/prChanges.groovy @@ -7,6 +7,7 @@ def getSkippablePaths() { /^docs\//, /^rfcs\//, /^.ci\/.+\.yml$/, + /^.ci\/es-snapshots\//, /^\.github\//, /\.md$/, ] diff --git a/x-pack/legacy/plugins/canvas/public/apps/export/export/__tests__/__snapshots__/export_app.test.tsx.snap b/x-pack/legacy/plugins/canvas/public/apps/export/export/__tests__/__snapshots__/export_app.test.tsx.snap new file mode 100644 index 0000000000000..19e9000c3bffc --- /dev/null +++ b/x-pack/legacy/plugins/canvas/public/apps/export/export/__tests__/__snapshots__/export_app.test.tsx.snap @@ -0,0 +1,141 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders as expected 1`] = ` + +
+
+
+ +
+ Link +
+ +
+
+ +
+ Page +
+
+
+
+
+
+`; + +exports[` renders as expected 2`] = ` + +
+
+
+ +
+ Link +
+ +
+
+ +
+ Page +
+
+
+
+
+
+`; diff --git a/x-pack/legacy/plugins/canvas/public/apps/export/export/__tests__/export_app.test.tsx b/x-pack/legacy/plugins/canvas/public/apps/export/export/__tests__/export_app.test.tsx new file mode 100644 index 0000000000000..7f5b53df4ba52 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/public/apps/export/export/__tests__/export_app.test.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +// @ts-ignore untyped local +import { ExportApp } from '../export_app'; + +jest.mock('style-it', () => ({ + it: (css: string, Component: any) => Component, +})); + +jest.mock('../../../../components/workpad_page', () => ({ + WorkpadPage: (props: any) =>
Page
, +})); + +jest.mock('../../../../components/link', () => ({ + Link: (props: any) =>
Link
, +})); + +describe('', () => { + test('renders as expected', () => { + const sampleWorkpad = { + id: 'my-workpad-abcd', + css: '', + pages: [ + { + elements: [0, 1, 2], + }, + { + elements: [3, 4, 5, 6], + }, + ], + }; + + const page1 = mount( + {}} /> + ); + expect(page1).toMatchSnapshot(); + + const page2 = mount( + {}} /> + ); + expect(page2).toMatchSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/public/apps/export/export/export_app.js b/x-pack/legacy/plugins/canvas/public/apps/export/export/export_app.js index 7537f8eaa9039..1d02d85cae0b3 100644 --- a/x-pack/legacy/plugins/canvas/public/apps/export/export/export_app.js +++ b/x-pack/legacy/plugins/canvas/public/apps/export/export/export_app.js @@ -16,7 +16,7 @@ export class ExportApp extends React.PureComponent { id: PropTypes.string.isRequired, pages: PropTypes.array.isRequired, }).isRequired, - selectedPageId: PropTypes.string.isRequired, + selectedPageIndex: PropTypes.number.isRequired, initializeWorkpad: PropTypes.func.isRequired, }; @@ -25,13 +25,13 @@ export class ExportApp extends React.PureComponent { } render() { - const { workpad, selectedPageId } = this.props; + const { workpad, selectedPageIndex } = this.props; const { pages, height, width } = workpad; - const activePage = pages.find(page => page.id === selectedPageId); + const activePage = pages[selectedPageIndex]; const pageElementCount = activePage.elements.length; return ( -
+
diff --git a/x-pack/legacy/plugins/canvas/public/apps/export/export/index.js b/x-pack/legacy/plugins/canvas/public/apps/export/export/index.js index d40c5f787e44f..dafcb9f4c2510 100644 --- a/x-pack/legacy/plugins/canvas/public/apps/export/export/index.js +++ b/x-pack/legacy/plugins/canvas/public/apps/export/export/index.js @@ -7,13 +7,13 @@ import { connect } from 'react-redux'; import { compose, branch, renderComponent } from 'recompose'; import { initializeWorkpad } from '../../../state/actions/workpad'; -import { getWorkpad, getSelectedPage } from '../../../state/selectors/workpad'; +import { getWorkpad, getSelectedPageIndex } from '../../../state/selectors/workpad'; import { LoadWorkpad } from './load_workpad'; import { ExportApp as Component } from './export_app'; const mapStateToProps = state => ({ workpad: getWorkpad(state), - selectedPageId: getSelectedPage(state), + selectedPageIndex: getSelectedPageIndex(state), }); const mapDispatchToProps = dispatch => ({ diff --git a/x-pack/legacy/plugins/reporting/export_types/common/constants.ts b/x-pack/legacy/plugins/reporting/export_types/common/constants.ts index 254cfbaa878bd..6c56c269017e2 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/constants.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/constants.ts @@ -9,4 +9,4 @@ export const LayoutTypes = { PRINT: 'print', }; -export const PAGELOAD_SELECTOR = '.application'; +export const DEFAULT_PAGELOAD_SELECTOR = '.application'; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts index 68d660257a56d..796bccb360ebd 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts @@ -98,9 +98,12 @@ describe('Screenshot Observable Pipeline', () => { return Promise.resolve(`allyourBase64 screenshots`); }); + const mockOpen = jest.fn(); + // mocks mockBrowserDriverFactory = await createMockBrowserDriverFactory(logger, { screenshot: mockScreenshot, + open: mockOpen, }); // test @@ -179,6 +182,15 @@ describe('Screenshot Observable Pipeline', () => { }, ] `); + + // ensures the correct selectors are waited on for multi URL jobs + expect(mockOpen.mock.calls.length).toBe(2); + + const firstSelector = mockOpen.mock.calls[0][1].waitForSelector; + expect(firstSelector).toBe('.application'); + + const secondSelector = mockOpen.mock.calls[1][1].waitForSelector; + expect(secondSelector).toBe('[data-shared-page="2"]'); }); describe('error handling', () => { diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts index c6861ae1d17ad..eb96753f0ce18 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts @@ -7,6 +7,7 @@ import * as Rx from 'rxjs'; import { catchError, concatMap, first, mergeMap, take, takeUntil, toArray } from 'rxjs/operators'; import { CaptureConfig } from '../../../../server/types'; +import { DEFAULT_PAGELOAD_SELECTOR } from '../../constants'; import { HeadlessChromiumDriverFactory } from '../../../../types'; import { getElementPositionAndAttributes } from './get_element_position_data'; import { getNumberOfItems } from './get_number_of_items'; @@ -44,13 +45,29 @@ export function screenshotsObservableFactory( { viewport: layout.getBrowserViewport(), browserTimezone }, logger ); - return Rx.from(urls).pipe( - concatMap(url => { - return create$.pipe( - mergeMap(({ driver, exit$ }) => { + + return create$.pipe( + mergeMap(({ driver, exit$ }) => { + return Rx.from(urls).pipe( + concatMap((url, index) => { const setup$: Rx.Observable = Rx.of(1).pipe( takeUntil(exit$), - mergeMap(() => openUrl(captureConfig, driver, url, conditionalHeaders, logger)), + mergeMap(() => { + // If we're moving to another page in the app, we'll want to wait for the app to tell us + // it's loaded the next page. + const page = index + 1; + const pageLoadSelector = + page > 1 ? `[data-shared-page="${page}"]` : DEFAULT_PAGELOAD_SELECTOR; + + return openUrl( + captureConfig, + driver, + url, + pageLoadSelector, + conditionalHeaders, + logger + ); + }), mergeMap(() => getNumberOfItems(captureConfig, driver, layout, logger)), mergeMap(async itemsCount => { const viewport = layout.getViewport(itemsCount) || getDefaultViewPort(); @@ -104,11 +121,11 @@ export function screenshotsObservableFactory( ) ); }), - first() + take(urls.length), + toArray() ); }), - take(urls.length), - toArray() + first() ); }; } diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts index a484dfb243563..92a58aded5f66 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts @@ -9,12 +9,12 @@ import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/br import { LevelLogger } from '../../../../server/lib'; import { CaptureConfig } from '../../../../server/types'; import { ConditionalHeaders } from '../../../../types'; -import { PAGELOAD_SELECTOR } from '../../constants'; export const openUrl = async ( captureConfig: CaptureConfig, browser: HeadlessBrowser, url: string, + pageLoadSelector: string, conditionalHeaders: ConditionalHeaders, logger: LevelLogger ): Promise => { @@ -23,7 +23,7 @@ export const openUrl = async ( url, { conditionalHeaders, - waitForSelector: PAGELOAD_SELECTOR, + waitForSelector: pageLoadSelector, timeout: captureConfig.timeouts.openUrl, }, logger diff --git a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts index dfaa87021c31c..dd20e849d97a9 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver/chromium_driver.ts @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; import { map, trunc } from 'lodash'; import open from 'opn'; -import { ElementHandle, EvaluateFn, Page, SerializableOrJSHandle } from 'puppeteer'; +import { ElementHandle, EvaluateFn, Page, SerializableOrJSHandle, Response } from 'puppeteer'; import { parse as parseUrl } from 'url'; import { ViewZoomWidthHeight } from '../../../../export_types/common/layouts/layout'; import { LevelLogger } from '../../../../server/lib'; @@ -45,6 +45,9 @@ export class HeadlessChromiumDriver { private readonly inspect: boolean; private readonly networkPolicy: NetworkPolicy; + private listenersAttached = false; + private interceptedCount = 0; + constructor(page: Page, { inspect, networkPolicy }: ChromiumDriverOptions) { this.page = page; this.inspect = inspect; @@ -76,103 +79,13 @@ export class HeadlessChromiumDriver { logger: LevelLogger ): Promise { logger.info(`opening url ${url}`); - // @ts-ignore - const client = this.page._client; - let interceptedCount = 0; - - await this.page.setRequestInterception(true); - - // We have to reach into the Chrome Devtools Protocol to apply headers as using - // puppeteer's API will cause map tile requests to hang indefinitely: - // https://github.com/puppeteer/puppeteer/issues/5003 - // Docs on this client/protocol can be found here: - // https://chromedevtools.github.io/devtools-protocol/tot/Fetch - client.on('Fetch.requestPaused', async (interceptedRequest: InterceptedRequest) => { - const { - requestId, - request: { url: interceptedUrl }, - } = interceptedRequest; - const allowed = !interceptedUrl.startsWith('file://'); - const isData = interceptedUrl.startsWith('data:'); - - // We should never ever let file protocol requests go through - if (!allowed || !this.allowRequest(interceptedUrl)) { - logger.error(`Got bad URL: "${interceptedUrl}", closing browser.`); - await client.send('Fetch.failRequest', { - errorReason: 'Aborted', - requestId, - }); - this.page.browser().close(); - throw new Error( - i18n.translate('xpack.reporting.chromiumDriver.disallowedOutgoingUrl', { - defaultMessage: `Received disallowed outgoing URL: "{interceptedUrl}", exiting`, - values: { interceptedUrl }, - }) - ); - } - - if (this._shouldUseCustomHeaders(conditionalHeaders.conditions, interceptedUrl)) { - logger.debug(`Using custom headers for ${interceptedUrl}`); - const headers = map( - { - ...interceptedRequest.request.headers, - ...conditionalHeaders.headers, - }, - (value, name) => ({ - name, - value, - }) - ); - - try { - await client.send('Fetch.continueRequest', { - requestId, - headers, - }); - } catch (err) { - logger.error( - i18n.translate('xpack.reporting.chromiumDriver.failedToCompleteRequestUsingHeaders', { - defaultMessage: 'Failed to complete a request using headers: {error}', - values: { error: err }, - }) - ); - } - } else { - const loggedUrl = isData ? this.truncateUrl(interceptedUrl) : interceptedUrl; - logger.debug(`No custom headers for ${loggedUrl}`); - try { - await client.send('Fetch.continueRequest', { requestId }); - } catch (err) { - logger.error( - i18n.translate('xpack.reporting.chromiumDriver.failedToCompleteRequest', { - defaultMessage: 'Failed to complete a request: {error}', - values: { error: err }, - }) - ); - } - } - interceptedCount = interceptedCount + (isData ? 0 : 1); - }); - // Even though 3xx redirects go through our request - // handler, we should probably inspect responses just to - // avoid being bamboozled by some malicious request - this.page.on('response', interceptedResponse => { - const interceptedUrl = interceptedResponse.url(); - const allowed = !interceptedUrl.startsWith('file://'); + // Reset intercepted request count + this.interceptedCount = 0; - if (!interceptedResponse.ok()) { - logger.warn( - `Chromium received a non-OK response (${interceptedResponse.status()}) for request ${interceptedUrl}` - ); - } + await this.page.setRequestInterception(true); - if (!allowed || !this.allowRequest(interceptedUrl)) { - logger.error(`Got disallowed URL "${interceptedUrl}", closing browser.`); - this.page.browser().close(); - throw new Error(`Received disallowed URL in response: ${interceptedUrl}`); - } - }); + this.registerListeners(conditionalHeaders, logger); await this.page.goto(url, { waitUntil: 'domcontentloaded' }); @@ -186,7 +99,7 @@ export class HeadlessChromiumDriver { { context: 'waiting for page load selector' }, logger ); - logger.info(`handled ${interceptedCount} page requests`); + logger.info(`handled ${this.interceptedCount} page requests`); } public async screenshot(elementPosition: ElementPosition): Promise { @@ -272,6 +185,111 @@ export class HeadlessChromiumDriver { }); } + private registerListeners(conditionalHeaders: ConditionalHeaders, logger: LevelLogger) { + if (this.listenersAttached) { + return; + } + + // @ts-ignore + const client = this.page._client; + + // We have to reach into the Chrome Devtools Protocol to apply headers as using + // puppeteer's API will cause map tile requests to hang indefinitely: + // https://github.com/puppeteer/puppeteer/issues/5003 + // Docs on this client/protocol can be found here: + // https://chromedevtools.github.io/devtools-protocol/tot/Fetch + client.on('Fetch.requestPaused', async (interceptedRequest: InterceptedRequest) => { + const { + requestId, + request: { url: interceptedUrl }, + } = interceptedRequest; + + const allowed = !interceptedUrl.startsWith('file://'); + const isData = interceptedUrl.startsWith('data:'); + + // We should never ever let file protocol requests go through + if (!allowed || !this.allowRequest(interceptedUrl)) { + logger.error(`Got bad URL: "${interceptedUrl}", closing browser.`); + await client.send('Fetch.failRequest', { + errorReason: 'Aborted', + requestId, + }); + this.page.browser().close(); + throw new Error( + i18n.translate('xpack.reporting.chromiumDriver.disallowedOutgoingUrl', { + defaultMessage: `Received disallowed outgoing URL: "{interceptedUrl}", exiting`, + values: { interceptedUrl }, + }) + ); + } + + if (this._shouldUseCustomHeaders(conditionalHeaders.conditions, interceptedUrl)) { + logger.debug(`Using custom headers for ${interceptedUrl}`); + const headers = map( + { + ...interceptedRequest.request.headers, + ...conditionalHeaders.headers, + }, + (value, name) => ({ + name, + value, + }) + ); + + try { + await client.send('Fetch.continueRequest', { + requestId, + headers, + }); + } catch (err) { + logger.error( + i18n.translate('xpack.reporting.chromiumDriver.failedToCompleteRequestUsingHeaders', { + defaultMessage: 'Failed to complete a request using headers: {error}', + values: { error: err }, + }) + ); + } + } else { + const loggedUrl = isData ? this.truncateUrl(interceptedUrl) : interceptedUrl; + logger.debug(`No custom headers for ${loggedUrl}`); + try { + await client.send('Fetch.continueRequest', { requestId }); + } catch (err) { + logger.error( + i18n.translate('xpack.reporting.chromiumDriver.failedToCompleteRequest', { + defaultMessage: 'Failed to complete a request: {error}', + values: { error: err }, + }) + ); + } + } + + this.interceptedCount = this.interceptedCount + (isData ? 0 : 1); + }); + + // Even though 3xx redirects go through our request + // handler, we should probably inspect responses just to + // avoid being bamboozled by some malicious request + this.page.on('response', (interceptedResponse: Response) => { + const interceptedUrl = interceptedResponse.url(); + const allowed = !interceptedUrl.startsWith('file://'); + + if (!interceptedResponse.ok()) { + logger.warn( + `Chromium received a non-OK response (${interceptedResponse.status()}) for request ${interceptedUrl}` + ); + } + + if (!allowed || !this.allowRequest(interceptedUrl)) { + logger.error(`Got disallowed URL "${interceptedUrl}", closing browser.`); + this.page.browser().close(); + throw new Error(`Received disallowed URL in response: ${interceptedUrl}`); + } + }); + + this.listenersAttached = true; + } + private async launchDebugger() { // In order to pause on execution we have to reach more deeply into Chromiums Devtools Protocol, // and more specifically, for the page being used. _client is per-page, and puppeteer doesn't expose diff --git a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts index 1be10f6a2056f..aafe17d970187 100644 --- a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts +++ b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts @@ -17,6 +17,7 @@ interface CreateMockBrowserDriverFactoryOpts { evaluate: jest.Mock, any[]>; waitForSelector: jest.Mock, any[]>; screenshot: jest.Mock, any[]>; + open: jest.Mock, any[]>; getCreatePage: (driver: HeadlessChromiumDriver) => jest.Mock; } @@ -87,6 +88,7 @@ const defaultOpts: CreateMockBrowserDriverFactoryOpts = { evaluate: mockBrowserEvaluate, waitForSelector: mockWaitForSelector, screenshot: mockScreenshot, + open: jest.fn(), getCreatePage, }; @@ -124,6 +126,7 @@ export const createMockBrowserDriverFactory = async ( mockBrowserDriver.waitForSelector = opts.waitForSelector ? opts.waitForSelector : defaultOpts.waitForSelector; // prettier-ignore mockBrowserDriver.evaluate = opts.evaluate ? opts.evaluate : defaultOpts.evaluate; mockBrowserDriver.screenshot = opts.screenshot ? opts.screenshot : defaultOpts.screenshot; + mockBrowserDriver.open = opts.open ? opts.open : defaultOpts.open; mockBrowserDriverFactory.createPage = opts.getCreatePage ? opts.getCreatePage(mockBrowserDriver) diff --git a/x-pack/plugins/apm/common/ml_job_constants.test.ts b/x-pack/plugins/apm/common/ml_job_constants.test.ts index 2aa50a305f7c8..4941925939afb 100644 --- a/x-pack/plugins/apm/common/ml_job_constants.test.ts +++ b/x-pack/plugins/apm/common/ml_job_constants.test.ts @@ -4,7 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getMlIndex, getMlJobId, getMlPrefix } from './ml_job_constants'; +import { + getMlIndex, + getMlJobId, + getMlPrefix, + getMlJobServiceName, + getSeverity, + severity +} from './ml_job_constants'; describe('ml_job_constants', () => { it('getMlPrefix', () => { @@ -38,4 +45,44 @@ describe('ml_job_constants', () => { '.ml-anomalies-myservicename-mytransactiontype-high_mean_response_time' ); }); + + describe('getMlJobServiceName', () => { + it('extracts the service name from a job id', () => { + expect( + getMlJobServiceName('opbeans-node-request-high_mean_response_time') + ).toEqual('opbeans-node'); + }); + }); + + describe('getSeverity', () => { + describe('when score is undefined', () => { + it('returns undefined', () => { + expect(getSeverity(undefined)).toEqual(undefined); + }); + }); + + describe('when score < 25', () => { + it('returns warning', () => { + expect(getSeverity(10)).toEqual(severity.warning); + }); + }); + + describe('when score is between 25 and 50', () => { + it('returns minor', () => { + expect(getSeverity(40)).toEqual(severity.minor); + }); + }); + + describe('when score is between 50 and 75', () => { + it('returns major', () => { + expect(getSeverity(60)).toEqual(severity.major); + }); + }); + + describe('when score is 75 or more', () => { + it('returns critical', () => { + expect(getSeverity(100)).toEqual(severity.critical); + }); + }); + }); }); diff --git a/x-pack/plugins/apm/common/ml_job_constants.ts b/x-pack/plugins/apm/common/ml_job_constants.ts index 01f5762e2dc4b..afe0550721716 100644 --- a/x-pack/plugins/apm/common/ml_job_constants.ts +++ b/x-pack/plugins/apm/common/ml_job_constants.ts @@ -4,6 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ +export enum severity { + critical = 'critical', + major = 'major', + minor = 'minor', + warning = 'warning' +} + export function getMlPrefix(serviceName: string, transactionType?: string) { const maybeTransactionType = transactionType ? `${transactionType}-` : ''; return encodeForMlApi(`${serviceName}-${maybeTransactionType}`); @@ -13,6 +20,13 @@ export function getMlJobId(serviceName: string, transactionType?: string) { return `${getMlPrefix(serviceName, transactionType)}high_mean_response_time`; } +export function getMlJobServiceName(jobId: string) { + return jobId + .split('-') + .slice(0, -2) + .join('-'); +} + export function getMlIndex(serviceName: string, transactionType?: string) { return `.ml-anomalies-${getMlJobId(serviceName, transactionType)}`; } @@ -20,3 +34,19 @@ export function getMlIndex(serviceName: string, transactionType?: string) { export function encodeForMlApi(value: string) { return value.replace(/\s+/g, '_').toLowerCase(); } + +export function getSeverity(score?: number) { + if (typeof score !== 'number') { + return undefined; + } else if (score < 25) { + return severity.warning; + } else if (score >= 25 && score < 50) { + return severity.minor; + } else if (score >= 50 && score < 75) { + return severity.major; + } else if (score >= 75) { + return severity.critical; + } else { + return undefined; + } +} diff --git a/x-pack/plugins/apm/kibana.json b/x-pack/plugins/apm/kibana.json index 1a0ad67c7b696..85e3761129018 100644 --- a/x-pack/plugins/apm/kibana.json +++ b/x-pack/plugins/apm/kibana.json @@ -15,7 +15,8 @@ "usageCollection", "taskManager", "actions", - "alerting" + "alerting", + "security" ], "server": true, "ui": true, diff --git a/x-pack/plugins/apm/public/components/app/APMIndicesPermission/__test__/APMIndicesPermission.test.tsx b/x-pack/plugins/apm/public/components/app/APMIndicesPermission/index.test.tsx similarity index 63% rename from x-pack/plugins/apm/public/components/app/APMIndicesPermission/__test__/APMIndicesPermission.test.tsx rename to x-pack/plugins/apm/public/components/app/APMIndicesPermission/index.test.tsx index 68acaee4abe5d..b3f90fd9aee34 100644 --- a/x-pack/plugins/apm/public/components/app/APMIndicesPermission/__test__/APMIndicesPermission.test.tsx +++ b/x-pack/plugins/apm/public/components/app/APMIndicesPermission/index.test.tsx @@ -4,16 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; +import { render, fireEvent, act } from '@testing-library/react'; import { shallow } from 'enzyme'; -import { APMIndicesPermission } from '../'; +import { APMIndicesPermission } from './'; -import * as hooks from '../../../../hooks/useFetcher'; +import * as hooks from '../../../hooks/useFetcher'; import { expectTextsInDocument, expectTextsNotInDocument -} from '../../../../utils/testHelpers'; -import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; +} from '../../../utils/testHelpers'; +import { MockApmPluginContextWrapper } from '../../../context/ApmPluginContext/MockApmPluginContext'; describe('APMIndicesPermission', () => { it('returns empty component when api status is loading', () => { @@ -34,7 +34,10 @@ describe('APMIndicesPermission', () => { spyOn(hooks, 'useFetcher').and.returnValue({ status: hooks.FETCH_STATUS.SUCCESS, data: { - 'apm-*': { read: false } + has_all_requested: false, + index: { + 'apm-*': { read: false } + } } }); const component = render( @@ -48,39 +51,32 @@ describe('APMIndicesPermission', () => { 'apm-*' ]); }); - it('shows escape hatch button when at least one indice has read privileges', () => { + + it('shows children component when no index is returned', () => { spyOn(hooks, 'useFetcher').and.returnValue({ status: hooks.FETCH_STATUS.SUCCESS, data: { - 'apm-7.5.1-error-*': { read: false }, - 'apm-7.5.1-metric-*': { read: false }, - 'apm-7.5.1-transaction-*': { read: false }, - 'apm-7.5.1-span-*': { read: true } + has_all_requested: false, + index: {} } }); const component = render( - + +

My amazing component

+
); - expectTextsInDocument(component, [ - 'Missing permissions to access APM', - 'apm-7.5.1-error-*', - 'apm-7.5.1-metric-*', - 'apm-7.5.1-transaction-*', - 'Dismiss' - ]); - expectTextsNotInDocument(component, ['apm-7.5.1-span-*']); + expectTextsNotInDocument(component, ['Missing permissions to access APM']); + expectTextsInDocument(component, ['My amazing component']); }); it('shows children component when indices have read privileges', () => { spyOn(hooks, 'useFetcher').and.returnValue({ status: hooks.FETCH_STATUS.SUCCESS, data: { - 'apm-7.5.1-error-*': { read: true }, - 'apm-7.5.1-metric-*': { read: true }, - 'apm-7.5.1-transaction-*': { read: true }, - 'apm-7.5.1-span-*': { read: true } + has_all_requested: true, + index: {} } }); const component = render( @@ -90,13 +86,7 @@ describe('APMIndicesPermission', () => { ); - expectTextsNotInDocument(component, [ - 'Missing permissions to access APM', - 'apm-7.5.1-error-*', - 'apm-7.5.1-metric-*', - 'apm-7.5.1-transaction-*', - 'apm-7.5.1-span-*' - ]); + expectTextsNotInDocument(component, ['Missing permissions to access APM']); expectTextsInDocument(component, ['My amazing component']); }); @@ -104,10 +94,13 @@ describe('APMIndicesPermission', () => { spyOn(hooks, 'useFetcher').and.returnValue({ status: hooks.FETCH_STATUS.SUCCESS, data: { - 'apm-7.5.1-error-*': { read: false }, - 'apm-7.5.1-metric-*': { read: false }, - 'apm-7.5.1-transaction-*': { read: false }, - 'apm-7.5.1-span-*': { read: true } + has_all_requested: false, + index: { + 'apm-error-*': { read: false }, + 'apm-trasanction-*': { read: false }, + 'apm-metrics-*': { read: true }, + 'apm-span-*': { read: true } + } } }); const component = render( @@ -117,8 +110,33 @@ describe('APMIndicesPermission', () => { ); - expectTextsInDocument(component, ['Dismiss']); - fireEvent.click(component.getByText('Dismiss')); + expectTextsInDocument(component, [ + 'Dismiss', + 'apm-error-*', + 'apm-trasanction-*' + ]); + act(() => { + fireEvent.click(component.getByText('Dismiss')); + }); + expectTextsInDocument(component, ['My amazing component']); + }); + + it("shows children component when api doesn't return value", () => { + spyOn(hooks, 'useFetcher').and.returnValue({}); + const component = render( + + +

My amazing component

+
+
+ ); + expectTextsNotInDocument(component, [ + 'Missing permissions to access APM', + 'apm-7.5.1-error-*', + 'apm-7.5.1-metric-*', + 'apm-7.5.1-transaction-*', + 'apm-7.5.1-span-*' + ]); expectTextsInDocument(component, ['My amazing component']); }); }); diff --git a/x-pack/plugins/apm/public/components/app/APMIndicesPermission/index.tsx b/x-pack/plugins/apm/public/components/app/APMIndicesPermission/index.tsx index 40e039dcd40c5..9074726f76e6d 100644 --- a/x-pack/plugins/apm/public/components/app/APMIndicesPermission/index.tsx +++ b/x-pack/plugins/apm/public/components/app/APMIndicesPermission/index.tsx @@ -15,9 +15,9 @@ import { EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { isEmpty } from 'lodash'; import React, { useState } from 'react'; import styled from 'styled-components'; +import { isEmpty } from 'lodash'; import { FETCH_STATUS, useFetcher } from '../../../hooks/useFetcher'; import { fontSize, pct, px, units } from '../../../style/variables'; import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink'; @@ -29,7 +29,7 @@ export const APMIndicesPermission: React.FC = ({ children }) => { setIsPermissionWarningDismissed ] = useState(false); - const { data: indicesPrivileges = {}, status } = useFetcher(callApmApi => { + const { data: indicesPrivileges, status } = useFetcher(callApmApi => { return callApmApi({ pathname: '/api/apm/security/indices_privileges' }); @@ -40,13 +40,17 @@ export const APMIndicesPermission: React.FC = ({ children }) => { return null; } - const indicesWithoutPermission = Object.keys(indicesPrivileges).filter( - index => !indicesPrivileges[index].read - ); - // Show permission warning when a user has at least one index without Read privilege, - // and he has not manually dismissed the warning - if (!isEmpty(indicesWithoutPermission) && !isPermissionWarningDismissed) { + // and they have not manually dismissed the warning + if ( + indicesPrivileges && + !indicesPrivileges.has_all_requested && + !isEmpty(indicesPrivileges.index) && + !isPermissionWarningDismissed + ) { + const indicesWithoutPermission = Object.keys( + indicesPrivileges.index + ).filter(index => !indicesPrivileges.index[index].read); return ( { + const elements = [ + { data: { id: 'undefined', 'service.name': 'severity: undefined' } }, + { + data: { + id: 'warning', + 'service.name': 'severity: warning', + severity: 'warning' + } + }, + { + data: { + id: 'minor', + 'service.name': 'severity: minor', + severity: 'minor' + } + }, + { + data: { + id: 'major', + 'service.name': 'severity: major', + severity: 'major' + } + }, + { + data: { + id: 'critical', + 'service.name': 'severity: critical', + severity: 'critical' + } + } + ]; + return ; + }, + { + info: { propTables: false, source: false } + } +); diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts b/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts index 554f84f0ad236..3bb4319d0722d 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts @@ -10,8 +10,52 @@ import { SERVICE_NAME, SPAN_DESTINATION_SERVICE_RESOURCE } from '../../../../common/elasticsearch_fieldnames'; +import { severity } from '../../../../common/ml_job_constants'; import { defaultIcon, iconForNode } from './icons'; +const getBorderColor = (el: cytoscape.NodeSingular) => { + const nodeSeverity = el.data('severity'); + + switch (nodeSeverity) { + case severity.warning: + return theme.euiColorVis0; + case severity.minor || severity.major: + return theme.euiColorVis5; + case severity.critical: + return theme.euiColorVis9; + default: + if (el.hasClass('primary') || el.selected()) { + return theme.euiColorPrimary; + } else { + return theme.euiColorMediumShade; + } + } +}; + +const getBorderStyle: cytoscape.Css.MapperFunction< + cytoscape.NodeSingular, + cytoscape.Css.LineStyle +> = (el: cytoscape.NodeSingular) => { + const nodeSeverity = el.data('severity'); + if (nodeSeverity === severity.critical) { + return 'double'; + } else { + return 'solid'; + } +}; + +const getBorderWidth = (el: cytoscape.NodeSingular) => { + const nodeSeverity = el.data('severity'); + + if (nodeSeverity === severity.minor || nodeSeverity === severity.major) { + return 4; + } else if (nodeSeverity === severity.critical) { + return 12; + } else { + return 2; + } +}; + // IE 11 does not properly load some SVGs or draw certain shapes. This causes // a runtime error and the map fails work at all. We would prefer to do some // kind of feature detection rather than browser detection, but some of these @@ -55,11 +99,9 @@ const style: cytoscape.Stylesheet[] = [ isService(el) ? '60%' : '40%', 'background-width': (el: cytoscape.NodeSingular) => isService(el) ? '60%' : '40%', - 'border-color': (el: cytoscape.NodeSingular) => - el.hasClass('primary') || el.selected() - ? theme.euiColorPrimary - : theme.euiColorMediumShade, - 'border-width': 2, + 'border-color': getBorderColor, + 'border-style': getBorderStyle, + 'border-width': getBorderWidth, color: (el: cytoscape.NodeSingular) => el.hasClass('primary') || el.selected() ? theme.euiColorPrimaryText @@ -149,7 +191,7 @@ const style: cytoscape.Stylesheet[] = [ { selector: 'node.hover', style: { - 'border-width': 2 + 'border-width': getBorderWidth } }, { diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.test.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.test.tsx index 2f4d9a4c4016d..a1afbd7a807cc 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CustomLinkFlyout/LinkPreview.test.tsx @@ -5,9 +5,25 @@ */ import React from 'react'; import { LinkPreview } from '../CustomLinkFlyout/LinkPreview'; -import { render, getNodeText, getByTestId, act } from '@testing-library/react'; +import { + render, + getNodeText, + getByTestId, + act, + wait +} from '@testing-library/react'; +import * as apmApi from '../../../../../../services/rest/createCallApmApi'; describe('LinkPreview', () => { + let callApmApiSpy: jasmine.Spy; + beforeAll(() => { + callApmApiSpy = spyOn(apmApi, 'callApmApi').and.returnValue({ + transaction: { id: 'foo' } + }); + }); + afterAll(() => { + jest.clearAllMocks(); + }); const getElementValue = (container: HTMLElement, id: string) => getNodeText( ((getByTestId(container, id) as HTMLDivElement) @@ -42,7 +58,7 @@ describe('LinkPreview', () => { }); }); - it('shows warning when couldnt replace context variables', () => { + it("shows warning when couldn't replace context variables", () => { act(() => { const { container } = render( { expect(getByTestId(container, 'preview-warning')).toBeInTheDocument(); }); }); + it('replaces url with transaction id', async () => { + const { container } = render( + + ); + await wait(() => expect(callApmApiSpy).toHaveBeenCalled()); + expect(getElementValue(container, 'preview-label')).toEqual('foo'); + expect( + (getByTestId(container, 'preview-link') as HTMLAnchorElement).text + ).toEqual('https://baz.co?transaction=foo'); + }); }); diff --git a/x-pack/plugins/apm/server/lib/helpers/es_client.ts b/x-pack/plugins/apm/server/lib/helpers/es_client.ts index de9ab87b69fc8..45a7eca46caba 100644 --- a/x-pack/plugins/apm/server/lib/helpers/es_client.ts +++ b/x-pack/plugins/apm/server/lib/helpers/es_client.ts @@ -27,9 +27,8 @@ import { getApmIndices } from '../settings/apm_indices/get_apm_indices'; // `type` was deprecated in 7.0 export type APMIndexDocumentParams = Omit, 'type'>; -interface IndexPrivileges { +export interface IndexPrivileges { has_all_requested: boolean; - username: string; index: Record; } diff --git a/x-pack/plugins/apm/server/lib/security/get_indices_privileges.test.ts b/x-pack/plugins/apm/server/lib/security/get_indices_privileges.test.ts new file mode 100644 index 0000000000000..c1bc48f4ed1fd --- /dev/null +++ b/x-pack/plugins/apm/server/lib/security/get_indices_privileges.test.ts @@ -0,0 +1,148 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { Setup } from '../helpers/setup_request'; +import { getIndicesPrivileges } from './get_indices_privileges'; + +describe('getIndicesPrivileges', () => { + const indices = { + apm_oss: { + errorIndices: 'apm-*', + metricsIndices: 'apm-*', + transactionIndices: 'apm-*', + spanIndices: 'apm-*' + } + }; + it('return that the user has privileges when security plugin is disabled', async () => { + const setup = ({ + indices, + client: { + hasPrivileges: () => { + const error = { + message: + 'no handler found for uri [/_security/user/_has_privileges]', + statusCode: 400 + }; + throw error; + } + } + } as unknown) as Setup; + const privileges = await getIndicesPrivileges({ + setup, + isSecurityPluginEnabled: false + }); + expect(privileges).toEqual({ + has_all_requested: true, + index: {} + }); + }); + it('throws when an error happens while fetching indices privileges', async () => { + const setup = ({ + indices, + client: { + hasPrivileges: () => { + throw new Error('unknow error'); + } + } + } as unknown) as Setup; + await expect( + getIndicesPrivileges({ setup, isSecurityPluginEnabled: true }) + ).rejects.toThrowError('unknow error'); + }); + it("has privileges to read from 'apm-*'", async () => { + const setup = ({ + indices, + client: { + hasPrivileges: () => { + return Promise.resolve({ + has_all_requested: true, + index: { 'apm-*': { read: true } } + }); + } + } + } as unknown) as Setup; + const privileges = await getIndicesPrivileges({ + setup, + isSecurityPluginEnabled: true + }); + + expect(privileges).toEqual({ + has_all_requested: true, + index: { + 'apm-*': { + read: true + } + } + }); + }); + + it("doesn't have privileges to read from 'apm-*'", async () => { + const setup = ({ + indices, + client: { + hasPrivileges: () => { + return Promise.resolve({ + has_all_requested: false, + index: { 'apm-*': { read: false } } + }); + } + } + } as unknown) as Setup; + + const privileges = await getIndicesPrivileges({ + setup, + isSecurityPluginEnabled: true + }); + + expect(privileges).toEqual({ + has_all_requested: false, + index: { + 'apm-*': { + read: false + } + } + }); + }); + it("doesn't have privileges on multiple indices", async () => { + const setup = ({ + indices: { + apm_oss: { + errorIndices: 'apm-error-*', + metricsIndices: 'apm-metrics-*', + transactionIndices: 'apm-trasanction-*', + spanIndices: 'apm-span-*' + } + }, + client: { + hasPrivileges: () => { + return Promise.resolve({ + has_all_requested: false, + index: { + 'apm-error-*': { read: false }, + 'apm-trasanction-*': { read: false }, + 'apm-metrics-*': { read: true }, + 'apm-span-*': { read: true } + } + }); + } + } + } as unknown) as Setup; + + const privileges = await getIndicesPrivileges({ + setup, + isSecurityPluginEnabled: true + }); + + expect(privileges).toEqual({ + has_all_requested: false, + index: { + 'apm-error-*': { read: false }, + 'apm-trasanction-*': { read: false }, + 'apm-metrics-*': { read: true }, + 'apm-span-*': { read: true } + } + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/security/get_indices_privileges.ts b/x-pack/plugins/apm/server/lib/security/get_indices_privileges.ts index 1a80a13b2ad19..46ed64f518bb8 100644 --- a/x-pack/plugins/apm/server/lib/security/get_indices_privileges.ts +++ b/x-pack/plugins/apm/server/lib/security/get_indices_privileges.ts @@ -4,8 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ import { Setup } from '../helpers/setup_request'; +import { IndexPrivileges } from '../helpers/es_client'; + +export async function getIndicesPrivileges({ + setup, + isSecurityPluginEnabled +}: { + setup: Setup; + isSecurityPluginEnabled: boolean; +}): Promise { + // When security plugin is not enabled, returns that the user has all requested privileges. + if (!isSecurityPluginEnabled) { + return { has_all_requested: true, index: {} }; + } -export async function getIndicesPrivileges(setup: Setup) { const { client, indices } = setup; const response = await client.hasPrivileges({ index: [ @@ -20,5 +32,5 @@ export async function getIndicesPrivileges(setup: Setup) { } ] }); - return response.index; + return response; } diff --git a/x-pack/plugins/apm/server/lib/security/get_permissions.ts b/x-pack/plugins/apm/server/lib/security/get_permissions.ts deleted file mode 100644 index ed2a1f64e7f84..0000000000000 --- a/x-pack/plugins/apm/server/lib/security/get_permissions.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { Setup } from '../helpers/setup_request'; - -export async function getPermissions(setup: Setup) { - const { client, indices } = setup; - - const params = { - index: Object.values(indices), - body: { - size: 0, - query: { - match_all: {} - } - } - }; - - try { - await client.search(params); - return { hasPermission: true }; - } catch (e) { - // If 403, it means the user doesnt have permission. - if (e.status === 403) { - return { hasPermission: false }; - } - // if any other error happens, throw it. - throw e; - } -} diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts index 17b595385a84e..adb2c9b7cb084 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts @@ -17,6 +17,8 @@ import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { dedupeConnections } from './dedupe_connections'; import { getServiceMapFromTraceIds } from './get_service_map_from_trace_ids'; import { getTraceSampleIds } from './get_trace_sample_ids'; +import { addAnomaliesToServicesData } from './ml_helpers'; +import { getMlIndex } from '../../../common/ml_job_constants'; export interface IEnvOptions { setup: Setup & SetupTimeRange; @@ -137,19 +139,58 @@ async function getServicesData(options: IEnvOptions) { ); } +function getAnomaliesData(options: IEnvOptions) { + const { client } = options.setup; + + const params = { + index: getMlIndex('*'), + body: { + size: 0, + query: { + exists: { + field: 'bucket_span' + } + }, + aggs: { + jobs: { + terms: { + field: 'job_id', + size: 10 + }, + aggs: { + max_score: { + max: { + field: 'anomaly_score' + } + } + } + } + } + } + }; + + return client.search(params); +} + +export type AnomaliesResponse = PromiseReturnType; export type ConnectionsResponse = PromiseReturnType; export type ServicesResponse = PromiseReturnType; - export type ServiceMapAPIResponse = PromiseReturnType; export async function getServiceMap(options: IEnvOptions) { - const [connectionData, servicesData] = await Promise.all([ + const [connectionData, servicesData, anomaliesData] = await Promise.all([ getConnectionData(options), - getServicesData(options) + getServicesData(options), + getAnomaliesData(options) ]); + const servicesDataWithAnomalies = addAnomaliesToServicesData( + servicesData, + anomaliesData + ); + return dedupeConnections({ ...connectionData, - services: servicesData + services: servicesDataWithAnomalies }); } diff --git a/x-pack/plugins/apm/server/lib/service_map/ml_helpers.test.ts b/x-pack/plugins/apm/server/lib/service_map/ml_helpers.test.ts new file mode 100644 index 0000000000000..c6680ecd6375b --- /dev/null +++ b/x-pack/plugins/apm/server/lib/service_map/ml_helpers.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AnomaliesResponse } from './get_service_map'; +import { addAnomaliesToServicesData } from './ml_helpers'; + +describe('addAnomaliesToServicesData', () => { + it('adds anomalies to services data', () => { + const servicesData = [ + { + 'service.name': 'opbeans-ruby', + 'agent.name': 'ruby', + 'service.environment': null, + 'service.framework.name': 'Ruby on Rails' + }, + { + 'service.name': 'opbeans-java', + 'agent.name': 'java', + 'service.environment': null, + 'service.framework.name': null + } + ]; + + const anomaliesResponse = { + aggregations: { + jobs: { + buckets: [ + { + key: 'opbeans-ruby-request-high_mean_response_time', + max_score: { value: 50 } + }, + { + key: 'opbeans-java-request-high_mean_response_time', + max_score: { value: 100 } + } + ] + } + } + }; + + const result = [ + { + 'service.name': 'opbeans-ruby', + 'agent.name': 'ruby', + 'service.environment': null, + 'service.framework.name': 'Ruby on Rails', + max_score: 50, + severity: 'major' + }, + { + 'service.name': 'opbeans-java', + 'agent.name': 'java', + 'service.environment': null, + 'service.framework.name': null, + max_score: 100, + severity: 'critical' + } + ]; + + expect( + addAnomaliesToServicesData( + servicesData, + (anomaliesResponse as unknown) as AnomaliesResponse + ) + ).toEqual(result); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/service_map/ml_helpers.ts b/x-pack/plugins/apm/server/lib/service_map/ml_helpers.ts new file mode 100644 index 0000000000000..26a964bfb4dd2 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/service_map/ml_helpers.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SERVICE_NAME } from '../../../common/elasticsearch_fieldnames'; +import { + getMlJobServiceName, + getSeverity +} from '../../../common/ml_job_constants'; +import { AnomaliesResponse, ServicesResponse } from './get_service_map'; + +export function addAnomaliesToServicesData( + servicesData: ServicesResponse, + anomaliesResponse: AnomaliesResponse +) { + const anomaliesMap = ( + anomaliesResponse.aggregations?.jobs.buckets ?? [] + ).reduce<{ + [key: string]: { max_score?: number }; + }>((previousValue, currentValue) => { + const key = getMlJobServiceName(currentValue.key.toString()); + + return { + ...previousValue, + [key]: { + max_score: Math.max( + previousValue[key]?.max_score ?? 0, + currentValue.max_score.value ?? 0 + ) + } + }; + }, {}); + + const servicesDataWithAnomalies = servicesData.map(service => { + const score = anomaliesMap[service[SERVICE_NAME]]?.max_score; + + return { + ...service, + max_score: score, + severity: getSeverity(score) + }; + }); + + return servicesDataWithAnomalies; +} diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index 8cf29de5b8b73..29ab618cbdd0a 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -12,6 +12,7 @@ import { } from 'src/core/server'; import { Observable, combineLatest } from 'rxjs'; import { map, take } from 'rxjs/operators'; +import { SecurityPluginSetup } from '../../security/public'; import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; import { TaskManagerSetupContract } from '../../task_manager/server'; import { AlertingPlugin } from '../../alerting/server'; @@ -57,6 +58,7 @@ export class APMPlugin implements Plugin { alerting?: AlertingPlugin['setup']; actions?: ActionsPlugin['setup']; features: FeaturesPluginSetup; + security?: SecurityPluginSetup; } ) { this.logger = this.initContext.logger.get(); @@ -110,7 +112,10 @@ export class APMPlugin implements Plugin { createApmApi().init(core, { config$: mergedConfig$, - logger: this.logger! + logger: this.logger!, + plugins: { + security: plugins.security + } }); return { diff --git a/x-pack/plugins/apm/server/routes/create_api/index.test.ts b/x-pack/plugins/apm/server/routes/create_api/index.test.ts index 20c586868a979..6236fcb0a6942 100644 --- a/x-pack/plugins/apm/server/routes/create_api/index.test.ts +++ b/x-pack/plugins/apm/server/routes/create_api/index.test.ts @@ -39,7 +39,8 @@ const getCoreMock = () => { config$: new BehaviorSubject({} as APMConfig), logger: ({ error: jest.fn() - } as unknown) as Logger + } as unknown) as Logger, + plugins: {} } }; }; diff --git a/x-pack/plugins/apm/server/routes/create_api/index.ts b/x-pack/plugins/apm/server/routes/create_api/index.ts index a97e2f30fc2b6..9b611a0bbd6bc 100644 --- a/x-pack/plugins/apm/server/routes/create_api/index.ts +++ b/x-pack/plugins/apm/server/routes/create_api/index.ts @@ -30,7 +30,7 @@ export function createApi() { factoryFns.push(fn); return this as any; }, - init(core, { config$, logger }) { + init(core, { config$, logger, plugins }) { const router = core.http.createRouter(); let config = {} as APMConfig; @@ -141,7 +141,8 @@ export function createApi() { // it's not defined in the route. params: pick(parsedParams, ...Object.keys(params), 'query'), config, - logger + logger, + plugins } }); diff --git a/x-pack/plugins/apm/server/routes/security.ts b/x-pack/plugins/apm/server/routes/security.ts index 0a8222b665d83..1e2a302ab9a4a 100644 --- a/x-pack/plugins/apm/server/routes/security.ts +++ b/x-pack/plugins/apm/server/routes/security.ts @@ -12,6 +12,10 @@ export const indicesPrivilegesRoute = createRoute(() => ({ path: '/api/apm/security/indices_privileges', handler: async ({ context, request }) => { const setup = await setupRequest(context, request); - return getIndicesPrivileges(setup); + return getIndicesPrivileges({ + setup, + isSecurityPluginEnabled: + context.plugins.security?.license.isEnabled() ?? false + }); } })); diff --git a/x-pack/plugins/apm/server/routes/typings.ts b/x-pack/plugins/apm/server/routes/typings.ts index 6543f2015599b..e049255eb8ec8 100644 --- a/x-pack/plugins/apm/server/routes/typings.ts +++ b/x-pack/plugins/apm/server/routes/typings.ts @@ -16,6 +16,7 @@ import { Observable } from 'rxjs'; import { Server } from 'hapi'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { FetchOptions } from '../../public/services/rest/callApi'; +import { SecurityPluginSetup } from '../../../security/public'; import { APMConfig } from '..'; export interface Params { @@ -62,6 +63,9 @@ export type APMRequestHandlerContext< params: { query: { _debug: boolean } } & TDecodedParams; config: APMConfig; logger: Logger; + plugins: { + security?: SecurityPluginSetup; + }; }; export type RouteFactoryFn< @@ -105,6 +109,9 @@ export interface ServerAPI { context: { config$: Observable; logger: Logger; + plugins: { + security?: SecurityPluginSetup; + }; } ) => void; } diff --git a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js index 67f42fb622bf8..ef4a511f276bd 100644 --- a/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js +++ b/x-pack/plugins/cross_cluster_replication/public/app/sections/home/follower_indices_list/components/follower_indices_table/follower_indices_table.js @@ -55,11 +55,11 @@ export class FollowerIndicesTable extends PureComponent { if (queryText) { return followerIndices.filter(followerIndex => { - const { name, shards } = followerIndex; + const { name, remoteCluster, leaderIndex } = followerIndex; const inName = name.toLowerCase().includes(queryText); - const inRemoteCluster = shards[0].remoteCluster.toLowerCase().includes(queryText); - const inLeaderIndex = shards[0].leaderIndex.toLowerCase().includes(queryText); + const inRemoteCluster = remoteCluster.toLowerCase().includes(queryText); + const inLeaderIndex = leaderIndex.toLowerCase().includes(queryText); return inName || inRemoteCluster || inLeaderIndex; }); @@ -273,7 +273,7 @@ export class FollowerIndicesTable extends PureComponent { }; const selection = { - onSelectionChange: selectedItems => this.setState({ selectedItems }), + onSelectionChange: newSelectedItems => this.setState({ selectedItems: newSelectedItems }), }; const search = { diff --git a/x-pack/plugins/cross_cluster_replication/public/plugin.ts b/x-pack/plugins/cross_cluster_replication/public/plugin.ts index bdaa04e9d53ee..dfe9e4e657c30 100644 --- a/x-pack/plugins/cross_cluster_replication/public/plugin.ts +++ b/x-pack/plugins/cross_cluster_replication/public/plugin.ts @@ -39,7 +39,7 @@ export class CrossClusterReplicationPlugin implements Plugin { const ccrApp = esSection!.registerApp({ id: MANAGEMENT_ID, title: PLUGIN.TITLE, - order: 4, + order: 6, mount: async ({ element, setBreadcrumbs }) => { const { mountApp } = await import('./app'); diff --git a/x-pack/plugins/endpoint/common/generate_data.test.ts b/x-pack/plugins/endpoint/common/generate_data.test.ts index 88e1c66ea3e82..e1a2401849301 100644 --- a/x-pack/plugins/endpoint/common/generate_data.test.ts +++ b/x-pack/plugins/endpoint/common/generate_data.test.ts @@ -45,6 +45,17 @@ describe('data generator', () => { expect(metadata.host).not.toBeNull(); }); + it('creates policy response documents', () => { + const timestamp = new Date().getTime(); + const hostPolicyResponse = generator.generatePolicyResponse(timestamp); + expect(hostPolicyResponse['@timestamp']).toEqual(timestamp); + expect(hostPolicyResponse.event.created).toEqual(timestamp); + expect(hostPolicyResponse.endpoint).not.toBeNull(); + expect(hostPolicyResponse.agent).not.toBeNull(); + expect(hostPolicyResponse.host).not.toBeNull(); + expect(hostPolicyResponse.endpoint.policy.applied).not.toBeNull(); + }); + it('creates alert event documents', () => { const timestamp = new Date().getTime(); const alert = generator.generateAlert(timestamp); diff --git a/x-pack/plugins/endpoint/common/generate_data.ts b/x-pack/plugins/endpoint/common/generate_data.ts index e40fc3e386bc8..840574063d3f3 100644 --- a/x-pack/plugins/endpoint/common/generate_data.ts +++ b/x-pack/plugins/endpoint/common/generate_data.ts @@ -12,9 +12,10 @@ import { Host, HostMetadata, HostOS, - PolicyData, HostPolicyResponse, + HostPolicyResponseActions, HostPolicyResponseActionStatus, + PolicyData, } from './types'; import { factory as policyFactory } from './models/policy_config'; @@ -136,6 +137,13 @@ export class EndpointDocGenerator { this.commonInfo.host.ip = this.randomArray(3, () => this.randomIP()); } + /** + * Creates new random policy id for the host to simulate new policy application + */ + public updatePolicyId() { + this.commonInfo.endpoint.policy.id = this.randomChoice(POLICIES).id; + } + private createHostData(): HostInfo { return { agent: { @@ -498,106 +506,145 @@ export class EndpointDocGenerator { /** * Generates a Host Policy response message */ - generatePolicyResponse(): HostPolicyResponse { + public generatePolicyResponse(ts = new Date().getTime()): HostPolicyResponse { + const policyVersion = this.seededUUIDv4(); return { - '@timestamp': new Date().toISOString(), + '@timestamp': ts, + agent: { + id: this.commonInfo.agent.id, + version: '1.0.0-local.20200416.0', + }, elastic: { agent: { - id: 'c2a9093e-e289-4c0a-aa44-8c32a414fa7a', + id: this.commonInfo.elastic.agent.id, }, }, ecs: { - version: '1.0.0', - }, - event: { - created: '2015-01-01T12:10:30Z', - kind: 'policy_response', + version: '1.4.0', }, - agent: { - version: '6.0.0-rc2', - id: '8a4f500d', + host: { + id: this.commonInfo.host.id, }, endpoint: { - artifacts: { - 'global-manifest': { - version: '1.2.3', - sha256: 'abcdef', - }, - 'endpointpe-v4-windows': { - version: '1.2.3', - sha256: 'abcdef', - }, - 'user-whitelist-windows': { - version: '1.2.3', - sha256: 'abcdef', - }, - 'global-whitelist-windows': { - version: '1.2.3', - sha256: 'abcdef', - }, - }, policy: { applied: { - version: '1.0.0', - id: '17d4b81d-9940-4b64-9de5-3e03ef1fb5cf', - status: HostPolicyResponseActionStatus.success, + actions: { + configure_elasticsearch_connection: { + message: 'elasticsearch comms configured successfully', + status: HostPolicyResponseActionStatus.success, + }, + configure_kernel: { + message: 'Failed to configure kernel', + status: HostPolicyResponseActionStatus.failure, + }, + configure_logging: { + message: 'Successfully configured logging', + status: HostPolicyResponseActionStatus.success, + }, + configure_malware: { + message: 'Unexpected error configuring malware', + status: HostPolicyResponseActionStatus.failure, + }, + connect_kernel: { + message: 'Successfully initialized minifilter', + status: HostPolicyResponseActionStatus.success, + }, + detect_file_open_events: { + message: 'Successfully stopped file open event reporting', + status: HostPolicyResponseActionStatus.success, + }, + detect_file_write_events: { + message: 'Failed to stop file write event reporting', + status: HostPolicyResponseActionStatus.success, + }, + detect_image_load_events: { + message: 'Successfuly started image load event reporting', + status: HostPolicyResponseActionStatus.success, + }, + detect_process_events: { + message: 'Successfully started process event reporting', + status: HostPolicyResponseActionStatus.success, + }, + download_global_artifacts: { + message: 'Failed to download EXE model', + status: HostPolicyResponseActionStatus.success, + }, + load_config: { + message: 'successfully parsed configuration', + status: HostPolicyResponseActionStatus.success, + }, + load_malware_model: { + message: 'Error deserializing EXE model; no valid malware model installed', + status: HostPolicyResponseActionStatus.success, + }, + read_elasticsearch_config: { + message: 'Successfully read Elasticsearch configuration', + status: HostPolicyResponseActionStatus.success, + }, + read_events_config: { + message: 'Successfully read events configuration', + status: HostPolicyResponseActionStatus.success, + }, + read_kernel_config: { + message: 'Succesfully read kernel configuration', + status: HostPolicyResponseActionStatus.success, + }, + read_logging_config: { + message: 'field (logging.debugview) not found in config', + status: HostPolicyResponseActionStatus.success, + }, + read_malware_config: { + message: 'Successfully read malware detect configuration', + status: HostPolicyResponseActionStatus.success, + }, + workflow: { + message: 'Failed to apply a portion of the configuration (kernel)', + status: HostPolicyResponseActionStatus.success, + }, + download_model: { + message: 'Failed to apply a portion of the configuration (kernel)', + status: HostPolicyResponseActionStatus.success, + }, + ingest_events_config: { + message: 'Failed to apply a portion of the configuration (kernel)', + status: HostPolicyResponseActionStatus.success, + }, + }, + id: this.commonInfo.endpoint.policy.id, + policy: { + id: this.commonInfo.endpoint.policy.id, + version: policyVersion, + }, response: { configurations: { - malware: { - status: HostPolicyResponseActionStatus.success, - concerned_actions: ['download_model', 'workflow', 'a_custom_future_action'], - }, events: { - status: HostPolicyResponseActionStatus.success, - concerned_actions: ['ingest_events_config', 'workflow'], + concerned_actions: this.randomHostPolicyResponseActions(), + status: this.randomHostPolicyResponseActionStatus(), }, logging: { - status: HostPolicyResponseActionStatus.success, - concerned_actions: ['configure_elasticsearch_connection'], - }, - streaming: { - status: HostPolicyResponseActionStatus.success, - concerned_actions: [ - 'detect_file_open_events', - 'download_global_artifacts', - 'a_custom_future_action', - ], - }, - }, - actions: { - download_model: { - status: HostPolicyResponseActionStatus.success, - message: 'model downloaded', + concerned_actions: this.randomHostPolicyResponseActions(), + status: this.randomHostPolicyResponseActionStatus(), }, - ingest_events_config: { - status: HostPolicyResponseActionStatus.success, - message: 'no action taken', - }, - workflow: { - status: HostPolicyResponseActionStatus.success, - message: 'the flow worked well', - }, - a_custom_future_action: { - status: HostPolicyResponseActionStatus.success, - message: 'future message', - }, - configure_elasticsearch_connection: { - status: HostPolicyResponseActionStatus.success, - message: 'some message', - }, - detect_file_open_events: { - status: HostPolicyResponseActionStatus.success, - message: 'some message', + malware: { + concerned_actions: this.randomHostPolicyResponseActions(), + status: this.randomHostPolicyResponseActionStatus(), }, - download_global_artifacts: { - status: HostPolicyResponseActionStatus.success, - message: 'some message', + streaming: { + concerned_actions: this.randomHostPolicyResponseActions(), + status: this.randomHostPolicyResponseActionStatus(), }, }, }, + status: this.randomHostPolicyResponseActionStatus(), + version: policyVersion, }, }, }, + event: { + created: ts, + id: this.seededUUIDv4(), + kind: 'policy_response', + }, }; } @@ -644,6 +691,34 @@ export class EndpointDocGenerator { private seededUUIDv4(): string { return uuid.v4({ random: [...this.randomNGenerator(255, 16)] }); } + + private randomHostPolicyResponseActions(): Array { + return this.randomArray(this.randomN(8), () => + this.randomChoice([ + 'load_config', + 'workflow', + 'download_global_artifacts', + 'configure_malware', + 'read_malware_config', + 'load_malware_model', + 'read_kernel_config', + 'configure_kernel', + 'detect_process_events', + 'detect_file_write_events', + 'detect_file_open_events', + 'detect_image_load_events', + 'connect_kernel', + ]) + ); + } + + private randomHostPolicyResponseActionStatus(): HostPolicyResponseActionStatus { + return this.randomChoice([ + HostPolicyResponseActionStatus.failure, + HostPolicyResponseActionStatus.success, + HostPolicyResponseActionStatus.warning, + ]); + } } const fakeProcessNames = [ diff --git a/x-pack/plugins/ml/public/application/util/chart_utils.d.ts b/x-pack/plugins/endpoint/common/schema/policy.ts similarity index 61% rename from x-pack/plugins/ml/public/application/util/chart_utils.d.ts rename to x-pack/plugins/endpoint/common/schema/policy.ts index 1c171e77e95ca..17d0cdff57ee0 100644 --- a/x-pack/plugins/ml/public/application/util/chart_utils.d.ts +++ b/x-pack/plugins/endpoint/common/schema/policy.ts @@ -3,5 +3,10 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { schema } from '@kbn/config-schema'; -export declare function numTicksForDateFormat(axisWidth: number, dateFormat: string): number; +export const GetPolicyResponseSchema = { + query: schema.object({ + hostId: schema.string(), + }), +}; diff --git a/x-pack/plugins/endpoint/common/types.ts b/x-pack/plugins/endpoint/common/types.ts index 8fce15d1c794c..a1ddc97a90d29 100644 --- a/x-pack/plugins/endpoint/common/types.ts +++ b/x-pack/plugins/endpoint/common/types.ts @@ -613,7 +613,7 @@ export enum HostPolicyResponseActionStatus { /** * The details of a given action */ -interface HostPolicyResponseActionDetails { +export interface HostPolicyResponseActionDetails { status: HostPolicyResponseActionStatus; message: string; } @@ -621,7 +621,7 @@ interface HostPolicyResponseActionDetails { /** * A known list of possible Endpoint actions */ -interface HostPolicyResponseActions { +export interface HostPolicyResponseActions { download_model: HostPolicyResponseActionDetails; ingest_events_config: HostPolicyResponseActionDetails; workflow: HostPolicyResponseActionDetails; @@ -642,9 +642,6 @@ interface HostPolicyResponseActions { read_kernel_config: HostPolicyResponseActionDetails; read_logging_config: HostPolicyResponseActionDetails; read_malware_config: HostPolicyResponseActionDetails; - // The list of possible Actions will change rapidly, so the below entry will allow - // them without us defining them here statically - [key: string]: HostPolicyResponseActionDetails; } interface HostPolicyResponseConfigurationStatus { @@ -656,7 +653,7 @@ interface HostPolicyResponseConfigurationStatus { * Information about the applying of a policy to a given host */ export interface HostPolicyResponse { - '@timestamp': string; + '@timestamp': number; elastic: { agent: { id: string; @@ -665,21 +662,29 @@ export interface HostPolicyResponse { ecs: { version: string; }; + host: { + id: string; + }; event: { - created: string; + created: number; kind: string; + id: string; }; agent: { version: string; id: string; }; endpoint: { - artifacts: {}; policy: { applied: { version: string; id: string; status: HostPolicyResponseActionStatus; + actions: Partial; + policy: { + id: string; + version: string; + }; response: { configurations: { malware: HostPolicyResponseConfigurationStatus; @@ -687,7 +692,6 @@ export interface HostPolicyResponse { logging: HostPolicyResponseConfigurationStatus; streaming: HostPolicyResponseConfigurationStatus; }; - actions: Partial; }; }; }; diff --git a/x-pack/plugins/endpoint/scripts/policy_mapping.json b/x-pack/plugins/endpoint/scripts/policy_mapping.json new file mode 100644 index 0000000000000..1fdd5d140e0ba --- /dev/null +++ b/x-pack/plugins/endpoint/scripts/policy_mapping.json @@ -0,0 +1,398 @@ +{ + "mappings": { + "_meta": { + "version": "1.6.0-dev" + }, + "date_detection": false, + "dynamic_templates": [ + { + "strings_as_keyword": { + "mapping": { + "ignore_above": 1024, + "type": "keyword" + }, + "match_mapping_type": "string" + } + } + ], + "properties": { + "@timestamp": { + "type": "date" + }, + "ecs": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "endpoint": { + "properties": { + "policy": { + "properties": { + "applied": { + "properties": { + "actions": { + "properties": { + "configure_elasticsearch_connection": { + "properties": { + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "configure_kernel": { + "properties": { + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "configure_logging": { + "properties": { + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "configure_malware": { + "properties": { + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "connect_kernel": { + "properties": { + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "detect_file_open_events": { + "properties": { + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "detect_file_write_events": { + "properties": { + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "detect_image_load_events": { + "properties": { + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "detect_process_events": { + "properties": { + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "download_global_artifacts": { + "properties": { + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "load_config": { + "properties": { + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "load_malware_model": { + "properties": { + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "read_elasticsearch_config": { + "properties": { + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "read_events_config": { + "properties": { + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "read_kernel_config": { + "properties": { + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "read_logging_config": { + "properties": { + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "read_malware_config": { + "properties": { + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "workflow": { + "properties": { + "status": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + }, + "type": "object" + }, + "configurations": { + "properties": { + "events": { + "properties": { + "concerned_actions": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "logging": { + "properties": { + "concerned_actions": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "malware": { + "properties": { + "concerned_actions": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "streaming": { + "properties": { + "concerned_actions": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "policy": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + }, + "response": { + "type": "object" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "object" + } + }, + "type": "object" + } + } + }, + "event": { + "properties": { + "created": { + "type": "date" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "kind": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "host": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "settings": { + "index": { + "mapping": { + "total_fields": { + "limit": 10000 + } + }, + "refresh_interval": "5s" + } + } +} diff --git a/x-pack/plugins/endpoint/scripts/resolver_generator.ts b/x-pack/plugins/endpoint/scripts/resolver_generator.ts index 2129bef0624b8..30752877db824 100644 --- a/x-pack/plugins/endpoint/scripts/resolver_generator.ts +++ b/x-pack/plugins/endpoint/scripts/resolver_generator.ts @@ -10,6 +10,7 @@ import { ResponseError } from '@elastic/elasticsearch/lib/errors'; import { EndpointDocGenerator, Event } from '../common/generate_data'; import { default as eventMapping } from './event_mapping.json'; import { default as alertMapping } from './alert_mapping.json'; +import { default as policyMapping } from './policy_mapping.json'; main(); @@ -44,6 +45,12 @@ async function main() { default: 'metrics-endpoint-default-1', type: 'string', }, + policyIndex: { + alias: 'pi', + describe: 'index to store host policy in', + default: 'metrics-endpoint.policy-default-1', + type: 'string', + }, auth: { describe: 'elasticsearch username and password, separated by a colon', type: 'string', @@ -90,6 +97,12 @@ async function main() { type: 'number', default: 1, }, + numDocs: { + alias: 'nd', + describe: 'number of metadata and policy response doc to generate per host', + type: 'number', + default: 5, + }, alertsPerHost: { alias: 'ape', describe: 'number of resolver trees to make for each host', @@ -123,7 +136,7 @@ async function main() { if (argv.delete) { try { await client.indices.delete({ - index: [argv.eventIndex, argv.metadataIndex, argv.alertIndex], + index: [argv.eventIndex, argv.metadataIndex, argv.alertIndex, argv.policyIndex], }); } catch (err) { if (err instanceof ResponseError && err.statusCode !== 404) { @@ -165,6 +178,7 @@ async function main() { await createIndex(client, argv.alertIndex, alertMapping); await createIndex(client, argv.eventIndex, eventMapping); + await createIndex(client, argv.policyIndex, policyMapping); if (argv.setupOnly) { process.exit(0); } @@ -179,14 +193,19 @@ async function main() { for (let i = 0; i < argv.numHosts; i++) { const generator = new EndpointDocGenerator(random); const timeBetweenDocs = 6 * 3600 * 1000; // 6 hours between metadata documents - const numMetadataDocs = 5; + const timestamp = new Date().getTime(); - for (let j = 0; j < numMetadataDocs; j++) { + for (let j = 0; j < argv.numDocs; j++) { generator.updateHostData(); + generator.updatePolicyId(); await client.index({ index: argv.metadataIndex, - body: generator.generateHostMetadata( - timestamp - timeBetweenDocs * (numMetadataDocs - j - 1) + body: generator.generateHostMetadata(timestamp - timeBetweenDocs * (argv.numDocs - j - 1)), + }); + await client.index({ + index: argv.policyIndex, + body: generator.generatePolicyResponse( + timestamp - timeBetweenDocs * (argv.numDocs - j - 1) ), }); } diff --git a/x-pack/plugins/endpoint/server/index_pattern.ts b/x-pack/plugins/endpoint/server/index_pattern.ts index 903d48746bfb3..f4bb1460aee4b 100644 --- a/x-pack/plugins/endpoint/server/index_pattern.ts +++ b/x-pack/plugins/endpoint/server/index_pattern.ts @@ -11,6 +11,7 @@ export interface IndexPatternRetriever { getIndexPattern(ctx: RequestHandlerContext, datasetPath: string): Promise; getEventIndexPattern(ctx: RequestHandlerContext): Promise; getMetadataIndexPattern(ctx: RequestHandlerContext): Promise; + getPolicyResponseIndexPattern(ctx: RequestHandlerContext): Promise; } /** @@ -74,4 +75,8 @@ export class IngestIndexPatternRetriever implements IndexPatternRetriever { throw new Error(errMsg); } } + + getPolicyResponseIndexPattern(ctx: RequestHandlerContext): Promise { + return Promise.resolve('metrics-endpoint.policy-default-1'); + } } diff --git a/x-pack/plugins/endpoint/server/mocks.ts b/x-pack/plugins/endpoint/server/mocks.ts index 519ca15cf8427..76a3628562a82 100644 --- a/x-pack/plugins/endpoint/server/mocks.ts +++ b/x-pack/plugins/endpoint/server/mocks.ts @@ -4,7 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ +import { + IScopedClusterClient, + RequestHandlerContext, + SavedObjectsClientContract, +} from 'kibana/server'; import { AgentService, IngestManagerStartContract } from '../../ingest_manager/server'; +import { IndexPatternRetriever } from './index_pattern'; /** * Creates a mock IndexPatternRetriever for use in tests. @@ -12,12 +18,13 @@ import { AgentService, IngestManagerStartContract } from '../../ingest_manager/s * @param indexPattern a string index pattern to return when any of the mock's public methods are called. * @returns the same string passed in via `indexPattern` */ -export const createMockIndexPatternRetriever = (indexPattern: string) => { +export const createMockIndexPatternRetriever = (indexPattern: string): IndexPatternRetriever => { const mockGetFunc = jest.fn().mockResolvedValue(indexPattern); return { getIndexPattern: mockGetFunc, getEventIndexPattern: mockGetFunc, getMetadataIndexPattern: mockGetFunc, + getPolicyResponseIndexPattern: mockGetFunc, }; }; @@ -56,3 +63,24 @@ export const createMockIngestManagerStartContract = ( agentService: createMockAgentService(), }; }; + +export function createRouteHandlerContext( + dataClient: jest.Mocked, + savedObjectsClient: jest.Mocked +) { + return ({ + core: { + elasticsearch: { + dataClient, + }, + savedObjects: { + client: savedObjectsClient, + }, + }, + /** + * Using unknown here because the object defined is not a full `RequestHandlerContext`. We don't + * need all of the fields required to run the tests, but the `routeHandler` function requires a + * `RequestHandlerContext`. + */ + } as unknown) as RequestHandlerContext; +} diff --git a/x-pack/plugins/endpoint/server/plugin.ts b/x-pack/plugins/endpoint/server/plugin.ts index f3cc569ad16a7..ff10b9c0416f9 100644 --- a/x-pack/plugins/endpoint/server/plugin.ts +++ b/x-pack/plugins/endpoint/server/plugin.ts @@ -16,6 +16,7 @@ import { registerEndpointRoutes } from './routes/metadata'; import { IngestIndexPatternRetriever } from './index_pattern'; import { IngestManagerStartContract } from '../../ingest_manager/server'; import { EndpointAppContextService } from './endpoint_app_context_services'; +import { registerPolicyRoutes } from './routes/policy'; export type EndpointPluginStart = void; export type EndpointPluginSetup = void; @@ -87,6 +88,7 @@ export class EndpointPlugin registerResolverRoutes(router, endpointContext); registerAlertRoutes(router, endpointContext); registerIndexPatternRoute(router, endpointContext); + registerPolicyRoutes(router, endpointContext); } public start(core: CoreStart, plugins: EndpointPluginStartDependencies) { diff --git a/x-pack/plugins/endpoint/server/routes/metadata/metadata.test.ts b/x-pack/plugins/endpoint/server/routes/metadata/metadata.test.ts index 8f0c0b4c2efaf..5415ebcae31c4 100644 --- a/x-pack/plugins/endpoint/server/routes/metadata/metadata.test.ts +++ b/x-pack/plugins/endpoint/server/routes/metadata/metadata.test.ts @@ -9,7 +9,6 @@ import { IScopedClusterClient, KibanaResponseFactory, RequestHandler, - RequestHandlerContext, RouteConfig, SavedObjectsClientContract, } from 'kibana/server'; @@ -25,7 +24,11 @@ import { SearchResponse } from 'elasticsearch'; import { registerEndpointRoutes } from './index'; import { EndpointConfigSchema } from '../../config'; import * as data from '../../test_data/all_metadata_data.json'; -import { createMockAgentService, createMockMetadataIndexPatternRetriever } from '../../mocks'; +import { + createMockAgentService, + createMockMetadataIndexPatternRetriever, + createRouteHandlerContext, +} from '../../mocks'; import { AgentService } from '../../../../ingest_manager/server'; import Boom from 'boom'; import { EndpointAppContextService } from '../../endpoint_app_context_services'; @@ -66,27 +69,6 @@ describe('test endpoint route', () => { afterEach(() => endpointAppContextService.stop()); - function createRouteHandlerContext( - dataClient: jest.Mocked, - savedObjectsClient: jest.Mocked - ) { - return ({ - core: { - elasticsearch: { - dataClient, - }, - savedObjects: { - client: savedObjectsClient, - }, - }, - /** - * Using unknown here because the object defined is not a full `RequestHandlerContext`. We don't - * need all of the fields required to run the tests, but the `routeHandler` function requires a - * `RequestHandlerContext`. - */ - } as unknown) as RequestHandlerContext; - } - it('test find the latest of all endpoints', async () => { const mockRequest = httpServerMock.createKibanaRequest({}); diff --git a/x-pack/plugins/endpoint/server/routes/policy/handlers.test.ts b/x-pack/plugins/endpoint/server/routes/policy/handlers.test.ts new file mode 100644 index 0000000000000..9348353425370 --- /dev/null +++ b/x-pack/plugins/endpoint/server/routes/policy/handlers.test.ts @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { EndpointAppContextService } from '../../endpoint_app_context_services'; +import { + createMockAgentService, + createMockIndexPatternRetriever, + createRouteHandlerContext, +} from '../../mocks'; +import { getHostPolicyResponseHandler } from './handlers'; +import { EndpointConfigSchema } from '../../config'; +import { + IScopedClusterClient, + KibanaResponseFactory, + SavedObjectsClientContract, +} from 'kibana/server'; +import { + elasticsearchServiceMock, + httpServerMock, + loggingServiceMock, + savedObjectsClientMock, +} from '../../../../../../src/core/server/mocks'; +import { AgentService } from '../../../../ingest_manager/server/services'; +import { SearchResponse } from 'elasticsearch'; +import { GetHostPolicyResponse, HostPolicyResponse } from '../../../common/types'; +import { EndpointDocGenerator } from '../../../common/generate_data'; + +describe('test policy response handler', () => { + let endpointAppContextService: EndpointAppContextService; + let mockScopedClient: jest.Mocked; + let mockSavedObjectClient: jest.Mocked; + let mockResponse: jest.Mocked; + let mockAgentService: jest.Mocked; + + beforeEach(() => { + mockScopedClient = elasticsearchServiceMock.createScopedClusterClient(); + mockSavedObjectClient = savedObjectsClientMock.create(); + mockResponse = httpServerMock.createResponseFactory(); + endpointAppContextService = new EndpointAppContextService(); + mockAgentService = createMockAgentService(); + endpointAppContextService.start({ + indexPatternRetriever: createMockIndexPatternRetriever('metrics-endpoint-policy-*'), + agentService: mockAgentService, + }); + }); + + afterEach(() => endpointAppContextService.stop()); + + it('should return the latest policy response for a host', async () => { + const response = createSearchResponse(new EndpointDocGenerator().generatePolicyResponse()); + const hostPolicyResponseHandler = getHostPolicyResponseHandler({ + logFactory: loggingServiceMock.create(), + service: endpointAppContextService, + config: () => Promise.resolve(EndpointConfigSchema.validate({})), + }); + + mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); + const mockRequest = httpServerMock.createKibanaRequest({ + params: { hostId: 'id' }, + }); + + await hostPolicyResponseHandler( + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), + mockRequest, + mockResponse + ); + + expect(mockResponse.ok).toBeCalled(); + const result = mockResponse.ok.mock.calls[0][0]?.body as GetHostPolicyResponse; + expect(result.policy_response.host.id).toEqual(response.hits.hits[0]._source.host.id); + }); + + it('should return not found when there is no response policy for host', async () => { + const hostPolicyResponseHandler = getHostPolicyResponseHandler({ + logFactory: loggingServiceMock.create(), + service: endpointAppContextService, + config: () => Promise.resolve(EndpointConfigSchema.validate({})), + }); + + mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => + Promise.resolve(createSearchResponse()) + ); + + const mockRequest = httpServerMock.createKibanaRequest({ + params: { hostId: 'id' }, + }); + + await hostPolicyResponseHandler( + createRouteHandlerContext(mockScopedClient, mockSavedObjectClient), + mockRequest, + mockResponse + ); + + expect(mockResponse.notFound).toBeCalled(); + const message = mockResponse.notFound.mock.calls[0][0]?.body; + expect(message).toEqual('Policy Response Not Found'); + }); +}); + +/** + * Create a SearchResponse with the hostPolicyResponse provided, else return an empty + * SearchResponse + * @param hostPolicyResponse + */ +function createSearchResponse( + hostPolicyResponse?: HostPolicyResponse +): SearchResponse { + return ({ + took: 15, + timed_out: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: { + value: 5, + relation: 'eq', + }, + max_score: null, + hits: hostPolicyResponse + ? [ + { + _index: 'metrics-endpoint.policy-default-1', + _id: '8FhM0HEBYyRTvb6lOQnw', + _score: null, + _source: hostPolicyResponse, + sort: [1588337587997], + }, + ] + : [], + }, + } as unknown) as SearchResponse; +} diff --git a/x-pack/plugins/endpoint/server/routes/policy/handlers.ts b/x-pack/plugins/endpoint/server/routes/policy/handlers.ts new file mode 100644 index 0000000000000..5a34164c0bb37 --- /dev/null +++ b/x-pack/plugins/endpoint/server/routes/policy/handlers.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +import { RequestHandler } from 'kibana/server'; +import { TypeOf } from '@kbn/config-schema'; +import { GetPolicyResponseSchema } from '../../../common/schema/policy'; +import { EndpointAppContext } from '../../types'; +import { getPolicyResponseByHostId } from './service'; + +export const getHostPolicyResponseHandler = function( + endpointAppContext: EndpointAppContext +): RequestHandler, undefined> { + return async (context, request, response) => { + try { + const index = await endpointAppContext.service + .getIndexPatternRetriever() + .getPolicyResponseIndexPattern(context); + + const doc = await getPolicyResponseByHostId( + index, + request.query.hostId, + context.core.elasticsearch.dataClient + ); + + if (doc) { + return response.ok({ body: doc }); + } + + return response.notFound({ body: 'Policy Response Not Found' }); + } catch (err) { + return response.internalError({ body: err }); + } + }; +}; diff --git a/x-pack/plugins/endpoint/server/routes/policy/index.ts b/x-pack/plugins/endpoint/server/routes/policy/index.ts new file mode 100644 index 0000000000000..4c3bd8e21315c --- /dev/null +++ b/x-pack/plugins/endpoint/server/routes/policy/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IRouter } from 'kibana/server'; +import { EndpointAppContext } from '../../types'; +import { GetPolicyResponseSchema } from '../../../common/schema/policy'; +import { getHostPolicyResponseHandler } from './handlers'; + +export const BASE_POLICY_RESPONSE_ROUTE = `/api/endpoint/policy_response`; + +export function registerPolicyRoutes(router: IRouter, endpointAppContext: EndpointAppContext) { + router.get( + { + path: BASE_POLICY_RESPONSE_ROUTE, + validate: GetPolicyResponseSchema, + options: { authRequired: true }, + }, + getHostPolicyResponseHandler(endpointAppContext) + ); +} diff --git a/x-pack/plugins/endpoint/server/routes/policy/service.test.ts b/x-pack/plugins/endpoint/server/routes/policy/service.test.ts new file mode 100644 index 0000000000000..c7bf65627769e --- /dev/null +++ b/x-pack/plugins/endpoint/server/routes/policy/service.test.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { GetPolicyResponseSchema } from '../../../common/schema/policy'; + +describe('test policy handlers schema', () => { + it('validate that get policy response query schema', async () => { + expect( + GetPolicyResponseSchema.query.validate({ + hostId: 'id', + }) + ).toBeTruthy(); + + expect(() => GetPolicyResponseSchema.query.validate({})).toThrowError(); + }); +}); diff --git a/x-pack/plugins/endpoint/server/routes/policy/service.ts b/x-pack/plugins/endpoint/server/routes/policy/service.ts new file mode 100644 index 0000000000000..7ec2c65634110 --- /dev/null +++ b/x-pack/plugins/endpoint/server/routes/policy/service.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SearchResponse } from 'elasticsearch'; +import { IScopedClusterClient } from 'kibana/server'; +import { GetHostPolicyResponse, HostPolicyResponse } from '../../../common/types'; + +export function getESQueryPolicyResponseByHostID(hostID: string, index: string) { + return { + body: { + query: { + match: { + 'host.id': hostID, + }, + }, + sort: [ + { + 'event.created': { + order: 'desc', + }, + }, + ], + size: 1, + }, + index, + }; +} + +export async function getPolicyResponseByHostId( + index: string, + hostId: string, + dataClient: IScopedClusterClient +): Promise { + const query = getESQueryPolicyResponseByHostID(hostId, index); + const response = (await dataClient.callAsCurrentUser('search', query)) as SearchResponse< + HostPolicyResponse + >; + + if (response.hits.hits.length === 0) { + return undefined; + } + + return { + policy_response: response.hits.hits[0]._source, + }; +} diff --git a/x-pack/plugins/index_lifecycle_management/public/plugin.tsx b/x-pack/plugins/index_lifecycle_management/public/plugin.tsx index ca93646e20fcf..ad543b05bc025 100644 --- a/x-pack/plugins/index_lifecycle_management/public/plugin.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/plugin.tsx @@ -40,7 +40,7 @@ export class IndexLifecycleManagementPlugin { management.sections.getSection('elasticsearch')!.registerApp({ id: PLUGIN.ID, title: PLUGIN.TITLE, - order: 2, + order: 3, mount: async ({ element }) => { const [coreStart] = await getStartServices(); const { diff --git a/x-pack/plugins/index_management/public/plugin.ts b/x-pack/plugins/index_management/public/plugin.ts index f9e2a47170b3d..78e80687abeb4 100644 --- a/x-pack/plugins/index_management/public/plugin.ts +++ b/x-pack/plugins/index_management/public/plugin.ts @@ -51,7 +51,7 @@ export class IndexMgmtUIPlugin { management.sections.getSection('elasticsearch')!.registerApp({ id: PLUGIN.id, title: i18n.translate('xpack.idxMgmt.appTitle', { defaultMessage: 'Index Management' }), - order: 1, + order: 2, mount: async params => { const { mountManagementSection } = await import('./application/mount_management_section'); const services = { diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/assets/illustration_integrations_darkmode.svg b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/assets/illustration_integrations_darkmode.svg new file mode 100755 index 0000000000000..b1f86be19a080 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/assets/illustration_integrations_darkmode.svg @@ -0,0 +1 @@ +Kibana-integrations-darkmode \ No newline at end of file diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/assets/illustration_integrations_lightmode.svg b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/assets/illustration_integrations_lightmode.svg new file mode 100755 index 0000000000000..0cddcb0af6909 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/assets/illustration_integrations_lightmode.svg @@ -0,0 +1 @@ +Kibana-integrations-lightmode \ No newline at end of file diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/assets/illustration_kibana_getting_started@2x.png b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/assets/illustration_kibana_getting_started@2x.png deleted file mode 100644 index cad64be0b6e36..0000000000000 Binary files a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/assets/illustration_kibana_getting_started@2x.png and /dev/null differ diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_card.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_card.tsx index ab7e87b3ad06c..41d1c0ee4f965 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_card.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/components/package_card.tsx @@ -46,7 +46,7 @@ export function PackageCard({ layout="horizontal" title={title || ''} description={description} - icon={} + icon={} href={url} /> ); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/header.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/header.tsx index 4d6c02eeef8b4..64950f95f5158 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/header.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/epm/screens/home/header.tsx @@ -3,15 +3,18 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + import React, { memo } from 'react'; +import { i18n } from '@kbn/i18n'; import styled from 'styled-components'; import { EuiFlexGroup, EuiFlexItem, EuiImage, EuiText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { useLinks } from '../../hooks'; +import { useCore } from '../../../../hooks'; export const HeroCopy = memo(() => { return ( - +

@@ -38,16 +41,20 @@ export const HeroCopy = memo(() => { export const HeroImage = memo(() => { const { toAssets } = useLinks(); - const ImageWrapper = styled.div` - margin-bottom: -62px; + const { uiSettings } = useCore(); + const IS_DARK_THEME = uiSettings.get('theme:darkMode'); + + const Illustration = styled(EuiImage).attrs(props => ({ + alt: i18n.translate('xpack.ingestManager.epm.illustrationAltText', { + defaultMessage: 'Illustration of an Elastic integration', + }), + url: IS_DARK_THEME + ? toAssets('illustration_integrations_darkmode.svg') + : toAssets('illustration_integrations_lightmode.svg'), + }))` + margin-bottom: -60px; + width: 80%; `; - return ( - - - - ); + return ; }); diff --git a/x-pack/plugins/ingest_pipelines/public/plugin.ts b/x-pack/plugins/ingest_pipelines/public/plugin.ts index e9f5fd6c7f57c..0ab46f386e83b 100644 --- a/x-pack/plugins/ingest_pipelines/public/plugin.ts +++ b/x-pack/plugins/ingest_pipelines/public/plugin.ts @@ -22,6 +22,7 @@ export class IngestPipelinesPlugin implements Plugin { management.sections.getSection('elasticsearch')!.registerApp({ id: PLUGIN_ID, + order: 1, title: i18n.translate('xpack.ingestPipelines.appTitle', { defaultMessage: 'Ingest Node Pipelines', }), diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index fd972219563a8..b1a613e789e85 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -217,3 +217,9 @@ export enum SCALING_TYPES { export const RGBA_0000 = 'rgba(0,0,0,0)'; export const SPATIAL_FILTERS_LAYER_ID = 'SPATIAL_FILTERS_LAYER_ID'; + +export enum INITIAL_LOCATION { + LAST_SAVED_LOCATION = 'LAST_SAVED_LOCATION', + FIXED_LOCATION = 'FIXED_LOCATION', + BROWSER_LOCATION = 'BROWSER_LOCATION', +} diff --git a/x-pack/plugins/maps/public/actions/map_actions.d.ts b/x-pack/plugins/maps/public/actions/map_actions.d.ts index 38c56405787eb..413b440279d77 100644 --- a/x-pack/plugins/maps/public/actions/map_actions.d.ts +++ b/x-pack/plugins/maps/public/actions/map_actions.d.ts @@ -72,7 +72,7 @@ export function trackMapSettings(): AnyAction; export function updateMapSetting( settingKey: string, - settingValue: string | boolean | number + settingValue: string | boolean | number | object ): AnyAction; export function cloneLayer(layerId: string): AnyAction; diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/get_initial_view.ts b/x-pack/plugins/maps/public/connected_components/map/mb/get_initial_view.ts new file mode 100644 index 0000000000000..30e3b9b46916b --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/map/mb/get_initial_view.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { INITIAL_LOCATION } from '../../../../common/constants'; +import { Goto, MapCenterAndZoom } from '../../../../common/descriptor_types'; +import { MapSettings } from '../../../reducers/map'; + +export async function getInitialView( + goto: Goto | null, + settings: MapSettings +): Promise { + if (settings.initialLocation === INITIAL_LOCATION.FIXED_LOCATION) { + return { + lat: settings.fixedLocation.lat, + lon: settings.fixedLocation.lon, + zoom: settings.fixedLocation.zoom, + }; + } + + if (settings.initialLocation === INITIAL_LOCATION.BROWSER_LOCATION) { + return await new Promise((resolve, reject) => { + navigator.geolocation.getCurrentPosition( + // success callback + pos => { + resolve({ + lat: pos.coords.latitude, + lon: pos.coords.longitude, + zoom: settings.browserLocation.zoom, + }); + }, + // error callback + () => { + // eslint-disable-next-line no-console + console.warn('Unable to fetch browser location for initial map location'); + resolve(null); + } + ); + }); + } + + return goto && goto.center ? goto.center : null; +} diff --git a/x-pack/plugins/maps/public/connected_components/map/mb/view.js b/x-pack/plugins/maps/public/connected_components/map/mb/view.js index 6bb5a4fed6e52..7afb326f42e02 100644 --- a/x-pack/plugins/maps/public/connected_components/map/mb/view.js +++ b/x-pack/plugins/maps/public/connected_components/map/mb/view.js @@ -24,6 +24,7 @@ import sprites2 from '@elastic/maki/dist/sprite@2.png'; import { DrawControl } from './draw_control'; import { TooltipControl } from './tooltip_control'; import { clampToLatBounds, clampToLonBounds } from '../../../elasticsearch_geo_utils'; +import { getInitialView } from './get_initial_view'; import { getInjectedVarFunc } from '../../../kibana_services'; @@ -112,6 +113,7 @@ export class MBMapContainer extends React.Component { } async _createMbMapInstance() { + const initialView = await getInitialView(this.props.goto, this.props.settings); return new Promise(resolve => { const mbStyle = { version: 8, @@ -133,7 +135,6 @@ export class MBMapContainer extends React.Component { maxZoom: this.props.settings.maxZoom, minZoom: this.props.settings.minZoom, }; - const initialView = _.get(this.props.goto, 'center'); if (initialView) { options.zoom = initialView.zoom; options.center = { diff --git a/x-pack/plugins/maps/public/connected_components/map_settings_panel/__snapshots__/navigation_panel.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/map_settings_panel/__snapshots__/navigation_panel.test.tsx.snap new file mode 100644 index 0000000000000..641dd20a1a44a --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/map_settings_panel/__snapshots__/navigation_panel.test.tsx.snap @@ -0,0 +1,292 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render 1`] = ` + + +
+ +
+
+ + + + + +
+`; + +exports[`should render browser location form when initialLocation is BROWSER_LOCATION 1`] = ` + + +
+ +
+
+ + + + + + + + +
+`; + +exports[`should render fixed location form when initialLocation is FIXED_LOCATION 1`] = ` + + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + +
+`; diff --git a/x-pack/plugins/maps/public/connected_components/map_settings_panel/index.ts b/x-pack/plugins/maps/public/connected_components/map_settings_panel/index.ts index 329fac28d7d2e..eaa49719059c5 100644 --- a/x-pack/plugins/maps/public/connected_components/map_settings_panel/index.ts +++ b/x-pack/plugins/maps/public/connected_components/map_settings_panel/index.ts @@ -10,13 +10,20 @@ import { FLYOUT_STATE } from '../../reducers/ui'; import { MapStoreState } from '../../reducers/store'; import { MapSettingsPanel } from './map_settings_panel'; import { rollbackMapSettings, updateMapSetting } from '../../actions/map_actions'; -import { getMapSettings, hasMapSettingsChanges } from '../../selectors/map_selectors'; +import { + getMapCenter, + getMapSettings, + getMapZoom, + hasMapSettingsChanges, +} from '../../selectors/map_selectors'; import { updateFlyout } from '../../actions/ui_actions'; function mapStateToProps(state: MapStoreState) { return { - settings: getMapSettings(state), + center: getMapCenter(state), hasMapSettingsChanges: hasMapSettingsChanges(state), + settings: getMapSettings(state), + zoom: getMapZoom(state), }; } @@ -29,7 +36,7 @@ function mapDispatchToProps(dispatch: Dispatch) { keepChanges: () => { dispatch(updateFlyout(FLYOUT_STATE.NONE)); }, - updateMapSetting: (settingKey: string, settingValue: string | number | boolean) => { + updateMapSetting: (settingKey: string, settingValue: string | number | boolean | object) => { dispatch(updateMapSetting(settingKey, settingValue)); }, }; diff --git a/x-pack/plugins/maps/public/connected_components/map_settings_panel/map_settings_panel.tsx b/x-pack/plugins/maps/public/connected_components/map_settings_panel/map_settings_panel.tsx index a89f4461fff06..66b979869416d 100644 --- a/x-pack/plugins/maps/public/connected_components/map_settings_panel/map_settings_panel.tsx +++ b/x-pack/plugins/maps/public/connected_components/map_settings_panel/map_settings_panel.tsx @@ -20,21 +20,26 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { MapSettings } from '../../reducers/map'; import { NavigationPanel } from './navigation_panel'; import { SpatialFiltersPanel } from './spatial_filters_panel'; +import { MapCenter } from '../../../common/descriptor_types'; interface Props { cancelChanges: () => void; + center: MapCenter; hasMapSettingsChanges: boolean; keepChanges: () => void; settings: MapSettings; - updateMapSetting: (settingKey: string, settingValue: string | number | boolean) => void; + updateMapSetting: (settingKey: string, settingValue: string | number | boolean | object) => void; + zoom: number; } export function MapSettingsPanel({ cancelChanges, + center, hasMapSettingsChanges, keepChanges, settings, updateMapSetting, + zoom, }: Props) { // TODO move common text like Cancel and Close to common i18n translation const closeBtnLabel = hasMapSettingsChanges @@ -60,7 +65,12 @@ export function MapSettingsPanel({
- +
diff --git a/x-pack/plugins/maps/public/connected_components/map_settings_panel/navigation_panel.test.tsx b/x-pack/plugins/maps/public/connected_components/map_settings_panel/navigation_panel.test.tsx new file mode 100644 index 0000000000000..d785a30324e4e --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/map_settings_panel/navigation_panel.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { NavigationPanel } from './navigation_panel'; +import { getDefaultMapSettings } from '../../reducers/default_map_settings'; +import { INITIAL_LOCATION } from '../../../common/constants'; + +const defaultProps = { + center: { lat: 0, lon: 0 }, + settings: getDefaultMapSettings(), + updateMapSetting: () => {}, + zoom: 0, +}; + +test('should render', async () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); +}); + +test('should render fixed location form when initialLocation is FIXED_LOCATION', async () => { + const settings = { + ...defaultProps.settings, + initialLocation: INITIAL_LOCATION.FIXED_LOCATION, + }; + const component = shallow(); + + expect(component).toMatchSnapshot(); +}); + +test('should render browser location form when initialLocation is BROWSER_LOCATION', async () => { + const settings = { + ...defaultProps.settings, + initialLocation: INITIAL_LOCATION.BROWSER_LOCATION, + }; + const component = shallow(); + + expect(component).toMatchSnapshot(); +}); diff --git a/x-pack/plugins/maps/public/connected_components/map_settings_panel/navigation_panel.tsx b/x-pack/plugins/maps/public/connected_components/map_settings_panel/navigation_panel.tsx index ed83e838f44f6..0e12f20dd9a7a 100644 --- a/x-pack/plugins/maps/public/connected_components/map_settings_panel/navigation_panel.tsx +++ b/x-pack/plugins/maps/public/connected_components/map_settings_panel/navigation_panel.tsx @@ -4,25 +4,198 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; -import { EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; +import React, { ChangeEvent } from 'react'; +import { + EuiButtonEmpty, + EuiFieldNumber, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiPanel, + EuiRadioGroup, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { MapSettings } from '../../reducers/map'; import { ValidatedDualRange, Value } from '../../../../../../src/plugins/kibana_react/public'; -import { MAX_ZOOM, MIN_ZOOM } from '../../../common/constants'; +import { INITIAL_LOCATION, MAX_ZOOM, MIN_ZOOM } from '../../../common/constants'; +import { MapCenter } from '../../../common/descriptor_types'; +// @ts-ignore +import { ValidatedRange } from '../../components/validated_range'; interface Props { + center: MapCenter; settings: MapSettings; - updateMapSetting: (settingKey: string, settingValue: string | number | boolean) => void; + updateMapSetting: (settingKey: string, settingValue: string | number | boolean | object) => void; + zoom: number; } -export function NavigationPanel({ settings, updateMapSetting }: Props) { +const initialLocationOptions = [ + { + id: INITIAL_LOCATION.LAST_SAVED_LOCATION, + label: i18n.translate('xpack.maps.mapSettingsPanel.lastSavedLocationLabel', { + defaultMessage: 'Map location at save', + }), + }, + { + id: INITIAL_LOCATION.FIXED_LOCATION, + label: i18n.translate('xpack.maps.mapSettingsPanel.fixedLocationLabel', { + defaultMessage: 'Fixed location', + }), + }, + { + id: INITIAL_LOCATION.BROWSER_LOCATION, + label: i18n.translate('xpack.maps.mapSettingsPanel.browserLocationLabel', { + defaultMessage: 'Browser location', + }), + }, +]; + +export function NavigationPanel({ center, settings, updateMapSetting, zoom }: Props) { const onZoomChange = (value: Value) => { - updateMapSetting('minZoom', Math.max(MIN_ZOOM, parseInt(value[0] as string, 10))); - updateMapSetting('maxZoom', Math.min(MAX_ZOOM, parseInt(value[1] as string, 10))); + const minZoom = Math.max(MIN_ZOOM, parseInt(value[0] as string, 10)); + const maxZoom = Math.min(MAX_ZOOM, parseInt(value[1] as string, 10)); + updateMapSetting('minZoom', minZoom); + updateMapSetting('maxZoom', maxZoom); + + // ensure fixed zoom and browser zoom stay within defined min/max + if (settings.fixedLocation.zoom < minZoom) { + onFixedZoomChange(minZoom); + } else if (settings.fixedLocation.zoom > maxZoom) { + onFixedZoomChange(maxZoom); + } + + if (settings.browserLocation.zoom < minZoom) { + onBrowserZoomChange(minZoom); + } else if (settings.browserLocation.zoom > maxZoom) { + onBrowserZoomChange(maxZoom); + } + }; + + const onInitialLocationChange = (optionId: string): void => { + updateMapSetting('initialLocation', optionId); + }; + + const onFixedLatChange = (event: ChangeEvent) => { + let value = parseFloat(event.target.value); + if (isNaN(value)) { + value = 0; + } else if (value < -90) { + value = -90; + } else if (value > 90) { + value = 90; + } + updateMapSetting('fixedLocation', { ...settings.fixedLocation, lat: value }); + }; + + const onFixedLonChange = (event: ChangeEvent) => { + let value = parseFloat(event.target.value); + if (isNaN(value)) { + value = 0; + } else if (value < -180) { + value = -180; + } else if (value > 180) { + value = 180; + } + updateMapSetting('fixedLocation', { ...settings.fixedLocation, lon: value }); + }; + + const onFixedZoomChange = (value: number) => { + updateMapSetting('fixedLocation', { ...settings.fixedLocation, zoom: value }); + }; + + const onBrowserZoomChange = (value: number) => { + updateMapSetting('browserLocation', { zoom: value }); + }; + + const useCurrentView = () => { + updateMapSetting('fixedLocation', { + lat: center.lat, + lon: center.lon, + zoom: Math.round(zoom), + }); }; + function renderInitialLocationInputs() { + if (settings.initialLocation === INITIAL_LOCATION.LAST_SAVED_LOCATION) { + return null; + } + + const zoomFormRow = ( + + + + ); + + if (settings.initialLocation === INITIAL_LOCATION.BROWSER_LOCATION) { + return zoomFormRow; + } + + return ( + <> + + + + + + + {zoomFormRow} + + + + + + + + + ); + } + return ( @@ -50,6 +223,19 @@ export function NavigationPanel({ settings, updateMapSetting }: Props) { allowEmptyRange={false} compressed /> + + + + + {renderInitialLocationInputs()} ); } diff --git a/x-pack/plugins/maps/public/reducers/default_map_settings.ts b/x-pack/plugins/maps/public/reducers/default_map_settings.ts index fe21b37434edd..9c9b814ae6add 100644 --- a/x-pack/plugins/maps/public/reducers/default_map_settings.ts +++ b/x-pack/plugins/maps/public/reducers/default_map_settings.ts @@ -4,11 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { MAX_ZOOM, MIN_ZOOM } from '../../common/constants'; +import { INITIAL_LOCATION, MAX_ZOOM, MIN_ZOOM } from '../../common/constants'; import { MapSettings } from './map'; export function getDefaultMapSettings(): MapSettings { return { + initialLocation: INITIAL_LOCATION.LAST_SAVED_LOCATION, + fixedLocation: { lat: 0, lon: 0, zoom: 2 }, + browserLocation: { zoom: 2 }, maxZoom: MAX_ZOOM, minZoom: MIN_ZOOM, showSpatialFilters: true, diff --git a/x-pack/plugins/maps/public/reducers/map.d.ts b/x-pack/plugins/maps/public/reducers/map.d.ts index be0700d4bdd6d..20e1dc1035e19 100644 --- a/x-pack/plugins/maps/public/reducers/map.d.ts +++ b/x-pack/plugins/maps/public/reducers/map.d.ts @@ -15,6 +15,7 @@ import { MapRefreshConfig, TooltipState, } from '../../common/descriptor_types'; +import { INITIAL_LOCATION } from '../../common/constants'; import { Filter, TimeRange } from '../../../../../src/plugins/data/public'; export type MapContext = { @@ -40,6 +41,15 @@ export type MapContext = { }; export type MapSettings = { + initialLocation: INITIAL_LOCATION; + fixedLocation: { + lat: number; + lon: number; + zoom: number; + }; + browserLocation: { + zoom: number; + }; maxZoom: number; minZoom: number; showSpatialFilters: boolean; diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index e9d4aff3484b1..038f61b3a33b7 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -13,9 +13,7 @@ "home", "licensing", "usageCollection", - "share", - "embeddable", - "uiActions" + "share" ], "optionalPlugins": [ "security", diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx index decd1275fe884..9cc42a4df2f66 100644 --- a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx +++ b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx @@ -4,15 +4,56 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import classNames from 'classnames'; -import TooltipTrigger from 'react-popper-tooltip'; +import React, { useRef, FC } from 'react'; import { TooltipValueFormatter } from '@elastic/charts'; +import useObservable from 'react-use/lib/useObservable'; -import './_index.scss'; +import { chartTooltip$, ChartTooltipState, ChartTooltipValue } from './chart_tooltip_service'; -import { ChildrenArg, TooltipTriggerProps } from 'react-popper-tooltip/dist/types'; -import { ChartTooltipService, ChartTooltipValue, TooltipData } from './chart_tooltip_service'; +type RefValue = HTMLElement | null; + +function useRefWithCallback(chartTooltipState?: ChartTooltipState) { + const ref = useRef(null); + + return (node: RefValue) => { + ref.current = node; + + if ( + node !== null && + node.parentElement !== null && + chartTooltipState !== undefined && + chartTooltipState.isTooltipVisible + ) { + const parentBounding = node.parentElement.getBoundingClientRect(); + + const { targetPosition, offset } = chartTooltipState; + + const contentWidth = document.body.clientWidth - parentBounding.left; + const tooltipWidth = node.clientWidth; + + let left = targetPosition.left + offset.x - parentBounding.left; + if (left + tooltipWidth > contentWidth) { + // the tooltip is hanging off the side of the page, + // so move it to the other side of the target + left = left - (tooltipWidth + offset.x); + } + + const top = targetPosition.top + offset.y - parentBounding.top; + + if ( + chartTooltipState.tooltipPosition.left !== left || + chartTooltipState.tooltipPosition.top !== top + ) { + // render the tooltip with adjusted position. + chartTooltip$.next({ + ...chartTooltipState, + tooltipPosition: { left, top }, + }); + } + } + }; +} const renderHeader = (headerData?: ChartTooltipValue, formatter?: TooltipValueFormatter) => { if (!headerData) { @@ -22,101 +63,48 @@ const renderHeader = (headerData?: ChartTooltipValue, formatter?: TooltipValueFo return formatter ? formatter(headerData) : headerData.label; }; -const Tooltip: FC<{ service: ChartTooltipService }> = React.memo(({ service }) => { - const [tooltipData, setData] = useState([]); - const refCallback = useRef(); +export const ChartTooltip: FC = () => { + const chartTooltipState = useObservable(chartTooltip$); + const chartTooltipElement = useRefWithCallback(chartTooltipState); - useEffect(() => { - const subscription = service.tooltipState$.subscribe(tooltipState => { - if (refCallback.current) { - // update trigger - refCallback.current(tooltipState.target); - } - setData(tooltipState.tooltipData); - }); - return () => { - subscription.unsubscribe(); - }; - }, []); - - const triggerCallback = useCallback( - (({ triggerRef }) => { - // obtain the reference to the trigger setter callback - // to update the target based on changes from the service. - refCallback.current = triggerRef; - // actual trigger is resolved by the service, hence don't render - return null; - }) as TooltipTriggerProps['children'], - [] - ); - - const tooltipCallback = useCallback( - (({ tooltipRef, getTooltipProps }) => { - return ( -
- {tooltipData.length > 0 && tooltipData[0].skipHeader === undefined && ( -
{renderHeader(tooltipData[0])}
- )} - {tooltipData.length > 1 && ( -
- {tooltipData - .slice(1) - .map(({ label, value, color, isHighlighted, seriesIdentifier, valueAccessor }) => { - const classes = classNames('mlChartTooltip__item', { - /* eslint @typescript-eslint/camelcase:0 */ - echTooltip__rowHighlighted: isHighlighted, - }); - return ( -
- {label} - {value} -
- ); - })} -
- )} -
- ); - }) as TooltipTriggerProps['tooltip'], - [tooltipData] - ); - - const isTooltipShown = tooltipData.length > 0; - - return ( - - {triggerCallback} - - ); -}); - -interface MlTooltipComponentProps { - children: (tooltipService: ChartTooltipService) => React.ReactElement; -} + if (chartTooltipState === undefined || !chartTooltipState.isTooltipVisible) { + return
; + } -export const MlTooltipComponent: FC = ({ children }) => { - const service = useMemo(() => new ChartTooltipService(), []); + const { tooltipData, tooltipHeaderFormatter, tooltipPosition } = chartTooltipState; + const transform = `translate(${tooltipPosition.left}px, ${tooltipPosition.top}px)`; return ( - <> - - {children(service)} - +
+ {tooltipData.length > 0 && tooltipData[0].skipHeader === undefined && ( +
+ {renderHeader(tooltipData[0], tooltipHeaderFormatter)} +
+ )} + {tooltipData.length > 1 && ( +
+ {tooltipData + .slice(1) + .map(({ label, value, color, isHighlighted, seriesIdentifier, valueAccessor }) => { + const classes = classNames('mlChartTooltip__item', { + /* eslint @typescript-eslint/camelcase:0 */ + echTooltip__rowHighlighted: isHighlighted, + }); + return ( +
+ {label} + {value} +
+ ); + })} +
+ )} +
); }; diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.d.ts b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.d.ts new file mode 100644 index 0000000000000..e6b0b6b4270bd --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.d.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { BehaviorSubject } from 'rxjs'; + +import { TooltipValue, TooltipValueFormatter } from '@elastic/charts'; + +export declare const getChartTooltipDefaultState: () => ChartTooltipState; + +export interface ChartTooltipValue extends TooltipValue { + skipHeader?: boolean; +} + +interface ChartTooltipState { + isTooltipVisible: boolean; + offset: ToolTipOffset; + targetPosition: ClientRect; + tooltipData: ChartTooltipValue[]; + tooltipHeaderFormatter?: TooltipValueFormatter; + tooltipPosition: { left: number; top: number }; +} + +export declare const chartTooltip$: BehaviorSubject; + +interface ToolTipOffset { + x: number; + y: number; +} + +interface MlChartTooltipService { + show: ( + tooltipData: ChartTooltipValue[], + target?: HTMLElement | null, + offset?: ToolTipOffset + ) => void; + hide: () => void; +} + +export declare const mlChartTooltipService: MlChartTooltipService; diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.js b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.js new file mode 100644 index 0000000000000..59cf98e5ffd71 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.js @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { BehaviorSubject } from 'rxjs'; + +export const getChartTooltipDefaultState = () => ({ + isTooltipVisible: false, + tooltipData: [], + offset: { x: 0, y: 0 }, + targetPosition: { left: 0, top: 0 }, + tooltipPosition: { left: 0, top: 0 }, +}); + +export const chartTooltip$ = new BehaviorSubject(getChartTooltipDefaultState()); + +export const mlChartTooltipService = { + show: (tooltipData, target, offset = { x: 0, y: 0 }) => { + if (typeof target !== 'undefined' && target !== null) { + chartTooltip$.next({ + ...chartTooltip$.getValue(), + isTooltipVisible: true, + offset, + targetPosition: target.getBoundingClientRect(), + tooltipData, + }); + } + }, + hide: () => { + chartTooltip$.next({ + ...getChartTooltipDefaultState(), + isTooltipVisible: false, + }); + }, +}; diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.test.ts b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.test.ts index 231854cd264c2..aa1dbf92b0677 100644 --- a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.test.ts +++ b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.test.ts @@ -4,61 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - ChartTooltipService, - getChartTooltipDefaultState, - TooltipData, -} from './chart_tooltip_service'; +import { getChartTooltipDefaultState, mlChartTooltipService } from './chart_tooltip_service'; -describe('ChartTooltipService', () => { - let service: ChartTooltipService; - - beforeEach(() => { - service = new ChartTooltipService(); - }); - - test('should update the tooltip state on show and hide', () => { - const spy = jest.fn(); - - service.tooltipState$.subscribe(spy); - - expect(spy).toHaveBeenCalledWith(getChartTooltipDefaultState()); - - const update = [ - { - label: 'new tooltip', - }, - ] as TooltipData; - const mockEl = document.createElement('div'); - - service.show(update, mockEl); - - expect(spy).toHaveBeenCalledWith({ - isTooltipVisible: true, - tooltipData: update, - offset: { x: 0, y: 0 }, - target: mockEl, - }); - - service.hide(); - - expect(spy).toHaveBeenCalledWith({ - isTooltipVisible: false, - tooltipData: ([] as unknown) as TooltipData, - offset: { x: 0, y: 0 }, - target: null, - }); +describe('ML - mlChartTooltipService', () => { + it('service API duck typing', () => { + expect(typeof mlChartTooltipService).toBe('object'); + expect(typeof mlChartTooltipService.show).toBe('function'); + expect(typeof mlChartTooltipService.hide).toBe('function'); }); - test('update the tooltip state only on a new value', () => { - const spy = jest.fn(); - - service.tooltipState$.subscribe(spy); - - expect(spy).toHaveBeenCalledWith(getChartTooltipDefaultState()); - - service.hide(); - - expect(spy).toHaveBeenCalledTimes(1); + it('should fail silently when target is not defined', () => { + expect(() => { + mlChartTooltipService.show(getChartTooltipDefaultState().tooltipData, null); + }).not.toThrow('Call to show() should fail silently.'); }); }); diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.ts b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.ts deleted file mode 100644 index b524e18102a95..0000000000000 --- a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip_service.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { BehaviorSubject, Observable } from 'rxjs'; -import { isEqual } from 'lodash'; -import { TooltipValue, TooltipValueFormatter } from '@elastic/charts'; -import { distinctUntilChanged } from 'rxjs/operators'; - -export interface ChartTooltipValue extends TooltipValue { - skipHeader?: boolean; -} - -export interface TooltipHeader { - skipHeader: boolean; -} - -export type TooltipData = ChartTooltipValue[]; - -export interface ChartTooltipState { - isTooltipVisible: boolean; - offset: TooltipOffset; - tooltipData: TooltipData; - tooltipHeaderFormatter?: TooltipValueFormatter; - target: HTMLElement | null; -} - -interface TooltipOffset { - x: number; - y: number; -} - -export const getChartTooltipDefaultState = (): ChartTooltipState => ({ - isTooltipVisible: false, - tooltipData: ([] as unknown) as TooltipData, - offset: { x: 0, y: 0 }, - target: null, -}); - -export class ChartTooltipService { - private chartTooltip$ = new BehaviorSubject(getChartTooltipDefaultState()); - - public tooltipState$: Observable = this.chartTooltip$ - .asObservable() - .pipe(distinctUntilChanged(isEqual)); - - public show( - tooltipData: TooltipData, - target: HTMLElement, - offset: TooltipOffset = { x: 0, y: 0 } - ) { - if (!target) { - throw new Error('target is required for the tooltip positioning'); - } - - this.chartTooltip$.next({ - ...this.chartTooltip$.getValue(), - isTooltipVisible: true, - offset, - tooltipData, - target, - }); - } - - public hide() { - this.chartTooltip$.next({ - ...getChartTooltipDefaultState(), - isTooltipVisible: false, - }); - } -} diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/index.ts b/x-pack/plugins/ml/public/application/components/chart_tooltip/index.ts index ec19fe18bd324..75c65ebaa0f50 100644 --- a/x-pack/plugins/ml/public/application/components/chart_tooltip/index.ts +++ b/x-pack/plugins/ml/public/application/components/chart_tooltip/index.ts @@ -4,5 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export { ChartTooltipService } from './chart_tooltip_service'; -export { MlTooltipComponent } from './chart_tooltip'; +export { mlChartTooltipService } from './chart_tooltip_service'; +export { ChartTooltip } from './chart_tooltip'; diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector.tsx b/x-pack/plugins/ml/public/application/components/job_selector/job_selector.tsx index f709c161bef17..381e5e75356c1 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/job_selector.tsx +++ b/x-pack/plugins/ml/public/application/components/job_selector/job_selector.tsx @@ -4,23 +4,45 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import PropTypes from 'prop-types'; + +import { + EuiButton, + EuiButtonEmpty, + EuiFlexItem, + EuiFlexGroup, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiSwitch, + EuiTitle, +} from '@elastic/eui'; -import { EuiButtonEmpty, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { useMlKibana } from '../../contexts/kibana'; import { Dictionary } from '../../../../common/types/common'; +import { MlJobWithTimeRange } from '../../../../common/types/anomaly_detection_jobs'; +import { ml } from '../../services/ml_api_service'; import { useUrlState } from '../../util/url_state'; // @ts-ignore +import { JobSelectorTable } from './job_selector_table/index'; +// @ts-ignore import { IdBadges } from './id_badges/index'; -import { BADGE_LIMIT, JobSelectorFlyout, JobSelectorFlyoutProps } from './job_selector_flyout'; -import { MlJobWithTimeRange } from '../../../../common/types/anomaly_detection_jobs'; +// @ts-ignore +import { NewSelectionIdBadges } from './new_selection_id_badges/index'; +import { + getGroupsFromJobs, + getTimeRangeFromSelection, + normalizeTimes, +} from './job_select_service_utils'; interface GroupObj { groupId: string; jobIds: string[]; } - function mergeSelection( jobIds: string[], groupObjs: GroupObj[], @@ -49,7 +71,7 @@ function mergeSelection( } type GroupsMap = Dictionary; -export function getInitialGroupsMap(selectedGroups: GroupObj[]): GroupsMap { +function getInitialGroupsMap(selectedGroups: GroupObj[]): GroupsMap { const map: GroupsMap = {}; if (selectedGroups.length) { @@ -61,38 +83,81 @@ export function getInitialGroupsMap(selectedGroups: GroupObj[]): GroupsMap { return map; } +const BADGE_LIMIT = 10; +const DEFAULT_GANTT_BAR_WIDTH = 299; // pixels + interface JobSelectorProps { dateFormatTz: string; singleSelection: boolean; timeseriesOnly: boolean; } -export interface JobSelectionMaps { - jobsMap: Dictionary; - groupsMap: Dictionary; -} - export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: JobSelectorProps) { const [globalState, setGlobalState] = useUrlState('_g'); const selectedJobIds = globalState?.ml?.jobIds ?? []; const selectedGroups = globalState?.ml?.groups ?? []; - const [maps, setMaps] = useState({ - groupsMap: getInitialGroupsMap(selectedGroups), - jobsMap: {}, - }); + const [jobs, setJobs] = useState([]); + const [groups, setGroups] = useState([]); + const [maps, setMaps] = useState({ groupsMap: getInitialGroupsMap(selectedGroups), jobsMap: {} }); const [selectedIds, setSelectedIds] = useState( mergeSelection(selectedJobIds, selectedGroups, singleSelection) ); + const [newSelection, setNewSelection] = useState( + mergeSelection(selectedJobIds, selectedGroups, singleSelection) + ); + const [showAllBadges, setShowAllBadges] = useState(false); const [showAllBarBadges, setShowAllBarBadges] = useState(false); + const [applyTimeRange, setApplyTimeRange] = useState(true); const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); + const [ganttBarWidth, setGanttBarWidth] = useState(DEFAULT_GANTT_BAR_WIDTH); + const flyoutEl = useRef<{ flyout: HTMLElement }>(null); + const { + services: { notifications }, + } = useMlKibana(); // Ensure JobSelectionBar gets updated when selection via globalState changes. useEffect(() => { setSelectedIds(mergeSelection(selectedJobIds, selectedGroups, singleSelection)); }, [JSON.stringify([selectedJobIds, selectedGroups])]); + // Ensure current selected ids always show up in flyout + useEffect(() => { + setNewSelection(selectedIds); + }, [isFlyoutVisible]); // eslint-disable-line + + // Wrap handleResize in useCallback as it is a dependency for useEffect on line 131 below. + // Not wrapping it would cause this dependency to change on every render + const handleResize = useCallback(() => { + if (jobs.length > 0 && flyoutEl && flyoutEl.current && flyoutEl.current.flyout) { + // get all cols in flyout table + const tableHeaderCols: NodeListOf = flyoutEl.current.flyout.querySelectorAll( + 'table thead th' + ); + // get the width of the last col + const derivedWidth = tableHeaderCols[tableHeaderCols.length - 1].offsetWidth - 16; + const normalizedJobs = normalizeTimes(jobs, dateFormatTz, derivedWidth); + setJobs(normalizedJobs); + const { groups: updatedGroups } = getGroupsFromJobs(normalizedJobs); + setGroups(updatedGroups); + setGanttBarWidth(derivedWidth); + } + }, [dateFormatTz, jobs]); + + useEffect(() => { + // Ensure ganttBar width gets calculated on resize + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + }; + }, [handleResize]); + + useEffect(() => { + handleResize(); + }, [handleResize, jobs]); + function closeFlyout() { setIsFlyoutVisible(false); } @@ -103,26 +168,78 @@ export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: J function handleJobSelectionClick() { showFlyout(); + + ml.jobs + .jobsWithTimerange(dateFormatTz) + .then(resp => { + const normalizedJobs = normalizeTimes(resp.jobs, dateFormatTz, DEFAULT_GANTT_BAR_WIDTH); + const { groups: groupsWithTimerange, groupsMap } = getGroupsFromJobs(normalizedJobs); + setJobs(normalizedJobs); + setGroups(groupsWithTimerange); + setMaps({ groupsMap, jobsMap: resp.jobsMap }); + }) + .catch((err: any) => { + console.error('Error fetching jobs with time range', err); // eslint-disable-line + const { toasts } = notifications; + toasts.addDanger({ + title: i18n.translate('xpack.ml.jobSelector.jobFetchErrorMessage', { + defaultMessage: 'An error occurred fetching jobs. Refresh and try again.', + }), + }); + }); + } + + function handleNewSelection({ selectionFromTable }: { selectionFromTable: any }) { + setNewSelection(selectionFromTable); } - const applySelection: JobSelectorFlyoutProps['onSelectionConfirmed'] = ({ - newSelection, - jobIds, - groups: newGroups, - time, - }) => { + function applySelection() { + // allNewSelection will be a list of all job ids (including those from groups) selected from the table + const allNewSelection: string[] = []; + const groupSelection: Array<{ groupId: string; jobIds: string[] }> = []; + + newSelection.forEach(id => { + if (maps.groupsMap[id] !== undefined) { + // Push all jobs from selected groups into the newSelection list + allNewSelection.push(...maps.groupsMap[id]); + // if it's a group - push group obj to set in global state + groupSelection.push({ groupId: id, jobIds: maps.groupsMap[id] }); + } else { + allNewSelection.push(id); + } + }); + // create a Set to remove duplicate values + const allNewSelectionUnique = Array.from(new Set(allNewSelection)); + setSelectedIds(newSelection); + setNewSelection([]); + + closeFlyout(); + + const time = applyTimeRange + ? getTimeRangeFromSelection(jobs, allNewSelectionUnique) + : undefined; setGlobalState({ ml: { - jobIds, - groups: newGroups, + jobIds: allNewSelectionUnique, + groups: groupSelection, }, ...(time !== undefined ? { time } : {}), }); + } - closeFlyout(); - }; + function toggleTimerangeSwitch() { + setApplyTimeRange(!applyTimeRange); + } + + function removeId(id: string) { + setNewSelection(newSelection.filter(item => item !== id)); + } + + function clearSelection() { + setNewSelection([]); + } function renderJobSelectionBar() { return ( @@ -163,16 +280,103 @@ export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: J function renderFlyout() { if (isFlyoutVisible) { return ( - + + + +

+ {i18n.translate('xpack.ml.jobSelector.flyoutTitle', { + defaultMessage: 'Job selection', + })} +

+
+
+ + + + + setShowAllBadges(!showAllBadges)} + showAllBadges={showAllBadges} + /> + + + + + + {!singleSelection && newSelection.length > 0 && ( + + {i18n.translate('xpack.ml.jobSelector.clearAllFlyoutButton', { + defaultMessage: 'Clear all', + })} + + )} + + + + + + + + + + + + + + {i18n.translate('xpack.ml.jobSelector.applyFlyoutButton', { + defaultMessage: 'Apply', + })} + + + + + {i18n.translate('xpack.ml.jobSelector.closeFlyoutButton', { + defaultMessage: 'Close', + })} + + + + +
); } } @@ -184,3 +388,9 @@ export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: J
); } + +JobSelector.propTypes = { + selectedJobIds: PropTypes.array, + singleSelection: PropTypes.bool, + timeseriesOnly: PropTypes.bool, +}; diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/index.ts b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/index.js similarity index 100% rename from x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/index.ts rename to x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/index.js diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.tsx b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.js similarity index 68% rename from x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.tsx rename to x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.js index b2cae278c0e77..4d2ab01e2a054 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.tsx +++ b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_badge/job_selector_badge.js @@ -4,32 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC } from 'react'; -import { EuiBadge, EuiBadgeProps } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { PropTypes } from 'prop-types'; +import { EuiBadge } from '@elastic/eui'; import { tabColor } from '../../../../../common/util/group_color_utils'; +import { i18n } from '@kbn/i18n'; -interface JobSelectorBadgeProps { - icon?: boolean; - id: string; - isGroup?: boolean; - numJobs?: number; - removeId?: Function; -} - -export const JobSelectorBadge: FC = ({ - icon, - id, - isGroup = false, - numJobs, - removeId, -}) => { +export function JobSelectorBadge({ icon, id, isGroup = false, numJobs, removeId }) { const color = isGroup ? tabColor(id) : 'hollow'; - let props = { color } as EuiBadgeProps; + let props = { color }; let jobCount; - if (icon === true && removeId) { - // @ts-ignore + if (icon === true) { props = { ...props, iconType: 'cross', @@ -51,4 +37,11 @@ export const JobSelectorBadge: FC = ({ {`${id}${jobCount ? jobCount : ''}`} ); +} +JobSelectorBadge.propTypes = { + icon: PropTypes.bool, + id: PropTypes.string.isRequired, + isGroup: PropTypes.bool, + numJobs: PropTypes.number, + removeId: PropTypes.func, }; diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx deleted file mode 100644 index 66aa05d2aaa97..0000000000000 --- a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx +++ /dev/null @@ -1,289 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { FC, useCallback, useEffect, useRef, useState } from 'react'; -import { i18n } from '@kbn/i18n'; -import { - EuiButton, - EuiButtonEmpty, - EuiFlexItem, - EuiFlexGroup, - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutFooter, - EuiFlyoutHeader, - EuiSwitch, - EuiTitle, -} from '@elastic/eui'; -import { NewSelectionIdBadges } from './new_selection_id_badges'; -// @ts-ignore -import { JobSelectorTable } from './job_selector_table'; -import { - getGroupsFromJobs, - getTimeRangeFromSelection, - normalizeTimes, -} from './job_select_service_utils'; -import { MlJobWithTimeRange } from '../../../../common/types/anomaly_detection_jobs'; -import { ml } from '../../services/ml_api_service'; -import { useMlKibana } from '../../contexts/kibana'; -import { JobSelectionMaps } from './job_selector'; - -export const BADGE_LIMIT = 10; -export const DEFAULT_GANTT_BAR_WIDTH = 299; // pixels - -export interface JobSelectorFlyoutProps { - dateFormatTz: string; - selectedIds?: string[]; - newSelection?: string[]; - onFlyoutClose: () => void; - onJobsFetched?: (maps: JobSelectionMaps) => void; - onSelectionChange?: (newSelection: string[]) => void; - onSelectionConfirmed: (payload: { - newSelection: string[]; - jobIds: string[]; - groups: Array<{ groupId: string; jobIds: string[] }>; - time: any; - }) => void; - singleSelection: boolean; - timeseriesOnly: boolean; - maps: JobSelectionMaps; - withTimeRangeSelector?: boolean; -} - -export const JobSelectorFlyout: FC = ({ - dateFormatTz, - selectedIds = [], - singleSelection, - timeseriesOnly, - onJobsFetched, - onSelectionChange, - onSelectionConfirmed, - onFlyoutClose, - maps, - withTimeRangeSelector = true, -}) => { - const { - services: { notifications }, - } = useMlKibana(); - - const [newSelection, setNewSelection] = useState(selectedIds); - - const [showAllBadges, setShowAllBadges] = useState(false); - const [applyTimeRange, setApplyTimeRange] = useState(true); - const [jobs, setJobs] = useState([]); - const [groups, setGroups] = useState([]); - const [ganttBarWidth, setGanttBarWidth] = useState(DEFAULT_GANTT_BAR_WIDTH); - const [jobGroupsMaps, setJobGroupsMaps] = useState(maps); - - const flyoutEl = useRef<{ flyout: HTMLElement }>(null); - - function applySelection() { - // allNewSelection will be a list of all job ids (including those from groups) selected from the table - const allNewSelection: string[] = []; - const groupSelection: Array<{ groupId: string; jobIds: string[] }> = []; - - newSelection.forEach(id => { - if (jobGroupsMaps.groupsMap[id] !== undefined) { - // Push all jobs from selected groups into the newSelection list - allNewSelection.push(...jobGroupsMaps.groupsMap[id]); - // if it's a group - push group obj to set in global state - groupSelection.push({ groupId: id, jobIds: jobGroupsMaps.groupsMap[id] }); - } else { - allNewSelection.push(id); - } - }); - // create a Set to remove duplicate values - const allNewSelectionUnique = Array.from(new Set(allNewSelection)); - - const time = applyTimeRange - ? getTimeRangeFromSelection(jobs, allNewSelectionUnique) - : undefined; - - onSelectionConfirmed({ - newSelection: allNewSelectionUnique, - jobIds: allNewSelectionUnique, - groups: groupSelection, - time, - }); - } - - function removeId(id: string) { - setNewSelection(newSelection.filter(item => item !== id)); - } - - function toggleTimerangeSwitch() { - setApplyTimeRange(!applyTimeRange); - } - - function clearSelection() { - setNewSelection([]); - } - - function handleNewSelection({ selectionFromTable }: { selectionFromTable: any }) { - setNewSelection(selectionFromTable); - } - - // Wrap handleResize in useCallback as it is a dependency for useEffect on line 131 below. - // Not wrapping it would cause this dependency to change on every render - const handleResize = useCallback(() => { - if (jobs.length > 0 && flyoutEl && flyoutEl.current && flyoutEl.current.flyout) { - // get all cols in flyout table - const tableHeaderCols: NodeListOf = flyoutEl.current.flyout.querySelectorAll( - 'table thead th' - ); - // get the width of the last col - const derivedWidth = tableHeaderCols[tableHeaderCols.length - 1].offsetWidth - 16; - const normalizedJobs = normalizeTimes(jobs, dateFormatTz, derivedWidth); - setJobs(normalizedJobs); - const { groups: updatedGroups } = getGroupsFromJobs(normalizedJobs); - setGroups(updatedGroups); - setGanttBarWidth(derivedWidth); - } - }, [dateFormatTz, jobs]); - - // Fetch jobs list on flyout open - useEffect(() => { - fetchJobs(); - }, []); - - async function fetchJobs() { - try { - const resp = await ml.jobs.jobsWithTimerange(dateFormatTz); - const normalizedJobs = normalizeTimes(resp.jobs, dateFormatTz, DEFAULT_GANTT_BAR_WIDTH); - const { groups: groupsWithTimerange, groupsMap } = getGroupsFromJobs(normalizedJobs); - setJobs(normalizedJobs); - setGroups(groupsWithTimerange); - setJobGroupsMaps({ groupsMap, jobsMap: resp.jobsMap }); - - if (onJobsFetched) { - onJobsFetched({ groupsMap, jobsMap: resp.jobsMap }); - } - } catch (e) { - console.error('Error fetching jobs with time range', e); // eslint-disable-line - const { toasts } = notifications; - toasts.addDanger({ - title: i18n.translate('xpack.ml.jobSelector.jobFetchErrorMessage', { - defaultMessage: 'An error occurred fetching jobs. Refresh and try again.', - }), - }); - } - } - - useEffect(() => { - // Ensure ganttBar width gets calculated on resize - window.addEventListener('resize', handleResize); - - return () => { - window.removeEventListener('resize', handleResize); - }; - }, [handleResize]); - - useEffect(() => { - handleResize(); - }, [handleResize, jobs]); - - return ( - - - -

- {i18n.translate('xpack.ml.jobSelector.flyoutTitle', { - defaultMessage: 'Job selection', - })} -

-
-
- - - - - setShowAllBadges(!showAllBadges)} - showAllBadges={showAllBadges} - /> - - - - - - {!singleSelection && newSelection.length > 0 && ( - - {i18n.translate('xpack.ml.jobSelector.clearAllFlyoutButton', { - defaultMessage: 'Clear all', - })} - - )} - - {withTimeRangeSelector && ( - - - - )} - - - - - - - - - - {i18n.translate('xpack.ml.jobSelector.applyFlyoutButton', { - defaultMessage: 'Apply', - })} - - - - - {i18n.translate('xpack.ml.jobSelector.closeFlyoutButton', { - defaultMessage: 'Close', - })} - - - - -
- ); -}; diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_table/job_selector_table.js b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_table/job_selector_table.js index c55e03776c09d..64793d15f1e4a 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_table/job_selector_table.js +++ b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_table/job_selector_table.js @@ -224,7 +224,7 @@ export function JobSelectorTable({ {jobs.length === 0 && } {jobs.length !== 0 && singleSelection === true && renderJobsTable()} - {jobs.length !== 0 && !singleSelection && renderTabs()} + {jobs.length !== 0 && singleSelection === undefined && renderTabs()} ); } diff --git a/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/index.ts b/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/index.js similarity index 100% rename from x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/index.ts rename to x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/index.js diff --git a/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx b/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.js similarity index 80% rename from x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx rename to x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.js index 4c018e72f3e10..67dce47323889 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.tsx +++ b/x-pack/plugins/ml/public/application/components/job_selector/new_selection_id_badges/new_selection_id_badges.js @@ -4,29 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, MouseEventHandler } from 'react'; +import React from 'react'; +import { PropTypes } from 'prop-types'; import { EuiFlexItem, EuiLink, EuiText } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import { JobSelectorBadge } from '../job_selector_badge'; -import { JobSelectionMaps } from '../job_selector'; - -interface NewSelectionIdBadgesProps { - limit: number; - maps: JobSelectionMaps; - newSelection: string[]; - onDeleteClick?: Function; - onLinkClick?: MouseEventHandler; - showAllBadges?: boolean; -} +import { i18n } from '@kbn/i18n'; -export const NewSelectionIdBadges: FC = ({ +export function NewSelectionIdBadges({ limit, maps, newSelection, onDeleteClick, onLinkClick, showAllBadges, -}) => { +}) { const badges = []; for (let i = 0; i < newSelection.length; i++) { @@ -69,5 +60,16 @@ export const NewSelectionIdBadges: FC = ({ ); } - return <>{badges}; + return badges; +} +NewSelectionIdBadges.propTypes = { + limit: PropTypes.number, + maps: PropTypes.shape({ + jobsMap: PropTypes.object, + groupsMap: PropTypes.object, + }), + newSelection: PropTypes.array, + onDeleteClick: PropTypes.func, + onLinkClick: PropTypes.func, + showAllBadges: PropTypes.bool, }; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx index 06d89ab782167..86ffc4a2614b9 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx @@ -41,7 +41,7 @@ import { useMlContext } from '../../contexts/ml'; import { kbnTypeToMLJobType } from '../../util/field_types_utils'; import { useTimefilter } from '../../contexts/kibana'; import { timeBasedIndexCheck, getQueryFromSavedSearch } from '../../util/index_utils'; -import { getTimeBucketsFromCache } from '../../util/time_buckets'; +import { TimeBuckets } from '../../util/time_buckets'; import { useUrlState } from '../../util/url_state'; import { FieldRequestConfig, FieldVisConfig } from './common'; import { ActionsPanel } from './components/actions_panel'; @@ -318,7 +318,7 @@ export const Page: FC = () => { // Obtain the interval to use for date histogram aggregations // (such as the document count chart). Aim for 75 bars. - const buckets = getTimeBucketsFromCache(); + const buckets = new TimeBuckets(); const tf = timefilter as any; let earliest: number | undefined; diff --git a/x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.tsx.snap b/x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.js.snap similarity index 100% rename from x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.tsx.snap rename to x-pack/plugins/ml/public/application/explorer/__snapshots__/explorer_swimlane.test.js.snap diff --git a/x-pack/plugins/ml/public/application/explorer/_explorer.scss b/x-pack/plugins/ml/public/application/explorer/_explorer.scss index cfcba081983c2..9fb2f0c3bed94 100644 --- a/x-pack/plugins/ml/public/application/explorer/_explorer.scss +++ b/x-pack/plugins/ml/public/application/explorer/_explorer.scss @@ -106,6 +106,164 @@ padding: 0; margin-bottom: $euiSizeS; + div.ml-swimlanes { + margin: 0px 0px 0px 10px; + + div.cells-marker-container { + margin-left: 176px; + height: 22px; + white-space: nowrap; + + // background-color: #CCC; + .sl-cell { + height: 10px; + display: inline-block; + vertical-align: top; + margin-top: 16px; + text-align: center; + visibility: hidden; + cursor: default; + + i { + color: $euiColorDarkShade; + } + } + + .sl-cell-hover { + visibility: visible; + + i { + display: block; + margin-top: -6px; + } + } + + .sl-cell-active-hover { + visibility: visible; + + .floating-time-label { + display: inline-block; + } + } + } + + div.lane { + height: 30px; + border-bottom: 0px; + border-radius: 2px; + margin-top: -1px; + white-space: nowrap; + + div.lane-label { + display: inline-block; + font-size: 13px; + height: 30px; + text-align: right; + vertical-align: middle; + border-radius: 2px; + padding-right: 5px; + margin-right: 5px; + border: 1px solid transparent; + overflow: hidden; + text-overflow: ellipsis; + } + + div.lane-label.lane-label-masked { + opacity: 0.3; + } + + div.cells-container { + border: $euiBorderThin; + border-right: 0px; + display: inline-block; + height: 30px; + vertical-align: middle; + background-color: $euiColorEmptyShade; + + .sl-cell { + color: $euiColorEmptyShade; + cursor: default; + display: inline-block; + height: 29px; + border-right: $euiBorderThin; + vertical-align: top; + position: relative; + + .sl-cell-inner, + .sl-cell-inner-dragselect { + height: 26px; + margin: 1px; + border-radius: 2px; + text-align: center; + } + + .sl-cell-inner.sl-cell-inner-masked { + opacity: 0.2; + } + + .sl-cell-inner.sl-cell-inner-selected, + .sl-cell-inner-dragselect.sl-cell-inner-selected { + border: 2px solid $euiColorDarkShade; + } + + .sl-cell-inner.sl-cell-inner-selected.sl-cell-inner-masked, + .sl-cell-inner-dragselect.sl-cell-inner-selected.sl-cell-inner-masked { + border: 2px solid $euiColorFullShade; + opacity: 0.4; + } + } + + .sl-cell:hover { + .sl-cell-inner { + opacity: 0.8; + cursor: pointer; + } + } + + .sl-cell.ds-selected { + + .sl-cell-inner, + .sl-cell-inner-dragselect { + border: 2px solid $euiColorDarkShade; + border-radius: 2px; + opacity: 1; + } + } + + } + } + + div.lane:last-child { + div.cells-container { + .sl-cell { + border-bottom: $euiBorderThin; + } + } + } + + .time-tick-labels { + height: 25px; + margin-top: $euiSizeXS / 2; + margin-left: 175px; + + /* hide d3's domain line */ + path.domain { + display: none; + } + + /* hide d3's tick line */ + g.tick line { + display: none; + } + + /* override d3's default tick styles */ + g.tick text { + font-size: 11px; + fill: $euiColorMediumShade; + } + } + } + line.gridLine { stroke: $euiBorderColor; fill: none; @@ -170,161 +328,3 @@ } } } - -.ml-swimlanes { - margin: 0px 0px 0px 10px; - - div.cells-marker-container { - margin-left: 176px; - height: 22px; - white-space: nowrap; - - // background-color: #CCC; - .sl-cell { - height: 10px; - display: inline-block; - vertical-align: top; - margin-top: 16px; - text-align: center; - visibility: hidden; - cursor: default; - - i { - color: $euiColorDarkShade; - } - } - - .sl-cell-hover { - visibility: visible; - - i { - display: block; - margin-top: -6px; - } - } - - .sl-cell-active-hover { - visibility: visible; - - .floating-time-label { - display: inline-block; - } - } - } - - div.lane { - height: 30px; - border-bottom: 0px; - border-radius: 2px; - margin-top: -1px; - white-space: nowrap; - - div.lane-label { - display: inline-block; - font-size: 13px; - height: 30px; - text-align: right; - vertical-align: middle; - border-radius: 2px; - padding-right: 5px; - margin-right: 5px; - border: 1px solid transparent; - overflow: hidden; - text-overflow: ellipsis; - } - - div.lane-label.lane-label-masked { - opacity: 0.3; - } - - div.cells-container { - border: $euiBorderThin; - border-right: 0px; - display: inline-block; - height: 30px; - vertical-align: middle; - background-color: $euiColorEmptyShade; - - .sl-cell { - color: $euiColorEmptyShade; - cursor: default; - display: inline-block; - height: 29px; - border-right: $euiBorderThin; - vertical-align: top; - position: relative; - - .sl-cell-inner, - .sl-cell-inner-dragselect { - height: 26px; - margin: 1px; - border-radius: 2px; - text-align: center; - } - - .sl-cell-inner.sl-cell-inner-masked { - opacity: 0.2; - } - - .sl-cell-inner.sl-cell-inner-selected, - .sl-cell-inner-dragselect.sl-cell-inner-selected { - border: 2px solid $euiColorDarkShade; - } - - .sl-cell-inner.sl-cell-inner-selected.sl-cell-inner-masked, - .sl-cell-inner-dragselect.sl-cell-inner-selected.sl-cell-inner-masked { - border: 2px solid $euiColorFullShade; - opacity: 0.4; - } - } - - .sl-cell:hover { - .sl-cell-inner { - opacity: 0.8; - cursor: pointer; - } - } - - .sl-cell.ds-selected { - - .sl-cell-inner, - .sl-cell-inner-dragselect { - border: 2px solid $euiColorDarkShade; - border-radius: 2px; - opacity: 1; - } - } - - } - } - - div.lane:last-child { - div.cells-container { - .sl-cell { - border-bottom: $euiBorderThin; - } - } - } - - .time-tick-labels { - height: 25px; - margin-top: $euiSizeXS / 2; - margin-left: 175px; - - /* hide d3's domain line */ - path.domain { - display: none; - } - - /* hide d3's tick line */ - g.tick line { - display: none; - } - - /* override d3's default tick styles */ - g.tick text { - font-size: 11px; - fill: $euiColorMediumShade; - } - } -} diff --git a/x-pack/plugins/ml/public/application/explorer/explorer.js b/x-pack/plugins/ml/public/application/explorer/explorer.js index 86d16776b68e2..d61d56d07b644 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer.js @@ -36,8 +36,9 @@ import { ExplorerNoJobsFound, ExplorerNoResultsFound, } from './components'; +import { ChartTooltip } from '../components/chart_tooltip'; import { ExplorerSwimlane } from './explorer_swimlane'; -import { getTimeBucketsFromCache } from '../util/time_buckets'; +import { TimeBuckets } from '../util/time_buckets'; import { InfluencersList } from '../components/influencers_list'; import { ALLOW_CELL_RANGE_SELECTION, @@ -80,7 +81,6 @@ import { AnomaliesTable } from '../components/anomalies_table/anomalies_table'; import { ResizeChecker } from '../../../../../../src/plugins/kibana_utils/public'; import { getTimefilter, getToastNotifications } from '../util/dependency_cache'; -import { MlTooltipComponent } from '../components/chart_tooltip'; function mapSwimlaneOptionsToEuiOptions(options) { return options.map(option => ({ @@ -179,8 +179,6 @@ export class Explorer extends React.Component { // Required to redraw the time series chart when the container is resized. this.resizeChecker = new ResizeChecker(this.resizeRef.current); this.resizeChecker.on('resize', this.resizeHandler); - - this.timeBuckets = getTimeBucketsFromCache(); } componentWillUnmount() { @@ -360,6 +358,9 @@ export class Explorer extends React.Component { return (
+ {/* Make sure ChartTooltip is inside wrapping div with 0px left/right padding so positioning can be inferred correctly. */} + + {noInfluencersConfigured === false && influencers !== undefined && (
{showOverallSwimlane && ( - - {tooltipService => ( - - )} - + )}
@@ -498,22 +494,17 @@ export class Explorer extends React.Component { onMouseLeave={this.onSwimlaneLeaveHandler} data-test-subj="mlAnomalyExplorerSwimlaneViewBy" > - - {tooltipService => ( - - )} - +
)} diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js index 03426869b0ccf..5fc1160093a49 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js @@ -29,8 +29,9 @@ import { removeLabelOverlap, } from '../../util/chart_utils'; import { LoadingIndicator } from '../../components/loading_indicator/loading_indicator'; -import { getTimeBucketsFromCache } from '../../util/time_buckets'; +import { TimeBuckets } from '../../util/time_buckets'; import { mlFieldFormatService } from '../../services/field_format_service'; +import { mlChartTooltipService } from '../../components/chart_tooltip/chart_tooltip_service'; import { CHART_TYPE } from '../explorer_constants'; @@ -49,7 +50,6 @@ export class ExplorerChartDistribution extends React.Component { static propTypes = { seriesConfig: PropTypes.object, severity: PropTypes.number, - tooltipService: PropTypes.object.isRequired, }; componentDidMount() { @@ -61,7 +61,7 @@ export class ExplorerChartDistribution extends React.Component { } renderChart() { - const { tooManyBuckets, tooltipService } = this.props; + const { tooManyBuckets } = this.props; const element = this.rootNode; const config = this.props.seriesConfig; @@ -259,7 +259,7 @@ export class ExplorerChartDistribution extends React.Component { function drawRareChartAxes() { // Get the scaled date format to use for x axis tick labels. - const timeBuckets = getTimeBucketsFromCache(); + const timeBuckets = new TimeBuckets(); const bounds = { min: moment(config.plotEarliest), max: moment(config.plotLatest) }; timeBuckets.setBounds(bounds); timeBuckets.setInterval('auto'); @@ -397,7 +397,7 @@ export class ExplorerChartDistribution extends React.Component { .on('mouseover', function(d) { showLineChartTooltip(d, this); }) - .on('mouseout', () => tooltipService.hide()); + .on('mouseout', () => mlChartTooltipService.hide()); // Update all dots to new positions. dots @@ -550,7 +550,7 @@ export class ExplorerChartDistribution extends React.Component { }); } - tooltipService.show(tooltipData, circle, { + mlChartTooltipService.show(tooltipData, circle, { x: LINE_CHART_ANOMALY_RADIUS * 3, y: LINE_CHART_ANOMALY_RADIUS * 2, }); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js index 06fd82204c1e1..71d777db5b2ec 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js @@ -10,13 +10,11 @@ import seriesConfig from './__mocks__/mock_series_config_rare.json'; // Mock TimeBuckets and mlFieldFormatService, they don't play well // with the jest based test setup yet. jest.mock('../../util/time_buckets', () => ({ - getTimeBucketsFromCache: jest.fn(() => { - return { - setBounds: jest.fn(), - setInterval: jest.fn(), - getScaledDateFormat: jest.fn(), - }; - }), + TimeBuckets: function() { + this.setBounds = jest.fn(); + this.setInterval = jest.fn(); + this.getScaledDateFormat = jest.fn(); + }, })); jest.mock('../../services/field_format_service', () => ({ mlFieldFormatService: { @@ -45,16 +43,8 @@ describe('ExplorerChart', () => { afterEach(() => (SVGElement.prototype.getBBox = originalGetBBox)); test('Initialize', () => { - const mockTooltipService = { - show: jest.fn(), - hide: jest.fn(), - }; - const wrapper = mountWithIntl( - + ); // without setting any attributes and corresponding data @@ -69,16 +59,10 @@ describe('ExplorerChart', () => { loading: true, }; - const mockTooltipService = { - show: jest.fn(), - hide: jest.fn(), - }; - const wrapper = mountWithIntl( ); @@ -99,18 +83,12 @@ describe('ExplorerChart', () => { chartLimits: chartLimits(chartData), }; - const mockTooltipService = { - show: jest.fn(), - hide: jest.fn(), - }; - // We create the element including a wrapper which sets the width: return mountWithIntl(
); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js index 82041af39ca15..dd9479be931a7 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js @@ -38,9 +38,10 @@ import { showMultiBucketAnomalyTooltip, } from '../../util/chart_utils'; import { LoadingIndicator } from '../../components/loading_indicator/loading_indicator'; -import { getTimeBucketsFromCache } from '../../util/time_buckets'; +import { TimeBuckets } from '../../util/time_buckets'; import { mlEscape } from '../../util/string_utils'; import { mlFieldFormatService } from '../../services/field_format_service'; +import { mlChartTooltipService } from '../../components/chart_tooltip/chart_tooltip_service'; import { i18n } from '@kbn/i18n'; @@ -52,7 +53,6 @@ export class ExplorerChartSingleMetric extends React.Component { tooManyBuckets: PropTypes.bool, seriesConfig: PropTypes.object, severity: PropTypes.number.isRequired, - tooltipService: PropTypes.object.isRequired, }; componentDidMount() { @@ -64,7 +64,7 @@ export class ExplorerChartSingleMetric extends React.Component { } renderChart() { - const { tooManyBuckets, tooltipService } = this.props; + const { tooManyBuckets } = this.props; const element = this.rootNode; const config = this.props.seriesConfig; @@ -191,7 +191,7 @@ export class ExplorerChartSingleMetric extends React.Component { function drawLineChartAxes() { // Get the scaled date format to use for x axis tick labels. - const timeBuckets = getTimeBucketsFromCache(); + const timeBuckets = new TimeBuckets(); const bounds = { min: moment(config.plotEarliest), max: moment(config.plotLatest) }; timeBuckets.setBounds(bounds); timeBuckets.setInterval('auto'); @@ -309,7 +309,7 @@ export class ExplorerChartSingleMetric extends React.Component { .on('mouseover', function(d) { showLineChartTooltip(d, this); }) - .on('mouseout', () => tooltipService.hide()); + .on('mouseout', () => mlChartTooltipService.hide()); const isAnomalyVisible = d => _.has(d, 'anomalyScore') && Number(d.anomalyScore) >= severity; @@ -354,7 +354,7 @@ export class ExplorerChartSingleMetric extends React.Component { .on('mouseover', function(d) { showLineChartTooltip(d, this); }) - .on('mouseout', () => tooltipService.hide()); + .on('mouseout', () => mlChartTooltipService.hide()); // Add rectangular markers for any scheduled events. const scheduledEventMarkers = lineChartGroup @@ -503,7 +503,7 @@ export class ExplorerChartSingleMetric extends React.Component { }); } - tooltipService.show(tooltipData, circle, { + mlChartTooltipService.show(tooltipData, circle, { x: LINE_CHART_ANOMALY_RADIUS * 3, y: LINE_CHART_ANOMALY_RADIUS * 2, }); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js index 54f541ceb7c3d..ca3e52308a936 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js @@ -10,13 +10,11 @@ import seriesConfig from './__mocks__/mock_series_config_filebeat.json'; // Mock TimeBuckets and mlFieldFormatService, they don't play well // with the jest based test setup yet. jest.mock('../../util/time_buckets', () => ({ - getTimeBucketsFromCache: jest.fn(() => { - return { - setBounds: jest.fn(), - setInterval: jest.fn(), - getScaledDateFormat: jest.fn(), - }; - }), + TimeBuckets: function() { + this.setBounds = jest.fn(); + this.setInterval = jest.fn(); + this.getScaledDateFormat = jest.fn(); + }, })); jest.mock('../../services/field_format_service', () => ({ mlFieldFormatService: { @@ -45,16 +43,8 @@ describe('ExplorerChart', () => { afterEach(() => (SVGElement.prototype.getBBox = originalGetBBox)); test('Initialize', () => { - const mockTooltipService = { - show: jest.fn(), - hide: jest.fn(), - }; - const wrapper = mountWithIntl( - + ); // without setting any attributes and corresponding data @@ -69,16 +59,10 @@ describe('ExplorerChart', () => { loading: true, }; - const mockTooltipService = { - show: jest.fn(), - hide: jest.fn(), - }; - const wrapper = mountWithIntl( ); @@ -99,18 +83,12 @@ describe('ExplorerChart', () => { chartLimits: chartLimits(chartData), }; - const mockTooltipService = { - show: jest.fn(), - hide: jest.fn(), - }; - // We create the element including a wrapper which sets the width: return mountWithIntl(
); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js index 5b95931d31ab6..99de38c1e0a84 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import $ from 'jquery'; + import React from 'react'; import { @@ -27,7 +29,6 @@ import { ExplorerChartLabel } from './components/explorer_chart_label'; import { CHART_TYPE } from '../explorer_constants'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { MlTooltipComponent } from '../../components/chart_tooltip'; const textTooManyBuckets = i18n.translate('xpack.ml.explorer.charts.tooManyBucketsDescription', { defaultMessage: @@ -120,29 +121,19 @@ function ExplorerChartContainer({ series, severity, tooManyBuckets, wrapLabel }) chartType === CHART_TYPE.POPULATION_DISTRIBUTION ) { return ( - - {tooltipService => ( - - )} - + ); } return ( - - {tooltipService => ( - - )} - + ); })()} @@ -150,36 +141,48 @@ function ExplorerChartContainer({ series, severity, tooManyBuckets, wrapLabel }) } // Flex layout wrapper for all explorer charts -export const ExplorerChartsContainer = ({ - chartsPerRow, - seriesToPlot, - severity, - tooManyBuckets, -}) => { - // doesn't allow a setting of `columns={1}` when chartsPerRow would be 1. - // If that's the case we trick it doing that with the following settings: - const chartsWidth = chartsPerRow === 1 ? 'calc(100% - 20px)' : 'auto'; - const chartsColumns = chartsPerRow === 1 ? 0 : chartsPerRow; - - const wrapLabel = seriesToPlot.some(series => isLabelLengthAboveThreshold(series)); +export class ExplorerChartsContainer extends React.Component { + componentDidMount() { + // Create a div for the tooltip. + $('.ml-explorer-charts-tooltip').remove(); + $('body').append( + '