diff --git a/package.json b/package.json index e91c5e96b78ab..06dfb4cdfe387 100644 --- a/package.json +++ b/package.json @@ -144,7 +144,6 @@ "@kbn/test-subj-selector": "0.2.1", "@kbn/ui-framework": "1.0.0", "@kbn/ui-shared-deps": "1.0.0", - "@types/tar": "^4.0.3", "JSONStream": "1.3.5", "abortcontroller-polyfill": "^1.4.0", "accept": "3.0.2", @@ -390,9 +389,10 @@ "@types/styled-components": "^5.1.0", "@types/supertest": "^2.0.5", "@types/supertest-as-promised": "^2.0.38", + "@types/tar": "^4.0.3", + "@types/testing-library__dom": "^6.10.0", "@types/testing-library__react": "^9.1.2", "@types/testing-library__react-hooks": "^3.1.0", - "@types/testing-library__dom": "^6.10.0", "@types/type-detect": "^4.0.1", "@types/uuid": "^3.4.4", "@types/vinyl-fs": "^2.4.11", @@ -486,12 +486,10 @@ "prettier": "^2.0.5", "proxyquire": "1.8.0", "react-popper-tooltip": "^2.10.1", - "react-textarea-autosize": "^7.1.2", "regenerate": "^1.4.0", "sass-lint": "^1.12.1", "selenium-webdriver": "^4.0.0-alpha.7", "simple-git": "1.116.0", - "simplebar-react": "^2.1.0", "sinon": "^7.4.2", "strip-ansi": "^3.0.1", "supertest": "^3.1.0", diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker index d1fb544de733c..745a3d1f0c830 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker @@ -235,6 +235,8 @@ kibana_vars=( xpack.security.session.lifespan xpack.security.loginAssistanceMessage xpack.security.loginHelp + xpack.spaces.enabled + xpack.spaces.maxSpaces telemetry.allowChangingOptInStatus telemetry.enabled telemetry.optIn diff --git a/src/plugins/dashboard/public/index.ts b/src/plugins/dashboard/public/index.ts index 28606b7dd9784..17968dd0281e6 100644 --- a/src/plugins/dashboard/public/index.ts +++ b/src/plugins/dashboard/public/index.ts @@ -32,8 +32,10 @@ export { export { DashboardConstants, createDashboardEditUrl } from './dashboard_constants'; export { DashboardStart, DashboardUrlGenerator } from './plugin'; -export { DASHBOARD_APP_URL_GENERATOR } from './url_generator'; +export { DASHBOARD_APP_URL_GENERATOR, createDashboardUrlGenerator } from './url_generator'; export { addEmbeddableToDashboardUrl } from './url_utils/url_helper'; +export { SavedObjectDashboard } from './saved_dashboards'; +export { SavedDashboardPanel } from './types'; export function plugin(initializerContext: PluginInitializerContext) { return new DashboardPlugin(initializerContext); diff --git a/src/plugins/dashboard/public/url_generator.ts b/src/plugins/dashboard/public/url_generator.ts index d6805b2d94119..188de7fd857be 100644 --- a/src/plugins/dashboard/public/url_generator.ts +++ b/src/plugins/dashboard/public/url_generator.ts @@ -28,6 +28,7 @@ import { import { setStateToKbnUrl } from '../../kibana_utils/public'; import { UrlGeneratorsDefinition, UrlGeneratorState } from '../../share/public'; import { SavedObjectLoader } from '../../saved_objects/public'; +import { ViewMode } from '../../embeddable/public'; export const STATE_STORAGE_KEY = '_a'; export const GLOBAL_STATE_STORAGE_KEY = '_g'; @@ -73,6 +74,11 @@ export type DashboardAppLinkGeneratorState = UrlGeneratorState<{ * true is default */ preserveSavedFilters?: boolean; + + /** + * View mode of the dashboard. + */ + viewMode?: ViewMode; }>; export const createDashboardUrlGenerator = ( @@ -123,6 +129,7 @@ export const createDashboardUrlGenerator = ( cleanEmptyKeys({ query: state.query, filters: filters?.filter((f) => !esFilters.isFilterPinned(f)), + viewMode: state.viewMode, }), { useHash }, `${appBasePath}#/${hash}` diff --git a/test/accessibility/services/a11y/a11y.ts b/test/accessibility/services/a11y/a11y.ts index 525445c60818a..35cef2b81d64e 100644 --- a/test/accessibility/services/a11y/a11y.ts +++ b/test/accessibility/services/a11y/a11y.ts @@ -99,6 +99,9 @@ export function A11yProvider({ getService }: FtrProviderContext) { 'color-contrast': { enabled: false, }, + bypass: { + enabled: false, // disabled because it's too flaky + }, }, }; diff --git a/test/functional/apps/dashboard/dashboard_snapshots.js b/test/functional/apps/dashboard/dashboard_snapshots.js index 787e839aa08a5..20bc30c889d65 100644 --- a/test/functional/apps/dashboard/dashboard_snapshots.js +++ b/test/functional/apps/dashboard/dashboard_snapshots.js @@ -28,7 +28,8 @@ export default function ({ getService, getPageObjects, updateBaselines }) { const dashboardPanelActions = getService('dashboardPanelActions'); const dashboardAddPanel = getService('dashboardAddPanel'); - describe('dashboard snapshots', function describeIndexTests() { + // FLAKY: https://github.com/elastic/kibana/issues/52854 + describe.skip('dashboard snapshots', function describeIndexTests() { before(async function () { await esArchiver.load('dashboard/current/kibana'); await kibanaServer.uiSettings.replace({ diff --git a/vars/kibanaPipeline.groovy b/vars/kibanaPipeline.groovy index ca9af5d2346cd..4a5c8dff8a230 100644 --- a/vars/kibanaPipeline.groovy +++ b/vars/kibanaPipeline.groovy @@ -98,7 +98,7 @@ def withGcsArtifactUpload(workerName, closure) { def uploadPrefix = "kibana-ci-artifacts/jobs/${env.JOB_NAME}/${BUILD_NUMBER}/${workerName}" def ARTIFACT_PATTERNS = [ 'target/kibana-*', - 'target/kibana-siem/**/*.png', + 'target/kibana-security-solution/**/*.png', 'target/junit/**/*', 'test/**/screenshots/**/*.png', 'test/functional/failure_debug/html/*.html', diff --git a/x-pack/package.json b/x-pack/package.json index e24d75cc0d968..b40a1e4364251 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -61,6 +61,7 @@ "@types/d3-shape": "^1.3.1", "@types/d3-time": "^1.0.10", "@types/d3-time-format": "^2.1.1", + "@types/dragselect": "^1.13.1", "@types/elasticsearch": "^5.0.33", "@types/fancy-log": "^1.3.1", "@types/file-saver": "^2.0.0", diff --git a/x-pack/plugins/apm/public/components/app/ServiceDetails/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceDetails/index.tsx index 0dbde5ea86a18..2d52ad88d20dc 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceDetails/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceDetails/index.tsx @@ -4,8 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiButtonEmpty, +} from '@elastic/eui'; import React from 'react'; +import { i18n } from '@kbn/i18n'; import { ApmHeader } from '../../shared/ApmHeader'; import { ServiceDetailTabs } from './ServiceDetailTabs'; import { ServiceIntegrations } from './ServiceIntegrations'; @@ -33,6 +39,12 @@ export function ServiceDetails({ tab }: Props) { const isAlertingAvailable = isAlertingPluginEnabled && (canReadAlerts || canSaveAlerts); + const { core } = useApmPluginContext(); + + const ADD_DATA_LABEL = i18n.translate('xpack.apm.addDataButtonLabel', { + defaultMessage: 'Add data', + }); + return (
@@ -53,6 +65,16 @@ export function ServiceDetails({ tab }: Props) { /> )} + + + {ADD_DATA_LABEL} + + diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap b/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap index e89acca55d4fe..241ba8c244496 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap @@ -114,7 +114,7 @@ NodeList [ - Setup Instructions + Setup instructions diff --git a/x-pack/plugins/apm/public/components/shared/Links/SetupInstructionsLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/SetupInstructionsLink.tsx index e85605e42981c..a5bcec1501ad3 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/SetupInstructionsLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/SetupInstructionsLink.tsx @@ -12,10 +12,14 @@ import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; const SETUP_INSTRUCTIONS_LABEL = i18n.translate( 'xpack.apm.setupInstructionsButtonLabel', { - defaultMessage: 'Setup Instructions', + defaultMessage: 'Setup instructions', } ); +const ADD_DATA_LABEL = i18n.translate('xpack.apm.addDataButtonLabel', { + defaultMessage: 'Add data', +}); + // renders a filled button or a link as a kibana link to setup instructions export function SetupInstructionsLink({ buttonFill = false, @@ -30,8 +34,8 @@ export function SetupInstructionsLink({ {SETUP_INSTRUCTIONS_LABEL} ) : ( - - {SETUP_INSTRUCTIONS_LABEL} + + {ADD_DATA_LABEL} )} diff --git a/x-pack/plugins/apm/server/feature.ts b/x-pack/plugins/apm/server/feature.ts index 60a7be9391eea..80f722bae0868 100644 --- a/x-pack/plugins/apm/server/feature.ts +++ b/x-pack/plugins/apm/server/feature.ts @@ -70,3 +70,6 @@ export const APM_FEATURE = { }, }, }; + +export const APM_SERVICE_MAPS_FEATURE_NAME = 'APM service maps'; +export const APM_SERVICE_MAPS_LICENSE_TYPE = 'platinum'; diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index f0a05dfc0df30..eb781ee078307 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -14,7 +14,7 @@ import { import { Observable, combineLatest } from 'rxjs'; import { map, take } from 'rxjs/operators'; import { ObservabilityPluginSetup } from '../../observability/server'; -import { SecurityPluginSetup } from '../../security/public'; +import { SecurityPluginSetup } from '../../security/server'; import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; import { TaskManagerSetupContract } from '../../task_manager/server'; import { AlertingPlugin } from '../../alerts/server'; @@ -28,11 +28,19 @@ import { APMConfig, mergeConfigs, APMXPackConfig } from '.'; import { HomeServerPluginSetup } from '../../../../src/plugins/home/server'; import { CloudSetup } from '../../cloud/server'; import { getInternalSavedObjectsClient } from './lib/helpers/get_internal_saved_objects_client'; -import { LicensingPluginSetup } from '../../licensing/public'; +import { + LicensingPluginSetup, + LicensingPluginStart, +} from '../../licensing/server'; import { registerApmAlerts } from './lib/alerts/register_apm_alerts'; import { createApmTelemetry } from './lib/apm_telemetry'; + import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; -import { APM_FEATURE } from './feature'; +import { + APM_FEATURE, + APM_SERVICE_MAPS_FEATURE_NAME, + APM_SERVICE_MAPS_LICENSE_TYPE, +} from './feature'; import { apmIndices, apmTelemetry } from './saved_objects'; import { createElasticCloudInstructions } from './tutorial/elastic_cloud'; import { MlPluginSetup } from '../../ml/server'; @@ -120,16 +128,25 @@ export class APMPlugin implements Plugin { elasticCloud: createElasticCloudInstructions(plugins.cloud), }; }); + plugins.features.registerFeature(APM_FEATURE); + plugins.licensing.featureUsage.register( + APM_SERVICE_MAPS_FEATURE_NAME, + APM_SERVICE_MAPS_LICENSE_TYPE + ); - createApmApi().init(core, { - config$: mergedConfig$, - logger: this.logger!, - plugins: { - observability: plugins.observability, - security: plugins.security, - ml: plugins.ml, - }, + core.getStartServices().then(([_coreStart, pluginsStart]) => { + createApmApi().init(core, { + config$: mergedConfig$, + logger: this.logger!, + plugins: { + licensing: (pluginsStart as { licensing: LicensingPluginStart }) + .licensing, + observability: plugins.observability, + security: plugins.security, + ml: plugins.ml, + }, + }); }); 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 3d3e26f680e0d..f5db936c00d3a 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 @@ -9,6 +9,7 @@ import { CoreSetup, Logger } from 'src/core/server'; import { Params } from '../typings'; import { BehaviorSubject } from 'rxjs'; import { APMConfig } from '../..'; +import { LicensingPluginStart } from '../../../../licensing/server'; const getCoreMock = () => { const get = jest.fn(); @@ -40,7 +41,7 @@ const getCoreMock = () => { logger: ({ error: jest.fn(), } as unknown) as Logger, - plugins: {}, + plugins: { licensing: {} as LicensingPluginStart }, }, }; }; diff --git a/x-pack/plugins/apm/server/routes/service_map.ts b/x-pack/plugins/apm/server/routes/service_map.ts index df0403be7b975..3937c18b3fe5e 100644 --- a/x-pack/plugins/apm/server/routes/service_map.ts +++ b/x-pack/plugins/apm/server/routes/service_map.ts @@ -15,6 +15,7 @@ import { getServiceMap } from '../lib/service_map/get_service_map'; import { getServiceMapServiceNodeInfo } from '../lib/service_map/get_service_map_service_node_info'; import { createRoute } from './create_route'; import { rangeRt } from './default_api_types'; +import { APM_SERVICE_MAPS_FEATURE_NAME } from '../feature'; export const serviceMapRoute = createRoute(() => ({ path: '/api/apm/service-map', @@ -35,6 +36,10 @@ export const serviceMapRoute = createRoute(() => ({ throw Boom.forbidden(invalidLicenseMessage); } + context.plugins.licensing.featureUsage.notifyUsage( + APM_SERVICE_MAPS_FEATURE_NAME + ); + const setup = await setupRequest(context, request); const { query: { serviceName, environment }, diff --git a/x-pack/plugins/apm/server/routes/typings.ts b/x-pack/plugins/apm/server/routes/typings.ts index bc31cb7a582af..f30a9d18d7aea 100644 --- a/x-pack/plugins/apm/server/routes/typings.ts +++ b/x-pack/plugins/apm/server/routes/typings.ts @@ -14,10 +14,11 @@ import { import { PickByValue, Optional } from 'utility-types'; import { Observable } from 'rxjs'; import { Server } from 'hapi'; +import { LicensingPluginStart } from '../../../licensing/server'; import { ObservabilityPluginSetup } from '../../../observability/server'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { FetchOptions } from '../../public/services/rest/callApi'; -import { SecurityPluginSetup } from '../../../security/public'; +import { SecurityPluginSetup } from '../../../security/server'; import { MlPluginSetup } from '../../../ml/server'; import { APMConfig } from '..'; @@ -66,6 +67,7 @@ export type APMRequestHandlerContext< config: APMConfig; logger: Logger; plugins: { + licensing: LicensingPluginStart; observability?: ObservabilityPluginSetup; security?: SecurityPluginSetup; ml?: MlPluginSetup; @@ -114,6 +116,7 @@ export interface ServerAPI { config$: Observable; logger: Logger; plugins: { + licensing: LicensingPluginStart; observability?: ObservabilityPluginSetup; security?: SecurityPluginSetup; ml?: MlPluginSetup; diff --git a/x-pack/plugins/infra/public/pages/logs/page_content.tsx b/x-pack/plugins/infra/public/pages/logs/page_content.tsx index 14c53557ba2c7..78b7f86993cbd 100644 --- a/x-pack/plugins/infra/public/pages/logs/page_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/page_content.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; import { Route, Switch } from 'react-router-dom'; @@ -32,6 +32,8 @@ export const LogsPageContent: React.FunctionComponent = () => { const { initialize } = useLogSourceContext(); + const kibana = useKibana(); + useMount(() => { initialize(); }); @@ -88,6 +90,16 @@ export const LogsPageContent: React.FunctionComponent = () => { + + + {ADD_DATA_LABEL} + + @@ -123,3 +135,7 @@ const settingsTabTitle = i18n.translate('xpack.infra.logs.index.settingsTabTitle }); const feedbackLinkUrl = 'https://discuss.elastic.co/c/logs'; + +const ADD_DATA_LABEL = i18n.translate('xpack.infra.logsHeaderAddDataButtonLabel', { + defaultMessage: 'Add data', +}); diff --git a/x-pack/plugins/infra/public/pages/metrics/index.tsx b/x-pack/plugins/infra/public/pages/metrics/index.tsx index 35a6cadc786f6..05296fbf6b0a3 100644 --- a/x-pack/plugins/infra/public/pages/metrics/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/index.tsx @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { Route, RouteComponentProps, Switch } from 'react-router-dom'; -import { EuiErrorBoundary, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; +import { EuiErrorBoundary, EuiFlexItem, EuiFlexGroup, EuiButtonEmpty } from '@elastic/eui'; import { DocumentTitle } from '../../components/document_title'; import { HelpCenterContent } from '../../components/help_center_content'; import { RoutedTabs } from '../../components/navigation/routed_tabs'; @@ -32,9 +32,15 @@ import { WaffleFiltersProvider } from './inventory_view/hooks/use_waffle_filters import { InventoryAlertDropdown } from '../../components/alerting/inventory/alert_dropdown'; import { MetricsAlertDropdown } from '../../alerting/metric_threshold/components/alert_dropdown'; +const ADD_DATA_LABEL = i18n.translate('xpack.infra.metricsHeaderAddDataButtonLabel', { + defaultMessage: 'Add data', +}); + export const InfrastructurePage = ({ match }: RouteComponentProps) => { const uiCapabilities = useKibana().services.application?.capabilities; + const kibana = useKibana(); + return ( @@ -102,6 +108,18 @@ export const InfrastructurePage = ({ match }: RouteComponentProps) => { + + + {ADD_DATA_LABEL} + + diff --git a/x-pack/plugins/ingest_manager/common/services/config_to_yaml.ts b/x-pack/plugins/ingest_manager/common/services/config_to_yaml.ts index 9dfd76b9ddd21..a3bef72e8db5a 100644 --- a/x-pack/plugins/ingest_manager/common/services/config_to_yaml.ts +++ b/x-pack/plugins/ingest_manager/common/services/config_to_yaml.ts @@ -11,10 +11,11 @@ const CONFIG_KEYS_ORDER = [ 'name', 'revision', 'type', - 'outputs', 'settings', - 'datasources', + 'outputs', + 'inputs', 'enabled', + 'use_output', 'package', 'input', ]; diff --git a/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.test.ts b/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.test.ts deleted file mode 100644 index d319ba2beddf9..0000000000000 --- a/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.test.ts +++ /dev/null @@ -1,190 +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 { Datasource, DatasourceInput } from '../types'; -import { storedDatasourceToAgentDatasource } from './datasource_to_agent_datasource'; - -describe('Ingest Manager - storedDatasourceToAgentDatasource', () => { - const mockDatasource: Datasource = { - id: 'some-uuid', - name: 'mock-datasource', - description: '', - created_at: '', - created_by: '', - updated_at: '', - updated_by: '', - config_id: '', - enabled: true, - output_id: '', - namespace: 'default', - inputs: [], - revision: 1, - }; - - const mockInput: DatasourceInput = { - type: 'test-logs', - enabled: true, - vars: { - inputVar: { value: 'input-value' }, - inputVar2: { value: undefined }, - inputVar3: { - type: 'yaml', - value: 'testField: test', - }, - inputVar4: { value: '' }, - }, - streams: [ - { - id: 'test-logs-foo', - enabled: true, - dataset: 'foo', - vars: { - fooVar: { value: 'foo-value' }, - fooVar2: { value: [1, 2] }, - }, - agent_stream: { - fooKey: 'fooValue1', - fooKey2: ['fooValue2'], - }, - }, - { - id: 'test-logs-bar', - enabled: true, - dataset: 'bar', - vars: { - barVar: { value: 'bar-value' }, - barVar2: { value: [1, 2] }, - barVar3: { - type: 'yaml', - value: - '- namespace: mockNamespace\n #disabledProp: ["test"]\n anotherProp: test\n- namespace: mockNamespace2\n #disabledProp: ["test2"]\n anotherProp: test2', - }, - barVar4: { - type: 'yaml', - value: '', - }, - barVar5: { - type: 'yaml', - value: 'testField: test\n invalidSpacing: foo', - }, - }, - }, - ], - }; - - it('returns agent datasource config for datasource with no inputs', () => { - expect(storedDatasourceToAgentDatasource(mockDatasource)).toEqual({ - id: 'some-uuid', - name: 'mock-datasource', - namespace: 'default', - enabled: true, - use_output: 'default', - inputs: [], - }); - - expect( - storedDatasourceToAgentDatasource({ - ...mockDatasource, - package: { - name: 'mock-package', - title: 'Mock package', - version: '0.0.0', - }, - }) - ).toEqual({ - id: 'some-uuid', - name: 'mock-datasource', - namespace: 'default', - enabled: true, - use_output: 'default', - package: { - name: 'mock-package', - version: '0.0.0', - }, - inputs: [], - }); - }); - - it('returns agent datasource config with flattened input and package stream', () => { - expect(storedDatasourceToAgentDatasource({ ...mockDatasource, inputs: [mockInput] })).toEqual({ - id: 'some-uuid', - name: 'mock-datasource', - namespace: 'default', - enabled: true, - use_output: 'default', - inputs: [ - { - type: 'test-logs', - enabled: true, - streams: [ - { - id: 'test-logs-foo', - enabled: true, - dataset: 'foo', - fooKey: 'fooValue1', - fooKey2: ['fooValue2'], - }, - { - id: 'test-logs-bar', - enabled: true, - dataset: 'bar', - }, - ], - }, - ], - }); - }); - - it('returns agent datasource config without disabled streams', () => { - expect( - storedDatasourceToAgentDatasource({ - ...mockDatasource, - inputs: [ - { - ...mockInput, - streams: [{ ...mockInput.streams[0] }, { ...mockInput.streams[1], enabled: false }], - }, - ], - }) - ).toEqual({ - id: 'some-uuid', - name: 'mock-datasource', - namespace: 'default', - enabled: true, - use_output: 'default', - inputs: [ - { - type: 'test-logs', - enabled: true, - streams: [ - { - id: 'test-logs-foo', - enabled: true, - dataset: 'foo', - fooKey: 'fooValue1', - fooKey2: ['fooValue2'], - }, - ], - }, - ], - }); - }); - - it('returns agent datasource config without disabled inputs', () => { - expect( - storedDatasourceToAgentDatasource({ - ...mockDatasource, - inputs: [{ ...mockInput, enabled: false }], - }) - ).toEqual({ - id: 'some-uuid', - name: 'mock-datasource', - namespace: 'default', - enabled: true, - use_output: 'default', - inputs: [], - }); - }); -}); diff --git a/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.ts b/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.ts deleted file mode 100644 index 2a8b687675bf9..0000000000000 --- a/x-pack/plugins/ingest_manager/common/services/datasource_to_agent_datasource.ts +++ /dev/null @@ -1,60 +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 { Datasource, FullAgentConfigDatasource } from '../types'; -import { DEFAULT_OUTPUT } from '../constants'; - -export const storedDatasourceToAgentDatasource = ( - datasource: Datasource -): FullAgentConfigDatasource => { - const { id, name, namespace, enabled, package: pkg, inputs } = datasource; - - const fullDatasource: FullAgentConfigDatasource = { - id: id || name, - name, - namespace, - enabled, - use_output: DEFAULT_OUTPUT.name, // TODO: hardcoded to default output for now - inputs: inputs - .filter((input) => input.enabled) - .map((input) => { - const fullInput = { - ...input, - ...Object.entries(input.config || {}).reduce((acc, [key, { value }]) => { - acc[key] = value; - return acc; - }, {} as { [k: string]: any }), - streams: input.streams - .filter((stream) => stream.enabled) - .map((stream) => { - const fullStream = { - ...stream, - ...stream.agent_stream, - ...Object.entries(stream.config || {}).reduce((acc, [key, { value }]) => { - acc[key] = value; - return acc; - }, {} as { [k: string]: any }), - }; - delete fullStream.agent_stream; - delete fullStream.vars; - delete fullStream.config; - return fullStream; - }), - }; - delete fullInput.vars; - delete fullInput.config; - return fullInput; - }), - }; - - if (pkg) { - fullDatasource.package = { - name: pkg.name, - version: pkg.version, - }; - } - - return fullDatasource; -}; diff --git a/x-pack/plugins/ingest_manager/common/services/datasources_to_agent_inputs.test.ts b/x-pack/plugins/ingest_manager/common/services/datasources_to_agent_inputs.test.ts new file mode 100644 index 0000000000000..df94168ec88d0 --- /dev/null +++ b/x-pack/plugins/ingest_manager/common/services/datasources_to_agent_inputs.test.ts @@ -0,0 +1,158 @@ +/* + * 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 { Datasource, DatasourceInput } from '../types'; +import { storedDatasourcesToAgentInputs } from './datasources_to_agent_inputs'; + +describe('Ingest Manager - storedDatasourcesToAgentInputs', () => { + const mockDatasource: Datasource = { + id: 'some-uuid', + name: 'mock-datasource', + description: '', + created_at: '', + created_by: '', + updated_at: '', + updated_by: '', + config_id: '', + enabled: true, + output_id: '', + namespace: 'default', + inputs: [], + revision: 1, + }; + + const mockInput: DatasourceInput = { + type: 'test-logs', + enabled: true, + vars: { + inputVar: { value: 'input-value' }, + inputVar2: { value: undefined }, + inputVar3: { + type: 'yaml', + value: 'testField: test', + }, + inputVar4: { value: '' }, + }, + streams: [ + { + id: 'test-logs-foo', + enabled: true, + dataset: 'foo', + vars: { + fooVar: { value: 'foo-value' }, + fooVar2: { value: [1, 2] }, + }, + agent_stream: { + fooKey: 'fooValue1', + fooKey2: ['fooValue2'], + }, + }, + { + id: 'test-logs-bar', + enabled: true, + dataset: 'bar', + vars: { + barVar: { value: 'bar-value' }, + barVar2: { value: [1, 2] }, + barVar3: { + type: 'yaml', + value: + '- namespace: mockNamespace\n #disabledProp: ["test"]\n anotherProp: test\n- namespace: mockNamespace2\n #disabledProp: ["test2"]\n anotherProp: test2', + }, + barVar4: { + type: 'yaml', + value: '', + }, + barVar5: { + type: 'yaml', + value: 'testField: test\n invalidSpacing: foo', + }, + }, + }, + ], + }; + + it('returns no inputs for datasource with no inputs, or only disabled inputs', () => { + expect(storedDatasourcesToAgentInputs([mockDatasource])).toEqual([]); + + expect( + storedDatasourcesToAgentInputs([ + { + ...mockDatasource, + package: { + name: 'mock-package', + title: 'Mock package', + version: '0.0.0', + }, + }, + ]) + ).toEqual([]); + + expect( + storedDatasourcesToAgentInputs([ + { + ...mockDatasource, + inputs: [{ ...mockInput, enabled: false }], + }, + ]) + ).toEqual([]); + }); + + it('returns agent inputs', () => { + expect(storedDatasourcesToAgentInputs([{ ...mockDatasource, inputs: [mockInput] }])).toEqual([ + { + id: 'some-uuid', + name: 'mock-datasource', + type: 'test-logs', + dataset: { namespace: 'default' }, + use_output: 'default', + streams: [ + { + id: 'test-logs-foo', + dataset: { name: 'foo' }, + fooKey: 'fooValue1', + fooKey2: ['fooValue2'], + }, + { + id: 'test-logs-bar', + dataset: { name: 'bar' }, + }, + ], + }, + ]); + }); + + it('returns agent inputs without disabled streams', () => { + expect( + storedDatasourcesToAgentInputs([ + { + ...mockDatasource, + inputs: [ + { + ...mockInput, + streams: [{ ...mockInput.streams[0] }, { ...mockInput.streams[1], enabled: false }], + }, + ], + }, + ]) + ).toEqual([ + { + id: 'some-uuid', + name: 'mock-datasource', + type: 'test-logs', + dataset: { namespace: 'default' }, + use_output: 'default', + streams: [ + { + id: 'test-logs-foo', + dataset: { name: 'foo' }, + fooKey: 'fooValue1', + fooKey2: ['fooValue2'], + }, + ], + }, + ]); + }); +}); diff --git a/x-pack/plugins/ingest_manager/common/services/datasources_to_agent_inputs.ts b/x-pack/plugins/ingest_manager/common/services/datasources_to_agent_inputs.ts new file mode 100644 index 0000000000000..d5a752e817b4f --- /dev/null +++ b/x-pack/plugins/ingest_manager/common/services/datasources_to_agent_inputs.ts @@ -0,0 +1,64 @@ +/* + * 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 { Datasource, FullAgentConfigInput, FullAgentConfigInputStream } from '../types'; +import { DEFAULT_OUTPUT } from '../constants'; + +export const storedDatasourcesToAgentInputs = ( + datasources: Datasource[] +): FullAgentConfigInput[] => { + const fullInputs: FullAgentConfigInput[] = []; + + datasources.forEach((datasource) => { + if (!datasource.enabled || !datasource.inputs || !datasource.inputs.length) { + return; + } + datasource.inputs.forEach((input) => { + if (!input.enabled) { + return; + } + + const fullInput: FullAgentConfigInput = { + id: datasource.id || datasource.name, + name: datasource.name, + type: input.type, + dataset: { namespace: datasource.namespace || 'default' }, + use_output: DEFAULT_OUTPUT.name, + ...Object.entries(input.config || {}).reduce((acc, [key, { value }]) => { + acc[key] = value; + return acc; + }, {} as { [k: string]: any }), + streams: input.streams + .filter((stream) => stream.enabled) + .map((stream) => { + const fullStream: FullAgentConfigInputStream = { + id: stream.id, + dataset: { name: stream.dataset }, + ...stream.agent_stream, + ...Object.entries(stream.config || {}).reduce((acc, [key, { value }]) => { + acc[key] = value; + return acc; + }, {} as { [k: string]: any }), + }; + if (stream.processors) { + fullStream.processors = stream.processors; + } + return fullStream; + }), + }; + + if (datasource.package) { + fullInput.package = { + name: datasource.package.name, + version: datasource.package.version, + }; + } + + fullInputs.push(fullInput); + }); + }); + + return fullInputs; +}; diff --git a/x-pack/plugins/ingest_manager/common/services/index.ts b/x-pack/plugins/ingest_manager/common/services/index.ts index c595c9a52f66f..e53d97972fa2f 100644 --- a/x-pack/plugins/ingest_manager/common/services/index.ts +++ b/x-pack/plugins/ingest_manager/common/services/index.ts @@ -7,7 +7,7 @@ import * as AgentStatusKueryHelper from './agent_status'; export * from './routes'; export { packageToConfigDatasourceInputs, packageToConfigDatasource } from './package_to_config'; -export { storedDatasourceToAgentDatasource } from './datasource_to_agent_datasource'; +export { storedDatasourcesToAgentInputs } from './datasources_to_agent_inputs'; export { configToYaml } from './config_to_yaml'; export { AgentStatusKueryHelper }; export { decodeCloudId } from './decode_cloud_id'; diff --git a/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts b/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts index 7547f56237eec..36b3176ffa415 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts @@ -3,12 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { - Datasource, - DatasourcePackage, - DatasourceInput, - DatasourceInputStream, -} from './datasource'; +import { Datasource, DatasourcePackage, DatasourceInputStream } from './datasource'; import { Output } from './output'; export enum AgentConfigStatus { @@ -35,23 +30,22 @@ export interface AgentConfig extends NewAgentConfig { export type AgentConfigSOAttributes = Omit; -export type FullAgentConfigDatasource = Pick< - Datasource, - 'id' | 'name' | 'namespace' | 'enabled' -> & { - package?: Pick; - use_output: string; - inputs: Array< - Omit & { - streams: Array< - Omit & { - [key: string]: any; - } - >; - } - >; +export type FullAgentConfigInputStream = Pick & { + dataset: { name: string }; + [key: string]: any; }; +export interface FullAgentConfigInput { + id: string; + name: string; + type: string; + dataset: { namespace: string }; + use_output: string; + package?: Pick; + streams: FullAgentConfigInputStream[]; + [key: string]: any; +} + export interface FullAgentConfig { id: string; outputs: { @@ -59,7 +53,7 @@ export interface FullAgentConfig { [key: string]: any; }; }; - datasources: FullAgentConfigDatasource[]; + inputs: FullAgentConfigInput[]; revision?: number; settings?: { monitoring: { diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/services/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/services/index.ts index 6dbc8d67caaee..ece7aef2c247f 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/services/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/services/index.ts @@ -19,7 +19,7 @@ export { settingsRoutesService, appRoutesService, packageToConfigDatasourceInputs, - storedDatasourceToAgentDatasource, + storedDatasourcesToAgentInputs, configToYaml, AgentStatusKueryHelper, } from '../../../../common'; diff --git a/x-pack/plugins/ingest_manager/server/services/agent_config.test.ts b/x-pack/plugins/ingest_manager/server/services/agent_config.test.ts index 17758f6e3d7f1..c46e648ad088a 100644 --- a/x-pack/plugins/ingest_manager/server/services/agent_config.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/agent_config.test.ts @@ -59,7 +59,7 @@ describe('agent config', () => { api_key: undefined, }, }, - datasources: [], + inputs: [], revision: 1, settings: { monitoring: { @@ -88,7 +88,7 @@ describe('agent config', () => { api_key: undefined, }, }, - datasources: [], + inputs: [], revision: 1, settings: { monitoring: { @@ -118,7 +118,7 @@ describe('agent config', () => { api_key: undefined, }, }, - datasources: [], + inputs: [], revision: 1, settings: { monitoring: { diff --git a/x-pack/plugins/ingest_manager/server/services/agent_config.ts b/x-pack/plugins/ingest_manager/server/services/agent_config.ts index 18d5d8dedfb1f..9e0386de74763 100644 --- a/x-pack/plugins/ingest_manager/server/services/agent_config.ts +++ b/x-pack/plugins/ingest_manager/server/services/agent_config.ts @@ -20,7 +20,7 @@ import { AgentConfigStatus, ListWithKuery, } from '../types'; -import { DeleteAgentConfigResponse, storedDatasourceToAgentDatasource } from '../../common'; +import { DeleteAgentConfigResponse, storedDatasourcesToAgentInputs } from '../../common'; import { listAgents } from './agents'; import { datasourceService } from './datasource'; import { outputService } from './output'; @@ -375,9 +375,7 @@ class AgentConfigService { {} as FullAgentConfig['outputs'] ), }, - datasources: (config.datasources as Datasource[]) - .filter((datasource) => datasource.enabled) - .map((ds) => storedDatasourceToAgentDatasource(ds)), + inputs: storedDatasourcesToAgentInputs(config.datasources as Datasource[]), revision: config.revision, ...(config.monitoring_enabled && config.monitoring_enabled.length > 0 ? { diff --git a/x-pack/plugins/ingest_manager/server/types/index.tsx b/x-pack/plugins/ingest_manager/server/types/index.tsx index 2218d967fa8aa..2b543490ca8da 100644 --- a/x-pack/plugins/ingest_manager/server/types/index.tsx +++ b/x-pack/plugins/ingest_manager/server/types/index.tsx @@ -20,7 +20,7 @@ export { Datasource, NewDatasource, DatasourceSOAttributes, - FullAgentConfigDatasource, + FullAgentConfigInput, FullAgentConfig, AgentConfig, AgentConfigSOAttributes, diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx index a0e7c8fd8bcd7..52d1a77c1df6d 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_form/pipeline_form_fields.tsx @@ -128,8 +128,7 @@ export const PipelineFormFields: React.FunctionComponent = ({ return ( { onUpdate, isTestButtonDisabled: false, onTestPipelineClick: jest.fn(), - learnMoreAboutProcessorsUrl: 'test', - learnMoreAboutOnFailureProcessorsUrl: 'test', + esDocsBasePath: 'test', }); const { @@ -56,8 +55,7 @@ describe('Pipeline Editor', () => { onUpdate: jest.fn(), isTestButtonDisabled: false, onTestPipelineClick: jest.fn(), - learnMoreAboutProcessorsUrl: 'test', - learnMoreAboutOnFailureProcessorsUrl: 'test', + esDocsBasePath: 'test', }); expect(exists('pipelineEditorOnFailureTree')).toBe(false); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/index.ts index cb5d5a10e9f42..2d512a6bfa2ed 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/index.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/index.ts @@ -4,9 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -export { SettingsFormFlyout, OnSubmitHandler } from './settings_form_flyout'; - -export { ProcessorSettingsForm, ProcessorSettingsFromOnSubmitArg } from './processor_settings_form'; +export { + ProcessorSettingsForm, + ProcessorSettingsFromOnSubmitArg, + OnSubmitHandler, +} from './processor_settings_form'; export { ProcessorsTree, ProcessorInfo, OnActionHandler } from './processors_tree'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/on_failure_processors_title.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/on_failure_processors_title.tsx index 1c8edac7cfd64..6451096c897d7 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/on_failure_processors_title.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/on_failure_processors_title.tsx @@ -34,7 +34,10 @@ export const OnFailureProcessorsTitle: FunctionComponent = () => { defaultMessage="The processors used to pre-process documents before indexing. {learnMoreLink}" values={{ learnMoreLink: ( - + {i18n.translate( 'xpack.ingestPipelines.pipelineEditor.onFailureProcessorsDocumentationLink', { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/documentation_button.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/documentation_button.tsx new file mode 100644 index 0000000000000..b1fd9e97aa23d --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/documentation_button.tsx @@ -0,0 +1,25 @@ +/* + * 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, { FunctionComponent } from 'react'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +interface Props { + processorLabel: string; + docLink: string; +} + +export const DocumentationButton: FunctionComponent = ({ processorLabel, docLink }) => { + return ( + + {i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.settingsForm.learnMoreLabelLink.processor', + { defaultMessage: '{processorLabel} documentation', values: { processorLabel } } + )} + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/index.ts index 60a1aa0a96fb1..1a7da4891967a 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/index.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/index.ts @@ -7,4 +7,5 @@ export { ProcessorSettingsForm, ProcessorSettingsFromOnSubmitArg, + OnSubmitHandler, } from './processor_settings_form.container'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/map_processor_type_to_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/map_processor_type_to_form.tsx index e8164a0057d39..5993d7fb3f87a 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/map_processor_type_to_form.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/map_processor_type_to_form.tsx @@ -4,53 +4,270 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; import { FunctionComponent } from 'react'; // import { SetProcessor } from './processors/set'; // import { Gsub } from './processors/gsub'; -const mapProcessorTypeToForm = { - append: undefined, // TODO: Implement - bytes: undefined, // TODO: Implement - circle: undefined, // TODO: Implement - convert: undefined, // TODO: Implement - csv: undefined, // TODO: Implement - date: undefined, // TODO: Implement - date_index_name: undefined, // TODO: Implement - dissect: undefined, // TODO: Implement - dot_expander: undefined, // TODO: Implement - drop: undefined, // TODO: Implement - enrich: undefined, // TODO: Implement - fail: undefined, // TODO: Implement - foreach: undefined, // TODO: Implement - geoip: undefined, // TODO: Implement - grok: undefined, // TODO: Implement - html_strip: undefined, // TODO: Implement - inference: undefined, // TODO: Implement - join: undefined, // TODO: Implement - json: undefined, // TODO: Implement - kv: undefined, // TODO: Implement - lowercase: undefined, // TODO: Implement - pipeline: undefined, // TODO: Implement - remove: undefined, // TODO: Implement - rename: undefined, // TODO: Implement - script: undefined, // TODO: Implement - set_security_user: undefined, // TODO: Implement - split: undefined, // TODO: Implement - sort: undefined, // TODO: Implement - trim: undefined, // TODO: Implement - uppercase: undefined, // TODO: Implement - urldecode: undefined, // TODO: Implement - user_agent: undefined, // TODO: Implement - - gsub: undefined, - set: undefined, +interface FieldsFormDescriptor { + FieldsComponent?: FunctionComponent; + docLinkPath: string; + /** + * A sentence case label that can be displayed to users + */ + label: string; +} + +const mapProcessorTypeToFormDescriptor: Record = { + append: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/append-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.append', { + defaultMessage: 'Append', + }), + }, + bytes: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/bytes-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.bytes', { + defaultMessage: 'Bytes', + }), + }, + circle: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/ingest-circle-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.circle', { + defaultMessage: 'Circle', + }), + }, + convert: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/convert-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.convert', { + defaultMessage: 'Convert', + }), + }, + csv: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/csv-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.csv', { + defaultMessage: 'CSV', + }), + }, + date: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/date-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.date', { + defaultMessage: 'Date', + }), + }, + date_index_name: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/date-index-name-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.dateIndexName', { + defaultMessage: 'Date Index Name', + }), + }, + dissect: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/dissect-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.dissect', { + defaultMessage: 'Dissect', + }), + }, + dot_expander: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/dot-expand-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.dotExpander', { + defaultMessage: 'Dot Expander', + }), + }, + drop: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/drop-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.drop', { + defaultMessage: 'Drop', + }), + }, + enrich: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/enrich-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.enrich', { + defaultMessage: 'Enrich', + }), + }, + fail: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/fail-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.fail', { + defaultMessage: 'Fail', + }), + }, + foreach: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/foreach-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.foreach', { + defaultMessage: 'Foreach', + }), + }, + geoip: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/geoip-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.geoip', { + defaultMessage: 'GeoIP', + }), + }, + gsub: { + FieldsComponent: undefined, + docLinkPath: '/gsub-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.gsub', { + defaultMessage: 'Gsub', + }), + }, + html_strip: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/htmlstrip-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.htmlStrip', { + defaultMessage: 'HTML Strip', + }), + }, + inference: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/inference-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.inference', { + defaultMessage: 'Inference', + }), + }, + join: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/join-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.join', { + defaultMessage: 'Join', + }), + }, + json: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/json-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.json', { + defaultMessage: 'JSON', + }), + }, + kv: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/kv-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.kv', { + defaultMessage: 'KV', + }), + }, + lowercase: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/lowercase-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.lowercase', { + defaultMessage: 'Lowercase', + }), + }, + pipeline: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/pipeline-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.pipeline', { + defaultMessage: 'Pipeline', + }), + }, + remove: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/remove-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.remove', { + defaultMessage: 'Remove', + }), + }, + rename: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/rename-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.rename', { + defaultMessage: 'Rename', + }), + }, + script: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/script-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.script', { + defaultMessage: 'Script', + }), + }, + set_security_user: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/ingest-node-set-security-user-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.setSecurityUser', { + defaultMessage: 'Set Security User', + }), + }, + split: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/split-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.split', { + defaultMessage: 'Split', + }), + }, + sort: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/sort-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.sort', { + defaultMessage: 'Sort', + }), + }, + trim: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/trim-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.trim', { + defaultMessage: 'Trim', + }), + }, + uppercase: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/uppercase-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.uppercase', { + defaultMessage: 'Uppercase', + }), + }, + urldecode: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/urldecode-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.urldecode', { + defaultMessage: 'URL Decode', + }), + }, + user_agent: { + FieldsComponent: undefined, // TODO: Implement + docLinkPath: '/user-agent-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.userAgent', { + defaultMessage: 'User Agent', + }), + }, + + // --- The below processor descriptors have components implemented --- + set: { + FieldsComponent: undefined, + docLinkPath: '/set-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.set', { + defaultMessage: 'Set', + }), + }, + grok: { + FieldsComponent: undefined, + docLinkPath: '/grok-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.grok', { + defaultMessage: 'Grok', + }), + }, }; -export const types = Object.keys(mapProcessorTypeToForm); +export const types = Object.keys(mapProcessorTypeToFormDescriptor).sort(); -export type ProcessorType = keyof typeof mapProcessorTypeToForm; +export type ProcessorType = keyof typeof mapProcessorTypeToFormDescriptor; -export const getProcessorForm = (type: ProcessorType | string): FunctionComponent | undefined => { - return mapProcessorTypeToForm[type as ProcessorType]; +export const getProcessorFormDescriptor = ( + type: ProcessorType | string +): FieldsFormDescriptor | undefined => { + return mapProcessorTypeToFormDescriptor[type as ProcessorType]; }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processor_settings_form.container.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processor_settings_form.container.tsx index 29b52ef84600a..d76e9225c1a13 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processor_settings_form.container.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processor_settings_form.container.tsx @@ -13,9 +13,16 @@ import { ProcessorSettingsForm as ViewComponent } from './processor_settings_for export type ProcessorSettingsFromOnSubmitArg = Omit; +export type OnSubmitHandler = (processor: ProcessorSettingsFromOnSubmitArg) => void; + +export type OnFormUpdateHandler = (form: OnFormUpdateArg) => void; + interface Props { - onFormUpdate: (form: OnFormUpdateArg) => void; - onSubmit: (processor: ProcessorSettingsFromOnSubmitArg) => void; + onFormUpdate: OnFormUpdateHandler; + onSubmit: OnSubmitHandler; + isOnFailure: boolean; + onOpen: () => void; + onClose: () => void; processor?: ProcessorInternal; } @@ -23,6 +30,7 @@ export const ProcessorSettingsForm: FunctionComponent = ({ processor, onFormUpdate, onSubmit, + ...rest }) => { const handleSubmit = useCallback( async (data: FormData, isValid: boolean) => { @@ -52,5 +60,5 @@ export const ProcessorSettingsForm: FunctionComponent = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [onFormUpdate]); - return ; + return ; }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processor_settings_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processor_settings_form.tsx index 49bde2129aab6..81b5731a96d5f 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processor_settings_form.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processor_settings_form/processor_settings_form.tsx @@ -3,67 +3,141 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + import { i18n } from '@kbn/i18n'; -import React, { FunctionComponent, memo } from 'react'; -import { EuiButton, EuiHorizontalRule } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React, { FunctionComponent, memo, useEffect } from 'react'; +import { + EuiButton, + EuiHorizontalRule, + EuiFlyout, + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutBody, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; import { Form, useForm, FormDataProvider } from '../../../../../shared_imports'; - +import { usePipelineProcessorsContext } from '../../context'; import { ProcessorInternal } from '../../types'; -import { getProcessorForm } from './map_processor_type_to_form'; +import { DocumentationButton } from './documentation_button'; +import { ProcessorSettingsFromOnSubmitArg } from './processor_settings_form.container'; +import { getProcessorFormDescriptor } from './map_processor_type_to_form'; import { CommonProcessorFields, ProcessorTypeField } from './processors/common_fields'; import { Custom } from './processors/custom'; +export type OnSubmitHandler = (processor: ProcessorSettingsFromOnSubmitArg) => void; + export interface Props { + isOnFailure: boolean; processor?: ProcessorInternal; form: ReturnType['form']; + onClose: () => void; + onOpen: () => void; } export const ProcessorSettingsForm: FunctionComponent = memo( - ({ processor, form }) => { + ({ processor, form, isOnFailure, onClose, onOpen }) => { + const { + links: { esDocsBasePath }, + } = usePipelineProcessorsContext(); + + const flyoutTitleContent = isOnFailure ? ( + + ) : ( + + ); + + useEffect( + () => { + onOpen(); + }, + [] /* eslint-disable-line react-hooks/exhaustive-deps */ + ); + return (
- - - - - - {(arg: any) => { - const { type } = arg; - let formContent: React.ReactNode | undefined; - - if (type?.length) { - const ProcessorFormFields = getProcessorForm(type as any); - - if (ProcessorFormFields) { - formContent = ( - <> - - - - ); - } else { - formContent = ; - } - - return ( - <> - {formContent} - - {i18n.translate( - 'xpack.ingestPipelines.pipelineEditor.settingsForm.submitButtonLabel', - { defaultMessage: 'Submit' } - )} - - - ); - } - - // If the user has not yet defined a type, we do not show any settings fields - return null; - }} - + + + + +
+ +

{flyoutTitleContent}

+
+
+
+ + + + {({ type }) => { + const formDescriptor = getProcessorFormDescriptor(type as any); + + if (formDescriptor) { + return ( + + ); + } + return null; + }} + + +
+
+ + + + + + + {(arg: any) => { + const { type } = arg; + let formContent: React.ReactNode | undefined; + + if (type?.length) { + const formDescriptor = getProcessorFormDescriptor(type as any); + + if (formDescriptor?.FieldsComponent) { + formContent = ( + <> + + + + ); + } else { + formContent = ; + } + + return ( + <> + {formContent} + + {i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.settingsForm.submitButtonLabel', + { defaultMessage: 'Submit' } + )} + + + ); + } + + // If the user has not yet defined a type, we do not show any settings fields + return null; + }} + + +
); }, diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_title_and_test_button.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_title_and_test_button.tsx index bc646c9eefa55..6d1e2610b5c2b 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_title_and_test_button.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/processors_title_and_test_button.tsx @@ -42,7 +42,7 @@ export const ProcessorsTitleAndTestButton: FunctionComponent = ({ defaultMessage="The processors used to pre-process documents before indexing. {learnMoreLink}" values={{ learnMoreLink: ( - + {i18n.translate( 'xpack.ingestPipelines.pipelineEditor.processorsDocumentationLink', { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/settings_form_flyout.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/settings_form_flyout.tsx deleted file mode 100644 index 94d5f0eda6454..0000000000000 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/settings_form_flyout.tsx +++ /dev/null @@ -1,67 +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 { EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader, EuiTitle } from '@elastic/eui'; - -import React, { FunctionComponent, memo, useEffect } from 'react'; - -import { FormattedMessage } from '@kbn/i18n/react'; - -import { OnFormUpdateArg } from '../../../../shared_imports'; - -import { ProcessorInternal } from '../types'; - -import { ProcessorSettingsForm, ProcessorSettingsFromOnSubmitArg } from '.'; - -export type OnSubmitHandler = (processor: ProcessorSettingsFromOnSubmitArg) => void; - -export interface Props { - processor: ProcessorInternal | undefined; - onFormUpdate: (form: OnFormUpdateArg) => void; - onSubmit: OnSubmitHandler; - isOnFailureProcessor: boolean; - onOpen: () => void; - onClose: () => void; -} - -export const SettingsFormFlyout: FunctionComponent = memo( - ({ onClose, processor, onSubmit, onFormUpdate, onOpen, isOnFailureProcessor }) => { - useEffect( - () => { - onOpen(); - }, - [] /* eslint-disable-line react-hooks/exhaustive-deps */ - ); - const flyoutTitleContent = isOnFailureProcessor ? ( - - ) : ( - - ); - - return ( - - - -

{flyoutTitleContent}

-
-
- - - -
- ); - } -); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context.tsx index 150a52f1a5fe0..fbc06f41208fe 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/context.tsx @@ -9,8 +9,7 @@ import { EditorMode } from './types'; import { ProcessorsDispatch } from './processors_reducer'; interface Links { - learnMoreAboutProcessorsUrl: string; - learnMoreAboutOnFailureProcessorsUrl: string; + esDocsBasePath: string; } const PipelineProcessorsContext = createContext<{ diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.container.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.container.tsx index 057f8638700a4..7257677c08fc2 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.container.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/pipeline_processors_editor.container.tsx @@ -26,8 +26,7 @@ export interface Props { onUpdate: (arg: OnUpdateHandlerArg) => void; isTestButtonDisabled: boolean; onTestPipelineClick: () => void; - learnMoreAboutProcessorsUrl: string; - learnMoreAboutOnFailureProcessorsUrl: string; + esDocsBasePath: string; /** * Give users a way to react to this component opening a flyout */ @@ -41,8 +40,7 @@ export const PipelineProcessorsEditor: FunctionComponent = ({ onFlyoutOpen, onUpdate, isTestButtonDisabled, - learnMoreAboutOnFailureProcessorsUrl, - learnMoreAboutProcessorsUrl, + esDocsBasePath, onTestPipelineClick, }) => { const deserializedResult = useMemo( @@ -61,7 +59,7 @@ export const PipelineProcessorsEditor: FunctionComponent = ({ return ( void; } -const PROCESSOR_STATE_SCOPE: ProcessorSelector = ['processors']; -const ON_FAILURE_STATE_SCOPE: ProcessorSelector = ['onFailure']; - export const PipelineProcessorsEditor: FunctionComponent = memo( function PipelineProcessorsEditor({ processors, @@ -168,7 +168,7 @@ export const PipelineProcessorsEditor: FunctionComponent = memo( = memo( = memo( ) : undefined} {editorMode.id === 'editingProcessor' || editorMode.id === 'creatingProcessor' ? ( - 1} + + selector[0] === ON_FAILURE_STATE_SCOPE || selector.length > 2; + export const PARENT_CHILD_NEST_ERROR = 'PARENT_CHILD_NEST_ERROR'; export const duplicateProcessor = (sourceProcessor: ProcessorInternal): ProcessorInternal => { diff --git a/x-pack/plugins/ingest_pipelines/public/application/services/documentation.ts b/x-pack/plugins/ingest_pipelines/public/application/services/documentation.ts index 05fdc4b1dfb84..7f6a87a46fea3 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/services/documentation.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/services/documentation.ts @@ -15,6 +15,10 @@ export class DocumentationService { this.esDocBasePath = `${docsBase}/elasticsearch/reference/${DOC_LINK_VERSION}`; } + public getEsDocsBasePath() { + return this.esDocBasePath; + } + public getIngestNodeUrl() { return `${this.esDocBasePath}/ingest.html`; } diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index ad99780a7d32f..edb395633827f 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -227,3 +227,9 @@ export enum INITIAL_LOCATION { FIXED_LOCATION = 'FIXED_LOCATION', BROWSER_LOCATION = 'BROWSER_LOCATION', } + +export enum LAYER_WIZARD_CATEGORY { + ELASTICSEARCH = 'ELASTICSEARCH', + REFERENCE = 'REFERENCE', + SOLUTIONS = 'SOLUTIONS', +} diff --git a/x-pack/plugins/maps/public/classes/layers/layer_wizard_registry.ts b/x-pack/plugins/maps/public/classes/layers/layer_wizard_registry.ts index 2bdeb6446cf28..a255ffb00e312 100644 --- a/x-pack/plugins/maps/public/classes/layers/layer_wizard_registry.ts +++ b/x-pack/plugins/maps/public/classes/layers/layer_wizard_registry.ts @@ -7,6 +7,7 @@ import { ReactElement } from 'react'; import { LayerDescriptor } from '../../../common/descriptor_types'; +import { LAYER_WIZARD_CATEGORY } from '../../../common/constants'; export type RenderWizardArguments = { previewLayers: (layerDescriptors: LayerDescriptor[], isIndexingSource?: boolean) => void; @@ -20,6 +21,7 @@ export type RenderWizardArguments = { }; export type LayerWizard = { + categories: LAYER_WIZARD_CATEGORY[]; checkVisibility?: () => Promise; description: string; icon: string; diff --git a/x-pack/plugins/maps/public/classes/layers/solution_layers/observability/observability_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/layers/solution_layers/observability/observability_layer_wizard.tsx index db97c08596e06..ddb07a9facee7 100644 --- a/x-pack/plugins/maps/public/classes/layers/solution_layers/observability/observability_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/layers/solution_layers/observability/observability_layer_wizard.tsx @@ -6,12 +6,14 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; +import { LAYER_WIZARD_CATEGORY } from '../../../../../common/constants'; import { LayerWizard, RenderWizardArguments } from '../../layer_wizard_registry'; import { ObservabilityLayerTemplate } from './observability_layer_template'; import { APM_INDEX_PATTERN_ID } from './create_layer_descriptor'; import { getIndexPatternService } from '../../../../kibana_services'; export const ObservabilityLayerWizardConfig: LayerWizard = { + categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH, LAYER_WIZARD_CATEGORY.SOLUTIONS], checkVisibility: async () => { try { await getIndexPatternService().get(APM_INDEX_PATTERN_ID); diff --git a/x-pack/plugins/maps/public/classes/layers/solution_layers/security/security_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/layers/solution_layers/security/security_layer_wizard.tsx index cece00fa37350..f51aa5b40aa80 100644 --- a/x-pack/plugins/maps/public/classes/layers/solution_layers/security/security_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/layers/solution_layers/security/security_layer_wizard.tsx @@ -6,11 +6,13 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; +import { LAYER_WIZARD_CATEGORY } from '../../../../../common/constants'; import { LayerWizard, RenderWizardArguments } from '../../layer_wizard_registry'; import { getSecurityIndexPatterns } from './security_index_pattern_utils'; import { SecurityLayerTemplate } from './security_layer_template'; export const SecurityLayerWizardConfig: LayerWizard = { + categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH, LAYER_WIZARD_CATEGORY.SOLUTIONS], checkVisibility: async () => { const indexPatterns = await getSecurityIndexPatterns(); return indexPatterns.length > 0; diff --git a/x-pack/plugins/maps/public/classes/sources/client_file_source/upload_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/client_file_source/upload_layer_wizard.tsx index 3f4ec0d3f1268..0a224f75b981d 100644 --- a/x-pack/plugins/maps/public/classes/sources/client_file_source/upload_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/client_file_source/upload_layer_wizard.tsx @@ -22,6 +22,7 @@ import { GeojsonFileSource } from './geojson_file_source'; import { VectorLayer } from '../../layers/vector_layer/vector_layer'; export const uploadLayerWizardConfig: LayerWizard = { + categories: [], description: i18n.translate('xpack.maps.source.geojsonFileDescription', { defaultMessage: 'Index GeoJSON data in Elasticsearch', }), diff --git a/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_boundaries_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_boundaries_layer_wizard.tsx index 7eec84ef5bb2e..c53a7a4facb0c 100644 --- a/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_boundaries_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_boundaries_layer_wizard.tsx @@ -13,8 +13,10 @@ import { EMSFileSource, sourceTitle } from './ems_file_source'; // @ts-ignore import { getIsEmsEnabled } from '../../../kibana_services'; import { EMSFileSourceDescriptor } from '../../../../common/descriptor_types'; +import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; export const emsBoundariesLayerWizardConfig: LayerWizard = { + categories: [LAYER_WIZARD_CATEGORY.REFERENCE], checkVisibility: () => { return getIsEmsEnabled(); }, diff --git a/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_base_map_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_base_map_layer_wizard.tsx index 60e67b1ae7053..49d262cbad1a1 100644 --- a/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_base_map_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_base_map_layer_wizard.tsx @@ -13,8 +13,10 @@ import { VectorTileLayer } from '../../layers/vector_tile_layer/vector_tile_laye // @ts-ignore import { TileServiceSelect } from './tile_service_select'; import { getIsEmsEnabled } from '../../../kibana_services'; +import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; export const emsBaseMapLayerWizardConfig: LayerWizard = { + categories: [LAYER_WIZARD_CATEGORY.REFERENCE], checkVisibility: () => { return getIsEmsEnabled(); }, diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/clusters_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/clusters_layer_wizard.tsx index b9d5faa8e18f1..715c16b22dc51 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/clusters_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/clusters_layer_wizard.tsx @@ -23,6 +23,7 @@ import { COUNT_PROP_NAME, COLOR_MAP_TYPE, FIELD_ORIGIN, + LAYER_WIZARD_CATEGORY, RENDER_AS, VECTOR_STYLES, STYLE_TYPE, @@ -30,6 +31,7 @@ import { import { COLOR_GRADIENTS } from '../../styles/color_utils'; export const clustersLayerWizardConfig: LayerWizard = { + categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH], description: i18n.translate('xpack.maps.source.esGridClustersDescription', { defaultMessage: 'Geospatial data grouped in grids with metrics for each gridded cell', }), diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/heatmap_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/heatmap_layer_wizard.tsx index 79252c7febf8c..92a0f1006ea43 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/heatmap_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/heatmap_layer_wizard.tsx @@ -14,9 +14,10 @@ import { LayerWizard, RenderWizardArguments } from '../../layers/layer_wizard_re // @ts-ignore import { HeatmapLayer } from '../../layers/heatmap_layer/heatmap_layer'; import { ESGeoGridSourceDescriptor } from '../../../../common/descriptor_types'; -import { RENDER_AS } from '../../../../common/constants'; +import { LAYER_WIZARD_CATEGORY, RENDER_AS } from '../../../../common/constants'; export const heatmapLayerWizardConfig: LayerWizard = { + categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH], description: i18n.translate('xpack.maps.source.esGridHeatmapDescription', { defaultMessage: 'Geospatial data grouped in grids to show density', }), diff --git a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx index 5169af9bdddf2..ae7414b827c8d 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx @@ -14,6 +14,7 @@ import { VectorStyle } from '../../styles/vector/vector_style'; import { FIELD_ORIGIN, COUNT_PROP_NAME, + LAYER_WIZARD_CATEGORY, VECTOR_STYLES, STYLE_TYPE, } from '../../../../common/constants'; @@ -24,6 +25,7 @@ import { LayerWizard, RenderWizardArguments } from '../../layers/layer_wizard_re import { ColorDynamicOptions, SizeDynamicOptions } from '../../../../common/descriptor_types'; export const point2PointLayerWizardConfig: LayerWizard = { + categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH], description: i18n.translate('xpack.maps.source.pewPewDescription', { defaultMessage: 'Aggregated data paths between the source and destination', }), diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_documents_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_documents_layer_wizard.tsx index 888de2e7297cb..4598b1467229d 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_documents_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_documents_layer_wizard.tsx @@ -13,7 +13,7 @@ import { LayerWizard, RenderWizardArguments } from '../../layers/layer_wizard_re import { ESSearchSource, sourceTitle } from './es_search_source'; import { BlendedVectorLayer } from '../../layers/blended_vector_layer/blended_vector_layer'; import { VectorLayer } from '../../layers/vector_layer/vector_layer'; -import { SCALING_TYPES } from '../../../../common/constants'; +import { LAYER_WIZARD_CATEGORY, SCALING_TYPES } from '../../../../common/constants'; export function createDefaultLayerDescriptor(sourceConfig: unknown, mapColors: string[]) { const sourceDescriptor = ESSearchSource.createDescriptor(sourceConfig); @@ -24,6 +24,7 @@ export function createDefaultLayerDescriptor(sourceConfig: unknown, mapColors: s } export const esDocumentsLayerWizardConfig: LayerWizard = { + categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH], description: i18n.translate('xpack.maps.source.esSearchDescription', { defaultMessage: 'Vector data from a Kibana index pattern', }), diff --git a/x-pack/plugins/maps/public/classes/sources/kibana_regionmap_source/kibana_regionmap_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/kibana_regionmap_source/kibana_regionmap_layer_wizard.tsx index ca78aaefe404f..c8a1c346646e0 100644 --- a/x-pack/plugins/maps/public/classes/sources/kibana_regionmap_source/kibana_regionmap_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/kibana_regionmap_source/kibana_regionmap_layer_wizard.tsx @@ -13,8 +13,10 @@ import { VectorLayer } from '../../layers/vector_layer/vector_layer'; // @ts-ignore import { CreateSourceEditor } from './create_source_editor'; import { getKibanaRegionList } from '../../../meta'; +import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; export const kibanaRegionMapLayerWizardConfig: LayerWizard = { + categories: [LAYER_WIZARD_CATEGORY.REFERENCE], checkVisibility: async () => { const regions = getKibanaRegionList(); return regions.length > 0; diff --git a/x-pack/plugins/maps/public/classes/sources/kibana_tilemap_source/kibana_base_map_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/kibana_tilemap_source/kibana_base_map_layer_wizard.tsx index 84d2e5e74fa9a..9f63372a78511 100644 --- a/x-pack/plugins/maps/public/classes/sources/kibana_tilemap_source/kibana_base_map_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/kibana_tilemap_source/kibana_base_map_layer_wizard.tsx @@ -13,8 +13,10 @@ import { CreateSourceEditor } from './create_source_editor'; import { KibanaTilemapSource, sourceTitle } from './kibana_tilemap_source'; import { TileLayer } from '../../layers/tile_layer/tile_layer'; import { getKibanaTileMap } from '../../../meta'; +import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; export const kibanaBasemapLayerWizardConfig: LayerWizard = { + categories: [LAYER_WIZARD_CATEGORY.REFERENCE], checkVisibility: async () => { const tilemap = getKibanaTileMap(); // @ts-ignore diff --git a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/layer_wizard.tsx index c29302a2058b2..067c7f5a47ca3 100644 --- a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/layer_wizard.tsx @@ -13,8 +13,10 @@ import { import { MVTSingleLayerVectorSource, sourceTitle } from './mvt_single_layer_vector_source'; import { LayerWizard, RenderWizardArguments } from '../../layers/layer_wizard_registry'; import { TiledVectorLayer } from '../../layers/tiled_vector_layer/tiled_vector_layer'; +import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; export const mvtVectorSourceWizardConfig: LayerWizard = { + categories: [LAYER_WIZARD_CATEGORY.REFERENCE], description: i18n.translate('xpack.maps.source.mvtVectorSourceWizard', { defaultMessage: 'Vector source wizard', }), diff --git a/x-pack/plugins/maps/public/classes/sources/wms_source/wms_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/wms_source/wms_layer_wizard.tsx index 62eeef234f414..b3950baf8dbeb 100644 --- a/x-pack/plugins/maps/public/classes/sources/wms_source/wms_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/wms_source/wms_layer_wizard.tsx @@ -12,8 +12,10 @@ import { WMSCreateSourceEditor } from './wms_create_source_editor'; import { sourceTitle, WMSSource } from './wms_source'; import { LayerWizard, RenderWizardArguments } from '../../layers/layer_wizard_registry'; import { TileLayer } from '../../layers/tile_layer/tile_layer'; +import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; export const wmsLayerWizardConfig: LayerWizard = { + categories: [LAYER_WIZARD_CATEGORY.REFERENCE], description: i18n.translate('xpack.maps.source.wmsDescription', { defaultMessage: 'Maps from OGC Standard WMS', }), diff --git a/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/layer_wizard.tsx index b99b17c1d22d4..48c526855d3a4 100644 --- a/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/layer_wizard.tsx @@ -10,8 +10,10 @@ import { XYZTMSEditor, XYZTMSSourceConfig } from './xyz_tms_editor'; import { XYZTMSSource, sourceTitle } from './xyz_tms_source'; import { LayerWizard, RenderWizardArguments } from '../../layers/layer_wizard_registry'; import { TileLayer } from '../../layers/tile_layer/tile_layer'; +import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; export const tmsLayerWizardConfig: LayerWizard = { + categories: [LAYER_WIZARD_CATEGORY.REFERENCE], description: i18n.translate('xpack.maps.source.ems_xyzDescription', { defaultMessage: 'Tile map service configured in interface', }), diff --git a/x-pack/plugins/maps/public/connected_components/_index.scss b/x-pack/plugins/maps/public/connected_components/_index.scss index 6de2a51590700..bd8070e8c36fd 100644 --- a/x-pack/plugins/maps/public/connected_components/_index.scss +++ b/x-pack/plugins/maps/public/connected_components/_index.scss @@ -1,5 +1,4 @@ @import 'gis_map/gis_map'; -@import 'add_layer_panel/index'; @import 'layer_panel/index'; @import 'widget_overlay/index'; @import 'toolbar_overlay/index'; diff --git a/x-pack/plugins/maps/public/connected_components/add_layer_panel/_index.scss b/x-pack/plugins/maps/public/connected_components/add_layer_panel/_index.scss deleted file mode 100644 index 4e60b8d4b7c4b..0000000000000 --- a/x-pack/plugins/maps/public/connected_components/add_layer_panel/_index.scss +++ /dev/null @@ -1,12 +0,0 @@ -.mapLayerAddpanel__card { - // EUITODO: Fix horizontal layout so it works with any size icon - .euiCard__content { - // sass-lint:disable-block no-important - padding-top: 0 !important; - } - - .euiCard__top + .euiCard__content { - // sass-lint:disable-block no-important - padding-top: 2px !important; - } -} diff --git a/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/__snapshots__/layer_wizard_select.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/__snapshots__/layer_wizard_select.test.tsx.snap new file mode 100644 index 0000000000000..ef11f9958d8db --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/__snapshots__/layer_wizard_select.test.tsx.snap @@ -0,0 +1,83 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LayerWizardSelect Should render layer select after layer wizards are loaded 1`] = ` + + + + + + + Elasticsearch + + + Solutions + + + + + + + + + + } + onClick={[Function]} + title="wizard 2" + /> + + + +`; + +exports[`LayerWizardSelect Should render loading screen before layer wizards are loaded 1`] = ` +
+ + } + layout="horizontal" + title="" + /> +
+`; diff --git a/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.test.tsx b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.test.tsx new file mode 100644 index 0000000000000..e802c5259e5ed --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.test.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +jest.mock('../../../classes/layers/layer_wizard_registry', () => ({})); + +import React from 'react'; +import { shallow } from 'enzyme'; +import { LayerWizardSelect } from './layer_wizard_select'; +import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; + +const defaultProps = { + onSelect: () => {}, +}; + +describe('LayerWizardSelect', () => { + beforeAll(() => { + require('../../../classes/layers/layer_wizard_registry').getLayerWizards = async () => { + return [ + { + categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH], + description: 'mock wizard without icon', + renderWizard: () => { + return
; + }, + title: 'wizard 1', + }, + { + categories: [LAYER_WIZARD_CATEGORY.SOLUTIONS], + description: 'mock wizard with icon', + icon: 'logoObservability', + renderWizard: () => { + return
; + }, + title: 'wizard 2', + }, + ]; + }; + }); + + test('Should render layer select after layer wizards are loaded', async () => { + const component = shallow(); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + expect(component).toMatchSnapshot(); + }); + + test('Should render loading screen before layer wizards are loaded', () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.tsx b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.tsx index b0c50133ceabb..f0195bc5dee2f 100644 --- a/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.tsx +++ b/x-pack/plugins/maps/public/connected_components/add_layer_panel/flyout_body/layer_wizard_select.tsx @@ -5,26 +5,63 @@ */ import _ from 'lodash'; -import React, { Component, Fragment } from 'react'; -import { EuiSpacer, EuiCard, EuiIcon } from '@elastic/eui'; -import { EuiLoadingContent } from '@elastic/eui'; +import React, { Component } from 'react'; +import { + EuiCard, + EuiIcon, + EuiFlexGrid, + EuiFlexItem, + EuiLoadingContent, + EuiFacetGroup, + EuiFacetButton, + EuiSpacer, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import { getLayerWizards, LayerWizard } from '../../../classes/layers/layer_wizard_registry'; +import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; interface Props { onSelect: (layerWizard: LayerWizard) => void; } interface State { - layerWizards: LayerWizard[]; + activeCategories: LAYER_WIZARD_CATEGORY[]; hasLoadedWizards: boolean; + layerWizards: LayerWizard[]; + selectedCategory: LAYER_WIZARD_CATEGORY | null; +} + +function getCategoryLabel(category: LAYER_WIZARD_CATEGORY): string { + if (category === LAYER_WIZARD_CATEGORY.ELASTICSEARCH) { + return i18n.translate('xpack.maps.layerWizardSelect.elasticsearchCategoryLabel', { + defaultMessage: 'Elasticsearch', + }); + } + + if (category === LAYER_WIZARD_CATEGORY.REFERENCE) { + return i18n.translate('xpack.maps.layerWizardSelect.referenceCategoryLabel', { + defaultMessage: 'Reference', + }); + } + + if (category === LAYER_WIZARD_CATEGORY.SOLUTIONS) { + return i18n.translate('xpack.maps.layerWizardSelect.solutionsCategoryLabel', { + defaultMessage: 'Solutions', + }); + } + + throw new Error(`Unexpected category: ${category}`); } export class LayerWizardSelect extends Component { private _isMounted: boolean = false; state = { - layerWizards: [], + activeCategories: [], hasLoadedWizards: false, + layerWizards: [], + selectedCategory: null, }; componentDidMount() { @@ -38,9 +75,57 @@ export class LayerWizardSelect extends Component { async _loadLayerWizards() { const layerWizards = await getLayerWizards(); + const activeCategories: LAYER_WIZARD_CATEGORY[] = []; + layerWizards.forEach((layerWizard: LayerWizard) => { + layerWizard.categories.forEach((category: LAYER_WIZARD_CATEGORY) => { + if (!activeCategories.includes(category)) { + activeCategories.push(category); + } + }); + }); + if (this._isMounted) { - this.setState({ layerWizards, hasLoadedWizards: true }); + this.setState({ + activeCategories, + layerWizards, + hasLoadedWizards: true, + }); + } + } + + _filterByCategory(category: LAYER_WIZARD_CATEGORY | null) { + this.setState({ selectedCategory: category }); + } + + _renderCategoryFacets() { + if (this.state.activeCategories.length === 0) { + return null; } + + const facets = this.state.activeCategories.map((category: LAYER_WIZARD_CATEGORY) => { + return ( + this._filterByCategory(category)} + > + {getCategoryLabel(category)} + + ); + }); + + return ( + + this._filterByCategory(null)} + > + + + {facets} + + ); } render() { @@ -51,27 +136,41 @@ export class LayerWizardSelect extends Component {
); } - return this.state.layerWizards.map((layerWizard: LayerWizard) => { - const icon = layerWizard.icon ? : undefined; - const onClick = () => { - this.props.onSelect(layerWizard); - }; + const wizardCards = this.state.layerWizards + .filter((layerWizard: LayerWizard) => { + return this.state.selectedCategory + ? layerWizard.categories.includes(this.state.selectedCategory!) + : true; + }) + .map((layerWizard: LayerWizard) => { + const icon = layerWizard.icon ? : undefined; - return ( - - - - - ); - }); + const onClick = () => { + this.props.onSelect(layerWizard); + }; + + return ( + + + + ); + }); + + return ( + <> + {this._renderCategoryFacets()} + + + {wizardCards} + + + ); } } diff --git a/x-pack/plugins/maps/public/connected_components/gis_map/_gis_map.scss b/x-pack/plugins/maps/public/connected_components/gis_map/_gis_map.scss index 85168d970c6de..2180573ef4583 100644 --- a/x-pack/plugins/maps/public/connected_components/gis_map/_gis_map.scss +++ b/x-pack/plugins/maps/public/connected_components/gis_map/_gis_map.scss @@ -9,11 +9,11 @@ overflow: hidden; > * { - width: $euiSizeXXL * 11; + width: $euiSizeXXL * 12; } &-isVisible { - width: $euiSizeXXL * 11; + width: $euiSizeXXL * 12; transition: width $euiAnimSpeedNormal $euiAnimSlightResistance; } } diff --git a/x-pack/plugins/ml/public/application/app.tsx b/x-pack/plugins/ml/public/application/app.tsx index 4b6ff8c64822b..b871d857f7fde 100644 --- a/x-pack/plugins/ml/public/application/app.tsx +++ b/x-pack/plugins/ml/public/application/app.tsx @@ -35,6 +35,8 @@ const App: FC = ({ coreStart, deps }) => { }; const services = { appName: 'ML', + kibanaVersion: deps.kibanaVersion, + share: deps.share, data: deps.data, security: deps.security, licenseManagement: deps.licenseManagement, diff --git a/x-pack/plugins/ml/public/application/components/loading_indicator/index.js b/x-pack/plugins/ml/public/application/components/loading_indicator/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/components/loading_indicator/index.js rename to x-pack/plugins/ml/public/application/components/loading_indicator/index.ts diff --git a/x-pack/plugins/ml/public/application/components/loading_indicator/loading_indicator.js b/x-pack/plugins/ml/public/application/components/loading_indicator/loading_indicator.tsx similarity index 70% rename from x-pack/plugins/ml/public/application/components/loading_indicator/loading_indicator.js rename to x-pack/plugins/ml/public/application/components/loading_indicator/loading_indicator.tsx index 20f4fb86b5372..364b23a27eaf7 100644 --- a/x-pack/plugins/ml/public/application/components/loading_indicator/loading_indicator.js +++ b/x-pack/plugins/ml/public/application/components/loading_indicator/loading_indicator.tsx @@ -4,12 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import PropTypes from 'prop-types'; -import React from 'react'; +import React, { FC } from 'react'; import { EuiLoadingChart, EuiSpacer } from '@elastic/eui'; -export function LoadingIndicator({ height, label }) { +export const LoadingIndicator: FC<{ height?: number; label?: string }> = ({ height, label }) => { height = height ? +height : 100; return (
-
{label}
+
{label}
)}
); -} -LoadingIndicator.propTypes = { - height: PropTypes.number, - label: PropTypes.string, }; diff --git a/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts b/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts index c65d872212ad6..2a156b5716ad4 100644 --- a/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts +++ b/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts @@ -12,13 +12,15 @@ import { } from '../../../../../../../src/plugins/kibana_react/public'; import { SecurityPluginSetup } from '../../../../../security/public'; import { LicenseManagementUIPluginSetup } from '../../../../../license_management/public'; +import { SharePluginStart } from '../../../../../../../src/plugins/share/public'; interface StartPlugins { data: DataPublicPluginStart; security?: SecurityPluginSetup; licenseManagement?: LicenseManagementUIPluginSetup; + share: SharePluginStart; } -export type StartServices = CoreStart & StartPlugins; +export type StartServices = CoreStart & StartPlugins & { kibanaVersion: string }; // eslint-disable-next-line react-hooks/rules-of-hooks export const useMlKibana = () => useKibana(); export type MlKibanaReactContextValue = KibanaReactContextValue; diff --git a/x-pack/plugins/ml/public/application/contexts/ml/ml_context.ts b/x-pack/plugins/ml/public/application/contexts/ml/ml_context.ts index f8abd48ce8562..07d5a153664b7 100644 --- a/x-pack/plugins/ml/public/application/contexts/ml/ml_context.ts +++ b/x-pack/plugins/ml/public/application/contexts/ml/ml_context.ts @@ -14,6 +14,7 @@ export interface MlContextValue { currentSavedSearch: SavedSearchSavedObject | null; indexPatterns: IndexPatternsContract; kibanaConfig: any; // IUiSettingsClient; + kibanaVersion: string; } export type SavedSearchQuery = object; diff --git a/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx b/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx new file mode 100644 index 0000000000000..cb11a33ccfd76 --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/add_to_dashboard_control.tsx @@ -0,0 +1,321 @@ +/* + * 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, useMemo, useState, useEffect } from 'react'; +import { debounce } from 'lodash'; +import { + EuiFormRow, + EuiCheckboxGroup, + EuiInMemoryTableProps, + EuiModal, + EuiModalHeader, + EuiModalHeaderTitle, + EuiOverlayMask, + EuiSpacer, + EuiButtonEmpty, + EuiButton, + EuiModalFooter, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiModalBody } from '@elastic/eui'; +import { EuiInMemoryTable } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useMlKibana } from '../contexts/kibana'; +import { SavedObjectDashboard } from '../../../../../../src/plugins/dashboard/public'; +import { + ANOMALY_SWIMLANE_EMBEDDABLE_TYPE, + getDefaultPanelTitle, +} from '../../embeddables/anomaly_swimlane/anomaly_swimlane_embeddable'; +import { useDashboardService } from '../services/dashboard_service'; +import { SWIMLANE_TYPE, SwimlaneType } from './explorer_constants'; +import { JobId } from '../../../common/types/anomaly_detection_jobs'; + +export interface DashboardItem { + id: string; + title: string; + description: string | undefined; + attributes: SavedObjectDashboard; +} + +export type EuiTableProps = EuiInMemoryTableProps; + +function getDefaultEmbeddablepaPanelConfig(jobIds: JobId[]) { + return { + type: ANOMALY_SWIMLANE_EMBEDDABLE_TYPE, + title: getDefaultPanelTitle(jobIds), + }; +} + +interface AddToDashboardControlProps { + jobIds: JobId[]; + viewBy: string; + limit: number; + onClose: (callback?: () => Promise) => void; +} + +/** + * Component for attaching anomaly swimlane embeddable to dashboards. + */ +export const AddToDashboardControl: FC = ({ + onClose, + jobIds, + viewBy, + limit, +}) => { + const { + notifications: { toasts }, + services: { + application: { navigateToUrl }, + }, + } = useMlKibana(); + + useEffect(() => { + fetchDashboards(); + + return () => { + fetchDashboards.cancel(); + }; + }, []); + + const dashboardService = useDashboardService(); + + const [isLoading, setIsLoading] = useState(false); + const [selectedSwimlanes, setSelectedSwimlanes] = useState<{ [key in SwimlaneType]: boolean }>({ + [SWIMLANE_TYPE.OVERALL]: true, + [SWIMLANE_TYPE.VIEW_BY]: false, + }); + const [dashboardItems, setDashboardItems] = useState([]); + const [selectedItems, setSelectedItems] = useState([]); + + const fetchDashboards = useCallback( + debounce(async (query?: string) => { + try { + const response = await dashboardService.fetchDashboards(query); + const items: DashboardItem[] = response.savedObjects.map((savedObject) => { + return { + id: savedObject.id, + title: savedObject.attributes.title, + description: savedObject.attributes.description, + attributes: savedObject.attributes, + }; + }); + setDashboardItems(items); + } catch (e) { + toasts.danger({ + body: e, + }); + } + setIsLoading(false); + }, 500), + [] + ); + + const search: EuiTableProps['search'] = useMemo(() => { + return { + onChange: ({ queryText }) => { + setIsLoading(true); + fetchDashboards(queryText); + }, + box: { + incremental: true, + 'data-test-subj': 'mlDashboardsSearchBox', + }, + }; + }, []); + + const addSwimlaneToDashboardCallback = useCallback(async () => { + const swimlanes = Object.entries(selectedSwimlanes) + .filter(([, isSelected]) => isSelected) + .map(([swimlaneType]) => swimlaneType); + + for (const selectedDashboard of selectedItems) { + const panelsData = swimlanes.map((swimlaneType) => { + const config = getDefaultEmbeddablepaPanelConfig(jobIds); + if (swimlaneType === SWIMLANE_TYPE.VIEW_BY) { + return { + ...config, + embeddableConfig: { + jobIds, + swimlaneType, + viewBy, + limit, + }, + }; + } + return { + ...config, + embeddableConfig: { + jobIds, + swimlaneType, + }, + }; + }); + + try { + await dashboardService.attachPanels( + selectedDashboard.id, + selectedDashboard.attributes, + panelsData + ); + toasts.success({ + title: ( + + ), + toastLifeTimeMs: 3000, + }); + } catch (e) { + toasts.danger({ + body: e, + }); + } + } + }, [selectedSwimlanes, selectedItems]); + + const columns: EuiTableProps['columns'] = [ + { + field: 'title', + name: i18n.translate('xpack.ml.explorer.dashboardsTable.titleColumnHeader', { + defaultMessage: 'Title', + }), + sortable: true, + truncateText: true, + }, + { + field: 'description', + name: i18n.translate('xpack.ml.explorer.dashboardsTable.descriptionColumnHeader', { + defaultMessage: 'Description', + }), + truncateText: true, + }, + ]; + + const swimlaneTypeOptions = [ + { + id: SWIMLANE_TYPE.OVERALL, + label: i18n.translate('xpack.ml.explorer.overallLabel', { + defaultMessage: 'Overall', + }), + }, + { + id: SWIMLANE_TYPE.VIEW_BY, + label: i18n.translate('xpack.ml.explorer.viewByFieldLabel', { + defaultMessage: 'View by {viewByField}, up to {limit} rows', + values: { viewByField: viewBy, limit }, + }), + }, + ]; + + const selection: EuiTableProps['selection'] = { + onSelectionChange: setSelectedItems, + }; + + const noSwimlaneSelected = Object.values(selectedSwimlanes).every((isSelected) => !isSelected); + + return ( + + + + + + + + + + } + > + { + const newSelection = { + ...selectedSwimlanes, + [optionId]: !selectedSwimlanes[optionId as SwimlaneType], + }; + setSelectedSwimlanes(newSelection); + }} + data-test-subj="mlAddToDashboardSwimlaneTypeSelector" + /> + + + + + + } + data-test-subj="mlDashboardSelectionContainer" + > + + + + + + + + { + onClose(async () => { + const selectedDashboardId = selectedItems[0].id; + await addSwimlaneToDashboardCallback(); + await navigateToUrl( + await dashboardService.getDashboardEditUrl(selectedDashboardId) + ); + }); + }} + data-test-subj="mlAddAndEditDashboardButton" + > + + + + + + + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx new file mode 100644 index 0000000000000..b4d32e2af64b8 --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx @@ -0,0 +1,392 @@ +/* + * 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, useMemo, useRef, useState } from 'react'; +import { isEqual } from 'lodash'; +import DragSelect from 'dragselect'; +import { + EuiPanel, + EuiPopover, + EuiContextMenuPanel, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiSelect, + EuiTitle, + EuiSpacer, + EuiContextMenuItem, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { DRAG_SELECT_ACTION, VIEW_BY_JOB_LABEL } from './explorer_constants'; +import { AddToDashboardControl } from './add_to_dashboard_control'; +import { useMlKibana } from '../contexts/kibana'; +import { TimeBuckets } from '../util/time_buckets'; +import { UI_SETTINGS } from '../../../../../../src/plugins/data/common'; +import { SelectLimit } from './select_limit'; +import { + ALLOW_CELL_RANGE_SELECTION, + dragSelect$, + explorerService, +} from './explorer_dashboard_service'; +import { ExplorerState } from './reducers/explorer_reducer'; +import { hasMatchingPoints } from './has_matching_points'; +import { ExplorerNoInfluencersFound } from './components/explorer_no_influencers_found/explorer_no_influencers_found'; +import { LoadingIndicator } from '../components/loading_indicator'; +import { SwimlaneContainer } from './swimlane_container'; +import { OverallSwimlaneData } from './explorer_utils'; + +function mapSwimlaneOptionsToEuiOptions(options: string[]) { + return options.map((option) => ({ + value: option, + text: option, + })); +} + +interface AnomalyTimelineProps { + explorerState: ExplorerState; + setSelectedCells: (cells?: any) => void; +} + +export const AnomalyTimeline: FC = React.memo( + ({ explorerState, setSelectedCells }) => { + const { + services: { + uiSettings, + application: { capabilities }, + }, + } = useMlKibana(); + + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [isAddDashboardsActive, setIsAddDashboardActive] = useState(false); + + const isSwimlaneSelectActive = useRef(false); + // make sure dragSelect is only available if the mouse pointer is actually over a swimlane + const disableDragSelectOnMouseLeave = useRef(true); + + const canEditDashboards = capabilities.dashboard?.createNew ?? false; + + const timeBuckets = useMemo(() => { + return new TimeBuckets({ + 'histogram:maxBars': uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS), + 'histogram:barTarget': uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET), + dateFormat: uiSettings.get('dateFormat'), + 'dateFormat:scaled': uiSettings.get('dateFormat:scaled'), + }); + }, [uiSettings]); + + const dragSelect = useMemo( + () => + new DragSelect({ + selectorClass: 'ml-swimlane-selector', + selectables: document.querySelectorAll('.sl-cell'), + callback(elements) { + if (elements.length > 1 && !ALLOW_CELL_RANGE_SELECTION) { + elements = [elements[0]]; + } + + if (elements.length > 0) { + dragSelect$.next({ + action: DRAG_SELECT_ACTION.NEW_SELECTION, + elements, + }); + } + + disableDragSelectOnMouseLeave.current = true; + }, + onDragStart(e) { + let target = e.target as HTMLElement; + while (target && target !== document.body && !target.classList.contains('sl-cell')) { + target = target.parentNode as HTMLElement; + } + if (ALLOW_CELL_RANGE_SELECTION && target !== document.body) { + dragSelect$.next({ + action: DRAG_SELECT_ACTION.DRAG_START, + }); + disableDragSelectOnMouseLeave.current = false; + } + }, + onElementSelect() { + if (ALLOW_CELL_RANGE_SELECTION) { + dragSelect$.next({ + action: DRAG_SELECT_ACTION.ELEMENT_SELECT, + }); + } + }, + }), + [] + ); + + const { + filterActive, + filteredFields, + maskAll, + overallSwimlaneData, + selectedCells, + viewByLoadedForTimeFormatted, + viewBySwimlaneData, + viewBySwimlaneDataLoading, + viewBySwimlaneFieldName, + viewBySwimlaneOptions, + swimlaneLimit, + selectedJobs, + } = explorerState; + + const setSwimlaneSelectActive = useCallback((active: boolean) => { + if (isSwimlaneSelectActive.current && !active && disableDragSelectOnMouseLeave.current) { + dragSelect.stop(); + isSwimlaneSelectActive.current = active; + return; + } + if (!isSwimlaneSelectActive.current && active) { + dragSelect.start(); + dragSelect.clearSelection(); + dragSelect.setSelectables(document.querySelectorAll('.sl-cell')); + isSwimlaneSelectActive.current = active; + } + }, []); + const onSwimlaneEnterHandler = () => setSwimlaneSelectActive(true); + const onSwimlaneLeaveHandler = () => setSwimlaneSelectActive(false); + + // Listens to render updates of the swimlanes to update dragSelect + const swimlaneRenderDoneListener = useCallback(() => { + dragSelect.clearSelection(); + dragSelect.setSelectables(document.querySelectorAll('.sl-cell')); + }, []); + + // Listener for click events in the swimlane to load corresponding anomaly data. + const swimlaneCellClick = useCallback((selectedCellsUpdate: any) => { + // If selectedCells is an empty object we clear any existing selection, + // otherwise we save the new selection in AppState and update the Explorer. + if (Object.keys(selectedCellsUpdate).length === 0) { + setSelectedCells(); + } else { + setSelectedCells(selectedCellsUpdate); + } + }, []); + + const showOverallSwimlane = + overallSwimlaneData !== null && + overallSwimlaneData.laneLabels && + overallSwimlaneData.laneLabels.length > 0; + + const showViewBySwimlane = + viewBySwimlaneData !== null && + viewBySwimlaneData.laneLabels && + viewBySwimlaneData.laneLabels.length > 0; + + const menuItems = useMemo(() => { + const items = []; + if (canEditDashboards) { + items.push( + + + + ); + } + return items; + }, [canEditDashboards]); + + return ( + <> + + + + +

+ +

+
+
+ {viewBySwimlaneOptions.length > 0 && ( + <> + + + + + } + display={'columnCompressed'} + > + explorerService.setViewBySwimlaneFieldName(e.target.value)} + /> + + + + + + + } + display={'columnCompressed'} + > + + + + +
+ {viewByLoadedForTimeFormatted && ( + + )} + {viewByLoadedForTimeFormatted === undefined && ( + + )} + {filterActive === true && viewBySwimlaneFieldName === VIEW_BY_JOB_LABEL && ( + + )} +
+
+ + )} + + {menuItems.length > 0 && ( + + + } + isOpen={isMenuOpen} + closePopover={setIsMenuOpen.bind(null, false)} + panelPaddingSize="none" + anchorPosition="downLeft" + > + + + + )} +
+ + + +
+ {showOverallSwimlane && ( + explorerService.setSwimlaneContainerWidth(width)} + /> + )} +
+ + {viewBySwimlaneOptions.length > 0 && ( + <> + {showViewBySwimlane && ( + <> + +
+ explorerService.setSwimlaneContainerWidth(width)} + /> +
+ + )} + + {viewBySwimlaneDataLoading && } + + {!showViewBySwimlane && + !viewBySwimlaneDataLoading && + typeof viewBySwimlaneFieldName === 'string' && ( + + )} + + )} +
+ {isAddDashboardsActive && selectedJobs && ( + { + setIsAddDashboardActive(false); + if (callback) { + await callback(); + } + }} + jobIds={selectedJobs.map(({ id }) => id)} + viewBy={viewBySwimlaneFieldName!} + limit={swimlaneLimit} + /> + )} + + ); + }, + (prevProps, nextProps) => { + return isEqual(prevProps.explorerState, nextProps.explorerState); + } +); diff --git a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_influencers_found/explorer_no_influencers_found.js b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_influencers_found/explorer_no_influencers_found.tsx similarity index 78% rename from x-pack/plugins/ml/public/application/explorer/components/explorer_no_influencers_found/explorer_no_influencers_found.js rename to x-pack/plugins/ml/public/application/explorer/components/explorer_no_influencers_found/explorer_no_influencers_found.tsx index 5f54c383e76ad..639c0f7b78504 100644 --- a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_influencers_found/explorer_no_influencers_found.js +++ b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_influencers_found/explorer_no_influencers_found.tsx @@ -4,20 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -/* - * React component for rendering EuiEmptyPrompt when no influencers were found. - */ - -import PropTypes from 'prop-types'; -import React from 'react'; +import React, { FC } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiEmptyPrompt } from '@elastic/eui'; -export const ExplorerNoInfluencersFound = ({ - viewBySwimlaneFieldName, - showFilterMessage = false, -}) => ( +/* + * React component for rendering EuiEmptyPrompt when no influencers were found. + */ +export const ExplorerNoInfluencersFound: FC<{ + viewBySwimlaneFieldName: string; + showFilterMessage?: boolean; +}> = ({ viewBySwimlaneFieldName, showFilterMessage = false }) => ( ); - -ExplorerNoInfluencersFound.propTypes = { - viewBySwimlaneFieldName: PropTypes.string.isRequired, - showFilterMessage: PropTypes.bool, -}; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer.js b/x-pack/plugins/ml/public/application/explorer/explorer.js index 1a5a9a9d82862..71c96840d1b57 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer.js @@ -9,10 +9,9 @@ */ import PropTypes from 'prop-types'; -import React, { createRef } from 'react'; +import React from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import DragSelect from 'dragselect/dist/ds.min.js'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; @@ -26,34 +25,23 @@ import { EuiPageBody, EuiPageHeader, EuiPageHeaderSection, - EuiSelect, EuiSpacer, EuiTitle, } from '@elastic/eui'; import { AnnotationFlyout } from '../components/annotations/annotation_flyout'; import { AnnotationsTable } from '../components/annotations/annotations_table'; -import { - ExplorerNoInfluencersFound, - ExplorerNoJobsFound, - ExplorerNoResultsFound, -} from './components'; -import { ExplorerSwimlane } from './explorer_swimlane'; -import { getTimeBucketsFromCache } from '../util/time_buckets'; +import { ExplorerNoJobsFound, ExplorerNoResultsFound } from './components'; import { DatePickerWrapper } from '../components/navigation_menu/date_picker_wrapper'; import { InfluencersList } from '../components/influencers_list'; -import { - ALLOW_CELL_RANGE_SELECTION, - dragSelect$, - explorerService, -} from './explorer_dashboard_service'; +import { explorerService } from './explorer_dashboard_service'; import { AnomalyResultsViewSelector } from '../components/anomaly_results_view_selector'; import { LoadingIndicator } from '../components/loading_indicator/loading_indicator'; import { NavigationMenu } from '../components/navigation_menu'; import { CheckboxShowCharts } from '../components/controls/checkbox_showcharts'; import { JobSelector } from '../components/job_selector'; import { SelectInterval } from '../components/controls/select_interval/select_interval'; -import { SelectLimit, limit$ } from './select_limit/select_limit'; +import { limit$ } from './select_limit/select_limit'; import { SelectSeverity } from '../components/controls/select_severity/select_severity'; import { ExplorerQueryBar, @@ -67,14 +55,9 @@ import { escapeParens, escapeDoubleQuotes, } from './explorer_utils'; -import { getSwimlaneContainerWidth } from './legacy_utils'; +import { AnomalyTimeline } from './anomaly_timeline'; -import { - DRAG_SELECT_ACTION, - FILTER_ACTION, - SWIMLANE_TYPE, - VIEW_BY_JOB_LABEL, -} from './explorer_constants'; +import { FILTER_ACTION } from './explorer_constants'; // Explorer Charts import { ExplorerChartsContainer } from './explorer_charts/explorer_charts_container'; @@ -82,17 +65,7 @@ import { ExplorerChartsContainer } from './explorer_charts/explorer_charts_conta // Anomalies Table 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'; -import { hasMatchingPoints } from './has_matching_points'; - -function mapSwimlaneOptionsToEuiOptions(options) { - return options.map((option) => ({ - value: option, - text: option, - })); -} const ExplorerPage = ({ children, @@ -105,9 +78,8 @@ const ExplorerPage = ({ queryString, filterIconTriggeredQuery, updateLanguage, - resizeRef, }) => ( -
+
@@ -171,108 +143,18 @@ export class Explorer extends React.Component { state = { filterIconTriggeredQuery: undefined, language: DEFAULT_QUERY_LANG }; _unsubscribeAll = new Subject(); - // make sure dragSelect is only available if the mouse pointer is actually over a swimlane - disableDragSelectOnMouseLeave = true; - - dragSelect = new DragSelect({ - selectorClass: 'ml-swimlane-selector', - selectables: document.getElementsByClassName('sl-cell'), - callback(elements) { - if (elements.length > 1 && !ALLOW_CELL_RANGE_SELECTION) { - elements = [elements[0]]; - } - - if (elements.length > 0) { - dragSelect$.next({ - action: DRAG_SELECT_ACTION.NEW_SELECTION, - elements, - }); - } - - this.disableDragSelectOnMouseLeave = true; - }, - onDragStart(e) { - let target = e.target; - while (target && target !== document.body && !target.classList.contains('sl-cell')) { - target = target.parentNode; - } - if (ALLOW_CELL_RANGE_SELECTION && target !== document.body) { - dragSelect$.next({ - action: DRAG_SELECT_ACTION.DRAG_START, - }); - this.disableDragSelectOnMouseLeave = false; - } - }, - onElementSelect() { - if (ALLOW_CELL_RANGE_SELECTION) { - dragSelect$.next({ - action: DRAG_SELECT_ACTION.ELEMENT_SELECT, - }); - } - }, - }); - - // Listens to render updates of the swimlanes to update dragSelect - swimlaneRenderDoneListener = () => { - this.dragSelect.clearSelection(); - this.dragSelect.setSelectables(document.getElementsByClassName('sl-cell')); - }; - - resizeRef = createRef(); - resizeChecker = undefined; - resizeHandler = () => { - explorerService.setSwimlaneContainerWidth(getSwimlaneContainerWidth()); - }; componentDidMount() { limit$.pipe(takeUntil(this._unsubscribeAll)).subscribe(explorerService.setSwimlaneLimit); - - // 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() { this._unsubscribeAll.next(); this._unsubscribeAll.complete(); - this.resizeChecker.destroy(); - } - - resetCache() { - this.anomaliesTablePreviousArgs = null; } viewByChangeHandler = (e) => explorerService.setViewBySwimlaneFieldName(e.target.value); - isSwimlaneSelectActive = false; - onSwimlaneEnterHandler = () => this.setSwimlaneSelectActive(true); - onSwimlaneLeaveHandler = () => this.setSwimlaneSelectActive(false); - setSwimlaneSelectActive = (active) => { - if (this.isSwimlaneSelectActive && !active && this.disableDragSelectOnMouseLeave) { - this.dragSelect.stop(); - this.isSwimlaneSelectActive = active; - return; - } - if (!this.isSwimlaneSelectActive && active) { - this.dragSelect.start(); - this.dragSelect.clearSelection(); - this.dragSelect.setSelectables(document.getElementsByClassName('sl-cell')); - this.isSwimlaneSelectActive = active; - } - }; - - // Listener for click events in the swimlane to load corresponding anomaly data. - swimlaneCellClick = (selectedCells) => { - // If selectedCells is an empty object we clear any existing selection, - // otherwise we save the new selection in AppState and update the Explorer. - if (Object.keys(selectedCells).length === 0) { - this.props.setSelectedCells(); - } else { - this.props.setSelectedCells(selectedCells); - } - }; // Escape regular parens from fieldName as that portion of the query is not wrapped in double quotes // and will cause a syntax error when called with getKqlQueryValues applyFilter = (fieldName, fieldValue, action) => { @@ -339,24 +221,16 @@ export class Explorer extends React.Component { annotationsData, chartsData, filterActive, - filteredFields, filterPlaceHolder, indexPattern, influencers, loading, - maskAll, noInfluencersConfigured, overallSwimlaneData, queryString, selectedCells, selectedJobs, - swimlaneContainerWidth, tableData, - viewByLoadedForTimeFormatted, - viewBySwimlaneData, - viewBySwimlaneDataLoading, - viewBySwimlaneFieldName, - viewBySwimlaneOptions, } = this.props.explorerState; const jobSelectorProps = { @@ -378,7 +252,6 @@ export class Explorer extends React.Component { indexPattern={indexPattern} queryString={queryString} updateLanguage={this.updateLanguage} - resizeRef={this.resizeRef} > + ); @@ -399,7 +272,7 @@ export class Explorer extends React.Component { if (noJobsFound && hasResults === false) { return ( - + ); @@ -408,15 +281,6 @@ export class Explorer extends React.Component { const mainColumnWidthClassName = noInfluencersConfigured === true ? 'col-xs-12' : 'col-xs-10'; const mainColumnClasses = `column ${mainColumnWidthClassName}`; - const showOverallSwimlane = - overallSwimlaneData !== null && - overallSwimlaneData.laneLabels && - overallSwimlaneData.laneLabels.length > 0; - const showViewBySwimlane = - viewBySwimlaneData !== null && - viewBySwimlaneData.laneLabels && - viewBySwimlaneData.laneLabels.length > 0; - const timefilter = getTimefilter(); const bounds = timefilter.getActiveBounds(); @@ -431,7 +295,6 @@ export class Explorer extends React.Component { indexPattern={indexPattern} queryString={queryString} updateLanguage={this.updateLanguage} - resizeRef={this.resizeRef} >
{noInfluencersConfigured && ( @@ -462,142 +325,12 @@ export class Explorer extends React.Component { )}
- -

- -

-
- -
- {showOverallSwimlane && ( - - {(tooltipService) => ( - - )} - - )} -
- - {viewBySwimlaneOptions.length > 0 && ( - <> - - - - - - - - - - - - - -
- {viewByLoadedForTimeFormatted && ( - - )} - {viewByLoadedForTimeFormatted === undefined && ( - - )} - {filterActive === true && viewBySwimlaneFieldName === VIEW_BY_JOB_LABEL && ( - - )} -
-
-
-
- - {showViewBySwimlane && ( - <> - -
- - {(tooltipService) => ( - - )} - -
- - )} - - {viewBySwimlaneDataLoading && } - - {!showViewBySwimlane && - !viewBySwimlaneDataLoading && - viewBySwimlaneFieldName !== null && ( - - )} - - )} + + + {annotationsData.length > 0 && ( <> diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts b/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts index 1cfd29e2f60d2..d1adf8c7ad744 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts @@ -37,10 +37,12 @@ export const FILTER_ACTION = { REMOVE: '-', }; -export enum SWIMLANE_TYPE { - OVERALL = 'overall', - VIEW_BY = 'viewBy', -} +export const SWIMLANE_TYPE = { + OVERALL: 'overall', + VIEW_BY: 'viewBy', +} as const; + +export type SwimlaneType = typeof SWIMLANE_TYPE[keyof typeof SWIMLANE_TYPE]; export const CHART_TYPE = { EVENT_DISTRIBUTION: 'event_distribution', diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx b/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx index 18b5de1d51f9c..4e6dcdcc5129c 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx +++ b/x-pack/plugins/ml/public/application/explorer/explorer_swimlane.tsx @@ -22,7 +22,7 @@ import { numTicksForDateFormat } from '../util/chart_utils'; import { getSeverityColor } from '../../../common/util/anomaly_utils'; import { mlEscape } from '../util/string_utils'; import { ALLOW_CELL_RANGE_SELECTION, dragSelect$ } from './explorer_dashboard_service'; -import { DRAG_SELECT_ACTION } from './explorer_constants'; +import { DRAG_SELECT_ACTION, SwimlaneType } from './explorer_constants'; import { EMPTY_FIELD_VALUE_LABEL } from '../timeseriesexplorer/components/entity_control/entity_control'; import { TimeBuckets as TimeBucketsClass } from '../util/time_buckets'; import { @@ -58,7 +58,7 @@ export interface ExplorerSwimlaneProps { timeBuckets: InstanceType; swimlaneCellClick?: Function; swimlaneData: OverallSwimlaneData; - swimlaneType: string; + swimlaneType: SwimlaneType; selection?: { lanes: any[]; type: string; diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts index 0a2dbf5bcff35..4e1a2af9b13a6 100644 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts +++ b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts @@ -16,8 +16,9 @@ import { AnomaliesTableData, ExplorerJob, AppStateSelectedCells, - SwimlaneData, TimeRangeBounds, + OverallSwimlaneData, + SwimlaneData, } from '../../explorer_utils'; export interface ExplorerState { @@ -35,7 +36,7 @@ export interface ExplorerState { loading: boolean; maskAll: boolean; noInfluencersConfigured: boolean; - overallSwimlaneData: SwimlaneData; + overallSwimlaneData: SwimlaneData | OverallSwimlaneData; queryString: string; selectedCells: AppStateSelectedCells | undefined; selectedJobs: ExplorerJob[] | null; @@ -45,7 +46,7 @@ export interface ExplorerState { tableData: AnomaliesTableData; tableQueryString: string; viewByLoadedForTimeFormatted: string | null; - viewBySwimlaneData: SwimlaneData; + viewBySwimlaneData: SwimlaneData | OverallSwimlaneData; viewBySwimlaneDataLoading: boolean; viewBySwimlaneFieldName?: string; viewBySwimlaneOptions: string[]; diff --git a/x-pack/plugins/ml/public/application/explorer/select_limit/select_limit.tsx b/x-pack/plugins/ml/public/application/explorer/select_limit/select_limit.tsx index 7f7a8fc5a70bd..7a2df1a0f0535 100644 --- a/x-pack/plugins/ml/public/application/explorer/select_limit/select_limit.tsx +++ b/x-pack/plugins/ml/public/application/explorer/select_limit/select_limit.tsx @@ -36,5 +36,5 @@ export const SelectLimit = () => { setLimit(parseInt(e.target.value, 10)); } - return ; + return ; }; diff --git a/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx new file mode 100644 index 0000000000000..57d1fd81000b7 --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/swimlane_container.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useCallback, useState } from 'react'; +import { EuiResizeObserver, EuiText } from '@elastic/eui'; + +import { throttle } from 'lodash'; +import { + ExplorerSwimlane, + ExplorerSwimlaneProps, +} from '../../application/explorer/explorer_swimlane'; + +import { MlTooltipComponent } from '../../application/components/chart_tooltip'; + +const RESIZE_THROTTLE_TIME_MS = 500; + +export const SwimlaneContainer: FC< + Omit & { + onResize: (width: number) => void; + } +> = ({ children, onResize, ...props }) => { + const [chartWidth, setChartWidth] = useState(0); + + const resizeHandler = useCallback( + throttle((e: { width: number; height: number }) => { + const labelWidth = 200; + setChartWidth(e.width - labelWidth); + onResize(e.width); + }, RESIZE_THROTTLE_TIME_MS), + [] + ); + + return ( + + {(resizeRef) => ( +
{ + resizeRef(el); + }} + > +
+ + + {(tooltipService) => ( + + )} + + +
+
+ )} +
+ ); +}; diff --git a/x-pack/plugins/ml/public/application/services/dashboard_service.test.ts b/x-pack/plugins/ml/public/application/services/dashboard_service.test.ts new file mode 100644 index 0000000000000..6cab23eb187c7 --- /dev/null +++ b/x-pack/plugins/ml/public/application/services/dashboard_service.test.ts @@ -0,0 +1,174 @@ +/* + * 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 { dashboardServiceProvider } from './dashboard_service'; +import { savedObjectsServiceMock } from '../../../../../../src/core/public/mocks'; +import { SavedObjectDashboard } from '../../../../../../src/plugins/dashboard/public/saved_dashboards'; +import { + DashboardUrlGenerator, + SavedDashboardPanel, +} from '../../../../../../src/plugins/dashboard/public'; + +jest.mock('@elastic/eui', () => { + return { + htmlIdGenerator: jest.fn(() => { + return jest.fn(() => 'test-panel-id'); + }), + }; +}); + +describe('DashboardService', () => { + const mockSavedObjectClient = savedObjectsServiceMock.createStartContract().client; + const dashboardUrlGenerator = ({ + createUrl: jest.fn(), + } as unknown) as DashboardUrlGenerator; + const dashboardService = dashboardServiceProvider( + mockSavedObjectClient, + '8.0.0', + dashboardUrlGenerator + ); + + test('should fetch dashboard', () => { + // act + dashboardService.fetchDashboards('test'); + // assert + expect(mockSavedObjectClient.find).toHaveBeenCalledWith({ + type: 'dashboard', + perPage: 10, + search: `test*`, + searchFields: ['title^3', 'description'], + }); + }); + + test('should attach panel to the dashboard', () => { + // act + dashboardService.attachPanels( + 'test-dashboard', + ({ + title: 'ML Test', + hits: 0, + description: '', + panelsJSON: JSON.stringify([ + { + version: '8.0.0', + type: 'ml_anomaly_swimlane', + gridData: { x: 0, y: 0, w: 24, h: 15, i: 'i63c960b1-ab1b-11ea-809d-f5c60c43347f' }, + panelIndex: 'i63c960b1-ab1b-11ea-809d-f5c60c43347f', + embeddableConfig: { + title: 'Panel test!', + jobIds: ['cw_multi_1'], + swimlaneType: 'overall', + }, + title: 'Panel test!', + }, + { + version: '8.0.0', + type: 'ml_anomaly_swimlane', + gridData: { x: 24, y: 0, w: 24, h: 15, i: '0aa334bd-8308-4ded-9462-80dbd37680ee' }, + panelIndex: '0aa334bd-8308-4ded-9462-80dbd37680ee', + embeddableConfig: { + title: 'ML anomaly swimlane for fb_population_1', + jobIds: ['fb_population_1'], + limit: 5, + swimlaneType: 'overall', + }, + title: 'ML anomaly swimlane for fb_population_1', + }, + { + version: '8.0.0', + gridData: { x: 0, y: 15, w: 24, h: 15, i: 'abd36eb7-4774-4216-891e-12100752b46d' }, + panelIndex: 'abd36eb7-4774-4216-891e-12100752b46d', + embeddableConfig: {}, + panelRefName: 'panel_2', + }, + ]), + optionsJSON: '{"hidePanelTitles":false,"useMargins":true}', + version: 1, + timeRestore: false, + kibanaSavedObjectMeta: { + searchSourceJSON: '{"query":{"language":"kuery","query":""},"filter":[]}', + }, + } as unknown) as SavedObjectDashboard, + [{ title: 'Test title', type: 'test-panel', embeddableConfig: { testConfig: '' } }] + ); + // assert + expect(mockSavedObjectClient.update).toHaveBeenCalledWith('dashboard', 'test-dashboard', { + title: 'ML Test', + hits: 0, + description: '', + panelsJSON: JSON.stringify([ + { + version: '8.0.0', + type: 'ml_anomaly_swimlane', + gridData: { x: 0, y: 0, w: 24, h: 15, i: 'i63c960b1-ab1b-11ea-809d-f5c60c43347f' }, + panelIndex: 'i63c960b1-ab1b-11ea-809d-f5c60c43347f', + embeddableConfig: { + title: 'Panel test!', + jobIds: ['cw_multi_1'], + swimlaneType: 'overall', + }, + title: 'Panel test!', + }, + { + version: '8.0.0', + type: 'ml_anomaly_swimlane', + gridData: { x: 24, y: 0, w: 24, h: 15, i: '0aa334bd-8308-4ded-9462-80dbd37680ee' }, + panelIndex: '0aa334bd-8308-4ded-9462-80dbd37680ee', + embeddableConfig: { + title: 'ML anomaly swimlane for fb_population_1', + jobIds: ['fb_population_1'], + limit: 5, + swimlaneType: 'overall', + }, + title: 'ML anomaly swimlane for fb_population_1', + }, + { + version: '8.0.0', + gridData: { x: 0, y: 15, w: 24, h: 15, i: 'abd36eb7-4774-4216-891e-12100752b46d' }, + panelIndex: 'abd36eb7-4774-4216-891e-12100752b46d', + embeddableConfig: {}, + panelRefName: 'panel_2', + }, + { + panelIndex: 'test-panel-id', + embeddableConfig: { testConfig: '' }, + title: 'Test title', + type: 'test-panel', + version: '8.0.0', + gridData: { h: 15, i: 'test-panel-id', w: 24, x: 24, y: 15 }, + }, + ]), + optionsJSON: '{"hidePanelTitles":false,"useMargins":true}', + version: 1, + timeRestore: false, + kibanaSavedObjectMeta: { + searchSourceJSON: '{"query":{"language":"kuery","query":""},"filter":[]}', + }, + }); + }); + + test('should generate edit url to the dashboard', () => { + dashboardService.getDashboardEditUrl('test-id'); + expect(dashboardUrlGenerator.createUrl).toHaveBeenCalledWith({ + dashboardId: 'test-id', + useHash: false, + viewMode: 'edit', + }); + }); + + test('should find the panel positioned at the end', () => { + expect( + dashboardService.getLastPanel([ + { gridData: { y: 15, x: 7 } }, + { gridData: { y: 17, x: 9 } }, + { gridData: { y: 15, x: 1 } }, + { gridData: { y: 17, x: 10 } }, + { gridData: { y: 15, x: 22 } }, + { gridData: { y: 17, x: 9 } }, + ] as SavedDashboardPanel[]) + ).toEqual({ gridData: { y: 17, x: 10 } }); + }); +}); diff --git a/x-pack/plugins/ml/public/application/services/dashboard_service.ts b/x-pack/plugins/ml/public/application/services/dashboard_service.ts new file mode 100644 index 0000000000000..7f2bb71d18eb9 --- /dev/null +++ b/x-pack/plugins/ml/public/application/services/dashboard_service.ts @@ -0,0 +1,136 @@ +/* + * 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 { SavedObjectsClientContract } from 'kibana/public'; +import { htmlIdGenerator } from '@elastic/eui'; +import { useMemo } from 'react'; +import { + DASHBOARD_APP_URL_GENERATOR, + DashboardUrlGenerator, + SavedDashboardPanel, + SavedObjectDashboard, +} from '../../../../../../src/plugins/dashboard/public'; +import { useMlKibana } from '../contexts/kibana'; +import { ViewMode } from '../../../../../../src/plugins/embeddable/public'; + +export type DashboardService = ReturnType; + +export function dashboardServiceProvider( + savedObjectClient: SavedObjectsClientContract, + kibanaVersion: string, + dashboardUrlGenerator: DashboardUrlGenerator +) { + const generateId = htmlIdGenerator(); + const DEFAULT_PANEL_WIDTH = 24; + const DEFAULT_PANEL_HEIGHT = 15; + + return { + /** + * Fetches dashboards + */ + async fetchDashboards(query?: string) { + return await savedObjectClient.find({ + type: 'dashboard', + perPage: 10, + search: query ? `${query}*` : '', + searchFields: ['title^3', 'description'], + }); + }, + /** + * Resolves the last positioned panel from the collection. + */ + getLastPanel(panels: SavedDashboardPanel[]): SavedDashboardPanel | null { + return panels.length > 0 + ? panels.reduce((prev, current) => + prev.gridData.y >= current.gridData.y + ? prev.gridData.y === current.gridData.y + ? prev.gridData.x > current.gridData.x + ? prev + : current + : prev + : current + ) + : null; + }, + /** + * Attaches embeddable panels to the dashboard + */ + async attachPanels( + dashboardId: string, + dashboardAttributes: SavedObjectDashboard, + panelsData: Array> + ) { + const panels = JSON.parse(dashboardAttributes.panelsJSON) as SavedDashboardPanel[]; + const version = kibanaVersion; + const rowWidth = DEFAULT_PANEL_WIDTH * 2; + + for (const panelData of panelsData) { + const panelIndex = generateId(); + const lastPanel = this.getLastPanel(panels); + + const xOffset = lastPanel ? lastPanel.gridData.w + lastPanel.gridData.x : 0; + const availableRowSpace = rowWidth - xOffset; + const xPosition = availableRowSpace - DEFAULT_PANEL_WIDTH >= 0 ? xOffset : 0; + + panels.push({ + panelIndex, + embeddableConfig: panelData.embeddableConfig as { [key: string]: any }, + title: panelData.title, + type: panelData.type, + version, + gridData: { + h: DEFAULT_PANEL_HEIGHT, + i: panelIndex, + w: DEFAULT_PANEL_WIDTH, + x: xPosition, + y: lastPanel + ? xPosition > 0 + ? lastPanel.gridData.y + : lastPanel.gridData.y + lastPanel.gridData.h + : 0, + }, + }); + } + + await savedObjectClient.update('dashboard', dashboardId, { + ...dashboardAttributes, + panelsJSON: JSON.stringify(panels), + }); + }, + /** + * Generates dashboard url with edit mode + */ + async getDashboardEditUrl(dashboardId: string) { + return await dashboardUrlGenerator.createUrl({ + dashboardId, + useHash: false, + viewMode: ViewMode.EDIT, + }); + }, + }; +} + +/** + * Hook to use {@link DashboardService} in react components + */ +export function useDashboardService(): DashboardService { + const { + services: { + savedObjects: { client: savedObjectClient }, + kibanaVersion, + share: { urlGenerators }, + }, + } = useMlKibana(); + return useMemo( + () => + dashboardServiceProvider( + savedObjectClient, + kibanaVersion, + urlGenerators.getUrlGenerator(DASHBOARD_APP_URL_GENERATOR) + ), + [savedObjectClient, kibanaVersion] + ); +} diff --git a/x-pack/plugins/ml/public/application/services/http_service.ts b/x-pack/plugins/ml/public/application/services/http_service.ts index 7144411c2885d..bd927dc0e3011 100644 --- a/x-pack/plugins/ml/public/application/services/http_service.ts +++ b/x-pack/plugins/ml/public/application/services/http_service.ts @@ -37,6 +37,8 @@ function getFetchOptions( /** * Function for making HTTP requests to Kibana's backend. * Wrapper for Kibana's HttpHandler. + * + * @deprecated use {@link HttpService} instead */ export async function http(options: HttpFetchOptionsWithPath): Promise { const { path, fetchOptions } = getFetchOptions(options); @@ -46,6 +48,8 @@ export async function http(options: HttpFetchOptionsWithPath): Promise { /** * Function for making HTTP requests to Kibana's backend which returns an Observable * with request cancellation support. + * + * @deprecated use {@link HttpService} instead */ export function http$(options: HttpFetchOptionsWithPath): Observable { const { path, fetchOptions } = getFetchOptions(options); @@ -55,7 +59,7 @@ export function http$(options: HttpFetchOptionsWithPath): Observable { /** * Creates an Observable from Kibana's HttpHandler. */ -export function fromHttpHandler(input: string, init?: RequestInit): Observable { +function fromHttpHandler(input: string, init?: RequestInit): Observable { return new Observable((subscriber) => { const controller = new AbortController(); const signal = controller.signal; diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts index fdaa3c2ffe79e..6d32fca6a645c 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts @@ -5,12 +5,13 @@ */ import { Observable } from 'rxjs'; -import { http, http$ } from '../http_service'; +import { HttpStart } from 'kibana/public'; +import { HttpService } from '../http_service'; import { annotations } from './annotations'; import { dataFrameAnalytics } from './data_frame_analytics'; import { filters } from './filters'; -import { results } from './results'; +import { resultsApiProvider } from './results'; import { jobs } from './jobs'; import { fileDatavisualizer } from './datavisualizer'; import { MlServerDefaults, MlServerLimits } from '../../../../common/types/ml_server_info'; @@ -28,6 +29,7 @@ import { import { ES_AGGREGATION } from '../../../../common/constants/aggregation_types'; import { FieldRequestConfig } from '../../datavisualizer/index_based/common'; import { DataRecognizerConfigResponse, Module } from '../../../../common/types/modules'; +import { getHttp } from '../../util/dependency_cache'; export interface MlInfoResponse { defaults: MlServerDefaults; @@ -87,327 +89,330 @@ export function basePath() { return '/api/ml'; } -export const ml = { - getJobs(obj?: { jobId?: string }) { - const jobId = obj && obj.jobId ? `/${obj.jobId}` : ''; - return http({ - path: `${basePath()}/anomaly_detectors${jobId}`, - }); - }, - - getJobStats(obj: { jobId?: string }) { - const jobId = obj && obj.jobId ? `/${obj.jobId}` : ''; - return http({ - path: `${basePath()}/anomaly_detectors${jobId}/_stats`, - }); - }, - - addJob({ jobId, job }: { jobId: string; job: Job }) { - const body = JSON.stringify(job); - return http({ - path: `${basePath()}/anomaly_detectors/${jobId}`, - method: 'PUT', - body, - }); - }, - - openJob({ jobId }: { jobId: string }) { - return http({ - path: `${basePath()}/anomaly_detectors/${jobId}/_open`, - method: 'POST', - }); - }, - - closeJob({ jobId }: { jobId: string }) { - return http({ - path: `${basePath()}/anomaly_detectors/${jobId}/_close`, - method: 'POST', - }); - }, - - forceCloseJob({ jobId }: { jobId: string }) { - return http({ - path: `${basePath()}/anomaly_detectors/${jobId}/_close?force=true`, - method: 'POST', - }); - }, - - deleteJob({ jobId }: { jobId: string }) { - return http({ - path: `${basePath()}/anomaly_detectors/${jobId}`, - method: 'DELETE', - }); - }, - - forceDeleteJob({ jobId }: { jobId: string }) { - return http({ - path: `${basePath()}/anomaly_detectors/${jobId}?force=true`, - method: 'DELETE', - }); - }, - - updateJob({ jobId, job }: { jobId: string; job: Job }) { - const body = JSON.stringify(job); - return http({ - path: `${basePath()}/anomaly_detectors/${jobId}/_update`, - method: 'POST', - body, - }); - }, - - estimateBucketSpan(obj: BucketSpanEstimatorData) { - const body = JSON.stringify(obj); - return http({ - path: `${basePath()}/validate/estimate_bucket_span`, - method: 'POST', - body, - }); - }, - - validateJob(payload: { - job: Job; - duration: { - start?: number; - end?: number; - }; - fields?: any[]; - }) { - const body = JSON.stringify(payload); - return http({ - path: `${basePath()}/validate/job`, - method: 'POST', - body, - }); - }, - - validateCardinality$(job: CombinedJob): Observable { - const body = JSON.stringify(job); - return http$({ - path: `${basePath()}/validate/cardinality`, - method: 'POST', - body, - }); - }, - - getDatafeeds(obj: { datafeedId: string }) { - const datafeedId = obj && obj.datafeedId ? `/${obj.datafeedId}` : ''; - return http({ - path: `${basePath()}/datafeeds${datafeedId}`, - }); - }, - - getDatafeedStats(obj: { datafeedId: string }) { - const datafeedId = obj && obj.datafeedId ? `/${obj.datafeedId}` : ''; - return http({ - path: `${basePath()}/datafeeds${datafeedId}/_stats`, - }); - }, - - addDatafeed({ datafeedId, datafeedConfig }: { datafeedId: string; datafeedConfig: Datafeed }) { - const body = JSON.stringify(datafeedConfig); - return http({ - path: `${basePath()}/datafeeds/${datafeedId}`, - method: 'PUT', - body, - }); - }, - - updateDatafeed({ datafeedId, datafeedConfig }: { datafeedId: string; datafeedConfig: Datafeed }) { - const body = JSON.stringify(datafeedConfig); - return http({ - path: `${basePath()}/datafeeds/${datafeedId}/_update`, - method: 'POST', - body, - }); - }, - - deleteDatafeed({ datafeedId }: { datafeedId: string }) { - return http({ - path: `${basePath()}/datafeeds/${datafeedId}`, - method: 'DELETE', - }); - }, - - forceDeleteDatafeed({ datafeedId }: { datafeedId: string }) { - return http({ - path: `${basePath()}/datafeeds/${datafeedId}?force=true`, - method: 'DELETE', - }); - }, - - startDatafeed({ datafeedId, start, end }: { datafeedId: string; start: number; end: number }) { - const body = JSON.stringify({ - ...(start !== undefined ? { start } : {}), - ...(end !== undefined ? { end } : {}), - }); - - return http({ - path: `${basePath()}/datafeeds/${datafeedId}/_start`, - method: 'POST', - body, - }); - }, - - stopDatafeed({ datafeedId }: { datafeedId: string }) { - return http({ - path: `${basePath()}/datafeeds/${datafeedId}/_stop`, - method: 'POST', - }); - }, - - forceStopDatafeed({ datafeedId }: { datafeedId: string }) { - return http({ - path: `${basePath()}/datafeeds/${datafeedId}/_stop?force=true`, - method: 'POST', - }); - }, - - datafeedPreview({ datafeedId }: { datafeedId: string }) { - return http({ - path: `${basePath()}/datafeeds/${datafeedId}/_preview`, - method: 'GET', - }); - }, - - validateDetector({ detector }: { detector: Detector }) { - const body = JSON.stringify(detector); - return http({ - path: `${basePath()}/anomaly_detectors/_validate/detector`, - method: 'POST', - body, - }); - }, - - forecast({ jobId, duration }: { jobId: string; duration?: string }) { - const body = JSON.stringify({ - ...(duration !== undefined ? { duration } : {}), - }); - - return http({ - path: `${basePath()}/anomaly_detectors/${jobId}/_forecast`, - method: 'POST', - body, - }); - }, - - overallBuckets({ - jobId, - topN, - bucketSpan, - start, - end, - }: { - jobId: string; - topN: string; - bucketSpan: string; - start: number; - end: number; - }) { - const body = JSON.stringify({ topN, bucketSpan, start, end }); - return http({ - path: `${basePath()}/anomaly_detectors/${jobId}/results/overall_buckets`, - method: 'POST', - body, - }); - }, - - hasPrivileges(obj: any) { - const body = JSON.stringify(obj); - return http({ - path: `${basePath()}/_has_privileges`, - method: 'POST', - body, - }); - }, - - checkMlCapabilities() { - return http({ - path: `${basePath()}/ml_capabilities`, - method: 'GET', - }); - }, - - checkManageMLCapabilities() { - return http({ - path: `${basePath()}/ml_capabilities`, - method: 'GET', - }); - }, - - getNotificationSettings() { - return http({ - path: `${basePath()}/notification_settings`, - method: 'GET', - }); - }, - - getFieldCaps({ index, fields }: { index: string; fields: string[] }) { - const body = JSON.stringify({ - ...(index !== undefined ? { index } : {}), - ...(fields !== undefined ? { fields } : {}), - }); - - return http({ - path: `${basePath()}/indices/field_caps`, - method: 'POST', - body, - }); - }, - - recognizeIndex({ indexPatternTitle }: { indexPatternTitle: string }) { - return http({ - path: `${basePath()}/modules/recognize/${indexPatternTitle}`, - method: 'GET', - }); - }, - - listDataRecognizerModules() { - return http({ - path: `${basePath()}/modules/get_module`, - method: 'GET', - }); - }, - - getDataRecognizerModule({ moduleId }: { moduleId: string }) { - return http({ - path: `${basePath()}/modules/get_module/${moduleId}`, - method: 'GET', - }); - }, - - dataRecognizerModuleJobsExist({ moduleId }: { moduleId: string }) { - return http({ - path: `${basePath()}/modules/jobs_exist/${moduleId}`, - method: 'GET', - }); +/** + * Temp solution to allow {@link ml} service to use http from + * the dependency_cache. + */ +const proxyHttpStart = new Proxy(({} as unknown) as HttpStart, { + get(obj, prop: keyof HttpStart) { + try { + return getHttp()[prop]; + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + } }, - - setupDataRecognizerConfig({ - moduleId, - prefix, - groups, - indexPatternName, - query, - useDedicatedIndex, - startDatafeed, - start, - end, - jobOverrides, - estimateModelMemory, - }: { - moduleId: string; - prefix?: string; - groups?: string[]; - indexPatternName?: string; - query?: any; - useDedicatedIndex?: boolean; - startDatafeed?: boolean; - start?: number; - end?: number; - jobOverrides?: Array>; - estimateModelMemory?: boolean; - }) { - const body = JSON.stringify({ +}); + +export type MlApiServices = ReturnType; + +export const ml = mlApiServicesProvider(new HttpService(proxyHttpStart)); + +export function mlApiServicesProvider(httpService: HttpService) { + const { http } = httpService; + return { + getJobs(obj?: { jobId?: string }) { + const jobId = obj && obj.jobId ? `/${obj.jobId}` : ''; + return httpService.http({ + path: `${basePath()}/anomaly_detectors${jobId}`, + }); + }, + + getJobStats(obj: { jobId?: string }) { + const jobId = obj && obj.jobId ? `/${obj.jobId}` : ''; + return httpService.http({ + path: `${basePath()}/anomaly_detectors${jobId}/_stats`, + }); + }, + + addJob({ jobId, job }: { jobId: string; job: Job }) { + const body = JSON.stringify(job); + return httpService.http({ + path: `${basePath()}/anomaly_detectors/${jobId}`, + method: 'PUT', + body, + }); + }, + + openJob({ jobId }: { jobId: string }) { + return httpService.http({ + path: `${basePath()}/anomaly_detectors/${jobId}/_open`, + method: 'POST', + }); + }, + + closeJob({ jobId }: { jobId: string }) { + return http({ + path: `${basePath()}/anomaly_detectors/${jobId}/_close`, + method: 'POST', + }); + }, + + forceCloseJob({ jobId }: { jobId: string }) { + return http({ + path: `${basePath()}/anomaly_detectors/${jobId}/_close?force=true`, + method: 'POST', + }); + }, + + deleteJob({ jobId }: { jobId: string }) { + return httpService.http({ + path: `${basePath()}/anomaly_detectors/${jobId}`, + method: 'DELETE', + }); + }, + + forceDeleteJob({ jobId }: { jobId: string }) { + return httpService.http({ + path: `${basePath()}/anomaly_detectors/${jobId}?force=true`, + method: 'DELETE', + }); + }, + + updateJob({ jobId, job }: { jobId: string; job: Job }) { + const body = JSON.stringify(job); + return httpService.http({ + path: `${basePath()}/anomaly_detectors/${jobId}/_update`, + method: 'POST', + body, + }); + }, + + estimateBucketSpan(obj: BucketSpanEstimatorData) { + const body = JSON.stringify(obj); + return httpService.http({ + path: `${basePath()}/validate/estimate_bucket_span`, + method: 'POST', + body, + }); + }, + + validateJob(payload: { + job: Job; + duration: { + start?: number; + end?: number; + }; + fields?: any[]; + }) { + const body = JSON.stringify(payload); + return httpService.http({ + path: `${basePath()}/validate/job`, + method: 'POST', + body, + }); + }, + + validateCardinality$(job: CombinedJob): Observable { + const body = JSON.stringify(job); + return httpService.http$({ + path: `${basePath()}/validate/cardinality`, + method: 'POST', + body, + }); + }, + + getDatafeeds(obj: { datafeedId: string }) { + const datafeedId = obj && obj.datafeedId ? `/${obj.datafeedId}` : ''; + return httpService.http({ + path: `${basePath()}/datafeeds${datafeedId}`, + }); + }, + + getDatafeedStats(obj: { datafeedId: string }) { + const datafeedId = obj && obj.datafeedId ? `/${obj.datafeedId}` : ''; + return httpService.http({ + path: `${basePath()}/datafeeds${datafeedId}/_stats`, + }); + }, + + addDatafeed({ datafeedId, datafeedConfig }: { datafeedId: string; datafeedConfig: Datafeed }) { + const body = JSON.stringify(datafeedConfig); + return httpService.http({ + path: `${basePath()}/datafeeds/${datafeedId}`, + method: 'PUT', + body, + }); + }, + + updateDatafeed({ + datafeedId, + datafeedConfig, + }: { + datafeedId: string; + datafeedConfig: Datafeed; + }) { + const body = JSON.stringify(datafeedConfig); + return httpService.http({ + path: `${basePath()}/datafeeds/${datafeedId}/_update`, + method: 'POST', + body, + }); + }, + + deleteDatafeed({ datafeedId }: { datafeedId: string }) { + return httpService.http({ + path: `${basePath()}/datafeeds/${datafeedId}`, + method: 'DELETE', + }); + }, + + forceDeleteDatafeed({ datafeedId }: { datafeedId: string }) { + return httpService.http({ + path: `${basePath()}/datafeeds/${datafeedId}?force=true`, + method: 'DELETE', + }); + }, + + startDatafeed({ datafeedId, start, end }: { datafeedId: string; start: number; end: number }) { + const body = JSON.stringify({ + ...(start !== undefined ? { start } : {}), + ...(end !== undefined ? { end } : {}), + }); + + return httpService.http({ + path: `${basePath()}/datafeeds/${datafeedId}/_start`, + method: 'POST', + body, + }); + }, + + stopDatafeed({ datafeedId }: { datafeedId: string }) { + return http({ + path: `${basePath()}/datafeeds/${datafeedId}/_stop`, + method: 'POST', + }); + }, + + forceStopDatafeed({ datafeedId }: { datafeedId: string }) { + return http({ + path: `${basePath()}/datafeeds/${datafeedId}/_stop?force=true`, + method: 'POST', + }); + }, + + datafeedPreview({ datafeedId }: { datafeedId: string }) { + return httpService.http({ + path: `${basePath()}/datafeeds/${datafeedId}/_preview`, + method: 'GET', + }); + }, + + validateDetector({ detector }: { detector: Detector }) { + const body = JSON.stringify(detector); + return httpService.http({ + path: `${basePath()}/anomaly_detectors/_validate/detector`, + method: 'POST', + body, + }); + }, + + forecast({ jobId, duration }: { jobId: string; duration?: string }) { + const body = JSON.stringify({ + ...(duration !== undefined ? { duration } : {}), + }); + + return httpService.http({ + path: `${basePath()}/anomaly_detectors/${jobId}/_forecast`, + method: 'POST', + body, + }); + }, + + overallBuckets({ + jobId, + topN, + bucketSpan, + start, + end, + }: { + jobId: string; + topN: string; + bucketSpan: string; + start: number; + end: number; + }) { + const body = JSON.stringify({ topN, bucketSpan, start, end }); + return httpService.http({ + path: `${basePath()}/anomaly_detectors/${jobId}/results/overall_buckets`, + method: 'POST', + body, + }); + }, + + hasPrivileges(obj: any) { + const body = JSON.stringify(obj); + return httpService.http({ + path: `${basePath()}/_has_privileges`, + method: 'POST', + body, + }); + }, + + checkMlCapabilities() { + return httpService.http({ + path: `${basePath()}/ml_capabilities`, + method: 'GET', + }); + }, + + checkManageMLCapabilities() { + return httpService.http({ + path: `${basePath()}/ml_capabilities`, + method: 'GET', + }); + }, + + getNotificationSettings() { + return httpService.http({ + path: `${basePath()}/notification_settings`, + method: 'GET', + }); + }, + + getFieldCaps({ index, fields }: { index: string; fields: string[] }) { + const body = JSON.stringify({ + ...(index !== undefined ? { index } : {}), + ...(fields !== undefined ? { fields } : {}), + }); + + return httpService.http({ + path: `${basePath()}/indices/field_caps`, + method: 'POST', + body, + }); + }, + + recognizeIndex({ indexPatternTitle }: { indexPatternTitle: string }) { + return httpService.http({ + path: `${basePath()}/modules/recognize/${indexPatternTitle}`, + method: 'GET', + }); + }, + + listDataRecognizerModules() { + return httpService.http({ + path: `${basePath()}/modules/get_module`, + method: 'GET', + }); + }, + + getDataRecognizerModule({ moduleId }: { moduleId: string }) { + return httpService.http({ + path: `${basePath()}/modules/get_module/${moduleId}`, + method: 'GET', + }); + }, + + dataRecognizerModuleJobsExist({ moduleId }: { moduleId: string }) { + return httpService.http({ + path: `${basePath()}/modules/jobs_exist/${moduleId}`, + method: 'GET', + }); + }, + + setupDataRecognizerConfig({ + moduleId, prefix, groups, indexPatternName, @@ -418,37 +423,41 @@ export const ml = { end, jobOverrides, estimateModelMemory, - }); - - return http({ - path: `${basePath()}/modules/setup/${moduleId}`, - method: 'POST', - body, - }); - }, - - getVisualizerFieldStats({ - indexPatternTitle, - query, - timeFieldName, - earliest, - latest, - samplerShardSize, - interval, - fields, - maxExamples, - }: { - indexPatternTitle: string; - query: any; - timeFieldName?: string; - earliest?: number; - latest?: number; - samplerShardSize?: number; - interval?: string; - fields?: FieldRequestConfig[]; - maxExamples?: number; - }) { - const body = JSON.stringify({ + }: { + moduleId: string; + prefix?: string; + groups?: string[]; + indexPatternName?: string; + query?: any; + useDedicatedIndex?: boolean; + startDatafeed?: boolean; + start?: number; + end?: number; + jobOverrides?: Array>; + estimateModelMemory?: boolean; + }) { + const body = JSON.stringify({ + prefix, + groups, + indexPatternName, + query, + useDedicatedIndex, + startDatafeed, + start, + end, + jobOverrides, + estimateModelMemory, + }); + + return httpService.http({ + path: `${basePath()}/modules/setup/${moduleId}`, + method: 'POST', + body, + }); + }, + + getVisualizerFieldStats({ + indexPatternTitle, query, timeFieldName, earliest, @@ -457,35 +466,37 @@ export const ml = { interval, fields, maxExamples, - }); - - return http({ - path: `${basePath()}/data_visualizer/get_field_stats/${indexPatternTitle}`, - method: 'POST', - body, - }); - }, - - getVisualizerOverallStats({ - indexPatternTitle, - query, - timeFieldName, - earliest, - latest, - samplerShardSize, - aggregatableFields, - nonAggregatableFields, - }: { - indexPatternTitle: string; - query: any; - timeFieldName?: string; - earliest?: number; - latest?: number; - samplerShardSize?: number; - aggregatableFields: string[]; - nonAggregatableFields: string[]; - }) { - const body = JSON.stringify({ + }: { + indexPatternTitle: string; + query: any; + timeFieldName?: string; + earliest?: number; + latest?: number; + samplerShardSize?: number; + interval?: string; + fields?: FieldRequestConfig[]; + maxExamples?: number; + }) { + const body = JSON.stringify({ + query, + timeFieldName, + earliest, + latest, + samplerShardSize, + interval, + fields, + maxExamples, + }); + + return httpService.http({ + path: `${basePath()}/data_visualizer/get_field_stats/${indexPatternTitle}`, + method: 'POST', + body, + }); + }, + + getVisualizerOverallStats({ + indexPatternTitle, query, timeFieldName, earliest, @@ -493,204 +504,230 @@ export const ml = { samplerShardSize, aggregatableFields, nonAggregatableFields, - }); - - return http({ - path: `${basePath()}/data_visualizer/get_overall_stats/${indexPatternTitle}`, - method: 'POST', - body, - }); - }, - - /** - * Gets a list of calendars - * @param obj - * @returns {Promise} - */ - calendars(obj?: { calendarId?: CalendarId; calendarIds?: CalendarId[] }) { - const { calendarId, calendarIds } = obj || {}; - let calendarIdsPathComponent = ''; - if (calendarId) { - calendarIdsPathComponent = `/${calendarId}`; - } else if (calendarIds) { - calendarIdsPathComponent = `/${calendarIds.join(',')}`; - } - return http({ - path: `${basePath()}/calendars${calendarIdsPathComponent}`, - method: 'GET', - }); - }, - - addCalendar(obj: Calendar) { - const body = JSON.stringify(obj); - return http({ - path: `${basePath()}/calendars`, - method: 'PUT', - body, - }); - }, - - updateCalendar(obj: UpdateCalendar) { - const calendarId = obj && obj.calendarId ? `/${obj.calendarId}` : ''; - const body = JSON.stringify(obj); - return http({ - path: `${basePath()}/calendars${calendarId}`, - method: 'PUT', - body, - }); - }, - - deleteCalendar({ calendarId }: { calendarId?: string }) { - return http({ - path: `${basePath()}/calendars/${calendarId}`, - method: 'DELETE', - }); - }, - - mlNodeCount() { - return http<{ count: number }>({ - path: `${basePath()}/ml_node_count`, - method: 'GET', - }); - }, - - mlInfo() { - return http({ - path: `${basePath()}/info`, - method: 'GET', - }); - }, - - calculateModelMemoryLimit$({ - analysisConfig, - indexPattern, - query, - timeFieldName, - earliestMs, - latestMs, - }: { - analysisConfig: AnalysisConfig; - indexPattern: string; - query: any; - timeFieldName: string; - earliestMs: number; - latestMs: number; - }) { - const body = JSON.stringify({ + }: { + indexPatternTitle: string; + query: any; + timeFieldName?: string; + earliest?: number; + latest?: number; + samplerShardSize?: number; + aggregatableFields: string[]; + nonAggregatableFields: string[]; + }) { + const body = JSON.stringify({ + query, + timeFieldName, + earliest, + latest, + samplerShardSize, + aggregatableFields, + nonAggregatableFields, + }); + + return httpService.http({ + path: `${basePath()}/data_visualizer/get_overall_stats/${indexPatternTitle}`, + method: 'POST', + body, + }); + }, + + /** + * Gets a list of calendars + * @param obj + * @returns {Promise} + */ + calendars(obj?: { calendarId?: CalendarId; calendarIds?: CalendarId[] }) { + const { calendarId, calendarIds } = obj || {}; + let calendarIdsPathComponent = ''; + if (calendarId) { + calendarIdsPathComponent = `/${calendarId}`; + } else if (calendarIds) { + calendarIdsPathComponent = `/${calendarIds.join(',')}`; + } + return httpService.http({ + path: `${basePath()}/calendars${calendarIdsPathComponent}`, + method: 'GET', + }); + }, + + addCalendar(obj: Calendar) { + const body = JSON.stringify(obj); + return httpService.http({ + path: `${basePath()}/calendars`, + method: 'PUT', + body, + }); + }, + + updateCalendar(obj: UpdateCalendar) { + const calendarId = obj && obj.calendarId ? `/${obj.calendarId}` : ''; + const body = JSON.stringify(obj); + return httpService.http({ + path: `${basePath()}/calendars${calendarId}`, + method: 'PUT', + body, + }); + }, + + deleteCalendar({ calendarId }: { calendarId?: string }) { + return httpService.http({ + path: `${basePath()}/calendars/${calendarId}`, + method: 'DELETE', + }); + }, + + mlNodeCount() { + return httpService.http<{ count: number }>({ + path: `${basePath()}/ml_node_count`, + method: 'GET', + }); + }, + + mlInfo() { + return httpService.http({ + path: `${basePath()}/info`, + method: 'GET', + }); + }, + + calculateModelMemoryLimit$({ analysisConfig, indexPattern, query, timeFieldName, earliestMs, latestMs, - }); - - return http$<{ modelMemoryLimit: string }>({ - path: `${basePath()}/validate/calculate_model_memory_limit`, - method: 'POST', - body, - }); - }, - - getCardinalityOfFields({ - index, - fieldNames, - query, - timeFieldName, - earliestMs, - latestMs, - }: { - index: string; - fieldNames: string[]; - query: any; - timeFieldName: string; - earliestMs: number; - latestMs: number; - }) { - const body = JSON.stringify({ index, fieldNames, query, timeFieldName, earliestMs, latestMs }); - - return http({ - path: `${basePath()}/fields_service/field_cardinality`, - method: 'POST', - body, - }); - }, - - getTimeFieldRange({ - index, - timeFieldName, - query, - }: { - index: string; - timeFieldName?: string; - query: any; - }) { - const body = JSON.stringify({ index, timeFieldName, query }); - - return http({ - path: `${basePath()}/fields_service/time_field_range`, - method: 'POST', - body, - }); - }, - - esSearch(obj: any) { - const body = JSON.stringify(obj); - return http({ - path: `${basePath()}/es_search`, - method: 'POST', - body, - }); - }, - - esSearch$(obj: any) { - const body = JSON.stringify(obj); - return http$({ - path: `${basePath()}/es_search`, - method: 'POST', - body, - }); - }, - - getIndices() { - const tempBasePath = '/api'; - return http>({ - path: `${tempBasePath}/index_management/indices`, - method: 'GET', - }); - }, - - getModelSnapshots(jobId: string, snapshotId?: string) { - return http({ - path: `${basePath()}/anomaly_detectors/${jobId}/model_snapshots${ - snapshotId !== undefined ? `/${snapshotId}` : '' - }`, - }); - }, - - updateModelSnapshot( - jobId: string, - snapshotId: string, - body: { description?: string; retain?: boolean } - ) { - return http({ - path: `${basePath()}/anomaly_detectors/${jobId}/model_snapshots/${snapshotId}/_update`, - method: 'POST', - body: JSON.stringify(body), - }); - }, - - deleteModelSnapshot(jobId: string, snapshotId: string) { - return http({ - path: `${basePath()}/anomaly_detectors/${jobId}/model_snapshots/${snapshotId}`, - method: 'DELETE', - }); - }, - - annotations, - dataFrameAnalytics, - filters, - results, - jobs, - fileDatavisualizer, -}; + }: { + analysisConfig: AnalysisConfig; + indexPattern: string; + query: any; + timeFieldName: string; + earliestMs: number; + latestMs: number; + }) { + const body = JSON.stringify({ + analysisConfig, + indexPattern, + query, + timeFieldName, + earliestMs, + latestMs, + }); + + return httpService.http$<{ modelMemoryLimit: string }>({ + path: `${basePath()}/validate/calculate_model_memory_limit`, + method: 'POST', + body, + }); + }, + + getCardinalityOfFields({ + index, + fieldNames, + query, + timeFieldName, + earliestMs, + latestMs, + }: { + index: string; + fieldNames: string[]; + query: any; + timeFieldName: string; + earliestMs: number; + latestMs: number; + }) { + const body = JSON.stringify({ + index, + fieldNames, + query, + timeFieldName, + earliestMs, + latestMs, + }); + + return httpService.http({ + path: `${basePath()}/fields_service/field_cardinality`, + method: 'POST', + body, + }); + }, + + getTimeFieldRange({ + index, + timeFieldName, + query, + }: { + index: string; + timeFieldName?: string; + query: any; + }) { + const body = JSON.stringify({ index, timeFieldName, query }); + + return httpService.http({ + path: `${basePath()}/fields_service/time_field_range`, + method: 'POST', + body, + }); + }, + + esSearch(obj: any) { + const body = JSON.stringify(obj); + return httpService.http({ + path: `${basePath()}/es_search`, + method: 'POST', + body, + }); + }, + + esSearch$(obj: any) { + const body = JSON.stringify(obj); + return httpService.http$({ + path: `${basePath()}/es_search`, + method: 'POST', + body, + }); + }, + + getIndices() { + const tempBasePath = '/api'; + return httpService.http>({ + path: `${tempBasePath}/index_management/indices`, + method: 'GET', + }); + }, + + getModelSnapshots(jobId: string, snapshotId?: string) { + return http({ + path: `${basePath()}/anomaly_detectors/${jobId}/model_snapshots${ + snapshotId !== undefined ? `/${snapshotId}` : '' + }`, + }); + }, + + updateModelSnapshot( + jobId: string, + snapshotId: string, + body: { description?: string; retain?: boolean } + ) { + return http({ + path: `${basePath()}/anomaly_detectors/${jobId}/model_snapshots/${snapshotId}/_update`, + method: 'POST', + body: JSON.stringify(body), + }); + }, + + deleteModelSnapshot(jobId: string, snapshotId: string) { + return http({ + path: `${basePath()}/anomaly_detectors/${jobId}/model_snapshots/${snapshotId}`, + method: 'DELETE', + }); + }, + + annotations, + dataFrameAnalytics, + filters, + results: resultsApiProvider(httpService), + jobs, + fileDatavisualizer, + }; +} diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts index 830e6fab4163a..521fd306847eb 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts @@ -5,14 +5,14 @@ */ // Service for obtaining data for the ML Results dashboards. -import { http, http$ } from '../http_service'; +import { HttpService } from '../http_service'; import { basePath } from './index'; import { JobId } from '../../../../common/types/anomaly_detection_jobs'; import { PartitionFieldsDefinition } from '../results_service/result_service_rx'; -export const results = { +export const resultsApiProvider = (httpService: HttpService) => ({ getAnomaliesTableData( jobIds: string[], criteriaFields: string[], @@ -40,7 +40,7 @@ export const results = { influencersFilterQuery, }); - return http$({ + return httpService.http$({ path: `${basePath()}/results/anomalies_table_data`, method: 'POST', body, @@ -53,7 +53,7 @@ export const results = { earliestMs, latestMs, }); - return http({ + return httpService.http({ path: `${basePath()}/results/max_anomaly_score`, method: 'POST', body, @@ -62,7 +62,7 @@ export const results = { getCategoryDefinition(jobId: string, categoryId: string) { const body = JSON.stringify({ jobId, categoryId }); - return http({ + return httpService.http({ path: `${basePath()}/results/category_definition`, method: 'POST', body, @@ -75,7 +75,7 @@ export const results = { categoryIds, maxExamples, }); - return http({ + return httpService.http({ path: `${basePath()}/results/category_examples`, method: 'POST', body, @@ -90,10 +90,10 @@ export const results = { latestMs: number ) { const body = JSON.stringify({ jobId, searchTerm, criteriaFields, earliestMs, latestMs }); - return http$({ + return httpService.http$({ path: `${basePath()}/results/partition_fields_values`, method: 'POST', body, }); }, -}; +}); diff --git a/x-pack/plugins/ml/public/application/services/results_service/index.ts b/x-pack/plugins/ml/public/application/services/results_service/index.ts index cc02248f4d5a9..6c508422e7063 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/index.ts +++ b/x-pack/plugins/ml/public/application/services/results_service/index.ts @@ -4,47 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - getMetricData, - getModelPlotOutput, - getRecordsForCriteria, - getScheduledEventsByBucket, - fetchPartitionFieldsValues, -} from './result_service_rx'; -import { - getEventDistributionData, - getEventRateData, - getInfluencerValueMaxScoreByTime, - getOverallBucketScores, - getRecordInfluencers, - getRecordMaxScoreByTime, - getRecords, - getRecordsForDetector, - getRecordsForInfluencer, - getScoresByBucket, - getTopInfluencers, - getTopInfluencerValues, -} from './results_service'; - -export const mlResultsService = { - getScoresByBucket, - getScheduledEventsByBucket, - getTopInfluencers, - getTopInfluencerValues, - getOverallBucketScores, - getInfluencerValueMaxScoreByTime, - getRecordInfluencers, - getRecordsForInfluencer, - getRecordsForDetector, - getRecords, - getRecordsForCriteria, - getMetricData, - getEventRateData, - getEventDistributionData, - getModelPlotOutput, - getRecordMaxScoreByTime, - fetchPartitionFieldsValues, -}; +import { resultsServiceRxProvider } from './result_service_rx'; +import { resultsServiceProvider } from './results_service'; +import { ml, MlApiServices } from '../ml_api_service'; export type MlResultsService = typeof mlResultsService; @@ -57,3 +19,12 @@ export interface CriteriaField { fieldName: string; fieldValue: any; } + +export const mlResultsService = mlResultsServiceProvider(ml); + +export function mlResultsServiceProvider(mlApiServices: MlApiServices) { + return { + ...resultsServiceProvider(mlApiServices), + ...resultsServiceRxProvider(mlApiServices), + }; +} diff --git a/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts b/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts index a21d0caaedd33..1bcbd8dbcdd63 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts +++ b/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts @@ -17,7 +17,7 @@ import _ from 'lodash'; import { Dictionary } from '../../../../common/types/common'; import { ML_MEDIAN_PERCENTS } from '../../../../common/util/job_utils'; import { JobId } from '../../../../common/types/anomaly_detection_jobs'; -import { ml } from '../ml_api_service'; +import { MlApiServices } from '../ml_api_service'; import { ML_RESULTS_INDEX_PATTERN } from '../../../../common/constants/index_patterns'; import { CriteriaField } from './index'; @@ -46,524 +46,528 @@ export type PartitionFieldsDefinition = { [field in FieldTypes]: FieldDefinition; }; -export function getMetricData( - index: string, - entityFields: any[], - query: object | undefined, - metricFunction: string, // ES aggregation name - metricFieldName: string, - timeFieldName: string, - earliestMs: number, - latestMs: number, - interval: string -): Observable { - // Build the criteria to use in the bool filter part of the request. - // Add criteria for the time range, entity fields, - // plus any additional supplied query. - const shouldCriteria: object[] = []; - const mustCriteria: object[] = [ - { - range: { - [timeFieldName]: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - ...(query ? [query] : []), - ]; - - entityFields.forEach((entity) => { - if (entity.fieldValue.length !== 0) { - mustCriteria.push({ - term: { - [entity.fieldName]: entity.fieldValue, - }, - }); - } else { - // Add special handling for blank entity field values, checking for either - // an empty string or the field not existing. - shouldCriteria.push({ - bool: { - must: [ - { - term: { - [entity.fieldName]: '', - }, - }, - ], - }, - }); - shouldCriteria.push({ - bool: { - must_not: [ - { - exists: { field: entity.fieldName }, - }, - ], - }, - }); - } - }); - - const body: any = { - query: { - bool: { - must: mustCriteria, - }, - }, - size: 0, - _source: { - excludes: [], - }, - aggs: { - byTime: { - date_histogram: { - field: timeFieldName, - interval, - min_doc_count: 0, - }, - }, - }, - }; - - if (shouldCriteria.length > 0) { - body.query.bool.should = shouldCriteria; - body.query.bool.minimum_should_match = shouldCriteria.length / 2; - } - - if (metricFieldName !== undefined && metricFieldName !== '') { - body.aggs.byTime.aggs = {}; - - const metricAgg: any = { - [metricFunction]: { - field: metricFieldName, - }, - }; - - if (metricFunction === 'percentiles') { - metricAgg[metricFunction].percents = [ML_MEDIAN_PERCENTS]; - } - body.aggs.byTime.aggs.metric = metricAgg; - } - - return ml.esSearch$({ index, body }).pipe( - map((resp: any) => { - const obj: MetricData = { success: true, results: {} }; - const dataByTime = resp?.aggregations?.byTime?.buckets ?? []; - dataByTime.forEach((dataForTime: any) => { - if (metricFunction === 'count') { - obj.results[dataForTime.key] = dataForTime.doc_count; - } else { - const value = dataForTime?.metric?.value; - const values = dataForTime?.metric?.values; - if (dataForTime.doc_count === 0) { - obj.results[dataForTime.key] = null; - } else if (value !== undefined) { - obj.results[dataForTime.key] = value; - } else if (values !== undefined) { - // Percentiles agg currently returns NaN rather than null when none of the docs in the - // bucket contain the field used in the aggregation - // (see elasticsearch issue https://github.com/elastic/elasticsearch/issues/29066). - // Store as null, so values can be handled in the same manner downstream as other aggs - // (min, mean, max) which return null. - const medianValues = values[ML_MEDIAN_PERCENTS]; - obj.results[dataForTime.key] = !isNaN(medianValues) ? medianValues : null; - } else { - obj.results[dataForTime.key] = null; - } - } - }); - - return obj; - }) - ); -} - export interface ModelPlotOutput extends ResultResponse { results: Record; } -export function getModelPlotOutput( - jobId: string, - detectorIndex: number, - criteriaFields: any[], - earliestMs: number, - latestMs: number, - interval: string, - aggType?: { min: any; max: any } -): Observable { - const obj: ModelPlotOutput = { - success: true, - results: {}, - }; +export interface RecordsForCriteria extends ResultResponse { + records: any[]; +} - // if an aggType object has been passed in, use it. - // otherwise default to min and max aggs for the upper and lower bounds - const modelAggs = - aggType === undefined - ? { max: 'max', min: 'min' } - : { - max: aggType.max, - min: aggType.min, - }; +export interface ScheduledEventsByBucket extends ResultResponse { + events: Record; +} - // Build the criteria to use in the bool filter part of the request. - // Add criteria for the job ID and time range. - const mustCriteria: object[] = [ - { - term: { job_id: jobId }, - }, - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - ]; - - // Add in term queries for each of the specified criteria. - _.each(criteriaFields, (criteria) => { - mustCriteria.push({ - term: { - [criteria.fieldName]: criteria.fieldValue, - }, - }); - }); - - // Add criteria for the detector index. Results from jobs created before 6.1 will not - // contain a detector_index field, so use a should criteria with a 'not exists' check. - const shouldCriteria = [ - { - term: { detector_index: detectorIndex }, - }, - { - bool: { - must_not: [ - { - exists: { field: 'detector_index' }, +export function resultsServiceRxProvider(mlApiServices: MlApiServices) { + return { + getMetricData( + index: string, + entityFields: any[], + query: object | undefined, + metricFunction: string, // ES aggregation name + metricFieldName: string, + timeFieldName: string, + earliestMs: number, + latestMs: number, + interval: string + ): Observable { + // Build the criteria to use in the bool filter part of the request. + // Add criteria for the time range, entity fields, + // plus any additional supplied query. + const shouldCriteria: object[] = []; + const mustCriteria: object[] = [ + { + range: { + [timeFieldName]: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, }, - ], - }, - }, - ]; + }, + ...(query ? [query] : []), + ]; + + entityFields.forEach((entity) => { + if (entity.fieldValue.length !== 0) { + mustCriteria.push({ + term: { + [entity.fieldName]: entity.fieldValue, + }, + }); + } else { + // Add special handling for blank entity field values, checking for either + // an empty string or the field not existing. + shouldCriteria.push({ + bool: { + must: [ + { + term: { + [entity.fieldName]: '', + }, + }, + ], + }, + }); + shouldCriteria.push({ + bool: { + must_not: [ + { + exists: { field: entity.fieldName }, + }, + ], + }, + }); + } + }); - return ml - .esSearch$({ - index: ML_RESULTS_INDEX_PATTERN, - size: 0, - body: { + const body: any = { query: { bool: { - filter: [ - { - query_string: { - query: 'result_type:model_plot', - analyze_wildcard: true, - }, - }, - { - bool: { - must: mustCriteria, - should: shouldCriteria, - minimum_should_match: 1, - }, - }, - ], + must: mustCriteria, }, }, + size: 0, + _source: { + excludes: [], + }, aggs: { - times: { + byTime: { date_histogram: { - field: 'timestamp', + field: timeFieldName, interval, min_doc_count: 0, }, - aggs: { - actual: { - avg: { - field: 'actual', - }, - }, - modelUpper: { - [modelAggs.max]: { - field: 'model_upper', - }, - }, - modelLower: { - [modelAggs.min]: { - field: 'model_lower', - }, - }, - }, }, }, - }, - }) - .pipe( - map((resp) => { - const aggregationsByTime = _.get(resp, ['aggregations', 'times', 'buckets'], []); - _.each(aggregationsByTime, (dataForTime: any) => { - const time = dataForTime.key; - const modelUpper: number | undefined = _.get(dataForTime, ['modelUpper', 'value']); - const modelLower: number | undefined = _.get(dataForTime, ['modelLower', 'value']); - const actual = _.get(dataForTime, ['actual', 'value']); - - obj.results[time] = { - actual, - modelUpper: - modelUpper === undefined || isFinite(modelUpper) === false ? null : modelUpper, - modelLower: - modelLower === undefined || isFinite(modelLower) === false ? null : modelLower, - }; - }); + }; - return obj; - }) - ); -} + if (shouldCriteria.length > 0) { + body.query.bool.should = shouldCriteria; + body.query.bool.minimum_should_match = shouldCriteria.length / 2; + } -export interface RecordsForCriteria extends ResultResponse { - records: any[]; -} + if (metricFieldName !== undefined && metricFieldName !== '') { + body.aggs.byTime.aggs = {}; -// Queries Elasticsearch to obtain the record level results matching the given criteria, -// for the specified job(s), time range, and record score threshold. -// criteriaFields parameter must be an array, with each object in the array having 'fieldName' -// 'fieldValue' properties. -// Pass an empty array or ['*'] to search over all job IDs. -export function getRecordsForCriteria( - jobIds: string[] | undefined, - criteriaFields: CriteriaField[], - threshold: any, - earliestMs: number, - latestMs: number, - maxResults: number | undefined -): Observable { - const obj: RecordsForCriteria = { success: true, records: [] }; - - // Build the criteria to use in the bool filter part of the request. - // Add criteria for the time range, record score, plus any specified job IDs. - const boolCriteria: any[] = [ - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - { - range: { - record_score: { - gte: threshold, - }, - }, - }, - ]; + const metricAgg: any = { + [metricFunction]: { + field: metricFieldName, + }, + }; - if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { - let jobIdFilterStr = ''; - _.each(jobIds, (jobId, i) => { - if (i > 0) { - jobIdFilterStr += ' OR '; + if (metricFunction === 'percentiles') { + metricAgg[metricFunction].percents = [ML_MEDIAN_PERCENTS]; + } + body.aggs.byTime.aggs.metric = metricAgg; } - jobIdFilterStr += 'job_id:'; - jobIdFilterStr += jobId; - }); - boolCriteria.push({ - query_string: { - analyze_wildcard: false, - query: jobIdFilterStr, - }, - }); - } - - // Add in term queries for each of the specified criteria. - _.each(criteriaFields, (criteria) => { - boolCriteria.push({ - term: { - [criteria.fieldName]: criteria.fieldValue, - }, - }); - }); - - return ml - .esSearch$({ - index: ML_RESULTS_INDEX_PATTERN, - rest_total_hits_as_int: true, - size: maxResults !== undefined ? maxResults : 100, - body: { - query: { - bool: { - filter: [ - { - query_string: { - query: 'result_type:record', - analyze_wildcard: false, - }, - }, - { - bool: { - must: boolCriteria, - }, - }, - ], - }, - }, - sort: [{ record_score: { order: 'desc' } }], - }, - }) - .pipe( - map((resp) => { - if (resp.hits.total !== 0) { - _.each(resp.hits.hits, (hit: any) => { - obj.records.push(hit._source); + + return mlApiServices.esSearch$({ index, body }).pipe( + map((resp: any) => { + const obj: MetricData = { success: true, results: {} }; + const dataByTime = resp?.aggregations?.byTime?.buckets ?? []; + dataByTime.forEach((dataForTime: any) => { + if (metricFunction === 'count') { + obj.results[dataForTime.key] = dataForTime.doc_count; + } else { + const value = dataForTime?.metric?.value; + const values = dataForTime?.metric?.values; + if (dataForTime.doc_count === 0) { + obj.results[dataForTime.key] = null; + } else if (value !== undefined) { + obj.results[dataForTime.key] = value; + } else if (values !== undefined) { + // Percentiles agg currently returns NaN rather than null when none of the docs in the + // bucket contain the field used in the aggregation + // (see elasticsearch issue https://github.com/elastic/elasticsearch/issues/29066). + // Store as null, so values can be handled in the same manner downstream as other aggs + // (min, mean, max) which return null. + const medianValues = values[ML_MEDIAN_PERCENTS]; + obj.results[dataForTime.key] = !isNaN(medianValues) ? medianValues : null; + } else { + obj.results[dataForTime.key] = null; + } + } }); - } - return obj; - }) - ); -} -export interface ScheduledEventsByBucket extends ResultResponse { - events: Record; -} + return obj; + }) + ); + }, -// Obtains a list of scheduled events by job ID and time. -// Pass an empty array or ['*'] to search over all job IDs. -// Returned response contains a events property, which will only -// contains keys for jobs which have scheduled events for the specified time range. -export function getScheduledEventsByBucket( - jobIds: string[] | undefined, - earliestMs: number, - latestMs: number, - interval: string, - maxJobs: number, - maxEvents: number -): Observable { - const obj: ScheduledEventsByBucket = { - success: true, - events: {}, - }; + getModelPlotOutput( + jobId: string, + detectorIndex: number, + criteriaFields: any[], + earliestMs: number, + latestMs: number, + interval: string, + aggType?: { min: any; max: any } + ): Observable { + const obj: ModelPlotOutput = { + success: true, + results: {}, + }; + + // if an aggType object has been passed in, use it. + // otherwise default to min and max aggs for the upper and lower bounds + const modelAggs = + aggType === undefined + ? { max: 'max', min: 'min' } + : { + max: aggType.max, + min: aggType.min, + }; + + // Build the criteria to use in the bool filter part of the request. + // Add criteria for the job ID and time range. + const mustCriteria: object[] = [ + { + term: { job_id: jobId }, + }, + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, + }, + }, + ]; - // Build the criteria to use in the bool filter part of the request. - // Adds criteria for the time range plus any specified job IDs. - const boolCriteria: any[] = [ - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', + // Add in term queries for each of the specified criteria. + _.each(criteriaFields, (criteria) => { + mustCriteria.push({ + term: { + [criteria.fieldName]: criteria.fieldValue, + }, + }); + }); + + // Add criteria for the detector index. Results from jobs created before 6.1 will not + // contain a detector_index field, so use a should criteria with a 'not exists' check. + const shouldCriteria = [ + { + term: { detector_index: detectorIndex }, }, - }, - }, - { - exists: { field: 'scheduled_events' }, - }, - ]; - - if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { - let jobIdFilterStr = ''; - _.each(jobIds, (jobId, i) => { - jobIdFilterStr += `${i > 0 ? ' OR ' : ''}job_id:${jobId}`; - }); - boolCriteria.push({ - query_string: { - analyze_wildcard: false, - query: jobIdFilterStr, - }, - }); - } - - return ml - .esSearch$({ - index: ML_RESULTS_INDEX_PATTERN, - size: 0, - body: { - query: { + { bool: { - filter: [ + must_not: [ { - query_string: { - query: 'result_type:bucket', - analyze_wildcard: false, - }, - }, - { - bool: { - must: boolCriteria, - }, + exists: { field: 'detector_index' }, }, ], }, }, - aggs: { - jobs: { - terms: { - field: 'job_id', - min_doc_count: 1, - size: maxJobs, + ]; + + return mlApiServices + .esSearch$({ + index: ML_RESULTS_INDEX_PATTERN, + size: 0, + body: { + query: { + bool: { + filter: [ + { + query_string: { + query: 'result_type:model_plot', + analyze_wildcard: true, + }, + }, + { + bool: { + must: mustCriteria, + should: shouldCriteria, + minimum_should_match: 1, + }, + }, + ], + }, }, aggs: { times: { date_histogram: { field: 'timestamp', interval, - min_doc_count: 1, + min_doc_count: 0, }, aggs: { - events: { - terms: { - field: 'scheduled_events', - size: maxEvents, + actual: { + avg: { + field: 'actual', + }, + }, + modelUpper: { + [modelAggs.max]: { + field: 'model_upper', + }, + }, + modelLower: { + [modelAggs.min]: { + field: 'model_lower', }, }, }, }, }, }, + }) + .pipe( + map((resp) => { + const aggregationsByTime = _.get(resp, ['aggregations', 'times', 'buckets'], []); + _.each(aggregationsByTime, (dataForTime: any) => { + const time = dataForTime.key; + const modelUpper: number | undefined = _.get(dataForTime, ['modelUpper', 'value']); + const modelLower: number | undefined = _.get(dataForTime, ['modelLower', 'value']); + const actual = _.get(dataForTime, ['actual', 'value']); + + obj.results[time] = { + actual, + modelUpper: + modelUpper === undefined || isFinite(modelUpper) === false ? null : modelUpper, + modelLower: + modelLower === undefined || isFinite(modelLower) === false ? null : modelLower, + }; + }); + + return obj; + }) + ); + }, + + // Queries Elasticsearch to obtain the record level results matching the given criteria, + // for the specified job(s), time range, and record score threshold. + // criteriaFields parameter must be an array, with each object in the array having 'fieldName' + // 'fieldValue' properties. + // Pass an empty array or ['*'] to search over all job IDs. + getRecordsForCriteria( + jobIds: string[] | undefined, + criteriaFields: CriteriaField[], + threshold: any, + earliestMs: number, + latestMs: number, + maxResults: number | undefined + ): Observable { + const obj: RecordsForCriteria = { success: true, records: [] }; + + // Build the criteria to use in the bool filter part of the request. + // Add criteria for the time range, record score, plus any specified job IDs. + const boolCriteria: any[] = [ + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, + }, }, - }, - }) - .pipe( - map((resp) => { - const dataByJobId = _.get(resp, ['aggregations', 'jobs', 'buckets'], []); - _.each(dataByJobId, (dataForJob: any) => { - const jobId: string = dataForJob.key; - const resultsForTime: Record = {}; - const dataByTime = _.get(dataForJob, ['times', 'buckets'], []); - _.each(dataByTime, (dataForTime: any) => { - const time: string = dataForTime.key; - const events: object[] = _.get(dataForTime, ['events', 'buckets']); - resultsForTime[time] = _.map(events, 'key'); - }); - obj.events[jobId] = resultsForTime; + { + range: { + record_score: { + gte: threshold, + }, + }, + }, + ]; + + if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { + let jobIdFilterStr = ''; + _.each(jobIds, (jobId, i) => { + if (i > 0) { + jobIdFilterStr += ' OR '; + } + jobIdFilterStr += 'job_id:'; + jobIdFilterStr += jobId; + }); + boolCriteria.push({ + query_string: { + analyze_wildcard: false, + query: jobIdFilterStr, + }, }); + } - return obj; - }) - ); -} + // Add in term queries for each of the specified criteria. + _.each(criteriaFields, (criteria) => { + boolCriteria.push({ + term: { + [criteria.fieldName]: criteria.fieldValue, + }, + }); + }); -export function fetchPartitionFieldsValues( - jobId: JobId, - searchTerm: Dictionary, - criteriaFields: Array<{ fieldName: string; fieldValue: any }>, - earliestMs: number, - latestMs: number -) { - return ml.results.fetchPartitionFieldsValues( - jobId, - searchTerm, - criteriaFields, - earliestMs, - latestMs - ); + return mlApiServices + .esSearch$({ + index: ML_RESULTS_INDEX_PATTERN, + rest_total_hits_as_int: true, + size: maxResults !== undefined ? maxResults : 100, + body: { + query: { + bool: { + filter: [ + { + query_string: { + query: 'result_type:record', + analyze_wildcard: false, + }, + }, + { + bool: { + must: boolCriteria, + }, + }, + ], + }, + }, + sort: [{ record_score: { order: 'desc' } }], + }, + }) + .pipe( + map((resp) => { + if (resp.hits.total !== 0) { + _.each(resp.hits.hits, (hit: any) => { + obj.records.push(hit._source); + }); + } + return obj; + }) + ); + }, + + // Obtains a list of scheduled events by job ID and time. + // Pass an empty array or ['*'] to search over all job IDs. + // Returned response contains a events property, which will only + // contains keys for jobs which have scheduled events for the specified time range. + getScheduledEventsByBucket( + jobIds: string[] | undefined, + earliestMs: number, + latestMs: number, + interval: string, + maxJobs: number, + maxEvents: number + ): Observable { + const obj: ScheduledEventsByBucket = { + success: true, + events: {}, + }; + + // Build the criteria to use in the bool filter part of the request. + // Adds criteria for the time range plus any specified job IDs. + const boolCriteria: any[] = [ + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, + }, + }, + { + exists: { field: 'scheduled_events' }, + }, + ]; + + if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { + let jobIdFilterStr = ''; + _.each(jobIds, (jobId, i) => { + jobIdFilterStr += `${i > 0 ? ' OR ' : ''}job_id:${jobId}`; + }); + boolCriteria.push({ + query_string: { + analyze_wildcard: false, + query: jobIdFilterStr, + }, + }); + } + + return mlApiServices + .esSearch$({ + index: ML_RESULTS_INDEX_PATTERN, + size: 0, + body: { + query: { + bool: { + filter: [ + { + query_string: { + query: 'result_type:bucket', + analyze_wildcard: false, + }, + }, + { + bool: { + must: boolCriteria, + }, + }, + ], + }, + }, + aggs: { + jobs: { + terms: { + field: 'job_id', + min_doc_count: 1, + size: maxJobs, + }, + aggs: { + times: { + date_histogram: { + field: 'timestamp', + interval, + min_doc_count: 1, + }, + aggs: { + events: { + terms: { + field: 'scheduled_events', + size: maxEvents, + }, + }, + }, + }, + }, + }, + }, + }, + }) + .pipe( + map((resp) => { + const dataByJobId = _.get(resp, ['aggregations', 'jobs', 'buckets'], []); + _.each(dataByJobId, (dataForJob: any) => { + const jobId: string = dataForJob.key; + const resultsForTime: Record = {}; + const dataByTime = _.get(dataForJob, ['times', 'buckets'], []); + _.each(dataByTime, (dataForTime: any) => { + const time: string = dataForTime.key; + const events: object[] = _.get(dataForTime, ['events', 'buckets']); + resultsForTime[time] = _.map(events, 'key'); + }); + obj.events[jobId] = resultsForTime; + }); + + return obj; + }) + ); + }, + + fetchPartitionFieldsValues( + jobId: JobId, + searchTerm: Dictionary, + criteriaFields: Array<{ fieldName: string; fieldValue: any }>, + earliestMs: number, + latestMs: number + ) { + return mlApiServices.results.fetchPartitionFieldsValues( + jobId, + searchTerm, + criteriaFields, + earliestMs, + latestMs + ); + }, + }; } diff --git a/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts b/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts index 4af08994432bd..1b2c01ab73fce 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts +++ b/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts @@ -4,43 +4,49 @@ * you may not use this file except in compliance with the Elastic License. */ -export function getScoresByBucket( - jobIds: string[], - earliestMs: number, - latestMs: number, - interval: string | number, - maxResults: number -): Promise; -export function getTopInfluencers(): Promise; -export function getTopInfluencerValues(): Promise; -export function getOverallBucketScores( - jobIds: any, - topN: any, - earliestMs: any, - latestMs: any, - interval?: any -): Promise; -export function getInfluencerValueMaxScoreByTime( - jobIds: string[], - influencerFieldName: string, - influencerFieldValues: string[], - earliestMs: number, - latestMs: number, - interval: string, - maxResults: number, - influencersFilterQuery: any -): Promise; -export function getRecordInfluencers(): Promise; -export function getRecordsForInfluencer(): Promise; -export function getRecordsForDetector(): Promise; -export function getRecords(): Promise; -export function getEventRateData( - index: string, - query: any, - timeFieldName: string, - earliestMs: number, - latestMs: number, - interval: string | number -): Promise; -export function getEventDistributionData(): Promise; -export function getRecordMaxScoreByTime(): Promise; +import { MlApiServices } from '../ml_api_service'; + +export function resultsServiceProvider( + mlApiServices: MlApiServices +): { + getScoresByBucket( + jobIds: string[], + earliestMs: number, + latestMs: number, + interval: string | number, + maxResults: number + ): Promise; + getTopInfluencers(): Promise; + getTopInfluencerValues(): Promise; + getOverallBucketScores( + jobIds: any, + topN: any, + earliestMs: any, + latestMs: any, + interval?: any + ): Promise; + getInfluencerValueMaxScoreByTime( + jobIds: string[], + influencerFieldName: string, + influencerFieldValues: string[], + earliestMs: number, + latestMs: number, + interval: string, + maxResults: number, + influencersFilterQuery: any + ): Promise; + getRecordInfluencers(): Promise; + getRecordsForInfluencer(): Promise; + getRecordsForDetector(): Promise; + getRecords(): Promise; + getEventRateData( + index: string, + query: any, + timeFieldName: string, + earliestMs: number, + latestMs: number, + interval: string | number + ): Promise; + getEventDistributionData(): Promise; + getRecordMaxScoreByTime(): Promise; +}; diff --git a/x-pack/plugins/ml/public/application/services/results_service/results_service.js b/x-pack/plugins/ml/public/application/services/results_service/results_service.js index 4fccc4d789370..9e3fed189b6f4 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/results_service.js +++ b/x-pack/plugins/ml/public/application/services/results_service/results_service.js @@ -4,1322 +4,1331 @@ * you may not use this file except in compliance with the Elastic License. */ -// Service for carrying out Elasticsearch queries to obtain data for the -// Ml Results dashboards. import _ from 'lodash'; -// import d3 from 'd3'; import { ML_MEDIAN_PERCENTS } from '../../../../common/util/job_utils'; import { escapeForElasticsearchQuery } from '../../util/string_utils'; import { ML_RESULTS_INDEX_PATTERN } from '../../../../common/constants/index_patterns'; -import { ml } from '../ml_api_service'; - -// Obtains the maximum bucket anomaly scores by job ID and time. -// Pass an empty array or ['*'] to search over all job IDs. -// Returned response contains a results property, with a key for job -// which has results for the specified time range. -export function getScoresByBucket(jobIds, earliestMs, latestMs, interval, maxResults) { - return new Promise((resolve, reject) => { - const obj = { - success: true, - results: {}, - }; - - // Build the criteria to use in the bool filter part of the request. - // Adds criteria for the time range plus any specified job IDs. - const boolCriteria = [ - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - ]; - - if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { - let jobIdFilterStr = ''; - _.each(jobIds, (jobId, i) => { - if (i > 0) { - jobIdFilterStr += ' OR '; - } - jobIdFilterStr += 'job_id:'; - jobIdFilterStr += jobId; - }); - boolCriteria.push({ - query_string: { - analyze_wildcard: false, - query: jobIdFilterStr, - }, - }); - } - - ml.esSearch({ - index: ML_RESULTS_INDEX_PATTERN, - size: 0, - body: { - query: { - bool: { - filter: [ - { - query_string: { - query: 'result_type:bucket', - analyze_wildcard: false, - }, - }, - { - bool: { - must: boolCriteria, - }, +/** + * Service for carrying out Elasticsearch queries to obtain data for the Ml Results dashboards. + */ +export function resultsServiceProvider(mlApiServices) { + const SAMPLER_TOP_TERMS_SHARD_SIZE = 20000; + const ENTITY_AGGREGATION_SIZE = 10; + const AGGREGATION_MIN_DOC_COUNT = 1; + const CARDINALITY_PRECISION_THRESHOLD = 100; + + return { + // Obtains the maximum bucket anomaly scores by job ID and time. + // Pass an empty array or ['*'] to search over all job IDs. + // Returned response contains a results property, with a key for job + // which has results for the specified time range. + getScoresByBucket(jobIds, earliestMs, latestMs, interval, maxResults) { + return new Promise((resolve, reject) => { + const obj = { + success: true, + results: {}, + }; + + // Build the criteria to use in the bool filter part of the request. + // Adds criteria for the time range plus any specified job IDs. + const boolCriteria = [ + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', }, - ], + }, }, - }, - aggs: { - jobId: { - terms: { - field: 'job_id', - size: maxResults !== undefined ? maxResults : 5, - order: { - anomalyScore: 'desc', - }, + ]; + + if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { + let jobIdFilterStr = ''; + _.each(jobIds, (jobId, i) => { + if (i > 0) { + jobIdFilterStr += ' OR '; + } + jobIdFilterStr += 'job_id:'; + jobIdFilterStr += jobId; + }); + boolCriteria.push({ + query_string: { + analyze_wildcard: false, + query: jobIdFilterStr, }, - aggs: { - anomalyScore: { - max: { - field: 'anomaly_score', + }); + } + + mlApiServices + .esSearch({ + index: ML_RESULTS_INDEX_PATTERN, + size: 0, + body: { + query: { + bool: { + filter: [ + { + query_string: { + query: 'result_type:bucket', + analyze_wildcard: false, + }, + }, + { + bool: { + must: boolCriteria, + }, + }, + ], }, }, - byTime: { - date_histogram: { - field: 'timestamp', - interval: interval, - min_doc_count: 1, - extended_bounds: { - min: earliestMs, - max: latestMs, + aggs: { + jobId: { + terms: { + field: 'job_id', + size: maxResults !== undefined ? maxResults : 5, + order: { + anomalyScore: 'desc', + }, }, - }, - aggs: { - anomalyScore: { - max: { - field: 'anomaly_score', + aggs: { + anomalyScore: { + max: { + field: 'anomaly_score', + }, + }, + byTime: { + date_histogram: { + field: 'timestamp', + interval: interval, + min_doc_count: 1, + extended_bounds: { + min: earliestMs, + max: latestMs, + }, + }, + aggs: { + anomalyScore: { + max: { + field: 'anomaly_score', + }, + }, + }, }, }, }, }, }, + }) + .then((resp) => { + const dataByJobId = _.get(resp, ['aggregations', 'jobId', 'buckets'], []); + _.each(dataByJobId, (dataForJob) => { + const jobId = dataForJob.key; + + const resultsForTime = {}; + + const dataByTime = _.get(dataForJob, ['byTime', 'buckets'], []); + _.each(dataByTime, (dataForTime) => { + const value = _.get(dataForTime, ['anomalyScore', 'value']); + if (value !== undefined) { + const time = dataForTime.key; + resultsForTime[time] = _.get(dataForTime, ['anomalyScore', 'value']); + } + }); + obj.results[jobId] = resultsForTime; + }); + + resolve(obj); + }) + .catch((resp) => { + reject(resp); + }); + }); + }, + + // Obtains the top influencers, by maximum influencer score, for the specified index, time range and job ID(s). + // Pass an empty array or ['*'] to search over all job IDs. + // An optional array of influencers may be supplied, with each object in the array having 'fieldName' + // and 'fieldValue' properties, to limit data to the supplied list of influencers. + // Returned response contains an influencers property, with a key for each of the influencer field names, + // whose value is an array of objects containing influencerFieldValue, maxAnomalyScore and sumAnomalyScore keys. + getTopInfluencers( + jobIds, + earliestMs, + latestMs, + maxFieldValues = 10, + influencers = [], + influencersFilterQuery + ) { + return new Promise((resolve, reject) => { + const obj = { success: true, influencers: {} }; + + // Build the criteria to use in the bool filter part of the request. + // Adds criteria for the time range plus any specified job IDs. + const boolCriteria = [ + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, + }, }, - }, - }, - }) - .then((resp) => { - const dataByJobId = _.get(resp, ['aggregations', 'jobId', 'buckets'], []); - _.each(dataByJobId, (dataForJob) => { - const jobId = dataForJob.key; - - const resultsForTime = {}; - - const dataByTime = _.get(dataForJob, ['byTime', 'buckets'], []); - _.each(dataByTime, (dataForTime) => { - const value = _.get(dataForTime, ['anomalyScore', 'value']); - if (value !== undefined) { - const time = dataForTime.key; - resultsForTime[time] = _.get(dataForTime, ['anomalyScore', 'value']); + { + range: { + influencer_score: { + gt: 0, + }, + }, + }, + ]; + + if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { + let jobIdFilterStr = ''; + _.each(jobIds, (jobId, i) => { + if (i > 0) { + jobIdFilterStr += ' OR '; } + jobIdFilterStr += 'job_id:'; + jobIdFilterStr += jobId; }); - obj.results[jobId] = resultsForTime; - }); + boolCriteria.push({ + query_string: { + analyze_wildcard: false, + query: jobIdFilterStr, + }, + }); + } - resolve(obj); - }) - .catch((resp) => { - reject(resp); - }); - }); -} + if (influencersFilterQuery !== undefined) { + boolCriteria.push(influencersFilterQuery); + } -// Obtains the top influencers, by maximum influencer score, for the specified index, time range and job ID(s). -// Pass an empty array or ['*'] to search over all job IDs. -// An optional array of influencers may be supplied, with each object in the array having 'fieldName' -// and 'fieldValue' properties, to limit data to the supplied list of influencers. -// Returned response contains an influencers property, with a key for each of the influencer field names, -// whose value is an array of objects containing influencerFieldValue, maxAnomalyScore and sumAnomalyScore keys. -export function getTopInfluencers( - jobIds, - earliestMs, - latestMs, - maxFieldValues = 10, - influencers = [], - influencersFilterQuery -) { - return new Promise((resolve, reject) => { - const obj = { success: true, influencers: {} }; - - // Build the criteria to use in the bool filter part of the request. - // Adds criteria for the time range plus any specified job IDs. - const boolCriteria = [ - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - { - range: { - influencer_score: { - gt: 0, - }, - }, - }, - ]; - - if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { - let jobIdFilterStr = ''; - _.each(jobIds, (jobId, i) => { - if (i > 0) { - jobIdFilterStr += ' OR '; + // Add a should query to filter for each of the specified influencers. + if (influencers.length > 0) { + boolCriteria.push({ + bool: { + should: influencers.map((influencer) => { + return { + bool: { + must: [ + { term: { influencer_field_name: influencer.fieldName } }, + { term: { influencer_field_value: influencer.fieldValue } }, + ], + }, + }; + }), + minimum_should_match: 1, + }, + }); } - jobIdFilterStr += 'job_id:'; - jobIdFilterStr += jobId; - }); - boolCriteria.push({ - query_string: { - analyze_wildcard: false, - query: jobIdFilterStr, - }, - }); - } - - if (influencersFilterQuery !== undefined) { - boolCriteria.push(influencersFilterQuery); - } - - // Add a should query to filter for each of the specified influencers. - if (influencers.length > 0) { - boolCriteria.push({ - bool: { - should: influencers.map((influencer) => { - return { - bool: { - must: [ - { term: { influencer_field_name: influencer.fieldName } }, - { term: { influencer_field_value: influencer.fieldValue } }, - ], - }, - }; - }), - minimum_should_match: 1, - }, - }); - } - - ml.esSearch({ - index: ML_RESULTS_INDEX_PATTERN, - size: 0, - body: { - query: { - bool: { - filter: [ - { - query_string: { - query: 'result_type:influencer', - analyze_wildcard: false, - }, - }, - { + + mlApiServices + .esSearch({ + index: ML_RESULTS_INDEX_PATTERN, + size: 0, + body: { + query: { bool: { - must: boolCriteria, - }, - }, - ], - }, - }, - aggs: { - influencerFieldNames: { - terms: { - field: 'influencer_field_name', - size: 5, - order: { - maxAnomalyScore: 'desc', - }, - }, - aggs: { - maxAnomalyScore: { - max: { - field: 'influencer_score', + filter: [ + { + query_string: { + query: 'result_type:influencer', + analyze_wildcard: false, + }, + }, + { + bool: { + must: boolCriteria, + }, + }, + ], }, }, - influencerFieldValues: { - terms: { - field: 'influencer_field_value', - size: maxFieldValues, - order: { - maxAnomalyScore: 'desc', - }, - }, - aggs: { - maxAnomalyScore: { - max: { - field: 'influencer_score', + aggs: { + influencerFieldNames: { + terms: { + field: 'influencer_field_name', + size: 5, + order: { + maxAnomalyScore: 'desc', }, }, - sumAnomalyScore: { - sum: { - field: 'influencer_score', + aggs: { + maxAnomalyScore: { + max: { + field: 'influencer_score', + }, + }, + influencerFieldValues: { + terms: { + field: 'influencer_field_value', + size: maxFieldValues, + order: { + maxAnomalyScore: 'desc', + }, + }, + aggs: { + maxAnomalyScore: { + max: { + field: 'influencer_score', + }, + }, + sumAnomalyScore: { + sum: { + field: 'influencer_score', + }, + }, + }, }, }, }, }, }, - }, - }, - }, - }) - .then((resp) => { - const fieldNameBuckets = _.get( - resp, - ['aggregations', 'influencerFieldNames', 'buckets'], - [] - ); - _.each(fieldNameBuckets, (nameBucket) => { - const fieldName = nameBucket.key; - const fieldValues = []; - - const fieldValueBuckets = _.get(nameBucket, ['influencerFieldValues', 'buckets'], []); - _.each(fieldValueBuckets, (valueBucket) => { - const fieldValueResult = { - influencerFieldValue: valueBucket.key, - maxAnomalyScore: valueBucket.maxAnomalyScore.value, - sumAnomalyScore: valueBucket.sumAnomalyScore.value, - }; - fieldValues.push(fieldValueResult); - }); - - obj.influencers[fieldName] = fieldValues; - }); - - resolve(obj); - }) - .catch((resp) => { - reject(resp); - }); - }); -} + }) + .then((resp) => { + const fieldNameBuckets = _.get( + resp, + ['aggregations', 'influencerFieldNames', 'buckets'], + [] + ); + _.each(fieldNameBuckets, (nameBucket) => { + const fieldName = nameBucket.key; + const fieldValues = []; + + const fieldValueBuckets = _.get(nameBucket, ['influencerFieldValues', 'buckets'], []); + _.each(fieldValueBuckets, (valueBucket) => { + const fieldValueResult = { + influencerFieldValue: valueBucket.key, + maxAnomalyScore: valueBucket.maxAnomalyScore.value, + sumAnomalyScore: valueBucket.sumAnomalyScore.value, + }; + fieldValues.push(fieldValueResult); + }); + + obj.influencers[fieldName] = fieldValues; + }); -// Obtains the top influencer field values, by maximum anomaly score, for a -// particular index, field name and job ID(s). -// Pass an empty array or ['*'] to search over all job IDs. -// Returned response contains a results property, which is an array of objects -// containing influencerFieldValue, maxAnomalyScore and sumAnomalyScore keys. -export function getTopInfluencerValues( - jobIds, - influencerFieldName, - earliestMs, - latestMs, - maxResults -) { - return new Promise((resolve, reject) => { - const obj = { success: true, results: [] }; - - // Build the criteria to use in the bool filter part of the request. - // Adds criteria for the time range plus any specified job IDs. - const boolCriteria = [ - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - ]; - - if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { - let jobIdFilterStr = ''; - _.each(jobIds, (jobId, i) => { - if (i > 0) { - jobIdFilterStr += ' OR '; - } - jobIdFilterStr += 'job_id:'; - jobIdFilterStr += jobId; - }); - boolCriteria.push({ - query_string: { - analyze_wildcard: false, - query: jobIdFilterStr, - }, + resolve(obj); + }) + .catch((resp) => { + reject(resp); + }); }); - } - - ml.esSearch({ - index: ML_RESULTS_INDEX_PATTERN, - size: 0, - body: { - query: { - bool: { - filter: [ - { - query_string: { - query: `result_type:influencer AND influencer_field_name: ${escapeForElasticsearchQuery( - influencerFieldName - )}`, - analyze_wildcard: false, - }, + }, + + // Obtains the top influencer field values, by maximum anomaly score, for a + // particular index, field name and job ID(s). + // Pass an empty array or ['*'] to search over all job IDs. + // Returned response contains a results property, which is an array of objects + // containing influencerFieldValue, maxAnomalyScore and sumAnomalyScore keys. + getTopInfluencerValues(jobIds, influencerFieldName, earliestMs, latestMs, maxResults) { + return new Promise((resolve, reject) => { + const obj = { success: true, results: [] }; + + // Build the criteria to use in the bool filter part of the request. + // Adds criteria for the time range plus any specified job IDs. + const boolCriteria = [ + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', }, - { - bool: { - must: boolCriteria, - }, - }, - ], + }, }, - }, - aggs: { - influencerFieldValues: { - terms: { - field: 'influencer_field_value', - size: maxResults !== undefined ? maxResults : 2, - order: { - maxAnomalyScore: 'desc', - }, + ]; + + if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { + let jobIdFilterStr = ''; + _.each(jobIds, (jobId, i) => { + if (i > 0) { + jobIdFilterStr += ' OR '; + } + jobIdFilterStr += 'job_id:'; + jobIdFilterStr += jobId; + }); + boolCriteria.push({ + query_string: { + analyze_wildcard: false, + query: jobIdFilterStr, }, - aggs: { - maxAnomalyScore: { - max: { - field: 'influencer_score', + }); + } + + mlApiServices + .esSearch({ + index: ML_RESULTS_INDEX_PATTERN, + size: 0, + body: { + query: { + bool: { + filter: [ + { + query_string: { + query: `result_type:influencer AND influencer_field_name: ${escapeForElasticsearchQuery( + influencerFieldName + )}`, + analyze_wildcard: false, + }, + }, + { + bool: { + must: boolCriteria, + }, + }, + ], }, }, - sumAnomalyScore: { - sum: { - field: 'influencer_score', + aggs: { + influencerFieldValues: { + terms: { + field: 'influencer_field_value', + size: maxResults !== undefined ? maxResults : 2, + order: { + maxAnomalyScore: 'desc', + }, + }, + aggs: { + maxAnomalyScore: { + max: { + field: 'influencer_score', + }, + }, + sumAnomalyScore: { + sum: { + field: 'influencer_score', + }, + }, + }, }, }, }, - }, - }, - }, - }) - .then((resp) => { - const buckets = _.get(resp, ['aggregations', 'influencerFieldValues', 'buckets'], []); - _.each(buckets, (bucket) => { - const result = { - influencerFieldValue: bucket.key, - maxAnomalyScore: bucket.maxAnomalyScore.value, - sumAnomalyScore: bucket.sumAnomalyScore.value, - }; - obj.results.push(result); - }); + }) + .then((resp) => { + const buckets = _.get(resp, ['aggregations', 'influencerFieldValues', 'buckets'], []); + _.each(buckets, (bucket) => { + const result = { + influencerFieldValue: bucket.key, + maxAnomalyScore: bucket.maxAnomalyScore.value, + sumAnomalyScore: bucket.sumAnomalyScore.value, + }; + obj.results.push(result); + }); - resolve(obj); - }) - .catch((resp) => { - reject(resp); + resolve(obj); + }) + .catch((resp) => { + reject(resp); + }); }); - }); -} - -// Obtains the overall bucket scores for the specified job ID(s). -// Pass ['*'] to search over all job IDs. -// Returned response contains a results property as an object of max score by time. -export function getOverallBucketScores(jobIds, topN, earliestMs, latestMs, interval) { - return new Promise((resolve, reject) => { - const obj = { success: true, results: {} }; - - ml.overallBuckets({ - jobId: jobIds, - topN: topN, - bucketSpan: interval, - start: earliestMs, - end: latestMs, - }) - .then((resp) => { - const dataByTime = _.get(resp, ['overall_buckets'], []); - _.each(dataByTime, (dataForTime) => { - const value = _.get(dataForTime, ['overall_score']); - if (value !== undefined) { - obj.results[dataForTime.timestamp] = value; - } - }); + }, + + // Obtains the overall bucket scores for the specified job ID(s). + // Pass ['*'] to search over all job IDs. + // Returned response contains a results property as an object of max score by time. + getOverallBucketScores(jobIds, topN, earliestMs, latestMs, interval) { + return new Promise((resolve, reject) => { + const obj = { success: true, results: {} }; + + mlApiServices + .overallBuckets({ + jobId: jobIds, + topN: topN, + bucketSpan: interval, + start: earliestMs, + end: latestMs, + }) + .then((resp) => { + const dataByTime = _.get(resp, ['overall_buckets'], []); + _.each(dataByTime, (dataForTime) => { + const value = _.get(dataForTime, ['overall_score']); + if (value !== undefined) { + obj.results[dataForTime.timestamp] = value; + } + }); - resolve(obj); - }) - .catch((resp) => { - reject(resp); + resolve(obj); + }) + .catch((resp) => { + reject(resp); + }); }); - }); -} - -// Obtains the maximum score by influencer_field_value and by time for the specified job ID(s) -// (pass an empty array or ['*'] to search over all job IDs), and specified influencer field -// values (pass an empty array to search over all field values). -// Returned response contains a results property with influencer field values keyed -// against max score by time. -export function getInfluencerValueMaxScoreByTime( - jobIds, - influencerFieldName, - influencerFieldValues, - earliestMs, - latestMs, - interval, - maxResults, - influencersFilterQuery -) { - return new Promise((resolve, reject) => { - const obj = { success: true, results: {} }; - - // Build the criteria to use in the bool filter part of the request. - // Adds criteria for the time range plus any specified job IDs. - const boolCriteria = [ - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', + }, + + // Obtains the maximum score by influencer_field_value and by time for the specified job ID(s) + // (pass an empty array or ['*'] to search over all job IDs), and specified influencer field + // values (pass an empty array to search over all field values). + // Returned response contains a results property with influencer field values keyed + // against max score by time. + getInfluencerValueMaxScoreByTime( + jobIds, + influencerFieldName, + influencerFieldValues, + earliestMs, + latestMs, + interval, + maxResults, + influencersFilterQuery + ) { + return new Promise((resolve, reject) => { + const obj = { success: true, results: {} }; + + // Build the criteria to use in the bool filter part of the request. + // Adds criteria for the time range plus any specified job IDs. + const boolCriteria = [ + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, + }, }, - }, - }, - { - range: { - influencer_score: { - gt: 0, + { + range: { + influencer_score: { + gt: 0, + }, + }, }, - }, - }, - ]; - - if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { - let jobIdFilterStr = ''; - _.each(jobIds, (jobId, i) => { - if (i > 0) { - jobIdFilterStr += ' OR '; - } - jobIdFilterStr += `job_id:${jobId}`; - }); - boolCriteria.push({ - query_string: { - analyze_wildcard: false, - query: jobIdFilterStr, - }, - }); - } + ]; - if (influencersFilterQuery !== undefined) { - boolCriteria.push(influencersFilterQuery); - } + if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { + let jobIdFilterStr = ''; + _.each(jobIds, (jobId, i) => { + if (i > 0) { + jobIdFilterStr += ' OR '; + } + jobIdFilterStr += `job_id:${jobId}`; + }); + boolCriteria.push({ + query_string: { + analyze_wildcard: false, + query: jobIdFilterStr, + }, + }); + } - if (influencerFieldValues && influencerFieldValues.length > 0) { - let influencerFilterStr = ''; - _.each(influencerFieldValues, (value, i) => { - if (i > 0) { - influencerFilterStr += ' OR '; + if (influencersFilterQuery !== undefined) { + boolCriteria.push(influencersFilterQuery); } - if (value.trim().length > 0) { - influencerFilterStr += `influencer_field_value:${escapeForElasticsearchQuery(value)}`; - } else { - // Wrap whitespace influencer field values in quotes for the query_string query. - influencerFilterStr += `influencer_field_value:"${value}"`; + + if (influencerFieldValues && influencerFieldValues.length > 0) { + let influencerFilterStr = ''; + _.each(influencerFieldValues, (value, i) => { + if (i > 0) { + influencerFilterStr += ' OR '; + } + if (value.trim().length > 0) { + influencerFilterStr += `influencer_field_value:${escapeForElasticsearchQuery(value)}`; + } else { + // Wrap whitespace influencer field values in quotes for the query_string query. + influencerFilterStr += `influencer_field_value:"${value}"`; + } + }); + boolCriteria.push({ + query_string: { + analyze_wildcard: false, + query: influencerFilterStr, + }, + }); } - }); - boolCriteria.push({ - query_string: { - analyze_wildcard: false, - query: influencerFilterStr, - }, - }); - } - - ml.esSearch({ - index: ML_RESULTS_INDEX_PATTERN, - size: 0, - body: { - query: { - bool: { - filter: [ - { - query_string: { - query: `result_type:influencer AND influencer_field_name: ${escapeForElasticsearchQuery( - influencerFieldName - )}`, - analyze_wildcard: false, + + mlApiServices + .esSearch({ + index: ML_RESULTS_INDEX_PATTERN, + size: 0, + body: { + query: { + bool: { + filter: [ + { + query_string: { + query: `result_type:influencer AND influencer_field_name: ${escapeForElasticsearchQuery( + influencerFieldName + )}`, + analyze_wildcard: false, + }, + }, + { + bool: { + must: boolCriteria, + }, + }, + ], + }, + }, + aggs: { + influencerFieldValues: { + terms: { + field: 'influencer_field_value', + size: maxResults !== undefined ? maxResults : 10, + order: { + maxAnomalyScore: 'desc', + }, + }, + aggs: { + maxAnomalyScore: { + max: { + field: 'influencer_score', + }, + }, + byTime: { + date_histogram: { + field: 'timestamp', + interval, + min_doc_count: 1, + }, + aggs: { + maxAnomalyScore: { + max: { + field: 'influencer_score', + }, + }, + }, + }, + }, }, }, - { + }, + }) + .then((resp) => { + const fieldValueBuckets = _.get( + resp, + ['aggregations', 'influencerFieldValues', 'buckets'], + [] + ); + _.each(fieldValueBuckets, (valueBucket) => { + const fieldValue = valueBucket.key; + const fieldValues = {}; + + const timeBuckets = _.get(valueBucket, ['byTime', 'buckets'], []); + _.each(timeBuckets, (timeBucket) => { + const time = timeBucket.key; + const score = timeBucket.maxAnomalyScore.value; + fieldValues[time] = score; + }); + + obj.results[fieldValue] = fieldValues; + }); + + resolve(obj); + }) + .catch((resp) => { + reject(resp); + }); + }); + }, + + // Queries Elasticsearch to obtain record level results containing the influencers + // for the specified job(s), record score threshold, and time range. + // Pass an empty array or ['*'] to search over all job IDs. + // Returned response contains a records property, with each record containing + // only the fields job_id, detector_index, record_score and influencers. + getRecordInfluencers(jobIds, threshold, earliestMs, latestMs, maxResults) { + return new Promise((resolve, reject) => { + const obj = { success: true, records: [] }; + + // Build the criteria to use in the bool filter part of the request. + // Adds criteria for the existence of the nested influencers field, time range, + // record score, plus any specified job IDs. + const boolCriteria = [ + { + nested: { + path: 'influencers', + query: { bool: { - must: boolCriteria, + must: [ + { + exists: { field: 'influencers' }, + }, + ], }, }, - ], + }, }, - }, - aggs: { - influencerFieldValues: { - terms: { - field: 'influencer_field_value', - size: maxResults !== undefined ? maxResults : 10, - order: { - maxAnomalyScore: 'desc', + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', }, }, - aggs: { - maxAnomalyScore: { - max: { - field: 'influencer_score', - }, + }, + { + range: { + record_score: { + gte: threshold, }, - byTime: { - date_histogram: { - field: 'timestamp', - interval, - min_doc_count: 1, - }, - aggs: { - maxAnomalyScore: { - max: { - field: 'influencer_score', + }, + }, + ]; + + if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { + let jobIdFilterStr = ''; + _.each(jobIds, (jobId, i) => { + if (i > 0) { + jobIdFilterStr += ' OR '; + } + jobIdFilterStr += 'job_id:'; + jobIdFilterStr += jobId; + }); + boolCriteria.push({ + query_string: { + analyze_wildcard: false, + query: jobIdFilterStr, + }, + }); + } + + mlApiServices + .esSearch({ + index: ML_RESULTS_INDEX_PATTERN, + size: maxResults !== undefined ? maxResults : 100, + rest_total_hits_as_int: true, + body: { + _source: ['job_id', 'detector_index', 'influencers', 'record_score'], + query: { + bool: { + filter: [ + { + query_string: { + query: 'result_type:record', + analyze_wildcard: false, + }, }, - }, + { + bool: { + must: boolCriteria, + }, + }, + ], }, }, + sort: [{ record_score: { order: 'desc' } }], }, - }, - }, - }, - }) - .then((resp) => { - const fieldValueBuckets = _.get( - resp, - ['aggregations', 'influencerFieldValues', 'buckets'], - [] - ); - _.each(fieldValueBuckets, (valueBucket) => { - const fieldValue = valueBucket.key; - const fieldValues = {}; - - const timeBuckets = _.get(valueBucket, ['byTime', 'buckets'], []); - _.each(timeBuckets, (timeBucket) => { - const time = timeBucket.key; - const score = timeBucket.maxAnomalyScore.value; - fieldValues[time] = score; + }) + .then((resp) => { + if (resp.hits.total !== 0) { + _.each(resp.hits.hits, (hit) => { + obj.records.push(hit._source); + }); + } + resolve(obj); + }) + .catch((resp) => { + reject(resp); }); + }); + }, + + // Queries Elasticsearch to obtain the record level results containing the specified influencer(s), + // for the specified job(s), time range, and record score threshold. + // influencers parameter must be an array, with each object in the array having 'fieldName' + // 'fieldValue' properties. The influencer array uses 'should' for the nested bool query, + // so this returns record level results which have at least one of the influencers. + // Pass an empty array or ['*'] to search over all job IDs. + getRecordsForInfluencer( + jobIds, + influencers, + threshold, + earliestMs, + latestMs, + maxResults, + influencersFilterQuery + ) { + return new Promise((resolve, reject) => { + const obj = { success: true, records: [] }; + + // Build the criteria to use in the bool filter part of the request. + // Add criteria for the time range, record score, plus any specified job IDs. + const boolCriteria = [ + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, + }, + }, + { + range: { + record_score: { + gte: threshold, + }, + }, + }, + ]; - obj.results[fieldValue] = fieldValues; - }); + if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { + let jobIdFilterStr = ''; + _.each(jobIds, (jobId, i) => { + if (i > 0) { + jobIdFilterStr += ' OR '; + } + jobIdFilterStr += 'job_id:'; + jobIdFilterStr += jobId; + }); + boolCriteria.push({ + query_string: { + analyze_wildcard: false, + query: jobIdFilterStr, + }, + }); + } - resolve(obj); - }) - .catch((resp) => { - reject(resp); - }); - }); -} + if (influencersFilterQuery !== undefined) { + boolCriteria.push(influencersFilterQuery); + } -// Queries Elasticsearch to obtain record level results containing the influencers -// for the specified job(s), record score threshold, and time range. -// Pass an empty array or ['*'] to search over all job IDs. -// Returned response contains a records property, with each record containing -// only the fields job_id, detector_index, record_score and influencers. -export function getRecordInfluencers(jobIds, threshold, earliestMs, latestMs, maxResults) { - return new Promise((resolve, reject) => { - const obj = { success: true, records: [] }; - - // Build the criteria to use in the bool filter part of the request. - // Adds criteria for the existence of the nested influencers field, time range, - // record score, plus any specified job IDs. - const boolCriteria = [ - { - nested: { - path: 'influencers', - query: { + // Add a nested query to filter for each of the specified influencers. + if (influencers.length > 0) { + boolCriteria.push({ bool: { - must: [ - { - exists: { field: 'influencers' }, + should: influencers.map((influencer) => { + return { + nested: { + path: 'influencers', + query: { + bool: { + must: [ + { + match: { + 'influencers.influencer_field_name': influencer.fieldName, + }, + }, + { + match: { + 'influencers.influencer_field_values': influencer.fieldValue, + }, + }, + ], + }, + }, + }, + }; + }), + minimum_should_match: 1, + }, + }); + } + + mlApiServices + .esSearch({ + index: ML_RESULTS_INDEX_PATTERN, + size: maxResults !== undefined ? maxResults : 100, + rest_total_hits_as_int: true, + body: { + query: { + bool: { + filter: [ + { + query_string: { + query: 'result_type:record', + analyze_wildcard: false, + }, + }, + { + bool: { + must: boolCriteria, + }, + }, + ], }, - ], + }, + sort: [{ record_score: { order: 'desc' } }], + }, + }) + .then((resp) => { + if (resp.hits.total !== 0) { + _.each(resp.hits.hits, (hit) => { + obj.records.push(hit._source); + }); + } + resolve(obj); + }) + .catch((resp) => { + reject(resp); + }); + }); + }, + + // Queries Elasticsearch to obtain the record level results for the specified job and detector, + // time range, record score threshold, and whether to only return results containing influencers. + // An additional, optional influencer field name and value may also be provided. + getRecordsForDetector( + jobId, + detectorIndex, + checkForInfluencers, + influencerFieldName, + influencerFieldValue, + threshold, + earliestMs, + latestMs, + maxResults + ) { + return new Promise((resolve, reject) => { + const obj = { success: true, records: [] }; + + // Build the criteria to use in the bool filter part of the request. + // Add criteria for the time range, record score, plus any specified job IDs. + const boolCriteria = [ + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, }, }, - }, - }, - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', + { + term: { job_id: jobId }, }, - }, - }, - { - range: { - record_score: { - gte: threshold, + { + term: { detector_index: detectorIndex }, }, - }, - }, - ]; - - if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { - let jobIdFilterStr = ''; - _.each(jobIds, (jobId, i) => { - if (i > 0) { - jobIdFilterStr += ' OR '; - } - jobIdFilterStr += 'job_id:'; - jobIdFilterStr += jobId; - }); - boolCriteria.push({ - query_string: { - analyze_wildcard: false, - query: jobIdFilterStr, - }, - }); - } - - ml.esSearch({ - index: ML_RESULTS_INDEX_PATTERN, - size: maxResults !== undefined ? maxResults : 100, - rest_total_hits_as_int: true, - body: { - _source: ['job_id', 'detector_index', 'influencers', 'record_score'], - query: { - bool: { - filter: [ - { - query_string: { - query: 'result_type:record', - analyze_wildcard: false, - }, + { + range: { + record_score: { + gte: threshold, }, - { + }, + }, + ]; + + // Add a nested query to filter for the specified influencer field name and value. + if (influencerFieldName && influencerFieldValue) { + boolCriteria.push({ + nested: { + path: 'influencers', + query: { bool: { - must: boolCriteria, + must: [ + { + match: { + 'influencers.influencer_field_name': influencerFieldName, + }, + }, + { + match: { + 'influencers.influencer_field_values': influencerFieldValue, + }, + }, + ], }, }, - ], - }, - }, - sort: [{ record_score: { order: 'desc' } }], - }, - }) - .then((resp) => { - if (resp.hits.total !== 0) { - _.each(resp.hits.hits, (hit) => { - obj.records.push(hit._source); + }, }); - } - resolve(obj); - }) - .catch((resp) => { - reject(resp); - }); - }); -} - -// Queries Elasticsearch to obtain the record level results containing the specified influencer(s), -// for the specified job(s), time range, and record score threshold. -// influencers parameter must be an array, with each object in the array having 'fieldName' -// 'fieldValue' properties. The influencer array uses 'should' for the nested bool query, -// so this returns record level results which have at least one of the influencers. -// Pass an empty array or ['*'] to search over all job IDs. -export function getRecordsForInfluencer( - jobIds, - influencers, - threshold, - earliestMs, - latestMs, - maxResults, - influencersFilterQuery -) { - return new Promise((resolve, reject) => { - const obj = { success: true, records: [] }; - - // Build the criteria to use in the bool filter part of the request. - // Add criteria for the time range, record score, plus any specified job IDs. - const boolCriteria = [ - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - { - range: { - record_score: { - gte: threshold, - }, - }, - }, - ]; - - if (jobIds && jobIds.length > 0 && !(jobIds.length === 1 && jobIds[0] === '*')) { - let jobIdFilterStr = ''; - _.each(jobIds, (jobId, i) => { - if (i > 0) { - jobIdFilterStr += ' OR '; - } - jobIdFilterStr += 'job_id:'; - jobIdFilterStr += jobId; - }); - boolCriteria.push({ - query_string: { - analyze_wildcard: false, - query: jobIdFilterStr, - }, - }); - } - - if (influencersFilterQuery !== undefined) { - boolCriteria.push(influencersFilterQuery); - } - - // Add a nested query to filter for each of the specified influencers. - if (influencers.length > 0) { - boolCriteria.push({ - bool: { - should: influencers.map((influencer) => { - return { + } else { + if (checkForInfluencers === true) { + boolCriteria.push({ nested: { path: 'influencers', query: { bool: { must: [ { - match: { - 'influencers.influencer_field_name': influencer.fieldName, - }, - }, - { - match: { - 'influencers.influencer_field_values': influencer.fieldValue, - }, + exists: { field: 'influencers' }, }, ], }, }, }, - }; - }), - minimum_should_match: 1, - }, - }); - } - - ml.esSearch({ - index: ML_RESULTS_INDEX_PATTERN, - size: maxResults !== undefined ? maxResults : 100, - rest_total_hits_as_int: true, - body: { - query: { - bool: { - filter: [ - { - query_string: { - query: 'result_type:record', - analyze_wildcard: false, - }, - }, - { - bool: { - must: boolCriteria, - }, - }, - ], - }, - }, - sort: [{ record_score: { order: 'desc' } }], - }, - }) - .then((resp) => { - if (resp.hits.total !== 0) { - _.each(resp.hits.hits, (hit) => { - obj.records.push(hit._source); - }); + }); + } } - resolve(obj); - }) - .catch((resp) => { - reject(resp); - }); - }); -} -// Queries Elasticsearch to obtain the record level results for the specified job and detector, -// time range, record score threshold, and whether to only return results containing influencers. -// An additional, optional influencer field name and value may also be provided. -export function getRecordsForDetector( - jobId, - detectorIndex, - checkForInfluencers, - influencerFieldName, - influencerFieldValue, - threshold, - earliestMs, - latestMs, - maxResults -) { - return new Promise((resolve, reject) => { - const obj = { success: true, records: [] }; - - // Build the criteria to use in the bool filter part of the request. - // Add criteria for the time range, record score, plus any specified job IDs. - const boolCriteria = [ - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - { - term: { job_id: jobId }, - }, - { - term: { detector_index: detectorIndex }, - }, - { - range: { - record_score: { - gte: threshold, - }, - }, - }, - ]; - - // Add a nested query to filter for the specified influencer field name and value. - if (influencerFieldName && influencerFieldValue) { - boolCriteria.push({ - nested: { - path: 'influencers', - query: { - bool: { - must: [ - { - match: { - 'influencers.influencer_field_name': influencerFieldName, - }, - }, - { - match: { - 'influencers.influencer_field_values': influencerFieldValue, - }, + mlApiServices + .esSearch({ + index: ML_RESULTS_INDEX_PATTERN, + size: maxResults !== undefined ? maxResults : 100, + rest_total_hits_as_int: true, + body: { + query: { + bool: { + filter: [ + { + query_string: { + query: 'result_type:record', + analyze_wildcard: false, + }, + }, + { + bool: { + must: boolCriteria, + }, + }, + ], }, - ], + }, + sort: [{ record_score: { order: 'desc' } }], }, - }, - }, + }) + .then((resp) => { + if (resp.hits.total !== 0) { + _.each(resp.hits.hits, (hit) => { + obj.records.push(hit._source); + }); + } + resolve(obj); + }) + .catch((resp) => { + reject(resp); + }); }); - } else { - if (checkForInfluencers === true) { - boolCriteria.push({ - nested: { - path: 'influencers', - query: { - bool: { - must: [ - { - exists: { field: 'influencers' }, - }, - ], + }, + + // Queries Elasticsearch to obtain all the record level results for the specified job(s), time range, + // and record score threshold. + // Pass an empty array or ['*'] to search over all job IDs. + // Returned response contains a records property, which is an array of the matching results. + getRecords(jobIds, threshold, earliestMs, latestMs, maxResults) { + return this.getRecordsForInfluencer(jobIds, [], threshold, earliestMs, latestMs, maxResults); + }, + + // Queries Elasticsearch to obtain event rate data i.e. the count + // of documents over time. + // index can be a String, or String[], of index names to search. + // Extra query object can be supplied, or pass null if no additional query. + // Returned response contains a results property, which is an object + // of document counts against time (epoch millis). + getEventRateData(index, query, timeFieldName, earliestMs, latestMs, interval) { + return new Promise((resolve, reject) => { + const obj = { success: true, results: {} }; + + // Build the criteria to use in the bool filter part of the request. + // Add criteria for the time range, entity fields, + // plus any additional supplied query. + const mustCriteria = [ + { + range: { + [timeFieldName]: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', }, }, }, - }); - } - } - - ml.esSearch({ - index: ML_RESULTS_INDEX_PATTERN, - size: maxResults !== undefined ? maxResults : 100, - rest_total_hits_as_int: true, - body: { - query: { - bool: { - filter: [ - { - query_string: { - query: 'result_type:record', - analyze_wildcard: false, + ]; + + if (query) { + mustCriteria.push(query); + } + + mlApiServices + .esSearch({ + index, + rest_total_hits_as_int: true, + size: 0, + body: { + query: { + bool: { + must: mustCriteria, }, }, - { - bool: { - must: boolCriteria, + _source: { + excludes: [], + }, + aggs: { + eventRate: { + date_histogram: { + field: timeFieldName, + interval: interval, + min_doc_count: 0, + extended_bounds: { + min: earliestMs, + max: latestMs, + }, + }, }, }, - ], - }, - }, - sort: [{ record_score: { order: 'desc' } }], - }, - }) - .then((resp) => { - if (resp.hits.total !== 0) { - _.each(resp.hits.hits, (hit) => { - obj.records.push(hit._source); + }, + }) + .then((resp) => { + const dataByTimeBucket = _.get(resp, ['aggregations', 'eventRate', 'buckets'], []); + _.each(dataByTimeBucket, (dataForTime) => { + const time = dataForTime.key; + obj.results[time] = dataForTime.doc_count; + }); + obj.total = resp.hits.total; + + resolve(obj); + }) + .catch((resp) => { + reject(resp); }); - } - resolve(obj); - }) - .catch((resp) => { - reject(resp); }); - }); -} - -// Queries Elasticsearch to obtain all the record level results for the specified job(s), time range, -// and record score threshold. -// Pass an empty array or ['*'] to search over all job IDs. -// Returned response contains a records property, which is an array of the matching results. -export function getRecords(jobIds, threshold, earliestMs, latestMs, maxResults) { - return this.getRecordsForInfluencer(jobIds, [], threshold, earliestMs, latestMs, maxResults); -} + }, -// Queries Elasticsearch to obtain event rate data i.e. the count -// of documents over time. -// index can be a String, or String[], of index names to search. -// Extra query object can be supplied, or pass null if no additional query. -// Returned response contains a results property, which is an object -// of document counts against time (epoch millis). -export function getEventRateData(index, query, timeFieldName, earliestMs, latestMs, interval) { - return new Promise((resolve, reject) => { - const obj = { success: true, results: {} }; - - // Build the criteria to use in the bool filter part of the request. - // Add criteria for the time range, entity fields, - // plus any additional supplied query. - const mustCriteria = [ - { - range: { - [timeFieldName]: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - ]; - - if (query) { - mustCriteria.push(query); - } + // Queries Elasticsearch to obtain event distribution i.e. the count + // of entities over time. + // index can be a String, or String[], of index names to search. + // Extra query object can be supplied, or pass null if no additional query. + // Returned response contains a results property, which is an object + // of document counts against time (epoch millis). - ml.esSearch({ + getEventDistributionData( index, - rest_total_hits_as_int: true, - size: 0, - body: { - query: { - bool: { - must: mustCriteria, - }, - }, - _source: { - excludes: [], - }, - aggs: { - eventRate: { - date_histogram: { - field: timeFieldName, - interval: interval, - min_doc_count: 0, - extended_bounds: { - min: earliestMs, - max: latestMs, - }, + splitField, + filterField = null, + query, + metricFunction, // ES aggregation name + metricFieldName, + timeFieldName, + earliestMs, + latestMs, + interval + ) { + return new Promise((resolve, reject) => { + if (splitField === undefined) { + return resolve([]); + } + + // Build the criteria to use in the bool filter part of the request. + // Add criteria for the time range, entity fields, + // plus any additional supplied query. + const mustCriteria = []; + + mustCriteria.push({ + range: { + [timeFieldName]: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', }, }, - }, - }, - }) - .then((resp) => { - const dataByTimeBucket = _.get(resp, ['aggregations', 'eventRate', 'buckets'], []); - _.each(dataByTimeBucket, (dataForTime) => { - const time = dataForTime.key; - obj.results[time] = dataForTime.doc_count; }); - obj.total = resp.hits.total; - resolve(obj); - }) - .catch((resp) => { - reject(resp); - }); - }); -} + if (query) { + mustCriteria.push(query); + } -// Queries Elasticsearch to obtain event distribution i.e. the count -// of entities over time. -// index can be a String, or String[], of index names to search. -// Extra query object can be supplied, or pass null if no additional query. -// Returned response contains a results property, which is an object -// of document counts against time (epoch millis). -const SAMPLER_TOP_TERMS_SHARD_SIZE = 20000; -const ENTITY_AGGREGATION_SIZE = 10; -const AGGREGATION_MIN_DOC_COUNT = 1; -const CARDINALITY_PRECISION_THRESHOLD = 100; -export function getEventDistributionData( - index, - splitField, - filterField = null, - query, - metricFunction, // ES aggregation name - metricFieldName, - timeFieldName, - earliestMs, - latestMs, - interval -) { - return new Promise((resolve, reject) => { - if (splitField === undefined) { - return resolve([]); - } - - // Build the criteria to use in the bool filter part of the request. - // Add criteria for the time range, entity fields, - // plus any additional supplied query. - const mustCriteria = []; - - mustCriteria.push({ - range: { - [timeFieldName]: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }); - - if (query) { - mustCriteria.push(query); - } - - if (filterField !== null) { - mustCriteria.push({ - term: { - [filterField.fieldName]: filterField.fieldValue, - }, - }); - } - - const body = { - query: { - // using function_score and random_score to get a random sample of documents. - // otherwise all documents would have the same score and the sampler aggregation - // would pick the first N documents instead of a random set. - function_score: { - query: { - bool: { - must: mustCriteria, + if (filterField !== null) { + mustCriteria.push({ + term: { + [filterField.fieldName]: filterField.fieldValue, }, - }, - functions: [ - { - random_score: { - // static seed to get same randomized results on every request - seed: 10, - field: '_seq_no', + }); + } + + const body = { + query: { + // using function_score and random_score to get a random sample of documents. + // otherwise all documents would have the same score and the sampler aggregation + // would pick the first N documents instead of a random set. + function_score: { + query: { + bool: { + must: mustCriteria, + }, }, + functions: [ + { + random_score: { + // static seed to get same randomized results on every request + seed: 10, + field: '_seq_no', + }, + }, + ], }, - ], - }, - }, - size: 0, - _source: { - excludes: [], - }, - aggs: { - sample: { - sampler: { - shard_size: SAMPLER_TOP_TERMS_SHARD_SIZE, + }, + size: 0, + _source: { + excludes: [], }, aggs: { - byTime: { - date_histogram: { - field: timeFieldName, - interval: interval, - min_doc_count: AGGREGATION_MIN_DOC_COUNT, + sample: { + sampler: { + shard_size: SAMPLER_TOP_TERMS_SHARD_SIZE, }, aggs: { - entities: { - terms: { - field: splitField.fieldName, - size: ENTITY_AGGREGATION_SIZE, + byTime: { + date_histogram: { + field: timeFieldName, + interval: interval, min_doc_count: AGGREGATION_MIN_DOC_COUNT, }, + aggs: { + entities: { + terms: { + field: splitField.fieldName, + size: ENTITY_AGGREGATION_SIZE, + min_doc_count: AGGREGATION_MIN_DOC_COUNT, + }, + }, + }, }, }, }, }, - }, - }, - }; - - if (metricFieldName !== undefined && metricFieldName !== '') { - body.aggs.sample.aggs.byTime.aggs.entities.aggs = {}; - - const metricAgg = { - [metricFunction]: { - field: metricFieldName, - }, - }; - - if (metricFunction === 'percentiles') { - metricAgg[metricFunction].percents = [ML_MEDIAN_PERCENTS]; - } - - if (metricFunction === 'cardinality') { - metricAgg[metricFunction].precision_threshold = CARDINALITY_PRECISION_THRESHOLD; - } - body.aggs.sample.aggs.byTime.aggs.entities.aggs.metric = metricAgg; - } - - ml.esSearch({ - index, - body, - rest_total_hits_as_int: true, - }) - .then((resp) => { - // Because of the sampling, results of metricFunctions which use sum or count - // can be significantly skewed. Taking into account totalHits we calculate a - // a factor to normalize results for these metricFunctions. - const totalHits = _.get(resp, ['hits', 'total'], 0); - const successfulShards = _.get(resp, ['_shards', 'successful'], 0); - - let normalizeFactor = 1; - if (totalHits > successfulShards * SAMPLER_TOP_TERMS_SHARD_SIZE) { - normalizeFactor = totalHits / (successfulShards * SAMPLER_TOP_TERMS_SHARD_SIZE); + }; + + if (metricFieldName !== undefined && metricFieldName !== '') { + body.aggs.sample.aggs.byTime.aggs.entities.aggs = {}; + + const metricAgg = { + [metricFunction]: { + field: metricFieldName, + }, + }; + + if (metricFunction === 'percentiles') { + metricAgg[metricFunction].percents = [ML_MEDIAN_PERCENTS]; + } + + if (metricFunction === 'cardinality') { + metricAgg[metricFunction].precision_threshold = CARDINALITY_PRECISION_THRESHOLD; + } + body.aggs.sample.aggs.byTime.aggs.entities.aggs.metric = metricAgg; } - const dataByTime = _.get(resp, ['aggregations', 'sample', 'byTime', 'buckets'], []); - const data = dataByTime.reduce((d, dataForTime) => { - const date = +dataForTime.key; - const entities = _.get(dataForTime, ['entities', 'buckets'], []); - entities.forEach((entity) => { - let value = metricFunction === 'count' ? entity.doc_count : entity.metric.value; - - if ( - metricFunction === 'count' || - metricFunction === 'cardinality' || - metricFunction === 'sum' - ) { - value = value * normalizeFactor; + mlApiServices + .esSearch({ + index, + body, + rest_total_hits_as_int: true, + }) + .then((resp) => { + // Because of the sampling, results of metricFunctions which use sum or count + // can be significantly skewed. Taking into account totalHits we calculate a + // a factor to normalize results for these metricFunctions. + const totalHits = _.get(resp, ['hits', 'total'], 0); + const successfulShards = _.get(resp, ['_shards', 'successful'], 0); + + let normalizeFactor = 1; + if (totalHits > successfulShards * SAMPLER_TOP_TERMS_SHARD_SIZE) { + normalizeFactor = totalHits / (successfulShards * SAMPLER_TOP_TERMS_SHARD_SIZE); } - d.push({ - date, - entity: entity.key, - value, - }); + const dataByTime = _.get(resp, ['aggregations', 'sample', 'byTime', 'buckets'], []); + const data = dataByTime.reduce((d, dataForTime) => { + const date = +dataForTime.key; + const entities = _.get(dataForTime, ['entities', 'buckets'], []); + entities.forEach((entity) => { + let value = metricFunction === 'count' ? entity.doc_count : entity.metric.value; + + if ( + metricFunction === 'count' || + metricFunction === 'cardinality' || + metricFunction === 'sum' + ) { + value = value * normalizeFactor; + } + + d.push({ + date, + entity: entity.key, + value, + }); + }); + return d; + }, []); + resolve(data); + }) + .catch((resp) => { + reject(resp); }); - return d; - }, []); - resolve(data); - }) - .catch((resp) => { - reject(resp); }); - }); -} - -// Queries Elasticsearch to obtain the max record score over time for the specified job, -// criteria, time range, and aggregation interval. -// criteriaFields parameter must be an array, with each object in the array having 'fieldName' -// 'fieldValue' properties. -export function getRecordMaxScoreByTime(jobId, criteriaFields, earliestMs, latestMs, interval) { - return new Promise((resolve, reject) => { - const obj = { - success: true, - results: {}, - }; - - // Build the criteria to use in the bool filter part of the request. - const mustCriteria = [ - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', - }, - }, - }, - { term: { job_id: jobId } }, - ]; - - _.each(criteriaFields, (criteria) => { - mustCriteria.push({ - term: { - [criteria.fieldName]: criteria.fieldValue, - }, - }); - }); - - ml.esSearch({ - index: ML_RESULTS_INDEX_PATTERN, - size: 0, - body: { - query: { - bool: { - filter: [ - { - query_string: { - query: 'result_type:record', - analyze_wildcard: true, - }, + }, + + // Queries Elasticsearch to obtain the max record score over time for the specified job, + // criteria, time range, and aggregation interval. + // criteriaFields parameter must be an array, with each object in the array having 'fieldName' + // 'fieldValue' properties. + getRecordMaxScoreByTime(jobId, criteriaFields, earliestMs, latestMs, interval) { + return new Promise((resolve, reject) => { + const obj = { + success: true, + results: {}, + }; + + // Build the criteria to use in the bool filter part of the request. + const mustCriteria = [ + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', }, - { + }, + }, + { term: { job_id: jobId } }, + ]; + + _.each(criteriaFields, (criteria) => { + mustCriteria.push({ + term: { + [criteria.fieldName]: criteria.fieldValue, + }, + }); + }); + + mlApiServices + .esSearch({ + index: ML_RESULTS_INDEX_PATTERN, + size: 0, + body: { + query: { bool: { - must: mustCriteria, + filter: [ + { + query_string: { + query: 'result_type:record', + analyze_wildcard: true, + }, + }, + { + bool: { + must: mustCriteria, + }, + }, + ], }, }, - ], - }, - }, - aggs: { - times: { - date_histogram: { - field: 'timestamp', - interval: interval, - min_doc_count: 1, - }, - aggs: { - recordScore: { - max: { - field: 'record_score', + aggs: { + times: { + date_histogram: { + field: 'timestamp', + interval: interval, + min_doc_count: 1, + }, + aggs: { + recordScore: { + max: { + field: 'record_score', + }, + }, + }, }, }, }, - }, - }, - }, - }) - .then((resp) => { - const aggregationsByTime = _.get(resp, ['aggregations', 'times', 'buckets'], []); - _.each(aggregationsByTime, (dataForTime) => { - const time = dataForTime.key; - obj.results[time] = { - score: _.get(dataForTime, ['recordScore', 'value']), - }; - }); + }) + .then((resp) => { + const aggregationsByTime = _.get(resp, ['aggregations', 'times', 'buckets'], []); + _.each(aggregationsByTime, (dataForTime) => { + const time = dataForTime.key; + obj.results[time] = { + score: _.get(dataForTime, ['recordScore', 'value']), + }; + }); - resolve(obj); - }) - .catch((resp) => { - reject(resp); + resolve(obj); + }) + .catch((resp) => { + reject(resp); + }); }); - }); + }, + }; } diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable.tsx index b53b08e5f6146..b4b25db452bdb 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable.tsx @@ -7,6 +7,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { CoreStart } from 'kibana/public'; +import { i18n } from '@kbn/i18n'; import { Subject } from 'rxjs'; import { Embeddable, @@ -25,12 +26,19 @@ import { RefreshInterval, TimeRange, } from '../../../../../../src/plugins/data/common'; +import { SwimlaneType } from '../../application/explorer/explorer_constants'; export const ANOMALY_SWIMLANE_EMBEDDABLE_TYPE = 'ml_anomaly_swimlane'; +export const getDefaultPanelTitle = (jobIds: JobId[]) => + i18n.translate('xpack.ml.swimlaneEmbeddable.title', { + defaultMessage: 'ML anomaly swimlane for {jobIds}', + values: { jobIds: jobIds.join(', ') }, + }); + export interface AnomalySwimlaneEmbeddableCustomInput { jobIds: JobId[]; - swimlaneType: string; + swimlaneType: SwimlaneType; viewBy?: string; limit?: number; @@ -43,9 +51,12 @@ export interface AnomalySwimlaneEmbeddableCustomInput { export type AnomalySwimlaneEmbeddableInput = EmbeddableInput & AnomalySwimlaneEmbeddableCustomInput; -export interface AnomalySwimlaneEmbeddableOutput extends EmbeddableOutput { +export type AnomalySwimlaneEmbeddableOutput = EmbeddableOutput & + AnomalySwimlaneEmbeddableCustomOutput; + +export interface AnomalySwimlaneEmbeddableCustomOutput { jobIds: JobId[]; - swimlaneType: string; + swimlaneType: SwimlaneType; viewBy?: string; limit?: number; } diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.ts b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.ts index e86d738d8b809..09091b21e49b6 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.ts +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_embeddable_factory.ts @@ -23,8 +23,9 @@ import { MlStartDependencies } from '../../plugin'; import { HttpService } from '../../application/services/http_service'; import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service'; import { ExplorerService } from '../../application/services/explorer_service'; -import { mlResultsService } from '../../application/services/results_service'; +import { mlResultsServiceProvider } from '../../application/services/results_service'; import { resolveAnomalySwimlaneUserInput } from './anomaly_swimlane_setup_flyout'; +import { mlApiServicesProvider } from '../../application/services/ml_api_service'; export class AnomalySwimlaneEmbeddableFactory implements EmbeddableFactoryDefinition { @@ -64,8 +65,7 @@ export class AnomalySwimlaneEmbeddableFactory const explorerService = new ExplorerService( pluginsStart.data.query.timefilter.timefilter, coreStart.uiSettings, - // TODO mlResultsService to use DI - mlResultsService + mlResultsServiceProvider(mlApiServicesProvider(httpService)) ); return [coreStart, pluginsStart, { anomalyDetectorService, explorerService }]; diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_initializer.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_initializer.tsx index 00d47c0d897c7..4c93b9ef23239 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_initializer.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_initializer.tsx @@ -20,7 +20,7 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { SWIMLANE_TYPE } from '../../application/explorer/explorer_constants'; +import { SWIMLANE_TYPE, SwimlaneType } from '../../application/explorer/explorer_constants'; import { AnomalySwimlaneEmbeddableInput } from './anomaly_swimlane_embeddable'; export interface AnomalySwimlaneInitializerProps { @@ -31,7 +31,7 @@ export interface AnomalySwimlaneInitializerProps { >; onCreate: (swimlaneProps: { panelTitle: string; - swimlaneType: string; + swimlaneType: SwimlaneType; viewBy?: string; limit?: number; }) => void; @@ -51,8 +51,8 @@ export const AnomalySwimlaneInitializer: FC = ( initialInput, }) => { const [panelTitle, setPanelTitle] = useState(defaultTitle); - const [swimlaneType, setSwimlaneType] = useState( - (initialInput?.swimlaneType ?? SWIMLANE_TYPE.OVERALL) as SWIMLANE_TYPE + const [swimlaneType, setSwimlaneType] = useState( + initialInput?.swimlaneType ?? SWIMLANE_TYPE.OVERALL ); const [viewBySwimlaneFieldName, setViewBySwimlaneFieldName] = useState(initialInput?.viewBy); const [limit, setLimit] = useState(initialInput?.limit ?? 5); @@ -135,7 +135,7 @@ export const AnomalySwimlaneInitializer: FC = ( })} options={swimlaneTypeOptions} idSelected={swimlaneType} - onChange={(id) => setSwimlaneType(id as SWIMLANE_TYPE)} + onChange={(id) => setSwimlaneType(id as SwimlaneType)} /> diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_setup_flyout.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_setup_flyout.tsx index 83f9833109bf4..54f50d2d3da32 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_setup_flyout.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/anomaly_swimlane_setup_flyout.tsx @@ -6,7 +6,6 @@ import React from 'react'; import { IUiSettingsClient, OverlayStart } from 'kibana/public'; -import { i18n } from '@kbn/i18n'; import moment from 'moment'; import { VIEW_BY_JOB_LABEL } from '../../application/explorer/explorer_constants'; import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; @@ -14,7 +13,10 @@ import { AnomalySwimlaneInitializer } from './anomaly_swimlane_initializer'; import { JobSelectorFlyout } from '../../application/components/job_selector/job_selector_flyout'; import { AnomalyDetectorService } from '../../application/services/anomaly_detector_service'; import { getInitialGroupsMap } from '../../application/components/job_selector/job_selector'; -import { AnomalySwimlaneEmbeddableInput } from './anomaly_swimlane_embeddable'; +import { + AnomalySwimlaneEmbeddableInput, + getDefaultPanelTitle, +} from './anomaly_swimlane_embeddable'; export async function resolveAnomalySwimlaneUserInput( { @@ -52,12 +54,7 @@ export async function resolveAnomalySwimlaneUserInput( reject(); }} onSelectionConfirmed={async ({ jobIds, groups }) => { - const title = - input?.title ?? - i18n.translate('xpack.ml.swimlaneEmbeddable.title', { - defaultMessage: 'ML anomaly swimlane for {jobIds}', - values: { jobIds: jobIds.join(', ') }, - }); + const title = input?.title ?? getDefaultPanelTitle(jobIds); const jobs = await anomalyDetectorService.getJobs$(jobIds).toPromise(); diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/explorer_swimlane_container.tsx b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/explorer_swimlane_container.tsx index e5d8584683c55..0bba9b59f7bf7 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/explorer_swimlane_container.tsx +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/explorer_swimlane_container.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, useState } from 'react'; +import React, { FC, useCallback, useState } from 'react'; import { EuiCallOut, EuiFlexGroup, @@ -28,6 +28,7 @@ import { } from './anomaly_swimlane_embeddable'; import { MlTooltipComponent } from '../../application/components/chart_tooltip'; import { useSwimlaneInputResolver } from './swimlane_input_resolver'; +import { SwimlaneType } from '../../application/explorer/explorer_constants'; const RESIZE_THROTTLE_TIME_MS = 500; @@ -54,10 +55,13 @@ export const ExplorerSwimlaneContainer: FC = ({ chartWidth ); - const onResize = throttle((e: { width: number; height: number }) => { - const labelWidth = 200; - setChartWidth(e.width - labelWidth); - }, RESIZE_THROTTLE_TIME_MS); + const onResize = useCallback( + throttle((e: { width: number; height: number }) => { + const labelWidth = 200; + setChartWidth(e.width - labelWidth); + }, RESIZE_THROTTLE_TIME_MS), + [] + ); if (error) { return ( @@ -91,14 +95,14 @@ export const ExplorerSwimlaneContainer: FC = ({ {chartWidth > 0 && swimlaneData && swimlaneType ? ( - + {(tooltipService) => ( )} diff --git a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts index e704582d5d61a..3829bbce5e5c9 100644 --- a/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts +++ b/x-pack/plugins/ml/public/embeddables/anomaly_swimlane/swimlane_input_resolver.ts @@ -24,7 +24,7 @@ import { AnomalySwimlaneServices, } from './anomaly_swimlane_embeddable'; import { MlStartDependencies } from '../../plugin'; -import { SWIMLANE_TYPE } from '../../application/explorer/explorer_constants'; +import { SWIMLANE_TYPE, SwimlaneType } from '../../application/explorer/explorer_constants'; import { Filter } from '../../../../../../src/plugins/data/common/es_query/filters'; import { Query } from '../../../../../../src/plugins/data/common/query'; import { esKuery, UI_SETTINGS } from '../../../../../../src/plugins/data/public'; @@ -55,7 +55,7 @@ export function useSwimlaneInputResolver( const [{ uiSettings }, , { explorerService, anomalyDetectorService }] = services; const [swimlaneData, setSwimlaneData] = useState(); - const [swimlaneType, setSwimlaneType] = useState(); + const [swimlaneType, setSwimlaneType] = useState(); const [error, setError] = useState(); const chartWidth$ = useMemo(() => new Subject(), []); diff --git a/x-pack/plugins/ml/public/index.ts b/x-pack/plugins/ml/public/index.ts index a9ffb1a5bf579..5a956651c86d8 100755 --- a/x-pack/plugins/ml/public/index.ts +++ b/x-pack/plugins/ml/public/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { PluginInitializer } from 'kibana/public'; +import { PluginInitializer, PluginInitializerContext } from 'kibana/public'; import './index.scss'; import { MlPlugin, @@ -19,7 +19,7 @@ export const plugin: PluginInitializer< MlPluginStart, MlSetupDependencies, MlStartDependencies -> = () => new MlPlugin(); +> = (initializerContext: PluginInitializerContext) => new MlPlugin(initializerContext); export { MlPluginSetup, MlPluginStart }; export * from './shared'; diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index fe9f602bc3637..be2ebb3caa416 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -5,7 +5,13 @@ */ import { i18n } from '@kbn/i18n'; -import { Plugin, CoreStart, CoreSetup, AppMountParameters } from 'kibana/public'; +import { + Plugin, + CoreStart, + CoreSetup, + AppMountParameters, + PluginInitializerContext, +} from 'kibana/public'; import { ManagementSetup } from 'src/plugins/management/public'; import { SharePluginStart } from 'src/plugins/share/public'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; @@ -38,9 +44,13 @@ export interface MlSetupDependencies { home: HomePublicPluginSetup; embeddable: EmbeddableSetup; uiActions: UiActionsSetup; + kibanaVersion: string; + share: SharePluginStart; } export class MlPlugin implements Plugin { + constructor(private initializerContext: PluginInitializerContext) {} + setup(core: CoreSetup, pluginsSetup: MlSetupDependencies) { core.application.register({ id: PLUGIN_ID, @@ -53,6 +63,7 @@ export class MlPlugin implements Plugin { category: DEFAULT_APP_CATEGORIES.kibana, mount: async (params: AppMountParameters) => { const [coreStart, pluginsStart] = await core.getStartServices(); + const kibanaVersion = this.initializerContext.env.packageInfo.version; const { renderApp } = await import('./application/app'); return renderApp( coreStart, @@ -67,6 +78,7 @@ export class MlPlugin implements Plugin { home: pluginsSetup.home, embeddable: pluginsSetup.embeddable, uiActions: pluginsSetup.uiActions, + kibanaVersion, }, { element: params.element, diff --git a/x-pack/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap b/x-pack/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap index dbdfd6b27e69f..fcf68ad97c8ce 100644 --- a/x-pack/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap +++ b/x-pack/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap @@ -104,6 +104,29 @@ Array [
+
+ +
) : null; + const kibana = useKibana(); + const extraLinkComponents = !extraLinks ? null : ( @@ -64,6 +71,15 @@ export const PageHeader = React.memo( + + + {ADD_DATA_LABEL} + + ); diff --git a/x-pack/test/accessibility/apps/home.ts b/x-pack/test/accessibility/apps/home.ts index fe698acec322a..1f05ff676e3a0 100644 --- a/x-pack/test/accessibility/apps/home.ts +++ b/x-pack/test/accessibility/apps/home.ts @@ -12,8 +12,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); const globalNav = getService('globalNav'); - // FLAKY: https://github.com/elastic/kibana/issues/66976 - describe.skip('Kibana Home', () => { + describe('Kibana Home', () => { before(async () => { await PageObjects.common.navigateToApp('home'); }); diff --git a/x-pack/test/accessibility/apps/search_profiler.ts b/x-pack/test/accessibility/apps/search_profiler.ts index 138231d3cf025..8a13940695f9e 100644 --- a/x-pack/test/accessibility/apps/search_profiler.ts +++ b/x-pack/test/accessibility/apps/search_profiler.ts @@ -14,8 +14,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const a11y = getService('a11y'); const flyout = getService('flyout'); - // FLAKY: https://github.com/elastic/kibana/issues/67821 - describe.skip('Accessibility Search Profiler Editor', () => { + describe('Accessibility Search Profiler Editor', () => { before(async () => { await PageObjects.common.navigateToApp('searchProfiler'); await a11y.testAppSnapshot(); diff --git a/x-pack/test/functional/apps/ml/anomaly_detection/anomaly_explorer.ts b/x-pack/test/functional/apps/ml/anomaly_detection/anomaly_explorer.ts index 56d00a4e11390..c23abead458f1 100644 --- a/x-pack/test/functional/apps/ml/anomaly_detection/anomaly_explorer.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection/anomaly_explorer.ts @@ -61,6 +61,7 @@ export default function ({ getService }: FtrProviderContext) { before(async () => { await esArchiver.loadIfNeeded('ml/farequote'); await ml.testResources.createIndexPatternIfNeeded('ft_farequote', '@timestamp'); + await ml.testResources.createMLTestDashboardIfNeeded(); await ml.testResources.setKibanaTimeZoneToUTC(); await ml.securityUI.loginAsMlPowerUser(); @@ -125,6 +126,12 @@ export default function ({ getService }: FtrProviderContext) { it('anomalies table is not empty', async () => { await ml.anomaliesTable.assertTableNotEmpty(); }); + + // should be the last step because it navigates away from the Anomaly Explorer page + it('should allow to attach anomaly swimlane embeddable to the dashboard', async () => { + await ml.anomalyExplorer.openAddToDashboardControl(); + await ml.anomalyExplorer.addAndEditSwimlaneInDashboard('ML Test'); + }); }); } }); diff --git a/x-pack/test/functional/apps/ml/index.ts b/x-pack/test/functional/apps/ml/index.ts index 92e836e0c4c1b..2d8aac3b8dddf 100644 --- a/x-pack/test/functional/apps/ml/index.ts +++ b/x-pack/test/functional/apps/ml/index.ts @@ -22,6 +22,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { await ml.securityCommon.cleanMlRoles(); await ml.testResources.deleteSavedSearches(); + await ml.testResources.deleteDashboards(); await ml.testResources.deleteIndexPatternByTitle('ft_farequote'); await ml.testResources.deleteIndexPatternByTitle('ft_ecommerce'); diff --git a/x-pack/test/functional/services/ml/anomaly_explorer.ts b/x-pack/test/functional/services/ml/anomaly_explorer.ts index 6ec72c76bb9cf..7c479a4234673 100644 --- a/x-pack/test/functional/services/ml/anomaly_explorer.ts +++ b/x-pack/test/functional/services/ml/anomaly_explorer.ts @@ -66,5 +66,38 @@ export function MachineLearningAnomalyExplorerProvider({ getService }: FtrProvid async assertSwimlaneViewByExists() { await testSubjects.existOrFail('mlAnomalyExplorerSwimlaneViewBy'); }, + + async openAddToDashboardControl() { + await testSubjects.click('mlAnomalyTimelinePanelMenu'); + await testSubjects.click('mlAnomalyTimelinePanelAddToDashboardButton'); + await testSubjects.existOrFail('mlAddToDashboardModal'); + }, + + async addAndEditSwimlaneInDashboard(dashboardTitle: string) { + await this.filterWithSearchString(dashboardTitle); + await testSubjects.isDisplayed('mlDashboardSelectionTable > checkboxSelectAll'); + await testSubjects.click('mlDashboardSelectionTable > checkboxSelectAll'); + expect(await testSubjects.isChecked('mlDashboardSelectionTable > checkboxSelectAll')).to.be( + true + ); + await testSubjects.clickWhenNotDisabled('mlAddAndEditDashboardButton'); + const embeddable = await testSubjects.find('mlAnomalySwimlaneEmbeddableWrapper'); + const swimlane = await embeddable.findByClassName('ml-swimlanes'); + expect(await swimlane.isDisplayed()).to.eql( + true, + 'Anomaly swimlane should be displayed in dashboard' + ); + }, + + async waitForDashboardsToLoad() { + await testSubjects.existOrFail('~mlDashboardSelectionTable', { timeout: 60 * 1000 }); + }, + + async filterWithSearchString(filter: string) { + await this.waitForDashboardsToLoad(); + const searchBarInput = await testSubjects.find('mlDashboardsSearchBox'); + await searchBarInput.clearValueWithKeyboard(); + await searchBarInput.type(filter); + }, }; } diff --git a/x-pack/test/functional/services/ml/test_resources.ts b/x-pack/test/functional/services/ml/test_resources.ts index 4e3d1d9d86271..9927c987bbea5 100644 --- a/x-pack/test/functional/services/ml/test_resources.ts +++ b/x-pack/test/functional/services/ml/test_resources.ts @@ -5,7 +5,7 @@ */ import { ProvidedType } from '@kbn/test/types/ftr'; -import { savedSearches } from './test_resources_data'; +import { savedSearches, dashboards } from './test_resources_data'; import { COMMON_REQUEST_HEADERS } from './common'; import { FtrProviderContext } from '../../ftr_provider_context'; @@ -137,6 +137,20 @@ export function MachineLearningTestResourcesProvider({ getService }: FtrProvider return createResponse.id; }, + async createDashboard(title: string, body: object): Promise { + log.debug(`Creating dashboard with title '${title}'`); + + const createResponse = await supertest + .post(`/api/saved_objects/${SavedObjectType.DASHBOARD}`) + .set(COMMON_REQUEST_HEADERS) + .send(body) + .expect(200) + .then((res: any) => res.body); + + log.debug(` > Created with id '${createResponse.id}'`); + return createResponse.id; + }, + async createSavedSearchIfNeeded(savedSearch: any): Promise { const title = savedSearch.requestBody.attributes.title; const savedSearchId = await this.getSavedSearchId(title); @@ -181,6 +195,21 @@ export function MachineLearningTestResourcesProvider({ getService }: FtrProvider await this.createSavedSearchIfNeeded(savedSearches.farequoteFilter); }, + async createMLTestDashboardIfNeeded() { + await this.createDashboardIfNeeded(dashboards.mlTestDashboard); + }, + + async createDashboardIfNeeded(dashboard: any) { + const title = dashboard.requestBody.attributes.title; + const dashboardId = await this.getDashboardId(title); + if (dashboardId !== undefined) { + log.debug(`Dashboard with title '${title}' already exists. Nothing to create.`); + return dashboardId; + } else { + return await this.createDashboard(title, dashboard.requestBody); + } + }, + async createSavedSearchFarequoteLuceneIfNeeded() { await this.createSavedSearchIfNeeded(savedSearches.farequoteLucene); }, @@ -285,6 +314,12 @@ export function MachineLearningTestResourcesProvider({ getService }: FtrProvider } }, + async deleteDashboards() { + for (const dashboard of Object.values(dashboards)) { + await this.deleteDashboardByTitle(dashboard.requestBody.attributes.title); + } + }, + async assertSavedObjectExistsByTitle(title: string, objectType: SavedObjectType) { await retry.waitForWithTimeout( `${objectType} with title '${title}' to exist`, diff --git a/x-pack/test/functional/services/ml/test_resources_data.ts b/x-pack/test/functional/services/ml/test_resources_data.ts index dd600077182f9..2ab1f4de54228 100644 --- a/x-pack/test/functional/services/ml/test_resources_data.ts +++ b/x-pack/test/functional/services/ml/test_resources_data.ts @@ -247,3 +247,22 @@ export const savedSearches = { }, }, }; + +export const dashboards = { + mlTestDashboard: { + requestBody: { + attributes: { + title: 'ML Test', + hits: 0, + description: '', + panelsJSON: '[]', + optionsJSON: '{"hidePanelTitles":false,"useMargins":true}', + version: 1, + timeRestore: false, + kibanaSavedObjectMeta: { + searchSourceJSON: '{"query":{"language":"kuery","query":""},"filter":[]}', + }, + }, + }, + }, +}; diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts index 25fb477b5a99a..036f82a591fb3 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts @@ -99,107 +99,71 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { ); expect(agentFullConfig).to.eql({ - datasources: [ + inputs: [ { - enabled: true, id: policyInfo.datasource.id, - inputs: [ - { - enabled: true, - policy: { - linux: { - advanced: { - elasticsearch: { - indices: { - control: 'control-index', - event: 'event-index', - logging: 'logging-index', - }, - kernel: { - connect: true, - process: true, - }, - }, - }, - events: { - file: false, - network: true, - process: true, - }, - logging: { - file: 'info', - stdout: 'debug', + dataset: { namespace: 'default' }, + name: 'Protect East Coast', + package: { + name: 'endpoint', + version: policyInfo.packageInfo.version, + }, + policy: { + linux: { + advanced: { + elasticsearch: { + indices: { + control: 'control-index', + event: 'event-index', + logging: 'logging-index', }, + kernel: { connect: true, process: true }, }, - mac: { - advanced: { - elasticsearch: { - indices: { - control: 'control-index', - event: 'event-index', - logging: 'logging-index', - }, - kernel: { - connect: true, - process: true, - }, - }, - }, - events: { - file: false, - network: true, - process: true, - }, - logging: { - file: 'info', - stdout: 'debug', - }, - malware: { - mode: 'detect', + }, + events: { file: false, network: true, process: true }, + logging: { file: 'info', stdout: 'debug' }, + }, + mac: { + advanced: { + elasticsearch: { + indices: { + control: 'control-index', + event: 'event-index', + logging: 'logging-index', }, + kernel: { connect: true, process: true }, }, - windows: { - advanced: { - elasticsearch: { - indices: { - control: 'control-index', - event: 'event-index', - logging: 'logging-index', - }, - kernel: { - connect: true, - process: true, - }, - }, - }, - events: { - dll_and_driver_load: true, - dns: true, - file: false, - network: true, - process: true, - registry: true, - security: true, - }, - logging: { - file: 'info', - stdout: 'debug', - }, - malware: { - mode: 'prevent', + }, + events: { file: false, network: true, process: true }, + logging: { file: 'info', stdout: 'debug' }, + malware: { mode: 'detect' }, + }, + windows: { + advanced: { + elasticsearch: { + indices: { + control: 'control-index', + event: 'event-index', + logging: 'logging-index', }, + kernel: { connect: true, process: true }, }, }, - streams: [], - type: 'endpoint', + events: { + dll_and_driver_load: true, + dns: true, + file: false, + network: true, + process: true, + registry: true, + security: true, + }, + logging: { file: 'info', stdout: 'debug' }, + malware: { mode: 'prevent' }, }, - ], - name: 'Protect East Coast', - namespace: 'default', - package: { - name: 'endpoint', - version: policyInfo.packageInfo.version, }, + streams: [], + type: 'endpoint', use_output: 'default', }, ], diff --git a/yarn.lock b/yarn.lock index 2b2eab444d04a..256c8642a02ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4803,6 +4803,11 @@ resolved "https://registry.yarnpkg.com/@types/delete-empty/-/delete-empty-2.0.0.tgz#1647ae9e68f708a6ba778531af667ec55bc61964" integrity sha512-sq+kwx8zA9BSugT9N+Jr8/uWjbHMZ+N/meJEzRyT3gmLq/WMtx/iSIpvdpmBUi/cvXl6Kzpvve8G2ESkabFwmg== +"@types/dragselect@^1.13.1": + version "1.13.1" + resolved "https://registry.yarnpkg.com/@types/dragselect/-/dragselect-1.13.1.tgz#f19b7b41063a7c9d5963194c83c3c364e84d46ee" + integrity sha512-3m0fvSM0cSs0DXvprytV/ZY92hNX3jJuEb/vkdqU+4QMzV2jxYKgBFTuaT2fflqbmfzUqHHIkGP55WIuigElQw== + "@types/elasticsearch@^5.0.33": version "5.0.33" resolved "https://registry.yarnpkg.com/@types/elasticsearch/-/elasticsearch-5.0.33.tgz#b0fd37dc674f498223b6d68c313bdfd71f4d812b" @@ -25632,14 +25637,6 @@ react-textarea-autosize@^7.1.0: "@babel/runtime" "^7.1.2" prop-types "^15.6.0" -react-textarea-autosize@^7.1.2: - version "7.1.2" - resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-7.1.2.tgz#70fdb333ef86bcca72717e25e623e90c336e2cda" - integrity sha512-uH3ORCsCa3C6LHxExExhF4jHoXYCQwE5oECmrRsunlspaDAbS4mGKNlWZqjLfInWtFQcf0o1n1jC/NGXFdUBCg== - dependencies: - "@babel/runtime" "^7.1.2" - prop-types "^15.6.0" - react-tiny-virtual-list@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/react-tiny-virtual-list/-/react-tiny-virtual-list-2.2.0.tgz#eafb6fcf764e4ed41150ff9752cdaad8b35edf4a" @@ -27887,14 +27884,6 @@ simplebar-react@^1.0.0-alpha.6: prop-types "^15.6.1" simplebar "^4.2.0" -simplebar-react@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/simplebar-react/-/simplebar-react-2.1.0.tgz#57d524f4253579d81ac30db00acf7886b17bf826" - integrity sha512-UIMFPNkn6o57v058vPOiYbnbpc1CUZwPKLmQaDMvEJdgm+btZ2umFA6meXfiqFEQUjDE6Vq4ePnL7Fr6nzJd8w== - dependencies: - prop-types "^15.6.1" - simplebar "^5.1.0" - simplebar@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/simplebar/-/simplebar-4.2.0.tgz#97e5c1c85d05cc04f8c92939e4da71dd087e325c" @@ -27907,18 +27896,6 @@ simplebar@^4.2.0: lodash.throttle "^4.1.1" resize-observer-polyfill "^1.5.1" -simplebar@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/simplebar/-/simplebar-5.1.0.tgz#15437ace314ec888accd7d8f24ada672e9bb2717" - integrity sha512-bdi1SleK1YOSnfeUjo5UQXt/79zNjsCJVEfzrm6photmGi2aU6x0l7rX4KAGcrtj5AwsWPBVXgDyYAqbbpnuRg== - dependencies: - can-use-dom "^0.1.0" - core-js "^3.0.1" - lodash.debounce "^4.0.8" - lodash.memoize "^4.1.2" - lodash.throttle "^4.1.1" - resize-observer-polyfill "^1.5.1" - simplicial-complex@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/simplicial-complex/-/simplicial-complex-1.0.0.tgz#6c33a4ed69fcd4d91b7bcadd3b30b63683eae241"