From a906c732b02cb54e9f19c00c01c2a526ce2d450f Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Mon, 13 Jul 2020 11:00:58 -0400 Subject: [PATCH 01/40] [Ingest Manager] During fleet setup create an enrollment for every config (#71308) --- .../ingest_manager/server/services/setup.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/ingest_manager/server/services/setup.ts b/x-pack/plugins/ingest_manager/server/services/setup.ts index e27a5456a5a7d..627abc158143d 100644 --- a/x-pack/plugins/ingest_manager/server/services/setup.ts +++ b/x-pack/plugins/ingest_manager/server/services/setup.ts @@ -180,11 +180,18 @@ export async function setupFleet( fleet_enroll_password: password, }); - // Generate default enrollment key - await generateEnrollmentAPIKey(soClient, { - name: 'Default', - configId: await agentConfigService.getDefaultAgentConfigId(soClient), + const { items: agentConfigs } = await agentConfigService.list(soClient, { + perPage: 10000, }); + + await Promise.all( + agentConfigs.map((agentConfig) => { + return generateEnrollmentAPIKey(soClient, { + name: `Default`, + configId: agentConfig.id, + }); + }) + ); } function generateRandomPassword() { From f0d744e8659a0f9d8369226084151bff235afe2c Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Mon, 13 Jul 2020 17:03:41 +0200 Subject: [PATCH 02/40] inclusive language (#71438) --- .../repository_form/type_settings/readonly_settings.tsx | 2 +- x-pack/plugins/translations/translations/ja-JP.json | 1 - x-pack/plugins/translations/translations/zh-CN.json | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/readonly_settings.tsx b/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/readonly_settings.tsx index 309dad366bef8..17cce6efafb6f 100644 --- a/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/readonly_settings.tsx +++ b/x-pack/plugins/snapshot_restore/public/application/components/repository_form/type_settings/readonly_settings.tsx @@ -46,7 +46,7 @@ export const ReadonlySettings: React.FunctionComponent = ({ case 'ftp': return ( repositories.url.allowed_urls, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 92285d8bf72f8..4118053396e90 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -15258,7 +15258,6 @@ "xpack.snapshotRestore.repositoryForm.typeReadonly.urlLabel": "パス (必須)", "xpack.snapshotRestore.repositoryForm.typeReadonly.urlSchemeLabel": "スキーム", "xpack.snapshotRestore.repositoryForm.typeReadonly.urlTitle": "URL", - "xpack.snapshotRestore.repositoryForm.typeReadonly.urlWhitelistDescription": "この URL は {settingKey} 設定で登録する必要があります。", "xpack.snapshotRestore.repositoryForm.typeS3.basePathDescription": "レポジトリデータへのバケットパスです。", "xpack.snapshotRestore.repositoryForm.typeS3.basePathLabel": "ベースパス", "xpack.snapshotRestore.repositoryForm.typeS3.basePathTitle": "ベースパス", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 457f65e89083d..01939bea417d4 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -15264,7 +15264,6 @@ "xpack.snapshotRestore.repositoryForm.typeReadonly.urlLabel": "路径(必填)", "xpack.snapshotRestore.repositoryForm.typeReadonly.urlSchemeLabel": "方案", "xpack.snapshotRestore.repositoryForm.typeReadonly.urlTitle": "URL", - "xpack.snapshotRestore.repositoryForm.typeReadonly.urlWhitelistDescription": "必须在 {settingKey} 设置中注册此 URL。", "xpack.snapshotRestore.repositoryForm.typeS3.basePathDescription": "存储库数据的存储桶路径。", "xpack.snapshotRestore.repositoryForm.typeS3.basePathLabel": "基路径", "xpack.snapshotRestore.repositoryForm.typeS3.basePathTitle": "基路径", From f0c9915280f5c92e1e32cdc39a70b84ef4cea367 Mon Sep 17 00:00:00 2001 From: Oliver Gupte Date: Mon, 13 Jul 2020 08:09:04 -0700 Subject: [PATCH 03/40] [APM] Anomaly detection integration with transaction duration chart (#71230) * Reintroduces the previous anomaly detection ML integration back into the transaction duration chart in the service details screen. Support the latest APM anoamly detection by environment jobs. * PR feedback * Code improvements from PR feedback * handle errors thrown when fetching ml job for current environment Co-authored-by: Elastic Machine --- .../app/TransactionDetails/index.tsx | 1 - .../app/TransactionOverview/index.tsx | 2 - .../shared/charts/TransactionCharts/index.tsx | 18 ++- .../apm/public/selectors/chartSelectors.ts | 2 + .../charts/get_anomaly_data/fetcher.ts | 93 ++++++++++++ .../get_anomaly_data/get_ml_bucket_size.ts | 61 ++++++++ .../charts/get_anomaly_data/index.ts | 72 ++++++++-- .../charts/get_anomaly_data/transform.ts | 136 ++++++++++++++++++ .../server/lib/transactions/charts/index.ts | 4 + .../server/lib/transactions/queries.test.ts | 8 ++ .../apm/server/routes/transaction_groups.ts | 16 ++- 11 files changed, 390 insertions(+), 23 deletions(-) create mode 100644 x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/fetcher.ts create mode 100644 x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/get_ml_bucket_size.ts create mode 100644 x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/transform.ts diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx index 620ae6708eda0..c56b7b9aaa720 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx @@ -89,7 +89,6 @@ export function TransactionDetails() { { }; public renderMLHeader(hasValidMlLicense: boolean | undefined) { - const { hasMLJob } = this.props; - if (!hasValidMlLicense || !hasMLJob) { + const { mlJobId } = this.props.charts; + + if (!hasValidMlLicense || !mlJobId) { return null; } - const { serviceName, kuery } = this.props.urlParams; + const { serviceName, kuery, transactionType } = this.props.urlParams; if (!serviceName) { return null; } - const linkedJobId = ''; // TODO [APM ML] link to ML job id for the selected environment - const hasKuery = !isEmpty(kuery); const icon = hasKuery ? ( { } )}{' '} - View Job + + View Job + ); diff --git a/x-pack/plugins/apm/public/selectors/chartSelectors.ts b/x-pack/plugins/apm/public/selectors/chartSelectors.ts index 714d62a703f51..26c2365ed77e1 100644 --- a/x-pack/plugins/apm/public/selectors/chartSelectors.ts +++ b/x-pack/plugins/apm/public/selectors/chartSelectors.ts @@ -33,6 +33,7 @@ export interface ITpmBucket { export interface ITransactionChartData { tpmSeries: ITpmBucket[]; responseTimeSeries: TimeSeries[]; + mlJobId: string | undefined; } const INITIAL_DATA = { @@ -62,6 +63,7 @@ export function getTransactionCharts( return { tpmSeries, responseTimeSeries, + mlJobId: anomalyTimeseries?.jobId, }; } diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/fetcher.ts b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/fetcher.ts new file mode 100644 index 0000000000000..3cf9a54e3fe9b --- /dev/null +++ b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/fetcher.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Logger } from 'kibana/server'; +import { PromiseReturnType } from '../../../../../../observability/typings/common'; +import { Setup, SetupTimeRange } from '../../../helpers/setup_request'; + +export type ESResponse = Exclude< + PromiseReturnType, + undefined +>; + +export async function anomalySeriesFetcher({ + serviceName, + transactionType, + intervalString, + mlBucketSize, + setup, + jobId, + logger, +}: { + serviceName: string; + transactionType: string; + intervalString: string; + mlBucketSize: number; + setup: Setup & SetupTimeRange; + jobId: string; + logger: Logger; +}) { + const { ml, start, end } = setup; + if (!ml) { + return; + } + + // move the start back with one bucket size, to ensure to get anomaly data in the beginning + // this is required because ML has a minimum bucket size (default is 900s) so if our buckets are smaller, we might have several null buckets in the beginning + const newStart = start - mlBucketSize * 1000; + + const params = { + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { job_id: jobId } }, + { exists: { field: 'bucket_span' } }, + { term: { result_type: 'model_plot' } }, + { term: { partition_field_value: serviceName } }, + { term: { by_field_value: transactionType } }, + { + range: { + timestamp: { gte: newStart, lte: end, format: 'epoch_millis' }, + }, + }, + ], + }, + }, + aggs: { + ml_avg_response_times: { + date_histogram: { + field: 'timestamp', + fixed_interval: intervalString, + min_doc_count: 0, + extended_bounds: { min: newStart, max: end }, + }, + aggs: { + anomaly_score: { max: { field: 'anomaly_score' } }, + lower: { min: { field: 'model_lower' } }, + upper: { max: { field: 'model_upper' } }, + }, + }, + }, + }, + }; + + try { + const response = await ml.mlSystem.mlAnomalySearch(params); + return response; + } catch (err) { + const isHttpError = 'statusCode' in err; + if (isHttpError) { + logger.info( + `Status code "${err.statusCode}" while retrieving ML anomalies for APM` + ); + return; + } + logger.error('An error occurred while retrieving ML anomalies for APM'); + logger.error(err); + } +} diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/get_ml_bucket_size.ts b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/get_ml_bucket_size.ts new file mode 100644 index 0000000000000..2f5e703251c03 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/get_ml_bucket_size.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Logger } from 'kibana/server'; +import { Setup, SetupTimeRange } from '../../../helpers/setup_request'; + +interface IOptions { + setup: Setup & SetupTimeRange; + jobId: string; + logger: Logger; +} + +interface ESResponse { + bucket_span: number; +} + +export async function getMlBucketSize({ + setup, + jobId, + logger, +}: IOptions): Promise { + const { ml, start, end } = setup; + if (!ml) { + return; + } + + const params = { + body: { + _source: 'bucket_span', + size: 1, + terminateAfter: 1, + query: { + bool: { + filter: [ + { term: { job_id: jobId } }, + { exists: { field: 'bucket_span' } }, + { + range: { + timestamp: { gte: start, lte: end, format: 'epoch_millis' }, + }, + }, + ], + }, + }, + }, + }; + + try { + const resp = await ml.mlSystem.mlAnomalySearch(params); + return resp.hits.hits[0]?._source.bucket_span; + } catch (err) { + const isHttpError = 'statusCode' in err; + if (isHttpError) { + return; + } + logger.error(err); + } +} diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.ts b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.ts index b2d11f2ffe19a..072099bc9553c 100644 --- a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/index.ts @@ -3,18 +3,19 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - +import { Logger } from 'kibana/server'; +import { isNumber } from 'lodash'; +import { getBucketSize } from '../../../helpers/get_bucket_size'; import { Setup, SetupTimeRange, SetupUIFilters, } from '../../../helpers/setup_request'; -import { Coordinate, RectCoordinate } from '../../../../../typings/timeseries'; - -interface AnomalyTimeseries { - anomalyBoundaries: Coordinate[]; - anomalyScore: RectCoordinate[]; -} +import { anomalySeriesFetcher } from './fetcher'; +import { getMlBucketSize } from './get_ml_bucket_size'; +import { anomalySeriesTransform } from './transform'; +import { getMLJobIds } from '../../../service_map/get_service_anomalies'; +import { UIFilters } from '../../../../../typings/ui_filters'; export async function getAnomalySeries({ serviceName, @@ -22,13 +23,17 @@ export async function getAnomalySeries({ transactionName, timeSeriesDates, setup, + logger, + uiFilters, }: { serviceName: string; transactionType: string | undefined; transactionName: string | undefined; timeSeriesDates: number[]; setup: Setup & SetupTimeRange & SetupUIFilters; -}): Promise { + logger: Logger; + uiFilters: UIFilters; +}) { // don't fetch anomalies for transaction details page if (transactionName) { return; @@ -39,8 +44,12 @@ export async function getAnomalySeries({ return; } - // don't fetch anomalies if uiFilters are applied - if (setup.uiFiltersES.length > 0) { + // don't fetch anomalies if unknown uiFilters are applied + const knownFilters = ['environment', 'serviceName']; + const uiFilterNames = Object.keys(uiFilters); + if ( + uiFilterNames.some((uiFilterName) => !knownFilters.includes(uiFilterName)) + ) { return; } @@ -55,6 +64,45 @@ export async function getAnomalySeries({ return; } - // TODO [APM ML] return a series of anomaly scores, upper & lower bounds for the given timeSeriesDates - return; + let mlJobIds: string[] = []; + try { + mlJobIds = await getMLJobIds(setup.ml, uiFilters.environment); + } catch (error) { + logger.error(error); + return; + } + + // don't fetch anomalies if there are isn't exaclty 1 ML job match for the given environment + if (mlJobIds.length !== 1) { + return; + } + const jobId = mlJobIds[0]; + + const mlBucketSize = await getMlBucketSize({ setup, jobId, logger }); + if (!isNumber(mlBucketSize)) { + return; + } + + const { start, end } = setup; + const { intervalString, bucketSize } = getBucketSize(start, end, 'auto'); + + const esResponse = await anomalySeriesFetcher({ + serviceName, + transactionType, + intervalString, + mlBucketSize, + setup, + jobId, + logger, + }); + + if (esResponse && mlBucketSize > 0) { + return anomalySeriesTransform( + esResponse, + mlBucketSize, + bucketSize, + timeSeriesDates, + jobId + ); + } } diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/transform.ts b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/transform.ts new file mode 100644 index 0000000000000..393a73f7c1ccd --- /dev/null +++ b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/transform.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 { first, last } from 'lodash'; +import { Coordinate, RectCoordinate } from '../../../../../typings/timeseries'; +import { ESResponse } from './fetcher'; + +type IBucket = ReturnType; +function getBucket( + bucket: Required< + ESResponse + >['aggregations']['ml_avg_response_times']['buckets'][0] +) { + return { + x: bucket.key, + anomalyScore: bucket.anomaly_score.value, + lower: bucket.lower.value, + upper: bucket.upper.value, + }; +} + +export type AnomalyTimeSeriesResponse = ReturnType< + typeof anomalySeriesTransform +>; +export function anomalySeriesTransform( + response: ESResponse, + mlBucketSize: number, + bucketSize: number, + timeSeriesDates: number[], + jobId: string +) { + const buckets = + response.aggregations?.ml_avg_response_times.buckets.map(getBucket) || []; + + const bucketSizeInMillis = Math.max(bucketSize, mlBucketSize) * 1000; + + return { + jobId, + anomalyScore: getAnomalyScoreDataPoints( + buckets, + timeSeriesDates, + bucketSizeInMillis + ), + anomalyBoundaries: getAnomalyBoundaryDataPoints(buckets, timeSeriesDates), + }; +} + +export function getAnomalyScoreDataPoints( + buckets: IBucket[], + timeSeriesDates: number[], + bucketSizeInMillis: number +): RectCoordinate[] { + const ANOMALY_THRESHOLD = 75; + const firstDate = first(timeSeriesDates); + const lastDate = last(timeSeriesDates); + + if (firstDate === undefined || lastDate === undefined) { + return []; + } + + return buckets + .filter( + (bucket) => + bucket.anomalyScore !== null && bucket.anomalyScore > ANOMALY_THRESHOLD + ) + .filter(isInDateRange(firstDate, lastDate)) + .map((bucket) => { + return { + x0: bucket.x, + x: Math.min(bucket.x + bucketSizeInMillis, lastDate), // don't go beyond last date + }; + }); +} + +export function getAnomalyBoundaryDataPoints( + buckets: IBucket[], + timeSeriesDates: number[] +): Coordinate[] { + return replaceFirstAndLastBucket(buckets, timeSeriesDates) + .filter((bucket) => bucket.lower !== null) + .map((bucket) => { + return { + x: bucket.x, + y0: bucket.lower, + y: bucket.upper, + }; + }); +} + +export function replaceFirstAndLastBucket( + buckets: IBucket[], + timeSeriesDates: number[] +) { + const firstDate = first(timeSeriesDates); + const lastDate = last(timeSeriesDates); + + if (firstDate === undefined || lastDate === undefined) { + return buckets; + } + + const preBucketWithValue = buckets + .filter((p) => p.x <= firstDate) + .reverse() + .find((p) => p.lower !== null); + + const bucketsInRange = buckets.filter(isInDateRange(firstDate, lastDate)); + + // replace first bucket if it is null + const firstBucket = first(bucketsInRange); + if (preBucketWithValue && firstBucket && firstBucket.lower === null) { + firstBucket.lower = preBucketWithValue.lower; + firstBucket.upper = preBucketWithValue.upper; + } + + const lastBucketWithValue = [...buckets] + .reverse() + .find((p) => p.lower !== null); + + // replace last bucket if it is null + const lastBucket = last(bucketsInRange); + if (lastBucketWithValue && lastBucket && lastBucket.lower === null) { + lastBucket.lower = lastBucketWithValue.lower; + lastBucket.upper = lastBucketWithValue.upper; + } + + return bucketsInRange; +} + +// anomaly time series contain one or more buckets extra in the beginning +// these extra buckets should be removed +function isInDateRange(firstDate: number, lastDate: number) { + return (p: IBucket) => p.x >= firstDate && p.x <= lastDate; +} diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/index.ts b/x-pack/plugins/apm/server/lib/transactions/charts/index.ts index 2ec049002d605..e862982145f77 100644 --- a/x-pack/plugins/apm/server/lib/transactions/charts/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/charts/index.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Logger } from 'kibana/server'; import { PromiseReturnType } from '../../../../../observability/typings/common'; import { Setup, @@ -13,6 +14,7 @@ import { import { getAnomalySeries } from './get_anomaly_data'; import { getApmTimeseriesData } from './get_timeseries_data'; import { ApmTimeSeriesResponse } from './get_timeseries_data/transform'; +import { UIFilters } from '../../../../typings/ui_filters'; function getDates(apmTimeseries: ApmTimeSeriesResponse) { return apmTimeseries.responseTimes.avg.map((p) => p.x); @@ -26,6 +28,8 @@ export async function getTransactionCharts(options: { transactionType: string | undefined; transactionName: string | undefined; setup: Setup & SetupTimeRange & SetupUIFilters; + logger: Logger; + uiFilters: UIFilters; }) { const apmTimeseries = await getApmTimeseriesData(options); const anomalyTimeseries = await getAnomalySeries({ diff --git a/x-pack/plugins/apm/server/lib/transactions/queries.test.ts b/x-pack/plugins/apm/server/lib/transactions/queries.test.ts index 713635cff2fbf..586fa1798b7bc 100644 --- a/x-pack/plugins/apm/server/lib/transactions/queries.test.ts +++ b/x-pack/plugins/apm/server/lib/transactions/queries.test.ts @@ -12,6 +12,8 @@ import { SearchParamsMock, inspectSearchParams, } from '../../../public/utils/testHelpers'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { loggerMock } from '../../../../../../src/core/server/logging/logger.mock'; describe('transaction queries', () => { let mock: SearchParamsMock; @@ -52,6 +54,8 @@ describe('transaction queries', () => { transactionName: undefined, transactionType: undefined, setup, + logger: loggerMock.create(), + uiFilters: {}, }) ); expect(mock.params).toMatchSnapshot(); @@ -64,6 +68,8 @@ describe('transaction queries', () => { transactionName: 'bar', transactionType: undefined, setup, + logger: loggerMock.create(), + uiFilters: {}, }) ); expect(mock.params).toMatchSnapshot(); @@ -76,6 +82,8 @@ describe('transaction queries', () => { transactionName: 'bar', transactionType: 'baz', setup, + logger: loggerMock.create(), + uiFilters: {}, }) ); diff --git a/x-pack/plugins/apm/server/routes/transaction_groups.ts b/x-pack/plugins/apm/server/routes/transaction_groups.ts index 9ad281159fca5..3d939b04795c6 100644 --- a/x-pack/plugins/apm/server/routes/transaction_groups.ts +++ b/x-pack/plugins/apm/server/routes/transaction_groups.ts @@ -14,6 +14,7 @@ import { createRoute } from './create_route'; import { uiFiltersRt, rangeRt } from './default_api_types'; import { getTransactionAvgDurationByBrowser } from '../lib/transactions/avg_duration_by_browser'; import { getTransactionAvgDurationByCountry } from '../lib/transactions/avg_duration_by_country'; +import { UIFilters } from '../../typings/ui_filters'; export const transactionGroupsRoute = createRoute(() => ({ path: '/api/apm/services/{serviceName}/transaction_groups', @@ -62,14 +63,27 @@ export const transactionGroupsChartsRoute = createRoute(() => ({ }, handler: async ({ context, request }) => { const setup = await setupRequest(context, request); + const logger = context.logger; const { serviceName } = context.params.path; - const { transactionType, transactionName } = context.params.query; + const { + transactionType, + transactionName, + uiFilters: uiFiltersJson, + } = context.params.query; + let uiFilters: UIFilters = {}; + try { + uiFilters = JSON.parse(uiFiltersJson); + } catch (error) { + logger.error(error); + } return getTransactionCharts({ serviceName, transactionType, transactionName, setup, + logger, + uiFilters, }); }, })); From ae231feef7c393d730e374bab11c46c592c848df Mon Sep 17 00:00:00 2001 From: Oliver Gupte Date: Mon, 13 Jul 2020 08:09:55 -0700 Subject: [PATCH 04/40] [APM] Anomaly detection setup link with alert if job doesn't exist (#71229) * Closes #70440 by adding a setup link to anomaly detection setting in the home header * PR feedback and type error fix * Code cleanup and PR feedback * Modified getEnvironmentUiFilterES return type from `ESFilter | undefined` to `ESFilter[]` for ease of use. Co-authored-by: Elastic Machine --- .../apm/common/environment_filter_values.ts | 11 +++ .../apm/public/components/app/Home/index.tsx | 4 + .../anomaly_detection/add_environments.tsx | 11 +-- .../Settings/anomaly_detection/jobs_list.tsx | 11 +-- .../Links/apm/AnomalyDetectionSetupLink.tsx | 63 +++++++++++++ .../create_anomaly_detection_jobs.ts | 17 +--- .../get_environment_ui_filter_es.test.ts | 17 ++-- .../get_environment_ui_filter_es.ts | 15 +-- .../convert_ui_filters/get_ui_filters_es.ts | 13 +-- .../get_service_map_service_node_info.ts | 93 +++---------------- .../get_derived_service_annotations.ts | 7 +- .../annotations/get_stored_annotations.ts | 4 +- 12 files changed, 117 insertions(+), 149 deletions(-) create mode 100644 x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.tsx diff --git a/x-pack/plugins/apm/common/environment_filter_values.ts b/x-pack/plugins/apm/common/environment_filter_values.ts index 239378d0ea94a..38b6f480ca3d3 100644 --- a/x-pack/plugins/apm/common/environment_filter_values.ts +++ b/x-pack/plugins/apm/common/environment_filter_values.ts @@ -4,5 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; + export const ENVIRONMENT_ALL = 'ENVIRONMENT_ALL'; export const ENVIRONMENT_NOT_DEFINED = 'ENVIRONMENT_NOT_DEFINED'; + +export function getEnvironmentLabel(environment: string) { + if (environment === ENVIRONMENT_NOT_DEFINED) { + return i18n.translate('xpack.apm.filter.environment.notDefinedLabel', { + defaultMessage: 'Not defined', + }); + } + return environment; +} diff --git a/x-pack/plugins/apm/public/components/app/Home/index.tsx b/x-pack/plugins/apm/public/components/app/Home/index.tsx index f612ac0d383ef..bcc834fef6a6a 100644 --- a/x-pack/plugins/apm/public/components/app/Home/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Home/index.tsx @@ -20,6 +20,7 @@ import { EuiTabLink } from '../../shared/EuiTabLink'; import { ServiceMapLink } from '../../shared/Links/apm/ServiceMapLink'; import { ServiceOverviewLink } from '../../shared/Links/apm/ServiceOverviewLink'; import { SettingsLink } from '../../shared/Links/apm/SettingsLink'; +import { AnomalyDetectionSetupLink } from '../../shared/Links/apm/AnomalyDetectionSetupLink'; import { TraceOverviewLink } from '../../shared/Links/apm/TraceOverviewLink'; import { SetupInstructionsLink } from '../../shared/Links/SetupInstructionsLink'; import { ServiceMap } from '../ServiceMap'; @@ -118,6 +119,9 @@ export function Home({ tab }: Props) { + + + diff --git a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/add_environments.tsx b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/add_environments.tsx index 2da3c12563104..98b4ae2f4b63f 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/add_environments.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/add_environments.tsx @@ -22,7 +22,7 @@ import { i18n } from '@kbn/i18n'; import { useFetcher, FETCH_STATUS } from '../../../../hooks/useFetcher'; import { useApmPluginContext } from '../../../../hooks/useApmPluginContext'; import { createJobs } from './create_jobs'; -import { ENVIRONMENT_NOT_DEFINED } from '../../../../../common/environment_filter_values'; +import { getEnvironmentLabel } from '../../../../../common/environment_filter_values'; interface Props { currentEnvironments: string[]; @@ -45,7 +45,7 @@ export const AddEnvironments = ({ ); const environmentOptions = data.map((env) => ({ - label: env === ENVIRONMENT_NOT_DEFINED ? NOT_DEFINED_OPTION_LABEL : env, + label: getEnvironmentLabel(env), value: env, disabled: currentEnvironments.includes(env), })); @@ -155,10 +155,3 @@ export const AddEnvironments = ({ ); }; - -const NOT_DEFINED_OPTION_LABEL = i18n.translate( - 'xpack.apm.filter.environment.notDefinedLabel', - { - defaultMessage: 'Not defined', - } -); diff --git a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx index 83d19aa27ac11..34687e5a8094e 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx @@ -22,7 +22,7 @@ import { LoadingStatePrompt } from '../../../shared/LoadingStatePrompt'; import { AnomalyDetectionJobByEnv } from '../../../../../typings/anomaly_detection'; import { MLJobLink } from '../../../shared/Links/MachineLearningLinks/MLJobLink'; import { MLLink } from '../../../shared/Links/MachineLearningLinks/MLLink'; -import { ENVIRONMENT_NOT_DEFINED } from '../../../../../common/environment_filter_values'; +import { getEnvironmentLabel } from '../../../../../common/environment_filter_values'; import { LegacyJobsCallout } from './legacy_jobs_callout'; const columns: Array> = [ @@ -32,14 +32,7 @@ const columns: Array> = [ 'xpack.apm.settings.anomalyDetection.jobList.environmentColumnLabel', { defaultMessage: 'Environment' } ), - render: (environment: string) => { - if (environment === ENVIRONMENT_NOT_DEFINED) { - return i18n.translate('xpack.apm.filter.environment.notDefinedLabel', { - defaultMessage: 'Not defined', - }); - } - return environment; - }, + render: getEnvironmentLabel, }, { field: 'job_id', diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.tsx new file mode 100644 index 0000000000000..88d15239b8fba --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { EuiButtonEmpty, EuiToolTip, EuiIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { APMLink } from './APMLink'; +import { getEnvironmentLabel } from '../../../../../common/environment_filter_values'; +import { useUrlParams } from '../../../../hooks/useUrlParams'; +import { useFetcher, FETCH_STATUS } from '../../../../hooks/useFetcher'; + +export function AnomalyDetectionSetupLink() { + const { uiFilters } = useUrlParams(); + const environment = uiFilters.environment; + + const { data = { jobs: [], hasLegacyJobs: false }, status } = useFetcher( + (callApmApi) => + callApmApi({ pathname: `/api/apm/settings/anomaly-detection` }), + [], + { preservePreviousData: false } + ); + const isFetchSuccess = status === FETCH_STATUS.SUCCESS; + + // Show alert if there are no jobs OR if no job matches the current environment + const showAlert = + isFetchSuccess && !data.jobs.some((job) => environment === job.environment); + + return ( + + + {ANOMALY_DETECTION_LINK_LABEL} + + {showAlert && ( + + + + )} + + ); +} + +function getTooltipText(environment?: string) { + if (!environment) { + return i18n.translate('xpack.apm.anomalyDetectionSetup.notEnabledText', { + defaultMessage: `Anomaly detection is not yet enabled. Click to continue setup.`, + }); + } + + return i18n.translate( + 'xpack.apm.anomalyDetectionSetup.notEnabledForEnvironmentText', + { + defaultMessage: `Anomaly detection is not yet enabled for the "{currentEnvironment}" environment. Click to continue setup.`, + values: { currentEnvironment: getEnvironmentLabel(environment) }, + } + ); +} + +const ANOMALY_DETECTION_LINK_LABEL = i18n.translate( + 'xpack.apm.anomalyDetectionSetup.linkLabel', + { defaultMessage: `Anomaly detection` } +); diff --git a/x-pack/plugins/apm/server/lib/anomaly_detection/create_anomaly_detection_jobs.ts b/x-pack/plugins/apm/server/lib/anomaly_detection/create_anomaly_detection_jobs.ts index e723393a24013..c387c5152b1c5 100644 --- a/x-pack/plugins/apm/server/lib/anomaly_detection/create_anomaly_detection_jobs.ts +++ b/x-pack/plugins/apm/server/lib/anomaly_detection/create_anomaly_detection_jobs.ts @@ -10,12 +10,11 @@ import { snakeCase } from 'lodash'; import { PromiseReturnType } from '../../../../observability/typings/common'; import { Setup } from '../helpers/setup_request'; import { - SERVICE_ENVIRONMENT, TRANSACTION_DURATION, PROCESSOR_EVENT, } from '../../../common/elasticsearch_fieldnames'; -import { ENVIRONMENT_NOT_DEFINED } from '../../../common/environment_filter_values'; import { APM_ML_JOB_GROUP, ML_MODULE_ID_APM_TRANSACTION } from './constants'; +import { getEnvironmentUiFilterES } from '../helpers/convert_ui_filters/get_environment_ui_filter_es'; export type CreateAnomalyDetectionJobsAPIResponse = PromiseReturnType< typeof createAnomalyDetectionJobs @@ -89,9 +88,7 @@ async function createAnomalyDetectionJob({ filter: [ { term: { [PROCESSOR_EVENT]: 'transaction' } }, { exists: { field: TRANSACTION_DURATION } }, - environment === ENVIRONMENT_NOT_DEFINED - ? ENVIRONMENT_NOT_DEFINED_FILTER - : { term: { [SERVICE_ENVIRONMENT]: environment } }, + ...getEnvironmentUiFilterES(environment), ], }, }, @@ -109,13 +106,3 @@ async function createAnomalyDetectionJob({ ], }); } - -const ENVIRONMENT_NOT_DEFINED_FILTER = { - bool: { - must_not: { - exists: { - field: SERVICE_ENVIRONMENT, - }, - }, - }, -}; diff --git a/x-pack/plugins/apm/server/lib/helpers/convert_ui_filters/__test__/get_environment_ui_filter_es.test.ts b/x-pack/plugins/apm/server/lib/helpers/convert_ui_filters/__test__/get_environment_ui_filter_es.test.ts index 0f0a11a868d6d..800f809727eb6 100644 --- a/x-pack/plugins/apm/server/lib/helpers/convert_ui_filters/__test__/get_environment_ui_filter_es.test.ts +++ b/x-pack/plugins/apm/server/lib/helpers/convert_ui_filters/__test__/get_environment_ui_filter_es.test.ts @@ -7,24 +7,23 @@ import { getEnvironmentUiFilterES } from '../get_environment_ui_filter_es'; import { ENVIRONMENT_NOT_DEFINED } from '../../../../../common/environment_filter_values'; import { SERVICE_ENVIRONMENT } from '../../../../../common/elasticsearch_fieldnames'; -import { ESFilter } from '../../../../../typings/elasticsearch'; describe('getEnvironmentUiFilterES', () => { - it('should return undefined, when environment is undefined', () => { + it('should return empty array, when environment is undefined', () => { const uiFilterES = getEnvironmentUiFilterES(); - expect(uiFilterES).toBeUndefined(); + expect(uiFilterES).toHaveLength(0); }); it('should create a filter for a service environment', () => { - const uiFilterES = getEnvironmentUiFilterES('test') as ESFilter; - expect(uiFilterES).toHaveProperty(['term', SERVICE_ENVIRONMENT], 'test'); + const uiFilterES = getEnvironmentUiFilterES('test'); + expect(uiFilterES).toHaveLength(1); + expect(uiFilterES[0]).toHaveProperty(['term', SERVICE_ENVIRONMENT], 'test'); }); it('should create a filter for missing service environments', () => { - const uiFilterES = getEnvironmentUiFilterES( - ENVIRONMENT_NOT_DEFINED - ) as ESFilter; - expect(uiFilterES).toHaveProperty( + const uiFilterES = getEnvironmentUiFilterES(ENVIRONMENT_NOT_DEFINED); + expect(uiFilterES).toHaveLength(1); + expect(uiFilterES[0]).toHaveProperty( ['bool', 'must_not', 'exists', 'field'], SERVICE_ENVIRONMENT ); diff --git a/x-pack/plugins/apm/server/lib/helpers/convert_ui_filters/get_environment_ui_filter_es.ts b/x-pack/plugins/apm/server/lib/helpers/convert_ui_filters/get_environment_ui_filter_es.ts index 63d222a7fcb6e..87bc8dc968373 100644 --- a/x-pack/plugins/apm/server/lib/helpers/convert_ui_filters/get_environment_ui_filter_es.ts +++ b/x-pack/plugins/apm/server/lib/helpers/convert_ui_filters/get_environment_ui_filter_es.ts @@ -8,19 +8,12 @@ import { ESFilter } from '../../../../typings/elasticsearch'; import { ENVIRONMENT_NOT_DEFINED } from '../../../../common/environment_filter_values'; import { SERVICE_ENVIRONMENT } from '../../../../common/elasticsearch_fieldnames'; -export function getEnvironmentUiFilterES( - environment?: string -): ESFilter | undefined { +export function getEnvironmentUiFilterES(environment?: string): ESFilter[] { if (!environment) { - return undefined; + return []; } - if (environment === ENVIRONMENT_NOT_DEFINED) { - return { - bool: { must_not: { exists: { field: SERVICE_ENVIRONMENT } } }, - }; + return [{ bool: { must_not: { exists: { field: SERVICE_ENVIRONMENT } } } }]; } - return { - term: { [SERVICE_ENVIRONMENT]: environment }, - }; + return [{ term: { [SERVICE_ENVIRONMENT]: environment } }]; } diff --git a/x-pack/plugins/apm/server/lib/helpers/convert_ui_filters/get_ui_filters_es.ts b/x-pack/plugins/apm/server/lib/helpers/convert_ui_filters/get_ui_filters_es.ts index b34d5535d58cc..c1405b44f2a8a 100644 --- a/x-pack/plugins/apm/server/lib/helpers/convert_ui_filters/get_ui_filters_es.ts +++ b/x-pack/plugins/apm/server/lib/helpers/convert_ui_filters/get_ui_filters_es.ts @@ -27,22 +27,19 @@ export function getUiFiltersES(uiFilters: UIFilters) { }; }) as ESFilter[]; - // remove undefined items from list const esFilters = [ - getKueryUiFilterES(uiFilters.kuery), - getEnvironmentUiFilterES(uiFilters.environment), - ] - .filter((filter) => !!filter) - .concat(mappedFilters) as ESFilter[]; + ...getKueryUiFilterES(uiFilters.kuery), + ...getEnvironmentUiFilterES(uiFilters.environment), + ].concat(mappedFilters) as ESFilter[]; return esFilters; } function getKueryUiFilterES(kuery?: string) { if (!kuery) { - return; + return []; } const ast = esKuery.fromKueryExpression(kuery); - return esKuery.toElasticsearchQuery(ast) as ESFilter; + return [esKuery.toElasticsearchQuery(ast) as ESFilter]; } diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts index be92bfe5a0099..dd5d19b620c51 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts @@ -9,7 +9,6 @@ import { ESFilter } from '../../../typings/elasticsearch'; import { rangeFilter } from '../../../common/utils/range_filter'; import { PROCESSOR_EVENT, - SERVICE_ENVIRONMENT, SERVICE_NAME, TRANSACTION_DURATION, TRANSACTION_TYPE, @@ -22,7 +21,7 @@ import { TRANSACTION_REQUEST, TRANSACTION_PAGE_LOAD, } from '../../../common/transaction_types'; -import { ENVIRONMENT_NOT_DEFINED } from '../../../common/environment_filter_values'; +import { getEnvironmentUiFilterES } from '../helpers/convert_ui_filters/get_environment_ui_filter_es'; interface Options { setup: Setup & SetupTimeRange; @@ -43,30 +42,14 @@ export async function getServiceMapServiceNodeInfo({ }: Options & { serviceName: string; environment?: string }) { const { start, end } = setup; - const environmentNotDefinedFilter = { - bool: { must_not: [{ exists: { field: SERVICE_ENVIRONMENT } }] }, - }; - const filter: ESFilter[] = [ { range: rangeFilter(start, end) }, { term: { [SERVICE_NAME]: serviceName } }, + ...getEnvironmentUiFilterES(environment), ]; - if (environment) { - filter.push( - environment === ENVIRONMENT_NOT_DEFINED - ? environmentNotDefinedFilter - : { term: { [SERVICE_ENVIRONMENT]: environment } } - ); - } - const minutes = Math.abs((end - start) / (1000 * 60)); - - const taskParams = { - setup, - minutes, - filter, - }; + const taskParams = { setup, minutes, filter }; const [ errorMetrics, @@ -97,11 +80,7 @@ async function getErrorMetrics({ setup, minutes, filter }: TaskParameters) { size: 0, query: { bool: { - filter: filter.concat({ - term: { - [PROCESSOR_EVENT]: 'error', - }, - }), + filter: filter.concat({ term: { [PROCESSOR_EVENT]: 'error' } }), }, }, track_total_hits: true, @@ -134,11 +113,7 @@ async function getTransactionStats({ bool: { filter: [ ...filter, - { - term: { - [PROCESSOR_EVENT]: 'transaction', - }, - }, + { term: { [PROCESSOR_EVENT]: 'transaction' } }, { terms: { [TRANSACTION_TYPE]: [ @@ -151,13 +126,7 @@ async function getTransactionStats({ }, }, track_total_hits: true, - aggs: { - duration: { - avg: { - field: TRANSACTION_DURATION, - }, - }, - }, + aggs: { duration: { avg: { field: TRANSACTION_DURATION } } }, }, }; const response = await client.search(params); @@ -181,32 +150,16 @@ async function getCpuMetrics({ query: { bool: { filter: filter.concat([ - { - term: { - [PROCESSOR_EVENT]: 'metric', - }, - }, - { - exists: { - field: METRIC_SYSTEM_CPU_PERCENT, - }, - }, + { term: { [PROCESSOR_EVENT]: 'metric' } }, + { exists: { field: METRIC_SYSTEM_CPU_PERCENT } }, ]), }, }, - aggs: { - avgCpuUsage: { - avg: { - field: METRIC_SYSTEM_CPU_PERCENT, - }, - }, - }, + aggs: { avgCpuUsage: { avg: { field: METRIC_SYSTEM_CPU_PERCENT } } }, }, }); - return { - avgCpuUsage: response.aggregations?.avgCpuUsage.value ?? null, - }; + return { avgCpuUsage: response.aggregations?.avgCpuUsage.value ?? null }; } async function getMemoryMetrics({ @@ -220,31 +173,13 @@ async function getMemoryMetrics({ query: { bool: { filter: filter.concat([ - { - term: { - [PROCESSOR_EVENT]: 'metric', - }, - }, - { - exists: { - field: METRIC_SYSTEM_FREE_MEMORY, - }, - }, - { - exists: { - field: METRIC_SYSTEM_TOTAL_MEMORY, - }, - }, + { term: { [PROCESSOR_EVENT]: 'metric' } }, + { exists: { field: METRIC_SYSTEM_FREE_MEMORY } }, + { exists: { field: METRIC_SYSTEM_TOTAL_MEMORY } }, ]), }, }, - aggs: { - avgMemoryUsage: { - avg: { - script: percentMemoryUsedScript, - }, - }, - }, + aggs: { avgMemoryUsage: { avg: { script: percentMemoryUsedScript } } }, }, }); diff --git a/x-pack/plugins/apm/server/lib/services/annotations/get_derived_service_annotations.ts b/x-pack/plugins/apm/server/lib/services/annotations/get_derived_service_annotations.ts index 6da5d195cf194..6a8aaf8dca8a6 100644 --- a/x-pack/plugins/apm/server/lib/services/annotations/get_derived_service_annotations.ts +++ b/x-pack/plugins/apm/server/lib/services/annotations/get_derived_service_annotations.ts @@ -29,14 +29,9 @@ export async function getDerivedServiceAnnotations({ const filter: ESFilter[] = [ { term: { [PROCESSOR_EVENT]: 'transaction' } }, { term: { [SERVICE_NAME]: serviceName } }, + ...getEnvironmentUiFilterES(environment), ]; - const environmentFilter = getEnvironmentUiFilterES(environment); - - if (environmentFilter) { - filter.push(environmentFilter); - } - const versions = ( await client.search({ diff --git a/x-pack/plugins/apm/server/lib/services/annotations/get_stored_annotations.ts b/x-pack/plugins/apm/server/lib/services/annotations/get_stored_annotations.ts index 75aeb27ea2122..6e3ae0181ddee 100644 --- a/x-pack/plugins/apm/server/lib/services/annotations/get_stored_annotations.ts +++ b/x-pack/plugins/apm/server/lib/services/annotations/get_stored_annotations.ts @@ -29,8 +29,6 @@ export async function getStoredAnnotations({ logger: Logger; }): Promise { try { - const environmentFilter = getEnvironmentUiFilterES(environment); - const response: ESSearchResponse = (await apiCaller( 'search', { @@ -51,7 +49,7 @@ export async function getStoredAnnotations({ { term: { 'annotation.type': 'deployment' } }, { term: { tags: 'apm' } }, { term: { [SERVICE_NAME]: serviceName } }, - ...(environmentFilter ? [environmentFilter] : []), + ...getEnvironmentUiFilterES(environment), ], }, }, From 4925a4983a74572b2fba84f46111f9d04225950e Mon Sep 17 00:00:00 2001 From: Luke Elmers Date: Mon, 13 Jul 2020 09:11:11 -0600 Subject: [PATCH 05/40] Ensure Other bucket works on scripted fields. (#71329) --- .../_terms_other_bucket_helper.test.ts | 77 +++++++++++++++++++ .../buckets/_terms_other_bucket_helper.ts | 10 ++- 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/src/plugins/data/public/search/aggs/buckets/_terms_other_bucket_helper.test.ts b/src/plugins/data/public/search/aggs/buckets/_terms_other_bucket_helper.test.ts index 8e862b5692ca3..e9b4629ba88cf 100644 --- a/src/plugins/data/public/search/aggs/buckets/_terms_other_bucket_helper.test.ts +++ b/src/plugins/data/public/search/aggs/buckets/_terms_other_bucket_helper.test.ts @@ -316,6 +316,83 @@ describe('Terms Agg Other bucket helper', () => { } }); + test('excludes exists filter for scripted fields', () => { + const aggConfigs = getAggConfigs(nestedTerm.aggs); + aggConfigs.aggs[1].params.field.scripted = true; + const agg = buildOtherBucketAgg( + aggConfigs, + aggConfigs.aggs[1] as IBucketAggConfig, + nestedTermResponse + ); + const expectedResponse = { + 'other-filter': { + aggs: undefined, + filters: { + filters: { + '-IN': { + bool: { + must: [], + filter: [{ match_phrase: { 'geo.src': 'IN' } }], + should: [], + must_not: [ + { + script: { + script: { + lang: undefined, + params: { value: 'ios' }, + source: '(undefined) == value', + }, + }, + }, + { + script: { + script: { + lang: undefined, + params: { value: 'win xp' }, + source: '(undefined) == value', + }, + }, + }, + ], + }, + }, + '-US': { + bool: { + must: [], + filter: [{ match_phrase: { 'geo.src': 'US' } }], + should: [], + must_not: [ + { + script: { + script: { + lang: undefined, + params: { value: 'ios' }, + source: '(undefined) == value', + }, + }, + }, + { + script: { + script: { + lang: undefined, + params: { value: 'win xp' }, + source: '(undefined) == value', + }, + }, + }, + ], + }, + }, + }, + }, + }, + }; + expect(agg).toBeDefined(); + if (agg) { + expect(agg()).toEqual(expectedResponse); + } + }); + test('returns false when nested terms agg has no buckets', () => { const aggConfigs = getAggConfigs(nestedTerm.aggs); const agg = buildOtherBucketAgg( diff --git a/src/plugins/data/public/search/aggs/buckets/_terms_other_bucket_helper.ts b/src/plugins/data/public/search/aggs/buckets/_terms_other_bucket_helper.ts index fba3d35f002af..1a7deafb548ae 100644 --- a/src/plugins/data/public/search/aggs/buckets/_terms_other_bucket_helper.ts +++ b/src/plugins/data/public/search/aggs/buckets/_terms_other_bucket_helper.ts @@ -202,10 +202,12 @@ export const buildOtherBucketAgg = ( return; } - if ( - !aggWithOtherBucket.params.missingBucket || - agg.buckets.some((bucket: { key: string }) => bucket.key === '__missing__') - ) { + const hasScriptedField = !!aggWithOtherBucket.params.field.scripted; + const hasMissingBucket = !!aggWithOtherBucket.params.missingBucket; + const hasMissingBucketKey = agg.buckets.some( + (bucket: { key: string }) => bucket.key === '__missing__' + ); + if (!hasScriptedField && (!hasMissingBucket || hasMissingBucketKey)) { filters.push( buildExistsFilter( aggWithOtherBucket.params.field, From 82f6c6a1df9168b104227e999d927ea500bb11cc Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Mon, 13 Jul 2020 08:12:53 -0700 Subject: [PATCH 06/40] [Ingest Manager] Simplify add/edit package config (integration) form (#71187) * Match add integration page with designs * Clean up package config layout code * Match edit integration config page with designs * Fix typing and i18n issues * Add back data test subj * Add loading UI for second step; code clean up * Fix limited packages incorrect response * Add ability to create agent config when selecting config to add integration to * Add error count to input-level panel; memoize children components * Add error count next to all advanced options toggles * Move general form error to bottom bar * #69750 Auto-expand inputs with required & empty (invalid) vars * #68019 Enforce unique package config names, per agent config * Fix typing * Fix i18n * Fix reloading when new agent config _wasn't_ created * Memoize edit integration and fix fields not collapsing on edit * Really fix types --- .../components/layout.tsx | 252 +++++++------- .../package_config_input_config.tsx | 281 ++++++++-------- .../components/package_config_input_panel.tsx | 317 +++++++++--------- .../package_config_input_stream.tsx | 293 ++++++++-------- .../package_config_input_var_field.tsx | 12 +- .../create_package_config_page/index.tsx | 213 +++++++----- .../has_invalid_but_required_var.test.ts | 94 ++++++ .../services/has_invalid_but_required_var.ts | 26 ++ .../services/index.ts | 3 + .../services/is_advanced_var.ts | 2 +- .../services/validate_package_config.ts | 17 +- .../step_configure_package.tsx | 120 +++---- .../step_define_package_config.tsx | 228 +++++++------ .../step_select_config.tsx | 231 ++++++++----- .../step_select_package.tsx | 25 +- .../edit_package_config_page/index.tsx | 191 ++++++----- .../list_page/components/create_config.tsx | 8 +- .../ingest_manager/types/index.ts | 1 + .../server/services/package_config.ts | 29 ++ .../translations/translations/ja-JP.json | 10 - .../translations/translations/zh-CN.json | 10 - .../apis/index.js | 1 + .../apis/package_config/create.ts | 43 +++ .../apis/package_config/update.ts | 127 +++++++ 24 files changed, 1506 insertions(+), 1028 deletions(-) create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/services/has_invalid_but_required_var.test.ts create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/services/has_invalid_but_required_var.ts create mode 100644 x-pack/test/ingest_manager_api_integration/apis/package_config/update.ts diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/components/layout.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/components/layout.tsx index e0f40f1b15375..7ccb59f0e741e 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/components/layout.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/components/layout.tsx @@ -3,7 +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 React from 'react'; +import React, { memo, useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiFlexGroup, @@ -27,130 +27,148 @@ export const CreatePackageConfigPageLayout: React.FunctionComponent<{ agentConfig?: AgentConfig; packageInfo?: PackageInfo; 'data-test-subj'?: string; -}> = ({ - from, - cancelUrl, - onCancel, - agentConfig, - packageInfo, - children, - 'data-test-subj': dataTestSubj, -}) => { - const leftColumn = ( - - - {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} - - - - - +}> = memo( + ({ + from, + cancelUrl, + onCancel, + agentConfig, + packageInfo, + children, + 'data-test-subj': dataTestSubj, + }) => { + const pageTitle = useMemo(() => { + if ((from === 'package' || from === 'edit') && packageInfo) { + return ( + + + + + + +

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

+
+
+
+ ); + } + + return from === 'edit' ? (

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

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

- ) : from === 'config' ? ( +

+
+ ); + }, [from, packageInfo]); + + const pageDescription = useMemo(() => { + return from === 'edit' ? ( + + ) : from === 'config' ? ( + + ) : ( + + ); + }, [from]); + + const leftColumn = ( + + + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} + - ) : ( + + + {pageTitle} + + + + {pageDescription} + + + + ); + + const rightColumn = + agentConfig && (from === 'config' || from === 'edit') ? ( + + - )} -
-
-
- ); - const rightColumn = ( - - - - {agentConfig && (from === 'config' || from === 'edit') ? ( - - - - - - {agentConfig?.name || '-'} - - - ) : null} - {packageInfo && from === 'package' ? ( - - - - - - - - - - - {packageInfo?.title || packageInfo?.name || '-'} - - - - - ) : null} - - - ); + + {agentConfig?.name || '-'} + + ) : undefined; - const maxWidth = 770; - return ( - - {children} - - ); -}; + const maxWidth = 770; + return ( + + {children} + + ); + } +); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/components/package_config_input_config.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/components/package_config_input_config.tsx index 85c0f2134d8dc..98f04dbd92659 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/components/package_config_input_config.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/components/package_config_input_config.tsx @@ -3,17 +3,15 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, Fragment } from 'react'; +import React, { useState, Fragment, memo, useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { + EuiFlexGrid, EuiFlexGroup, EuiFlexItem, EuiText, - EuiTextColor, EuiSpacer, EuiButtonEmpty, - EuiTitle, - EuiIconTip, } from '@elastic/eui'; import { PackageConfigInput, RegistryVarsEntry } from '../../../../types'; import { @@ -29,150 +27,157 @@ export const PackageConfigInputConfig: React.FunctionComponent<{ updatePackageConfigInput: (updatedInput: Partial) => void; inputVarsValidationResults: PackageConfigConfigValidationResults; forceShowErrors?: boolean; -}> = ({ - packageInputVars, - packageConfigInput, - updatePackageConfigInput, - inputVarsValidationResults, - forceShowErrors, -}) => { - // Showing advanced options toggle state - const [isShowingAdvanced, setIsShowingAdvanced] = useState(false); +}> = memo( + ({ + packageInputVars, + packageConfigInput, + updatePackageConfigInput, + inputVarsValidationResults, + forceShowErrors, + }) => { + // Showing advanced options toggle state + const [isShowingAdvanced, setIsShowingAdvanced] = useState(false); - // Errors state - const hasErrors = forceShowErrors && validationHasErrors(inputVarsValidationResults); + // Errors state + const hasErrors = forceShowErrors && validationHasErrors(inputVarsValidationResults); - const requiredVars: RegistryVarsEntry[] = []; - const advancedVars: RegistryVarsEntry[] = []; + const requiredVars: RegistryVarsEntry[] = []; + const advancedVars: RegistryVarsEntry[] = []; - if (packageInputVars) { - packageInputVars.forEach((varDef) => { - if (isAdvancedVar(varDef)) { - advancedVars.push(varDef); - } else { - requiredVars.push(varDef); - } - }); - } + if (packageInputVars) { + packageInputVars.forEach((varDef) => { + if (isAdvancedVar(varDef)) { + advancedVars.push(varDef); + } else { + requiredVars.push(varDef); + } + }); + } + + const advancedVarsWithErrorsCount: number = useMemo( + () => + advancedVars.filter( + ({ name: varName }) => inputVarsValidationResults.vars?.[varName]?.length + ).length, + [advancedVars, inputVarsValidationResults.vars] + ); - return ( - - - - - -

- + return ( + + + + + + +

- -

+

+ + + +

+ +

+
- {hasErrors ? ( - - - } - position="right" - type="alert" - iconProps={{ color: 'danger' }} - /> - - ) : null}
-
- - -

- -

-
-
- - - {requiredVars.map((varDef) => { - const { name: varName, type: varType } = varDef; - const value = packageConfigInput.vars![varName].value; - return ( - - { - updatePackageConfigInput({ - vars: { - ...packageConfigInput.vars, - [varName]: { - type: varType, - value: newValue, + + + + {requiredVars.map((varDef) => { + const { name: varName, type: varType } = varDef; + const value = packageConfigInput.vars![varName].value; + return ( + + { + updatePackageConfigInput({ + vars: { + ...packageConfigInput.vars, + [varName]: { + type: varType, + value: newValue, + }, }, - }, - }); - }} - errors={inputVarsValidationResults.vars![varName]} - forceShowErrors={forceShowErrors} - /> - - ); - })} - {advancedVars.length ? ( - - - {/* Wrapper div to prevent button from going full width */} -
- setIsShowingAdvanced(!isShowingAdvanced)} - flush="left" - > - - -
-
- {isShowingAdvanced - ? advancedVars.map((varDef) => { - const { name: varName, type: varType } = varDef; - const value = packageConfigInput.vars![varName].value; - return ( - - { - updatePackageConfigInput({ - vars: { - ...packageConfigInput.vars, - [varName]: { - type: varType, - value: newValue, - }, - }, - }); - }} - errors={inputVarsValidationResults.vars![varName]} - forceShowErrors={forceShowErrors} + }); + }} + errors={inputVarsValidationResults.vars![varName]} + forceShowErrors={forceShowErrors} + /> + + ); + })} + {advancedVars.length ? ( + + + {/* Wrapper div to prevent button from going full width */} + + + setIsShowingAdvanced(!isShowingAdvanced)} + flush="left" + > + + + + {!isShowingAdvanced && hasErrors && advancedVarsWithErrorsCount ? ( + + + + - ); - }) - : null} - - ) : null} -
-
-
- ); -}; + ) : null} +
+ + {isShowingAdvanced + ? advancedVars.map((varDef) => { + const { name: varName, type: varType } = varDef; + const value = packageConfigInput.vars![varName].value; + return ( + + { + updatePackageConfigInput({ + vars: { + ...packageConfigInput.vars, + [varName]: { + type: varType, + value: newValue, + }, + }, + }); + }} + errors={inputVarsValidationResults.vars![varName]} + forceShowErrors={forceShowErrors} + /> + + ); + }) + : null} + + ) : null} + + + + ); + } +); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/components/package_config_input_panel.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/components/package_config_input_panel.tsx index f9c9dcd469b25..af26afdbf74d7 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/components/package_config_input_panel.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/components/package_config_input_panel.tsx @@ -3,21 +3,18 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, Fragment } from 'react'; +import React, { useState, Fragment, memo } from 'react'; import styled from 'styled-components'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { - EuiPanel, EuiFlexGroup, EuiFlexItem, EuiSwitch, EuiText, - EuiTextColor, EuiButtonIcon, EuiHorizontalRule, EuiSpacer, - EuiIconTip, } from '@elastic/eui'; import { PackageConfigInput, @@ -25,16 +22,44 @@ import { RegistryInput, RegistryStream, } from '../../../../types'; -import { PackageConfigInputValidationResults, validationHasErrors } from '../services'; +import { + PackageConfigInputValidationResults, + hasInvalidButRequiredVar, + countValidationErrors, +} from '../services'; import { PackageConfigInputConfig } from './package_config_input_config'; import { PackageConfigInputStreamConfig } from './package_config_input_stream'; -const FlushHorizontalRule = styled(EuiHorizontalRule)` - margin-left: -${(props) => props.theme.eui.paddingSizes.m}; - margin-right: -${(props) => props.theme.eui.paddingSizes.m}; - width: auto; +const ShortenedHorizontalRule = styled(EuiHorizontalRule)` + &&& { + width: ${(11 / 12) * 100}%; + margin-left: auto; + } `; +const shouldShowStreamsByDefault = ( + packageInput: RegistryInput, + packageInputStreams: Array, + packageConfigInput: PackageConfigInput +): boolean => { + return ( + packageConfigInput.enabled && + (hasInvalidButRequiredVar(packageInput.vars, packageConfigInput.vars) || + Boolean( + packageInputStreams.find( + (stream) => + stream.enabled && + hasInvalidButRequiredVar( + stream.vars, + packageConfigInput.streams.find( + (pkgStream) => stream.dataset.name === pkgStream.dataset.name + )?.vars + ) + ) + )) + ); +}; + export const PackageConfigInputPanel: React.FunctionComponent<{ packageInput: RegistryInput; packageInputStreams: Array; @@ -42,148 +67,136 @@ export const PackageConfigInputPanel: React.FunctionComponent<{ updatePackageConfigInput: (updatedInput: Partial) => void; inputValidationResults: PackageConfigInputValidationResults; forceShowErrors?: boolean; -}> = ({ - packageInput, - packageInputStreams, - packageConfigInput, - updatePackageConfigInput, - inputValidationResults, - forceShowErrors, -}) => { - // Showing streams toggle state - const [isShowingStreams, setIsShowingStreams] = useState(false); +}> = memo( + ({ + packageInput, + packageInputStreams, + packageConfigInput, + updatePackageConfigInput, + inputValidationResults, + forceShowErrors, + }) => { + // Showing streams toggle state + const [isShowingStreams, setIsShowingStreams] = useState( + shouldShowStreamsByDefault(packageInput, packageInputStreams, packageConfigInput) + ); - // Errors state - const hasErrors = forceShowErrors && validationHasErrors(inputValidationResults); + // Errors state + const errorCount = countValidationErrors(inputValidationResults); + const hasErrors = forceShowErrors && errorCount; - return ( - - {/* Header / input-level toggle */} - - - - - -

- - {packageInput.title || packageInput.type} - -

-
-
- {hasErrors ? ( + const inputStreams = packageInputStreams + .map((packageInputStream) => { + return { + packageInputStream, + packageConfigInputStream: packageConfigInput.streams.find( + (stream) => stream.dataset.name === packageInputStream.dataset.name + ), + }; + }) + .filter((stream) => Boolean(stream.packageConfigInputStream)); + + return ( + <> + {/* Header / input-level toggle */} + + + - - } - position="right" - type="alert" - iconProps={{ color: 'danger' }} - /> + +

{packageInput.title || packageInput.type}

+
- ) : null} -
- } - checked={packageConfigInput.enabled} - onChange={(e) => { - const enabled = e.target.checked; - updatePackageConfigInput({ - enabled, - streams: packageConfigInput.streams.map((stream) => ({ - ...stream, +
+ } + checked={packageConfigInput.enabled} + onChange={(e) => { + const enabled = e.target.checked; + updatePackageConfigInput({ enabled, - })), - }); - }} - /> - - - - - - - - {packageConfigInput.streams.filter((stream) => stream.enabled).length} - - - ), - total: packageInputStreams.length, - }} - /> - - - - setIsShowingStreams(!isShowingStreams)} - color="text" - aria-label={ - isShowingStreams - ? i18n.translate( - 'xpack.ingestManager.createPackageConfig.stepConfigure.hideStreamsAriaLabel', - { - defaultMessage: 'Hide {type} streams', - values: { - type: packageInput.type, - }, - } - ) - : i18n.translate( - 'xpack.ingestManager.createPackageConfig.stepConfigure.showStreamsAriaLabel', - { - defaultMessage: 'Show {type} streams', - values: { - type: packageInput.type, - }, - } - ) + streams: packageConfigInput.streams.map((stream) => ({ + ...stream, + enabled, + })), + }); + if (!enabled && isShowingStreams) { + setIsShowingStreams(false); } - /> - - - - + }} + /> + + + + {hasErrors ? ( + + + + + + ) : null} + + setIsShowingStreams(!isShowingStreams)} + color={hasErrors ? 'danger' : 'text'} + aria-label={ + isShowingStreams + ? i18n.translate( + 'xpack.ingestManager.createPackageConfig.stepConfigure.hideStreamsAriaLabel', + { + defaultMessage: 'Hide {type} inputs', + values: { + type: packageInput.type, + }, + } + ) + : i18n.translate( + 'xpack.ingestManager.createPackageConfig.stepConfigure.showStreamsAriaLabel', + { + defaultMessage: 'Show {type} inputs', + values: { + type: packageInput.type, + }, + } + ) + } + /> + + + + - {/* Header rule break */} - {isShowingStreams ? : null} + {/* Header rule break */} + {isShowingStreams ? : null} - {/* Input level configuration */} - {isShowingStreams && packageInput.vars && packageInput.vars.length ? ( - - - - - ) : null} + {/* Input level configuration */} + {isShowingStreams && packageInput.vars && packageInput.vars.length ? ( + + + + + ) : null} - {/* Per-stream configuration */} - {isShowingStreams ? ( - - {packageInputStreams.map((packageInputStream) => { - const packageConfigInputStream = packageConfigInput.streams.find( - (stream) => stream.dataset.name === packageInputStream.dataset.name - ); - return packageConfigInputStream ? ( - + {/* Per-stream configuration */} + {isShowingStreams ? ( + + {inputStreams.map(({ packageInputStream, packageConfigInputStream }, index) => ( + ) => { @@ -213,17 +226,21 @@ export const PackageConfigInputPanel: React.FunctionComponent<{ updatePackageConfigInput(updatedInput); }} inputStreamValidationResults={ - inputValidationResults.streams![packageConfigInputStream.id] + inputValidationResults.streams![packageConfigInputStream!.id] } forceShowErrors={forceShowErrors} /> - - + {index !== inputStreams.length - 1 ? ( + <> + + + + ) : null} - ) : null; - })} - - ) : null} -
- ); -}; + ))} + + ) : null} + + ); + } +); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/components/package_config_input_stream.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/components/package_config_input_stream.tsx index 52a4748fe14c7..11a9df276485b 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/components/package_config_input_stream.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/components/package_config_input_stream.tsx @@ -3,18 +3,17 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, Fragment } from 'react'; +import React, { useState, Fragment, memo, useMemo } from 'react'; import ReactMarkdown from 'react-markdown'; import { FormattedMessage } from '@kbn/i18n/react'; import { + EuiFlexGrid, EuiFlexGroup, EuiFlexItem, EuiSwitch, EuiText, EuiSpacer, EuiButtonEmpty, - EuiTextColor, - EuiIconTip, } from '@elastic/eui'; import { PackageConfigInputStream, RegistryStream, RegistryVarsEntry } from '../../../../types'; import { @@ -30,153 +29,157 @@ export const PackageConfigInputStreamConfig: React.FunctionComponent<{ updatePackageConfigInputStream: (updatedStream: Partial) => void; inputStreamValidationResults: PackageConfigConfigValidationResults; forceShowErrors?: boolean; -}> = ({ - packageInputStream, - packageConfigInputStream, - updatePackageConfigInputStream, - inputStreamValidationResults, - forceShowErrors, -}) => { - // Showing advanced options toggle state - const [isShowingAdvanced, setIsShowingAdvanced] = useState(false); +}> = memo( + ({ + packageInputStream, + packageConfigInputStream, + updatePackageConfigInputStream, + inputStreamValidationResults, + forceShowErrors, + }) => { + // Showing advanced options toggle state + const [isShowingAdvanced, setIsShowingAdvanced] = useState(); - // Errors state - const hasErrors = forceShowErrors && validationHasErrors(inputStreamValidationResults); + // Errors state + const hasErrors = forceShowErrors && validationHasErrors(inputStreamValidationResults); - const requiredVars: RegistryVarsEntry[] = []; - const advancedVars: RegistryVarsEntry[] = []; + const requiredVars: RegistryVarsEntry[] = []; + const advancedVars: RegistryVarsEntry[] = []; - if (packageInputStream.vars && packageInputStream.vars.length) { - packageInputStream.vars.forEach((varDef) => { - if (isAdvancedVar(varDef)) { - advancedVars.push(varDef); - } else { - requiredVars.push(varDef); - } - }); - } + if (packageInputStream.vars && packageInputStream.vars.length) { + packageInputStream.vars.forEach((varDef) => { + if (isAdvancedVar(varDef)) { + advancedVars.push(varDef); + } else { + requiredVars.push(varDef); + } + }); + } - return ( - - - - - - {packageInputStream.title} - - - {hasErrors ? ( - - - } - position="right" - type="alert" - iconProps={{ color: 'danger' }} - /> - + const advancedVarsWithErrorsCount: number = useMemo( + () => + advancedVars.filter( + ({ name: varName }) => inputStreamValidationResults.vars?.[varName]?.length + ).length, + [advancedVars, inputStreamValidationResults.vars] + ); + + return ( + + + + + + { + const enabled = e.target.checked; + updatePackageConfigInputStream({ + enabled, + }); + }} + /> + {packageInputStream.description ? ( + + + + + + ) : null} - - } - checked={packageConfigInputStream.enabled} - onChange={(e) => { - const enabled = e.target.checked; - updatePackageConfigInputStream({ - enabled, - }); - }} - /> - {packageInputStream.description ? ( - - - - - - - ) : null} - - - - {requiredVars.map((varDef) => { - const { name: varName, type: varType } = varDef; - const value = packageConfigInputStream.vars![varName].value; - return ( - - { - updatePackageConfigInputStream({ - vars: { - ...packageConfigInputStream.vars, - [varName]: { - type: varType, - value: newValue, + + + + + + {requiredVars.map((varDef) => { + const { name: varName, type: varType } = varDef; + const value = packageConfigInputStream.vars![varName].value; + return ( + + { + updatePackageConfigInputStream({ + vars: { + ...packageConfigInputStream.vars, + [varName]: { + type: varType, + value: newValue, + }, }, - }, - }); - }} - errors={inputStreamValidationResults.vars![varName]} - forceShowErrors={forceShowErrors} - /> - - ); - })} - {advancedVars.length ? ( - - - {/* Wrapper div to prevent button from going full width */} -
- setIsShowingAdvanced(!isShowingAdvanced)} - flush="left" - > - - -
-
- {isShowingAdvanced - ? advancedVars.map((varDef) => { - const { name: varName, type: varType } = varDef; - const value = packageConfigInputStream.vars![varName].value; - return ( - - { - updatePackageConfigInputStream({ - vars: { - ...packageConfigInputStream.vars, - [varName]: { - type: varType, - value: newValue, - }, - }, - }); - }} - errors={inputStreamValidationResults.vars![varName]} - forceShowErrors={forceShowErrors} + }); + }} + errors={inputStreamValidationResults.vars![varName]} + forceShowErrors={forceShowErrors} + /> + + ); + })} + {advancedVars.length ? ( + + + + + setIsShowingAdvanced(!isShowingAdvanced)} + flush="left" + > + + + + {!isShowingAdvanced && hasErrors && advancedVarsWithErrorsCount ? ( + + + + - ); - }) - : null} - - ) : null} -
-
-
- ); -}; + ) : null} + + + {isShowingAdvanced + ? advancedVars.map((varDef) => { + const { name: varName, type: varType } = varDef; + const value = packageConfigInputStream.vars![varName].value; + return ( + + { + updatePackageConfigInputStream({ + vars: { + ...packageConfigInputStream.vars, + [varName]: { + type: varType, + value: newValue, + }, + }, + }); + }} + errors={inputStreamValidationResults.vars![varName]} + forceShowErrors={forceShowErrors} + /> + + ); + }) + : null} + + ) : null} + + + + ); + } +); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/components/package_config_input_var_field.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/components/package_config_input_var_field.tsx index 8868e00ecc1f1..eb681096a080e 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/components/package_config_input_var_field.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/components/package_config_input_var_field.tsx @@ -3,7 +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 React, { useState } from 'react'; +import React, { useState, memo, useMemo } from 'react'; import ReactMarkdown from 'react-markdown'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiFormRow, EuiFieldText, EuiComboBox, EuiText, EuiCodeEditor } from '@elastic/eui'; @@ -18,13 +18,13 @@ export const PackageConfigInputVarField: React.FunctionComponent<{ onChange: (newValue: any) => void; errors?: string[] | null; forceShowErrors?: boolean; -}> = ({ varDef, value, onChange, errors: varErrors, forceShowErrors }) => { +}> = memo(({ varDef, value, onChange, errors: varErrors, forceShowErrors }) => { const [isDirty, setIsDirty] = useState(false); const { multi, required, type, title, name, description } = varDef; const isInvalid = (isDirty || forceShowErrors) && !!varErrors; const errors = isInvalid ? varErrors : null; - const renderField = () => { + const field = useMemo(() => { if (multi) { return ( setIsDirty(true)} /> ); - }; + }, [isInvalid, multi, onChange, type, value]); return ( } > - {renderField()} + {field} ); -}; +}); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/index.tsx index b446e6bf97e7b..74cbcdca512db 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/index.tsx @@ -5,6 +5,7 @@ */ import React, { useState, useEffect, useMemo, useCallback, ReactEventHandler } from 'react'; import { useRouteMatch, useHistory } from 'react-router-dom'; +import styled from 'styled-components'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -31,6 +32,7 @@ import { useConfig, sendGetAgentStatus, } from '../../../hooks'; +import { Loading } from '../../../components'; import { ConfirmDeployConfigModal } from '../components'; import { CreatePackageConfigPageLayout } from './components'; import { CreatePackageConfigFrom, PackageConfigFormState } from './types'; @@ -45,6 +47,12 @@ import { StepConfigurePackage } from './step_configure_package'; import { StepDefinePackageConfig } from './step_define_package_config'; import { useIntraAppState } from '../../../hooks/use_intra_app_state'; +const StepsWithLessPadding = styled(EuiSteps)` + .euiStep__content { + padding-bottom: ${(props) => props.theme.eui.paddingSizes.m}; + } +`; + export const CreatePackageConfigPage: React.FunctionComponent = () => { const { notifications, @@ -75,6 +83,7 @@ export const CreatePackageConfigPage: React.FunctionComponent = () => { // Agent config and package info states const [agentConfig, setAgentConfig] = useState(); const [packageInfo, setPackageInfo] = useState(); + const [isLoadingSecondStep, setIsLoadingSecondStep] = useState(false); const agentConfigId = agentConfig?.id; // Retrieve agent count @@ -151,40 +160,47 @@ export const CreatePackageConfigPage: React.FunctionComponent = () => { const hasErrors = validationResults ? validationHasErrors(validationResults) : false; + // Update package config validation + const updatePackageConfigValidation = useCallback( + (newPackageConfig?: NewPackageConfig) => { + if (packageInfo) { + const newValidationResult = validatePackageConfig( + newPackageConfig || packageConfig, + packageInfo + ); + setValidationResults(newValidationResult); + // eslint-disable-next-line no-console + console.debug('Package config validation results', newValidationResult); + + return newValidationResult; + } + }, + [packageConfig, packageInfo] + ); + // Update package config method - const updatePackageConfig = (updatedFields: Partial) => { - const newPackageConfig = { - ...packageConfig, - ...updatedFields, - }; - setPackageConfig(newPackageConfig); - - // eslint-disable-next-line no-console - console.debug('Package config updated', newPackageConfig); - const newValidationResults = updatePackageConfigValidation(newPackageConfig); - const hasPackage = newPackageConfig.package; - const hasValidationErrors = newValidationResults - ? validationHasErrors(newValidationResults) - : false; - const hasAgentConfig = newPackageConfig.config_id && newPackageConfig.config_id !== ''; - if (hasPackage && hasAgentConfig && !hasValidationErrors) { - setFormState('VALID'); - } - }; + const updatePackageConfig = useCallback( + (updatedFields: Partial) => { + const newPackageConfig = { + ...packageConfig, + ...updatedFields, + }; + setPackageConfig(newPackageConfig); - const updatePackageConfigValidation = (newPackageConfig?: NewPackageConfig) => { - if (packageInfo) { - const newValidationResult = validatePackageConfig( - newPackageConfig || packageConfig, - packageInfo - ); - setValidationResults(newValidationResult); // eslint-disable-next-line no-console - console.debug('Package config validation results', newValidationResult); - - return newValidationResult; - } - }; + console.debug('Package config updated', newPackageConfig); + const newValidationResults = updatePackageConfigValidation(newPackageConfig); + const hasPackage = newPackageConfig.package; + const hasValidationErrors = newValidationResults + ? validationHasErrors(newValidationResults) + : false; + const hasAgentConfig = newPackageConfig.config_id && newPackageConfig.config_id !== ''; + if (hasPackage && hasAgentConfig && !hasValidationErrors) { + setFormState('VALID'); + } + }, + [packageConfig, updatePackageConfigValidation] + ); // Cancel path const cancelUrl = useMemo(() => { @@ -276,6 +292,7 @@ export const CreatePackageConfigPage: React.FunctionComponent = () => { updatePackageInfo={updatePackageInfo} agentConfig={agentConfig} updateAgentConfig={updateAgentConfig} + setIsLoadingSecondStep={setIsLoadingSecondStep} /> ), [pkgkey, updatePackageInfo, agentConfig, updateAgentConfig] @@ -288,11 +305,47 @@ export const CreatePackageConfigPage: React.FunctionComponent = () => { updateAgentConfig={updateAgentConfig} packageInfo={packageInfo} updatePackageInfo={updatePackageInfo} + setIsLoadingSecondStep={setIsLoadingSecondStep} /> ), [configId, updateAgentConfig, packageInfo, updatePackageInfo] ); + const stepConfigurePackage = useMemo( + () => + isLoadingSecondStep ? ( + + ) : agentConfig && packageInfo ? ( + <> + + + + ) : ( +
+ ), + [ + agentConfig, + formState, + isLoadingSecondStep, + packageConfig, + packageInfo, + updatePackageConfig, + validationResults, + ] + ); + const steps: EuiStepProps[] = [ from === 'package' ? { @@ -310,44 +363,16 @@ export const CreatePackageConfigPage: React.FunctionComponent = () => { }), children: stepSelectPackage, }, - { - title: i18n.translate( - 'xpack.ingestManager.createPackageConfig.stepDefinePackageConfigTitle', - { - defaultMessage: 'Configure integration', - } - ), - status: !packageInfo || !agentConfig ? 'disabled' : undefined, - children: - agentConfig && packageInfo ? ( - - ) : null, - }, { title: i18n.translate( 'xpack.ingestManager.createPackageConfig.stepConfigurePackageConfigTitle', { - defaultMessage: 'Select the data you want to collect', + defaultMessage: 'Configure integration', } ), - status: !packageInfo || !agentConfig ? 'disabled' : undefined, + status: !packageInfo || !agentConfig || isLoadingSecondStep ? 'disabled' : undefined, 'data-test-subj': 'dataCollectionSetupStep', - children: - agentConfig && packageInfo ? ( - - ) : null, + children: stepConfigurePackage, }, ]; @@ -371,7 +396,7 @@ export const CreatePackageConfigPage: React.FunctionComponent = () => { : agentConfig && ( )} - + {/* TODO #64541 - Remove classes */} { : undefined } > - + - {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} - + {!isLoadingSecondStep && agentConfig && packageInfo && formState === 'INVALID' ? ( - + ) : null} - - - + + + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} + + + + + + + + + + diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/services/has_invalid_but_required_var.test.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/services/has_invalid_but_required_var.test.ts new file mode 100644 index 0000000000000..679ae4b1456d6 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/services/has_invalid_but_required_var.test.ts @@ -0,0 +1,94 @@ +/* + * 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 { hasInvalidButRequiredVar } from './has_invalid_but_required_var'; + +describe('Ingest Manager - hasInvalidButRequiredVar', () => { + it('returns true for invalid & required vars', () => { + expect( + hasInvalidButRequiredVar( + [ + { + name: 'mock_var', + type: 'text', + required: true, + }, + ], + {} + ) + ).toBe(true); + + expect( + hasInvalidButRequiredVar( + [ + { + name: 'mock_var', + type: 'text', + required: true, + }, + ], + { + mock_var: { + value: undefined, + }, + } + ) + ).toBe(true); + }); + + it('returns false for valid & required vars', () => { + expect( + hasInvalidButRequiredVar( + [ + { + name: 'mock_var', + type: 'text', + required: true, + }, + ], + { + mock_var: { + value: 'foo', + }, + } + ) + ).toBe(false); + }); + + it('returns false for optional vars', () => { + expect( + hasInvalidButRequiredVar( + [ + { + name: 'mock_var', + type: 'text', + }, + ], + { + mock_var: { + value: 'foo', + }, + } + ) + ).toBe(false); + + expect( + hasInvalidButRequiredVar( + [ + { + name: 'mock_var', + type: 'text', + required: false, + }, + ], + { + mock_var: { + value: undefined, + }, + } + ) + ).toBe(false); + }); +}); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/services/has_invalid_but_required_var.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/services/has_invalid_but_required_var.ts new file mode 100644 index 0000000000000..f632d40a05621 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/services/has_invalid_but_required_var.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { PackageConfigConfigRecord, RegistryVarsEntry } from '../../../../types'; +import { validatePackageConfigConfig } from './'; + +export const hasInvalidButRequiredVar = ( + registryVars?: RegistryVarsEntry[], + packageConfigVars?: PackageConfigConfigRecord +): boolean => { + return ( + (registryVars && !packageConfigVars) || + Boolean( + registryVars && + registryVars.find( + (registryVar) => + registryVar.required && + (!packageConfigVars || + !packageConfigVars[registryVar.name] || + validatePackageConfigConfig(packageConfigVars[registryVar.name], registryVar)?.length) + ) + ) + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/services/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/services/index.ts index 6cfb1c74bd661..0d33a4e113f03 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/services/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/services/index.ts @@ -4,10 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ export { isAdvancedVar } from './is_advanced_var'; +export { hasInvalidButRequiredVar } from './has_invalid_but_required_var'; export { PackageConfigValidationResults, PackageConfigConfigValidationResults, PackageConfigInputValidationResults, validatePackageConfig, + validatePackageConfigConfig, validationHasErrors, + countValidationErrors, } from './validate_package_config'; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/services/is_advanced_var.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/services/is_advanced_var.ts index 398f1d675c5df..a2f4a6675ac80 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/services/is_advanced_var.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/services/is_advanced_var.ts @@ -6,7 +6,7 @@ import { RegistryVarsEntry } from '../../../../types'; export const isAdvancedVar = (varDef: RegistryVarsEntry): boolean => { - if (varDef.show_user || (varDef.required && !varDef.default)) { + if (varDef.show_user || (varDef.required && varDef.default === undefined)) { return false; } return true; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/services/validate_package_config.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/services/validate_package_config.ts index cd301747c3f53..bd9d216ca969a 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/services/validate_package_config.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/services/validate_package_config.ts @@ -171,7 +171,7 @@ export const validatePackageConfig = ( return validationResults; }; -const validatePackageConfigConfig = ( +export const validatePackageConfigConfig = ( configEntry: PackageConfigConfigRecordEntry, varDef: RegistryVarsEntry ): string[] | null => { @@ -237,13 +237,22 @@ const validatePackageConfigConfig = ( return errors.length ? errors : null; }; -export const validationHasErrors = ( +export const countValidationErrors = ( validationResults: | PackageConfigValidationResults | PackageConfigInputValidationResults | PackageConfigConfigValidationResults -) => { +): number => { const flattenedValidation = getFlattenedObject(validationResults); + const errors = Object.values(flattenedValidation).filter((value) => Boolean(value)) || []; + return errors.length; +}; - return !!Object.entries(flattenedValidation).find(([, value]) => !!value); +export const validationHasErrors = ( + validationResults: + | PackageConfigValidationResults + | PackageConfigInputValidationResults + | PackageConfigConfigValidationResults +): boolean => { + return countValidationErrors(validationResults) > 0; }; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/step_configure_package.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/step_configure_package.tsx index eecd204a5e307..380a03e15695b 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/step_configure_package.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/step_configure_package.tsx @@ -4,12 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ import React from 'react'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiPanel, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiCallOut } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; +import { EuiHorizontalRule, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { PackageInfo, RegistryStream, NewPackageConfig, PackageConfigInput } from '../../../types'; import { Loading } from '../../../components'; -import { PackageConfigValidationResults, validationHasErrors } from './services'; +import { PackageConfigValidationResults } from './services'; import { PackageConfigInputPanel, CustomPackageConfig } from './components'; import { CreatePackageConfigFrom } from './types'; @@ -52,8 +50,6 @@ export const StepConfigurePackage: React.FunctionComponent<{ validationResults, submitAttempted, }) => { - const hasErrors = validationResults ? validationHasErrors(validationResults) : false; - // Configure inputs (and their streams) // Assume packages only export one config template for now const renderConfigureInputs = () => @@ -61,76 +57,50 @@ export const StepConfigurePackage: React.FunctionComponent<{ packageInfo.config_templates[0] && packageInfo.config_templates[0].inputs && packageInfo.config_templates[0].inputs.length ? ( - - {packageInfo.config_templates[0].inputs.map((packageInput) => { - const packageConfigInput = packageConfig.inputs.find( - (input) => input.type === packageInput.type - ); - const packageInputStreams = findStreamsForInputType(packageInput.type, packageInfo); - return packageConfigInput ? ( - - ) => { - const indexOfUpdatedInput = packageConfig.inputs.findIndex( - (input) => input.type === packageInput.type - ); - const newInputs = [...packageConfig.inputs]; - newInputs[indexOfUpdatedInput] = { - ...newInputs[indexOfUpdatedInput], - ...updatedInput, - }; - updatePackageConfig({ - inputs: newInputs, - }); - }} - inputValidationResults={validationResults!.inputs![packageConfigInput.type]} - forceShowErrors={submitAttempted} - /> - - ) : null; - })} - + <> + + + {packageInfo.config_templates[0].inputs.map((packageInput) => { + const packageConfigInput = packageConfig.inputs.find( + (input) => input.type === packageInput.type + ); + const packageInputStreams = findStreamsForInputType(packageInput.type, packageInfo); + return packageConfigInput ? ( + + ) => { + const indexOfUpdatedInput = packageConfig.inputs.findIndex( + (input) => input.type === packageInput.type + ); + const newInputs = [...packageConfig.inputs]; + newInputs[indexOfUpdatedInput] = { + ...newInputs[indexOfUpdatedInput], + ...updatedInput, + }; + updatePackageConfig({ + inputs: newInputs, + }); + }} + inputValidationResults={validationResults!.inputs![packageConfigInput.type]} + forceShowErrors={submitAttempted} + /> + + + ) : null; + })} + + ) : ( - - - + ); - return validationResults ? ( - - {renderConfigureInputs()} - {hasErrors && submitAttempted ? ( - - - -

- -

-
- -
- ) : null} -
- ) : ( - - ); + return validationResults ? renderConfigureInputs() : ; }; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/step_define_package_config.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/step_define_package_config.tsx index b2ffe62104eb1..a04d023ebcc48 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/step_define_package_config.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/step_define_package_config.tsx @@ -3,17 +3,18 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect, useState, Fragment } from 'react'; +import React, { useEffect, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { - EuiFlexGrid, - EuiFlexItem, EuiFormRow, EuiFieldText, EuiButtonEmpty, EuiSpacer, EuiText, EuiComboBox, + EuiDescribedFormGroup, + EuiFlexGroup, + EuiFlexItem, } from '@elastic/eui'; import { AgentConfig, PackageInfo, PackageConfig, NewPackageConfig } from '../../../types'; import { packageToPackageConfigInputs } from '../../../services'; @@ -28,7 +29,7 @@ export const StepDefinePackageConfig: React.FunctionComponent<{ validationResults: PackageConfigValidationResults; }> = ({ agentConfig, packageInfo, packageConfig, updatePackageConfig, validationResults }) => { // Form show/hide states - const [isShowingAdvancedDefine, setIsShowingAdvancedDefine] = useState(false); + const [isShowingAdvanced, setIsShowingAdvanced] = useState(false); // Update package config's package and config info useEffect(() => { @@ -74,111 +75,140 @@ export const StepDefinePackageConfig: React.FunctionComponent<{ ]); return validationResults ? ( - <> - - - + + + + } + description={ + + } + > + <> + {/* Name */} + + } + > + + updatePackageConfig({ + name: e.target.value, + }) } - > - - updatePackageConfig({ - name: e.target.value, - }) - } - data-test-subj="packageConfigNameInput" + data-test-subj="packageConfigNameInput" + /> + + + {/* Description */} + - - - - + + } + isInvalid={!!validationResults.description} + error={validationResults.description} + > + + updatePackageConfig({ + description: e.target.value, + }) } - labelAppend={ - + /> + + + + {/* Advanced options toggle */} + + + setIsShowingAdvanced(!isShowingAdvanced)} + flush="left" + > + + + + {!isShowingAdvanced && !!validationResults.namespace ? ( + + - } - isInvalid={!!validationResults.description} - error={validationResults.description} - > - - updatePackageConfig({ - description: e.target.value, - }) + + ) : null} + + + {/* Advanced options content */} + {/* Todo: Populate list of existing namespaces */} + {isShowingAdvanced ? ( + <> + + } - /> - - - - - setIsShowingAdvancedDefine(!isShowingAdvancedDefine)} - > - - - {/* Todo: Populate list of existing namespaces */} - {isShowingAdvancedDefine || !!validationResults.namespace ? ( - - - - - + > + - { - updatePackageConfig({ - namespace: newNamespace, - }); - }} - onChange={(newNamespaces: Array<{ label: string }>) => { - updatePackageConfig({ - namespace: newNamespaces.length ? newNamespaces[0].label : '', - }); - }} - /> - - - - - ) : null} - + onCreateOption={(newNamespace: string) => { + updatePackageConfig({ + namespace: newNamespace, + }); + }} + onChange={(newNamespaces: Array<{ label: string }>) => { + updatePackageConfig({ + namespace: newNamespaces.length ? newNamespaces[0].label : '', + }); + }} + /> + + + ) : null} + + ) : ( ); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/step_select_config.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/step_select_config.tsx index f6391cf1fa456..d3120f9051f45 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/step_select_config.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/step_select_config.tsx @@ -6,29 +6,50 @@ import React, { useEffect, useState, Fragment } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiFlexGroup, EuiFlexItem, EuiSelectable, EuiSpacer, EuiTextColor } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiSelectable, + EuiSpacer, + EuiTextColor, + EuiPortal, + EuiButtonEmpty, +} from '@elastic/eui'; import { Error } from '../../../components'; import { AgentConfig, PackageInfo, GetAgentConfigsResponseItem } from '../../../types'; import { isPackageLimited, doesAgentConfigAlreadyIncludePackage } from '../../../services'; -import { useGetPackageInfoByKey, useGetAgentConfigs, sendGetOneAgentConfig } from '../../../hooks'; +import { + useGetPackageInfoByKey, + useGetAgentConfigs, + sendGetOneAgentConfig, + useCapabilities, +} from '../../../hooks'; +import { CreateAgentConfigFlyout } from '../list_page/components'; export const StepSelectConfig: React.FunctionComponent<{ pkgkey: string; updatePackageInfo: (packageInfo: PackageInfo | undefined) => void; agentConfig: AgentConfig | undefined; updateAgentConfig: (config: AgentConfig | undefined) => void; -}> = ({ pkgkey, updatePackageInfo, agentConfig, updateAgentConfig }) => { + setIsLoadingSecondStep: (isLoading: boolean) => void; +}> = ({ pkgkey, updatePackageInfo, agentConfig, updateAgentConfig, setIsLoadingSecondStep }) => { // Selected config state const [selectedConfigId, setSelectedConfigId] = useState( agentConfig ? agentConfig.id : undefined ); const [selectedConfigError, setSelectedConfigError] = useState(); + // Create new config flyout state + const hasWriteCapabilites = useCapabilities().write; + const [isCreateAgentConfigFlyoutOpen, setIsCreateAgentConfigFlyoutOpen] = useState( + false + ); + // Fetch package info const { data: packageInfoData, error: packageInfoError, - isLoading: packageInfoLoading, + isLoading: isPackageInfoLoading, } = useGetPackageInfoByKey(pkgkey); const isLimitedPackage = (packageInfoData && isPackageLimited(packageInfoData.response)) || false; @@ -37,6 +58,7 @@ export const StepSelectConfig: React.FunctionComponent<{ data: agentConfigsData, error: agentConfigsError, isLoading: isAgentConfigsLoading, + sendRequest: refreshAgentConfigs, } = useGetAgentConfigs({ page: 1, perPage: 1000, @@ -64,6 +86,7 @@ export const StepSelectConfig: React.FunctionComponent<{ useEffect(() => { const fetchAgentConfigInfo = async () => { if (selectedConfigId) { + setIsLoadingSecondStep(true); const { data, error } = await sendGetOneAgentConfig(selectedConfigId); if (error) { setSelectedConfigError(error); @@ -76,11 +99,12 @@ export const StepSelectConfig: React.FunctionComponent<{ setSelectedConfigError(undefined); updateAgentConfig(undefined); } + setIsLoadingSecondStep(false); }; if (!agentConfig || selectedConfigId !== agentConfig.id) { fetchAgentConfigInfo(); } - }, [selectedConfigId, agentConfig, updateAgentConfig]); + }, [selectedConfigId, agentConfig, updateAgentConfig, setIsLoadingSecondStep]); // Display package error if there is one if (packageInfoError) { @@ -113,92 +137,125 @@ export const StepSelectConfig: React.FunctionComponent<{ } return ( - - - { - const alreadyHasLimitedPackage = - (isLimitedPackage && - packageInfoData && - doesAgentConfigAlreadyIncludePackage(agentConf, packageInfoData.response.name)) || - false; - return { - label: agentConf.name, - key: agentConf.id, - checked: selectedConfigId === agentConf.id ? 'on' : undefined, - disabled: alreadyHasLimitedPackage, - 'data-test-subj': 'agentConfigItem', - }; - })} - renderOption={(option) => ( - - {option.label} - - - {agentConfigsById[option.key!].description} - - - - - - - - - )} - listProps={{ - bordered: true, - }} - searchProps={{ - placeholder: i18n.translate( - 'xpack.ingestManager.createPackageConfig.StepSelectConfig.filterAgentConfigsInputPlaceholder', - { - defaultMessage: 'Search for agent configurations', + <> + {isCreateAgentConfigFlyoutOpen ? ( + + { + setIsCreateAgentConfigFlyoutOpen(false); + if (newAgentConfig) { + refreshAgentConfigs(); + setSelectedConfigId(newAgentConfig.id); + } + }} + /> + + ) : null} + + + { + const alreadyHasLimitedPackage = + (isLimitedPackage && + packageInfoData && + doesAgentConfigAlreadyIncludePackage(agentConf, packageInfoData.response.name)) || + false; + return { + label: agentConf.name, + key: agentConf.id, + checked: selectedConfigId === agentConf.id ? 'on' : undefined, + disabled: alreadyHasLimitedPackage, + 'data-test-subj': 'agentConfigItem', + }; + })} + renderOption={(option) => ( + + {option.label} + + + {agentConfigsById[option.key!].description} + + + + + + + + + )} + listProps={{ + bordered: true, + }} + searchProps={{ + placeholder: i18n.translate( + 'xpack.ingestManager.createPackageConfig.StepSelectConfig.filterAgentConfigsInputPlaceholder', + { + defaultMessage: 'Search for agent configurations', + } + ), + }} + height={180} + onChange={(options) => { + const selectedOption = options.find((option) => option.checked === 'on'); + if (selectedOption) { + if (selectedOption.key !== selectedConfigId) { + setSelectedConfigId(selectedOption.key); + } + } else { + setSelectedConfigId(undefined); + } + }} + > + {(list, search) => ( + + {search} + + {list} + + )} + + + {/* Display selected agent config error if there is one */} + {selectedConfigError ? ( + + } - ), - }} - height={240} - onChange={(options) => { - const selectedOption = options.find((option) => option.checked === 'on'); - if (selectedOption) { - setSelectedConfigId(selectedOption.key); - } else { - setSelectedConfigId(undefined); - } - }} - > - {(list, search) => ( - - {search} - - {list} - - )} - - - {/* Display selected agent config error if there is one */} - {selectedConfigError ? ( + error={selectedConfigError} + /> + + ) : null} - + setIsCreateAgentConfigFlyoutOpen(true)} + flush="left" + size="s" + > - } - error={selectedConfigError} - /> + +
- ) : null} - + + ); }; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/step_select_package.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/step_select_package.tsx index 204b862bd4dc4..048ae101fcd6f 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/step_select_package.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/step_select_package.tsx @@ -22,7 +22,14 @@ export const StepSelectPackage: React.FunctionComponent<{ updateAgentConfig: (config: AgentConfig | undefined) => void; packageInfo?: PackageInfo; updatePackageInfo: (packageInfo: PackageInfo | undefined) => void; -}> = ({ agentConfigId, updateAgentConfig, packageInfo, updatePackageInfo }) => { + setIsLoadingSecondStep: (isLoading: boolean) => void; +}> = ({ + agentConfigId, + updateAgentConfig, + packageInfo, + updatePackageInfo, + setIsLoadingSecondStep, +}) => { // Selected package state const [selectedPkgKey, setSelectedPkgKey] = useState( packageInfo ? `${packageInfo.name}-${packageInfo.version}` : undefined @@ -30,7 +37,11 @@ export const StepSelectPackage: React.FunctionComponent<{ const [selectedPkgError, setSelectedPkgError] = useState(); // Fetch agent config info - const { data: agentConfigData, error: agentConfigError } = useGetOneAgentConfig(agentConfigId); + const { + data: agentConfigData, + error: agentConfigError, + isLoading: isAgentConfigsLoading, + } = useGetOneAgentConfig(agentConfigId); // Fetch packages info // Filter out limited packages already part of selected agent config @@ -66,6 +77,7 @@ export const StepSelectPackage: React.FunctionComponent<{ useEffect(() => { const fetchPackageInfo = async () => { if (selectedPkgKey) { + setIsLoadingSecondStep(true); const { data, error } = await sendGetPackageInfoByKey(selectedPkgKey); if (error) { setSelectedPkgError(error); @@ -74,6 +86,7 @@ export const StepSelectPackage: React.FunctionComponent<{ setSelectedPkgError(undefined); updatePackageInfo(data.response); } + setIsLoadingSecondStep(false); } else { setSelectedPkgError(undefined); updatePackageInfo(undefined); @@ -82,7 +95,7 @@ export const StepSelectPackage: React.FunctionComponent<{ if (!packageInfo || selectedPkgKey !== `${packageInfo.name}-${packageInfo.version}`) { fetchPackageInfo(); } - }, [selectedPkgKey, packageInfo, updatePackageInfo]); + }, [selectedPkgKey, packageInfo, updatePackageInfo, setIsLoadingSecondStep]); // Display agent config error if there is one if (agentConfigError) { @@ -121,7 +134,7 @@ export const StepSelectPackage: React.FunctionComponent<{ searchable allowExclusions={false} singleSelection={true} - isLoading={isPackagesLoading || isLimitedPackagesLoading} + isLoading={isPackagesLoading || isLimitedPackagesLoading || isAgentConfigsLoading} options={packages.map(({ title, name, version, icons }) => { const pkgkey = `${name}-${version}`; return { @@ -154,7 +167,9 @@ export const StepSelectPackage: React.FunctionComponent<{ onChange={(options) => { const selectedOption = options.find((option) => option.checked === 'on'); if (selectedOption) { - setSelectedPkgKey(selectedOption.key); + if (selectedOption.key !== selectedPkgKey) { + setSelectedPkgKey(selectedOption.key); + } } else { setSelectedPkgKey(undefined); } diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/edit_package_config_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/edit_package_config_page/index.tsx index 52fd95d663671..f4411a6057a15 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/edit_package_config_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/edit_package_config_page/index.tsx @@ -3,14 +3,13 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { useRouteMatch, useHistory } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButtonEmpty, EuiButton, - EuiSteps, EuiBottomBar, EuiFlexGroup, EuiFlexItem, @@ -160,38 +159,45 @@ export const EditPackageConfigPage: React.FunctionComponent = () => { const [validationResults, setValidationResults] = useState(); const hasErrors = validationResults ? validationHasErrors(validationResults) : false; - // Update package config method - const updatePackageConfig = (updatedFields: Partial) => { - const newPackageConfig = { - ...packageConfig, - ...updatedFields, - }; - setPackageConfig(newPackageConfig); + // Update package config validation + const updatePackageConfigValidation = useCallback( + (newPackageConfig?: UpdatePackageConfig) => { + if (packageInfo) { + const newValidationResult = validatePackageConfig( + newPackageConfig || packageConfig, + packageInfo + ); + setValidationResults(newValidationResult); + // eslint-disable-next-line no-console + console.debug('Package config validation results', newValidationResult); - // eslint-disable-next-line no-console - console.debug('Package config updated', newPackageConfig); - const newValidationResults = updatePackageConfigValidation(newPackageConfig); - const hasValidationErrors = newValidationResults - ? validationHasErrors(newValidationResults) - : false; - if (!hasValidationErrors) { - setFormState('VALID'); - } - }; + return newValidationResult; + } + }, + [packageConfig, packageInfo] + ); - const updatePackageConfigValidation = (newPackageConfig?: UpdatePackageConfig) => { - if (packageInfo) { - const newValidationResult = validatePackageConfig( - newPackageConfig || packageConfig, - packageInfo - ); - setValidationResults(newValidationResult); - // eslint-disable-next-line no-console - console.debug('Package config validation results', newValidationResult); + // Update package config method + const updatePackageConfig = useCallback( + (updatedFields: Partial) => { + const newPackageConfig = { + ...packageConfig, + ...updatedFields, + }; + setPackageConfig(newPackageConfig); - return newValidationResult; - } - }; + // eslint-disable-next-line no-console + console.debug('Package config updated', newPackageConfig); + const newValidationResults = updatePackageConfigValidation(newPackageConfig); + const hasValidationErrors = newValidationResults + ? validationHasErrors(newValidationResults) + : false; + if (!hasValidationErrors) { + setFormState('VALID'); + } + }, + [packageConfig, updatePackageConfigValidation] + ); // Cancel url const cancelUrl = getHref('configuration_details', { configId }); @@ -271,6 +277,40 @@ export const EditPackageConfigPage: React.FunctionComponent = () => { packageInfo, }; + const configurePackage = useMemo( + () => + agentConfig && packageInfo ? ( + <> + + + + + ) : null, + [ + agentConfig, + formState, + packageConfig, + packageConfigId, + packageInfo, + updatePackageConfig, + validationResults, + ] + ); + return ( {isLoadingData ? ( @@ -301,46 +341,7 @@ export const EditPackageConfigPage: React.FunctionComponent = () => { onCancel={() => setFormState('VALID')} /> )} - - ), - }, - { - title: i18n.translate( - 'xpack.ingestManager.editPackageConfig.stepConfigurePackageConfigTitle', - { - defaultMessage: 'Select the data you want to collect', - } - ), - children: ( - - ), - }, - ]} - /> + {configurePackage} {/* TODO #64541 - Remove classes */} { : undefined } > - + - + {agentConfig && packageInfo && formState === 'INVALID' ? ( - + ) : null} - - - + + + + + + + + + + + + diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/create_config.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/create_config.tsx index d1abd88adba86..795c46ec282c5 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/create_config.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/create_config.tsx @@ -18,12 +18,12 @@ import { EuiButton, EuiText, } from '@elastic/eui'; -import { NewAgentConfig } from '../../../../types'; +import { NewAgentConfig, AgentConfig } from '../../../../types'; import { useCapabilities, useCore, sendCreateAgentConfig } from '../../../../hooks'; import { AgentConfigForm, agentConfigFormValidation } from '../../components'; interface Props { - onClose: () => void; + onClose: (createdAgentConfig?: AgentConfig) => void; } export const CreateAgentConfigFlyout: React.FunctionComponent = ({ onClose }) => { @@ -86,7 +86,7 @@ export const CreateAgentConfigFlyout: React.FunctionComponent = ({ onClos - + onClose()} flush="left"> = ({ onClos } ) ); - onClose(); + onClose(data.item); } else { notifications.toasts.addDanger( error diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts index 170a9cedc08d9..dc27da18bc008 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/types/index.ts @@ -18,6 +18,7 @@ export { UpdatePackageConfig, PackageConfigInput, PackageConfigInputStream, + PackageConfigConfigRecord, PackageConfigConfigRecordEntry, Output, DataStream, diff --git a/x-pack/plugins/ingest_manager/server/services/package_config.ts b/x-pack/plugins/ingest_manager/server/services/package_config.ts index 9433a81e74b07..e8ca09a83c2b6 100644 --- a/x-pack/plugins/ingest_manager/server/services/package_config.ts +++ b/x-pack/plugins/ingest_manager/server/services/package_config.ts @@ -44,6 +44,20 @@ class PackageConfigService { packageConfig: NewPackageConfig, options?: { id?: string; user?: AuthenticatedUser } ): Promise { + // Check that its agent config does not have a package config with the same name + const parentAgentConfig = await agentConfigService.get(soClient, packageConfig.config_id); + if (!parentAgentConfig) { + throw new Error('Agent config not found'); + } else { + if ( + (parentAgentConfig.package_configs as PackageConfig[]).find( + (siblingPackageConfig) => siblingPackageConfig.name === packageConfig.name + ) + ) { + throw new Error('There is already a package with the same name on this agent config'); + } + } + // Make sure the associated package is installed if (packageConfig.package?.name) { const [, pkgInfo] = await Promise.all([ @@ -225,6 +239,21 @@ class PackageConfigService { throw new Error('Package config not found'); } + // Check that its agent config does not have a package config with the same name + const parentAgentConfig = await agentConfigService.get(soClient, packageConfig.config_id); + if (!parentAgentConfig) { + throw new Error('Agent config not found'); + } else { + if ( + (parentAgentConfig.package_configs as PackageConfig[]).find( + (siblingPackageConfig) => + siblingPackageConfig.id !== id && siblingPackageConfig.name === packageConfig.name + ) + ) { + throw new Error('There is already a package with the same name on this agent config'); + } + } + await soClient.update( SAVED_OBJECT_TYPE, id, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 4118053396e90..97afa9e058b98 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -8173,7 +8173,6 @@ "xpack.ingestManager.createPackageConfig.agentConfigurationNameLabel": "構成", "xpack.ingestManager.createPackageConfig.cancelButton": "キャンセル", "xpack.ingestManager.createPackageConfig.cancelLinkText": "キャンセル", - "xpack.ingestManager.createPackageConfig.packageNameLabel": "統合", "xpack.ingestManager.createPackageConfig.pageDescriptionfromConfig": "次の手順に従い、統合をこのエージェント構成に追加します。", "xpack.ingestManager.createPackageConfig.pageDescriptionfromPackage": "次の手順に従い、この統合をエージェント構成に追加します。", "xpack.ingestManager.createPackageConfig.pageTitle": "データソースを追加", @@ -8184,19 +8183,12 @@ "xpack.ingestManager.createPackageConfig.stepConfigure.packageConfigNameInputLabel": "データソース名", "xpack.ingestManager.createPackageConfig.stepConfigure.packageConfigNamespaceInputLabel": "名前空間", "xpack.ingestManager.createPackageConfig.stepConfigure.hideStreamsAriaLabel": "{type} ストリームを隠す", - "xpack.ingestManager.createPackageConfig.stepConfigure.inputConfigErrorsTooltip": "構成エラーを修正してください", - "xpack.ingestManager.createPackageConfig.stepConfigure.inputLevelErrorsTooltip": "構成エラーを修正してください", "xpack.ingestManager.createPackageConfig.stepConfigure.inputSettingsDescription": "次の設定はすべてのストリームに適用されます。", "xpack.ingestManager.createPackageConfig.stepConfigure.inputSettingsTitle": "設定", "xpack.ingestManager.createPackageConfig.stepConfigure.inputVarFieldOptionalLabel": "オプション", "xpack.ingestManager.createPackageConfig.stepConfigure.noConfigOptionsMessage": "構成するものがありません", "xpack.ingestManager.createPackageConfig.stepConfigure.showStreamsAriaLabel": "{type} ストリームを表示", - "xpack.ingestManager.createPackageConfig.stepConfigure.streamLevelErrorsTooltip": "構成エラーを修正してください", - "xpack.ingestManager.createPackageConfig.stepConfigure.streamsEnabledCountText": "{count} / {total, plural, one {# ストリーム} other {# ストリーム}}が有効です", "xpack.ingestManager.createPackageConfig.stepConfigure.toggleAdvancedOptionsButtonText": "高度なオプション", - "xpack.ingestManager.createPackageConfig.stepConfigure.validationErrorText": "続行する前に、上記のエラーを修正してください", - "xpack.ingestManager.createPackageConfig.stepConfigure.validationErrorTitle": "データソース構成にエラーがあります", - "xpack.ingestManager.createPackageConfig.stepDefinePackageConfigTitle": "データソースを定義", "xpack.ingestManager.createPackageConfig.stepSelectAgentConfigTitle": "エージェント構成を選択する", "xpack.ingestManager.createPackageConfig.StepSelectConfig.agentConfigAgentsCountText": "{count, plural, one {# エージェント} other {# エージェント}}", "xpack.ingestManager.createPackageConfig.StepSelectConfig.errorLoadingAgentConfigsTitle": "エージェント構成の読み込みエラー", @@ -8268,8 +8260,6 @@ "xpack.ingestManager.editPackageConfig.pageDescription": "次の手順に従い、このデータソースを編集します。", "xpack.ingestManager.editPackageConfig.pageTitle": "データソースを編集", "xpack.ingestManager.editPackageConfig.saveButton": "データソースを保存", - "xpack.ingestManager.editPackageConfig.stepConfigurePackageConfigTitle": "収集するデータを選択", - "xpack.ingestManager.editPackageConfig.stepDefinePackageConfigTitle": "データソースを定義", "xpack.ingestManager.editPackageConfig.updatedNotificationMessage": "フリートは'{agentConfigName}'構成で使用されているすべてのエージェントに更新をデプロイします。", "xpack.ingestManager.editPackageConfig.updatedNotificationTitle": "正常に'{packageConfigName}'を更新しました", "xpack.ingestManager.enrollemntAPIKeyList.emptyMessage": "登録トークンが見つかりません。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 01939bea417d4..31a33bfa09b64 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -8178,7 +8178,6 @@ "xpack.ingestManager.createPackageConfig.agentConfigurationNameLabel": "配置", "xpack.ingestManager.createPackageConfig.cancelButton": "取消", "xpack.ingestManager.createPackageConfig.cancelLinkText": "取消", - "xpack.ingestManager.createPackageConfig.packageNameLabel": "集成", "xpack.ingestManager.createPackageConfig.pageDescriptionfromConfig": "按照下面的说明将集成添加此代理配置。", "xpack.ingestManager.createPackageConfig.pageDescriptionfromPackage": "按照下面的说明将此集成添加代理配置。", "xpack.ingestManager.createPackageConfig.pageTitle": "添加数据源", @@ -8189,19 +8188,12 @@ "xpack.ingestManager.createPackageConfig.stepConfigure.packageConfigNameInputLabel": "数据源名称", "xpack.ingestManager.createPackageConfig.stepConfigure.packageConfigNamespaceInputLabel": "命名空间", "xpack.ingestManager.createPackageConfig.stepConfigure.hideStreamsAriaLabel": "隐藏 {type} 流", - "xpack.ingestManager.createPackageConfig.stepConfigure.inputConfigErrorsTooltip": "解决配置错误", - "xpack.ingestManager.createPackageConfig.stepConfigure.inputLevelErrorsTooltip": "解决配置错误", "xpack.ingestManager.createPackageConfig.stepConfigure.inputSettingsDescription": "以下设置适用于所有流。", "xpack.ingestManager.createPackageConfig.stepConfigure.inputSettingsTitle": "设置", "xpack.ingestManager.createPackageConfig.stepConfigure.inputVarFieldOptionalLabel": "可选", "xpack.ingestManager.createPackageConfig.stepConfigure.noConfigOptionsMessage": "没有可配置的内容", "xpack.ingestManager.createPackageConfig.stepConfigure.showStreamsAriaLabel": "显示 {type} 流", - "xpack.ingestManager.createPackageConfig.stepConfigure.streamLevelErrorsTooltip": "解决配置错误", - "xpack.ingestManager.createPackageConfig.stepConfigure.streamsEnabledCountText": "{count} / {total, plural, one {# 个流} other {# 个流}}已启用", "xpack.ingestManager.createPackageConfig.stepConfigure.toggleAdvancedOptionsButtonText": "高级选项", - "xpack.ingestManager.createPackageConfig.stepConfigure.validationErrorText": "在继续之前请解决上述错误", - "xpack.ingestManager.createPackageConfig.stepConfigure.validationErrorTitle": "您的数据源配置有错误", - "xpack.ingestManager.createPackageConfig.stepDefinePackageConfigTitle": "定义您的数据源", "xpack.ingestManager.createPackageConfig.stepSelectAgentConfigTitle": "选择代理配置", "xpack.ingestManager.createPackageConfig.StepSelectConfig.agentConfigAgentsCountText": "{count, plural, one {# 个代理} other {# 个代理}}", "xpack.ingestManager.createPackageConfig.StepSelectConfig.errorLoadingAgentConfigsTitle": "加载代理配置时出错", @@ -8273,8 +8265,6 @@ "xpack.ingestManager.editPackageConfig.pageDescription": "按照下面的说明编辑此数据源。", "xpack.ingestManager.editPackageConfig.pageTitle": "编辑数据源", "xpack.ingestManager.editPackageConfig.saveButton": "保存数据源", - "xpack.ingestManager.editPackageConfig.stepConfigurePackageConfigTitle": "选择要收集的数据", - "xpack.ingestManager.editPackageConfig.stepDefinePackageConfigTitle": "定义您的数据源", "xpack.ingestManager.editPackageConfig.updatedNotificationMessage": "Fleet 会将更新部署到所有使用配置“{agentConfigName}”的代理", "xpack.ingestManager.editPackageConfig.updatedNotificationTitle": "已成功更新“{packageConfigName}”", "xpack.ingestManager.enrollemntAPIKeyList.emptyMessage": "未找到任何注册令牌。", diff --git a/x-pack/test/ingest_manager_api_integration/apis/index.js b/x-pack/test/ingest_manager_api_integration/apis/index.js index 30c49140c6e2a..81848917f9b05 100644 --- a/x-pack/test/ingest_manager_api_integration/apis/index.js +++ b/x-pack/test/ingest_manager_api_integration/apis/index.js @@ -17,5 +17,6 @@ export default function ({ loadTestFile }) { // Package configs loadTestFile(require.resolve('./package_config/create')); + loadTestFile(require.resolve('./package_config/update')); }); } diff --git a/x-pack/test/ingest_manager_api_integration/apis/package_config/create.ts b/x-pack/test/ingest_manager_api_integration/apis/package_config/create.ts index c7748ab255f43..cae4ff79bdef6 100644 --- a/x-pack/test/ingest_manager_api_integration/apis/package_config/create.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/package_config/create.ts @@ -126,5 +126,48 @@ export default function ({ getService }: FtrProviderContext) { warnAndSkipTest(this, log); } }); + + it('should return a 500 if there is another package config with the same name', async function () { + if (server.enabled) { + await supertest + .post(`/api/ingest_manager/package_configs`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'same-name-test-1', + description: '', + namespace: 'default', + config_id: agentConfigId, + enabled: true, + output_id: '', + inputs: [], + package: { + name: 'filetest', + title: 'For File Tests', + version: '0.1.0', + }, + }) + .expect(200); + await supertest + .post(`/api/ingest_manager/package_configs`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'same-name-test-1', + description: '', + namespace: 'default', + config_id: agentConfigId, + enabled: true, + output_id: '', + inputs: [], + package: { + name: 'filetest', + title: 'For File Tests', + version: '0.1.0', + }, + }) + .expect(500); + } else { + warnAndSkipTest(this, log); + } + }); }); } diff --git a/x-pack/test/ingest_manager_api_integration/apis/package_config/update.ts b/x-pack/test/ingest_manager_api_integration/apis/package_config/update.ts new file mode 100644 index 0000000000000..0251fef5f767c --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/package_config/update.ts @@ -0,0 +1,127 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; +import { warnAndSkipTest } from '../../helpers'; + +export default function ({ getService }: FtrProviderContext) { + const log = getService('log'); + const supertest = getService('supertest'); + const dockerServers = getService('dockerServers'); + + const server = dockerServers.get('registry'); + // use function () {} and not () => {} here + // because `this` has to point to the Mocha context + // see https://mochajs.org/#arrow-functions + + describe('Package Config - update', async function () { + let agentConfigId: string; + let packageConfigId: string; + let packageConfigId2: string; + + before(async function () { + const { body: agentConfigResponse } = await supertest + .post(`/api/ingest_manager/agent_configs`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'Test config', + namespace: 'default', + }); + agentConfigId = agentConfigResponse.item.id; + + const { body: packageConfigResponse } = await supertest + .post(`/api/ingest_manager/package_configs`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'filetest-1', + description: '', + namespace: 'default', + config_id: agentConfigId, + enabled: true, + output_id: '', + inputs: [], + package: { + name: 'filetest', + title: 'For File Tests', + version: '0.1.0', + }, + }); + packageConfigId = packageConfigResponse.item.id; + + const { body: packageConfigResponse2 } = await supertest + .post(`/api/ingest_manager/package_configs`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'filetest-2', + description: '', + namespace: 'default', + config_id: agentConfigId, + enabled: true, + output_id: '', + inputs: [], + package: { + name: 'filetest', + title: 'For File Tests', + version: '0.1.0', + }, + }); + packageConfigId2 = packageConfigResponse2.item.id; + }); + + it('should work with valid values', async function () { + if (server.enabled) { + const { body: apiResponse } = await supertest + .put(`/api/ingest_manager/package_configs/${packageConfigId}`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'filetest-1', + description: '', + namespace: 'updated_namespace', + config_id: agentConfigId, + enabled: true, + output_id: '', + inputs: [], + package: { + name: 'filetest', + title: 'For File Tests', + version: '0.1.0', + }, + }) + .expect(200); + + expect(apiResponse.success).to.be(true); + } else { + warnAndSkipTest(this, log); + } + }); + + it('should return a 500 if there is another package config with the same name', async function () { + if (server.enabled) { + await supertest + .put(`/api/ingest_manager/package_configs/${packageConfigId2}`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'filetest-1', + description: '', + namespace: 'updated_namespace', + config_id: agentConfigId, + enabled: true, + output_id: '', + inputs: [], + package: { + name: 'filetest', + title: 'For File Tests', + version: '0.1.0', + }, + }) + .expect(500); + } else { + warnAndSkipTest(this, log); + } + }); + }); +} From 8a4d0d06a5edcc8d3679a3a04964c87ce1a7cd27 Mon Sep 17 00:00:00 2001 From: spalger Date: Mon, 13 Jul 2020 08:16:26 -0700 Subject: [PATCH 07/40] add old .chromium to gitignore to prevent it from being accidentally committed --- x-pack/.gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/.gitignore b/x-pack/.gitignore index e181caf2b1a49..0c916ef0e9b91 100644 --- a/x-pack/.gitignore +++ b/x-pack/.gitignore @@ -6,6 +6,7 @@ /test/page_load_metrics/screenshots /test/functional/apps/reporting/reports/session /test/reporting/configs/failure_debug/ +/plugins/reporting/.chromium/ /legacy/plugins/reporting/.chromium/ /legacy/plugins/reporting/.phantom/ /plugins/reporting/chromium/ From 327fed87bbb369b3061de3862a1ceefcbfa1e5ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Louv-Jansen?= Date: Mon, 13 Jul 2020 17:21:22 +0200 Subject: [PATCH 08/40] [APM] Improvements to the ML Settings page (#71309) --- .../Settings/anomaly_detection/add_environments.tsx | 8 +++++++- .../app/Settings/anomaly_detection/index.tsx | 8 ++++++-- .../app/Settings/anomaly_detection/jobs_list.tsx | 12 +++++++----- .../anomaly_detection/get_anomaly_detection_jobs.ts | 2 +- ...obs_by_group.ts => get_ml_jobs_with_apm_group.ts} | 0 .../server/lib/anomaly_detection/has_legacy_jobs.ts | 2 +- .../apm/server/routes/settings/anomaly_detection.ts | 7 +++++-- x-pack/plugins/apm/typings/anomaly_detection.ts | 10 ---------- 8 files changed, 27 insertions(+), 22 deletions(-) rename x-pack/plugins/apm/server/lib/anomaly_detection/{get_ml_jobs_by_group.ts => get_ml_jobs_with_apm_group.ts} (100%) delete mode 100644 x-pack/plugins/apm/typings/anomaly_detection.ts diff --git a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/add_environments.tsx b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/add_environments.tsx index 98b4ae2f4b63f..4c056d48f4b14 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/add_environments.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/add_environments.tsx @@ -50,6 +50,8 @@ export const AddEnvironments = ({ disabled: currentEnvironments.includes(env), })); + const [isSaving, setIsSaving] = useState(false); + const [selectedOptions, setSelected] = useState< Array> >([]); @@ -127,9 +129,12 @@ export const AddEnvironments = ({ { + setIsSaving(true); + const selectedEnvironments = selectedOptions.map( ({ value }) => value as string ); @@ -140,6 +145,7 @@ export const AddEnvironments = ({ if (success) { onCreateJobSuccess(); } + setIsSaving(false); }} > {i18n.translate( diff --git a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/index.tsx index 6f985d06dba9d..f02350fafbabb 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/index.tsx @@ -15,7 +15,11 @@ import { LicensePrompt } from '../../../shared/LicensePrompt'; import { useLicense } from '../../../../hooks/useLicense'; import { APIReturnType } from '../../../../services/rest/createCallApmApi'; -const DEFAULT_VALUE: APIReturnType<'/api/apm/settings/anomaly-detection'> = { +export type AnomalyDetectionApiResponse = APIReturnType< + '/api/apm/settings/anomaly-detection' +>; + +const DEFAULT_VALUE: AnomalyDetectionApiResponse = { jobs: [], hasLegacyJobs: false, }; @@ -80,7 +84,7 @@ export const AnomalyDetection = () => { ) : ( { setViewAddEnvironments(true); diff --git a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx index 34687e5a8094e..5954b82f3b9e7 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx @@ -19,13 +19,15 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { FETCH_STATUS } from '../../../../hooks/useFetcher'; import { ITableColumn, ManagedTable } from '../../../shared/ManagedTable'; import { LoadingStatePrompt } from '../../../shared/LoadingStatePrompt'; -import { AnomalyDetectionJobByEnv } from '../../../../../typings/anomaly_detection'; import { MLJobLink } from '../../../shared/Links/MachineLearningLinks/MLJobLink'; import { MLLink } from '../../../shared/Links/MachineLearningLinks/MLLink'; import { getEnvironmentLabel } from '../../../../../common/environment_filter_values'; import { LegacyJobsCallout } from './legacy_jobs_callout'; +import { AnomalyDetectionApiResponse } from './index'; -const columns: Array> = [ +type Jobs = AnomalyDetectionApiResponse['jobs']; + +const columns: Array> = [ { field: 'environment', name: i18n.translate( @@ -57,13 +59,13 @@ const columns: Array> = [ interface Props { status: FETCH_STATUS; onAddEnvironments: () => void; - anomalyDetectionJobsByEnv: AnomalyDetectionJobByEnv[]; + jobs: Jobs; hasLegacyJobs: boolean; } export const JobsList = ({ status, onAddEnvironments, - anomalyDetectionJobsByEnv, + jobs, hasLegacyJobs, }: Props) => { const isLoading = @@ -128,7 +130,7 @@ export const JobsList = ({ ) } columns={columns} - items={isLoading || hasFetchFailure ? [] : anomalyDetectionJobsByEnv} + items={jobs} /> diff --git a/x-pack/plugins/apm/server/lib/anomaly_detection/get_anomaly_detection_jobs.ts b/x-pack/plugins/apm/server/lib/anomaly_detection/get_anomaly_detection_jobs.ts index 8fdebeb597eaf..13b30f159eed1 100644 --- a/x-pack/plugins/apm/server/lib/anomaly_detection/get_anomaly_detection_jobs.ts +++ b/x-pack/plugins/apm/server/lib/anomaly_detection/get_anomaly_detection_jobs.ts @@ -6,7 +6,7 @@ import { Logger } from 'kibana/server'; import { Setup } from '../helpers/setup_request'; -import { getMlJobsWithAPMGroup } from './get_ml_jobs_by_group'; +import { getMlJobsWithAPMGroup } from './get_ml_jobs_with_apm_group'; export async function getAnomalyDetectionJobs(setup: Setup, logger: Logger) { const { ml } = setup; diff --git a/x-pack/plugins/apm/server/lib/anomaly_detection/get_ml_jobs_by_group.ts b/x-pack/plugins/apm/server/lib/anomaly_detection/get_ml_jobs_with_apm_group.ts similarity index 100% rename from x-pack/plugins/apm/server/lib/anomaly_detection/get_ml_jobs_by_group.ts rename to x-pack/plugins/apm/server/lib/anomaly_detection/get_ml_jobs_with_apm_group.ts diff --git a/x-pack/plugins/apm/server/lib/anomaly_detection/has_legacy_jobs.ts b/x-pack/plugins/apm/server/lib/anomaly_detection/has_legacy_jobs.ts index bf502607fcc1d..999d28309121a 100644 --- a/x-pack/plugins/apm/server/lib/anomaly_detection/has_legacy_jobs.ts +++ b/x-pack/plugins/apm/server/lib/anomaly_detection/has_legacy_jobs.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { Setup } from '../helpers/setup_request'; -import { getMlJobsWithAPMGroup } from './get_ml_jobs_by_group'; +import { getMlJobsWithAPMGroup } from './get_ml_jobs_with_apm_group'; // Determine whether there are any legacy ml jobs. // A legacy ML job has a job id that ends with "high_mean_response_time" and created_by=ml-module-apm-transaction diff --git a/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts b/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts index 7009470e1ff17..4d564b773e397 100644 --- a/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts +++ b/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts @@ -18,10 +18,13 @@ export const anomalyDetectionJobsRoute = createRoute(() => ({ path: '/api/apm/settings/anomaly-detection', handler: async ({ context, request }) => { const setup = await setupRequest(context, request); - const jobs = await getAnomalyDetectionJobs(setup, context.logger); + const [jobs, legacyJobs] = await Promise.all([ + getAnomalyDetectionJobs(setup, context.logger), + hasLegacyJobs(setup), + ]); return { jobs, - hasLegacyJobs: await hasLegacyJobs(setup), + hasLegacyJobs: legacyJobs, }; }, })); diff --git a/x-pack/plugins/apm/typings/anomaly_detection.ts b/x-pack/plugins/apm/typings/anomaly_detection.ts deleted file mode 100644 index 30dc92c36dea4..0000000000000 --- a/x-pack/plugins/apm/typings/anomaly_detection.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export interface AnomalyDetectionJobByEnv { - environment: string; - job_id: string; -} From 24edc804c94a0df8a7a8ddece377978882d5c364 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 13 Jul 2020 16:30:03 +0100 Subject: [PATCH 09/40] Node options from cfg file for production (#62468) * chore(NA): load NODE_OPTIONS from options files across environments * chore(NA): move node.ci.options to config folder * docs(NA): update docs to explain how to set node options from the cfg fil * chore(NA): removed test npm scripts * fix(NA): typo on setup script for CI * chore(NA): add debug info * chore(NA): export options on CI * chore(NA): remove debug info * chore(NA): support for configurable config folder using env var * chore(NA): add node.options file into docker img * fix(NA): use calculated config dir on node options for ci * chore(NA): node bin scripts bootstrap and node_with_options implementation for bash * chore(NA): complete node_with_options scripts with bat version * chore(NA): add bin/node dev script and remove cli for run_with_node_options * chore(NA): increase default maxBuffer * chore(NA): remove run with options script from package.json * chore(NA): include kbn-node script and underlying usage of it * chore(NA): remove change on eslint * chore(NA): correct typo on kbn node script comment Co-authored-by: Tyler Smalley * chore(NA): correct typo on kbn node script comment Co-authored-by: Tyler Smalley * chore(NA): add line to describe each option should be specified in a separated line * chore(NA): remove node options from dev and ci env * chore(NA): remove changes from package.json * chore(NA): fix docker image build * chore(NA): change value for example of --max-old-space-size in the node.options file Co-authored-by: Tyler Smalley * chore(NA): remove --no-warnings from node.options and force it in the bin scripts * chore(NA): prevent 'The system cannot find the file' error message * chore(NA): introduce slash when building path for %DIR% * chore(NA): read options from file only if it exists Co-authored-by: Jonathan Budzenski Co-authored-by: Elastic Machine Co-authored-by: Tyler Smalley --- .gitignore | 1 + config/node.options | 6 +++++ docs/setup/production.asciidoc | 4 ++-- package.json | 2 +- src/dev/build/tasks/bin/scripts/kibana | 7 +++++- .../build/tasks/bin/scripts/kibana-keystore | 7 +++++- .../tasks/bin/scripts/kibana-keystore.bat | 17 ++++++++++++- src/dev/build/tasks/bin/scripts/kibana-plugin | 7 +++++- .../build/tasks/bin/scripts/kibana-plugin.bat | 23 +++++++++++++++--- src/dev/build/tasks/bin/scripts/kibana.bat | 24 +++++++++++++++++-- src/dev/build/tasks/copy_source_task.js | 1 + 11 files changed, 87 insertions(+), 12 deletions(-) create mode 100644 config/node.options diff --git a/.gitignore b/.gitignore index 716cea937f9c0..dfd02de7b1186 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ disabledPlugins webpackstats.json /config/* !/config/kibana.yml +!/config/node.options coverage selenium .babel_register_cache.json diff --git a/config/node.options b/config/node.options new file mode 100644 index 0000000000000..2927d1b576716 --- /dev/null +++ b/config/node.options @@ -0,0 +1,6 @@ +## Node command line options +## See `node --help` and `node --v8-options` for available options +## Please note you should specify one option per line + +## max size of old space in megabytes +#--max-old-space-size=4096 diff --git a/docs/setup/production.asciidoc b/docs/setup/production.asciidoc index 72f275e237490..afb4b37df6a28 100644 --- a/docs/setup/production.asciidoc +++ b/docs/setup/production.asciidoc @@ -167,9 +167,9 @@ These can be used to automatically update the list of hosts as a cluster is resi Kibana has a default maximum memory limit of 1.4 GB, and in most cases, we recommend leaving this unconfigured. In some scenarios, such as large reporting jobs, it may make sense to tweak limits to meet more specific requirements. -You can modify this limit by setting `--max-old-space-size` in the `NODE_OPTIONS` environment variable. For deb and rpm, packages this is passed in via `/etc/default/kibana` and can be appended to the bottom of the file. +You can modify this limit by setting `--max-old-space-size` in the `node.options` config file that can be found inside `kibana/config` folder or any other configured with the environment variable `KIBANA_PATH_CONF` (for example in debian based system would be `/etc/kibana`). The option accepts a limit in MB: -------- -NODE_OPTIONS="--max-old-space-size=2048" bin/kibana +--max-old-space-size=2048 -------- diff --git a/package.json b/package.json index 7889909b15244..7ab6bfb91a376 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "uiFramework:documentComponent": "cd packages/kbn-ui-framework && yarn documentComponent", "kbn:watch": "node scripts/kibana --dev --logging.json=false", "build:types": "tsc --p tsconfig.types.json", - "docs:acceptApiChanges": "node --max-old-space-size=6144 scripts/check_published_api_changes.js --accept", + "docs:acceptApiChanges": "node --max-old-space-size=6144 scripts/check_published_api_changes.js --accept", "kbn:bootstrap": "node scripts/register_git_hook", "spec_to_console": "node scripts/spec_to_console", "backport-skip-ci": "backport --prDescription \"[skip-ci]\"", diff --git a/src/dev/build/tasks/bin/scripts/kibana b/src/dev/build/tasks/bin/scripts/kibana index 558facb9da32b..3283e17008e7c 100755 --- a/src/dev/build/tasks/bin/scripts/kibana +++ b/src/dev/build/tasks/bin/scripts/kibana @@ -14,6 +14,7 @@ while [ -h "$SCRIPT" ] ; do done DIR="$(dirname "${SCRIPT}")/.." +CONFIG_DIR=${KIBANA_PATH_CONF:-"$DIR/config"} NODE="${DIR}/node/bin/node" test -x "$NODE" if [ ! -x "$NODE" ]; then @@ -21,4 +22,8 @@ if [ ! -x "$NODE" ]; then exit 1 fi -NODE_OPTIONS="--no-warnings --max-http-header-size=65536 ${NODE_OPTIONS}" NODE_ENV=production exec "${NODE}" "${DIR}/src/cli" ${@} +if [ -f "${CONFIG_DIR}/node.options" ]; then + KBN_NODE_OPTS="$(grep -v ^# < ${CONFIG_DIR}/node.options | xargs)" +fi + +NODE_OPTIONS="--no-warnings --max-http-header-size=65536 $KBN_NODE_OPTS $NODE_OPTIONS" NODE_ENV=production exec "${NODE}" "${DIR}/src/cli" ${@} diff --git a/src/dev/build/tasks/bin/scripts/kibana-keystore b/src/dev/build/tasks/bin/scripts/kibana-keystore index 43800c7b895d3..f83df118d24e8 100755 --- a/src/dev/build/tasks/bin/scripts/kibana-keystore +++ b/src/dev/build/tasks/bin/scripts/kibana-keystore @@ -14,6 +14,7 @@ while [ -h "$SCRIPT" ] ; do done DIR="$(dirname "${SCRIPT}")/.." +CONFIG_DIR=${KIBANA_PATH_CONF:-"$DIR/config"} NODE="${DIR}/node/bin/node" test -x "$NODE" if [ ! -x "$NODE" ]; then @@ -21,4 +22,8 @@ if [ ! -x "$NODE" ]; then exit 1 fi -"${NODE}" "${DIR}/src/cli_keystore" "$@" +if [ -f "${CONFIG_DIR}/node.options" ]; then + KBN_NODE_OPTS="$(grep -v ^# < ${CONFIG_DIR}/node.options | xargs)" +fi + +NODE_OPTIONS="$KBN_NODE_OPTS $NODE_OPTIONS" "${NODE}" "${DIR}/src/cli_keystore" "$@" diff --git a/src/dev/build/tasks/bin/scripts/kibana-keystore.bat b/src/dev/build/tasks/bin/scripts/kibana-keystore.bat index b8311db2cfae5..389eb5bf488e4 100644 --- a/src/dev/build/tasks/bin/scripts/kibana-keystore.bat +++ b/src/dev/build/tasks/bin/scripts/kibana-keystore.bat @@ -1,6 +1,6 @@ @echo off -SETLOCAL +SETLOCAL ENABLEDELAYEDEXPANSION set SCRIPT_DIR=%~dp0 for %%I in ("%SCRIPT_DIR%..") do set DIR=%%~dpfI @@ -12,6 +12,21 @@ If Not Exist "%NODE%" ( Exit /B 1 ) +set CONFIG_DIR=%KIBANA_PATH_CONF% +If [%KIBANA_PATH_CONF%] == [] ( + set CONFIG_DIR=%DIR%\config +) + +IF EXIST "%CONFIG_DIR%\node.options" ( + for /F "eol=# tokens=*" %%i in (%CONFIG_DIR%\node.options) do ( + If [!NODE_OPTIONS!] == [] ( + set "NODE_OPTIONS=%%i" + ) Else ( + set "NODE_OPTIONS=!NODE_OPTIONS! %%i" + ) + ) +) + TITLE Kibana Keystore "%NODE%" "%DIR%\src\cli_keystore" %* diff --git a/src/dev/build/tasks/bin/scripts/kibana-plugin b/src/dev/build/tasks/bin/scripts/kibana-plugin index b843d4966c6d1..f1102e1ef5a32 100755 --- a/src/dev/build/tasks/bin/scripts/kibana-plugin +++ b/src/dev/build/tasks/bin/scripts/kibana-plugin @@ -14,6 +14,7 @@ while [ -h "$SCRIPT" ] ; do done DIR="$(dirname "${SCRIPT}")/.." +CONFIG_DIR=${KIBANA_PATH_CONF:-"$DIR/config"} NODE="${DIR}/node/bin/node" test -x "$NODE" if [ ! -x "$NODE" ]; then @@ -21,4 +22,8 @@ if [ ! -x "$NODE" ]; then exit 1 fi -NODE_OPTIONS="--no-warnings ${NODE_OPTIONS}" NODE_ENV=production exec "${NODE}" "${DIR}/src/cli_plugin" "$@" +if [ -f "${CONFIG_DIR}/node.options" ]; then + KBN_NODE_OPTS="$(grep -v ^# < ${CONFIG_DIR}/node.options | xargs)" +fi + +NODE_OPTIONS="--no-warnings $KBN_NODE_OPTS $NODE_OPTIONS" NODE_ENV=production exec "${NODE}" "${DIR}/src/cli_plugin" "$@" diff --git a/src/dev/build/tasks/bin/scripts/kibana-plugin.bat b/src/dev/build/tasks/bin/scripts/kibana-plugin.bat index bf382a0657ade..6815b1b9eab8c 100755 --- a/src/dev/build/tasks/bin/scripts/kibana-plugin.bat +++ b/src/dev/build/tasks/bin/scripts/kibana-plugin.bat @@ -1,6 +1,6 @@ @echo off -SETLOCAL +SETLOCAL ENABLEDELAYEDEXPANSION set SCRIPT_DIR=%~dp0 for %%I in ("%SCRIPT_DIR%..") do set DIR=%%~dpfI @@ -13,9 +13,26 @@ If Not Exist "%NODE%" ( Exit /B 1 ) -TITLE Kibana Server +set CONFIG_DIR=%KIBANA_PATH_CONF% +If [%KIBANA_PATH_CONF%] == [] ( + set CONFIG_DIR=%DIR%\config +) + +IF EXIST "%CONFIG_DIR%\node.options" ( + for /F "eol=# tokens=*" %%i in (%CONFIG_DIR%\node.options) do ( + If [!NODE_OPTIONS!] == [] ( + set "NODE_OPTIONS=%%i" + ) Else ( + set "NODE_OPTIONS=!NODE_OPTIONS! %%i" + ) + ) +) + +:: Include pre-defined node option +set "NODE_OPTIONS=--no-warnings %NODE_OPTIONS%" -set "NODE_OPTIONS=--no-warnings %NODE_OPTIONS%" && "%NODE%" "%DIR%\src\cli_plugin" %* +TITLE Kibana Server +"%NODE%" "%DIR%\src\cli_plugin" %* :finally diff --git a/src/dev/build/tasks/bin/scripts/kibana.bat b/src/dev/build/tasks/bin/scripts/kibana.bat index 9d8ba359e53af..d3edc92f110a5 100755 --- a/src/dev/build/tasks/bin/scripts/kibana.bat +++ b/src/dev/build/tasks/bin/scripts/kibana.bat @@ -1,6 +1,6 @@ @echo off -SETLOCAL +SETLOCAL ENABLEDELAYEDEXPANSION set SCRIPT_DIR=%~dp0 for %%I in ("%SCRIPT_DIR%..") do set DIR=%%~dpfI @@ -14,7 +14,27 @@ If Not Exist "%NODE%" ( Exit /B 1 ) -set "NODE_OPTIONS=--no-warnings --max-http-header-size=65536 %NODE_OPTIONS%" && "%NODE%" "%DIR%\src\cli" %* +set CONFIG_DIR=%KIBANA_PATH_CONF% +If [%KIBANA_PATH_CONF%] == [] ( + set CONFIG_DIR=%DIR%\config +) + +IF EXIST "%CONFIG_DIR%\node.options" ( + for /F "eol=# tokens=*" %%i in (%CONFIG_DIR%\node.options) do ( + If [!NODE_OPTIONS!] == [] ( + set "NODE_OPTIONS=%%i" + ) Else ( + set "NODE_OPTIONS=!NODE_OPTIONS! %%i" + ) + ) +) + +:: Include pre-defined node option +set "NODE_OPTIONS=--no-warnings --max-http-header-size=65536 %NODE_OPTIONS%" + +:: This should run independently as the last instruction +:: as we need NODE_OPTIONS previously set to expand +"%NODE%" "%DIR%\src\cli" %* :finally diff --git a/src/dev/build/tasks/copy_source_task.js b/src/dev/build/tasks/copy_source_task.js index 32eb7bf8712e3..e34f05bd6cfff 100644 --- a/src/dev/build/tasks/copy_source_task.js +++ b/src/dev/build/tasks/copy_source_task.js @@ -43,6 +43,7 @@ export const CopySourceTask = { 'typings/**', 'webpackShims/**', 'config/kibana.yml', + 'config/node.options', 'tsconfig*.json', '.i18nrc.json', 'kibana.d.ts', From 17dc0439e21737cfd9c4a598028251771cdfb5e0 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Mon, 13 Jul 2020 11:33:51 -0400 Subject: [PATCH 10/40] [Ingest Manager] Add UI to enroll standalone agent (#71288) --- .../common/services/config_to_yaml.ts | 2 +- .../hooks/use_request/agent_config.ts | 11 ++ .../hooks/use_request/enrollment_api_keys.ts | 12 ++ .../config_selection.tsx | 163 ++++++++++------ .../agent_enrollment_flyout/index.tsx | 119 +++--------- .../managed_instructions.tsx | 91 +++++++++ .../standalone_instructions.tsx | 181 ++++++++++++++++++ .../agent_enrollment_flyout/steps.tsx | 66 +++++++ .../server/routes/agent_config/handlers.ts | 21 +- .../server/services/agent_config.ts | 10 +- .../server/types/rest_spec/agent_config.ts | 1 + .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 13 files changed, 516 insertions(+), 163 deletions(-) create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/managed_instructions.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/standalone_instructions.tsx create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/steps.tsx 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 c2043a40369e2..7e03e4572f9ee 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,8 +11,8 @@ const CONFIG_KEYS_ORDER = [ 'name', 'revision', 'type', - 'settings', 'outputs', + 'settings', 'inputs', 'enabled', 'use_output', diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agent_config.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agent_config.ts index 56b78c6faa93a..0bb09c2731032 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agent_config.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agent_config.ts @@ -48,6 +48,17 @@ export const useGetOneAgentConfigFull = (agentConfigId: string) => { }); }; +export const sendGetOneAgentConfigFull = ( + agentConfigId: string, + query: { standalone?: boolean } = {} +) => { + return sendRequest({ + path: agentConfigRouteService.getInfoFullPath(agentConfigId), + method: 'get', + query, + }); +}; + export const sendGetOneAgentConfig = (agentConfigId: string) => { return sendRequest({ path: agentConfigRouteService.getInfoPath(agentConfigId), diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/enrollment_api_keys.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/enrollment_api_keys.ts index 10d9e03e986e1..5a334e2739027 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/enrollment_api_keys.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/enrollment_api_keys.ts @@ -44,6 +44,18 @@ export function sendDeleteOneEnrollmentAPIKey(keyId: string, options?: RequestOp }); } +export function sendGetEnrollmentAPIKeys( + query: GetEnrollmentAPIKeysRequest['query'], + options?: RequestOptions +) { + return sendRequest({ + method: 'get', + path: enrollmentAPIKeyRouteService.getListPath(), + query, + ...options, + }); +} + export function useGetEnrollmentAPIKeys( query: GetEnrollmentAPIKeysRequest['query'], options?: RequestOptions diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/config_selection.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/config_selection.tsx index 8cd337586d1bc..6f53a237187e5 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/config_selection.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/config_selection.tsx @@ -4,46 +4,91 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiSelect, EuiSpacer, EuiText, EuiButtonEmpty } from '@elastic/eui'; -import { AgentConfig } from '../../../../types'; -import { useGetEnrollmentAPIKeys } from '../../../../hooks'; +import { AgentConfig, GetEnrollmentAPIKeysResponse } from '../../../../types'; +import { sendGetEnrollmentAPIKeys, useCore } from '../../../../hooks'; import { AgentConfigPackageBadges } from '../agent_config_package_badges'; -interface Props { +type Props = { agentConfigs: AgentConfig[]; - onKeyChange: (key: string) => void; -} + onConfigChange?: (key: string) => void; +} & ( + | { + withKeySelection: true; + onKeyChange?: (key: string) => void; + } + | { + withKeySelection: false; + } +); -export const EnrollmentStepAgentConfig: React.FC = ({ agentConfigs, onKeyChange }) => { - const [isAuthenticationSettingsOpen, setIsAuthenticationSettingsOpen] = useState(false); - const enrollmentAPIKeysRequest = useGetEnrollmentAPIKeys({ - page: 1, - perPage: 1000, - }); +export const EnrollmentStepAgentConfig: React.FC = (props) => { + const { notifications } = useCore(); + const { withKeySelection, agentConfigs, onConfigChange } = props; + const onKeyChange = props.withKeySelection && props.onKeyChange; + const [isAuthenticationSettingsOpen, setIsAuthenticationSettingsOpen] = useState(false); + const [enrollmentAPIKeys, setEnrollmentAPIKeys] = useState( + [] + ); const [selectedState, setSelectedState] = useState<{ agentConfigId?: string; enrollmentAPIKeyId?: string; }>({ agentConfigId: agentConfigs.length ? agentConfigs[0].id : undefined, }); - const filteredEnrollmentAPIKeys = React.useMemo(() => { - if (!selectedState.agentConfigId || !enrollmentAPIKeysRequest.data) { - return []; + + useEffect(() => { + if (onConfigChange && selectedState.agentConfigId) { + onConfigChange(selectedState.agentConfigId); } + }, [selectedState.agentConfigId, onConfigChange]); - return enrollmentAPIKeysRequest.data.list.filter( - (key) => key.config_id === selectedState.agentConfigId - ); - }, [enrollmentAPIKeysRequest.data, selectedState.agentConfigId]); + useEffect(() => { + if (!withKeySelection) { + return; + } + if (!selectedState.agentConfigId) { + setEnrollmentAPIKeys([]); + return; + } + + async function fetchEnrollmentAPIKeys() { + try { + const res = await sendGetEnrollmentAPIKeys({ + page: 1, + perPage: 10000, + }); + if (res.error) { + throw res.error; + } + + if (!res.data) { + throw new Error('No data while fetching enrollment API keys'); + } + + setEnrollmentAPIKeys( + res.data.list.filter((key) => key.config_id === selectedState.agentConfigId) + ); + } catch (error) { + notifications.toasts.addError(error, { + title: 'Error', + }); + } + } + fetchEnrollmentAPIKeys(); + }, [withKeySelection, selectedState.agentConfigId, notifications.toasts]); // Select first API key when config change React.useEffect(() => { - if (!selectedState.enrollmentAPIKeyId && filteredEnrollmentAPIKeys.length > 0) { - const enrollmentAPIKeyId = filteredEnrollmentAPIKeys[0].id; + if (!withKeySelection || !onKeyChange) { + return; + } + if (!selectedState.enrollmentAPIKeyId && enrollmentAPIKeys.length > 0) { + const enrollmentAPIKeyId = enrollmentAPIKeys[0].id; setSelectedState({ agentConfigId: selectedState.agentConfigId, enrollmentAPIKeyId, @@ -51,7 +96,7 @@ export const EnrollmentStepAgentConfig: React.FC = ({ agentConfigs, onKey onKeyChange(enrollmentAPIKeyId); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filteredEnrollmentAPIKeys, selectedState.enrollmentAPIKeyId, selectedState.agentConfigId]); + }, [enrollmentAPIKeys, selectedState.enrollmentAPIKeyId, selectedState.agentConfigId]); return ( <> @@ -85,43 +130,47 @@ export const EnrollmentStepAgentConfig: React.FC = ({ agentConfigs, onKey {selectedState.agentConfigId && ( )} - - setIsAuthenticationSettingsOpen(!isAuthenticationSettingsOpen)} - > - - - {isAuthenticationSettingsOpen && ( + {withKeySelection && onKeyChange && ( <> - ({ - value: key.id, - text: key.name, - }))} - value={selectedState.enrollmentAPIKeyId || undefined} - prepend={ - - - - } - onChange={(e) => { - setSelectedState({ - ...selectedState, - enrollmentAPIKeyId: e.target.value, - }); - onKeyChange(e.target.value); - }} - /> + setIsAuthenticationSettingsOpen(!isAuthenticationSettingsOpen)} + > + + + {isAuthenticationSettingsOpen && ( + <> + + ({ + value: key.id, + text: key.name, + }))} + value={selectedState.enrollmentAPIKeyId || undefined} + prepend={ + + + + } + onChange={(e) => { + setSelectedState({ + ...selectedState, + enrollmentAPIKeyId: e.target.value, + }); + onKeyChange(e.target.value); + }} + /> + + )} )} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/index.tsx index 43173124d6bae..5a9d3b7efe1bb 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/index.tsx @@ -14,23 +14,13 @@ import { EuiButtonEmpty, EuiButton, EuiFlyoutFooter, - EuiSteps, - EuiText, - EuiLink, + EuiTab, + EuiTabs, } from '@elastic/eui'; -import { EuiContainedStepProps } from '@elastic/eui/src/components/steps/steps'; -import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { AgentConfig } from '../../../../types'; -import { EnrollmentStepAgentConfig } from './config_selection'; -import { - useGetOneEnrollmentAPIKey, - useCore, - useGetSettings, - useLink, - useFleetStatus, -} from '../../../../hooks'; -import { ManualInstructions } from '../../../../components/enrollment_instructions'; +import { ManagedInstructions } from './managed_instructions'; +import { StandaloneInstructions } from './standalone_instructions'; interface Props { onClose: () => void; @@ -41,99 +31,40 @@ export const AgentEnrollmentFlyout: React.FunctionComponent = ({ onClose, agentConfigs = [], }) => { - const { getHref } = useLink(); - const core = useCore(); - const fleetStatus = useFleetStatus(); - - const [selectedAPIKeyId, setSelectedAPIKeyId] = useState(); - - const settings = useGetSettings(); - const apiKey = useGetOneEnrollmentAPIKey(selectedAPIKeyId); - - const kibanaUrl = - settings.data?.item?.kibana_url ?? `${window.location.origin}${core.http.basePath.get()}`; - const kibanaCASha256 = settings.data?.item?.kibana_ca_sha256; - - const steps: EuiContainedStepProps[] = [ - { - title: i18n.translate('xpack.ingestManager.agentEnrollment.stepDownloadAgentTitle', { - defaultMessage: 'Download the Elastic Agent', - }), - children: ( - - - - - ), - }} - /> - - ), - }, - { - title: i18n.translate('xpack.ingestManager.agentEnrollment.stepChooseAgentConfigTitle', { - defaultMessage: 'Choose an agent configuration', - }), - children: ( - - ), - }, - { - title: i18n.translate('xpack.ingestManager.agentEnrollment.stepRunAgentTitle', { - defaultMessage: 'Enroll and run the Elastic Agent', - }), - children: apiKey.data && ( - - ), - }, - ]; + const [mode, setMode] = useState<'managed' | 'standalone'>('managed'); return ( - +

+ + setMode('managed')}> + + + setMode('standalone')}> + + +
+ - {fleetStatus.isReady ? ( - <> - - + {mode === 'managed' ? ( + ) : ( - <> - - - - ), - }} - /> - + )} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/managed_instructions.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/managed_instructions.tsx new file mode 100644 index 0000000000000..aabbd37e809a8 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/managed_instructions.tsx @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { EuiSteps, EuiLink, EuiText, EuiSpacer } from '@elastic/eui'; +import { EuiContainedStepProps } from '@elastic/eui/src/components/steps/steps'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { AgentConfig } from '../../../../types'; +import { + useGetOneEnrollmentAPIKey, + useCore, + useGetSettings, + useLink, + useFleetStatus, +} from '../../../../hooks'; +import { ManualInstructions } from '../../../../components/enrollment_instructions'; +import { DownloadStep, AgentConfigSelectionStep } from './steps'; + +interface Props { + agentConfigs: AgentConfig[]; +} + +export const ManagedInstructions: React.FunctionComponent = ({ agentConfigs = [] }) => { + const { getHref } = useLink(); + const core = useCore(); + const fleetStatus = useFleetStatus(); + + const [selectedAPIKeyId, setSelectedAPIKeyId] = useState(); + + const settings = useGetSettings(); + const apiKey = useGetOneEnrollmentAPIKey(selectedAPIKeyId); + + const kibanaUrl = + settings.data?.item?.kibana_url ?? `${window.location.origin}${core.http.basePath.get()}`; + const kibanaCASha256 = settings.data?.item?.kibana_ca_sha256; + + const steps: EuiContainedStepProps[] = [ + DownloadStep(), + AgentConfigSelectionStep({ agentConfigs, setSelectedAPIKeyId }), + { + title: i18n.translate('xpack.ingestManager.agentEnrollment.stepEnrollAndRunAgentTitle', { + defaultMessage: 'Enroll and start the Elastic Agent', + }), + children: apiKey.data && ( + + ), + }, + ]; + + return ( + <> + + + + + {fleetStatus.isReady ? ( + <> + + + ) : ( + <> + + + + ), + }} + /> + + )}{' '} + + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/standalone_instructions.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/standalone_instructions.tsx new file mode 100644 index 0000000000000..27f64059deb84 --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/standalone_instructions.tsx @@ -0,0 +1,181 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, useEffect, useMemo } from 'react'; +import { + EuiSteps, + EuiText, + EuiSpacer, + EuiButton, + EuiCode, + EuiFlexItem, + EuiFlexGroup, + EuiCodeBlock, + EuiCopy, +} from '@elastic/eui'; +import { EuiContainedStepProps } from '@elastic/eui/src/components/steps/steps'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { AgentConfig } from '../../../../types'; +import { useCore, sendGetOneAgentConfigFull } from '../../../../hooks'; +import { DownloadStep, AgentConfigSelectionStep } from './steps'; +import { configToYaml, agentConfigRouteService } from '../../../../services'; + +interface Props { + agentConfigs: AgentConfig[]; +} + +const RUN_INSTRUCTIONS = './elastic-agent run'; + +export const StandaloneInstructions: React.FunctionComponent = ({ agentConfigs = [] }) => { + const core = useCore(); + const { notifications } = core; + + const [selectedConfigId, setSelectedConfigId] = useState(); + const [fullAgentConfig, setFullAgentConfig] = useState(); + + const downloadLink = selectedConfigId + ? core.http.basePath.prepend( + `${agentConfigRouteService.getInfoFullDownloadPath(selectedConfigId)}?standalone=true` + ) + : undefined; + + useEffect(() => { + async function fetchFullConfig() { + try { + if (!selectedConfigId) { + return; + } + const res = await sendGetOneAgentConfigFull(selectedConfigId, { standalone: true }); + if (res.error) { + throw res.error; + } + + if (!res.data) { + throw new Error('No data while fetching full agent config'); + } + + setFullAgentConfig(res.data.item); + } catch (error) { + notifications.toasts.addError(error, { + title: 'Error', + }); + } + } + fetchFullConfig(); + }, [selectedConfigId, notifications.toasts]); + + const yaml = useMemo(() => configToYaml(fullAgentConfig), [fullAgentConfig]); + const steps: EuiContainedStepProps[] = [ + DownloadStep(), + AgentConfigSelectionStep({ agentConfigs, setSelectedConfigId }), + { + title: i18n.translate('xpack.ingestManager.agentEnrollment.stepConfigureAgentTitle', { + defaultMessage: 'Configure the agent', + }), + children: ( + <> + + elastic-agent.yml, + ESUsernameVariable: ES_USERNAME, + ESPasswordVariable: ES_PASSWORD, + outputSection: outputs, + }} + /> + + + + + {(copy) => ( + + + + )} + + + + + + + + + + + {yaml} + + + + ), + }, + { + title: i18n.translate('xpack.ingestManager.agentEnrollment.stepRunAgentTitle', { + defaultMessage: 'Start the agent', + }), + children: ( + <> + + + + {RUN_INSTRUCTIONS} + + + {(copy) => ( + + + + )} + + + + ), + }, + { + title: i18n.translate('xpack.ingestManager.agentEnrollment.stepCheckForDataTitle', { + defaultMessage: 'Check for data', + }), + status: 'incomplete', + children: ( + <> + + + + + ), + }, + ]; + + return ( + <> + + + + + + + ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/steps.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/steps.tsx new file mode 100644 index 0000000000000..267f9027a094a --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/steps.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiText, EuiButton, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { EnrollmentStepAgentConfig } from './config_selection'; +import { AgentConfig } from '../../../../types'; + +export const DownloadStep = () => { + return { + title: i18n.translate('xpack.ingestManager.agentEnrollment.stepDownloadAgentTitle', { + defaultMessage: 'Download the Elastic Agent', + }), + children: ( + <> + + + + + + + + + ), + }; +}; + +export const AgentConfigSelectionStep = ({ + agentConfigs, + setSelectedAPIKeyId, + setSelectedConfigId, +}: { + agentConfigs: AgentConfig[]; + setSelectedAPIKeyId?: (key: string) => void; + setSelectedConfigId?: (configId: string) => void; +}) => { + return { + title: i18n.translate('xpack.ingestManager.agentEnrollment.stepChooseAgentConfigTitle', { + defaultMessage: 'Choose an agent configuration', + }), + children: ( + + ), + }; +}; diff --git a/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts index 110f6b9950829..2aaf889296bd6 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts @@ -232,15 +232,17 @@ export const deleteAgentConfigsHandler: RequestHandler< } }; -export const getFullAgentConfig: RequestHandler> = async (context, request, response) => { +export const getFullAgentConfig: RequestHandler< + TypeOf, + TypeOf +> = async (context, request, response) => { const soClient = context.core.savedObjects.client; try { const fullAgentConfig = await agentConfigService.getFullConfig( soClient, - request.params.agentConfigId + request.params.agentConfigId, + { standalone: request.query.standalone === true } ); if (fullAgentConfig) { const body: GetFullAgentConfigResponse = { @@ -264,16 +266,19 @@ export const getFullAgentConfig: RequestHandler> = async (context, request, response) => { +export const downloadFullAgentConfig: RequestHandler< + TypeOf, + TypeOf +> = async (context, request, response) => { const soClient = context.core.savedObjects.client; const { params: { agentConfigId }, } = request; try { - const fullAgentConfig = await agentConfigService.getFullConfig(soClient, agentConfigId); + const fullAgentConfig = await agentConfigService.getFullConfig(soClient, agentConfigId, { + standalone: request.query.standalone === true, + }); if (fullAgentConfig) { const body = configToYaml(fullAgentConfig); const headers: ResponseHeaders = { 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 fe247d5b91db0..5f98c8881388d 100644 --- a/x-pack/plugins/ingest_manager/server/services/agent_config.ts +++ b/x-pack/plugins/ingest_manager/server/services/agent_config.ts @@ -365,7 +365,8 @@ class AgentConfigService { public async getFullConfig( soClient: SavedObjectsClientContract, - id: string + id: string, + options?: { standalone: boolean } ): Promise { let config; @@ -400,6 +401,13 @@ class AgentConfigService { api_key, ...outputConfig, }; + + if (options?.standalone) { + delete outputs[name].api_key; + outputs[name].username = 'ES_USERNAME'; + outputs[name].password = 'ES_PASSWORD'; + } + return outputs; }, {} as FullAgentConfig['outputs'] diff --git a/x-pack/plugins/ingest_manager/server/types/rest_spec/agent_config.ts b/x-pack/plugins/ingest_manager/server/types/rest_spec/agent_config.ts index d076a803f4b53..594bd141459c1 100644 --- a/x-pack/plugins/ingest_manager/server/types/rest_spec/agent_config.ts +++ b/x-pack/plugins/ingest_manager/server/types/rest_spec/agent_config.ts @@ -51,5 +51,6 @@ export const GetFullAgentConfigRequestSchema = { }), query: schema.object({ download: schema.maybe(schema.boolean()), + standalone: schema.maybe(schema.boolean()), }), }; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 97afa9e058b98..e28ef8ff07bdd 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -8042,7 +8042,6 @@ "xpack.ingestManager.agentDetails.viewAgentListTitle": "すべてのエージェント構成を表示", "xpack.ingestManager.agentEnrollment.cancelButtonLabel": "キャンセル", "xpack.ingestManager.agentEnrollment.continueButtonLabel": "続行", - "xpack.ingestManager.agentEnrollment.downloadDescription": "ホストのコンピューターでElasticエージェントをダウンロードします。エージェントバイナリをダウンロードできます。検証署名はElasticの{downloadLink}にあります。", "xpack.ingestManager.agentEnrollment.downloadLink": "ダウンロードページ", "xpack.ingestManager.agentEnrollment.fleetNotInitializedText": "エージェントを登録する前に、フリートを設定する必要があります。{link}", "xpack.ingestManager.agentEnrollment.flyoutTitle": "新しいエージェントを登録", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 31a33bfa09b64..1df676ba7cffd 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -8047,7 +8047,6 @@ "xpack.ingestManager.agentDetails.viewAgentListTitle": "查看所有代理配置", "xpack.ingestManager.agentEnrollment.cancelButtonLabel": "取消", "xpack.ingestManager.agentEnrollment.continueButtonLabel": "继续", - "xpack.ingestManager.agentEnrollment.downloadDescription": "在主机计算机上下载 Elastic 代理。可以从 Elastic 的{downloadLink}下载代理二进制文件及其验证签名。", "xpack.ingestManager.agentEnrollment.downloadLink": "下载页面", "xpack.ingestManager.agentEnrollment.fleetNotInitializedText": "注册代理前需要设置 Fleet。{link}", "xpack.ingestManager.agentEnrollment.flyoutTitle": "注册新代理", From b435d4608036c89439d1cfec6349ac7bc22adbca Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 13 Jul 2020 16:43:40 +0100 Subject: [PATCH 11/40] skip flaky suite (#71361) --- .../cypress/integration/timeline_toggle_column.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_toggle_column.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_toggle_column.spec.ts index 12e6f3db9b61e..759eec69bc022 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timeline_toggle_column.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_toggle_column.spec.ts @@ -24,7 +24,8 @@ import { import { HOSTS_URL } from '../urls/navigation'; -describe('toggle column in timeline', () => { +// Flaky: https://github.com/elastic/kibana/issues/71361 +describe.skip('toggle column in timeline', () => { before(() => { loginAndWaitForPage(HOSTS_URL); }); From 2c19feb55fb45bd68bfa34eeb0c4ca5fda0726d9 Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Mon, 13 Jul 2020 10:51:30 -0500 Subject: [PATCH 12/40] [os packages] local permission adjustments (#66614) * outline permissions * rm keystore setup Co-authored-by: Elastic Machine --- src/dev/build/tasks/bin/scripts/kibana-keystore.bat | 0 .../tasks/os_packages/package_scripts/post_install.sh | 10 +++++++++- .../service_templates/sysv/etc/init.d/kibana | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) mode change 100644 => 100755 src/dev/build/tasks/bin/scripts/kibana-keystore.bat diff --git a/src/dev/build/tasks/bin/scripts/kibana-keystore.bat b/src/dev/build/tasks/bin/scripts/kibana-keystore.bat old mode 100644 new mode 100755 diff --git a/src/dev/build/tasks/os_packages/package_scripts/post_install.sh b/src/dev/build/tasks/os_packages/package_scripts/post_install.sh index 9cf08ea38254d..10f11ff51874e 100644 --- a/src/dev/build/tasks/os_packages/package_scripts/post_install.sh +++ b/src/dev/build/tasks/os_packages/package_scripts/post_install.sh @@ -1,6 +1,8 @@ #!/bin/sh set -e +export KBN_PATH_CONF=${KBN_PATH_CONF:-<%= configDir %>} + case $1 in # Debian configure) @@ -35,4 +37,10 @@ case $1 in esac chown -R <%= user %>:<%= group %> <%= dataDir %> -chown <%= user %>:<%= group %> <%= pluginsDir %> +chmod 2750 <%= dataDir %> +chmod -R 2755 <%= dataDir %>/* + +chown :<%= group %> ${KBN_PATH_CONF} +chown :<%= group %> ${KBN_PATH_CONF}/kibana.yml +chmod 2750 ${KBN_PATH_CONF} +chmod 660 ${KBN_PATH_CONF}/kibana.yml diff --git a/src/dev/build/tasks/os_packages/service_templates/sysv/etc/init.d/kibana b/src/dev/build/tasks/os_packages/service_templates/sysv/etc/init.d/kibana index d935dc6e31f80..8facbb709cc5c 100755 --- a/src/dev/build/tasks/os_packages/service_templates/sysv/etc/init.d/kibana +++ b/src/dev/build/tasks/os_packages/service_templates/sysv/etc/init.d/kibana @@ -39,7 +39,7 @@ emit() { start() { [ ! -d "/var/log/kibana/" ] && mkdir "/var/log/kibana/" chown "$user":"$group" "/var/log/kibana/" - chmod 755 "/var/log/kibana/" + chmod 2750 "/var/log/kibana/" [ ! -d "/var/run/kibana/" ] && mkdir "/var/run/kibana/" chown "$user":"$group" "/var/run/kibana/" From c44f01979019d32500856753ca35d67e542030c5 Mon Sep 17 00:00:00 2001 From: Thomas Neirynck Date: Mon, 13 Jul 2020 11:55:36 -0400 Subject: [PATCH 13/40] [Maps] Show joins disabled message (#70826) Show feedback in the layer-settings when the scaling-method does not support Term-joins. --- .../data_request_descriptor_types.ts | 5 +- .../maps/common/descriptor_types/sources.ts | 6 +- .../blended_vector_layer.ts | 16 ++- .../maps/public/classes/layers/layer.tsx | 22 ++-- .../es_geo_grid_source/es_geo_grid_source.js | 4 +- .../es_pew_pew_source/es_pew_pew_source.js | 2 +- .../es_search_source/es_search_source.js | 11 +- .../maps/public/classes/sources/source.ts | 10 +- .../sources/vector_source/vector_source.js | 2 +- .../__snapshots__/view.test.js.snap | 13 +- .../connected_components/layer_panel/index.js | 2 +- .../__snapshots__/join_editor.test.tsx.snap | 100 ++++++++++++++ .../layer_panel/join_editor/index.js | 31 ----- .../layer_panel/join_editor/index.tsx | 31 +++++ .../join_editor/join_editor.test.tsx | 63 +++++++++ .../layer_panel/join_editor/join_editor.tsx | 124 ++++++++++++++++++ .../layer_panel/join_editor/view.js | 103 --------------- .../connected_components/layer_panel/view.js | 5 +- .../layer_panel/view.test.js | 2 +- 19 files changed, 383 insertions(+), 169 deletions(-) create mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/__snapshots__/join_editor.test.tsx.snap delete mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/index.js create mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/index.tsx create mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/join_editor.test.tsx create mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/join_editor.tsx delete mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/view.js diff --git a/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts b/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts index c7bfe94742bd6..1bd8c5401eb1d 100644 --- a/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts +++ b/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts @@ -5,7 +5,7 @@ */ /* eslint-disable @typescript-eslint/consistent-type-definitions */ -import { RENDER_AS, SORT_ORDER, SCALING_TYPES } from '../constants'; +import { RENDER_AS, SORT_ORDER, SCALING_TYPES, SOURCE_TYPES } from '../constants'; import { MapExtent, MapQuery } from './map_descriptor'; import { Filter, TimeRange } from '../../../../../src/plugins/data/common'; @@ -26,10 +26,12 @@ type ESSearchSourceSyncMeta = { scalingType: SCALING_TYPES; topHitsSplitField: string; topHitsSize: number; + sourceType: SOURCE_TYPES.ES_SEARCH; }; type ESGeoGridSourceSyncMeta = { requestType: RENDER_AS; + sourceType: SOURCE_TYPES.ES_GEO_GRID; }; export type VectorSourceSyncMeta = ESSearchSourceSyncMeta | ESGeoGridSourceSyncMeta | null; @@ -51,7 +53,6 @@ export type VectorStyleRequestMeta = MapFilters & { export type ESSearchSourceResponseMeta = { areResultsTrimmed?: boolean; - sourceType?: string; // top hits meta areEntitiesTrimmed?: boolean; diff --git a/x-pack/plugins/maps/common/descriptor_types/sources.ts b/x-pack/plugins/maps/common/descriptor_types/sources.ts index e32b5f44c8272..7eda37bf53351 100644 --- a/x-pack/plugins/maps/common/descriptor_types/sources.ts +++ b/x-pack/plugins/maps/common/descriptor_types/sources.ts @@ -77,8 +77,8 @@ export type ESPewPewSourceDescriptor = AbstractESAggSourceDescriptor & { }; export type ESTermSourceDescriptor = AbstractESAggSourceDescriptor & { - indexPatternTitle: string; - term: string; // term field name + indexPatternTitle?: string; + term?: string; // term field name whereQuery?: Query; }; @@ -138,7 +138,7 @@ export type GeojsonFileSourceDescriptor = { }; export type JoinDescriptor = { - leftField: string; + leftField?: string; right: ESTermSourceDescriptor; }; diff --git a/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts index 551e20fc5ceb5..26a0ffc1b1a37 100644 --- a/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts +++ b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts @@ -126,7 +126,7 @@ function getClusterStyleDescriptor( ), } : undefined; - // @ts-ignore + // @ts-expect-error clusterStyleDescriptor.properties[styleName] = { type: STYLE_TYPE.DYNAMIC, options: { @@ -136,7 +136,7 @@ function getClusterStyleDescriptor( }; } else { // copy static styles to cluster style - // @ts-ignore + // @ts-expect-error clusterStyleDescriptor.properties[styleName] = { type: STYLE_TYPE.STATIC, options: { ...styleProperty.getOptions() }, @@ -192,8 +192,8 @@ export class BlendedVectorLayer extends VectorLayer implements IVectorLayer { const requestMeta = sourceDataRequest.getMeta(); if ( requestMeta && - requestMeta.sourceType && - requestMeta.sourceType === SOURCE_TYPES.ES_GEO_GRID + requestMeta.sourceMeta && + requestMeta.sourceMeta.sourceType === SOURCE_TYPES.ES_GEO_GRID ) { isClustered = true; } @@ -220,8 +220,12 @@ export class BlendedVectorLayer extends VectorLayer implements IVectorLayer { : displayName; } - isJoinable() { - return false; + showJoinEditor() { + return true; + } + + getJoinsDisabledReason() { + return this._documentSource.getJoinsDisabledReason(); } getJoins() { diff --git a/x-pack/plugins/maps/public/classes/layers/layer.tsx b/x-pack/plugins/maps/public/classes/layers/layer.tsx index d6f6ee8fa609b..d8def155a9185 100644 --- a/x-pack/plugins/maps/public/classes/layers/layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/layer.tsx @@ -78,6 +78,8 @@ export interface ILayer { isPreviewLayer: () => boolean; areLabelsOnTop: () => boolean; supportsLabelsOnTop: () => boolean; + showJoinEditor(): boolean; + getJoinsDisabledReason(): string | null; } export type Footnote = { icon: ReactElement; @@ -141,13 +143,12 @@ export class AbstractLayer implements ILayer { } static getBoundDataForSource(mbMap: unknown, sourceId: string): FeatureCollection { - // @ts-ignore + // @ts-expect-error const mbStyle = mbMap.getStyle(); return mbStyle.sources[sourceId].data; } async cloneDescriptor(): Promise { - // @ts-ignore const clonedDescriptor = copyPersistentState(this._descriptor); // layer id is uuid used to track styles/layers in mapbox clonedDescriptor.id = uuid(); @@ -155,14 +156,10 @@ export class AbstractLayer implements ILayer { clonedDescriptor.label = `Clone of ${displayName}`; clonedDescriptor.sourceDescriptor = this.getSource().cloneDescriptor(); - // todo: remove this - // This should not be in AbstractLayer. It relies on knowledge of VectorLayerDescriptor - // @ts-ignore if (clonedDescriptor.joins) { - // @ts-ignore + // @ts-expect-error clonedDescriptor.joins.forEach((joinDescriptor) => { // right.id is uuid used to track requests in inspector - // @ts-ignore joinDescriptor.right.id = uuid(); }); } @@ -173,8 +170,12 @@ export class AbstractLayer implements ILayer { return `${this.getId()}${MB_SOURCE_ID_LAYER_ID_PREFIX_DELIMITER}${layerNameSuffix}`; } - isJoinable(): boolean { - return this.getSource().isJoinable(); + showJoinEditor(): boolean { + return this.getSource().showJoinEditor(); + } + + getJoinsDisabledReason() { + return this.getSource().getJoinsDisabledReason(); } isPreviewLayer(): boolean { @@ -394,7 +395,6 @@ export class AbstractLayer implements ILayer { const requestTokens = this._dataRequests.map((dataRequest) => dataRequest.getRequestToken()); // Compact removes all the undefineds - // @ts-ignore return _.compact(requestTokens); } @@ -478,7 +478,7 @@ export class AbstractLayer implements ILayer { } syncVisibilityWithMb(mbMap: unknown, mbLayerId: string) { - // @ts-ignore + // @ts-expect-error mbMap.setLayoutProperty(mbLayerId, 'visibility', this.isVisible() ? 'visible' : 'none'); } diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.js b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.js index 9431fb55dc88b..1be74140fe1bf 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.js +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.js @@ -63,6 +63,7 @@ export class ESGeoGridSource extends AbstractESAggSource { getSyncMeta() { return { requestType: this._descriptor.requestType, + sourceType: SOURCE_TYPES.ES_GEO_GRID, }; } @@ -103,7 +104,7 @@ export class ESGeoGridSource extends AbstractESAggSource { return true; } - isJoinable() { + showJoinEditor() { return false; } @@ -307,7 +308,6 @@ export class ESGeoGridSource extends AbstractESAggSource { }, meta: { areResultsTrimmed: false, - sourceType: SOURCE_TYPES.ES_GEO_GRID, }, }; } diff --git a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js index a4cff7c89a011..98db7bcdcc8a3 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js +++ b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js @@ -51,7 +51,7 @@ export class ESPewPewSource extends AbstractESAggSource { return true; } - isJoinable() { + showJoinEditor() { return false; } diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.js b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.js index c8f14f1dc6a4b..330fa6e8318ed 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.js +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.js @@ -385,7 +385,7 @@ export class ESSearchSource extends AbstractESSource { return { data: featureCollection, - meta: { ...meta, sourceType: SOURCE_TYPES.ES_SEARCH }, + meta, }; } @@ -540,6 +540,7 @@ export class ESSearchSource extends AbstractESSource { scalingType: this._descriptor.scalingType, topHitsSplitField: this._descriptor.topHitsSplitField, topHitsSize: this._descriptor.topHitsSize, + sourceType: SOURCE_TYPES.ES_SEARCH, }; } @@ -551,6 +552,14 @@ export class ESSearchSource extends AbstractESSource { path: geoField.name, }; } + + getJoinsDisabledReason() { + return this._descriptor.scalingType === SCALING_TYPES.CLUSTERS + ? i18n.translate('xpack.maps.source.esSearch.joinsDisabledReason', { + defaultMessage: 'Joins are not supported when scaling by clusters', + }) + : null; + } } registerSource({ diff --git a/x-pack/plugins/maps/public/classes/sources/source.ts b/x-pack/plugins/maps/public/classes/sources/source.ts index c68e22ada8b0c..696c07376575b 100644 --- a/x-pack/plugins/maps/public/classes/sources/source.ts +++ b/x-pack/plugins/maps/public/classes/sources/source.ts @@ -54,7 +54,8 @@ export interface ISource { isESSource(): boolean; renderSourceSettingsEditor({ onChange }: SourceEditorArgs): ReactElement | null; supportsFitToBounds(): Promise; - isJoinable(): boolean; + showJoinEditor(): boolean; + getJoinsDisabledReason(): string | null; cloneDescriptor(): SourceDescriptor; getFieldNames(): string[]; getApplyGlobalQuery(): boolean; @@ -80,7 +81,6 @@ export class AbstractSource implements ISource { destroy(): void {} cloneDescriptor(): SourceDescriptor { - // @ts-ignore return copyPersistentState(this._descriptor); } @@ -148,10 +148,14 @@ export class AbstractSource implements ISource { return 0; } - isJoinable(): boolean { + showJoinEditor(): boolean { return false; } + getJoinsDisabledReason() { + return null; + } + isESSource(): boolean { return false; } diff --git a/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.js b/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.js index ecb13bb875721..98ed89a6ff0ad 100644 --- a/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.js +++ b/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.js @@ -122,7 +122,7 @@ export class AbstractVectorSource extends AbstractSource { return false; } - isJoinable() { + showJoinEditor() { return true; } diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/__snapshots__/view.test.js.snap b/x-pack/plugins/maps/public/connected_components/layer_panel/__snapshots__/view.test.js.snap index 1c48ed2290dce..2cf5287ae6594 100644 --- a/x-pack/plugins/maps/public/connected_components/layer_panel/__snapshots__/view.test.js.snap +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/__snapshots__/view.test.js.snap @@ -96,8 +96,8 @@ exports[`LayerPanel is rendered 1`] = ` "getId": [Function], "getImmutableSourceProperties": [Function], "getLayerTypeIconName": [Function], - "isJoinable": [Function], "renderSourceSettingsEditor": [Function], + "showJoinEditor": [Function], "supportsElasticsearchFilters": [Function], } } @@ -107,6 +107,17 @@ exports[`LayerPanel is rendered 1`] = ` diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/index.js b/x-pack/plugins/maps/public/connected_components/layer_panel/index.js index 1c8dcdb43d434..17fd41d120194 100644 --- a/x-pack/plugins/maps/public/connected_components/layer_panel/index.js +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/index.js @@ -12,7 +12,7 @@ import { updateSourceProp } from '../../actions'; function mapStateToProps(state = {}) { const selectedLayer = getSelectedLayer(state); return { - key: selectedLayer ? `${selectedLayer.getId()}${selectedLayer.isJoinable()}` : '', + key: selectedLayer ? `${selectedLayer.getId()}${selectedLayer.showJoinEditor()}` : '', selectedLayer, }; } diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/__snapshots__/join_editor.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/__snapshots__/join_editor.test.tsx.snap new file mode 100644 index 0000000000000..00d7f44d6273f --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/__snapshots__/join_editor.test.tsx.snap @@ -0,0 +1,100 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Should render callout when joins are disabled 1`] = ` +
+ +
+ + + +
+
+ + Simulated disabled reason + +
+`; + +exports[`Should render join editor 1`] = ` +
+ +
+ + + +
+
+ + + + + + + + +
+`; diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/index.js b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/index.js deleted file mode 100644 index cf55c16bbe0be..0000000000000 --- a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/index.js +++ /dev/null @@ -1,31 +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 { connect } from 'react-redux'; -import { JoinEditor } from './view'; -import { - getSelectedLayer, - getSelectedLayerJoinDescriptors, -} from '../../../selectors/map_selectors'; -import { setJoinsForLayer } from '../../../actions'; - -function mapDispatchToProps(dispatch) { - return { - onChange: (layer, joins) => { - dispatch(setJoinsForLayer(layer, joins)); - }, - }; -} - -function mapStateToProps(state = {}) { - return { - joins: getSelectedLayerJoinDescriptors(state), - layer: getSelectedLayer(state), - }; -} - -const connectedJoinEditor = connect(mapStateToProps, mapDispatchToProps)(JoinEditor); -export { connectedJoinEditor as JoinEditor }; diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/index.tsx b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/index.tsx new file mode 100644 index 0000000000000..0348b38351971 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/index.tsx @@ -0,0 +1,31 @@ +/* + * 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 { AnyAction, Dispatch } from 'redux'; +import { connect } from 'react-redux'; +import { JoinEditor } from './join_editor'; +import { getSelectedLayerJoinDescriptors } from '../../../selectors/map_selectors'; +import { setJoinsForLayer } from '../../../actions'; +import { MapStoreState } from '../../../reducers/store'; +import { ILayer } from '../../../classes/layers/layer'; +import { JoinDescriptor } from '../../../../common/descriptor_types'; + +function mapStateToProps(state: MapStoreState) { + return { + joins: getSelectedLayerJoinDescriptors(state), + }; +} + +function mapDispatchToProps(dispatch: Dispatch) { + return { + onChange: (layer: ILayer, joins: JoinDescriptor[]) => { + dispatch(setJoinsForLayer(layer, joins)); + }, + }; +} + +const connectedJoinEditor = connect(mapStateToProps, mapDispatchToProps)(JoinEditor); +export { connectedJoinEditor as JoinEditor }; diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/join_editor.test.tsx b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/join_editor.test.tsx new file mode 100644 index 0000000000000..12da1c4bb9388 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/join_editor.test.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { ILayer } from '../../../classes/layers/layer'; +import { JoinEditor } from './join_editor'; +import { shallow } from 'enzyme'; +import { JoinDescriptor } from '../../../../common/descriptor_types'; + +class MockLayer { + private readonly _disableReason: string | null; + + constructor(disableReason: string | null) { + this._disableReason = disableReason; + } + + getJoinsDisabledReason() { + return this._disableReason; + } +} + +const defaultProps = { + joins: [ + { + leftField: 'iso2', + right: { + id: '673ff994-fc75-4c67-909b-69fcb0e1060e', + indexPatternTitle: 'kibana_sample_data_logs', + term: 'geo.src', + indexPatternId: 'abcde', + metrics: [ + { + type: 'count', + label: 'web logs count', + }, + ], + }, + } as JoinDescriptor, + ], + layerDisplayName: 'myLeftJoinField', + leftJoinFields: [], + onChange: () => {}, +}; + +test('Should render join editor', () => { + const component = shallow( + + ); + expect(component).toMatchSnapshot(); +}); + +test('Should render callout when joins are disabled', () => { + const component = shallow( + + ); + expect(component).toMatchSnapshot(); +}); diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/join_editor.tsx b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/join_editor.tsx new file mode 100644 index 0000000000000..c589604e85112 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/join_editor.tsx @@ -0,0 +1,124 @@ +/* + * 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, { Fragment } from 'react'; +import uuid from 'uuid/v4'; + +import { + EuiButtonEmpty, + EuiTitle, + EuiSpacer, + EuiToolTip, + EuiTextAlign, + EuiCallOut, +} from '@elastic/eui'; + +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +// @ts-expect-error +import { Join } from './resources/join'; +import { ILayer } from '../../../classes/layers/layer'; +import { JoinDescriptor } from '../../../../common/descriptor_types'; +import { IField } from '../../../classes/fields/field'; + +interface Props { + joins: JoinDescriptor[]; + layer: ILayer; + layerDisplayName: string; + leftJoinFields: IField[]; + onChange: (layer: ILayer, joins: JoinDescriptor[]) => void; +} + +export function JoinEditor({ joins, layer, onChange, leftJoinFields, layerDisplayName }: Props) { + const renderJoins = () => { + return joins.map((joinDescriptor: JoinDescriptor, index: number) => { + const handleOnChange = (updatedDescriptor: JoinDescriptor) => { + onChange(layer, [...joins.slice(0, index), updatedDescriptor, ...joins.slice(index + 1)]); + }; + + const handleOnRemove = () => { + onChange(layer, [...joins.slice(0, index), ...joins.slice(index + 1)]); + }; + + return ( + + + + + ); + }); + }; + + const addJoin = () => { + onChange(layer, [ + ...joins, + { + right: { + id: uuid(), + applyGlobalQuery: true, + }, + } as JoinDescriptor, + ]); + }; + + const renderContent = () => { + const disabledReason = layer.getJoinsDisabledReason(); + return disabledReason ? ( + {disabledReason} + ) : ( + + {renderJoins()} + + + + + + + + + + ); + }; + + return ( +
+ +
+ + + +
+
+ + {renderContent()} +
+ ); +} diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/view.js b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/view.js deleted file mode 100644 index 900f5c9ff53ea..0000000000000 --- a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/view.js +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { Fragment } from 'react'; -import uuid from 'uuid/v4'; - -import { - EuiFlexGroup, - EuiFlexItem, - EuiButtonIcon, - EuiTitle, - EuiSpacer, - EuiToolTip, -} from '@elastic/eui'; - -import { Join } from './resources/join'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; - -export function JoinEditor({ joins, layer, onChange, leftJoinFields, layerDisplayName }) { - const renderJoins = () => { - return joins.map((joinDescriptor, index) => { - const handleOnChange = (updatedDescriptor) => { - onChange(layer, [...joins.slice(0, index), updatedDescriptor, ...joins.slice(index + 1)]); - }; - - const handleOnRemove = () => { - onChange(layer, [...joins.slice(0, index), ...joins.slice(index + 1)]); - }; - - return ( - - - - - ); - }); - }; - - const addJoin = () => { - onChange(layer, [ - ...joins, - { - right: { - id: uuid(), - applyGlobalQuery: true, - }, - }, - ]); - }; - - if (!layer.isJoinable()) { - return null; - } - - return ( -
- - - -
- - - -
-
-
- - - -
- - {renderJoins()} -
- ); -} diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/view.js b/x-pack/plugins/maps/public/connected_components/layer_panel/view.js index 71d76ff53d8a9..2e20a4492f08b 100644 --- a/x-pack/plugins/maps/public/connected_components/layer_panel/view.js +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/view.js @@ -75,7 +75,7 @@ export class LayerPanel extends React.Component { }; async _loadLeftJoinFields() { - if (!this.props.selectedLayer || !this.props.selectedLayer.isJoinable()) { + if (!this.props.selectedLayer || !this.props.selectedLayer.showJoinEditor()) { return; } @@ -120,7 +120,7 @@ export class LayerPanel extends React.Component { } _renderJoinSection() { - if (!this.props.selectedLayer.isJoinable()) { + if (!this.props.selectedLayer.showJoinEditor()) { return null; } @@ -128,6 +128,7 @@ export class LayerPanel extends React.Component { diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/view.test.js b/x-pack/plugins/maps/public/connected_components/layer_panel/view.test.js index 99893c1bc5bee..33ca80b00c451 100644 --- a/x-pack/plugins/maps/public/connected_components/layer_panel/view.test.js +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/view.test.js @@ -55,7 +55,7 @@ const mockLayer = { getImmutableSourceProperties: () => { return [{ label: 'source prop1', value: 'you get one chance to set me' }]; }, - isJoinable: () => { + showJoinEditor: () => { return true; }, supportsElasticsearchFilters: () => { From 94ef03dbd3ab57426fc04bbf0d6c11a8e12e11ac Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Mon, 13 Jul 2020 10:56:25 -0500 Subject: [PATCH 14/40] Move kibana-keystore from data/ to config/ (#57856) * Move kibana-keystore from data/ to config/ This is a breaking change to move the location of kibana-keystore. Keystores in other stack products live in the config directory, so this updates our current path to be consistent. Closes #25746 * add breaking changes * update comment * wip * fix docs * read from both keystore locations, write priority to non-deprecated * note data directory fallback * add tests for get_keystore Co-authored-by: Elastic Machine --- docs/migration/migrate_8_0.asciidoc | 7 +++- src/cli_keystore/cli_keystore.js | 8 ++-- src/cli_keystore/get_keystore.js | 40 +++++++++++++++++++ src/cli_keystore/get_keystore.test.js | 57 +++++++++++++++++++++++++++ src/core/server/path/index.test.ts | 7 +++- src/core/server/path/index.ts | 15 ++++++- 6 files changed, 125 insertions(+), 9 deletions(-) create mode 100644 src/cli_keystore/get_keystore.js create mode 100644 src/cli_keystore/get_keystore.test.js diff --git a/docs/migration/migrate_8_0.asciidoc b/docs/migration/migrate_8_0.asciidoc index 82798e948822a..b80503750a26e 100644 --- a/docs/migration/migrate_8_0.asciidoc +++ b/docs/migration/migrate_8_0.asciidoc @@ -115,12 +115,17 @@ URL that it derived from the actual server address and `xpack.security.public` s *Impact:* Any workflow that involved manually clearing generated bundles will have to be updated with the new path. +[float]] +=== kibana.keystore has moved from the `data` folder to the `config` folder +*Details:* By default, kibana.keystore has moved from the configured `path.data` folder to `/config` for archive distributions +and `/etc/kibana` for package distributions. If a pre-existing keystore exists in the data directory that path will continue to be used. + [float] [[breaking_80_user_role_changes]] === User role changes [float] -==== `kibana_user` role has been removed and `kibana_admin` has been added. +=== `kibana_user` role has been removed and `kibana_admin` has been added. *Details:* The `kibana_user` role has been removed and `kibana_admin` has been added to better reflect its intended use. This role continues to grant all access to every diff --git a/src/cli_keystore/cli_keystore.js b/src/cli_keystore/cli_keystore.js index e1561b343ef39..d12c80b361c92 100644 --- a/src/cli_keystore/cli_keystore.js +++ b/src/cli_keystore/cli_keystore.js @@ -18,20 +18,16 @@ */ import _ from 'lodash'; -import { join } from 'path'; import { pkg } from '../core/server/utils'; import Command from '../cli/command'; -import { getDataPath } from '../core/server/path'; import { Keystore } from '../legacy/server/keystore'; -const path = join(getDataPath(), 'kibana.keystore'); -const keystore = new Keystore(path); - import { createCli } from './create'; import { listCli } from './list'; import { addCli } from './add'; import { removeCli } from './remove'; +import { getKeystore } from './get_keystore'; const argv = process.env.kbnWorkerArgv ? JSON.parse(process.env.kbnWorkerArgv) @@ -42,6 +38,8 @@ program .version(pkg.version) .description('A tool for managing settings stored in the Kibana keystore'); +const keystore = new Keystore(getKeystore()); + createCli(program, keystore); listCli(program, keystore); addCli(program, keystore); diff --git a/src/cli_keystore/get_keystore.js b/src/cli_keystore/get_keystore.js new file mode 100644 index 0000000000000..c8ff2555563ad --- /dev/null +++ b/src/cli_keystore/get_keystore.js @@ -0,0 +1,40 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { existsSync } from 'fs'; +import { join } from 'path'; + +import Logger from '../cli_plugin/lib/logger'; +import { getConfigDirectory, getDataPath } from '../core/server/path'; + +export function getKeystore() { + const configKeystore = join(getConfigDirectory(), 'kibana.keystore'); + const dataKeystore = join(getDataPath(), 'kibana.keystore'); + let keystorePath = null; + if (existsSync(dataKeystore)) { + const logger = new Logger(); + logger.log( + `kibana.keystore located in the data folder is deprecated. Future versions will use the config folder.` + ); + keystorePath = dataKeystore; + } else { + keystorePath = configKeystore; + } + return keystorePath; +} diff --git a/src/cli_keystore/get_keystore.test.js b/src/cli_keystore/get_keystore.test.js new file mode 100644 index 0000000000000..88102b8f51d57 --- /dev/null +++ b/src/cli_keystore/get_keystore.test.js @@ -0,0 +1,57 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { getKeystore } from './get_keystore'; +import Logger from '../cli_plugin/lib/logger'; +import fs from 'fs'; +import sinon from 'sinon'; + +describe('get_keystore', () => { + const sandbox = sinon.createSandbox(); + + beforeEach(() => { + sandbox.stub(Logger.prototype, 'log'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('uses the config directory if there is no pre-existing keystore', () => { + sandbox.stub(fs, 'existsSync').returns(false); + expect(getKeystore()).toContain('config'); + expect(getKeystore()).not.toContain('data'); + }); + + it('uses the data directory if there is a pre-existing keystore in the data directory', () => { + sandbox.stub(fs, 'existsSync').returns(true); + expect(getKeystore()).toContain('data'); + expect(getKeystore()).not.toContain('config'); + }); + + it('logs a deprecation warning if the data directory is used', () => { + sandbox.stub(fs, 'existsSync').returns(true); + getKeystore(); + sandbox.assert.calledOnce(Logger.prototype.log); + sandbox.assert.calledWith( + Logger.prototype.log, + 'kibana.keystore located in the data folder is deprecated. Future versions will use the config folder.' + ); + }); +}); diff --git a/src/core/server/path/index.test.ts b/src/core/server/path/index.test.ts index 048622e1f7eab..522e100d85e5d 100644 --- a/src/core/server/path/index.test.ts +++ b/src/core/server/path/index.test.ts @@ -18,7 +18,7 @@ */ import { accessSync, constants } from 'fs'; -import { getConfigPath, getDataPath } from './'; +import { getConfigPath, getDataPath, getConfigDirectory } from './'; describe('Default path finder', () => { it('should find a kibana.yml', () => { @@ -30,4 +30,9 @@ describe('Default path finder', () => { const dataPath = getDataPath(); expect(() => accessSync(dataPath, constants.R_OK)).not.toThrow(); }); + + it('should find a config directory', () => { + const configDirectory = getConfigDirectory(); + expect(() => accessSync(configDirectory, constants.R_OK)).not.toThrow(); + }); }); diff --git a/src/core/server/path/index.ts b/src/core/server/path/index.ts index 2e05e3856bd4c..1bb650518c47a 100644 --- a/src/core/server/path/index.ts +++ b/src/core/server/path/index.ts @@ -30,6 +30,10 @@ const CONFIG_PATHS = [ fromRoot('config/kibana.yml'), ].filter(isString); +const CONFIG_DIRECTORIES = [process.env.KIBANA_PATH_CONF, fromRoot('config'), '/etc/kibana'].filter( + isString +); + const DATA_PATHS = [ process.env.DATA_PATH, // deprecated fromRoot('data'), @@ -49,12 +53,19 @@ function findFile(paths: string[]) { } /** - * Get the path where the config files are stored + * Get the path of kibana.yml * @internal */ export const getConfigPath = () => findFile(CONFIG_PATHS); + +/** + * Get the directory containing configuration files + * @internal + */ +export const getConfigDirectory = () => findFile(CONFIG_DIRECTORIES); + /** - * Get the path where the data can be stored + * Get the directory containing runtime data * @internal */ export const getDataPath = () => findFile(DATA_PATHS); From ced0023ef943fb2e20c5a53e28f1333bf9cb2dee Mon Sep 17 00:00:00 2001 From: Madison Caldwell Date: Mon, 13 Jul 2020 12:02:29 -0400 Subject: [PATCH 15/40] Mapping adjustments (#71449) --- .../server/endpoint/lib/artifacts/common.ts | 4 ++-- .../endpoint/lib/artifacts/saved_object_mappings.ts | 5 ++--- .../endpoint/artifacts/api_feature/data.json | 12 ++++++------ 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/common.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/common.ts index 71d14eb1226d5..77a5e85b14199 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/common.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/common.ts @@ -7,13 +7,13 @@ import { Logger } from 'src/core/server'; export const ArtifactConstants = { GLOBAL_ALLOWLIST_NAME: 'endpoint-exceptionlist', - SAVED_OBJECT_TYPE: 'endpoint:user-artifact:v2', + SAVED_OBJECT_TYPE: 'endpoint:user-artifact', SUPPORTED_OPERATING_SYSTEMS: ['linux', 'macos', 'windows'], SCHEMA_VERSION: 'v1', }; export const ManifestConstants = { - SAVED_OBJECT_TYPE: 'endpoint:user-artifact-manifest:v2', + SAVED_OBJECT_TYPE: 'endpoint:user-artifact-manifest', SCHEMA_VERSION: 'v1', INITIAL_VERSION: 'WzAsMF0=', }; diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/saved_object_mappings.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/saved_object_mappings.ts index 89e974a3d5fd3..0fb433df95de3 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/saved_object_mappings.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/saved_object_mappings.ts @@ -45,7 +45,6 @@ export const exceptionsArtifactSavedObjectMappings: SavedObjectsType['mappings'] }, body: { type: 'binary', - index: false, }, }, }; @@ -66,14 +65,14 @@ export const manifestSavedObjectMappings: SavedObjectsType['mappings'] = { export const exceptionsArtifactType: SavedObjectsType = { name: exceptionsArtifactSavedObjectType, - hidden: false, // TODO: should these be hidden? + hidden: false, namespaceType: 'agnostic', mappings: exceptionsArtifactSavedObjectMappings, }; export const manifestType: SavedObjectsType = { name: manifestSavedObjectType, - hidden: false, // TODO: should these be hidden? + hidden: false, namespaceType: 'agnostic', mappings: manifestSavedObjectMappings, }; diff --git a/x-pack/test/functional/es_archives/endpoint/artifacts/api_feature/data.json b/x-pack/test/functional/es_archives/endpoint/artifacts/api_feature/data.json index bd1010240f86c..ab476660e3ffc 100644 --- a/x-pack/test/functional/es_archives/endpoint/artifacts/api_feature/data.json +++ b/x-pack/test/functional/es_archives/endpoint/artifacts/api_feature/data.json @@ -1,12 +1,12 @@ { "type": "doc", "value": { - "id": "endpoint:user-artifact:v2:endpoint-exceptionlist-linux-v1-d2a9c760005b08d43394e59a8701ae75c80881934ccf15a006944452b80f7f9f", + "id": "endpoint:user-artifact:endpoint-exceptionlist-linux-v1-d2a9c760005b08d43394e59a8701ae75c80881934ccf15a006944452b80f7f9f", "index": ".kibana", "source": { "references": [ ], - "endpoint:user-artifact:v2": { + "endpoint:user-artifact": { "body": "eJylkM8KwjAMxl9Fci59gN29iicvMqR02QjUbiSpKGPvbiw6ETwpuX1/fh9kBszKhALNcQa9TQgNCJ2nhOA+vJ4wdWaGqJSHPY8RRXxPCb3QkJEtP07IQUe2GOWYSoedqU8qXq16ikGqeAmpPNRtCqIU3WbnDx4WN38d/WvhQqmCXzDlIlojP9CsjLC0bqWtHwhaGN/1jHVkae3u+6N6Sg==", "created": 1593016187465, "compressionAlgorithm": "zlib", @@ -17,7 +17,7 @@ "decodedSha256": "d2a9c760005b08d43394e59a8701ae75c80881934ccf15a006944452b80f7f9f", "decodedSize": 358 }, - "type": "endpoint:user-artifact:v2", + "type": "endpoint:user-artifact", "updated_at": "2020-06-24T16:29:47.584Z" } } @@ -26,12 +26,12 @@ { "type": "doc", "value": { - "id": "endpoint:user-artifact-manifest:v2:endpoint-manifest-v1", + "id": "endpoint:user-artifact-manifest:endpoint-manifest-v1", "index": ".kibana", "source": { "references": [ ], - "endpoint:user-artifact-manifest:v2": { + "endpoint:user-artifact-manifest": { "created": 1593183699663, "ids": [ "endpoint-exceptionlist-linux-v1-d2a9c760005b08d43394e59a8701ae75c80881934ccf15a006944452b80f7f9f", @@ -39,7 +39,7 @@ "endpoint-exceptionlist-windows-v1-d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658" ] }, - "type": "endpoint:user-artifact-manifest:v2", + "type": "endpoint:user-artifact-manifest", "updated_at": "2020-06-26T15:01:39.704Z" } } From eac0f8d98d5ec8dfcb0ae3d6a6176a9640a5a9ad Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Mon, 13 Jul 2020 18:03:49 +0200 Subject: [PATCH 16/40] preserve 401 errors from legacy es client (#71234) * preserve 401 errors from legacy es client * use exact import to resolve mocked import issue --- .../legacy/cluster_client.test.ts | 4 +-- .../core_service.test.mocks.ts | 5 ++- .../integration_tests/core_services.test.ts | 34 +++++++++++++++++-- src/core/server/http/router/router.ts | 5 +++ 4 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/core/server/elasticsearch/legacy/cluster_client.test.ts b/src/core/server/elasticsearch/legacy/cluster_client.test.ts index 2f0f80728c707..fd57d06e61eee 100644 --- a/src/core/server/elasticsearch/legacy/cluster_client.test.ts +++ b/src/core/server/elasticsearch/legacy/cluster_client.test.ts @@ -130,7 +130,7 @@ describe('#callAsInternalUser', () => { expect(mockEsClientInstance.security.authenticate).toHaveBeenLastCalledWith(mockParams); }); - test('does not wrap errors if `wrap401Errors` is not set', async () => { + test('does not wrap errors if `wrap401Errors` is set to `false`', async () => { const mockError = { message: 'some error' }; mockEsClientInstance.ping.mockRejectedValue(mockError); @@ -146,7 +146,7 @@ describe('#callAsInternalUser', () => { ).rejects.toBe(mockAuthenticationError); }); - test('wraps only 401 errors by default or when `wrap401Errors` is set', async () => { + test('wraps 401 errors when `wrap401Errors` is set to `true` or unspecified', async () => { const mockError = { message: 'some error' }; mockEsClientInstance.ping.mockRejectedValue(mockError); diff --git a/src/core/server/http/integration_tests/core_service.test.mocks.ts b/src/core/server/http/integration_tests/core_service.test.mocks.ts index f7ebd18b9c488..c23724b7d332f 100644 --- a/src/core/server/http/integration_tests/core_service.test.mocks.ts +++ b/src/core/server/http/integration_tests/core_service.test.mocks.ts @@ -19,10 +19,9 @@ import { elasticsearchServiceMock } from '../../elasticsearch/elasticsearch_service.mock'; export const clusterClientMock = jest.fn(); +export const clusterClientInstanceMock = elasticsearchServiceMock.createLegacyScopedClusterClient(); jest.doMock('../../elasticsearch/legacy/scoped_cluster_client', () => ({ - LegacyScopedClusterClient: clusterClientMock.mockImplementation(function () { - return elasticsearchServiceMock.createLegacyScopedClusterClient(); - }), + LegacyScopedClusterClient: clusterClientMock.mockImplementation(() => clusterClientInstanceMock), })); jest.doMock('elasticsearch', () => { diff --git a/src/core/server/http/integration_tests/core_services.test.ts b/src/core/server/http/integration_tests/core_services.test.ts index ba39effa77016..0ee53a04d9f87 100644 --- a/src/core/server/http/integration_tests/core_services.test.ts +++ b/src/core/server/http/integration_tests/core_services.test.ts @@ -16,9 +16,13 @@ * specific language governing permissions and limitations * under the License. */ + +import { clusterClientMock, clusterClientInstanceMock } from './core_service.test.mocks'; + import Boom from 'boom'; import { Request } from 'hapi'; -import { clusterClientMock } from './core_service.test.mocks'; +import { errors as esErrors } from 'elasticsearch'; +import { LegacyElasticsearchErrorHelpers } from '../../elasticsearch/legacy'; import * as kbnTestServer from '../../../../test_utils/kbn_server'; @@ -352,7 +356,7 @@ describe('http service', () => { }); }); }); - describe('elasticsearch', () => { + describe('legacy elasticsearch client', () => { let root: ReturnType; beforeEach(async () => { root = kbnTestServer.createRoot({ plugins: { initialize: false } }); @@ -410,5 +414,31 @@ describe('http service', () => { const [, , clientHeaders] = client; expect(clientHeaders).toEqual({ authorization: authorizationHeader }); }); + + it('forwards 401 errors returned from elasticsearch', async () => { + const { http } = await root.setup(); + const { createRouter } = http; + + const authenticationError = LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError( + new (esErrors.AuthenticationException as any)('Authentication Exception', { + body: { error: { header: { 'WWW-Authenticate': 'authenticate header' } } }, + statusCode: 401, + }) + ); + + clusterClientInstanceMock.callAsCurrentUser.mockRejectedValue(authenticationError); + + const router = createRouter('/new-platform'); + router.get({ path: '/', validate: false }, async (context, req, res) => { + await context.core.elasticsearch.legacy.client.callAsCurrentUser('ping'); + return res.ok(); + }); + + await root.start(); + + const response = await kbnTestServer.request.get(root, '/new-platform/').expect(401); + + expect(response.header['www-authenticate']).toEqual('authenticate header'); + }); }); }); diff --git a/src/core/server/http/router/router.ts b/src/core/server/http/router/router.ts index 69402a74eda5f..35eec746163ce 100644 --- a/src/core/server/http/router/router.ts +++ b/src/core/server/http/router/router.ts @@ -22,6 +22,7 @@ import Boom from 'boom'; import { isConfigSchema } from '@kbn/config-schema'; import { Logger } from '../../logging'; +import { LegacyElasticsearchErrorHelpers } from '../../elasticsearch/legacy/errors'; import { KibanaRequest } from './request'; import { KibanaResponseFactory, kibanaResponseFactory, IKibanaResponse } from './response'; import { RouteConfig, RouteConfigOptions, RouteMethod, validBodyOutput } from './route'; @@ -263,6 +264,10 @@ export class Router implements IRouter { return hapiResponseAdapter.handle(kibanaResponse); } catch (e) { this.log.error(e); + // forward 401 (boom) error from ES + if (LegacyElasticsearchErrorHelpers.isNotAuthorizedError(e)) { + return e; + } return hapiResponseAdapter.toInternalError(); } } From 105afbce3d0d0264ea7de4c901e4dd41f392e89e Mon Sep 17 00:00:00 2001 From: Alison Goryachev Date: Mon, 13 Jul 2020 12:10:01 -0400 Subject: [PATCH 17/40] [Component templates] Address feedback (#70912) --- .../component_template_serialization.test.ts | 2 + .../lib/component_template_serialization.ts | 6 +- .../index_management/common/lib/index.ts | 2 +- .../common/types/component_templates.ts | 2 + .../component_template_create.test.tsx | 2 +- .../component_template_details.test.ts | 4 +- .../component_template_edit.test.tsx | 2 +- .../component_template_list.test.ts | 2 + .../helpers/setup_environment.tsx | 2 + .../component_template_details.tsx | 38 +++++++++-- .../tab_summary.tsx | 48 ++++++++++++- .../component_template_list.tsx | 42 +++++++++--- .../component_template_list/empty_prompt.tsx | 6 +- .../component_template_list/table.tsx | 51 +++++++++----- .../component_template_form.tsx | 67 +++++++++++++------ .../steps/step_logistics.tsx | 8 +-- .../steps/step_review.tsx | 14 ++-- .../component_templates_context.tsx | 25 ++++++- .../component_templates/shared_imports.ts | 2 + .../public/application/index.tsx | 3 +- .../routes/api/component_templates/get.ts | 4 +- .../component_templates/schema_validation.ts | 1 + .../index_management/component_templates.ts | 7 ++ 23 files changed, 254 insertions(+), 86 deletions(-) diff --git a/x-pack/plugins/index_management/common/lib/component_template_serialization.test.ts b/x-pack/plugins/index_management/common/lib/component_template_serialization.test.ts index 83682f45918e3..16c45991d1f32 100644 --- a/x-pack/plugins/index_management/common/lib/component_template_serialization.test.ts +++ b/x-pack/plugins/index_management/common/lib/component_template_serialization.test.ts @@ -92,6 +92,7 @@ describe('Component template serialization', () => { }, _kbnMeta: { usedBy: ['my_index_template'], + isManaged: false, }, }); }); @@ -105,6 +106,7 @@ describe('Component template serialization', () => { version: 1, _kbnMeta: { usedBy: [], + isManaged: false, }, _meta: { serialization: { diff --git a/x-pack/plugins/index_management/common/lib/component_template_serialization.ts b/x-pack/plugins/index_management/common/lib/component_template_serialization.ts index 672b8140f79fb..3a1c2c1ca55b2 100644 --- a/x-pack/plugins/index_management/common/lib/component_template_serialization.ts +++ b/x-pack/plugins/index_management/common/lib/component_template_serialization.ts @@ -60,24 +60,26 @@ export function deserializeComponentTemplate( _meta, _kbnMeta: { usedBy: indexTemplatesToUsedBy[name] || [], + isManaged: Boolean(_meta?.managed === true), }, }; return deserializedComponentTemplate; } -export function deserializeComponenTemplateList( +export function deserializeComponentTemplateList( componentTemplateEs: ComponentTemplateFromEs, indexTemplatesEs: TemplateFromEs[] ) { const { name, component_template: componentTemplate } = componentTemplateEs; - const { template } = componentTemplate; + const { template, _meta } = componentTemplate; const indexTemplatesToUsedBy = getIndexTemplatesToUsedBy(indexTemplatesEs); const componentTemplateListItem: ComponentTemplateListItem = { name, usedBy: indexTemplatesToUsedBy[name] || [], + isManaged: Boolean(_meta?.managed === true), hasSettings: hasEntries(template.settings), hasMappings: hasEntries(template.mappings), hasAliases: hasEntries(template.aliases), diff --git a/x-pack/plugins/index_management/common/lib/index.ts b/x-pack/plugins/index_management/common/lib/index.ts index f39cc063ba731..9e87e87b0eee0 100644 --- a/x-pack/plugins/index_management/common/lib/index.ts +++ b/x-pack/plugins/index_management/common/lib/index.ts @@ -19,6 +19,6 @@ export { getTemplateParameter } from './utils'; export { deserializeComponentTemplate, - deserializeComponenTemplateList, + deserializeComponentTemplateList, serializeComponentTemplate, } from './component_template_serialization'; diff --git a/x-pack/plugins/index_management/common/types/component_templates.ts b/x-pack/plugins/index_management/common/types/component_templates.ts index bc7ebdc2753dd..c8dec40d061bd 100644 --- a/x-pack/plugins/index_management/common/types/component_templates.ts +++ b/x-pack/plugins/index_management/common/types/component_templates.ts @@ -22,6 +22,7 @@ export interface ComponentTemplateDeserialized extends ComponentTemplateSerializ name: string; _kbnMeta: { usedBy: string[]; + isManaged: boolean; }; } @@ -36,4 +37,5 @@ export interface ComponentTemplateListItem { hasMappings: boolean; hasAliases: boolean; hasSettings: boolean; + isManaged: boolean; } diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx index 75eb419d56a5c..4462a42758878 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx @@ -185,7 +185,7 @@ describe('', () => { }, aliases: ALIASES, }, - _kbnMeta: { usedBy: [] }, + _kbnMeta: { usedBy: [], isManaged: false }, }; expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected); diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts index 7c17dde119c42..3d496d68cc66e 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts @@ -26,13 +26,13 @@ const COMPONENT_TEMPLATE: ComponentTemplateDeserialized = { }, version: 1, _meta: { description: 'component template test' }, - _kbnMeta: { usedBy: ['template_1'] }, + _kbnMeta: { usedBy: ['template_1'], isManaged: false }, }; const COMPONENT_TEMPLATE_ONLY_REQUIRED_FIELDS: ComponentTemplateDeserialized = { name: 'comp-base', template: {}, - _kbnMeta: { usedBy: [] }, + _kbnMeta: { usedBy: [], isManaged: false }, }; describe('', () => { diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx index 115fdf032da8f..114cafe9defde 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx @@ -52,7 +52,7 @@ describe('', () => { template: { settings: { number_of_shards: 1 }, }, - _kbnMeta: { usedBy: [] }, + _kbnMeta: { usedBy: [], isManaged: false }, }; beforeEach(async () => { diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts index 6f09e51255f3b..bd6ac27375836 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts @@ -42,6 +42,7 @@ describe('', () => { hasAliases: true, hasSettings: true, usedBy: [], + isManaged: false, }; const componentTemplate2: ComponentTemplateListItem = { @@ -50,6 +51,7 @@ describe('', () => { hasAliases: true, hasSettings: true, usedBy: ['test_index_template_1'], + isManaged: false, }; const componentTemplates = [componentTemplate1, componentTemplate2]; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx index 70634a226c67b..7e460d3855cb0 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx @@ -12,6 +12,7 @@ import { HttpSetup } from 'kibana/public'; import { notificationServiceMock, docLinksServiceMock, + applicationServiceMock, } from '../../../../../../../../../../src/core/public/mocks'; import { ComponentTemplatesProvider } from '../../../component_templates_context'; @@ -28,6 +29,7 @@ const appDependencies = { docLinks: docLinksServiceMock.createStartContract(), toasts: notificationServiceMock.createSetupContract().toasts, setBreadcrumbs: () => {}, + getUrlForApp: applicationServiceMock.createStartContract().getUrlForApp, }; export const setupEnvironment = () => { diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/component_template_details.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/component_template_details.tsx index f94c5c38f23dd..60f1fff3cc9de 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/component_template_details.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/component_template_details.tsx @@ -6,6 +6,7 @@ import React, { useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; + import { EuiFlyout, EuiFlyoutHeader, @@ -17,6 +18,7 @@ import { EuiButtonEmpty, EuiSpacer, EuiCallOut, + EuiBadge, } from '@elastic/eui'; import { SectionLoading, TabSettings, TabAliases, TabMappings } from '../shared_imports'; @@ -29,14 +31,15 @@ import { attemptToDecodeURI } from '../lib'; interface Props { componentTemplateName: string; onClose: () => void; - showFooter?: boolean; actions?: ManageAction[]; + showSummaryCallToAction?: boolean; } export const ComponentTemplateDetailsFlyout: React.FunctionComponent = ({ componentTemplateName, onClose, actions, + showSummaryCallToAction, }) => { const { api } = useComponentTemplatesContext(); @@ -81,7 +84,12 @@ export const ComponentTemplateDetailsFlyout: React.FunctionComponent = ({ } = componentTemplateDetails; const tabToComponentMap: Record = { - summary: , + summary: ( + + ), settings: , mappings: , aliases: , @@ -109,11 +117,27 @@ export const ComponentTemplateDetailsFlyout: React.FunctionComponent = ({ maxWidth={500} > - -

- {decodedComponentTemplateName} -

-
+ + + +

+ {decodedComponentTemplateName} +

+
+
+ + {componentTemplateDetails?._kbnMeta.isManaged ? ( + + {' '} + + + + + ) : null} +
{content} diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/tab_summary.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/tab_summary.tsx index 80f28f23c9f91..8d054b97cb4f6 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/tab_summary.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/tab_summary.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; + import { EuiDescriptionList, EuiDescriptionListTitle, @@ -14,15 +15,23 @@ import { EuiTitle, EuiCallOut, EuiSpacer, + EuiLink, } from '@elastic/eui'; import { ComponentTemplateDeserialized } from '../shared_imports'; +import { useComponentTemplatesContext } from '../component_templates_context'; interface Props { componentTemplateDetails: ComponentTemplateDeserialized; + showCallToAction?: boolean; } -export const TabSummary: React.FunctionComponent = ({ componentTemplateDetails }) => { +export const TabSummary: React.FunctionComponent = ({ + componentTemplateDetails, + showCallToAction, +}) => { + const { getUrlForApp } = useComponentTemplatesContext(); + const { version, _meta, _kbnMeta } = componentTemplateDetails; const { usedBy } = _kbnMeta; @@ -43,7 +52,42 @@ export const TabSummary: React.FunctionComponent = ({ componentTemplateDe iconType="pin" data-test-subj="notInUseCallout" size="s" - /> + > + {showCallToAction && ( +

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

+ )} + )} diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/component_template_list.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/component_template_list.tsx index d356eabc7997d..efc8b649ef872 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/component_template_list.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/component_template_list.tsx @@ -9,6 +9,7 @@ import { RouteComponentProps } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { ScopedHistory } from 'kibana/public'; +import { EuiLink, EuiText, EuiSpacer } from '@elastic/eui'; import { SectionLoading, ComponentTemplateDeserialized } from '../shared_imports'; import { UIM_COMPONENT_TEMPLATE_LIST_LOAD } from '../constants'; @@ -29,7 +30,7 @@ export const ComponentTemplateList: React.FunctionComponent = ({ componentTemplateName, history, }) => { - const { api, trackMetric } = useComponentTemplatesContext(); + const { api, trackMetric, documentation } = useComponentTemplatesContext(); const { data, isLoading, error, sendRequest } = api.useLoadComponentTemplates(); @@ -65,20 +66,40 @@ export const ComponentTemplateList: React.FunctionComponent = ({ ); } else if (data?.length) { content = ( - + <> + + + {i18n.translate('xpack.idxMgmt.componentTemplates.list.learnMoreLinkText', { + defaultMessage: 'Learn more.', + })} + + ), + }} + /> + + + + + + ); } else if (data && data.length === 0) { content = ; @@ -111,6 +132,7 @@ export const ComponentTemplateList: React.FunctionComponent = ({ = ({ history }) => {


{i18n.translate('xpack.idxMgmt.home.componentTemplates.emptyPromptDocumentionLink', { - defaultMessage: 'Learn more', + defaultMessage: 'Learn more.', })}

diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/table.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/table.tsx index 089c2f889e726..fc86609f1217d 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/table.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/table.tsx @@ -13,11 +13,11 @@ import { EuiTextColor, EuiIcon, EuiLink, + EuiBadge, } from '@elastic/eui'; import { ScopedHistory } from 'kibana/public'; -import { reactRouterNavigate } from '../../../../../../../../src/plugins/kibana_react/public'; -import { ComponentTemplateListItem } from '../shared_imports'; +import { ComponentTemplateListItem, reactRouterNavigate } from '../shared_imports'; import { UIM_COMPONENT_TEMPLATE_DETAILS } from '../constants'; import { useComponentTemplatesContext } from '../component_templates_context'; @@ -105,6 +105,13 @@ export const ComponentTable: FunctionComponent = ({ incremental: true, }, filters: [ + { + type: 'is', + field: 'isManaged', + name: i18n.translate('xpack.idxMgmt.componentTemplatesList.table.isManagedFilterLabel', { + defaultMessage: 'Managed', + }), + }, { type: 'field_value_toggle_group', field: 'usedBy.length', @@ -144,26 +151,38 @@ export const ComponentTable: FunctionComponent = ({ defaultMessage: 'Name', }), sortable: true, - render: (name: string) => ( - /* eslint-disable-next-line @elastic/eui/href-or-on-click */ - trackMetric('click', UIM_COMPONENT_TEMPLATE_DETAILS) + width: '20%', + render: (name: string, item: ComponentTemplateListItem) => ( + <> + trackMetric('click', UIM_COMPONENT_TEMPLATE_DETAILS) + )} + data-test-subj="templateDetailsLink" + > + {name} + + {item.isManaged && ( + <> +   + + {i18n.translate('xpack.idxMgmt.componentTemplatesList.table.managedBadgeLabel', { + defaultMessage: 'Managed', + })} + + )} - data-test-subj="templateDetailsLink" - > - {name} - + ), }, { field: 'usedBy', name: i18n.translate('xpack.idxMgmt.componentTemplatesList.table.isInUseColumnTitle', { - defaultMessage: 'Index templates', + defaultMessage: 'Usage count', }), sortable: true, render: (usedBy: string[]) => { diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/component_template_form.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/component_template_form.tsx index 6e35fbad31d4e..134b8b5eda93d 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/component_template_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/component_template_form.tsx @@ -74,14 +74,11 @@ const wizardSections: { [id: string]: { id: WizardSection; label: string } } = { export const ComponentTemplateForm = ({ defaultValue = { name: '', - template: { - settings: {}, - mappings: {}, - aliases: {}, - }, + template: {}, _meta: {}, _kbnMeta: { usedBy: [], + isManaged: false, }, }, isEditing, @@ -137,23 +134,49 @@ export const ComponentTemplateForm = ({ ) : null; - const buildComponentTemplateObject = (initialTemplate: ComponentTemplateDeserialized) => ( - wizardData: WizardContent - ): ComponentTemplateDeserialized => { - const componentTemplate = { - ...initialTemplate, - name: wizardData.logistics.name, - version: wizardData.logistics.version, - _meta: wizardData.logistics._meta, - template: { - settings: wizardData.settings, - mappings: wizardData.mappings, - aliases: wizardData.aliases, - }, - }; - return componentTemplate; + /** + * If no mappings, settings or aliases are defined, it is better to not send an empty + * object for those values. + * @param componentTemplate The component template object to clean up + */ + const cleanupComponentTemplateObject = (componentTemplate: ComponentTemplateDeserialized) => { + const outputTemplate = { ...componentTemplate }; + + if (outputTemplate.template.settings === undefined) { + delete outputTemplate.template.settings; + } + + if (outputTemplate.template.mappings === undefined) { + delete outputTemplate.template.mappings; + } + + if (outputTemplate.template.aliases === undefined) { + delete outputTemplate.template.aliases; + } + + return outputTemplate; }; + const buildComponentTemplateObject = useCallback( + (initialTemplate: ComponentTemplateDeserialized) => ( + wizardData: WizardContent + ): ComponentTemplateDeserialized => { + const outputComponentTemplate = { + ...initialTemplate, + name: wizardData.logistics.name, + version: wizardData.logistics.version, + _meta: wizardData.logistics._meta, + template: { + settings: wizardData.settings, + mappings: wizardData.mappings, + aliases: wizardData.aliases, + }, + }; + return cleanupComponentTemplateObject(outputComponentTemplate); + }, + [] + ); + const onSaveComponentTemplate = useCallback( async (wizardData: WizardContent) => { const componentTemplate = buildComponentTemplateObject(defaultValue)(wizardData); @@ -161,13 +184,13 @@ export const ComponentTemplateForm = ({ // This will strip an empty string if "version" is not set, as well as an empty "_meta" object onSave( stripEmptyFields(componentTemplate, { - types: ['string', 'object'], + types: ['string'], }) as ComponentTemplateDeserialized ); clearSaveError(); }, - [defaultValue, onSave, clearSaveError] + [buildComponentTemplateObject, defaultValue, onSave, clearSaveError] ); return ( diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_logistics.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_logistics.tsx index 18988fa125a06..c48a23226a371 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_logistics.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_logistics.tsx @@ -117,7 +117,7 @@ export const StepLogistics: React.FunctionComponent = React.memo( description={ } > @@ -141,7 +141,7 @@ export const StepLogistics: React.FunctionComponent = React.memo( description={ } > @@ -165,7 +165,7 @@ export const StepLogistics: React.FunctionComponent = React.memo( <> = React.memo( {i18n.translate( 'xpack.idxMgmt.componentTemplateForm.stepLogistics.metaDocumentionLink', { - defaultMessage: 'Learn more', + defaultMessage: 'Learn more.', } )} diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_review.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_review.tsx index ce85854dc79ab..67246f2e10c3b 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_review.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_review.tsx @@ -52,16 +52,12 @@ export const StepReview: React.FunctionComponent = React.memo(({ componen const serializedComponentTemplate = serializeComponentTemplate( stripEmptyFields(componentTemplate, { - types: ['string', 'object'], + types: ['string'], }) as ComponentTemplateDeserialized ); const { - template: { - mappings: serializedMappings, - settings: serializedSettings, - aliases: serializedAliases, - }, + template: serializedTemplate, _meta: serializedMeta, version: serializedVersion, } = serializedComponentTemplate; @@ -94,7 +90,7 @@ export const StepReview: React.FunctionComponent = React.memo(({ componen /> - {getDescriptionText(serializedSettings)} + {getDescriptionText(serializedTemplate?.settings)} {/* Mappings */} @@ -105,7 +101,7 @@ export const StepReview: React.FunctionComponent = React.memo(({ componen /> - {getDescriptionText(serializedMappings)} + {getDescriptionText(serializedTemplate?.mappings)} {/* Aliases */} @@ -116,7 +112,7 @@ export const StepReview: React.FunctionComponent = React.memo(({ componen /> - {getDescriptionText(serializedAliases)} + {getDescriptionText(serializedTemplate?.aliases)}
diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_templates_context.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_templates_context.tsx index ce9e28d0feefe..7be0618481a69 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_templates_context.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_templates_context.tsx @@ -5,7 +5,7 @@ */ import React, { createContext, useContext } from 'react'; -import { HttpSetup, DocLinksStart, NotificationsSetup } from 'src/core/public'; +import { HttpSetup, DocLinksStart, NotificationsSetup, CoreStart } from 'src/core/public'; import { ManagementAppMountParams } from 'src/plugins/management/public'; import { getApi, getUseRequest, getSendRequest, getDocumentation, getBreadcrumbs } from './lib'; @@ -19,6 +19,7 @@ interface Props { docLinks: DocLinksStart; toasts: NotificationsSetup['toasts']; setBreadcrumbs: ManagementAppMountParams['setBreadcrumbs']; + getUrlForApp: CoreStart['application']['getUrlForApp']; } interface Context { @@ -29,6 +30,7 @@ interface Context { breadcrumbs: ReturnType; trackMetric: (type: 'loaded' | 'click' | 'count', eventName: string) => void; toasts: NotificationsSetup['toasts']; + getUrlForApp: CoreStart['application']['getUrlForApp']; } export const ComponentTemplatesProvider = ({ @@ -38,7 +40,15 @@ export const ComponentTemplatesProvider = ({ value: Props; children: React.ReactNode; }) => { - const { httpClient, apiBasePath, trackMetric, docLinks, toasts, setBreadcrumbs } = value; + const { + httpClient, + apiBasePath, + trackMetric, + docLinks, + toasts, + setBreadcrumbs, + getUrlForApp, + } = value; const useRequest = getUseRequest(httpClient); const sendRequest = getSendRequest(httpClient); @@ -49,7 +59,16 @@ export const ComponentTemplatesProvider = ({ return ( {children} diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/shared_imports.ts b/x-pack/plugins/index_management/public/application/components/component_templates/shared_imports.ts index 80e222f4f7706..278fadcd90c8b 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/shared_imports.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/shared_imports.ts @@ -62,3 +62,5 @@ export { } from '../../../../common'; export { serializeComponentTemplate } from '../../../../common/lib'; + +export { reactRouterNavigate } from '../../../../../../../src/plugins/kibana_react/public'; diff --git a/x-pack/plugins/index_management/public/application/index.tsx b/x-pack/plugins/index_management/public/application/index.tsx index 7b053a15b26d0..ebc29ac86a17f 100644 --- a/x-pack/plugins/index_management/public/application/index.tsx +++ b/x-pack/plugins/index_management/public/application/index.tsx @@ -25,7 +25,7 @@ export const renderApp = ( return () => undefined; } - const { i18n, docLinks, notifications } = core; + const { i18n, docLinks, notifications, application } = core; const { Context: I18nContext } = i18n; const { services, history, setBreadcrumbs } = dependencies; @@ -36,6 +36,7 @@ export const renderApp = ( docLinks, toasts: notifications.toasts, setBreadcrumbs, + getUrlForApp: application.getUrlForApp, }; render( diff --git a/x-pack/plugins/index_management/server/routes/api/component_templates/get.ts b/x-pack/plugins/index_management/server/routes/api/component_templates/get.ts index f6f8e7d63d370..16b028887f63c 100644 --- a/x-pack/plugins/index_management/server/routes/api/component_templates/get.ts +++ b/x-pack/plugins/index_management/server/routes/api/component_templates/get.ts @@ -7,7 +7,7 @@ import { schema } from '@kbn/config-schema'; import { deserializeComponentTemplate, - deserializeComponenTemplateList, + deserializeComponentTemplateList, } from '../../../../common/lib'; import { ComponentTemplateFromEs } from '../../../../common'; import { RouteDependencies } from '../../../types'; @@ -36,7 +36,7 @@ export function registerGetAllRoute({ router, license, lib: { isEsError } }: Rou ); const body = componentTemplates.map((componentTemplate) => { - const deserializedComponentTemplateListItem = deserializeComponenTemplateList( + const deserializedComponentTemplateListItem = deserializeComponentTemplateList( componentTemplate, indexTemplates ); diff --git a/x-pack/plugins/index_management/server/routes/api/component_templates/schema_validation.ts b/x-pack/plugins/index_management/server/routes/api/component_templates/schema_validation.ts index a1fc258127229..cfcb428f00501 100644 --- a/x-pack/plugins/index_management/server/routes/api/component_templates/schema_validation.ts +++ b/x-pack/plugins/index_management/server/routes/api/component_templates/schema_validation.ts @@ -16,5 +16,6 @@ export const componentTemplateSchema = schema.object({ _meta: schema.maybe(schema.object({}, { unknowns: 'allow' })), _kbnMeta: schema.object({ usedBy: schema.arrayOf(schema.string()), + isManaged: schema.boolean(), }), }); diff --git a/x-pack/test/api_integration/apis/management/index_management/component_templates.ts b/x-pack/test/api_integration/apis/management/index_management/component_templates.ts index 1a00eaba35aa1..30ec95f208c80 100644 --- a/x-pack/test/api_integration/apis/management/index_management/component_templates.ts +++ b/x-pack/test/api_integration/apis/management/index_management/component_templates.ts @@ -78,6 +78,7 @@ export default function ({ getService }: FtrProviderContext) { expect(testComponentTemplate).to.eql({ name: COMPONENT_NAME, usedBy: [], + isManaged: false, hasSettings: true, hasMappings: true, hasAliases: false, @@ -96,6 +97,7 @@ export default function ({ getService }: FtrProviderContext) { ...COMPONENT, _kbnMeta: { usedBy: [], + isManaged: false, }, }); }); @@ -148,6 +150,7 @@ export default function ({ getService }: FtrProviderContext) { }, _kbnMeta: { usedBy: [], + isManaged: false, }, }) .expect(200); @@ -167,6 +170,7 @@ export default function ({ getService }: FtrProviderContext) { template: {}, _kbnMeta: { usedBy: [], + isManaged: false, }, }) .expect(200); @@ -185,6 +189,7 @@ export default function ({ getService }: FtrProviderContext) { template: {}, _kbnMeta: { usedBy: [], + isManaged: false, }, }) .expect(409); @@ -246,6 +251,7 @@ export default function ({ getService }: FtrProviderContext) { version: 1, _kbnMeta: { usedBy: [], + isManaged: false, }, }) .expect(200); @@ -267,6 +273,7 @@ export default function ({ getService }: FtrProviderContext) { version: 1, _kbnMeta: { usedBy: [], + isManaged: false, }, }) .expect(404); From e082719870375a2188d50042e3a92ddff991ea76 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 13 Jul 2020 17:12:25 +0100 Subject: [PATCH 18/40] skip flaky suite (#68400) --- .../apps/saved_objects_management/edit_saved_object.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/functional/apps/saved_objects_management/edit_saved_object.ts b/test/functional/apps/saved_objects_management/edit_saved_object.ts index 2c9200c2f8d93..0e2ff44ff62ef 100644 --- a/test/functional/apps/saved_objects_management/edit_saved_object.ts +++ b/test/functional/apps/saved_objects_management/edit_saved_object.ts @@ -66,6 +66,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await button.click(); }; + // Flaky: https://github.com/elastic/kibana/issues/68400 describe('saved objects edition page', () => { beforeEach(async () => { await esArchiver.load('saved_objects_management/edit_saved_object'); From 56794718c7cda41824bf5172fb28930dd06622ce Mon Sep 17 00:00:00 2001 From: Liza Katz Date: Mon, 13 Jul 2020 19:21:34 +0300 Subject: [PATCH 19/40] Resolve range date filter bugs and improve usability (#71298) * improve test stability * Filter date range improvements * Make onBlur optional * i18n Co-authored-by: Elastic Machine --- .../lib/filter_editor_utils.test.ts | 18 +++++++++- .../filter_editor/lib/filter_editor_utils.ts | 5 ++- .../filter_editor/range_value_input.tsx | 35 ++++++++++--------- .../filter_editor/value_input_type.tsx | 9 +++++ .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 6 files changed, 49 insertions(+), 20 deletions(-) diff --git a/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_editor_utils.test.ts b/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_editor_utils.test.ts index 12cdf13caeb55..e2caca7895c42 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_editor_utils.test.ts +++ b/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_editor_utils.test.ts @@ -177,11 +177,27 @@ describe('Filter editor utils', () => { it('should return true for range filter with from/to', () => { const isValid = isFilterValid(stubIndexPattern, stubFields[0], isBetweenOperator, { from: 'foo', - too: 'goo', + to: 'goo', }); expect(isValid).toBe(true); }); + it('should return false for date range filter with bad from', () => { + const isValid = isFilterValid(stubIndexPattern, stubFields[4], isBetweenOperator, { + from: 'foo', + to: 'now', + }); + expect(isValid).toBe(false); + }); + + it('should return false for date range filter with bad to', () => { + const isValid = isFilterValid(stubIndexPattern, stubFields[4], isBetweenOperator, { + from: '2020-01-01', + to: 'mau', + }); + expect(isValid).toBe(false); + }); + it('should return true for exists filter without params', () => { const isValid = isFilterValid(stubIndexPattern, stubFields[0], existsOperator); expect(isValid).toBe(true); diff --git a/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_editor_utils.ts b/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_editor_utils.ts index 114be67e490cf..97a59fa69f458 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_editor_utils.ts +++ b/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_editor_utils.ts @@ -85,7 +85,10 @@ export function isFilterValid( if (typeof params !== 'object') { return false; } - return validateParams(params.from, field.type) || validateParams(params.to, field.type); + return ( + (!params.from || validateParams(params.from, field.type)) && + (!params.to || validateParams(params.to, field.type)) + ); case 'exists': return true; default: diff --git a/src/plugins/data/public/ui/filter_bar/filter_editor/range_value_input.tsx b/src/plugins/data/public/ui/filter_bar/filter_editor/range_value_input.tsx index 65b842f0bd4aa..bdfd1014625d8 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_editor/range_value_input.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_editor/range_value_input.tsx @@ -17,8 +17,9 @@ * under the License. */ -import { EuiIcon, EuiLink, EuiFormHelpText, EuiFormControlLayoutDelimited } from '@elastic/eui'; -import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react'; +import moment from 'moment'; +import { EuiFormControlLayoutDelimited } from '@elastic/eui'; +import { InjectedIntl, injectI18n } from '@kbn/i18n/react'; import { get } from 'lodash'; import React from 'react'; import { useKibana } from '../../../../../kibana_react/public'; @@ -41,8 +42,17 @@ interface Props { function RangeValueInputUI(props: Props) { const kibana = useKibana(); - const dataMathDocLink = kibana.services.docLinks!.links.date.dateMath; const type = props.field ? props.field.type : 'string'; + const tzConfig = kibana.services.uiSettings!.get('dateFormat:tz'); + + const formatDateChange = (value: string | number | boolean) => { + if (typeof value !== 'string' && typeof value !== 'number') return value; + + const momentParsedValue = moment(value).tz(tzConfig); + if (momentParsedValue.isValid()) return momentParsedValue?.format('YYYY-MM-DDTHH:mm:ss.SSSZ'); + + return value; + }; const onFromChange = (value: string | number | boolean) => { if (typeof value !== 'string' && typeof value !== 'number') { @@ -71,6 +81,9 @@ function RangeValueInputUI(props: Props) { type={type} value={props.value ? props.value.from : undefined} onChange={onFromChange} + onBlur={(value) => { + onFromChange(formatDateChange(value)); + }} placeholder={props.intl.formatMessage({ id: 'data.filter.filterEditor.rangeStartInputPlaceholder', defaultMessage: 'Start of the range', @@ -83,6 +96,9 @@ function RangeValueInputUI(props: Props) { type={type} value={props.value ? props.value.to : undefined} onChange={onToChange} + onBlur={(value) => { + onToChange(formatDateChange(value)); + }} placeholder={props.intl.formatMessage({ id: 'data.filter.filterEditor.rangeEndInputPlaceholder', defaultMessage: 'End of the range', @@ -90,19 +106,6 @@ function RangeValueInputUI(props: Props) { /> } /> - {type === 'date' ? ( - - - {' '} - - - - ) : ( - '' - )} ); } diff --git a/src/plugins/data/public/ui/filter_bar/filter_editor/value_input_type.tsx b/src/plugins/data/public/ui/filter_bar/filter_editor/value_input_type.tsx index 3737dae1bf9ef..1a165c78d4d79 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_editor/value_input_type.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_editor/value_input_type.tsx @@ -27,6 +27,7 @@ interface Props { value?: string | number; type: string; onChange: (value: string | number | boolean) => void; + onBlur?: (value: string | number | boolean) => void; placeholder: string; intl: InjectedIntl; controlOnly?: boolean; @@ -66,6 +67,7 @@ class ValueInputTypeUI extends Component { placeholder={this.props.placeholder} value={value} onChange={this.onChange} + onBlur={this.onBlur} isInvalid={!isEmpty(value) && !validateParams(value, this.props.type)} controlOnly={this.props.controlOnly} className={this.props.className} @@ -126,6 +128,13 @@ class ValueInputTypeUI extends Component { const params = event.target.value; this.props.onChange(params); }; + + private onBlur = (event: React.ChangeEvent) => { + if (this.props.onBlur) { + const params = event.target.value; + this.props.onBlur(params); + } + }; } export const ValueInputType = injectI18n(ValueInputTypeUI); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index e28ef8ff07bdd..4c83fa71a7060 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -641,7 +641,6 @@ "data.filter.filterEditor.cancelButtonLabel": "キャンセル", "data.filter.filterEditor.createCustomLabelInputLabel": "カスタムラベル", "data.filter.filterEditor.createCustomLabelSwitchLabel": "カスタムラベルを作成しますか?", - "data.filter.filterEditor.dateFormatHelpLinkLabel": "対応データフォーマット", "data.filter.filterEditor.doesNotExistOperatorOptionLabel": "存在しません", "data.filter.filterEditor.editFilterPopupTitle": "フィルターを編集", "data.filter.filterEditor.editFilterValuesButtonLabel": "フィルター値を編集", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 1df676ba7cffd..86b2480e3b314 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -641,7 +641,6 @@ "data.filter.filterEditor.cancelButtonLabel": "取消", "data.filter.filterEditor.createCustomLabelInputLabel": "定制标签", "data.filter.filterEditor.createCustomLabelSwitchLabel": "创建定制标签?", - "data.filter.filterEditor.dateFormatHelpLinkLabel": "已接受日期格式", "data.filter.filterEditor.doesNotExistOperatorOptionLabel": "不存在", "data.filter.filterEditor.editFilterPopupTitle": "编辑筛选", "data.filter.filterEditor.editFilterValuesButtonLabel": "编辑筛选值", From 3a52eaf7ee5e2b2a00fe9c40191528d8ca0ce97e Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 13 Jul 2020 17:31:08 +0100 Subject: [PATCH 20/40] skip flaky suite (#70928) --- x-pack/test/functional_embedded/tests/iframe_embedded.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional_embedded/tests/iframe_embedded.ts b/x-pack/test/functional_embedded/tests/iframe_embedded.ts index 9b5c9894a9407..f05d70b6cb3e8 100644 --- a/x-pack/test/functional_embedded/tests/iframe_embedded.ts +++ b/x-pack/test/functional_embedded/tests/iframe_embedded.ts @@ -14,7 +14,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const config = getService('config'); const testSubjects = getService('testSubjects'); - describe('in iframe', () => { + // Flaky: https://github.com/elastic/kibana/issues/70928 + describe.skip('in iframe', () => { it('should open Kibana for logged-in user', async () => { const isChromeHiddenBefore = await PageObjects.common.isChromeHidden(); expect(isChromeHiddenBefore).to.be(true); From c5729b87d6c806e5d992f038d219856cdfe08979 Mon Sep 17 00:00:00 2001 From: Michael Hirsch Date: Mon, 13 Jul 2020 12:35:04 -0400 Subject: [PATCH 21/40] [ML] Adds siem_cloudtrail Module (#71323) * adds siem_cloudtrail module * updates logo to logoSecurity Co-authored-by: Elastic Machine --- .../modules/siem_cloudtrail/logo.json | 3 + .../modules/siem_cloudtrail/manifest.json | 64 +++++++++++++++++++ ...eed_high_distinct_count_error_message.json | 16 +++++ .../ml/datafeed_rare_error_code.json | 16 +++++ .../ml/datafeed_rare_method_for_a_city.json | 16 +++++ .../datafeed_rare_method_for_a_country.json | 16 +++++ .../datafeed_rare_method_for_a_username.json | 16 +++++ .../ml/high_distinct_count_error_message.json | 33 ++++++++++ .../siem_cloudtrail/ml/rare_error_code.json | 33 ++++++++++ .../ml/rare_method_for_a_city.json | 34 ++++++++++ .../ml/rare_method_for_a_country.json | 34 ++++++++++ .../ml/rare_method_for_a_username.json | 34 ++++++++++ .../apis/ml/modules/get_module.ts | 1 + 13 files changed, 316 insertions(+) create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/logo.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/manifest.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_high_distinct_count_error_message.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_error_code.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_method_for_a_city.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_method_for_a_country.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_method_for_a_username.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/high_distinct_count_error_message.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_error_code.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_method_for_a_city.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_method_for_a_country.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_method_for_a_username.json diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/logo.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/logo.json new file mode 100644 index 0000000000000..ca61db7992083 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/logo.json @@ -0,0 +1,3 @@ +{ + "icon": "logoSecurity" +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/manifest.json new file mode 100644 index 0000000000000..b7afe8d2b158a --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/manifest.json @@ -0,0 +1,64 @@ +{ + "id": "siem_cloudtrail", + "title": "SIEM Cloudtrail", + "description": "Detect suspicious activity recorded in your cloudtrail logs.", + "type": "Filebeat data", + "logoFile": "logo.json", + "defaultIndexPattern": "filebeat-*", + "query": { + "bool": { + "filter": [ + {"term": {"event.dataset": "aws.cloudtrail"}} + ] + } + }, + "jobs": [ + { + "id": "rare_method_for_a_city", + "file": "rare_method_for_a_city.json" + }, + { + "id": "rare_method_for_a_country", + "file": "rare_method_for_a_country.json" + }, + { + "id": "rare_method_for_a_username", + "file": "rare_method_for_a_username.json" + }, + { + "id": "high_distinct_count_error_message", + "file": "high_distinct_count_error_message.json" + }, + { + "id": "rare_error_code", + "file": "rare_error_code.json" + } + ], + "datafeeds": [ + { + "id": "datafeed-rare_method_for_a_city", + "file": "datafeed_rare_method_for_a_city.json", + "job_id": "rare_method_for_a_city" + }, + { + "id": "datafeed-rare_method_for_a_country", + "file": "datafeed_rare_method_for_a_country.json", + "job_id": "rare_method_for_a_country" + }, + { + "id": "datafeed-rare_method_for_a_username", + "file": "datafeed_rare_method_for_a_username.json", + "job_id": "rare_method_for_a_username" + }, + { + "id": "datafeed-high_distinct_count_error_message", + "file": "datafeed_high_distinct_count_error_message.json", + "job_id": "high_distinct_count_error_message" + }, + { + "id": "datafeed-rare_error_code", + "file": "datafeed_rare_error_code.json", + "job_id": "rare_error_code" + } + ] + } \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_high_distinct_count_error_message.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_high_distinct_count_error_message.json new file mode 100644 index 0000000000000..269aac2ea72a1 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_high_distinct_count_error_message.json @@ -0,0 +1,16 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + {"term": {"event.dataset": "aws.cloudtrail"}}, + {"term": {"event.module": "aws"}}, + {"exists": {"field": "aws.cloudtrail.error_message"}} + ] + } + } +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_error_code.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_error_code.json new file mode 100644 index 0000000000000..4b463a4d10991 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_error_code.json @@ -0,0 +1,16 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + {"term": {"event.dataset": "aws.cloudtrail"}}, + {"term": {"event.module": "aws"}}, + {"exists": {"field": "aws.cloudtrail.error_code"}} + ] + } + } +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_method_for_a_city.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_method_for_a_city.json new file mode 100644 index 0000000000000..e436273a848e7 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_method_for_a_city.json @@ -0,0 +1,16 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + {"term": {"event.dataset": "aws.cloudtrail"}}, + {"term": {"event.module": "aws"}}, + {"exists": {"field": "source.geo.city_name"}} + ] + } + } +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_method_for_a_country.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_method_for_a_country.json new file mode 100644 index 0000000000000..f0e80174b8791 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_method_for_a_country.json @@ -0,0 +1,16 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + {"term": {"event.dataset": "aws.cloudtrail"}}, + {"term": {"event.module": "aws"}}, + {"exists": {"field": "source.geo.country_iso_code"}} + ] + } + } +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_method_for_a_username.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_method_for_a_username.json new file mode 100644 index 0000000000000..2fd3622ff81ce --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_method_for_a_username.json @@ -0,0 +1,16 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + {"term": {"event.dataset": "aws.cloudtrail"}}, + {"term": {"event.module": "aws"}}, + {"exists": {"field": "user.name"}} + ] + } + } +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/high_distinct_count_error_message.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/high_distinct_count_error_message.json new file mode 100644 index 0000000000000..fdabf66ac91b3 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/high_distinct_count_error_message.json @@ -0,0 +1,33 @@ +{ + "job_type": "anomaly_detector", + "description": "Looks for a spike in the rate of an error message which may simply indicate an impending service failure but these can also be byproducts of attempted or successful persistence, privilege escalation, defense evasion, discovery, lateral movement, or collection activity by a threat actor.", + "groups": [ + "siem", + "cloudtrail" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "high_distinct_count(\"aws.cloudtrail.error_message\")", + "function": "high_distinct_count", + "field_name": "aws.cloudtrail.error_message" + } + ], + "influencers": [ + "aws.cloudtrail.user_identity.arn", + "source.ip", + "source.geo.city_name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "16mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-siem-cloudtrail" + } + } \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_error_code.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_error_code.json new file mode 100644 index 0000000000000..0f8fa814ac60a --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_error_code.json @@ -0,0 +1,33 @@ +{ + "job_type": "anomaly_detector", + "description": "Looks for unsual errors. Rare and unusual errors may simply indicate an impending service failure but they can also be byproducts of attempted or successful persistence, privilege escalation, defense evasion, discovery, lateral movement, or collection activity by a threat actor.", + "groups": [ + "siem", + "cloudtrail" + ], + "analysis_config": { + "bucket_span": "60m", + "detectors": [ + { + "detector_description": "rare by \"aws.cloudtrail.error_code\"", + "function": "rare", + "by_field_name": "aws.cloudtrail.error_code" + } + ], + "influencers": [ + "aws.cloudtrail.user_identity.arn", + "source.ip", + "source.geo.city_name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "16mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-siem-cloudtrail" + } + } \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_method_for_a_city.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_method_for_a_city.json new file mode 100644 index 0000000000000..eff4d4cdbb889 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_method_for_a_city.json @@ -0,0 +1,34 @@ +{ + "job_type": "anomaly_detector", + "description": "Looks for AWS API calls that, while not inherently suspicious or abnormal, are sourcing from a geolocation (city) that is unusual. This can be the result of compromised credentials or keys.", + "groups": [ + "siem", + "cloudtrail" + ], + "analysis_config": { + "bucket_span": "60m", + "detectors": [ + { + "detector_description": "rare by \"event.action\" partition by \"source.geo.city_name\"", + "function": "rare", + "by_field_name": "event.action", + "partition_field_name": "source.geo.city_name" + } + ], + "influencers": [ + "aws.cloudtrail.user_identity.arn", + "source.ip", + "source.geo.city_name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "64mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-siem-cloudtrail" + } + } \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_method_for_a_country.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_method_for_a_country.json new file mode 100644 index 0000000000000..810822c30a5dd --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_method_for_a_country.json @@ -0,0 +1,34 @@ +{ + "job_type": "anomaly_detector", + "description": "Looks for AWS API calls that, while not inherently suspicious or abnormal, are sourcing from a geolocation (country) that is unusual. This can be the result of compromised credentials or keys.", + "groups": [ + "siem", + "cloudtrail" + ], + "analysis_config": { + "bucket_span": "60m", + "detectors": [ + { + "detector_description": "rare by \"event.action\" partition by \"source.geo.country_iso_code\"", + "function": "rare", + "by_field_name": "event.action", + "partition_field_name": "source.geo.country_iso_code" + } + ], + "influencers": [ + "aws.cloudtrail.user_identity.arn", + "source.ip", + "source.geo.country_iso_code" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "64mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-siem-cloudtrail" + } + } \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_method_for_a_username.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_method_for_a_username.json new file mode 100644 index 0000000000000..2edf52e8351ed --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_method_for_a_username.json @@ -0,0 +1,34 @@ +{ + "job_type": "anomaly_detector", + "description": "Looks for AWS API calls that, while not inherently suspicious or abnormal, are sourcing from a user context that does not normally call the method. This can be the result of compromised credentials or keys as someone uses a valid account to persist, move laterally, or exfil data.", + "groups": [ + "siem", + "cloudtrail" + ], + "analysis_config": { + "bucket_span": "60m", + "detectors": [ + { + "detector_description": "rare by \"event.action\" partition by \"user.name\"", + "function": "rare", + "by_field_name": "event.action", + "partition_field_name": "user.name" + } + ], + "influencers": [ + "user.name", + "source.ip", + "source.geo.city_name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "128mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-siem-cloudtrail" + } + } \ No newline at end of file diff --git a/x-pack/test/api_integration/apis/ml/modules/get_module.ts b/x-pack/test/api_integration/apis/ml/modules/get_module.ts index 5ca496a7a7fe9..cfb3c17ac7f21 100644 --- a/x-pack/test/api_integration/apis/ml/modules/get_module.ts +++ b/x-pack/test/api_integration/apis/ml/modules/get_module.ts @@ -25,6 +25,7 @@ const moduleIds = [ 'sample_data_weblogs', 'siem_auditbeat', 'siem_auditbeat_auth', + 'siem_cloudtrail', 'siem_packetbeat', 'siem_winlogbeat', 'siem_winlogbeat_auth', From 1a65900e8ed45e17b621234ec64d88ce57ee075e Mon Sep 17 00:00:00 2001 From: Chris Cowan Date: Mon, 13 Jul 2020 09:51:43 -0700 Subject: [PATCH 22/40] [TSVB] Add support for histogram type (#68837) * [TSVB] Add support for histogram type * Merge branch 'master' of github.com:elastic/kibana into issue-52426-tsvb-support-for-histograms * Adding support to filter ratio; updating test * Limist aggs for filter_ratio and histogram fields; add test for AggSelect; Fixes #70984 * Ensure only compatible fields are displayed for filter ratio metric aggs Co-authored-by: Elastic Machine --- .../common/metric_types.js | 3 + .../components/aggs/agg_select.test.tsx | 184 ++++++++++++++++++ .../components/aggs/agg_select.tsx | 17 ++ .../components/aggs/filter_ratio.js | 19 +- .../components/aggs/filter_ratio.test.js | 136 +++++++++++++ .../components/aggs/histogram_support.test.js | 94 +++++++++ .../application/components/aggs/percentile.js | 2 +- .../aggs/percentile_rank/percentile_rank.tsx | 2 +- .../components/aggs/positive_rate.js | 2 +- .../application/components/aggs/std_agg.js | 6 +- .../get_supported_fields_by_metric_type.js | 34 ++++ ...et_supported_fields_by_metric_type.test.js | 44 +++++ .../public/test_utils/index.ts | 50 +++++ 13 files changed, 580 insertions(+), 13 deletions(-) create mode 100644 src/plugins/vis_type_timeseries/public/application/components/aggs/agg_select.test.tsx create mode 100644 src/plugins/vis_type_timeseries/public/application/components/aggs/filter_ratio.test.js create mode 100644 src/plugins/vis_type_timeseries/public/application/components/aggs/histogram_support.test.js create mode 100644 src/plugins/vis_type_timeseries/public/application/components/lib/get_supported_fields_by_metric_type.js create mode 100644 src/plugins/vis_type_timeseries/public/application/components/lib/get_supported_fields_by_metric_type.test.js create mode 100644 src/plugins/vis_type_timeseries/public/test_utils/index.ts diff --git a/src/plugins/vis_type_timeseries/common/metric_types.js b/src/plugins/vis_type_timeseries/common/metric_types.js index 9dc6085b080e9..05836a6df410a 100644 --- a/src/plugins/vis_type_timeseries/common/metric_types.js +++ b/src/plugins/vis_type_timeseries/common/metric_types.js @@ -27,6 +27,9 @@ export const METRIC_TYPES = { VARIANCE: 'variance', SUM_OF_SQUARES: 'sum_of_squares', CARDINALITY: 'cardinality', + VALUE_COUNT: 'value_count', + AVERAGE: 'avg', + SUM: 'sum', }; export const EXTENDED_STATS_TYPES = [ diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/agg_select.test.tsx b/src/plugins/vis_type_timeseries/public/application/components/aggs/agg_select.test.tsx new file mode 100644 index 0000000000000..968fa5384e1d8 --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/agg_select.test.tsx @@ -0,0 +1,184 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { AggSelect } from './agg_select'; +import { METRIC, SERIES } from '../../../test_utils'; +import { EuiComboBox } from '@elastic/eui'; + +describe('TSVB AggSelect', () => { + const setup = (panelType: string, value: string) => { + const metric = { + ...METRIC, + type: 'filter_ratio', + field: 'histogram_value', + }; + const series = { ...SERIES, metrics: [metric] }; + + const wrapper = mountWithIntl( +
+ +
+ ); + return wrapper; + }; + + it('should only display filter ratio compattible aggs', () => { + const wrapper = setup('filter_ratio', 'avg'); + expect(wrapper.find(EuiComboBox).props().options).toMatchInlineSnapshot(` + Array [ + Object { + "label": "Average", + "value": "avg", + }, + Object { + "label": "Cardinality", + "value": "cardinality", + }, + Object { + "label": "Count", + "value": "count", + }, + Object { + "label": "Positive Rate", + "value": "positive_rate", + }, + Object { + "label": "Max", + "value": "max", + }, + Object { + "label": "Min", + "value": "min", + }, + Object { + "label": "Sum", + "value": "sum", + }, + Object { + "label": "Value Count", + "value": "value_count", + }, + ] + `); + }); + + it('should only display histogram compattible aggs', () => { + const wrapper = setup('histogram', 'avg'); + expect(wrapper.find(EuiComboBox).props().options).toMatchInlineSnapshot(` + Array [ + Object { + "label": "Average", + "value": "avg", + }, + Object { + "label": "Count", + "value": "count", + }, + Object { + "label": "Sum", + "value": "sum", + }, + Object { + "label": "Value Count", + "value": "value_count", + }, + ] + `); + }); + + it('should only display metrics compattible aggs', () => { + const wrapper = setup('metrics', 'avg'); + expect(wrapper.find(EuiComboBox).props().options).toMatchInlineSnapshot(` + Array [ + Object { + "label": "Average", + "value": "avg", + }, + Object { + "label": "Cardinality", + "value": "cardinality", + }, + Object { + "label": "Count", + "value": "count", + }, + Object { + "label": "Filter Ratio", + "value": "filter_ratio", + }, + Object { + "label": "Positive Rate", + "value": "positive_rate", + }, + Object { + "label": "Max", + "value": "max", + }, + Object { + "label": "Min", + "value": "min", + }, + Object { + "label": "Percentile", + "value": "percentile", + }, + Object { + "label": "Percentile Rank", + "value": "percentile_rank", + }, + Object { + "label": "Static Value", + "value": "static", + }, + Object { + "label": "Std. Deviation", + "value": "std_deviation", + }, + Object { + "label": "Sum", + "value": "sum", + }, + Object { + "label": "Sum of Squares", + "value": "sum_of_squares", + }, + Object { + "label": "Top Hit", + "value": "top_hit", + }, + Object { + "label": "Value Count", + "value": "value_count", + }, + Object { + "label": "Variance", + "value": "variance", + }, + ] + `); + }); +}); diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/agg_select.tsx b/src/plugins/vis_type_timeseries/public/application/components/aggs/agg_select.tsx index 6fa1a2adaa08e..7701d351e5478 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/agg_select.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/agg_select.tsx @@ -225,6 +225,19 @@ const specialAggs: AggSelectOption[] = [ }, ]; +const FILTER_RATIO_AGGS = [ + 'avg', + 'cardinality', + 'count', + 'positive_rate', + 'max', + 'min', + 'sum', + 'value_count', +]; + +const HISTOGRAM_AGGS = ['avg', 'count', 'sum', 'value_count']; + const allAggOptions = [...metricAggs, ...pipelineAggs, ...siblingAggs, ...specialAggs]; function filterByPanelType(panelType: string) { @@ -257,6 +270,10 @@ export function AggSelect(props: AggSelectUiProps) { let options: EuiComboBoxOptionOption[]; if (panelType === 'metrics') { options = metricAggs; + } else if (panelType === 'filter_ratio') { + options = metricAggs.filter((m) => FILTER_RATIO_AGGS.includes(`${m.value}`)); + } else if (panelType === 'histogram') { + options = metricAggs.filter((m) => HISTOGRAM_AGGS.includes(`${m.value}`)); } else { const disableSiblingAggs = (agg: AggSelectOption) => ({ ...agg, diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/filter_ratio.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/filter_ratio.js index b5311e3832da4..2aa994c09a2ad 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/filter_ratio.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/filter_ratio.js @@ -36,7 +36,15 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { KBN_FIELD_TYPES } from '../../../../../../plugins/data/public'; -import { METRIC_TYPES } from '../../../../../../plugins/vis_type_timeseries/common/metric_types'; +import { getSupportedFieldsByMetricType } from '../lib/get_supported_fields_by_metric_type'; + +const isFieldHistogram = (fields, indexPattern, field) => { + const indexFields = fields[indexPattern]; + if (!indexFields) return false; + const fieldObject = indexFields.find((f) => f.name === field); + if (!fieldObject) return false; + return fieldObject.type === KBN_FIELD_TYPES.HISTOGRAM; +}; export const FilterRatioAgg = (props) => { const { series, fields, panel } = props; @@ -56,9 +64,6 @@ export const FilterRatioAgg = (props) => { const model = { ...defaults, ...props.model }; const htmlId = htmlIdGenerator(); - const restrictFields = - model.metric_agg === METRIC_TYPES.CARDINALITY ? [] : [KBN_FIELD_TYPES.NUMBER]; - return ( { @@ -149,7 +156,7 @@ export const FilterRatioAgg = (props) => { { + const setup = (metric) => { + const series = { ...SERIES, metrics: [metric] }; + const panel = { ...PANEL, series }; + + const wrapper = mountWithIntl( +
+ +
+ ); + return wrapper; + }; + + describe('histogram support', () => { + it('should only display histogram compattible aggs', () => { + const metric = { + ...METRIC, + metric_agg: 'avg', + field: 'histogram_value', + }; + const wrapper = setup(metric); + expect(wrapper.find(EuiComboBox).at(1).props().options).toMatchInlineSnapshot(` + Array [ + Object { + "label": "Average", + "value": "avg", + }, + Object { + "label": "Count", + "value": "count", + }, + Object { + "label": "Sum", + "value": "sum", + }, + Object { + "label": "Value Count", + "value": "value_count", + }, + ] + `); + }); + const shouldNotHaveHistogramField = (agg) => { + it(`should not have histogram fields for ${agg}`, () => { + const metric = { + ...METRIC, + metric_agg: agg, + field: '', + }; + const wrapper = setup(metric); + expect(wrapper.find(EuiComboBox).at(2).props().options).toMatchInlineSnapshot(` + Array [ + Object { + "label": "number", + "options": Array [ + Object { + "label": "system.cpu.user.pct", + "value": "system.cpu.user.pct", + }, + ], + }, + ] + `); + }); + }; + shouldNotHaveHistogramField('max'); + shouldNotHaveHistogramField('min'); + shouldNotHaveHistogramField('positive_rate'); + + it(`should not have histogram fields for cardinality`, () => { + const metric = { + ...METRIC, + metric_agg: 'cardinality', + field: '', + }; + const wrapper = setup(metric); + expect(wrapper.find(EuiComboBox).at(2).props().options).toMatchInlineSnapshot(` + Array [ + Object { + "label": "date", + "options": Array [ + Object { + "label": "@timestamp", + "value": "@timestamp", + }, + ], + }, + Object { + "label": "number", + "options": Array [ + Object { + "label": "system.cpu.user.pct", + "value": "system.cpu.user.pct", + }, + ], + }, + ] + `); + }); + }); +}); diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/histogram_support.test.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/histogram_support.test.js new file mode 100644 index 0000000000000..7af33ba11f247 --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/histogram_support.test.js @@ -0,0 +1,94 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { Agg } from './agg'; +import { FieldSelect } from './field_select'; +import { FIELDS, METRIC, SERIES, PANEL } from '../../../test_utils'; +const runTest = (aggType, name, test, additionalProps = {}) => { + describe(aggType, () => { + const metric = { + ...METRIC, + type: aggType, + field: 'histogram_value', + ...additionalProps, + }; + const series = { ...SERIES, metrics: [metric] }; + const panel = { ...PANEL, series }; + + it(name, () => { + const wrapper = mountWithIntl( +
+ +
+ ); + test(wrapper); + }); + }); +}; + +describe('Histogram Types', () => { + describe('supported', () => { + const shouldHaveHistogramSupport = (aggType, additionalProps = {}) => { + runTest( + aggType, + 'supports', + (wrapper) => + expect(wrapper.find(FieldSelect).at(0).props().restrict).toContain('histogram'), + additionalProps + ); + }; + shouldHaveHistogramSupport('avg'); + shouldHaveHistogramSupport('sum'); + shouldHaveHistogramSupport('value_count'); + shouldHaveHistogramSupport('percentile'); + shouldHaveHistogramSupport('percentile_rank'); + shouldHaveHistogramSupport('filter_ratio', { metric_agg: 'avg' }); + }); + describe('not supported', () => { + const shouldNotHaveHistogramSupport = (aggType, additionalProps = {}) => { + runTest( + aggType, + 'does not support', + (wrapper) => + expect(wrapper.find(FieldSelect).at(0).props().restrict).not.toContain('histogram'), + additionalProps + ); + }; + shouldNotHaveHistogramSupport('cardinality'); + shouldNotHaveHistogramSupport('max'); + shouldNotHaveHistogramSupport('min'); + shouldNotHaveHistogramSupport('variance'); + shouldNotHaveHistogramSupport('sum_of_squares'); + shouldNotHaveHistogramSupport('std_deviation'); + shouldNotHaveHistogramSupport('positive_rate'); + shouldNotHaveHistogramSupport('top_hit'); + }); +}); diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile.js index 6a7bf1bffe83c..f12c0c8f6f465 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile.js @@ -36,7 +36,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { KBN_FIELD_TYPES } from '../../../../../../plugins/data/public'; import { Percentiles, newPercentile } from './percentile_ui'; -const RESTRICT_FIELDS = [KBN_FIELD_TYPES.NUMBER]; +const RESTRICT_FIELDS = [KBN_FIELD_TYPES.NUMBER, KBN_FIELD_TYPES.HISTOGRAM]; const checkModel = (model) => Array.isArray(model.percentiles); diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/percentile_rank.tsx b/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/percentile_rank.tsx index a16f5aeefc49c..d02a16ade2bba 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/percentile_rank.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/percentile_rank.tsx @@ -41,7 +41,7 @@ import { IFieldType, KBN_FIELD_TYPES } from '../../../../../../../plugins/data/p import { MetricsItemsSchema, PanelSchema, SeriesItemsSchema } from '../../../../../common/types'; import { DragHandleProps } from '../../../../types'; -const RESTRICT_FIELDS = [KBN_FIELD_TYPES.NUMBER]; +const RESTRICT_FIELDS = [KBN_FIELD_TYPES.NUMBER, KBN_FIELD_TYPES.HISTOGRAM]; interface PercentileRankAggProps { disableDelete: boolean; diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/positive_rate.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/positive_rate.js index 3ca89f7289d65..c20bcc1babc1d 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/positive_rate.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/positive_rate.js @@ -123,7 +123,7 @@ export const PositiveRateAgg = (props) => { t !== KBN_FIELD_TYPES.HISTOGRAM); + case METRIC_TYPES.VALUE_COUNT: + case METRIC_TYPES.AVERAGE: + case METRIC_TYPES.SUM: + return [KBN_FIELD_TYPES.NUMBER, KBN_FIELD_TYPES.HISTOGRAM]; + default: + return [KBN_FIELD_TYPES.NUMBER]; + } +} diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/get_supported_fields_by_metric_type.test.js b/src/plugins/vis_type_timeseries/public/application/components/lib/get_supported_fields_by_metric_type.test.js new file mode 100644 index 0000000000000..3cd3fac191bf1 --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/get_supported_fields_by_metric_type.test.js @@ -0,0 +1,44 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { getSupportedFieldsByMetricType } from './get_supported_fields_by_metric_type'; + +describe('getSupportedFieldsByMetricType', () => { + const shouldHaveHistogramAndNumbers = (type) => + it(`should return numbers and histogram for ${type}`, () => { + expect(getSupportedFieldsByMetricType(type)).toEqual(['number', 'histogram']); + }); + const shouldHaveOnlyNumbers = (type) => + it(`should return only numbers for ${type}`, () => { + expect(getSupportedFieldsByMetricType(type)).toEqual(['number']); + }); + + shouldHaveHistogramAndNumbers('value_count'); + shouldHaveHistogramAndNumbers('avg'); + shouldHaveHistogramAndNumbers('sum'); + + shouldHaveOnlyNumbers('positive_rate'); + shouldHaveOnlyNumbers('std_deviation'); + shouldHaveOnlyNumbers('max'); + shouldHaveOnlyNumbers('min'); + + it(`should return everything but histogram for cardinality`, () => { + expect(getSupportedFieldsByMetricType('cardinality')).not.toContain('histogram'); + }); +}); diff --git a/src/plugins/vis_type_timeseries/public/test_utils/index.ts b/src/plugins/vis_type_timeseries/public/test_utils/index.ts new file mode 100644 index 0000000000000..96ecc89b70c2d --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/test_utils/index.ts @@ -0,0 +1,50 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export const UI_RESTRICTIONS = { '*': true }; +export const INDEX_PATTERN = 'some-pattern'; +export const FIELDS = { + [INDEX_PATTERN]: [ + { + type: 'date', + name: '@timestamp', + }, + { + type: 'number', + name: 'system.cpu.user.pct', + }, + { + type: 'histogram', + name: 'histogram_value', + }, + ], +}; +export const METRIC = { + id: 'sample_metric', + type: 'avg', + field: 'system.cpu.user.pct', +}; +export const SERIES = { + metrics: [METRIC], +}; +export const PANEL = { + type: 'timeseries', + index_pattern: INDEX_PATTERN, + series: SERIES, +}; From 4db58164597638e20f255f6450a9a29c97618a07 Mon Sep 17 00:00:00 2001 From: Kerry Gallagher Date: Mon, 13 Jul 2020 18:09:10 +0100 Subject: [PATCH 23/40] [Logs UI] Add category anomalies to anomalies page (#70982) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add category anomalies to anomalies page Co-authored-by: Felix Stürmer Co-authored-by: Elastic Machine --- .../http_api/log_analysis/results/index.ts | 3 +- .../results/log_entry_anomalies.ts | 137 ++++++ .../results/log_entry_examples.ts | 82 ++++ .../results/log_entry_rate_examples.ts | 77 ---- .../log_analysis/log_analysis_results.ts | 4 + .../log_entry_rate/page_results_content.tsx | 121 +++--- .../sections/anomalies/chart.tsx | 97 +++-- .../sections/anomalies/expanded_row.tsx | 58 ++- .../sections/anomalies/index.tsx | 162 ++++--- .../sections/anomalies/log_entry_example.tsx | 22 +- .../sections/anomalies/table.tsx | 303 +++++++------ .../sections/log_rate/bar_chart.tsx | 100 ----- .../sections/log_rate/index.tsx | 98 ----- .../service_calls/get_log_entry_anomalies.ts | 41 ++ ..._examples.ts => get_log_entry_examples.ts} | 14 +- .../use_log_entry_anomalies_results.ts | 262 ++++++++++++ .../log_entry_rate/use_log_entry_examples.ts | 65 +++ .../use_log_entry_rate_examples.ts | 63 --- x-pack/plugins/infra/server/infra_server.ts | 6 +- .../infra/server/lib/log_analysis/common.ts | 29 ++ .../infra/server/lib/log_analysis/errors.ts | 7 + .../infra/server/lib/log_analysis/index.ts | 1 + .../lib/log_analysis/log_entry_anomalies.ts | 398 ++++++++++++++++++ .../log_entry_categories_analysis.ts | 30 +- .../log_analysis/log_entry_rate_analysis.ts | 145 +------ .../server/lib/log_analysis/queries/common.ts | 8 + .../server/lib/log_analysis/queries/index.ts | 1 + .../queries/log_entry_anomalies.ts | 128 ++++++ ...rate_examples.ts => log_entry_examples.ts} | 41 +- .../routes/log_analysis/results/index.ts | 3 +- .../results/log_entry_anomalies.ts | 112 +++++ ...rate_examples.ts => log_entry_examples.ts} | 20 +- .../translations/translations/ja-JP.json | 9 - .../translations/translations/zh-CN.json | 9 - 34 files changed, 1764 insertions(+), 892 deletions(-) create mode 100644 x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_anomalies.ts create mode 100644 x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_examples.ts delete mode 100644 x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_rate_examples.ts delete mode 100644 x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/log_rate/bar_chart.tsx delete mode 100644 x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/log_rate/index.tsx create mode 100644 x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_anomalies.ts rename x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/{get_log_entry_rate_examples.ts => get_log_entry_examples.ts} (77%) create mode 100644 x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_anomalies_results.ts create mode 100644 x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_examples.ts delete mode 100644 x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_examples.ts create mode 100644 x-pack/plugins/infra/server/lib/log_analysis/common.ts create mode 100644 x-pack/plugins/infra/server/lib/log_analysis/log_entry_anomalies.ts create mode 100644 x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_anomalies.ts rename x-pack/plugins/infra/server/lib/log_analysis/queries/{log_entry_rate_examples.ts => log_entry_examples.ts} (59%) create mode 100644 x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_anomalies.ts rename x-pack/plugins/infra/server/routes/log_analysis/results/{log_entry_rate_examples.ts => log_entry_examples.ts} (75%) diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/results/index.ts b/x-pack/plugins/infra/common/http_api/log_analysis/results/index.ts index 30b6be435837b..cbd89db97236f 100644 --- a/x-pack/plugins/infra/common/http_api/log_analysis/results/index.ts +++ b/x-pack/plugins/infra/common/http_api/log_analysis/results/index.ts @@ -8,4 +8,5 @@ export * from './log_entry_categories'; export * from './log_entry_category_datasets'; export * from './log_entry_category_examples'; export * from './log_entry_rate'; -export * from './log_entry_rate_examples'; +export * from './log_entry_examples'; +export * from './log_entry_anomalies'; diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_anomalies.ts b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_anomalies.ts new file mode 100644 index 0000000000000..639ac63f9b14d --- /dev/null +++ b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_anomalies.ts @@ -0,0 +1,137 @@ +/* + * 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 * as rt from 'io-ts'; + +import { timeRangeRT, routeTimingMetadataRT } from '../../shared'; + +export const LOG_ANALYSIS_GET_LOG_ENTRY_ANOMALIES_PATH = + '/api/infra/log_analysis/results/log_entry_anomalies'; + +// [Sort field value, tiebreaker value] +const paginationCursorRT = rt.tuple([ + rt.union([rt.string, rt.number]), + rt.union([rt.string, rt.number]), +]); + +export type PaginationCursor = rt.TypeOf; + +export const anomalyTypeRT = rt.keyof({ + logRate: null, + logCategory: null, +}); + +export type AnomalyType = rt.TypeOf; + +const logEntryAnomalyCommonFieldsRT = rt.type({ + id: rt.string, + anomalyScore: rt.number, + dataset: rt.string, + typical: rt.number, + actual: rt.number, + type: anomalyTypeRT, + duration: rt.number, + startTime: rt.number, + jobId: rt.string, +}); +const logEntrylogRateAnomalyRT = logEntryAnomalyCommonFieldsRT; +const logEntrylogCategoryAnomalyRT = rt.partial({ + categoryId: rt.string, +}); +const logEntryAnomalyRT = rt.intersection([ + logEntryAnomalyCommonFieldsRT, + logEntrylogRateAnomalyRT, + logEntrylogCategoryAnomalyRT, +]); + +export type LogEntryAnomaly = rt.TypeOf; + +export const getLogEntryAnomaliesSuccessReponsePayloadRT = rt.intersection([ + rt.type({ + data: rt.intersection([ + rt.type({ + anomalies: rt.array(logEntryAnomalyRT), + // Signifies there are more entries backwards or forwards. If this was a request + // for a previous page, there are more previous pages, if this was a request for a next page, + // there are more next pages. + hasMoreEntries: rt.boolean, + }), + rt.partial({ + paginationCursors: rt.type({ + // The cursor to use to fetch the previous page + previousPageCursor: paginationCursorRT, + // The cursor to use to fetch the next page + nextPageCursor: paginationCursorRT, + }), + }), + ]), + }), + rt.partial({ + timing: routeTimingMetadataRT, + }), +]); + +export type GetLogEntryAnomaliesSuccessResponsePayload = rt.TypeOf< + typeof getLogEntryAnomaliesSuccessReponsePayloadRT +>; + +const sortOptionsRT = rt.keyof({ + anomalyScore: null, + dataset: null, + startTime: null, +}); + +const sortDirectionsRT = rt.keyof({ + asc: null, + desc: null, +}); + +const paginationPreviousPageCursorRT = rt.type({ + searchBefore: paginationCursorRT, +}); + +const paginationNextPageCursorRT = rt.type({ + searchAfter: paginationCursorRT, +}); + +const paginationRT = rt.intersection([ + rt.type({ + pageSize: rt.number, + }), + rt.partial({ + cursor: rt.union([paginationPreviousPageCursorRT, paginationNextPageCursorRT]), + }), +]); + +export type Pagination = rt.TypeOf; + +const sortRT = rt.type({ + field: sortOptionsRT, + direction: sortDirectionsRT, +}); + +export type Sort = rt.TypeOf; + +export const getLogEntryAnomaliesRequestPayloadRT = rt.type({ + data: rt.intersection([ + rt.type({ + // the ID of the source configuration + sourceId: rt.string, + // the time range to fetch the log entry anomalies from + timeRange: timeRangeRT, + }), + rt.partial({ + // Pagination properties + pagination: paginationRT, + // Sort properties + sort: sortRT, + }), + ]), +}); + +export type GetLogEntryAnomaliesRequestPayload = rt.TypeOf< + typeof getLogEntryAnomaliesRequestPayloadRT +>; diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_examples.ts b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_examples.ts new file mode 100644 index 0000000000000..1eed29cd37560 --- /dev/null +++ b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_examples.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +import { + badRequestErrorRT, + forbiddenErrorRT, + timeRangeRT, + routeTimingMetadataRT, +} from '../../shared'; + +export const LOG_ANALYSIS_GET_LOG_ENTRY_RATE_EXAMPLES_PATH = + '/api/infra/log_analysis/results/log_entry_examples'; + +/** + * request + */ + +export const getLogEntryExamplesRequestPayloadRT = rt.type({ + data: rt.intersection([ + rt.type({ + // the dataset to fetch the log rate examples from + dataset: rt.string, + // the number of examples to fetch + exampleCount: rt.number, + // the id of the source configuration + sourceId: rt.string, + // the time range to fetch the log rate examples from + timeRange: timeRangeRT, + }), + rt.partial({ + categoryId: rt.string, + }), + ]), +}); + +export type GetLogEntryExamplesRequestPayload = rt.TypeOf< + typeof getLogEntryExamplesRequestPayloadRT +>; + +/** + * response + */ + +const logEntryExampleRT = rt.type({ + id: rt.string, + dataset: rt.string, + message: rt.string, + timestamp: rt.number, + tiebreaker: rt.number, +}); + +export type LogEntryExample = rt.TypeOf; + +export const getLogEntryExamplesSuccessReponsePayloadRT = rt.intersection([ + rt.type({ + data: rt.type({ + examples: rt.array(logEntryExampleRT), + }), + }), + rt.partial({ + timing: routeTimingMetadataRT, + }), +]); + +export type GetLogEntryExamplesSuccessReponsePayload = rt.TypeOf< + typeof getLogEntryExamplesSuccessReponsePayloadRT +>; + +export const getLogEntryExamplesResponsePayloadRT = rt.union([ + getLogEntryExamplesSuccessReponsePayloadRT, + badRequestErrorRT, + forbiddenErrorRT, +]); + +export type GetLogEntryExamplesResponsePayload = rt.TypeOf< + typeof getLogEntryExamplesResponsePayloadRT +>; diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_rate_examples.ts b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_rate_examples.ts deleted file mode 100644 index 700f87ec3beb1..0000000000000 --- a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_rate_examples.ts +++ /dev/null @@ -1,77 +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 * as rt from 'io-ts'; - -import { - badRequestErrorRT, - forbiddenErrorRT, - timeRangeRT, - routeTimingMetadataRT, -} from '../../shared'; - -export const LOG_ANALYSIS_GET_LOG_ENTRY_RATE_EXAMPLES_PATH = - '/api/infra/log_analysis/results/log_entry_rate_examples'; - -/** - * request - */ - -export const getLogEntryRateExamplesRequestPayloadRT = rt.type({ - data: rt.type({ - // the dataset to fetch the log rate examples from - dataset: rt.string, - // the number of examples to fetch - exampleCount: rt.number, - // the id of the source configuration - sourceId: rt.string, - // the time range to fetch the log rate examples from - timeRange: timeRangeRT, - }), -}); - -export type GetLogEntryRateExamplesRequestPayload = rt.TypeOf< - typeof getLogEntryRateExamplesRequestPayloadRT ->; - -/** - * response - */ - -const logEntryRateExampleRT = rt.type({ - id: rt.string, - dataset: rt.string, - message: rt.string, - timestamp: rt.number, - tiebreaker: rt.number, -}); - -export type LogEntryRateExample = rt.TypeOf; - -export const getLogEntryRateExamplesSuccessReponsePayloadRT = rt.intersection([ - rt.type({ - data: rt.type({ - examples: rt.array(logEntryRateExampleRT), - }), - }), - rt.partial({ - timing: routeTimingMetadataRT, - }), -]); - -export type GetLogEntryRateExamplesSuccessReponsePayload = rt.TypeOf< - typeof getLogEntryRateExamplesSuccessReponsePayloadRT ->; - -export const getLogEntryRateExamplesResponsePayloadRT = rt.union([ - getLogEntryRateExamplesSuccessReponsePayloadRT, - badRequestErrorRT, - forbiddenErrorRT, -]); - -export type GetLogEntryRateExamplesResponsePayload = rt.TypeOf< - typeof getLogEntryRateExamplesResponsePayloadRT ->; diff --git a/x-pack/plugins/infra/common/log_analysis/log_analysis_results.ts b/x-pack/plugins/infra/common/log_analysis/log_analysis_results.ts index 19c92cb381104..f4497dbba5056 100644 --- a/x-pack/plugins/infra/common/log_analysis/log_analysis_results.ts +++ b/x-pack/plugins/infra/common/log_analysis/log_analysis_results.ts @@ -41,6 +41,10 @@ export const formatAnomalyScore = (score: number) => { return Math.round(score); }; +export const formatOneDecimalPlace = (number: number) => { + return Math.round(number * 10) / 10; +}; + export const getFriendlyNameForPartitionId = (partitionId: string) => { return partitionId !== '' ? partitionId : 'unknown'; }; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx index bf4dbcd87cc41..21c3e3ec70029 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx @@ -5,30 +5,18 @@ */ import datemath from '@elastic/datemath'; -import { - EuiBadge, - EuiFlexGroup, - EuiFlexItem, - EuiPage, - EuiPanel, - EuiSuperDatePicker, - EuiText, -} from '@elastic/eui'; -import numeral from '@elastic/numeral'; -import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFlexGroup, EuiFlexItem, EuiPage, EuiPanel, EuiSuperDatePicker } from '@elastic/eui'; import moment from 'moment'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { euiStyled, useTrackPageview } from '../../../../../observability/public'; import { TimeRange } from '../../../../common/http_api/shared/time_range'; import { bucketSpan } from '../../../../common/log_analysis'; -import { LoadingOverlayWrapper } from '../../../components/loading_overlay_wrapper'; import { LogAnalysisJobProblemIndicator } from '../../../components/logging/log_analysis_job_status'; import { useInterval } from '../../../hooks/use_interval'; -import { useKibanaUiSetting } from '../../../utils/use_kibana_ui_setting'; import { AnomaliesResults } from './sections/anomalies'; -import { LogRateResults } from './sections/log_rate'; import { useLogEntryRateModuleContext } from './use_log_entry_rate_module'; import { useLogEntryRateResults } from './use_log_entry_rate_results'; +import { useLogEntryAnomaliesResults } from './use_log_entry_anomalies_results'; import { StringTimeRange, useLogAnalysisResultsUrlState, @@ -36,6 +24,15 @@ import { const JOB_STATUS_POLLING_INTERVAL = 30000; +export const SORT_DEFAULTS = { + direction: 'desc' as const, + field: 'anomalyScore' as const, +}; + +export const PAGINATION_DEFAULTS = { + pageSize: 25, +}; + interface LogEntryRateResultsContentProps { onOpenSetup: () => void; } @@ -46,8 +43,6 @@ export const LogEntryRateResultsContent: React.FunctionComponent { setQueryTimeRange({ @@ -182,45 +194,18 @@ export const LogEntryRateResultsContent: React.FunctionComponent - - - - {logEntryRate ? ( - - - - - {numeral(logEntryRate.totalNumberOfLogEntries).format('0.00a')} - - - ), - startTime: ( - {moment(queryTimeRange.value.startTime).format(dateFormat)} - ), - endTime: {moment(queryTimeRange.value.endTime).format(dateFormat)}, - }} - /> - - - ) : null} - - - - - - + + + + + - - - - - diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/chart.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/chart.tsx index 79ab4475ee5a3..ae5c3b5b93b47 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/chart.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/chart.tsx @@ -3,7 +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 { EuiEmptyPrompt } from '@elastic/eui'; import { RectAnnotationDatum, AnnotationId } from '@elastic/charts'; import { Axis, @@ -21,6 +21,7 @@ import numeral from '@elastic/numeral'; import { i18n } from '@kbn/i18n'; import moment from 'moment'; import React, { useCallback, useMemo } from 'react'; +import { LoadingOverlayWrapper } from '../../../../../components/loading_overlay_wrapper'; import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; import { @@ -36,7 +37,16 @@ export const AnomaliesChart: React.FunctionComponent<{ series: Array<{ time: number; value: number }>; annotations: Record; renderAnnotationTooltip?: (details?: string) => JSX.Element; -}> = ({ chartId, series, annotations, setTimeRange, timeRange, renderAnnotationTooltip }) => { + isLoading: boolean; +}> = ({ + chartId, + series, + annotations, + setTimeRange, + timeRange, + renderAnnotationTooltip, + isLoading, +}) => { const [dateFormat] = useKibanaUiSetting('dateFormat', 'Y-MM-DD HH:mm:ss.SSS'); const [isDarkMode] = useKibanaUiSetting('theme:darkMode'); @@ -68,41 +78,56 @@ export const AnomaliesChart: React.FunctionComponent<{ [setTimeRange] ); - return ( -
- - - numeral(value.toPrecision(3)).format('0[.][00]a')} // https://github.com/adamwdraper/Numeral-js/issues/194 - /> - + {i18n.translate('xpack.infra.logs.analysis.anomalySectionLogRateChartNoData', { + defaultMessage: 'There is no log rate data to display.', })} - xScaleType="time" - yScaleType="linear" - xAccessor={'time'} - yAccessors={['value']} - data={series} - barSeriesStyle={barSeriesStyle} - /> - {renderAnnotations(annotations, chartId, renderAnnotationTooltip)} - - -
+ + } + titleSize="m" + /> + ) : ( + +
+ {series.length ? ( + + + numeral(value.toPrecision(3)).format('0[.][00]a')} // https://github.com/adamwdraper/Numeral-js/issues/194 + /> + + {renderAnnotations(annotations, chartId, renderAnnotationTooltip)} + + + ) : null} +
+
); }; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/expanded_row.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/expanded_row.tsx index c527b8c49d099..e4b12e199a048 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/expanded_row.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/expanded_row.tsx @@ -10,12 +10,12 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { useMount } from 'react-use'; import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; -import { AnomalyRecord } from '../../use_log_entry_rate_results'; -import { useLogEntryRateModuleContext } from '../../use_log_entry_rate_module'; -import { useLogEntryRateExamples } from '../../use_log_entry_rate_examples'; +import { LogEntryAnomaly } from '../../../../../../common/http_api'; +import { useLogEntryExamples } from '../../use_log_entry_examples'; import { LogEntryExampleMessages } from '../../../../../components/logging/log_entry_examples/log_entry_examples'; -import { LogEntryRateExampleMessage, LogEntryRateExampleMessageHeaders } from './log_entry_example'; +import { LogEntryExampleMessage, LogEntryExampleMessageHeaders } from './log_entry_example'; import { euiStyled } from '../../../../../../../observability/public'; +import { useLogSourceContext } from '../../../../../containers/logs/log_source'; const EXAMPLE_COUNT = 5; @@ -24,29 +24,27 @@ const examplesTitle = i18n.translate('xpack.infra.logs.analysis.anomaliesTableEx }); export const AnomaliesTableExpandedRow: React.FunctionComponent<{ - anomaly: AnomalyRecord; + anomaly: LogEntryAnomaly; timeRange: TimeRange; - jobId: string; -}> = ({ anomaly, timeRange, jobId }) => { - const { - sourceConfiguration: { sourceId }, - } = useLogEntryRateModuleContext(); +}> = ({ anomaly, timeRange }) => { + const { sourceId } = useLogSourceContext(); const { - getLogEntryRateExamples, - hasFailedLoadingLogEntryRateExamples, - isLoadingLogEntryRateExamples, - logEntryRateExamples, - } = useLogEntryRateExamples({ - dataset: anomaly.partitionId, + getLogEntryExamples, + hasFailedLoadingLogEntryExamples, + isLoadingLogEntryExamples, + logEntryExamples, + } = useLogEntryExamples({ + dataset: anomaly.dataset, endTime: anomaly.startTime + anomaly.duration, exampleCount: EXAMPLE_COUNT, sourceId, startTime: anomaly.startTime, + categoryId: anomaly.categoryId, }); useMount(() => { - getLogEntryRateExamples(); + getLogEntryExamples(); }); return ( @@ -57,17 +55,17 @@ export const AnomaliesTableExpandedRow: React.FunctionComponent<{

{examplesTitle}

0} + isLoading={isLoadingLogEntryExamples} + hasFailedLoading={hasFailedLoadingLogEntryExamples} + hasResults={logEntryExamples.length > 0} exampleCount={EXAMPLE_COUNT} - onReload={getLogEntryRateExamples} + onReload={getLogEntryExamples} > - {logEntryRateExamples.length > 0 ? ( + {logEntryExamples.length > 0 ? ( <> - - {logEntryRateExamples.map((example, exampleIndex) => ( - + {logEntryExamples.map((example, exampleIndex) => ( + ))} @@ -87,11 +85,11 @@ export const AnomaliesTableExpandedRow: React.FunctionComponent<{ void; timeRange: TimeRange; viewSetupForReconfiguration: () => void; - jobId: string; -}> = ({ isLoading, results, setTimeRange, timeRange, viewSetupForReconfiguration, jobId }) => { - const hasAnomalies = useMemo(() => { - return results && results.histogramBuckets - ? results.histogramBuckets.some((bucket) => { - return bucket.partitions.some((partition) => { - return partition.anomalies.length > 0; - }); - }) - : false; - }, [results]); - + page: Page; + fetchNextPage?: FetchNextPage; + fetchPreviousPage?: FetchPreviousPage; + changeSortOptions: ChangeSortOptions; + changePaginationOptions: ChangePaginationOptions; + sortOptions: SortOptions; + paginationOptions: PaginationOptions; +}> = ({ + isLoadingLogRateResults, + isLoadingAnomaliesResults, + logEntryRateResults, + setTimeRange, + timeRange, + viewSetupForReconfiguration, + anomalies, + changeSortOptions, + sortOptions, + changePaginationOptions, + paginationOptions, + fetchNextPage, + fetchPreviousPage, + page, +}) => { const logEntryRateSeries = useMemo( - () => (results && results.histogramBuckets ? getLogEntryRateCombinedSeries(results) : []), - [results] + () => + logEntryRateResults && logEntryRateResults.histogramBuckets + ? getLogEntryRateCombinedSeries(logEntryRateResults) + : [], + [logEntryRateResults] ); const anomalyAnnotations = useMemo( () => - results && results.histogramBuckets - ? getAnnotationsForAll(results) + logEntryRateResults && logEntryRateResults.histogramBuckets + ? getAnnotationsForAll(logEntryRateResults) : { warning: [], minor: [], major: [], critical: [], }, - [results] + [logEntryRateResults] ); return ( <> - -

{title}

+ +

{title}

- - -
- }> - {!results || (results && results.histogramBuckets && !results.histogramBuckets.length) ? ( + {(!logEntryRateResults || + (logEntryRateResults && + logEntryRateResults.histogramBuckets && + !logEntryRateResults.histogramBuckets.length)) && + (!anomalies || anomalies.length === 0) ? ( + } + > @@ -94,41 +123,38 @@ export const AnomaliesResults: React.FunctionComponent<{

} /> - ) : !hasAnomalies ? ( - - {i18n.translate('xpack.infra.logs.analysis.anomalySectionNoAnomaliesTitle', { - defaultMessage: 'No anomalies were detected.', - })} - - } - titleSize="m" +
+ ) : ( + <> + + + + + + + - ) : ( - <> - - - - - - - - - )} -
+ + )} ); }; @@ -137,13 +163,6 @@ const title = i18n.translate('xpack.infra.logs.analysis.anomaliesSectionTitle', defaultMessage: 'Anomalies', }); -const loadingAriaLabel = i18n.translate( - 'xpack.infra.logs.analysis.anomaliesSectionLoadingAriaLabel', - { defaultMessage: 'Loading anomalies' } -); - -const LoadingOverlayContent = () => ; - interface ParsedAnnotationDetails { anomalyScoresByPartition: Array<{ partitionName: string; maximumAnomalyScore: number }>; } @@ -189,3 +208,10 @@ const renderAnnotationTooltip = (details?: string) => { const TooltipWrapper = euiStyled('div')` white-space: nowrap; `; + +const loadingAriaLabel = i18n.translate( + 'xpack.infra.logs.analysis.anomaliesSectionLoadingAriaLabel', + { defaultMessage: 'Loading anomalies' } +); + +const LoadingOverlayContent = () => ; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx index 96f665b3693ca..2965e1fede822 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx @@ -28,7 +28,7 @@ import { useLinkProps } from '../../../../../hooks/use_link_props'; import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; import { partitionField } from '../../../../../../common/log_analysis/job_parameters'; import { getEntitySpecificSingleMetricViewerLink } from '../../../../../components/logging/log_analysis_results/analyze_in_ml_button'; -import { LogEntryRateExample } from '../../../../../../common/http_api/log_analysis/results'; +import { LogEntryExample } from '../../../../../../common/http_api/log_analysis/results'; import { LogColumnConfiguration, isTimestampLogColumnConfiguration, @@ -36,6 +36,7 @@ import { isMessageLogColumnConfiguration, } from '../../../../../utils/source_configuration'; import { localizedDate } from '../../../../../../common/formatters/datetime'; +import { LogEntryAnomaly } from '../../../../../../common/http_api'; export const exampleMessageScale = 'medium' as const; export const exampleTimestampFormat = 'time' as const; @@ -58,19 +59,19 @@ const VIEW_ANOMALY_IN_ML_LABEL = i18n.translate( } ); -type Props = LogEntryRateExample & { +type Props = LogEntryExample & { timeRange: TimeRange; - jobId: string; + anomaly: LogEntryAnomaly; }; -export const LogEntryRateExampleMessage: React.FunctionComponent = ({ +export const LogEntryExampleMessage: React.FunctionComponent = ({ id, dataset, message, timestamp, tiebreaker, timeRange, - jobId, + anomaly, }) => { const [isHovered, setIsHovered] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false); @@ -107,8 +108,9 @@ export const LogEntryRateExampleMessage: React.FunctionComponent = ({ }); const viewAnomalyInMachineLearningLinkProps = useLinkProps( - getEntitySpecificSingleMetricViewerLink(jobId, timeRange, { + getEntitySpecificSingleMetricViewerLink(anomaly.jobId, timeRange, { [partitionField]: dataset, + ...(anomaly.categoryId ? { mlcategory: anomaly.categoryId } : {}), }) ); @@ -233,11 +235,11 @@ export const exampleMessageColumnConfigurations: LogColumnConfiguration[] = [ }, ]; -export const LogEntryRateExampleMessageHeaders: React.FunctionComponent<{ +export const LogEntryExampleMessageHeaders: React.FunctionComponent<{ dateTime: number; }> = ({ dateTime }) => { return ( - + <> {exampleMessageColumnConfigurations.map((columnConfiguration) => { if (isTimestampLogColumnConfiguration(columnConfiguration)) { @@ -280,11 +282,11 @@ export const LogEntryRateExampleMessageHeaders: React.FunctionComponent<{ {null} - + ); }; -const LogEntryRateExampleMessageHeadersWrapper = euiStyled(LogColumnHeadersWrapper)` +const LogEntryExampleMessageHeadersWrapper = euiStyled(LogColumnHeadersWrapper)` border-bottom: none; box-shadow: none; padding-right: 0; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx index c70a456bfe06a..e0a3b6fb91db0 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx @@ -4,45 +4,52 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiBasicTable, EuiBasicTableColumn } from '@elastic/eui'; +import { + EuiBasicTable, + EuiBasicTableColumn, + EuiIcon, + EuiFlexGroup, + EuiFlexItem, + EuiButtonIcon, + EuiSpacer, +} from '@elastic/eui'; import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; import moment from 'moment'; import { i18n } from '@kbn/i18n'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { useSet } from 'react-use'; import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; import { formatAnomalyScore, getFriendlyNameForPartitionId, + formatOneDecimalPlace, } from '../../../../../../common/log_analysis'; +import { AnomalyType } from '../../../../../../common/http_api/log_analysis'; import { RowExpansionButton } from '../../../../../components/basic_table'; -import { LogEntryRateResults } from '../../use_log_entry_rate_results'; import { AnomaliesTableExpandedRow } from './expanded_row'; import { AnomalySeverityIndicator } from '../../../../../components/logging/log_analysis_results/anomaly_severity_indicator'; import { useKibanaUiSetting } from '../../../../../utils/use_kibana_ui_setting'; +import { + Page, + FetchNextPage, + FetchPreviousPage, + ChangeSortOptions, + ChangePaginationOptions, + SortOptions, + PaginationOptions, + LogEntryAnomalies, +} from '../../use_log_entry_anomalies_results'; +import { LoadingOverlayWrapper } from '../../../../../components/loading_overlay_wrapper'; interface TableItem { id: string; dataset: string; datasetName: string; anomalyScore: number; - anomalyMessage: string; startTime: number; -} - -interface SortingOptions { - sort: { - field: keyof TableItem; - direction: 'asc' | 'desc'; - }; -} - -interface PaginationOptions { - pageIndex: number; - pageSize: number; - totalItemCount: number; - pageSizeOptions: number[]; - hidePerPageOptions: boolean; + typical: number; + actual: number; + type: AnomalyType; } const anomalyScoreColumnName = i18n.translate( @@ -73,125 +80,78 @@ const datasetColumnName = i18n.translate( } ); -const moreThanExpectedAnomalyMessage = i18n.translate( - 'xpack.infra.logs.analysis.anomaliesTableMoreThanExpectedAnomalyMessage', - { - defaultMessage: 'More log messages in this dataset than expected', - } -); - -const fewerThanExpectedAnomalyMessage = i18n.translate( - 'xpack.infra.logs.analysis.anomaliesTableFewerThanExpectedAnomalyMessage', - { - defaultMessage: 'Fewer log messages in this dataset than expected', - } -); - -const getAnomalyMessage = (actualRate: number, typicalRate: number): string => { - return actualRate < typicalRate - ? fewerThanExpectedAnomalyMessage - : moreThanExpectedAnomalyMessage; -}; - export const AnomaliesTable: React.FunctionComponent<{ - results: LogEntryRateResults; + results: LogEntryAnomalies; setTimeRange: (timeRange: TimeRange) => void; timeRange: TimeRange; - jobId: string; -}> = ({ results, timeRange, setTimeRange, jobId }) => { + changeSortOptions: ChangeSortOptions; + changePaginationOptions: ChangePaginationOptions; + sortOptions: SortOptions; + paginationOptions: PaginationOptions; + page: Page; + fetchNextPage?: FetchNextPage; + fetchPreviousPage?: FetchPreviousPage; + isLoading: boolean; +}> = ({ + results, + timeRange, + setTimeRange, + changeSortOptions, + sortOptions, + changePaginationOptions, + paginationOptions, + fetchNextPage, + fetchPreviousPage, + page, + isLoading, +}) => { const [dateFormat] = useKibanaUiSetting('dateFormat', 'Y-MM-DD HH:mm:ss'); + const tableSortOptions = useMemo(() => { + return { + sort: sortOptions, + }; + }, [sortOptions]); + const tableItems: TableItem[] = useMemo(() => { - return results.anomalies.map((anomaly) => { + return results.map((anomaly) => { return { id: anomaly.id, - dataset: anomaly.partitionId, - datasetName: getFriendlyNameForPartitionId(anomaly.partitionId), + dataset: anomaly.dataset, + datasetName: getFriendlyNameForPartitionId(anomaly.dataset), anomalyScore: formatAnomalyScore(anomaly.anomalyScore), - anomalyMessage: getAnomalyMessage(anomaly.actualLogEntryRate, anomaly.typicalLogEntryRate), startTime: anomaly.startTime, + type: anomaly.type, + typical: anomaly.typical, + actual: anomaly.actual, }; }); }, [results]); const [expandedIds, { add: expandId, remove: collapseId }] = useSet(new Set()); - const expandedDatasetRowContents = useMemo( + const expandedIdsRowContents = useMemo( () => - [...expandedIds].reduce>((aggregatedDatasetRows, id) => { - const anomaly = results.anomalies.find((_anomaly) => _anomaly.id === id); + [...expandedIds].reduce>((aggregatedRows, id) => { + const anomaly = results.find((_anomaly) => _anomaly.id === id); return { - ...aggregatedDatasetRows, + ...aggregatedRows, [id]: anomaly ? ( - + ) : null, }; }, {}), - [expandedIds, results, timeRange, jobId] + [expandedIds, results, timeRange] ); - const [sorting, setSorting] = useState({ - sort: { - field: 'anomalyScore', - direction: 'desc', - }, - }); - - const [_pagination, setPagination] = useState({ - pageIndex: 0, - pageSize: 20, - totalItemCount: results.anomalies.length, - pageSizeOptions: [10, 20, 50], - hidePerPageOptions: false, - }); - - const paginationOptions = useMemo(() => { - return { - ..._pagination, - totalItemCount: results.anomalies.length, - }; - }, [_pagination, results]); - const handleTableChange = useCallback( - ({ page = {}, sort = {} }) => { - const { index, size } = page; - setPagination((currentPagination) => { - return { - ...currentPagination, - pageIndex: index, - pageSize: size, - }; - }); - const { field, direction } = sort; - setSorting({ - sort: { - field, - direction, - }, - }); + ({ sort = {} }) => { + changeSortOptions(sort); }, - [setSorting, setPagination] + [changeSortOptions] ); - const sortedTableItems = useMemo(() => { - let sortedItems: TableItem[] = []; - if (sorting.sort.field === 'datasetName') { - sortedItems = tableItems.sort((a, b) => (a.datasetName > b.datasetName ? 1 : -1)); - } else if (sorting.sort.field === 'anomalyScore') { - sortedItems = tableItems.sort((a, b) => a.anomalyScore - b.anomalyScore); - } else if (sorting.sort.field === 'startTime') { - sortedItems = tableItems.sort((a, b) => a.startTime - b.startTime); - } - - return sorting.sort.direction === 'asc' ? sortedItems : sortedItems.reverse(); - }, [tableItems, sorting]); - - const pageOfItems: TableItem[] = useMemo(() => { - const { pageIndex, pageSize } = paginationOptions; - return sortedTableItems.slice(pageIndex * pageSize, pageIndex * pageSize + pageSize); - }, [paginationOptions, sortedTableItems]); - const columns: Array> = useMemo( () => [ { @@ -204,10 +164,11 @@ export const AnomaliesTable: React.FunctionComponent<{ render: (anomalyScore: number) => , }, { - field: 'anomalyMessage', name: anomalyMessageColumnName, - sortable: false, truncateText: true, + render: (item: TableItem) => ( + + ), }, { field: 'startTime', @@ -240,18 +201,116 @@ export const AnomaliesTable: React.FunctionComponent<{ ], [collapseId, expandId, expandedIds, dateFormat] ); + return ( + <> + + + + + + + ); +}; + +const AnomalyMessage = ({ + actual, + typical, + type, +}: { + actual: number; + typical: number; + type: AnomalyType; +}) => { + const moreThanExpectedAnomalyMessage = i18n.translate( + 'xpack.infra.logs.analysis.anomaliesTableMoreThanExpectedAnomalyMessage', + { + defaultMessage: + 'more log messages in this {type, select, logRate {dataset} logCategory {category}} than expected', + values: { type }, + } + ); + + const fewerThanExpectedAnomalyMessage = i18n.translate( + 'xpack.infra.logs.analysis.anomaliesTableFewerThanExpectedAnomalyMessage', + { + defaultMessage: + 'fewer log messages in this {type, select, logRate {dataset} logCategory {category}} than expected', + values: { type }, + } + ); + + const isMore = actual > typical; + const message = isMore ? moreThanExpectedAnomalyMessage : fewerThanExpectedAnomalyMessage; + const ratio = isMore ? actual / typical : typical / actual; + const icon = isMore ? 'sortUp' : 'sortDown'; + // Edge case scenarios where actual and typical might sit at 0. + const useRatio = ratio !== Infinity; + const ratioMessage = useRatio ? `${formatOneDecimalPlace(ratio)}x` : ''; return ( - + + {`${ratioMessage} ${message}`} + + ); +}; + +const previousPageLabel = i18n.translate( + 'xpack.infra.logs.analysis.anomaliesTablePreviousPageLabel', + { + defaultMessage: 'Previous page', + } +); + +const nextPageLabel = i18n.translate('xpack.infra.logs.analysis.anomaliesTableNextPageLabel', { + defaultMessage: 'Next page', +}); + +const PaginationControls = ({ + fetchPreviousPage, + fetchNextPage, + page, + isLoading, +}: { + fetchPreviousPage?: () => void; + fetchNextPage?: () => void; + page: number; + isLoading: boolean; +}) => { + return ( + + + + + + {page} + + + + + ); }; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/log_rate/bar_chart.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/log_rate/bar_chart.tsx deleted file mode 100644 index 498a9f88176f8..0000000000000 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/log_rate/bar_chart.tsx +++ /dev/null @@ -1,100 +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 { - Axis, - BarSeries, - Chart, - niceTimeFormatter, - Settings, - TooltipValue, - BrushEndListener, - LIGHT_THEME, - DARK_THEME, -} from '@elastic/charts'; -import { i18n } from '@kbn/i18n'; -import numeral from '@elastic/numeral'; -import moment from 'moment'; -import React, { useCallback, useMemo } from 'react'; - -import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; -import { useKibanaUiSetting } from '../../../../../utils/use_kibana_ui_setting'; - -export const LogEntryRateBarChart: React.FunctionComponent<{ - setTimeRange: (timeRange: TimeRange) => void; - timeRange: TimeRange; - series: Array<{ group: string; time: number; value: number }>; -}> = ({ series, setTimeRange, timeRange }) => { - const [dateFormat] = useKibanaUiSetting('dateFormat'); - const [isDarkMode] = useKibanaUiSetting('theme:darkMode'); - - const chartDateFormatter = useMemo( - () => niceTimeFormatter([timeRange.startTime, timeRange.endTime]), - [timeRange] - ); - - const tooltipProps = useMemo( - () => ({ - headerFormatter: (tooltipData: TooltipValue) => - moment(tooltipData.value).format(dateFormat || 'Y-MM-DD HH:mm:ss.SSS'), - }), - [dateFormat] - ); - - const handleBrushEnd = useCallback( - ({ x }) => { - if (!x) { - return; - } - const [startTime, endTime] = x; - setTimeRange({ - endTime, - startTime, - }); - }, - [setTimeRange] - ); - - return ( -
- - - numeral(value.toPrecision(3)).format('0[.][00]a')} // https://github.com/adamwdraper/Numeral-js/issues/194 - /> - - - -
- ); -}; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/log_rate/index.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/log_rate/index.tsx deleted file mode 100644 index 3da025d90119f..0000000000000 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/log_rate/index.tsx +++ /dev/null @@ -1,98 +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 { EuiEmptyPrompt, EuiLoadingSpinner, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React, { useMemo } from 'react'; - -import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; -import { BetaBadge } from '../../../../../components/beta_badge'; -import { LoadingOverlayWrapper } from '../../../../../components/loading_overlay_wrapper'; -import { LogEntryRateResults as Results } from '../../use_log_entry_rate_results'; -import { getLogEntryRatePartitionedSeries } from '../helpers/data_formatters'; -import { LogEntryRateBarChart } from './bar_chart'; - -export const LogRateResults = ({ - isLoading, - results, - setTimeRange, - timeRange, -}: { - isLoading: boolean; - results: Results | null; - setTimeRange: (timeRange: TimeRange) => void; - timeRange: TimeRange; -}) => { - const logEntryRateSeries = useMemo( - () => (results && results.histogramBuckets ? getLogEntryRatePartitionedSeries(results) : []), - [results] - ); - - return ( - <> - -

- {title} -

-
- }> - {!results || (results && results.histogramBuckets && !results.histogramBuckets.length) ? ( - <> - - - {i18n.translate('xpack.infra.logs.analysis.logRateSectionNoDataTitle', { - defaultMessage: 'There is no data to display.', - })} - - } - titleSize="m" - body={ -

- {i18n.translate('xpack.infra.logs.analysis.logRateSectionNoDataBody', { - defaultMessage: 'You may want to adjust your time range.', - })} -

- } - /> - - ) : ( - <> - -

- - {i18n.translate('xpack.infra.logs.analysis.logRateSectionBucketSpanLabel', { - defaultMessage: 'Bucket span: ', - })} - - {i18n.translate('xpack.infra.logs.analysis.logRateSectionBucketSpanValue', { - defaultMessage: '15 minutes', - })} -

-
- - - )} -
- - ); -}; - -const title = i18n.translate('xpack.infra.logs.analysis.logRateSectionTitle', { - defaultMessage: 'Log entries', -}); - -const loadingAriaLabel = i18n.translate( - 'xpack.infra.logs.analysis.logRateSectionLoadingAriaLabel', - { defaultMessage: 'Loading log rate results' } -); - -const LoadingOverlayContent = () => ; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_anomalies.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_anomalies.ts new file mode 100644 index 0000000000000..d4a0eaae43ac0 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_anomalies.ts @@ -0,0 +1,41 @@ +/* + * 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 { npStart } from '../../../../legacy_singletons'; +import { + getLogEntryAnomaliesRequestPayloadRT, + getLogEntryAnomaliesSuccessReponsePayloadRT, + LOG_ANALYSIS_GET_LOG_ENTRY_ANOMALIES_PATH, +} from '../../../../../common/http_api/log_analysis'; +import { decodeOrThrow } from '../../../../../common/runtime_types'; +import { Sort, Pagination } from '../../../../../common/http_api/log_analysis'; + +export const callGetLogEntryAnomaliesAPI = async ( + sourceId: string, + startTime: number, + endTime: number, + sort: Sort, + pagination: Pagination +) => { + const response = await npStart.http.fetch(LOG_ANALYSIS_GET_LOG_ENTRY_ANOMALIES_PATH, { + method: 'POST', + body: JSON.stringify( + getLogEntryAnomaliesRequestPayloadRT.encode({ + data: { + sourceId, + timeRange: { + startTime, + endTime, + }, + sort, + pagination, + }, + }) + ), + }); + + return decodeOrThrow(getLogEntryAnomaliesSuccessReponsePayloadRT)(response); +}; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_rate_examples.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_examples.ts similarity index 77% rename from x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_rate_examples.ts rename to x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_examples.ts index d3b30da72af96..a125b53f9e635 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_rate_examples.ts +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_examples.ts @@ -10,23 +10,24 @@ import { identity } from 'fp-ts/lib/function'; import { npStart } from '../../../../legacy_singletons'; import { - getLogEntryRateExamplesRequestPayloadRT, - getLogEntryRateExamplesSuccessReponsePayloadRT, + getLogEntryExamplesRequestPayloadRT, + getLogEntryExamplesSuccessReponsePayloadRT, LOG_ANALYSIS_GET_LOG_ENTRY_RATE_EXAMPLES_PATH, } from '../../../../../common/http_api/log_analysis'; import { createPlainError, throwErrors } from '../../../../../common/runtime_types'; -export const callGetLogEntryRateExamplesAPI = async ( +export const callGetLogEntryExamplesAPI = async ( sourceId: string, startTime: number, endTime: number, dataset: string, - exampleCount: number + exampleCount: number, + categoryId?: string ) => { const response = await npStart.http.fetch(LOG_ANALYSIS_GET_LOG_ENTRY_RATE_EXAMPLES_PATH, { method: 'POST', body: JSON.stringify( - getLogEntryRateExamplesRequestPayloadRT.encode({ + getLogEntryExamplesRequestPayloadRT.encode({ data: { dataset, exampleCount, @@ -35,13 +36,14 @@ export const callGetLogEntryRateExamplesAPI = async ( startTime, endTime, }, + categoryId, }, }) ), }); return pipe( - getLogEntryRateExamplesSuccessReponsePayloadRT.decode(response), + getLogEntryExamplesSuccessReponsePayloadRT.decode(response), fold(throwErrors(createPlainError), identity) ); }; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_anomalies_results.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_anomalies_results.ts new file mode 100644 index 0000000000000..cadb4c420c133 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_anomalies_results.ts @@ -0,0 +1,262 @@ +/* + * 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 { useMemo, useState, useCallback, useEffect, useReducer } from 'react'; + +import { LogEntryAnomaly } from '../../../../common/http_api'; +import { useTrackedPromise } from '../../../utils/use_tracked_promise'; +import { callGetLogEntryAnomaliesAPI } from './service_calls/get_log_entry_anomalies'; +import { Sort, Pagination, PaginationCursor } from '../../../../common/http_api/log_analysis'; + +export type SortOptions = Sort; +export type PaginationOptions = Pick; +export type Page = number; +export type FetchNextPage = () => void; +export type FetchPreviousPage = () => void; +export type ChangeSortOptions = (sortOptions: Sort) => void; +export type ChangePaginationOptions = (paginationOptions: PaginationOptions) => void; +export type LogEntryAnomalies = LogEntryAnomaly[]; +interface PaginationCursors { + previousPageCursor: PaginationCursor; + nextPageCursor: PaginationCursor; +} + +interface ReducerState { + page: number; + lastReceivedCursors: PaginationCursors | undefined; + paginationCursor: Pagination['cursor'] | undefined; + hasNextPage: boolean; + paginationOptions: PaginationOptions; + sortOptions: Sort; + timeRange: { + start: number; + end: number; + }; +} + +type ReducerStateDefaults = Pick< + ReducerState, + 'page' | 'lastReceivedCursors' | 'paginationCursor' | 'hasNextPage' +>; + +type ReducerAction = + | { type: 'changePaginationOptions'; payload: { paginationOptions: PaginationOptions } } + | { type: 'changeSortOptions'; payload: { sortOptions: Sort } } + | { type: 'fetchNextPage' } + | { type: 'fetchPreviousPage' } + | { type: 'changeHasNextPage'; payload: { hasNextPage: boolean } } + | { type: 'changeLastReceivedCursors'; payload: { lastReceivedCursors: PaginationCursors } } + | { type: 'changeTimeRange'; payload: { timeRange: { start: number; end: number } } }; + +const stateReducer = (state: ReducerState, action: ReducerAction): ReducerState => { + const resetPagination = { + page: 1, + paginationCursor: undefined, + }; + switch (action.type) { + case 'changePaginationOptions': + return { + ...state, + ...resetPagination, + ...action.payload, + }; + case 'changeSortOptions': + return { + ...state, + ...resetPagination, + ...action.payload, + }; + case 'changeHasNextPage': + return { + ...state, + ...action.payload, + }; + case 'changeLastReceivedCursors': + return { + ...state, + ...action.payload, + }; + case 'fetchNextPage': + return state.lastReceivedCursors + ? { + ...state, + page: state.page + 1, + paginationCursor: { searchAfter: state.lastReceivedCursors.nextPageCursor }, + } + : state; + case 'fetchPreviousPage': + return state.lastReceivedCursors + ? { + ...state, + page: state.page - 1, + paginationCursor: { searchBefore: state.lastReceivedCursors.previousPageCursor }, + } + : state; + case 'changeTimeRange': + return { + ...state, + ...resetPagination, + ...action.payload, + }; + default: + return state; + } +}; + +const STATE_DEFAULTS: ReducerStateDefaults = { + // NOTE: This piece of state is purely for the client side, it could be extracted out of the hook. + page: 1, + // Cursor from the last request + lastReceivedCursors: undefined, + // Cursor to use for the next request. For the first request, and therefore not paging, this will be undefined. + paginationCursor: undefined, + hasNextPage: false, +}; + +export const useLogEntryAnomaliesResults = ({ + endTime, + startTime, + sourceId, + defaultSortOptions, + defaultPaginationOptions, +}: { + endTime: number; + startTime: number; + sourceId: string; + defaultSortOptions: Sort; + defaultPaginationOptions: Pick; +}) => { + const initStateReducer = (stateDefaults: ReducerStateDefaults): ReducerState => { + return { + ...stateDefaults, + paginationOptions: defaultPaginationOptions, + sortOptions: defaultSortOptions, + timeRange: { + start: startTime, + end: endTime, + }, + }; + }; + + const [reducerState, dispatch] = useReducer(stateReducer, STATE_DEFAULTS, initStateReducer); + + const [logEntryAnomalies, setLogEntryAnomalies] = useState([]); + + const [getLogEntryAnomaliesRequest, getLogEntryAnomalies] = useTrackedPromise( + { + cancelPreviousOn: 'creation', + createPromise: async () => { + const { + timeRange: { start: queryStartTime, end: queryEndTime }, + sortOptions, + paginationOptions, + paginationCursor, + } = reducerState; + return await callGetLogEntryAnomaliesAPI( + sourceId, + queryStartTime, + queryEndTime, + sortOptions, + { + ...paginationOptions, + cursor: paginationCursor, + } + ); + }, + onResolve: ({ data: { anomalies, paginationCursors: requestCursors, hasMoreEntries } }) => { + const { paginationCursor } = reducerState; + if (requestCursors) { + dispatch({ + type: 'changeLastReceivedCursors', + payload: { lastReceivedCursors: requestCursors }, + }); + } + // Check if we have more "next" entries. "Page" covers the "previous" scenario, + // since we need to know the page we're on anyway. + if (!paginationCursor || (paginationCursor && 'searchAfter' in paginationCursor)) { + dispatch({ type: 'changeHasNextPage', payload: { hasNextPage: hasMoreEntries } }); + } else if (paginationCursor && 'searchBefore' in paginationCursor) { + // We've requested a previous page, therefore there is a next page. + dispatch({ type: 'changeHasNextPage', payload: { hasNextPage: true } }); + } + setLogEntryAnomalies(anomalies); + }, + }, + [ + sourceId, + dispatch, + reducerState.timeRange, + reducerState.sortOptions, + reducerState.paginationOptions, + reducerState.paginationCursor, + ] + ); + + const changeSortOptions = useCallback( + (nextSortOptions: Sort) => { + dispatch({ type: 'changeSortOptions', payload: { sortOptions: nextSortOptions } }); + }, + [dispatch] + ); + + const changePaginationOptions = useCallback( + (nextPaginationOptions: PaginationOptions) => { + dispatch({ + type: 'changePaginationOptions', + payload: { paginationOptions: nextPaginationOptions }, + }); + }, + [dispatch] + ); + + // Time range has changed + useEffect(() => { + dispatch({ + type: 'changeTimeRange', + payload: { timeRange: { start: startTime, end: endTime } }, + }); + }, [startTime, endTime]); + + useEffect(() => { + getLogEntryAnomalies(); + }, [getLogEntryAnomalies]); + + const handleFetchNextPage = useCallback(() => { + if (reducerState.lastReceivedCursors) { + dispatch({ type: 'fetchNextPage' }); + } + }, [dispatch, reducerState]); + + const handleFetchPreviousPage = useCallback(() => { + if (reducerState.lastReceivedCursors) { + dispatch({ type: 'fetchPreviousPage' }); + } + }, [dispatch, reducerState]); + + const isLoadingLogEntryAnomalies = useMemo( + () => getLogEntryAnomaliesRequest.state === 'pending', + [getLogEntryAnomaliesRequest.state] + ); + + const hasFailedLoadingLogEntryAnomalies = useMemo( + () => getLogEntryAnomaliesRequest.state === 'rejected', + [getLogEntryAnomaliesRequest.state] + ); + + return { + logEntryAnomalies, + getLogEntryAnomalies, + isLoadingLogEntryAnomalies, + hasFailedLoadingLogEntryAnomalies, + changeSortOptions, + sortOptions: reducerState.sortOptions, + changePaginationOptions, + paginationOptions: reducerState.paginationOptions, + fetchPreviousPage: reducerState.page > 1 ? handleFetchPreviousPage : undefined, + fetchNextPage: reducerState.hasNextPage ? handleFetchNextPage : undefined, + page: reducerState.page, + }; +}; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_examples.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_examples.ts new file mode 100644 index 0000000000000..fae5bd200a415 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_examples.ts @@ -0,0 +1,65 @@ +/* + * 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 { useMemo, useState } from 'react'; + +import { LogEntryExample } from '../../../../common/http_api'; +import { useTrackedPromise } from '../../../utils/use_tracked_promise'; +import { callGetLogEntryExamplesAPI } from './service_calls/get_log_entry_examples'; + +export const useLogEntryExamples = ({ + dataset, + endTime, + exampleCount, + sourceId, + startTime, + categoryId, +}: { + dataset: string; + endTime: number; + exampleCount: number; + sourceId: string; + startTime: number; + categoryId?: string; +}) => { + const [logEntryExamples, setLogEntryExamples] = useState([]); + + const [getLogEntryExamplesRequest, getLogEntryExamples] = useTrackedPromise( + { + cancelPreviousOn: 'creation', + createPromise: async () => { + return await callGetLogEntryExamplesAPI( + sourceId, + startTime, + endTime, + dataset, + exampleCount, + categoryId + ); + }, + onResolve: ({ data: { examples } }) => { + setLogEntryExamples(examples); + }, + }, + [dataset, endTime, exampleCount, sourceId, startTime] + ); + + const isLoadingLogEntryExamples = useMemo(() => getLogEntryExamplesRequest.state === 'pending', [ + getLogEntryExamplesRequest.state, + ]); + + const hasFailedLoadingLogEntryExamples = useMemo( + () => getLogEntryExamplesRequest.state === 'rejected', + [getLogEntryExamplesRequest.state] + ); + + return { + getLogEntryExamples, + hasFailedLoadingLogEntryExamples, + isLoadingLogEntryExamples, + logEntryExamples, + }; +}; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_examples.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_examples.ts deleted file mode 100644 index 12bcdb2a4b4d6..0000000000000 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_examples.ts +++ /dev/null @@ -1,63 +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 { useMemo, useState } from 'react'; - -import { LogEntryRateExample } from '../../../../common/http_api'; -import { useTrackedPromise } from '../../../utils/use_tracked_promise'; -import { callGetLogEntryRateExamplesAPI } from './service_calls/get_log_entry_rate_examples'; - -export const useLogEntryRateExamples = ({ - dataset, - endTime, - exampleCount, - sourceId, - startTime, -}: { - dataset: string; - endTime: number; - exampleCount: number; - sourceId: string; - startTime: number; -}) => { - const [logEntryRateExamples, setLogEntryRateExamples] = useState([]); - - const [getLogEntryRateExamplesRequest, getLogEntryRateExamples] = useTrackedPromise( - { - cancelPreviousOn: 'creation', - createPromise: async () => { - return await callGetLogEntryRateExamplesAPI( - sourceId, - startTime, - endTime, - dataset, - exampleCount - ); - }, - onResolve: ({ data: { examples } }) => { - setLogEntryRateExamples(examples); - }, - }, - [dataset, endTime, exampleCount, sourceId, startTime] - ); - - const isLoadingLogEntryRateExamples = useMemo( - () => getLogEntryRateExamplesRequest.state === 'pending', - [getLogEntryRateExamplesRequest.state] - ); - - const hasFailedLoadingLogEntryRateExamples = useMemo( - () => getLogEntryRateExamplesRequest.state === 'rejected', - [getLogEntryRateExamplesRequest.state] - ); - - return { - getLogEntryRateExamples, - hasFailedLoadingLogEntryRateExamples, - isLoadingLogEntryRateExamples, - logEntryRateExamples, - }; -}; diff --git a/x-pack/plugins/infra/server/infra_server.ts b/x-pack/plugins/infra/server/infra_server.ts index 8af37a36ef745..6596e07ebaca5 100644 --- a/x-pack/plugins/infra/server/infra_server.ts +++ b/x-pack/plugins/infra/server/infra_server.ts @@ -15,9 +15,10 @@ import { initGetLogEntryCategoryDatasetsRoute, initGetLogEntryCategoryExamplesRoute, initGetLogEntryRateRoute, - initGetLogEntryRateExamplesRoute, + initGetLogEntryExamplesRoute, initValidateLogAnalysisDatasetsRoute, initValidateLogAnalysisIndicesRoute, + initGetLogEntryAnomaliesRoute, } from './routes/log_analysis'; import { initMetricExplorerRoute } from './routes/metrics_explorer'; import { initMetadataRoute } from './routes/metadata'; @@ -51,13 +52,14 @@ export const initInfraServer = (libs: InfraBackendLibs) => { initGetLogEntryCategoryDatasetsRoute(libs); initGetLogEntryCategoryExamplesRoute(libs); initGetLogEntryRateRoute(libs); + initGetLogEntryAnomaliesRoute(libs); initSnapshotRoute(libs); initNodeDetailsRoute(libs); initSourceRoute(libs); initValidateLogAnalysisDatasetsRoute(libs); initValidateLogAnalysisIndicesRoute(libs); initLogEntriesRoute(libs); - initGetLogEntryRateExamplesRoute(libs); + initGetLogEntryExamplesRoute(libs); initLogEntriesHighlightsRoute(libs); initLogEntriesSummaryRoute(libs); initLogEntriesSummaryHighlightsRoute(libs); diff --git a/x-pack/plugins/infra/server/lib/log_analysis/common.ts b/x-pack/plugins/infra/server/lib/log_analysis/common.ts new file mode 100644 index 0000000000000..0c0b0a0f19982 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/log_analysis/common.ts @@ -0,0 +1,29 @@ +/* + * 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 type { MlAnomalyDetectors } from '../../types'; +import { startTracingSpan } from '../../../common/performance_tracing'; +import { NoLogAnalysisMlJobError } from './errors'; + +export async function fetchMlJob(mlAnomalyDetectors: MlAnomalyDetectors, jobId: string) { + const finalizeMlGetJobSpan = startTracingSpan('Fetch ml job from ES'); + const { + jobs: [mlJob], + } = await mlAnomalyDetectors.jobs(jobId); + + const mlGetJobSpan = finalizeMlGetJobSpan(); + + if (mlJob == null) { + throw new NoLogAnalysisMlJobError(`Failed to find ml job ${jobId}.`); + } + + return { + mlJob, + timing: { + spans: [mlGetJobSpan], + }, + }; +} diff --git a/x-pack/plugins/infra/server/lib/log_analysis/errors.ts b/x-pack/plugins/infra/server/lib/log_analysis/errors.ts index e07126416f4ce..09fee8844fbc5 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/errors.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/errors.ts @@ -33,3 +33,10 @@ export class UnknownCategoryError extends Error { Object.setPrototypeOf(this, new.target.prototype); } } + +export class InsufficientAnomalyMlJobsConfigured extends Error { + constructor(message?: string) { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + } +} diff --git a/x-pack/plugins/infra/server/lib/log_analysis/index.ts b/x-pack/plugins/infra/server/lib/log_analysis/index.ts index 44c2bafce4194..c9a176be0a28f 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/index.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/index.ts @@ -7,3 +7,4 @@ export * from './errors'; export * from './log_entry_categories_analysis'; export * from './log_entry_rate_analysis'; +export * from './log_entry_anomalies'; diff --git a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_anomalies.ts b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_anomalies.ts new file mode 100644 index 0000000000000..12ae516564d66 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_anomalies.ts @@ -0,0 +1,398 @@ +/* + * 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 { RequestHandlerContext } from 'src/core/server'; +import { InfraRequestHandlerContext } from '../../types'; +import { TracingSpan, startTracingSpan } from '../../../common/performance_tracing'; +import { fetchMlJob } from './common'; +import { + getJobId, + logEntryCategoriesJobTypes, + logEntryRateJobTypes, + jobCustomSettingsRT, +} from '../../../common/log_analysis'; +import { Sort, Pagination } from '../../../common/http_api/log_analysis'; +import type { MlSystem } from '../../types'; +import { createLogEntryAnomaliesQuery, logEntryAnomaliesResponseRT } from './queries'; +import { + InsufficientAnomalyMlJobsConfigured, + InsufficientLogAnalysisMlJobConfigurationError, + UnknownCategoryError, +} from './errors'; +import { decodeOrThrow } from '../../../common/runtime_types'; +import { + createLogEntryExamplesQuery, + logEntryExamplesResponseRT, +} from './queries/log_entry_examples'; +import { InfraSource } from '../sources'; +import { KibanaFramework } from '../adapters/framework/kibana_framework_adapter'; +import { fetchLogEntryCategories } from './log_entry_categories_analysis'; + +interface MappedAnomalyHit { + id: string; + anomalyScore: number; + dataset: string; + typical: number; + actual: number; + jobId: string; + startTime: number; + duration: number; + categoryId?: string; +} + +export async function getLogEntryAnomalies( + context: RequestHandlerContext & { infra: Required }, + sourceId: string, + startTime: number, + endTime: number, + sort: Sort, + pagination: Pagination +) { + const finalizeLogEntryAnomaliesSpan = startTracingSpan('get log entry anomalies'); + + const logRateJobId = getJobId(context.infra.spaceId, sourceId, logEntryRateJobTypes[0]); + const logCategoriesJobId = getJobId( + context.infra.spaceId, + sourceId, + logEntryCategoriesJobTypes[0] + ); + + const jobIds: string[] = []; + let jobSpans: TracingSpan[] = []; + + try { + const { + timing: { spans }, + } = await fetchMlJob(context.infra.mlAnomalyDetectors, logRateJobId); + jobIds.push(logRateJobId); + jobSpans = [...jobSpans, ...spans]; + } catch (e) { + // Job wasn't found + } + + try { + const { + timing: { spans }, + } = await fetchMlJob(context.infra.mlAnomalyDetectors, logCategoriesJobId); + jobIds.push(logCategoriesJobId); + jobSpans = [...jobSpans, ...spans]; + } catch (e) { + // Job wasn't found + } + + if (jobIds.length === 0) { + throw new InsufficientAnomalyMlJobsConfigured( + 'Log rate or categorisation ML jobs need to be configured to search anomalies' + ); + } + + const { + anomalies, + paginationCursors, + hasMoreEntries, + timing: { spans: fetchLogEntryAnomaliesSpans }, + } = await fetchLogEntryAnomalies( + context.infra.mlSystem, + jobIds, + startTime, + endTime, + sort, + pagination + ); + + const data = anomalies.map((anomaly) => { + const { jobId } = anomaly; + + if (jobId === logRateJobId) { + return parseLogRateAnomalyResult(anomaly, logRateJobId); + } else { + return parseCategoryAnomalyResult(anomaly, logCategoriesJobId); + } + }); + + const logEntryAnomaliesSpan = finalizeLogEntryAnomaliesSpan(); + + return { + data, + paginationCursors, + hasMoreEntries, + timing: { + spans: [logEntryAnomaliesSpan, ...jobSpans, ...fetchLogEntryAnomaliesSpans], + }, + }; +} + +const parseLogRateAnomalyResult = (anomaly: MappedAnomalyHit, jobId: string) => { + const { + id, + anomalyScore, + dataset, + typical, + actual, + duration, + startTime: anomalyStartTime, + } = anomaly; + + return { + id, + anomalyScore, + dataset, + typical, + actual, + duration, + startTime: anomalyStartTime, + type: 'logRate' as const, + jobId, + }; +}; + +const parseCategoryAnomalyResult = (anomaly: MappedAnomalyHit, jobId: string) => { + const { + id, + anomalyScore, + dataset, + typical, + actual, + duration, + startTime: anomalyStartTime, + categoryId, + } = anomaly; + + return { + id, + anomalyScore, + dataset, + typical, + actual, + duration, + startTime: anomalyStartTime, + categoryId, + type: 'logCategory' as const, + jobId, + }; +}; + +async function fetchLogEntryAnomalies( + mlSystem: MlSystem, + jobIds: string[], + startTime: number, + endTime: number, + sort: Sort, + pagination: Pagination +) { + // We'll request 1 extra entry on top of our pageSize to determine if there are + // more entries to be fetched. This avoids scenarios where the client side can't + // determine if entries.length === pageSize actually means there are more entries / next page + // or not. + const expandedPagination = { ...pagination, pageSize: pagination.pageSize + 1 }; + + const finalizeFetchLogEntryAnomaliesSpan = startTracingSpan('fetch log entry anomalies'); + + const results = decodeOrThrow(logEntryAnomaliesResponseRT)( + await mlSystem.mlAnomalySearch( + createLogEntryAnomaliesQuery(jobIds, startTime, endTime, sort, expandedPagination) + ) + ); + + const { + hits: { hits }, + } = results; + const hasMoreEntries = hits.length > pagination.pageSize; + + // An extra entry was found and hasMoreEntries has been determined, the extra entry can be removed. + if (hasMoreEntries) { + hits.pop(); + } + + // To "search_before" the sort order will have been reversed for ES. + // The results are now reversed back, to match the requested sort. + if (pagination.cursor && 'searchBefore' in pagination.cursor) { + hits.reverse(); + } + + const paginationCursors = + hits.length > 0 + ? { + previousPageCursor: hits[0].sort, + nextPageCursor: hits[hits.length - 1].sort, + } + : undefined; + + const anomalies = hits.map((result) => { + const { + job_id, + record_score: anomalyScore, + typical, + actual, + partition_field_value: dataset, + bucket_span: duration, + timestamp: anomalyStartTime, + by_field_value: categoryId, + } = result._source; + + return { + id: result._id, + anomalyScore, + dataset, + typical: typical[0], + actual: actual[0], + jobId: job_id, + startTime: anomalyStartTime, + duration: duration * 1000, + categoryId, + }; + }); + + const fetchLogEntryAnomaliesSpan = finalizeFetchLogEntryAnomaliesSpan(); + + return { + anomalies, + paginationCursors, + hasMoreEntries, + timing: { + spans: [fetchLogEntryAnomaliesSpan], + }, + }; +} + +export async function getLogEntryExamples( + context: RequestHandlerContext & { infra: Required }, + sourceId: string, + startTime: number, + endTime: number, + dataset: string, + exampleCount: number, + sourceConfiguration: InfraSource, + callWithRequest: KibanaFramework['callWithRequest'], + categoryId?: string +) { + const finalizeLogEntryExamplesSpan = startTracingSpan('get log entry rate example log entries'); + + const jobId = getJobId( + context.infra.spaceId, + sourceId, + categoryId != null ? logEntryCategoriesJobTypes[0] : logEntryRateJobTypes[0] + ); + + const { + mlJob, + timing: { spans: fetchMlJobSpans }, + } = await fetchMlJob(context.infra.mlAnomalyDetectors, jobId); + + const customSettings = decodeOrThrow(jobCustomSettingsRT)(mlJob.custom_settings); + const indices = customSettings?.logs_source_config?.indexPattern; + const timestampField = customSettings?.logs_source_config?.timestampField; + const tiebreakerField = sourceConfiguration.configuration.fields.tiebreaker; + + if (indices == null || timestampField == null) { + throw new InsufficientLogAnalysisMlJobConfigurationError( + `Failed to find index configuration for ml job ${jobId}` + ); + } + + const { + examples, + timing: { spans: fetchLogEntryExamplesSpans }, + } = await fetchLogEntryExamples( + context, + sourceId, + indices, + timestampField, + tiebreakerField, + startTime, + endTime, + dataset, + exampleCount, + callWithRequest, + categoryId + ); + + const logEntryExamplesSpan = finalizeLogEntryExamplesSpan(); + + return { + data: examples, + timing: { + spans: [logEntryExamplesSpan, ...fetchMlJobSpans, ...fetchLogEntryExamplesSpans], + }, + }; +} + +export async function fetchLogEntryExamples( + context: RequestHandlerContext & { infra: Required }, + sourceId: string, + indices: string, + timestampField: string, + tiebreakerField: string, + startTime: number, + endTime: number, + dataset: string, + exampleCount: number, + callWithRequest: KibanaFramework['callWithRequest'], + categoryId?: string +) { + const finalizeEsSearchSpan = startTracingSpan('Fetch log rate examples from ES'); + + let categoryQuery: string | undefined; + + // Examples should be further scoped to a specific ML category + if (categoryId) { + const parsedCategoryId = parseInt(categoryId, 10); + + const logEntryCategoriesCountJobId = getJobId( + context.infra.spaceId, + sourceId, + logEntryCategoriesJobTypes[0] + ); + + const { logEntryCategoriesById } = await fetchLogEntryCategories( + context, + logEntryCategoriesCountJobId, + [parsedCategoryId] + ); + + const category = logEntryCategoriesById[parsedCategoryId]; + + if (category == null) { + throw new UnknownCategoryError(parsedCategoryId); + } + + categoryQuery = category._source.terms; + } + + const { + hits: { hits }, + } = decodeOrThrow(logEntryExamplesResponseRT)( + await callWithRequest( + context, + 'search', + createLogEntryExamplesQuery( + indices, + timestampField, + tiebreakerField, + startTime, + endTime, + dataset, + exampleCount, + categoryQuery + ) + ) + ); + + const esSearchSpan = finalizeEsSearchSpan(); + + return { + examples: hits.map((hit) => ({ + id: hit._id, + dataset: hit._source.event?.dataset ?? '', + message: hit._source.message ?? '', + timestamp: hit.sort[0], + tiebreaker: hit.sort[1], + })), + timing: { + spans: [esSearchSpan], + }, + }; +} diff --git a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts index 4f244d724405e..6d00ba56e0e66 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts @@ -17,7 +17,6 @@ import { decodeOrThrow } from '../../../common/runtime_types'; import type { MlAnomalyDetectors, MlSystem } from '../../types'; import { InsufficientLogAnalysisMlJobConfigurationError, - NoLogAnalysisMlJobError, NoLogAnalysisResultsIndexError, UnknownCategoryError, } from './errors'; @@ -45,6 +44,7 @@ import { topLogEntryCategoriesResponseRT, } from './queries/top_log_entry_categories'; import { InfraSource } from '../sources'; +import { fetchMlJob } from './common'; const COMPOSITE_AGGREGATION_BATCH_SIZE = 1000; @@ -213,7 +213,7 @@ export async function getLogEntryCategoryExamples( const { mlJob, timing: { spans: fetchMlJobSpans }, - } = await fetchMlJob(context, logEntryCategoriesCountJobId); + } = await fetchMlJob(context.infra.mlAnomalyDetectors, logEntryCategoriesCountJobId); const customSettings = decodeOrThrow(jobCustomSettingsRT)(mlJob.custom_settings); const indices = customSettings?.logs_source_config?.indexPattern; @@ -330,7 +330,7 @@ async function fetchTopLogEntryCategories( }; } -async function fetchLogEntryCategories( +export async function fetchLogEntryCategories( context: { infra: { mlSystem: MlSystem } }, logEntryCategoriesCountJobId: string, categoryIds: number[] @@ -452,30 +452,6 @@ async function fetchTopLogEntryCategoryHistograms( }; } -async function fetchMlJob( - context: { infra: { mlAnomalyDetectors: MlAnomalyDetectors } }, - logEntryCategoriesCountJobId: string -) { - const finalizeMlGetJobSpan = startTracingSpan('Fetch ml job from ES'); - - const { - jobs: [mlJob], - } = await context.infra.mlAnomalyDetectors.jobs(logEntryCategoriesCountJobId); - - const mlGetJobSpan = finalizeMlGetJobSpan(); - - if (mlJob == null) { - throw new NoLogAnalysisMlJobError(`Failed to find ml job ${logEntryCategoriesCountJobId}.`); - } - - return { - mlJob, - timing: { - spans: [mlGetJobSpan], - }, - }; -} - async function fetchLogEntryCategoryExamples( requestContext: { core: { elasticsearch: { legacy: { client: ILegacyScopedClusterClient } } } }, indices: string, diff --git a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts index 290cf03b67365..0323980dcd013 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts @@ -7,7 +7,6 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { map, fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { RequestHandlerContext } from 'src/core/server'; import { throwErrors, createPlainError } from '../../../common/runtime_types'; import { logRateModelPlotResponseRT, @@ -15,22 +14,9 @@ import { LogRateModelPlotBucket, CompositeTimestampPartitionKey, } from './queries'; -import { startTracingSpan } from '../../../common/performance_tracing'; -import { decodeOrThrow } from '../../../common/runtime_types'; -import { getJobId, jobCustomSettingsRT } from '../../../common/log_analysis'; -import { - createLogEntryRateExamplesQuery, - logEntryRateExamplesResponseRT, -} from './queries/log_entry_rate_examples'; -import { - InsufficientLogAnalysisMlJobConfigurationError, - NoLogAnalysisMlJobError, - NoLogAnalysisResultsIndexError, -} from './errors'; -import { InfraSource } from '../sources'; +import { getJobId } from '../../../common/log_analysis'; +import { NoLogAnalysisResultsIndexError } from './errors'; import type { MlSystem } from '../../types'; -import { InfraRequestHandlerContext } from '../../types'; -import { KibanaFramework } from '../adapters/framework/kibana_framework_adapter'; const COMPOSITE_AGGREGATION_BATCH_SIZE = 1000; @@ -143,130 +129,3 @@ export async function getLogEntryRateBuckets( } }, []); } - -export async function getLogEntryRateExamples( - context: RequestHandlerContext & { infra: Required }, - sourceId: string, - startTime: number, - endTime: number, - dataset: string, - exampleCount: number, - sourceConfiguration: InfraSource, - callWithRequest: KibanaFramework['callWithRequest'] -) { - const finalizeLogEntryRateExamplesSpan = startTracingSpan( - 'get log entry rate example log entries' - ); - - const jobId = getJobId(context.infra.spaceId, sourceId, 'log-entry-rate'); - - const { - mlJob, - timing: { spans: fetchMlJobSpans }, - } = await fetchMlJob(context, jobId); - - const customSettings = decodeOrThrow(jobCustomSettingsRT)(mlJob.custom_settings); - const indices = customSettings?.logs_source_config?.indexPattern; - const timestampField = customSettings?.logs_source_config?.timestampField; - const tiebreakerField = sourceConfiguration.configuration.fields.tiebreaker; - - if (indices == null || timestampField == null) { - throw new InsufficientLogAnalysisMlJobConfigurationError( - `Failed to find index configuration for ml job ${jobId}` - ); - } - - const { - examples, - timing: { spans: fetchLogEntryRateExamplesSpans }, - } = await fetchLogEntryRateExamples( - context, - indices, - timestampField, - tiebreakerField, - startTime, - endTime, - dataset, - exampleCount, - callWithRequest - ); - - const logEntryRateExamplesSpan = finalizeLogEntryRateExamplesSpan(); - - return { - data: examples, - timing: { - spans: [logEntryRateExamplesSpan, ...fetchMlJobSpans, ...fetchLogEntryRateExamplesSpans], - }, - }; -} - -export async function fetchLogEntryRateExamples( - context: RequestHandlerContext & { infra: Required }, - indices: string, - timestampField: string, - tiebreakerField: string, - startTime: number, - endTime: number, - dataset: string, - exampleCount: number, - callWithRequest: KibanaFramework['callWithRequest'] -) { - const finalizeEsSearchSpan = startTracingSpan('Fetch log rate examples from ES'); - - const { - hits: { hits }, - } = decodeOrThrow(logEntryRateExamplesResponseRT)( - await callWithRequest( - context, - 'search', - createLogEntryRateExamplesQuery( - indices, - timestampField, - tiebreakerField, - startTime, - endTime, - dataset, - exampleCount - ) - ) - ); - - const esSearchSpan = finalizeEsSearchSpan(); - - return { - examples: hits.map((hit) => ({ - id: hit._id, - dataset, - message: hit._source.message ?? '', - timestamp: hit.sort[0], - tiebreaker: hit.sort[1], - })), - timing: { - spans: [esSearchSpan], - }, - }; -} - -async function fetchMlJob( - context: RequestHandlerContext & { infra: Required }, - logEntryRateJobId: string -) { - const finalizeMlGetJobSpan = startTracingSpan('Fetch ml job from ES'); - const { - jobs: [mlJob], - } = await context.infra.mlAnomalyDetectors.jobs(logEntryRateJobId); - - const mlGetJobSpan = finalizeMlGetJobSpan(); - - if (mlJob == null) { - throw new NoLogAnalysisMlJobError(`Failed to find ml job ${logEntryRateJobId}.`); - } - - return { - mlJob, - timing: { - spans: [mlGetJobSpan], - }, - }; -} diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/common.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/common.ts index eacf29b303db0..87394028095de 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/common.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/common.ts @@ -21,6 +21,14 @@ export const createJobIdFilters = (jobId: string) => [ }, ]; +export const createJobIdsFilters = (jobIds: string[]) => [ + { + terms: { + job_id: jobIds, + }, + }, +]; + export const createTimeRangeFilters = (startTime: number, endTime: number) => [ { range: { diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/index.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/index.ts index 8c470acbf02fb..792c5bf98b538 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/index.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/index.ts @@ -6,3 +6,4 @@ export * from './log_entry_rate'; export * from './top_log_entry_categories'; +export * from './log_entry_anomalies'; diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_anomalies.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_anomalies.ts new file mode 100644 index 0000000000000..fc72776ea5cac --- /dev/null +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_anomalies.ts @@ -0,0 +1,128 @@ +/* + * 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 * as rt from 'io-ts'; +import { commonSearchSuccessResponseFieldsRT } from '../../../utils/elasticsearch_runtime_types'; +import { + createJobIdsFilters, + createTimeRangeFilters, + createResultTypeFilters, + defaultRequestParameters, +} from './common'; +import { Sort, Pagination } from '../../../../common/http_api/log_analysis'; + +// TODO: Reassess validity of this against ML docs +const TIEBREAKER_FIELD = '_doc'; + +const sortToMlFieldMap = { + dataset: 'partition_field_value', + anomalyScore: 'record_score', + startTime: 'timestamp', +}; + +export const createLogEntryAnomaliesQuery = ( + jobIds: string[], + startTime: number, + endTime: number, + sort: Sort, + pagination: Pagination +) => { + const { field } = sort; + const { pageSize } = pagination; + + const filters = [ + ...createJobIdsFilters(jobIds), + ...createTimeRangeFilters(startTime, endTime), + ...createResultTypeFilters(['record']), + ]; + + const sourceFields = [ + 'job_id', + 'record_score', + 'typical', + 'actual', + 'partition_field_value', + 'timestamp', + 'bucket_span', + 'by_field_value', + ]; + + const { querySortDirection, queryCursor } = parsePaginationCursor(sort, pagination); + + const sortOptions = [ + { [sortToMlFieldMap[field]]: querySortDirection }, + { [TIEBREAKER_FIELD]: querySortDirection }, // Tiebreaker + ]; + + const resultsQuery = { + ...defaultRequestParameters, + body: { + query: { + bool: { + filter: filters, + }, + }, + search_after: queryCursor, + sort: sortOptions, + size: pageSize, + _source: sourceFields, + }, + }; + + return resultsQuery; +}; + +export const logEntryAnomalyHitRT = rt.type({ + _id: rt.string, + _source: rt.intersection([ + rt.type({ + job_id: rt.string, + record_score: rt.number, + typical: rt.array(rt.number), + actual: rt.array(rt.number), + partition_field_value: rt.string, + bucket_span: rt.number, + timestamp: rt.number, + }), + rt.partial({ + by_field_value: rt.string, + }), + ]), + sort: rt.tuple([rt.union([rt.string, rt.number]), rt.union([rt.string, rt.number])]), +}); + +export type LogEntryAnomalyHit = rt.TypeOf; + +export const logEntryAnomaliesResponseRT = rt.intersection([ + commonSearchSuccessResponseFieldsRT, + rt.type({ + hits: rt.type({ + hits: rt.array(logEntryAnomalyHitRT), + }), + }), +]); + +export type LogEntryAnomaliesResponseRT = rt.TypeOf; + +const parsePaginationCursor = (sort: Sort, pagination: Pagination) => { + const { cursor } = pagination; + const { direction } = sort; + + if (!cursor) { + return { querySortDirection: direction, queryCursor: undefined }; + } + + // We will always use ES's search_after to paginate, to mimic "search_before" behaviour we + // need to reverse the user's chosen search direction for the ES query. + if ('searchBefore' in cursor) { + return { + querySortDirection: direction === 'desc' ? 'asc' : 'desc', + queryCursor: cursor.searchBefore, + }; + } else { + return { querySortDirection: direction, queryCursor: cursor.searchAfter }; + } +}; diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate_examples.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_examples.ts similarity index 59% rename from x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate_examples.ts rename to x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_examples.ts index ef06641caf797..74a664e78dcd6 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate_examples.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_examples.ts @@ -10,14 +10,15 @@ import { commonSearchSuccessResponseFieldsRT } from '../../../utils/elasticsearc import { defaultRequestParameters } from './common'; import { partitionField } from '../../../../common/log_analysis'; -export const createLogEntryRateExamplesQuery = ( +export const createLogEntryExamplesQuery = ( indices: string, timestampField: string, tiebreakerField: string, startTime: number, endTime: number, dataset: string, - exampleCount: number + exampleCount: number, + categoryQuery?: string ) => ({ ...defaultRequestParameters, body: { @@ -32,11 +33,27 @@ export const createLogEntryRateExamplesQuery = ( }, }, }, - { - term: { - [partitionField]: dataset, - }, - }, + ...(!!dataset + ? [ + { + term: { + [partitionField]: dataset, + }, + }, + ] + : []), + ...(categoryQuery + ? [ + { + match: { + message: { + query: categoryQuery, + operator: 'AND', + }, + }, + }, + ] + : []), ], }, }, @@ -47,7 +64,7 @@ export const createLogEntryRateExamplesQuery = ( size: exampleCount, }); -export const logEntryRateExampleHitRT = rt.type({ +export const logEntryExampleHitRT = rt.type({ _id: rt.string, _source: rt.partial({ event: rt.partial({ @@ -58,15 +75,15 @@ export const logEntryRateExampleHitRT = rt.type({ sort: rt.tuple([rt.number, rt.number]), }); -export type LogEntryRateExampleHit = rt.TypeOf; +export type LogEntryExampleHit = rt.TypeOf; -export const logEntryRateExamplesResponseRT = rt.intersection([ +export const logEntryExamplesResponseRT = rt.intersection([ commonSearchSuccessResponseFieldsRT, rt.type({ hits: rt.type({ - hits: rt.array(logEntryRateExampleHitRT), + hits: rt.array(logEntryExampleHitRT), }), }), ]); -export type LogEntryRateExamplesResponse = rt.TypeOf; +export type LogEntryExamplesResponse = rt.TypeOf; diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/index.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/index.ts index 30b6be435837b..cbd89db97236f 100644 --- a/x-pack/plugins/infra/server/routes/log_analysis/results/index.ts +++ b/x-pack/plugins/infra/server/routes/log_analysis/results/index.ts @@ -8,4 +8,5 @@ export * from './log_entry_categories'; export * from './log_entry_category_datasets'; export * from './log_entry_category_examples'; export * from './log_entry_rate'; -export * from './log_entry_rate_examples'; +export * from './log_entry_examples'; +export * from './log_entry_anomalies'; diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_anomalies.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_anomalies.ts new file mode 100644 index 0000000000000..f4911658ea496 --- /dev/null +++ b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_anomalies.ts @@ -0,0 +1,112 @@ +/* + * 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 Boom from 'boom'; +import { InfraBackendLibs } from '../../../lib/infra_types'; +import { + LOG_ANALYSIS_GET_LOG_ENTRY_ANOMALIES_PATH, + getLogEntryAnomaliesSuccessReponsePayloadRT, + getLogEntryAnomaliesRequestPayloadRT, + GetLogEntryAnomaliesRequestPayload, + Sort, + Pagination, +} from '../../../../common/http_api/log_analysis'; +import { createValidationFunction } from '../../../../common/runtime_types'; +import { assertHasInfraMlPlugins } from '../../../utils/request_context'; +import { getLogEntryAnomalies } from '../../../lib/log_analysis'; + +export const initGetLogEntryAnomaliesRoute = ({ framework }: InfraBackendLibs) => { + framework.registerRoute( + { + method: 'post', + path: LOG_ANALYSIS_GET_LOG_ENTRY_ANOMALIES_PATH, + validate: { + body: createValidationFunction(getLogEntryAnomaliesRequestPayloadRT), + }, + }, + framework.router.handleLegacyErrors(async (requestContext, request, response) => { + const { + data: { + sourceId, + timeRange: { startTime, endTime }, + sort: sortParam, + pagination: paginationParam, + }, + } = request.body; + + const { sort, pagination } = getSortAndPagination(sortParam, paginationParam); + + try { + assertHasInfraMlPlugins(requestContext); + + const { + data: logEntryAnomalies, + paginationCursors, + hasMoreEntries, + timing, + } = await getLogEntryAnomalies( + requestContext, + sourceId, + startTime, + endTime, + sort, + pagination + ); + + return response.ok({ + body: getLogEntryAnomaliesSuccessReponsePayloadRT.encode({ + data: { + anomalies: logEntryAnomalies, + hasMoreEntries, + paginationCursors, + }, + timing, + }), + }); + } catch (error) { + if (Boom.isBoom(error)) { + throw error; + } + + return response.customError({ + statusCode: error.statusCode ?? 500, + body: { + message: error.message ?? 'An unexpected error occurred', + }, + }); + } + }) + ); +}; + +const getSortAndPagination = ( + sort: Partial = {}, + pagination: Partial = {} +): { + sort: Sort; + pagination: Pagination; +} => { + const sortDefaults = { + field: 'anomalyScore' as const, + direction: 'desc' as const, + }; + + const sortWithDefaults = { + ...sortDefaults, + ...sort, + }; + + const paginationDefaults = { + pageSize: 50, + }; + + const paginationWithDefaults = { + ...paginationDefaults, + ...pagination, + }; + + return { sort: sortWithDefaults, pagination: paginationWithDefaults }; +}; diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_rate_examples.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_examples.ts similarity index 75% rename from x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_rate_examples.ts rename to x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_examples.ts index b8ebcc66911dc..be4caee769506 100644 --- a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_rate_examples.ts +++ b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_examples.ts @@ -7,21 +7,21 @@ import Boom from 'boom'; import { createValidationFunction } from '../../../../common/runtime_types'; import { InfraBackendLibs } from '../../../lib/infra_types'; -import { NoLogAnalysisResultsIndexError, getLogEntryRateExamples } from '../../../lib/log_analysis'; +import { NoLogAnalysisResultsIndexError, getLogEntryExamples } from '../../../lib/log_analysis'; import { assertHasInfraMlPlugins } from '../../../utils/request_context'; import { - getLogEntryRateExamplesRequestPayloadRT, - getLogEntryRateExamplesSuccessReponsePayloadRT, + getLogEntryExamplesRequestPayloadRT, + getLogEntryExamplesSuccessReponsePayloadRT, LOG_ANALYSIS_GET_LOG_ENTRY_RATE_EXAMPLES_PATH, } from '../../../../common/http_api/log_analysis'; -export const initGetLogEntryRateExamplesRoute = ({ framework, sources }: InfraBackendLibs) => { +export const initGetLogEntryExamplesRoute = ({ framework, sources }: InfraBackendLibs) => { framework.registerRoute( { method: 'post', path: LOG_ANALYSIS_GET_LOG_ENTRY_RATE_EXAMPLES_PATH, validate: { - body: createValidationFunction(getLogEntryRateExamplesRequestPayloadRT), + body: createValidationFunction(getLogEntryExamplesRequestPayloadRT), }, }, framework.router.handleLegacyErrors(async (requestContext, request, response) => { @@ -31,6 +31,7 @@ export const initGetLogEntryRateExamplesRoute = ({ framework, sources }: InfraBa exampleCount, sourceId, timeRange: { startTime, endTime }, + categoryId, }, } = request.body; @@ -42,7 +43,7 @@ export const initGetLogEntryRateExamplesRoute = ({ framework, sources }: InfraBa try { assertHasInfraMlPlugins(requestContext); - const { data: logEntryRateExamples, timing } = await getLogEntryRateExamples( + const { data: logEntryExamples, timing } = await getLogEntryExamples( requestContext, sourceId, startTime, @@ -50,13 +51,14 @@ export const initGetLogEntryRateExamplesRoute = ({ framework, sources }: InfraBa dataset, exampleCount, sourceConfiguration, - framework.callWithRequest + framework.callWithRequest, + categoryId ); return response.ok({ - body: getLogEntryRateExamplesSuccessReponsePayloadRT.encode({ + body: getLogEntryExamplesSuccessReponsePayloadRT.encode({ data: { - examples: logEntryRateExamples, + examples: logEntryExamples, }, timing, }), diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 4c83fa71a7060..c1f36372ec94e 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7471,7 +7471,6 @@ "xpack.infra.logs.analysis.anomaliesSectionLineSeriesName": "15 分ごとのログエントリー (平均)", "xpack.infra.logs.analysis.anomaliesSectionLoadingAriaLabel": "異常を読み込み中", "xpack.infra.logs.analysis.anomaliesSectionTitle": "異常", - "xpack.infra.logs.analysis.anomalySectionNoAnomaliesTitle": "異常が検出されませんでした。", "xpack.infra.logs.analysis.anomalySectionNoDataBody": "時間範囲を調整する必要があるかもしれません。", "xpack.infra.logs.analysis.anomalySectionNoDataTitle": "表示するデータがありません。", "xpack.infra.logs.analysis.jobConfigurationOutdatedCalloutMessage": "異なるソース構成を使用して ML ジョブが作成されました。現在の構成を適用するにはジョブを再作成してください。これにより以前検出された異常が削除されます。", @@ -7480,14 +7479,6 @@ "xpack.infra.logs.analysis.jobDefinitionOutdatedCalloutTitle": "古い ML ジョブ定義", "xpack.infra.logs.analysis.jobStoppedCalloutMessage": "ML ジョブが手動またはリソース不足により停止しました。新しいログエントリーはジョブが再起動するまで処理されません。", "xpack.infra.logs.analysis.jobStoppedCalloutTitle": "ML ジョブが停止しました", - "xpack.infra.logs.analysis.logRateResultsToolbarText": "{startTime} から {endTime} までの {numberOfLogs} 件のログエントリーを分析しました", - "xpack.infra.logs.analysis.logRateSectionBucketSpanLabel": "バケットスパン: ", - "xpack.infra.logs.analysis.logRateSectionBucketSpanValue": "15 分", - "xpack.infra.logs.analysis.logRateSectionLineSeriesName": "15 分ごとのログエントリー (平均)", - "xpack.infra.logs.analysis.logRateSectionLoadingAriaLabel": "ログレートの結果を読み込み中", - "xpack.infra.logs.analysis.logRateSectionNoDataBody": "時間範囲を調整する必要があるかもしれません。", - "xpack.infra.logs.analysis.logRateSectionNoDataTitle": "表示するデータがありません。", - "xpack.infra.logs.analysis.logRateSectionTitle": "ログレート", "xpack.infra.logs.analysis.missingMlResultsPrivilegesBody": "本機能は機械学習ジョブを利用し、そのステータスと結果にアクセスするためには、少なくとも{machineLearningUserRole}ロールが必要です。", "xpack.infra.logs.analysis.missingMlResultsPrivilegesTitle": "追加の機械学習の権限が必要です", "xpack.infra.logs.analysis.missingMlSetupPrivilegesBody": "本機能は機械学習ジョブを利用し、設定には{machineLearningAdminRole}ロールが必要です。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 86b2480e3b314..7e36d5676585c 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7476,7 +7476,6 @@ "xpack.infra.logs.analysis.anomaliesSectionLineSeriesName": "每 15 分钟日志条目数(平均值)", "xpack.infra.logs.analysis.anomaliesSectionLoadingAriaLabel": "正在加载异常", "xpack.infra.logs.analysis.anomaliesSectionTitle": "异常", - "xpack.infra.logs.analysis.anomalySectionNoAnomaliesTitle": "未检测到任何异常。", "xpack.infra.logs.analysis.anomalySectionNoDataBody": "您可能想调整时间范围。", "xpack.infra.logs.analysis.anomalySectionNoDataTitle": "没有可显示的数据。", "xpack.infra.logs.analysis.jobConfigurationOutdatedCalloutMessage": "创建 ML 作业时所使用的源配置不同。重新创建作业以应用当前配置。这将移除以前检测到的异常。", @@ -7485,14 +7484,6 @@ "xpack.infra.logs.analysis.jobDefinitionOutdatedCalloutTitle": "ML 作业定义已过期", "xpack.infra.logs.analysis.jobStoppedCalloutMessage": "ML 作业已手动停止或由于缺乏资源而停止。作业重新启动后,才会处理新的日志条目。", "xpack.infra.logs.analysis.jobStoppedCalloutTitle": "ML 作业已停止", - "xpack.infra.logs.analysis.logRateResultsToolbarText": "从 {startTime} 到 {endTime} 已分析 {numberOfLogs} 个日志条目", - "xpack.infra.logs.analysis.logRateSectionBucketSpanLabel": "存储桶跨度: ", - "xpack.infra.logs.analysis.logRateSectionBucketSpanValue": "15 分钟", - "xpack.infra.logs.analysis.logRateSectionLineSeriesName": "每 15 分钟日志条目数(平均值)", - "xpack.infra.logs.analysis.logRateSectionLoadingAriaLabel": "正在加载日志速率结果", - "xpack.infra.logs.analysis.logRateSectionNoDataBody": "您可能想调整时间范围。", - "xpack.infra.logs.analysis.logRateSectionNoDataTitle": "没有可显示的数据。", - "xpack.infra.logs.analysis.logRateSectionTitle": "日志速率", "xpack.infra.logs.analysis.missingMlResultsPrivilegesBody": "此功能使用 Machine Learning 作业,要访问这些作业的状态和结果,至少需要 {machineLearningUserRole} 角色。", "xpack.infra.logs.analysis.missingMlResultsPrivilegesTitle": "需要额外的 Machine Learning 权限", "xpack.infra.logs.analysis.missingMlSetupPrivilegesBody": "此功能使用 Machine Learning 作业,这需要 {machineLearningAdminRole} 角色才能设置。", From 6eeff6bfb4d7e458384d04af239272071b87ab53 Mon Sep 17 00:00:00 2001 From: Brent Kimmel Date: Mon, 13 Jul 2020 13:36:24 -0400 Subject: [PATCH 24/40] [Security_Solution][GTV] Add lineage limit warnings to graph (#70097) * [Security Solution][GTV] Add lineage limit warnings to graph Co-authored-by: Elastic Machine Co-authored-by: oatkiller --- .../common/endpoint/models/event.ts | 15 +- .../public/resolver/models/resolver_tree.ts | 5 +- .../resolver/store/data/reducer.test.ts | 281 +++++++++++++++++- .../public/resolver/store/data/selectors.ts | 114 ++++++- .../public/resolver/store/selectors.ts | 20 ++ .../public/resolver/view/limit_warnings.tsx | 126 ++++++++ .../panels/panel_content_process_list.tsx | 27 ++ .../panels/panel_content_related_list.tsx | 48 ++- 8 files changed, 617 insertions(+), 19 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/resolver/view/limit_warnings.tsx diff --git a/x-pack/plugins/security_solution/common/endpoint/models/event.ts b/x-pack/plugins/security_solution/common/endpoint/models/event.ts index 86cccff957211..9b4550f52ff22 100644 --- a/x-pack/plugins/security_solution/common/endpoint/models/event.ts +++ b/x-pack/plugins/security_solution/common/endpoint/models/event.ts @@ -82,7 +82,6 @@ export function getAncestryAsArray(event: ResolverEvent | undefined): string[] { * @param event The event to get the category for */ export function primaryEventCategory(event: ResolverEvent): string | undefined { - // Returning "Process" as a catch-all here because it seems pretty general if (isLegacyEvent(event)) { const legacyFullType = event.endgame.event_type_full; if (legacyFullType) { @@ -96,6 +95,20 @@ export function primaryEventCategory(event: ResolverEvent): string | undefined { } } +/** + * @param event The event to get the full ECS category for + */ +export function allEventCategories(event: ResolverEvent): string | string[] | undefined { + if (isLegacyEvent(event)) { + const legacyFullType = event.endgame.event_type_full; + if (legacyFullType) { + return legacyFullType; + } + } else { + return event.event.category; + } +} + /** * ECS event type will be things like 'creation', 'deletion', 'access', etc. * see: https://www.elastic.co/guide/en/ecs/current/ecs-event.html diff --git a/x-pack/plugins/security_solution/public/resolver/models/resolver_tree.ts b/x-pack/plugins/security_solution/public/resolver/models/resolver_tree.ts index cf32988a856b2..446e371832d38 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/resolver_tree.ts +++ b/x-pack/plugins/security_solution/public/resolver/models/resolver_tree.ts @@ -9,6 +9,7 @@ import { ResolverEvent, ResolverNodeStats, ResolverLifecycleNode, + ResolverChildNode, } from '../../../common/endpoint/types'; import { uniquePidForProcess } from './process_event'; @@ -60,11 +61,13 @@ export function relatedEventsStats(tree: ResolverTree): Map { let store: Store; + let dispatchTree: (tree: ResolverTree) => void; beforeEach(() => { store = createStore(dataReducer, undefined); + dispatchTree = (tree) => { + const action: DataAction = { + type: 'serverReturnedResolverData', + payload: { + result: tree, + databaseDocumentID: '', + }, + }; + store.dispatch(action); + }; }); describe('when data was received and the ancestry and children edges had cursors', () => { beforeEach(() => { - const generator = new EndpointDocGenerator('seed'); + // Generate a 'tree' using the Resolver generator code. This structure isn't the same as what the API returns. + const baseTree = generateBaseTree(); const tree = mockResolverTree({ - events: generator.generateTree({ ancestors: 1, generations: 2, children: 2 }).allEvents, + events: baseTree.allEvents, cursors: { - childrenNextChild: 'aValidChildursor', + childrenNextChild: 'aValidChildCursor', ancestryNextAncestor: 'aValidAncestorCursor', }, - }); - if (tree) { - const action: DataAction = { - type: 'serverReturnedResolverData', - payload: { - result: tree, - databaseDocumentID: '', - }, - }; - store.dispatch(action); - } + })!; + dispatchTree(tree); }); it('should indicate there are additional ancestor', () => { expect(selectors.hasMoreAncestors(store.getState())).toBe(true); @@ -49,4 +53,251 @@ describe('Resolver Data Middleware', () => { expect(selectors.hasMoreChildren(store.getState())).toBe(true); }); }); + + describe('when data was received with stats mocked for the first child node', () => { + let firstChildNodeInTree: TreeNode; + let eventStatsForFirstChildNode: { total: number; byCategory: Record }; + let categoryToOverCount: string; + let tree: ResolverTree; + + /** + * Compiling stats to use for checking limit warnings and counts of missing events + * e.g. Limit warnings should show when number of related events actually displayed + * is lower than the estimated count from stats. + */ + + beforeEach(() => { + ({ + tree, + firstChildNodeInTree, + eventStatsForFirstChildNode, + categoryToOverCount, + } = mockedTree()); + if (tree) { + dispatchTree(tree); + } + }); + + describe('and when related events were returned with totals equalling what stat counts indicate they should be', () => { + beforeEach(() => { + // Return related events for the first child node + const relatedAction: DataAction = { + type: 'serverReturnedRelatedEventData', + payload: { + entityID: firstChildNodeInTree.id, + events: firstChildNodeInTree.relatedEvents, + nextEvent: null, + }, + }; + store.dispatch(relatedAction); + }); + it('should have the correct related events', () => { + const selectedEventsByEntityId = selectors.relatedEventsByEntityId(store.getState()); + const selectedEventsForFirstChildNode = selectedEventsByEntityId.get( + firstChildNodeInTree.id + )!.events; + + expect(selectedEventsForFirstChildNode).toBe(firstChildNodeInTree.relatedEvents); + }); + it('should indicate the correct related event count for each category', () => { + const selectedRelatedInfo = selectors.relatedEventInfoByEntityId(store.getState()); + const displayCountsForCategory = selectedRelatedInfo(firstChildNodeInTree.id) + ?.numberActuallyDisplayedForCategory!; + + const eventCategoriesForNode: string[] = Object.keys( + eventStatsForFirstChildNode.byCategory + ); + + for (const eventCategory of eventCategoriesForNode) { + expect(`${eventCategory}:${displayCountsForCategory(eventCategory)}`).toBe( + `${eventCategory}:${eventStatsForFirstChildNode.byCategory[eventCategory]}` + ); + } + }); + /** + * The general approach reflected here is to _avoid_ showing a limit warning - even if we hit + * the overall related event limit - as long as the number in our category matches what the stats + * say we have. E.g. If the stats say you have 20 dns events, and we receive 20 dns events, we + * don't need to display a limit warning for that, even if we hit some overall event limit of e.g. 100 + * while we were fetching the 20. + */ + it('should not indicate the limit has been exceeded because the number of related events received for the category is greater or equal to the stats count', () => { + const selectedRelatedInfo = selectors.relatedEventInfoByEntityId(store.getState()); + const shouldShowLimit = selectedRelatedInfo(firstChildNodeInTree.id) + ?.shouldShowLimitForCategory!; + for (const typeCounted of Object.keys(eventStatsForFirstChildNode.byCategory)) { + expect(shouldShowLimit(typeCounted)).toBe(false); + } + }); + it('should not indicate that there are any related events missing because the number of related events received for the category is greater or equal to the stats count', () => { + const selectedRelatedInfo = selectors.relatedEventInfoByEntityId(store.getState()); + const notDisplayed = selectedRelatedInfo(firstChildNodeInTree.id) + ?.numberNotDisplayedForCategory!; + for (const typeCounted of Object.keys(eventStatsForFirstChildNode.byCategory)) { + expect(notDisplayed(typeCounted)).toBe(0); + } + }); + }); + describe('when data was received and stats show more related events than the API can provide', () => { + beforeEach(() => { + // Add 1 to the stats for an event category so that the selectors think we are missing data. + // This mutates `tree`, and then we re-dispatch it + eventStatsForFirstChildNode.byCategory[categoryToOverCount] = + eventStatsForFirstChildNode.byCategory[categoryToOverCount] + 1; + + if (tree) { + dispatchTree(tree); + const relatedAction: DataAction = { + type: 'serverReturnedRelatedEventData', + payload: { + entityID: firstChildNodeInTree.id, + events: firstChildNodeInTree.relatedEvents, + nextEvent: 'aValidNextEventCursor', + }, + }; + store.dispatch(relatedAction); + } + }); + it('should have the correct related events', () => { + const selectedEventsByEntityId = selectors.relatedEventsByEntityId(store.getState()); + const selectedEventsForFirstChildNode = selectedEventsByEntityId.get( + firstChildNodeInTree.id + )!.events; + + expect(selectedEventsForFirstChildNode).toBe(firstChildNodeInTree.relatedEvents); + }); + it('should indicate the limit has been exceeded because the number of related events received for the category is less than what the stats count said it would be', () => { + const selectedRelatedInfo = selectors.relatedEventInfoByEntityId(store.getState()); + const shouldShowLimit = selectedRelatedInfo(firstChildNodeInTree.id) + ?.shouldShowLimitForCategory!; + expect(shouldShowLimit(categoryToOverCount)).toBe(true); + }); + it('should indicate that there are related events missing because the number of related events received for the category is less than what the stats count said it would be', () => { + const selectedRelatedInfo = selectors.relatedEventInfoByEntityId(store.getState()); + const notDisplayed = selectedRelatedInfo(firstChildNodeInTree.id) + ?.numberNotDisplayedForCategory!; + expect(notDisplayed(categoryToOverCount)).toBe(1); + }); + }); + }); }); + +function mockedTree() { + // Generate a 'tree' using the Resolver generator code. This structure isn't the same as what the API returns. + const baseTree = generateBaseTree(); + + const { children } = baseTree; + const firstChildNodeInTree = [...children.values()][0]; + + // The `generateBaseTree` mock doesn't calculate stats (the actual data has them.) + // So calculate some stats for just the node that we'll test. + const statsResults = compileStatsForChild(firstChildNodeInTree); + + const tree = mockResolverTree({ + events: baseTree.allEvents, + /** + * Calculate children from the ResolverTree response using the children of the `Tree` we generated using the Resolver data generator code. + * Compile (and attach) stats to the first child node. + * + * The purpose of `children` here is to set the `actual` + * value that the stats values will be compared with + * to derive things like the number of missing events and if + * related event limits should be shown. + */ + children: [...baseTree.children.values()].map((node: TreeNode) => { + // Treat each `TreeNode` as a `ResolverChildNode`. + // These types are almost close enough to be used interchangably (for the purposes of this test.) + const childNode: Partial = node; + + // `TreeNode` has `id` which is the same as `entityID`. + // The `ResolverChildNode` calls the entityID as `entityID`. + // Set `entityID` on `childNode` since the code in test relies on it. + childNode.entityID = (childNode as TreeNode).id; + + // This should only be true for the first child. + if (node.id === firstChildNodeInTree.id) { + // attach stats + childNode.stats = { + events: statsResults.eventStats, + totalAlerts: 0, + }; + } + return childNode; + }) as ResolverChildNode[] /** + Cast to ResolverChildNode[] array is needed because incoming + TreeNodes from the generator cannot be assigned cleanly to the + tree model's expected ResolverChildNode type. + */, + }); + + return { + tree: tree!, + firstChildNodeInTree, + eventStatsForFirstChildNode: statsResults.eventStats, + categoryToOverCount: statsResults.firstCategory, + }; +} + +function generateBaseTree() { + const generator = new EndpointDocGenerator('seed'); + return generator.generateTree({ + ancestors: 1, + generations: 2, + children: 3, + percentWithRelated: 100, + alwaysGenMaxChildrenPerNode: true, + }); +} + +function compileStatsForChild( + node: TreeNode +): { + eventStats: { + /** The total number of related events. */ + total: number; + /** A record with the categories of events as keys, and the count of events per category as values. */ + byCategory: Record; + }; + /** The category of the first event. */ + firstCategory: string; +} { + const totalRelatedEvents = node.relatedEvents.length; + // For the purposes of testing, we pick one category to fake an extra event for + // so we can test if the event limit selectors do the right thing. + + let firstCategory: string | undefined; + + const compiledStats = node.relatedEvents.reduce( + (counts: Record, relatedEvent) => { + // `relatedEvent.event.category` is `string | string[]`. + // Wrap it in an array and flatten that array to get a `string[] | [string]` + // which we can loop over. + const categories: string[] = [relatedEvent.event.category].flat(); + + for (const category of categories) { + // Set the first category as 'categoryToOverCount' + if (firstCategory === undefined) { + firstCategory = category; + } + + // Increment the count of events with this category + counts[category] = counts[category] ? counts[category] + 1 : 1; + } + return counts; + }, + {} + ); + if (firstCategory === undefined) { + throw new Error('there were no related events for the node.'); + } + return { + /** + * Object to use for the first child nodes stats `events` object? + */ + eventStats: { + total: totalRelatedEvents, + byCategory: compiledStats, + }, + firstCategory, + }; +} diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts index 9c47c765457e3..990b911e5dbd0 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts @@ -5,7 +5,7 @@ */ import rbush from 'rbush'; -import { createSelector } from 'reselect'; +import { createSelector, defaultMemoize } from 'reselect'; import { DataState, AdjacentProcessMap, @@ -32,6 +32,7 @@ import { } from '../../../../common/endpoint/types'; import * as resolverTreeModel from '../../models/resolver_tree'; import { isometricTaxiLayout } from '../../models/indexed_process_tree/isometric_taxi_layout'; +import { allEventCategories } from '../../../../common/endpoint/models/event'; /** * If there is currently a request. @@ -167,6 +168,116 @@ export function hasMoreAncestors(state: DataState): boolean { return tree ? resolverTreeModel.hasMoreAncestors(tree) : false; } +interface RelatedInfoFunctions { + shouldShowLimitForCategory: (category: string) => boolean; + numberNotDisplayedForCategory: (category: string) => number; + numberActuallyDisplayedForCategory: (category: string) => number; +} +/** + * A map of `entity_id`s to functions that provide information about + * related events by ECS `.category` Primarily to avoid having business logic + * in UI components. + */ +export const relatedEventInfoByEntityId: ( + state: DataState +) => (entityID: string) => RelatedInfoFunctions | null = createSelector( + relatedEventsByEntityId, + relatedEventsStats, + function selectLineageLimitInfo( + /* eslint-disable no-shadow */ + relatedEventsByEntityId, + relatedEventsStats + /* eslint-enable no-shadow */ + ) { + if (!relatedEventsStats) { + // If there are no related event stats, there are no related event info objects + return (entityId: string) => null; + } + return (entityId) => { + const stats = relatedEventsStats.get(entityId); + if (!stats) { + return null; + } + const eventsResponseForThisEntry = relatedEventsByEntityId.get(entityId); + const hasMoreEvents = + eventsResponseForThisEntry && eventsResponseForThisEntry.nextEvent !== null; + /** + * Get the "aggregate" total for the event category (i.e. _all_ events that would qualify as being "in category") + * For a set like `[DNS,File][File,DNS][Registry]` The first and second events would contribute to the aggregate total for DNS being 2. + * This is currently aligned with how the backed provides this information. + * + * @param eventCategory {string} The ECS category like 'file','dns',etc. + */ + const aggregateTotalForCategory = (eventCategory: string): number => { + return stats.events.byCategory[eventCategory] || 0; + }; + + /** + * Get all the related events in the category provided. + * + * @param eventCategory {string} The ECS category like 'file','dns',etc. + */ + const unmemoizedMatchingEventsForCategory = (eventCategory: string): ResolverEvent[] => { + if (!eventsResponseForThisEntry) { + return []; + } + return eventsResponseForThisEntry.events.filter((resolverEvent) => { + for (const category of [allEventCategories(resolverEvent)].flat()) { + if (category === eventCategory) { + return true; + } + } + return false; + }); + }; + + const matchingEventsForCategory = defaultMemoize(unmemoizedMatchingEventsForCategory); + + /** + * The number of events that occurred before the API limit was reached. + * The number of events that came back form the API that have `eventCategory` in their list of categories. + * + * @param eventCategory {string} The ECS category like 'file','dns',etc. + */ + const numberActuallyDisplayedForCategory = (eventCategory: string): number => { + return matchingEventsForCategory(eventCategory)?.length || 0; + }; + + /** + * The total number counted by the backend - the number displayed + * + * @param eventCategory {string} The ECS category like 'file','dns',etc. + */ + const numberNotDisplayedForCategory = (eventCategory: string): number => { + return ( + aggregateTotalForCategory(eventCategory) - + numberActuallyDisplayedForCategory(eventCategory) + ); + }; + + /** + * `true` when the `nextEvent` cursor appeared in the results and we are short on the number needed to + * fullfill the aggregate count. + * + * @param eventCategory {string} The ECS category like 'file','dns',etc. + */ + const shouldShowLimitForCategory = (eventCategory: string): boolean => { + if (hasMoreEvents && numberNotDisplayedForCategory(eventCategory) > 0) { + return true; + } + return false; + }; + + const entryValue = { + shouldShowLimitForCategory, + numberNotDisplayedForCategory, + numberActuallyDisplayedForCategory, + }; + return entryValue; + }; + } +); + /** * If we need to fetch, this is the ID to fetch. */ @@ -285,6 +396,7 @@ export const visibleProcessNodePositionsAndEdgeLineSegments = createSelector( }; } ); + /** * If there is a pending request that's for a entity ID that doesn't matche the `entityID`, then we should cancel it. */ diff --git a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts index 2bc254d118d33..6e512cfe13f62 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts @@ -103,6 +103,16 @@ export const relatedEventsReady = composeSelectors( dataSelectors.relatedEventsReady ); +/** + * Business logic lookup functions by ECS category by entity id. + * Example usage: + * const numberOfFileEvents = infoByEntityId.get(`someEntityId`)?.getAggregateTotalForCategory(`file`); + */ +export const relatedEventInfoByEntityId = composeSelectors( + dataStateSelector, + dataSelectors.relatedEventInfoByEntityId +); + /** * Returns the id of the "current" tree node (fake-focused) */ @@ -158,6 +168,16 @@ export const isLoading = composeSelectors(dataStateSelector, dataSelectors.isLoa */ export const hasError = composeSelectors(dataStateSelector, dataSelectors.hasError); +/** + * True if the children cursor is not null + */ +export const hasMoreChildren = composeSelectors(dataStateSelector, dataSelectors.hasMoreChildren); + +/** + * True if the ancestor cursor is not null + */ +export const hasMoreAncestors = composeSelectors(dataStateSelector, dataSelectors.hasMoreAncestors); + /** * An array containing all the processes currently in the Resolver than can be graphed */ diff --git a/x-pack/plugins/security_solution/public/resolver/view/limit_warnings.tsx b/x-pack/plugins/security_solution/public/resolver/view/limit_warnings.tsx new file mode 100644 index 0000000000000..e3bad8ee2e574 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/limit_warnings.tsx @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiCallOut } from '@elastic/eui'; +import { FormattedMessage } from 'react-intl'; + +const lineageLimitMessage = ( + <> + + +); + +const LineageTitleMessage = React.memo(function LineageTitleMessage({ + numberOfEntries, +}: { + numberOfEntries: number; +}) { + return ( + <> + + + ); +}); + +const RelatedEventsLimitMessage = React.memo(function RelatedEventsLimitMessage({ + category, + numberOfEventsMissing, +}: { + numberOfEventsMissing: number; + category: string; +}) { + return ( + <> + + + ); +}); + +const RelatedLimitTitleMessage = React.memo(function RelatedLimitTitleMessage({ + category, + numberOfEventsDisplayed, +}: { + numberOfEventsDisplayed: number; + category: string; +}) { + return ( + <> + + + ); +}); + +/** + * Limit warning for hitting the /events API limit + */ +export const RelatedEventLimitWarning = React.memo(function RelatedEventLimitWarning({ + className, + eventType, + numberActuallyDisplayed, + numberMissing, +}: { + className?: string; + eventType: string; + numberActuallyDisplayed: number; + numberMissing: number; +}) { + /** + * Based on API limits, all related events may not be displayed. + */ + return ( + + } + > +

+ +

+
+ ); +}); + +/** + * Limit warning for hitting a limit of nodes in the tree + */ +export const LimitWarning = React.memo(function LimitWarning({ + className, + numberDisplayed, +}: { + className?: string; + numberDisplayed: number; +}) { + return ( + } + > +

{lineageLimitMessage}

+
+ ); +}); diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_list.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_list.tsx index 9152649c07abf..0ed677885775f 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_list.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_list.tsx @@ -13,6 +13,7 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useSelector } from 'react-redux'; +import styled from 'styled-components'; import * as event from '../../../../common/endpoint/models/event'; import * as selectors from '../../store/selectors'; import { CrumbInfo, formatter, StyledBreadcrumbs } from './panel_content_utilities'; @@ -20,6 +21,27 @@ import { useResolverDispatch } from '../use_resolver_dispatch'; import { SideEffectContext } from '../side_effect_context'; import { CubeForProcess } from './process_cube_icon'; import { ResolverEvent } from '../../../../common/endpoint/types'; +import { LimitWarning } from '../limit_warnings'; + +const StyledLimitWarning = styled(LimitWarning)` + flex-flow: row wrap; + display: block; + align-items: baseline; + margin-top: 1em; + + & .euiCallOutHeader { + display: inline; + margin-right: 0.25em; + } + + & .euiText { + display: inline; + } + + & .euiText p { + display: inline; + } +`; /** * The "default" view for the panel: A list of all the processes currently in the graph. @@ -145,6 +167,7 @@ export const ProcessListWithCounts = memo(function ProcessListWithCounts({ }), [processNodePositions] ); + const numberOfProcesses = processTableView.length; const crumbs = useMemo(() => { return [ @@ -160,9 +183,13 @@ export const ProcessListWithCounts = memo(function ProcessListWithCounts({ ]; }, []); + const children = useSelector(selectors.hasMoreChildren); + const ancestors = useSelector(selectors.hasMoreAncestors); + const showWarning = children === true || ancestors === true; return ( <> + {showWarning && } items={processTableView} columns={columns} sorting /> diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_related_list.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_related_list.tsx index 1c17cf7e6ce34..591432e1f9f9f 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_related_list.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_related_list.tsx @@ -9,6 +9,7 @@ import { i18n } from '@kbn/i18n'; import { EuiTitle, EuiSpacer, EuiText, EuiButtonEmpty, EuiHorizontalRule } from '@elastic/eui'; import { useSelector } from 'react-redux'; import { FormattedMessage } from 'react-intl'; +import styled from 'styled-components'; import { CrumbInfo, formatDate, @@ -20,6 +21,7 @@ import * as event from '../../../../common/endpoint/models/event'; import { ResolverEvent, ResolverNodeStats } from '../../../../common/endpoint/types'; import * as selectors from '../../store/selectors'; import { useResolverDispatch } from '../use_resolver_dispatch'; +import { RelatedEventLimitWarning } from '../limit_warnings'; /** * This view presents a list of related events of a given type for a given process. @@ -40,16 +42,53 @@ interface MatchingEventEntry { setQueryParams: () => void; } +const StyledRelatedLimitWarning = styled(RelatedEventLimitWarning)` + flex-flow: row wrap; + display: block; + align-items: baseline; + margin-top: 1em; + + & .euiCallOutHeader { + display: inline; + margin-right: 0.25em; + } + + & .euiText { + display: inline; + } + + & .euiText p { + display: inline; + } +`; + const DisplayList = memo(function DisplayList({ crumbs, matchingEventEntries, + eventType, + processEntityId, }: { crumbs: Array<{ text: string | JSX.Element; onClick: () => void }>; matchingEventEntries: MatchingEventEntry[]; + eventType: string; + processEntityId: string; }) { + const relatedLookupsByCategory = useSelector(selectors.relatedEventInfoByEntityId); + const lookupsForThisNode = relatedLookupsByCategory(processEntityId); + const shouldShowLimitWarning = lookupsForThisNode?.shouldShowLimitForCategory(eventType); + const numberDisplayed = lookupsForThisNode?.numberActuallyDisplayedForCategory(eventType); + const numberMissing = lookupsForThisNode?.numberNotDisplayedForCategory(eventType); + return ( <> + {shouldShowLimitWarning && typeof numberDisplayed !== 'undefined' && numberMissing ? ( + + ) : null} <> {matchingEventEntries.map((eventView, index) => { @@ -250,6 +289,13 @@ export const ProcessEventListNarrowedByType = memo(function ProcessEventListNarr ); } - return ; + return ( + + ); }); ProcessEventListNarrowedByType.displayName = 'ProcessEventListNarrowedByType'; From c82ccfedc6852179e8404c1100adc13ecef6ae6f Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Mon, 13 Jul 2020 13:41:49 -0400 Subject: [PATCH 25/40] [SECURITY_SOLUTION][ENDPOINT] Sync up i18n of Policy Response action names to the latest from Endpoint (#71472) * Added updated Policy Response action names to translation file * `formatResponse` to generate a user friendly value for action name if no i18n * test case to cover formatting unknown actions --- .../details/policy_response_friendly_names.ts | 352 +++++++++++------- .../pages/endpoint_hosts/view/index.test.tsx | 17 +- 2 files changed, 228 insertions(+), 141 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/policy_response_friendly_names.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/policy_response_friendly_names.ts index 28e91331b428d..020e8c9e38ad5 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/policy_response_friendly_names.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/policy_response_friendly_names.ts @@ -6,7 +6,209 @@ import { i18n } from '@kbn/i18n'; -const responseMap = new Map(); +const policyResponses: Array<[string, string]> = [ + [ + 'configure_dns_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.configure_dns_events', + { defaultMessage: 'Configure DNS Events' } + ), + ], + [ + 'configure_elasticsearch_connection', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.configure_elasticsearch_connection', + { defaultMessage: 'Configure Elastic Search Connection' } + ), + ], + [ + 'configure_file_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.configure_file_events', + { defaultMessage: 'Configure File Events' } + ), + ], + [ + 'configure_imageload_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.configure_imageload_events', + { defaultMessage: 'Configure Image Load Events' } + ), + ], + [ + 'configure_kernel', + i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.configure_kernel', { + defaultMessage: 'Configure Kernel', + }), + ], + [ + 'configure_logging', + i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.configure_logging', { + defaultMessage: 'Configure Logging', + }), + ], + [ + 'configure_malware', + i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.configure_malware', { + defaultMessage: 'Configure Malware', + }), + ], + [ + 'configure_network_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.configure_network_events', + { defaultMessage: 'Configure Network Events' } + ), + ], + [ + 'configure_process_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.configure_process_events', + { defaultMessage: 'Configure Process Events' } + ), + ], + [ + 'configure_registry_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.configure_registry_events', + { defaultMessage: 'Configure Registry Events' } + ), + ], + [ + 'configure_security_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.configure_security_events', + { defaultMessage: 'Configure Security Events' } + ), + ], + [ + 'connect_kernel', + i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.connect_kernel', { + defaultMessage: 'Connect Kernel', + }), + ], + [ + 'detect_async_image_load_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.detect_async_image_load_events', + { defaultMessage: 'Detect Async Image Load Events' } + ), + ], + [ + 'detect_file_open_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.detect_file_open_events', + { defaultMessage: 'Detect File Open Events' } + ), + ], + [ + 'detect_file_write_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.detect_file_write_events', + { defaultMessage: 'Detect File Write Events' } + ), + ], + [ + 'detect_network_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.detect_network_events', + { defaultMessage: 'Detect Network Events' } + ), + ], + [ + 'detect_process_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.detect_process_events', + { defaultMessage: 'Detect Process Events' } + ), + ], + [ + 'detect_registry_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.detect_registry_events', + { defaultMessage: 'Detect Registry Events' } + ), + ], + [ + 'detect_sync_image_load_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.detect_sync_image_load_events', + { defaultMessage: 'Detect Sync Image Load Events' } + ), + ], + [ + 'download_global_artifacts', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.download_global_artifacts', + { defaultMessage: 'Download Global Artifacts' } + ), + ], + [ + 'download_user_artifacts', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.download_user_artifacts', + { defaultMessage: 'Download User Artifacts' } + ), + ], + [ + 'load_config', + i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.load_config', { + defaultMessage: 'Load Config', + }), + ], + [ + 'load_malware_model', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.load_malware_model', + { defaultMessage: 'Load Malware Model' } + ), + ], + [ + 'read_elasticsearch_config', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.read_elasticsearch_config', + { defaultMessage: 'Read ElasticSearch Config' } + ), + ], + [ + 'read_events_config', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.read_events_config', + { defaultMessage: 'Read Events Config' } + ), + ], + [ + 'read_kernel_config', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.read_kernel_config', + { defaultMessage: 'Read Kernel Config' } + ), + ], + [ + 'read_logging_config', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.read_logging_config', + { defaultMessage: 'Read Logging Config' } + ), + ], + [ + 'read_malware_config', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.read_malware_config', + { defaultMessage: 'Read Malware Config' } + ), + ], + [ + 'workflow', + i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.workflow', { + defaultMessage: 'Workflow', + }), + ], +]; + +const responseMap = new Map(policyResponses); + +// Additional values used in the Policy Response UI responseMap.set( 'success', i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.success', { @@ -49,144 +251,6 @@ responseMap.set( defaultMessage: 'Events', }) ); -responseMap.set( - 'configure_elasticsearch_connection', - i18n.translate( - 'xpack.securitySolution.endpoint.hostDetails.policyResponse.configureElasticSearchConnection', - { - defaultMessage: 'Configure Elastic Search Connection', - } - ) -); -responseMap.set( - 'configure_logging', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.configureLogging', { - defaultMessage: 'Configure Logging', - }) -); -responseMap.set( - 'configure_kernel', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.configureKernel', { - defaultMessage: 'Configure Kernel', - }) -); -responseMap.set( - 'configure_malware', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.configureMalware', { - defaultMessage: 'Configure Malware', - }) -); -responseMap.set( - 'connect_kernel', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.connectKernel', { - defaultMessage: 'Connect Kernel', - }) -); -responseMap.set( - 'detect_file_open_events', - i18n.translate( - 'xpack.securitySolution.endpoint.hostDetails.policyResponse.detectFileOpenEvents', - { - defaultMessage: 'Detect File Open Events', - } - ) -); -responseMap.set( - 'detect_file_write_events', - i18n.translate( - 'xpack.securitySolution.endpoint.hostDetails.policyResponse.detectFileWriteEvents', - { - defaultMessage: 'Detect File Write Events', - } - ) -); -responseMap.set( - 'detect_image_load_events', - i18n.translate( - 'xpack.securitySolution.endpoint.hostDetails.policyResponse.detectImageLoadEvents', - { - defaultMessage: 'Detect Image Load Events', - } - ) -); -responseMap.set( - 'detect_process_events', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.detectProcessEvents', { - defaultMessage: 'Detect Process Events', - }) -); -responseMap.set( - 'download_global_artifacts', - i18n.translate( - 'xpack.securitySolution.endpoint.hostDetails.policyResponse.downloadGlobalArtifacts', - { - defaultMessage: 'Download Global Artifacts', - } - ) -); -responseMap.set( - 'load_config', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.loadConfig', { - defaultMessage: 'Load Config', - }) -); -responseMap.set( - 'load_malware_model', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.loadMalwareModel', { - defaultMessage: 'Load Malware Model', - }) -); -responseMap.set( - 'read_elasticsearch_config', - i18n.translate( - 'xpack.securitySolution.endpoint.hostDetails.policyResponse.readElasticSearchConfig', - { - defaultMessage: 'Read ElasticSearch Config', - } - ) -); -responseMap.set( - 'read_events_config', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.readEventsConfig', { - defaultMessage: 'Read Events Config', - }) -); -responseMap.set( - 'read_kernel_config', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.readKernelConfig', { - defaultMessage: 'Read Kernel Config', - }) -); -responseMap.set( - 'read_logging_config', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.readLoggingConfig', { - defaultMessage: 'Read Logging Config', - }) -); -responseMap.set( - 'read_malware_config', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.readMalwareConfig', { - defaultMessage: 'Read Malware Config', - }) -); -responseMap.set( - 'workflow', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.workflow', { - defaultMessage: 'Workflow', - }) -); -responseMap.set( - 'download_model', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.downloadModel', { - defaultMessage: 'Download Model', - }) -); -responseMap.set( - 'ingest_events_config', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.injestEventsConfig', { - defaultMessage: 'Injest Events Config', - }) -); /** * Maps a server provided value to corresponding i18n'd string. @@ -195,5 +259,13 @@ export function formatResponse(responseString: string) { if (responseMap.has(responseString)) { return responseMap.get(responseString); } - return responseString; + + // Its possible for the UI to receive an Action name that it does not yet have a translation, + // thus we generate a label for it here by making it more user fiendly + responseMap.set( + responseString, + responseString.replace(/_/g, ' ').replace(/\b(\w)/g, (m) => m.toUpperCase()) + ); + + return responseMap.get(responseString); } diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx index 996b987ea2be3..a61088e2edd29 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx @@ -13,8 +13,9 @@ import { mockPolicyResultList } from '../../policy/store/policy_list/mock_policy import { AppContextTestRender, createAppRootMockRenderer } from '../../../../common/mock/endpoint'; import { HostInfo, - HostStatus, HostPolicyResponseActionStatus, + HostPolicyResponseAppliedAction, + HostStatus, } from '../../../../../common/endpoint/types'; import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_data'; import { AppAction } from '../../../../common/store/actions'; @@ -251,6 +252,16 @@ describe('when on the hosts page', () => { ) { malwareResponseConfigurations.concerned_actions.push(downloadModelAction.name); } + + // Add an unknown Action Name - to ensure we handle the format of it on the UI + const unknownAction: HostPolicyResponseAppliedAction = { + status: HostPolicyResponseActionStatus.success, + message: 'test message', + name: 'a_new_unknown_action', + }; + policyResponse.Endpoint.policy.applied.actions.push(unknownAction); + malwareResponseConfigurations.concerned_actions.push(unknownAction.name); + reactTestingLibrary.act(() => { store.dispatch({ type: 'serverReturnedHostPolicyResponse', @@ -564,6 +575,10 @@ describe('when on the hosts page', () => { '?page_index=0&page_size=10&selected_host=1' ); }); + + it('should format unknown policy action names', async () => { + expect(renderResult.getByText('A New Unknown Action')).not.toBeNull(); + }); }); }); }); From 41c4f18b8961dcfe537c727c8547d28de7c8c501 Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Mon, 13 Jul 2020 13:10:35 -0500 Subject: [PATCH 26/40] Workplace Search in Kibana MVP (#70979) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Workplace Search plugin to app - Adds telemetry for Workplace Search - Adds routing for telemetry and overview - Registers plugin * Add breadcrumbs for Workplace Search * Add Workplace Search index * Add route paths, types and shared assets * Add shared Workplace Search components * Add setup guide to Workplace Search * Add error state to Workplace Search * Add Workplace Search overview This is the functional MVP for Workplace Search * Update telemetry per recent changes - Remove saved objects indexing - add schema definition - remove no_ws_account - minor cleanup * Fix pluralization syntax - Still not working but fixed the syntax nonetheless * Change pluralization method - Was unable to get the `FormattedMessage` to work using the syntax in the docs. Always added ‘more’, even when there were zero (or one for users). This commit uses an alternative approach that works * Update readme * Fix duplicate i18n label * Fix failing test from previous commit :facepalm: * Update link for image in Setup Guide * Remove need for hash in routes Because of a change in the Workplace Search rails code, we can now use non-hash routes that will be redirected by rails so that we don’t have users stuck on the overview page in Workplace Search when logging in * Directly link to source details from activity feed Previously the dashboard in legacy Workplace Search linked to the sources page and this was replicated in the Kibana MVP. This PR aligns with the legacy dashboard directly linking to the source details https://github.com/elastic/ent-search/pull/1688 * Add warn logging to Workplace Search telemetry collector * Change casing to camel to match App Search * Misc security feedback for Workplace Search * Update licence mocks to match App Search * PR feedback from App Search PR * REmove duplicate code from merge conflict * Fix tests * Move varible declaration inside map for TypeScript There was no other way :facepalm: * Refactor last commit * Add punctuation Smallest commit ever. * Fix actionPath type errors * Update rebase feedback * Fix failing test * Update telemetry test after AS PR feedback * DRY out error state prompt copy * DRY out telemetry endpoint into a single route + DRY out DRY out endpoint - Instead of /api/app_search/telemetry & /api/workplace_search/telemetry, just have a single /api/enterprise_search/telemetry endpoint that takes a product param - Update public/send_telemetry accordingly (+ write tests for SendWorkplaceSearchTelemetry) DRY out helpers - Pull out certain reusable helper functions into a shared lib/ folder and have them take the repo id/name as a param - Move tests over - Remove misplaced comment block +BONUS - pull out content type header that's been giving us grief in Chrome into a constant * Remove unused telemetry type * Minor server cleanup - DRY out mockLogger * Setup Guide cleanup * Clean up Loading component - use EUI vars per feedback - remove unnecessary wrapper - adjust vh for Kibana layout - Actually apply loadingSpinner styles * Misc i18n fixes + minor newline reduction, because prettier lets me * Refactor Recent Activity component/styles - Remove table markup/styles - not semantically correct or accessible in this case - replace w flex - Fix link colors not inheriting - Add EuiPanel, error colors looked odd against page background - Fix prop/type definition - CSS cleanup - EUI vars, correct BEM, don't target generic selectors * [Opinionated] Refactor RecentActivity component - Pull out iterated activity items into a child subcomponent - Move constants/strings closer to where they're being used, instead of having to jump around the file - Move IActivityFeed definition to this file, since that's primarily where it's used @scottybollinger - if you're not a fan of this commit no worries, just let me know and we can discuss/roll back as needed * Refactor ViewContentHeader - remove unused CSS - fallback cleanup - refactor tests * Refactor ContentSection - Remove unused CSS classes - Refactor tests to include all props/more specific assertions * Refactor StatisticCard - Prefer using EuiTextColor to spans / custom classes - Prefer using EuiCard's native `href` behavior over using our own wrapping link/--isClickablec class - Note that when we port the link/destination over to React Router, we should instead opt to use React Router history, which will involve creating a EuiCard helper - Make test a bit more specific * Minor OrganizationStats cleanup - Use EuiFlexGrid * Refactor OnboardingSteps - i18n - Compact i18n newlines (nit) - Convert FormattedMessage to i18n.translate for easier test assertions - Org Name CTA - Move to separate child subcomponent to make it easier to quickly skim the parent container - Remove unused CSS class - Fix/add responsive behavior - Tests refactor - Use describe() blocks to break up tests by card/section - Make sure each card has tests for each state - zero, some/complete, and disabled/no access - Assert by plain text now that we're using i18n.translate() - Remove ContentSection/EuiPanel assertions - they're not terribly useful, and we have more specific elements to check - Add accounts={0} test to satisfy yellow branch line * Clean up OnboardingCard - Remove unused CSS class - Remove unnecessary template literal Tests - Swap out check for EuiFlexItem - it's not really the content we're concerned about displaying, EuiEmptyPrompt is the primary component - Remove need for mount() by dive()ing into EuiEmptyPrompt (this also removes the need to specify a[data-test-subj] instead of just [data-test-subj]) - Simplify empty button test - previous test has already checked for href/telemetry - Cover uncovered actionPath branch line * Minor Overview cleanup - Remove unused telemetry type - Remove unused CSS class - finally - Remove unused license context from tests * Feedback: UI fixes - Fix setup guide CSS class casing - Remove border transparent (UX > UI) * Fix Workplace Search not being hidden on feature control - Whoops, totally missed this :facepalm: * Add very basic functional Workplace Search test - Has to be without_host_configured, since with host requires Enterprise Search - Just checks for basic Setup Guide redirect for now - TODO: Add more in-depth feature/privilege functional tests for both plugins at later date * Pay down test render/loading tech debt - Turns out you don't need render(), shallow() skips useEffect already :facepalm: - Fix outdated comment import example * DRY out repeated mountWithApiMock into mountWithAsyncContext + Minor engines_overview test refactors: - Prefer to define `const wrapper` at the start of each test rather than a `let wrapper` - this better for sandboxing / not leaking state between tests - Move Platinum license tests above pagination, so the contrast between the two tests are easier to grok * Design feedback - README copy tweak + linting - Remove unused euiCard classes from onboarding card Co-authored-by: Constance Chen --- x-pack/plugins/enterprise_search/README.md | 5 +- .../enterprise_search/common/constants.ts | 2 + .../public/applications/__mocks__/index.ts | 6 +- .../__mocks__/mount_with_context.mock.tsx | 33 +++- .../__mocks__/shallow_usecontext.mock.ts | 2 +- .../empty_states/empty_states.test.tsx | 3 +- .../components/empty_states/error_state.tsx | 74 +------- .../engine_overview/engine_overview.test.tsx | 145 ++++++-------- .../public/applications/index.test.tsx | 10 +- .../error_state/error_state_prompt.test.tsx | 21 ++ .../shared/error_state/error_state_prompt.tsx | 79 ++++++++ .../applications/shared/error_state/index.ts | 7 + .../generate_breadcrumbs.test.ts | 85 ++++++++- .../generate_breadcrumbs.ts | 3 + .../shared/kibana_breadcrumbs/index.ts | 9 +- .../kibana_breadcrumbs/set_breadcrumbs.tsx | 29 ++- .../applications/shared/telemetry/index.ts | 1 + .../shared/telemetry/send_telemetry.test.tsx | 25 ++- .../shared/telemetry/send_telemetry.tsx | 19 +- .../public/applications/shared/types.ts | 14 ++ .../assets/getting_started.png | Bin 0 -> 487510 bytes .../workplace_search/assets/logo.svg | 5 + .../error_state/error_state.test.tsx | 21 ++ .../components/error_state/error_state.tsx | 34 ++++ .../components/error_state/index.ts | 7 + .../components/overview/index.ts | 7 + .../overview/onboarding_card.test.tsx | 54 ++++++ .../components/overview/onboarding_card.tsx | 92 +++++++++ .../overview/onboarding_steps.test.tsx | 136 +++++++++++++ .../components/overview/onboarding_steps.tsx | 179 ++++++++++++++++++ .../overview/organization_stats.test.tsx | 31 +++ .../overview/organization_stats.tsx | 74 ++++++++ .../components/overview/overview.test.tsx | 77 ++++++++ .../components/overview/overview.tsx | 151 +++++++++++++++ .../components/overview/recent_activity.scss | 37 ++++ .../overview/recent_activity.test.tsx | 61 ++++++ .../components/overview/recent_activity.tsx | 131 +++++++++++++ .../overview/statistic_card.test.tsx | 32 ++++ .../components/overview/statistic_card.tsx | 46 +++++ .../components/setup_guide/index.ts | 7 + .../setup_guide/setup_guide.test.tsx | 21 ++ .../components/setup_guide/setup_guide.tsx | 70 +++++++ .../components/shared/assets/share_circle.svg | 3 + .../content_section/content_section.test.tsx | 50 +++++ .../content_section/content_section.tsx | 45 +++++ .../shared/content_section/index.ts | 7 + .../components/shared/loading/index.ts | 7 + .../components/shared/loading/loading.scss | 14 ++ .../shared/loading/loading.test.tsx | 21 ++ .../components/shared/loading/loading.tsx | 17 ++ .../components/shared/product_button/index.ts | 7 + .../product_button/product_button.test.tsx | 38 ++++ .../shared/product_button/product_button.tsx | 41 ++++ .../components/shared/use_routes/index.ts | 7 + .../shared/use_routes/use_routes.tsx | 15 ++ .../shared/view_content_header/index.ts | 7 + .../view_content_header.test.tsx | 39 ++++ .../view_content_header.tsx | 42 ++++ .../workplace_search/index.test.tsx | 46 +++++ .../applications/workplace_search/index.tsx | 29 +++ .../applications/workplace_search/routes.ts | 12 ++ .../applications/workplace_search/types.ts | 16 ++ .../enterprise_search/public/plugin.ts | 29 ++- .../collectors/app_search/telemetry.test.ts | 49 +---- .../server/collectors/app_search/telemetry.ts | 55 +----- .../server/collectors/lib/telemetry.test.ts | 69 +++++++ .../server/collectors/lib/telemetry.ts | 62 ++++++ .../workplace_search/telemetry.test.ts | 101 ++++++++++ .../collectors/workplace_search/telemetry.ts | 115 +++++++++++ .../enterprise_search/server/plugin.ts | 31 +-- .../telemetry.test.ts | 81 ++++++-- .../telemetry.ts | 26 ++- .../routes/workplace_search/overview.test.ts | 127 +++++++++++++ .../routes/workplace_search/overview.ts | 46 +++++ .../workplace_search/telemetry.ts | 19 ++ .../schema/xpack_plugins.json | 37 ++++ .../app_search/setup_guide.ts | 2 +- .../without_host_configured/index.ts | 1 + .../workplace_search/setup_guide.ts | 36 ++++ .../page_objects/index.ts | 2 + .../page_objects/workplace_search.ts | 17 ++ .../security_and_spaces/tests/catalogue.ts | 3 +- .../security_and_spaces/tests/nav_links.ts | 8 +- .../security_only/tests/catalogue.ts | 3 +- .../security_only/tests/nav_links.ts | 2 +- 85 files changed, 2908 insertions(+), 321 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/error_state/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/types.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/getting_started.png create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/logo.svg create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.scss create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/share_circle.svg create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.scss create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/use_routes.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts create mode 100644 x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.ts create mode 100644 x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.ts rename x-pack/plugins/enterprise_search/server/routes/{app_search => enterprise_search}/telemetry.test.ts (56%) rename x-pack/plugins/enterprise_search/server/routes/{app_search => enterprise_search}/telemetry.ts (55%) create mode 100644 x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.ts create mode 100644 x-pack/plugins/enterprise_search/server/saved_objects/workplace_search/telemetry.ts create mode 100644 x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/workplace_search/setup_guide.ts create mode 100644 x-pack/test/functional_enterprise_search/page_objects/workplace_search.ts diff --git a/x-pack/plugins/enterprise_search/README.md b/x-pack/plugins/enterprise_search/README.md index 8c316c848184b..31ee304fe2247 100644 --- a/x-pack/plugins/enterprise_search/README.md +++ b/x-pack/plugins/enterprise_search/README.md @@ -2,7 +2,10 @@ ## Overview -This plugin's goal is to provide a Kibana user interface to the Enterprise Search solution's products (App Search and Workplace Search). In its current MVP state, the plugin provides a basic engines overview from App Search with the goal of gathering user feedback and raising product awareness. +This plugin's goal is to provide a Kibana user interface to the Enterprise Search solution's products (App Search and Workplace Search). In it's current MVP state, the plugin provides the following with the goal of gathering user feedback and raising product awareness: + +- **App Search:** A basic engines overview with links into the product. +- **Workplace Search:** A simple app overview with basic statistics, links to the sources, users (if standard auth), and product settings. ## Development diff --git a/x-pack/plugins/enterprise_search/common/constants.ts b/x-pack/plugins/enterprise_search/common/constants.ts index c134131caba75..fc9a47717871b 100644 --- a/x-pack/plugins/enterprise_search/common/constants.ts +++ b/x-pack/plugins/enterprise_search/common/constants.ts @@ -4,4 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ +export const JSON_HEADER = { 'Content-Type': 'application/json' }; // This needs specific casing or Chrome throws a 415 error + export const ENGINES_PAGE_SIZE = 10; diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts index 14fde357a980a..6f82946c0ea14 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts @@ -7,7 +7,11 @@ export { mockHistory } from './react_router_history.mock'; export { mockKibanaContext } from './kibana_context.mock'; export { mockLicenseContext } from './license_context.mock'; -export { mountWithContext, mountWithKibanaContext } from './mount_with_context.mock'; +export { + mountWithContext, + mountWithKibanaContext, + mountWithAsyncContext, +} from './mount_with_context.mock'; export { shallowWithIntl } from './shallow_with_i18n.mock'; // Note: shallow_usecontext must be imported directly as a file diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx b/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx index dfcda544459d4..1e0df1326c177 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx @@ -5,7 +5,8 @@ */ import React from 'react'; -import { mount } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { mount, ReactWrapper } from 'enzyme'; import { I18nProvider } from '@kbn/i18n/react'; import { KibanaContext } from '../'; @@ -47,3 +48,33 @@ export const mountWithKibanaContext = (children: React.ReactNode, context?: obje ); }; + +/** + * This helper is intended for components that have async effects + * (e.g. http fetches) on mount. It mostly adds act/update boilerplate + * that's needed for the wrapper to play nice with Enzyme/Jest + * + * Example usage: + * + * const wrapper = mountWithAsyncContext(, { http: { get: () => someData } }); + */ +export const mountWithAsyncContext = async ( + children: React.ReactNode, + context: object +): Promise => { + let wrapper: ReactWrapper | undefined; + + // We get a lot of act() warning/errors in the terminal without this. + // TBH, I don't fully understand why since Enzyme's mount is supposed to + // have act() baked in - could be because of the wrapping context provider? + await act(async () => { + wrapper = mountWithContext(children, context); + }); + if (wrapper) { + wrapper.update(); // This seems to be required for the DOM to actually update + + return wrapper; + } else { + throw new Error('Could not mount wrapper'); + } +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts index 767a52a75d1fb..2bcdd42c38055 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts @@ -19,7 +19,7 @@ jest.mock('react', () => ({ /** * Example usage within a component test using shallow(): * - * import '../../../test_utils/mock_shallow_usecontext'; // Must come before React's import, adjust relative path as needed + * import '../../../__mocks__/shallow_usecontext'; // Must come before React's import, adjust relative path as needed * * import React from 'react'; * import { shallow } from 'enzyme'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx index 12bf003564103..25a9fa7430c40 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx @@ -9,6 +9,7 @@ import '../../../__mocks__/shallow_usecontext.mock'; import React from 'react'; import { shallow } from 'enzyme'; import { EuiEmptyPrompt, EuiButton, EuiLoadingContent } from '@elastic/eui'; +import { ErrorStatePrompt } from '../../../shared/error_state'; jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn(), @@ -22,7 +23,7 @@ describe('ErrorState', () => { it('renders', () => { const wrapper = shallow(); - expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + expect(wrapper.find(ErrorStatePrompt)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx index d8eeff2aba1c6..7ac02082ee75c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx @@ -4,21 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useContext } from 'react'; -import { EuiPage, EuiPageBody, EuiPageContent, EuiEmptyPrompt, EuiCode } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; +import React from 'react'; +import { EuiPage, EuiPageBody, EuiPageContent } from '@elastic/eui'; -import { EuiButton } from '../../../shared/react_router_helpers'; +import { ErrorStatePrompt } from '../../../shared/error_state'; import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; -import { KibanaContext, IKibanaContext } from '../../../index'; import { EngineOverviewHeader } from '../engine_overview_header'; import './empty_states.scss'; export const ErrorState: React.FC = () => { - const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext; - return ( @@ -26,68 +22,8 @@ export const ErrorState: React.FC = () => { - - - - - } - titleSize="l" - body={ - <> -

- {enterpriseSearchUrl}, - }} - /> -

-
    -
  1. - config/kibana.yml, - }} - /> -
  2. -
  3. - -
  4. -
  5. - [enterpriseSearch][plugins], - }} - /> -
  6. -
- - } - actions={ - - - - } - /> + +
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx index 4d2a2ea1df9aa..45ab5dc5b9ab1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx @@ -8,51 +8,45 @@ import '../../../__mocks__/react_router_history.mock'; import React from 'react'; import { act } from 'react-dom/test-utils'; -import { render, ReactWrapper } from 'enzyme'; +import { shallow, ReactWrapper } from 'enzyme'; -import { I18nProvider } from '@kbn/i18n/react'; -import { KibanaContext } from '../../../'; -import { LicenseContext } from '../../../shared/licensing'; -import { mountWithContext, mockKibanaContext } from '../../../__mocks__'; +import { mountWithAsyncContext, mockKibanaContext } from '../../../__mocks__'; -import { EmptyState, ErrorState } from '../empty_states'; -import { EngineTable, IEngineTablePagination } from './engine_table'; +import { LoadingState, EmptyState, ErrorState } from '../empty_states'; +import { EngineTable } from './engine_table'; import { EngineOverview } from './'; describe('EngineOverview', () => { + const mockHttp = mockKibanaContext.http; + describe('non-happy-path states', () => { it('isLoading', () => { - // We use render() instead of mount() here to not trigger lifecycle methods (i.e., useEffect) - // TODO: Consider pulling this out to a renderWithContext mock/helper - const wrapper: Cheerio = render( - - - - - - - - ); - - // render() directly renders HTML which means we have to look for selectors instead of for LoadingState directly - expect(wrapper.find('.euiLoadingContent')).toHaveLength(2); + const wrapper = shallow(); + + expect(wrapper.find(LoadingState)).toHaveLength(1); }); it('isEmpty', async () => { - const wrapper = await mountWithApiMock({ - get: () => ({ - results: [], - meta: { page: { total_results: 0 } }, - }), + const wrapper = await mountWithAsyncContext(, { + http: { + ...mockHttp, + get: () => ({ + results: [], + meta: { page: { total_results: 0 } }, + }), + }, }); expect(wrapper.find(EmptyState)).toHaveLength(1); }); it('hasErrorConnecting', async () => { - const wrapper = await mountWithApiMock({ - get: () => ({ invalidPayload: true }), + const wrapper = await mountWithAsyncContext(, { + http: { + ...mockHttp, + get: () => ({ invalidPayload: true }), + }, }); expect(wrapper.find(ErrorState)).toHaveLength(1); }); @@ -78,17 +72,17 @@ describe('EngineOverview', () => { }, }; const mockApi = jest.fn(() => mockedApiResponse); - let wrapper: ReactWrapper; - beforeAll(async () => { - wrapper = await mountWithApiMock({ get: mockApi }); + beforeEach(() => { + jest.clearAllMocks(); }); - it('renders', () => { - expect(wrapper.find(EngineTable)).toHaveLength(1); - }); + it('renders and calls the engines API', async () => { + const wrapper = await mountWithAsyncContext(, { + http: { ...mockHttp, get: mockApi }, + }); - it('calls the engines API', () => { + expect(wrapper.find(EngineTable)).toHaveLength(1); expect(mockApi).toHaveBeenNthCalledWith(1, '/api/app_search/engines', { query: { type: 'indexed', @@ -97,19 +91,42 @@ describe('EngineOverview', () => { }); }); + describe('when on a platinum license', () => { + it('renders a 2nd meta engines table & makes a 2nd meta engines API call', async () => { + const wrapper = await mountWithAsyncContext(, { + http: { ...mockHttp, get: mockApi }, + license: { type: 'platinum', isActive: true }, + }); + + expect(wrapper.find(EngineTable)).toHaveLength(2); + expect(mockApi).toHaveBeenNthCalledWith(2, '/api/app_search/engines', { + query: { + type: 'meta', + pageIndex: 1, + }, + }); + }); + }); + describe('pagination', () => { - const getTablePagination: () => IEngineTablePagination = () => - wrapper.find(EngineTable).first().prop('pagination'); + const getTablePagination = (wrapper: ReactWrapper) => + wrapper.find(EngineTable).prop('pagination'); - it('passes down page data from the API', () => { - const pagination = getTablePagination(); + it('passes down page data from the API', async () => { + const wrapper = await mountWithAsyncContext(, { + http: { ...mockHttp, get: mockApi }, + }); + const pagination = getTablePagination(wrapper); expect(pagination.totalEngines).toEqual(100); expect(pagination.pageIndex).toEqual(0); }); it('re-polls the API on page change', async () => { - await act(async () => getTablePagination().onPaginate(5)); + const wrapper = await mountWithAsyncContext(, { + http: { ...mockHttp, get: mockApi }, + }); + await act(async () => getTablePagination(wrapper).onPaginate(5)); wrapper.update(); expect(mockApi).toHaveBeenLastCalledWith('/api/app_search/engines', { @@ -118,54 +135,8 @@ describe('EngineOverview', () => { pageIndex: 5, }, }); - expect(getTablePagination().pageIndex).toEqual(4); - }); - }); - - describe('when on a platinum license', () => { - beforeAll(async () => { - mockApi.mockClear(); - wrapper = await mountWithApiMock({ - license: { type: 'platinum', isActive: true }, - get: mockApi, - }); - }); - - it('renders a 2nd meta engines table', () => { - expect(wrapper.find(EngineTable)).toHaveLength(2); - }); - - it('makes a 2nd call to the engines API with type meta', () => { - expect(mockApi).toHaveBeenNthCalledWith(2, '/api/app_search/engines', { - query: { - type: 'meta', - pageIndex: 1, - }, - }); + expect(getTablePagination(wrapper).pageIndex).toEqual(4); }); }); }); - - /** - * Test helpers - */ - - const mountWithApiMock = async ({ get, license }: { get(): any; license?: object }) => { - let wrapper: ReactWrapper | undefined; - const httpMock = { ...mockKibanaContext.http, get }; - - // We get a lot of act() warning/errors in the terminal without this. - // TBH, I don't fully understand why since Enzyme's mount is supposed to - // have act() baked in - could be because of the wrapping context provider? - await act(async () => { - wrapper = mountWithContext(, { http: httpMock, license }); - }); - if (wrapper) { - wrapper.update(); // This seems to be required for the DOM to actually update - - return wrapper; - } else { - throw new Error('Could not mount wrapper'); - } - }; }); diff --git a/x-pack/plugins/enterprise_search/public/applications/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/index.test.tsx index 1aead8468ca3b..70e16e61846b4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/index.test.tsx @@ -6,14 +6,16 @@ import React from 'react'; +import { AppMountParameters } from 'src/core/public'; import { coreMock } from 'src/core/public/mocks'; import { licensingMock } from '../../../licensing/public/mocks'; import { renderApp } from './'; import { AppSearch } from './app_search'; +import { WorkplaceSearch } from './workplace_search'; describe('renderApp', () => { - const params = coreMock.createAppMountParamters(); + let params: AppMountParameters; const core = coreMock.createStart(); const config = {}; const plugins = { @@ -22,6 +24,7 @@ describe('renderApp', () => { beforeEach(() => { jest.clearAllMocks(); + params = coreMock.createAppMountParamters(); }); it('mounts and unmounts UI', () => { @@ -37,4 +40,9 @@ describe('renderApp', () => { renderApp(AppSearch, core, params, config, plugins); expect(params.element.querySelector('.setupGuide')).not.toBeNull(); }); + + it('renders WorkplaceSearch', () => { + renderApp(WorkplaceSearch, core, params, config, plugins); + expect(params.element.querySelector('.setupGuide')).not.toBeNull(); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.test.tsx new file mode 100644 index 0000000000000..29b773b80158a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.test.tsx @@ -0,0 +1,21 @@ +/* + * 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 '../../__mocks__/shallow_usecontext.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiEmptyPrompt } from '@elastic/eui'; + +import { ErrorStatePrompt } from './'; + +describe('ErrorState', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.tsx new file mode 100644 index 0000000000000..81455cea0b497 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.tsx @@ -0,0 +1,79 @@ +/* + * 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, { useContext } from 'react'; +import { EuiEmptyPrompt, EuiCode } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { EuiButton } from '../react_router_helpers'; +import { KibanaContext, IKibanaContext } from '../../index'; + +export const ErrorStatePrompt: React.FC = () => { + const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext; + + return ( + + + + } + titleSize="l" + body={ + <> +

+ {enterpriseSearchUrl}, + }} + /> +

+
    +
  1. + config/kibana.yml, + }} + /> +
  2. +
  3. + +
  4. +
  5. + [enterpriseSearch][plugins], + }} + /> +
  6. +
+ + } + actions={ + + + + } + /> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/error_state/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/error_state/index.ts new file mode 100644 index 0000000000000..1012fdf4126a2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/error_state/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ErrorStatePrompt } from './error_state_prompt'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts index 7ea73577c4de6..70aa723d62601 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts @@ -5,7 +5,7 @@ */ import { generateBreadcrumb } from './generate_breadcrumbs'; -import { appSearchBreadcrumbs, enterpriseSearchBreadcrumbs } from './'; +import { appSearchBreadcrumbs, enterpriseSearchBreadcrumbs, workplaceSearchBreadcrumbs } from './'; import { mockHistory as mockHistoryUntyped } from '../../__mocks__'; const mockHistory = mockHistoryUntyped as any; @@ -204,3 +204,86 @@ describe('appSearchBreadcrumbs', () => { }); }); }); + +describe('workplaceSearchBreadcrumbs', () => { + const breadCrumbs = [ + { + text: 'Page 1', + path: '/page1', + }, + { + text: 'Page 2', + path: '/page2', + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + mockHistory.createHref.mockImplementation( + ({ pathname }: any) => `/enterprise_search/workplace_search${pathname}` + ); + }); + + const subject = () => workplaceSearchBreadcrumbs(mockHistory)(breadCrumbs); + + it('Builds a chain of breadcrumbs with Enterprise Search and Workplace Search at the root', () => { + expect(subject()).toEqual([ + { + text: 'Enterprise Search', + }, + { + href: '/enterprise_search/workplace_search/', + onClick: expect.any(Function), + text: 'Workplace Search', + }, + { + href: '/enterprise_search/workplace_search/page1', + onClick: expect.any(Function), + text: 'Page 1', + }, + { + href: '/enterprise_search/workplace_search/page2', + onClick: expect.any(Function), + text: 'Page 2', + }, + ]); + }); + + it('shows just the root if breadcrumbs is empty', () => { + expect(workplaceSearchBreadcrumbs(mockHistory)()).toEqual([ + { + text: 'Enterprise Search', + }, + { + href: '/enterprise_search/workplace_search/', + onClick: expect.any(Function), + text: 'Workplace Search', + }, + ]); + }); + + describe('links', () => { + const eventMock = { + preventDefault: jest.fn(), + } as any; + + it('has Enterprise Search text first', () => { + expect(subject()[0].onClick).toBeUndefined(); + }); + + it('has a link to Workplace Search second', () => { + (subject()[1] as any).onClick(eventMock); + expect(mockHistory.push).toHaveBeenCalledWith('/'); + }); + + it('has a link to page 1 third', () => { + (subject()[2] as any).onClick(eventMock); + expect(mockHistory.push).toHaveBeenCalledWith('/page1'); + }); + + it('has a link to page 2 last', () => { + (subject()[3] as any).onClick(eventMock); + expect(mockHistory.push).toHaveBeenCalledWith('/page2'); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts index 8f72875a32bd4..b57fdfdbb75ca 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts @@ -52,3 +52,6 @@ export const enterpriseSearchBreadcrumbs = (history: History) => ( export const appSearchBreadcrumbs = (history: History) => (breadcrumbs: TBreadcrumbs = []) => enterpriseSearchBreadcrumbs(history)([{ text: 'App Search', path: '/' }, ...breadcrumbs]); + +export const workplaceSearchBreadcrumbs = (history: History) => (breadcrumbs: TBreadcrumbs = []) => + enterpriseSearchBreadcrumbs(history)([{ text: 'Workplace Search', path: '/' }, ...breadcrumbs]); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts index cf8bbbc593f2f..c4ef68704b7e0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts @@ -4,6 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -export { enterpriseSearchBreadcrumbs } from './generate_breadcrumbs'; -export { appSearchBreadcrumbs } from './generate_breadcrumbs'; -export { SetAppSearchBreadcrumbs } from './set_breadcrumbs'; +export { + enterpriseSearchBreadcrumbs, + appSearchBreadcrumbs, + workplaceSearchBreadcrumbs, +} from './generate_breadcrumbs'; +export { SetAppSearchBreadcrumbs, SetWorkplaceSearchBreadcrumbs } from './set_breadcrumbs'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.tsx index 530117e197616..e54f1a12b73cb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.tsx @@ -8,7 +8,11 @@ import React, { useContext, useEffect } from 'react'; import { useHistory } from 'react-router-dom'; import { EuiBreadcrumb } from '@elastic/eui'; import { KibanaContext, IKibanaContext } from '../../index'; -import { appSearchBreadcrumbs, TBreadcrumbs } from './generate_breadcrumbs'; +import { + appSearchBreadcrumbs, + workplaceSearchBreadcrumbs, + TBreadcrumbs, +} from './generate_breadcrumbs'; /** * Small on-mount helper for setting Kibana's chrome breadcrumbs on any App Search view @@ -17,19 +21,17 @@ import { appSearchBreadcrumbs, TBreadcrumbs } from './generate_breadcrumbs'; export type TSetBreadcrumbs = (breadcrumbs: EuiBreadcrumb[]) => void; -interface IBreadcrumbProps { +interface IBreadcrumbsProps { text: string; isRoot?: never; } -interface IRootBreadcrumbProps { +interface IRootBreadcrumbsProps { isRoot: true; text?: never; } +type TBreadcrumbsProps = IBreadcrumbsProps | IRootBreadcrumbsProps; -export const SetAppSearchBreadcrumbs: React.FC = ({ - text, - isRoot, -}) => { +export const SetAppSearchBreadcrumbs: React.FC = ({ text, isRoot }) => { const history = useHistory(); const { setBreadcrumbs } = useContext(KibanaContext) as IKibanaContext; @@ -41,3 +43,16 @@ export const SetAppSearchBreadcrumbs: React.FC = ({ text, isRoot }) => { + const history = useHistory(); + const { setBreadcrumbs } = useContext(KibanaContext) as IKibanaContext; + + const crumb = isRoot ? [] : [{ text, path: history.location.pathname }]; + + useEffect(() => { + setBreadcrumbs(workplaceSearchBreadcrumbs(history)(crumb as TBreadcrumbs | [])); + }, []); + + return null; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts index f871f48b17154..eadf7fa805590 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts @@ -6,3 +6,4 @@ export { sendTelemetry } from './send_telemetry'; export { SendAppSearchTelemetry } from './send_telemetry'; +export { SendWorkplaceSearchTelemetry } from './send_telemetry'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx index 9825c0d8ab889..3c873dbc25e37 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx @@ -7,8 +7,10 @@ import React from 'react'; import { httpServiceMock } from 'src/core/public/mocks'; +import { JSON_HEADER as headers } from '../../../../common/constants'; import { mountWithKibanaContext } from '../../__mocks__'; -import { sendTelemetry, SendAppSearchTelemetry } from './'; + +import { sendTelemetry, SendAppSearchTelemetry, SendWorkplaceSearchTelemetry } from './'; describe('Shared Telemetry Helpers', () => { const httpMock = httpServiceMock.createSetupContract(); @@ -27,8 +29,8 @@ describe('Shared Telemetry Helpers', () => { }); expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/telemetry', { - headers: { 'Content-Type': 'application/json' }, - body: '{"action":"viewed","metric":"setup_guide"}', + headers, + body: '{"product":"enterprise_search","action":"viewed","metric":"setup_guide"}', }); }); @@ -47,9 +49,20 @@ describe('Shared Telemetry Helpers', () => { http: httpMock, }); - expect(httpMock.put).toHaveBeenCalledWith('/api/app_search/telemetry', { - headers: { 'Content-Type': 'application/json' }, - body: '{"action":"clicked","metric":"button"}', + expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/telemetry', { + headers, + body: '{"product":"app_search","action":"clicked","metric":"button"}', + }); + }); + + it('SendWorkplaceSearchTelemetry component', () => { + mountWithKibanaContext(, { + http: httpMock, + }); + + expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/telemetry', { + headers, + body: '{"product":"workplace_search","action":"viewed","metric":"page"}', }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx index 300cb18272717..715d61b31512c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx @@ -7,6 +7,7 @@ import React, { useContext, useEffect } from 'react'; import { HttpSetup } from 'src/core/public'; +import { JSON_HEADER as headers } from '../../../../common/constants'; import { KibanaContext, IKibanaContext } from '../../index'; interface ISendTelemetryProps { @@ -25,10 +26,8 @@ interface ISendTelemetry extends ISendTelemetryProps { export const sendTelemetry = async ({ http, product, action, metric }: ISendTelemetry) => { try { - await http.put(`/api/${product}/telemetry`, { - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action, metric }), - }); + const body = JSON.stringify({ product, action, metric }); + await http.put('/api/enterprise_search/telemetry', { headers, body }); } catch (error) { throw new Error('Unable to send telemetry'); } @@ -36,7 +35,7 @@ export const sendTelemetry = async ({ http, product, action, metric }: ISendTele /** * React component helpers - useful for on-page-load/views - * TODO: SendWorkplaceSearchTelemetry and SendEnterpriseSearchTelemetry + * TODO: SendEnterpriseSearchTelemetry */ export const SendAppSearchTelemetry: React.FC = ({ action, metric }) => { @@ -48,3 +47,13 @@ export const SendAppSearchTelemetry: React.FC = ({ action, return null; }; + +export const SendWorkplaceSearchTelemetry: React.FC = ({ action, metric }) => { + const { http } = useContext(KibanaContext) as IKibanaContext; + + useEffect(() => { + sendTelemetry({ http, action, metric, product: 'workplace_search' }); + }, [action, metric, http]); + + return null; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/types.ts b/x-pack/plugins/enterprise_search/public/applications/shared/types.ts new file mode 100644 index 0000000000000..3f28710d92295 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/types.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface IFlashMessagesProps { + info?: string[]; + warning?: string[]; + error?: string[]; + success?: string[]; + isWrapped?: boolean; + children?: React.ReactNode; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/getting_started.png b/x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/getting_started.png new file mode 100644 index 0000000000000000000000000000000000000000..b6267b6e2c48e614a6376dc8d64ff8279d836670 GIT binary patch literal 487510 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�D8b` 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 z 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�-%YE+mZ!Q%COqMZMI10o8dQY0FhZx4(qg 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 uhq#GFh>9hF + + + + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.test.tsx new file mode 100644 index 0000000000000..ab5cd7f0de90f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.test.tsx @@ -0,0 +1,21 @@ +/* + * 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 '../../../__mocks__/shallow_usecontext.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { ErrorStatePrompt } from '../../../shared/error_state'; +import { ErrorState } from './'; + +describe('ErrorState', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(ErrorStatePrompt)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.tsx new file mode 100644 index 0000000000000..9fa508d599425 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiPage, EuiPageBody, EuiPageContent } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { ErrorStatePrompt } from '../../../shared/error_state'; +import { SetWorkplaceSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; +import { ViewContentHeader } from '../shared/view_content_header'; + +export const ErrorState: React.FC = () => { + return ( + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/index.ts new file mode 100644 index 0000000000000..b4d58bab58ff1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ErrorState } from './error_state'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/index.ts new file mode 100644 index 0000000000000..9ee1b444ee817 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { Overview } from './overview'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.test.tsx new file mode 100644 index 0000000000000..1d7c565935e97 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.test.tsx @@ -0,0 +1,54 @@ +/* + * 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 '../../../__mocks__/shallow_usecontext.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { EuiEmptyPrompt, EuiButton, EuiButtonEmpty } from '@elastic/eui'; + +import { OnboardingCard } from './onboarding_card'; + +jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn() })); +import { sendTelemetry } from '../../../shared/telemetry'; + +const cardProps = { + title: 'My card', + icon: 'icon', + description: 'this is a card', + actionTitle: 'action', + testSubj: 'actionButton', +}; + +describe('OnboardingCard', () => { + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + }); + + it('renders an action button', () => { + const wrapper = shallow(); + const prompt = wrapper.find(EuiEmptyPrompt).dive(); + + expect(prompt.find(EuiButton)).toHaveLength(1); + expect(prompt.find(EuiButtonEmpty)).toHaveLength(0); + + const button = prompt.find('[data-test-subj="actionButton"]'); + expect(button.prop('href')).toBe('http://localhost:3002/ws/some_path'); + + button.simulate('click'); + expect(sendTelemetry).toHaveBeenCalled(); + }); + + it('renders an empty button when onboarding is completed', () => { + const wrapper = shallow(); + const prompt = wrapper.find(EuiEmptyPrompt).dive(); + + expect(prompt.find(EuiButton)).toHaveLength(0); + expect(prompt.find(EuiButtonEmpty)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.tsx new file mode 100644 index 0000000000000..288c0be84fa9a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.tsx @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext } from 'react'; + +import { + EuiButton, + EuiButtonEmpty, + EuiFlexItem, + EuiPanel, + EuiEmptyPrompt, + IconType, + EuiButtonProps, + EuiButtonEmptyProps, + EuiLinkProps, +} from '@elastic/eui'; +import { useRoutes } from '../shared/use_routes'; +import { sendTelemetry } from '../../../shared/telemetry'; +import { KibanaContext, IKibanaContext } from '../../../index'; + +interface IOnboardingCardProps { + title: React.ReactNode; + icon: React.ReactNode; + description: React.ReactNode; + actionTitle: React.ReactNode; + testSubj: string; + actionPath?: string; + complete?: boolean; +} + +export const OnboardingCard: React.FC = ({ + title, + icon, + description, + actionTitle, + testSubj, + actionPath, + complete, +}) => { + const { http } = useContext(KibanaContext) as IKibanaContext; + const { getWSRoute } = useRoutes(); + + const onClick = () => + sendTelemetry({ + http, + product: 'workplace_search', + action: 'clicked', + metric: 'onboarding_card_button', + }); + const buttonActionProps = actionPath + ? { + onClick, + href: getWSRoute(actionPath), + target: '_blank', + 'data-test-subj': testSubj, + } + : { + 'data-test-subj': testSubj, + }; + + const emptyButtonProps = { + ...buttonActionProps, + } as EuiButtonEmptyProps & EuiLinkProps; + const fillButtonProps = { + ...buttonActionProps, + color: 'secondary', + fill: true, + } as EuiButtonProps & EuiLinkProps; + + return ( + + + {title}} + body={description} + actions={ + complete ? ( + {actionTitle} + ) : ( + {actionTitle} + ) + } + /> + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.test.tsx new file mode 100644 index 0000000000000..6174dc1c795eb --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.test.tsx @@ -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 '../../../__mocks__/shallow_usecontext.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { ORG_SOURCES_PATH, USERS_PATH } from '../../routes'; + +jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn() })); +import { sendTelemetry } from '../../../shared/telemetry'; + +import { OnboardingSteps, OrgNameOnboarding } from './onboarding_steps'; +import { OnboardingCard } from './onboarding_card'; +import { defaultServerData } from './overview'; + +const account = { + id: '1', + isAdmin: true, + canCreatePersonalSources: true, + groups: [], + supportEligible: true, + isCurated: false, +}; + +describe('OnboardingSteps', () => { + describe('Shared Sources', () => { + it('renders 0 sources state', () => { + const wrapper = shallow(); + + expect(wrapper.find(OnboardingCard)).toHaveLength(1); + expect(wrapper.find(OnboardingCard).prop('actionPath')).toBe(ORG_SOURCES_PATH); + expect(wrapper.find(OnboardingCard).prop('description')).toBe( + 'Add shared sources for your organization to start searching.' + ); + }); + + it('renders completed sources state', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(OnboardingCard).prop('description')).toEqual( + 'You have added 2 shared sources. Happy searching.' + ); + }); + + it('disables link when the user cannot create sources', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(OnboardingCard).prop('actionPath')).toBe(undefined); + }); + }); + + describe('Users & Invitations', () => { + it('renders 0 users when not on federated auth', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(OnboardingCard)).toHaveLength(2); + expect(wrapper.find(OnboardingCard).last().prop('actionPath')).toBe(USERS_PATH); + expect(wrapper.find(OnboardingCard).last().prop('description')).toEqual( + 'Invite your colleagues into this organization to search with you.' + ); + }); + + it('renders completed users state', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(OnboardingCard).last().prop('description')).toEqual( + 'Nice, you’ve invited colleagues to search with you.' + ); + }); + + it('disables link when the user cannot create invitations', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(OnboardingCard).last().prop('actionPath')).toBe(undefined); + }); + }); + + describe('Org Name', () => { + it('renders button to change name', () => { + const wrapper = shallow(); + + const button = wrapper + .find(OrgNameOnboarding) + .dive() + .find('[data-test-subj="orgNameChangeButton"]'); + + button.simulate('click'); + expect(sendTelemetry).toHaveBeenCalled(); + }); + + it('hides card when name has been changed', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(OrgNameOnboarding)).toHaveLength(0); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.tsx new file mode 100644 index 0000000000000..1b00347437338 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.tsx @@ -0,0 +1,179 @@ +/* + * 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, { useContext } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { + EuiSpacer, + EuiButtonEmpty, + EuiTitle, + EuiPanel, + EuiIcon, + EuiFlexGrid, + EuiFlexItem, + EuiFlexGroup, + EuiButtonEmptyProps, + EuiLinkProps, +} from '@elastic/eui'; +import sharedSourcesIcon from '../shared/assets/share_circle.svg'; +import { useRoutes } from '../shared/use_routes'; +import { sendTelemetry } from '../../../shared/telemetry'; +import { KibanaContext, IKibanaContext } from '../../../index'; +import { ORG_SOURCES_PATH, USERS_PATH, ORG_SETTINGS_PATH } from '../../routes'; + +import { ContentSection } from '../shared/content_section'; + +import { IAppServerData } from './overview'; + +import { OnboardingCard } from './onboarding_card'; + +const SOURCES_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingSourcesCard.title', + { defaultMessage: 'Shared sources' } +); + +const USERS_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingUsersCard.title', + { defaultMessage: 'Users & invitations' } +); + +const ONBOARDING_SOURCES_CARD_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingSourcesCard.description', + { defaultMessage: 'Add shared sources for your organization to start searching.' } +); + +const USERS_CARD_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewUsersCard.title', + { defaultMessage: 'Nice, you’ve invited colleagues to search with you.' } +); + +const ONBOARDING_USERS_CARD_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingUsersCard.description', + { defaultMessage: 'Invite your colleagues into this organization to search with you.' } +); + +export const OnboardingSteps: React.FC = ({ + hasUsers, + hasOrgSources, + canCreateContentSources, + canCreateInvitations, + accountsCount, + sourcesCount, + fpAccount: { isCurated }, + organization: { name, defaultOrgName }, + isFederatedAuth, +}) => { + const accountsPath = + !isFederatedAuth && (canCreateInvitations || isCurated) ? USERS_PATH : undefined; + const sourcesPath = canCreateContentSources || isCurated ? ORG_SOURCES_PATH : undefined; + + const SOURCES_CARD_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sourcesOnboardingCard.description', + { + defaultMessage: + 'You have added {sourcesCount, number} shared {sourcesCount, plural, one {source} other {sources}}. Happy searching.', + values: { sourcesCount }, + } + ); + + return ( + + + 0 ? 'more' : '' }, + } + )} + actionPath={sourcesPath} + complete={hasOrgSources} + /> + {!isFederatedAuth && ( + 0 ? 'more' : '' }, + } + )} + actionPath={accountsPath} + complete={hasUsers} + /> + )} + + {name === defaultOrgName && ( + <> + + + + )} + + ); +}; + +export const OrgNameOnboarding: React.FC = () => { + const { http } = useContext(KibanaContext) as IKibanaContext; + const { getWSRoute } = useRoutes(); + + const onClick = () => + sendTelemetry({ + http, + product: 'workplace_search', + action: 'clicked', + metric: 'org_name_change_button', + }); + + const buttonProps = { + onClick, + target: '_blank', + color: 'primary', + href: getWSRoute(ORG_SETTINGS_PATH), + 'data-test-subj': 'orgNameChangeButton', + } as EuiButtonEmptyProps & EuiLinkProps; + + return ( + + + + + + + +

+ +

+ +

t$XkYF+E-U6Mt>KtFJQYTh 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

+ + + + + +
+ + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.test.tsx new file mode 100644 index 0000000000000..112e9a910667a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.test.tsx @@ -0,0 +1,31 @@ +/* + * 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 '../../../__mocks__/shallow_usecontext.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiFlexGrid } from '@elastic/eui'; + +import { OrganizationStats } from './organization_stats'; +import { StatisticCard } from './statistic_card'; +import { defaultServerData } from './overview'; + +describe('OrganizationStats', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(StatisticCard)).toHaveLength(2); + expect(wrapper.find(EuiFlexGrid).prop('columns')).toEqual(2); + }); + + it('renders additional cards for federated auth', () => { + const wrapper = shallow(); + + expect(wrapper.find(StatisticCard)).toHaveLength(4); + expect(wrapper.find(EuiFlexGrid).prop('columns')).toEqual(4); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.tsx new file mode 100644 index 0000000000000..aa9be81f32bae --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiFlexGrid } from '@elastic/eui'; + +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import { ContentSection } from '../shared/content_section'; +import { ORG_SOURCES_PATH, USERS_PATH } from '../../routes'; + +import { IAppServerData } from './overview'; + +import { StatisticCard } from './statistic_card'; + +export const OrganizationStats: React.FC = ({ + sourcesCount, + pendingInvitationsCount, + accountsCount, + personalSourcesCount, + isFederatedAuth, +}) => ( + + } + headerSpacer="m" + > + + + {!isFederatedAuth && ( + <> + + + + )} + + + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.test.tsx new file mode 100644 index 0000000000000..e5e5235c52368 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.test.tsx @@ -0,0 +1,77 @@ +/* + * 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 '../../../__mocks__/react_router_history.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { mountWithAsyncContext, mockKibanaContext } from '../../../__mocks__'; + +import { ErrorState } from '../error_state'; +import { Loading } from '../shared/loading'; +import { ViewContentHeader } from '../shared/view_content_header'; + +import { OnboardingSteps } from './onboarding_steps'; +import { OrganizationStats } from './organization_stats'; +import { RecentActivity } from './recent_activity'; +import { Overview, defaultServerData } from './overview'; + +describe('Overview', () => { + const mockHttp = mockKibanaContext.http; + + describe('non-happy-path states', () => { + it('isLoading', () => { + const wrapper = shallow(); + + expect(wrapper.find(Loading)).toHaveLength(1); + }); + + it('hasErrorConnecting', async () => { + const wrapper = await mountWithAsyncContext(, { + http: { + ...mockHttp, + get: () => Promise.reject({ invalidPayload: true }), + }, + }); + + expect(wrapper.find(ErrorState)).toHaveLength(1); + }); + }); + + describe('happy-path states', () => { + it('renders onboarding state', async () => { + const mockApi = jest.fn(() => defaultServerData); + const wrapper = await mountWithAsyncContext(, { + http: { ...mockHttp, get: mockApi }, + }); + + expect(wrapper.find(ViewContentHeader)).toHaveLength(1); + expect(wrapper.find(OnboardingSteps)).toHaveLength(1); + expect(wrapper.find(OrganizationStats)).toHaveLength(1); + expect(wrapper.find(RecentActivity)).toHaveLength(1); + }); + + it('renders when onboarding complete', async () => { + const obCompleteData = { + ...defaultServerData, + hasUsers: true, + hasOrgSources: true, + isOldAccount: true, + organization: { + name: 'foo', + defaultOrgName: 'bar', + }, + }; + const mockApi = jest.fn(() => obCompleteData); + const wrapper = await mountWithAsyncContext(, { + http: { ...mockHttp, get: mockApi }, + }); + + expect(wrapper.find(OnboardingSteps)).toHaveLength(0); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.tsx new file mode 100644 index 0000000000000..bacd65a2be75f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.tsx @@ -0,0 +1,151 @@ +/* + * 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, { useContext, useEffect, useState } from 'react'; +import { EuiPage, EuiPageBody, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { SetWorkplaceSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; +import { KibanaContext, IKibanaContext } from '../../../index'; + +import { IAccount } from '../../types'; + +import { ErrorState } from '../error_state'; + +import { Loading } from '../shared/loading'; +import { ProductButton } from '../shared/product_button'; +import { ViewContentHeader } from '../shared/view_content_header'; + +import { OnboardingSteps } from './onboarding_steps'; +import { OrganizationStats } from './organization_stats'; +import { RecentActivity, IFeedActivity } from './recent_activity'; + +export interface IAppServerData { + hasUsers: boolean; + hasOrgSources: boolean; + canCreateContentSources: boolean; + canCreateInvitations: boolean; + isOldAccount: boolean; + sourcesCount: number; + pendingInvitationsCount: number; + accountsCount: number; + personalSourcesCount: number; + activityFeed: IFeedActivity[]; + organization: { + name: string; + defaultOrgName: string; + }; + isFederatedAuth: boolean; + currentUser: { + firstName: string; + email: string; + name: string; + color: string; + }; + fpAccount: IAccount; +} + +export const defaultServerData = { + accountsCount: 1, + activityFeed: [], + canCreateContentSources: true, + canCreateInvitations: true, + currentUser: { + firstName: '', + email: '', + name: '', + color: '', + }, + fpAccount: {} as IAccount, + hasOrgSources: false, + hasUsers: false, + isFederatedAuth: true, + isOldAccount: false, + organization: { + name: '', + defaultOrgName: '', + }, + pendingInvitationsCount: 0, + personalSourcesCount: 0, + sourcesCount: 0, +} as IAppServerData; + +const ONBOARDING_HEADER_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingHeader.title', + { defaultMessage: 'Get started with Workplace Search' } +); + +const HEADER_TITLE = i18n.translate('xpack.enterpriseSearch.workplaceSearch.overviewHeader.title', { + defaultMessage: 'Organization overview', +}); + +const ONBOARDING_HEADER_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingHeader.description', + { defaultMessage: 'Complete the following to set up your organization.' } +); + +const HEADER_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewHeader.description', + { defaultMessage: "Your organizations's statistics and activity" } +); + +export const Overview: React.FC = () => { + const { http } = useContext(KibanaContext) as IKibanaContext; + + const [isLoading, setIsLoading] = useState(true); + const [hasErrorConnecting, setHasErrorConnecting] = useState(false); + const [appData, setAppData] = useState(defaultServerData); + + const getAppData = async () => { + try { + const response = await http.get('/api/workplace_search/overview'); + setAppData(response); + } catch (error) { + setHasErrorConnecting(true); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + getAppData(); + }, []); + + if (hasErrorConnecting) return ; + if (isLoading) return ; + + const { + hasUsers, + hasOrgSources, + isOldAccount, + organization: { name: orgName, defaultOrgName }, + } = appData as IAppServerData; + const hideOnboarding = hasUsers && hasOrgSources && isOldAccount && orgName !== defaultOrgName; + + const headerTitle = hideOnboarding ? HEADER_TITLE : ONBOARDING_HEADER_TITLE; + const headerDescription = hideOnboarding ? HEADER_DESCRIPTION : ONBOARDING_HEADER_DESCRIPTION; + + return ( + + + + + + } + /> + {!hideOnboarding && } + + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.scss b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.scss new file mode 100644 index 0000000000000..2d1e474c03faa --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.scss @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +.activity { + display: flex; + justify-content: space-between; + padding: $euiSizeM; + font-size: $euiFontSizeS; + + &--error { + font-weight: $euiFontWeightSemiBold; + color: $euiColorDanger; + background: rgba($euiColorDanger, 0.1); + + &__label { + margin-left: $euiSizeS * 1.75; + font-weight: $euiFontWeightRegular; + text-decoration: underline; + opacity: 0.7; + } + } + + &__message { + flex-grow: 1; + } + + &__date { + flex-grow: 0; + } + + & + & { + border-top: $euiBorderThin; + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.test.tsx new file mode 100644 index 0000000000000..e9bdedb199dad --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.test.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../__mocks__/shallow_usecontext.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { EuiEmptyPrompt, EuiLink } from '@elastic/eui'; + +import { RecentActivity, RecentActivityItem } from './recent_activity'; +import { defaultServerData } from './overview'; + +jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn() })); +import { sendTelemetry } from '../../../shared/telemetry'; + +const org = { name: 'foo', defaultOrgName: 'bar' }; + +const feed = [ + { + id: 'demo', + sourceId: 'd2d2d23d', + message: 'was successfully connected', + target: 'http://localhost:3002/ws/org/sources', + timestamp: '2020-06-24 16:34:16', + }, +]; + +describe('RecentActivity', () => { + it('renders with no feed data', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + + // Branch coverage - renders without error for custom org name + shallow(); + }); + + it('renders an activity feed with links', () => { + const wrapper = shallow(); + const activity = wrapper.find(RecentActivityItem).dive(); + + expect(activity).toHaveLength(1); + + const link = activity.find('[data-test-subj="viewSourceDetailsLink"]'); + link.simulate('click'); + expect(sendTelemetry).toHaveBeenCalled(); + }); + + it('renders activity item error state', () => { + const props = { ...feed[0], status: 'error' }; + const wrapper = shallow(); + + expect(wrapper.find('.activity--error')).toHaveLength(1); + expect(wrapper.find('.activity--error__label')).toHaveLength(1); + expect(wrapper.find(EuiLink).prop('color')).toEqual('danger'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.tsx new file mode 100644 index 0000000000000..8d69582c93684 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.tsx @@ -0,0 +1,131 @@ +/* + * 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, { useContext } from 'react'; + +import moment from 'moment'; + +import { EuiEmptyPrompt, EuiLink, EuiPanel, EuiSpacer, EuiLinkProps } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { ContentSection } from '../shared/content_section'; +import { useRoutes } from '../shared/use_routes'; +import { sendTelemetry } from '../../../shared/telemetry'; +import { KibanaContext, IKibanaContext } from '../../../index'; +import { getSourcePath } from '../../routes'; + +import { IAppServerData } from './overview'; + +import './recent_activity.scss'; + +export interface IFeedActivity { + status?: string; + id: string; + message: string; + timestamp: string; + sourceId: string; +} + +export const RecentActivity: React.FC = ({ + organization: { name, defaultOrgName }, + activityFeed, +}) => { + return ( + + } + headerSpacer="m" + > + + {activityFeed.length > 0 ? ( + <> + {activityFeed.map((props: IFeedActivity, index) => ( + + ))} + + ) : ( + <> + + + {name === defaultOrgName ? ( + + ) : ( + + )} + + } + /> + + + )} + + + ); +}; + +export const RecentActivityItem: React.FC = ({ + id, + status, + message, + timestamp, + sourceId, +}) => { + const { http } = useContext(KibanaContext) as IKibanaContext; + const { getWSRoute } = useRoutes(); + + const onClick = () => + sendTelemetry({ + http, + product: 'workplace_search', + action: 'clicked', + metric: 'recent_activity_source_details_link', + }); + + const linkProps = { + onClick, + target: '_blank', + href: getWSRoute(getSourcePath(sourceId)), + external: true, + color: status === 'error' ? 'danger' : 'primary', + 'data-test-subj': 'viewSourceDetailsLink', + } as EuiLinkProps; + + return ( +
+
+ + {id} {message} + {status === 'error' && ( + + {' '} + + + )} + +
+
{moment.utc(timestamp).fromNow()}
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.test.tsx new file mode 100644 index 0000000000000..edf266231b39e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.test.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../__mocks__/shallow_usecontext.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { EuiCard } from '@elastic/eui'; + +import { StatisticCard } from './statistic_card'; + +const props = { + title: 'foo', +}; + +describe('StatisticCard', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiCard)).toHaveLength(1); + }); + + it('renders clickable card', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiCard).prop('href')).toBe('http://localhost:3002/ws/foo'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.tsx new file mode 100644 index 0000000000000..9bc8f4f768073 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { EuiCard, EuiFlexItem, EuiTitle, EuiTextColor } from '@elastic/eui'; + +import { useRoutes } from '../shared/use_routes'; + +interface IStatisticCardProps { + title: string; + count?: number; + actionPath?: string; +} + +export const StatisticCard: React.FC = ({ title, count = 0, actionPath }) => { + const { getWSRoute } = useRoutes(); + + const linkProps = actionPath + ? { + href: getWSRoute(actionPath), + target: '_blank', + rel: 'noopener', + } + : {}; + // TODO: When we port this destination to Kibana, we'll want to create a EuiReactRouterCard component (see shared/react_router_helpers/eui_link.tsx) + + return ( + + + {count} + + } + /> + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/index.ts new file mode 100644 index 0000000000000..c367424d375f9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { SetupGuide } from './setup_guide'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.test.tsx new file mode 100644 index 0000000000000..b87c35d5a5942 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.test.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { SetWorkplaceSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { SetupGuide as SetupGuideLayout } from '../../../shared/setup_guide'; +import { SetupGuide } from './'; + +describe('SetupGuide', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(SetupGuideLayout)).toHaveLength(1); + expect(wrapper.find(SetBreadcrumbs)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.tsx new file mode 100644 index 0000000000000..5b5d067d23eb8 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiSpacer, EuiTitle, EuiText, EuiButton } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import { SetupGuide as SetupGuideLayout } from '../../../shared/setup_guide'; + +import { SetWorkplaceSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; +import GettingStarted from '../../assets/getting_started.png'; + +const GETTING_STARTED_LINK_URL = + 'https://www.elastic.co/guide/en/workplace-search/current/workplace-search-getting-started.html'; + +export const SetupGuide: React.FC = () => { + return ( + + + + +
+ {i18n.translate('xpack.enterpriseSearch.workplaceSearch.setupGuide.imageAlt', + + + +

+ +

+
+ + + Get started with Workplace Search + + + +

+ +

+
+ + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/share_circle.svg b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/share_circle.svg new file mode 100644 index 0000000000000..f8d2ea1e634f6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/share_circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.test.tsx new file mode 100644 index 0000000000000..f406fb136f13f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.test.tsx @@ -0,0 +1,50 @@ +/* + * 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 '../../../../__mocks__/shallow_usecontext.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiTitle, EuiSpacer } from '@elastic/eui'; + +import { ContentSection } from './'; + +const props = { + children:
, + testSubj: 'contentSection', + className: 'test', +}; + +describe('ContentSection', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.prop('data-test-subj')).toEqual('contentSection'); + expect(wrapper.prop('className')).toEqual('test'); + expect(wrapper.find('.children')).toHaveLength(1); + }); + + it('displays title and description', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiTitle)).toHaveLength(1); + expect(wrapper.find('p').text()).toEqual('bar'); + }); + + it('displays header content', () => { + const wrapper = shallow( + } + /> + ); + + expect(wrapper.find(EuiSpacer).prop('size')).toEqual('s'); + expect(wrapper.find('.header')).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.tsx new file mode 100644 index 0000000000000..b2a9eebc72e85 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { EuiSpacer, EuiTitle } from '@elastic/eui'; + +import { TSpacerSize } from '../../../types'; + +interface IContentSectionProps { + children: React.ReactNode; + className?: string; + title?: React.ReactNode; + description?: React.ReactNode; + headerChildren?: React.ReactNode; + headerSpacer?: TSpacerSize; + testSubj?: string; +} + +export const ContentSection: React.FC = ({ + children, + className = '', + title, + description, + headerChildren, + headerSpacer, + testSubj, +}) => ( +
+ {title && ( + <> + +

{title}

+
+ {description &&

{description}

} + {headerChildren} + {headerSpacer && } + + )} + {children} +
+); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/index.ts new file mode 100644 index 0000000000000..7dcb1b13ad1dc --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ContentSection } from './content_section'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/index.ts new file mode 100644 index 0000000000000..745639955dcba --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { Loading } from './loading'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.scss b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.scss new file mode 100644 index 0000000000000..008a8066f807b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.scss @@ -0,0 +1,14 @@ +.loadingSpinnerWrapper { + width: 100%; + height: 90vh; + margin: auto; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.loadingSpinner { + width: $euiSizeXXL * 1.25; + height: $euiSizeXXL * 1.25; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.test.tsx new file mode 100644 index 0000000000000..8d168b436cc3b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.test.tsx @@ -0,0 +1,21 @@ +/* + * 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 '../../../../__mocks__/shallow_usecontext.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiLoadingSpinner } from '@elastic/eui'; + +import { Loading } from './'; + +describe('Loading', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.tsx new file mode 100644 index 0000000000000..399abedf55e87 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.tsx @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { EuiLoadingSpinner } from '@elastic/eui'; + +import './loading.scss'; + +export const Loading: React.FC = () => ( +
+ +
+); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/index.ts new file mode 100644 index 0000000000000..c41e27bacb892 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ProductButton } from './product_button'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.test.tsx new file mode 100644 index 0000000000000..429a2c509813d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.test.tsx @@ -0,0 +1,38 @@ +/* + * 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 '../../../../__mocks__/shallow_usecontext.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiButton } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { ProductButton } from './'; + +jest.mock('../../../../shared/telemetry', () => ({ + sendTelemetry: jest.fn(), + SendAppSearchTelemetry: jest.fn(), +})); +import { sendTelemetry } from '../../../../shared/telemetry'; + +describe('ProductButton', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiButton)).toHaveLength(1); + expect(wrapper.find(FormattedMessage)).toHaveLength(1); + }); + + it('sends telemetry on create first engine click', () => { + const wrapper = shallow(); + const button = wrapper.find(EuiButton); + + button.simulate('click'); + expect(sendTelemetry).toHaveBeenCalled(); + (sendTelemetry as jest.Mock).mockClear(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.tsx new file mode 100644 index 0000000000000..5b86e14132e0f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.tsx @@ -0,0 +1,41 @@ +/* + * 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, { useContext } from 'react'; + +import { EuiButton, EuiButtonProps, EuiLinkProps } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { sendTelemetry } from '../../../../shared/telemetry'; +import { KibanaContext, IKibanaContext } from '../../../../index'; + +export const ProductButton: React.FC = () => { + const { enterpriseSearchUrl, http } = useContext(KibanaContext) as IKibanaContext; + + const buttonProps = { + fill: true, + iconType: 'popout', + 'data-test-subj': 'launchButton', + } as EuiButtonProps & EuiLinkProps; + buttonProps.href = `${enterpriseSearchUrl}/ws`; + buttonProps.target = '_blank'; + buttonProps.onClick = () => + sendTelemetry({ + http, + product: 'workplace_search', + action: 'clicked', + metric: 'header_launch_button', + }); + + return ( + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/index.ts new file mode 100644 index 0000000000000..cb9684408c459 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { useRoutes } from './use_routes'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/use_routes.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/use_routes.tsx new file mode 100644 index 0000000000000..48b8695f82b43 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/use_routes.tsx @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useContext } from 'react'; + +import { KibanaContext, IKibanaContext } from '../../../../index'; + +export const useRoutes = () => { + const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext; + const getWSRoute = (path: string): string => `${enterpriseSearchUrl}/ws${path}`; + return { getWSRoute }; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/index.ts new file mode 100644 index 0000000000000..774b3d85c8c85 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ViewContentHeader } from './view_content_header'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.test.tsx new file mode 100644 index 0000000000000..4680f15771caa --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.test.tsx @@ -0,0 +1,39 @@ +/* + * 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 '../../../../__mocks__/shallow_usecontext.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiFlexGroup } from '@elastic/eui'; + +import { ViewContentHeader } from './'; + +const props = { + title: 'Header', + alignItems: 'flexStart' as any, +}; + +describe('ViewContentHeader', () => { + it('renders with title and alignItems', () => { + const wrapper = shallow(); + + expect(wrapper.find('h2').text()).toEqual('Header'); + expect(wrapper.find(EuiFlexGroup).prop('alignItems')).toEqual('flexStart'); + }); + + it('shows description, when present', () => { + const wrapper = shallow(); + + expect(wrapper.find('p').text()).toEqual('Hello World'); + }); + + it('shows action, when present', () => { + const wrapper = shallow(} />); + + expect(wrapper.find('.action')).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.tsx new file mode 100644 index 0000000000000..0408517fd4ec5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiTitle, EuiSpacer } from '@elastic/eui'; + +import { FlexGroupAlignItems } from '@elastic/eui/src/components/flex/flex_group'; + +interface IViewContentHeaderProps { + title: React.ReactNode; + description?: React.ReactNode; + action?: React.ReactNode; + alignItems?: FlexGroupAlignItems; +} + +export const ViewContentHeader: React.FC = ({ + title, + description, + action, + alignItems = 'center', +}) => ( + <> + + + +

{title}

+
+ {description && ( + +

{description}

+
+ )} +
+ {action && {action}} +
+ + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx new file mode 100644 index 0000000000000..743080d965c36 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx @@ -0,0 +1,46 @@ +/* + * 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 '../__mocks__/shallow_usecontext.mock'; + +import React, { useContext } from 'react'; +import { Redirect } from 'react-router-dom'; +import { shallow } from 'enzyme'; + +import { SetupGuide } from './components/setup_guide'; +import { Overview } from './components/overview'; + +import { WorkplaceSearch } from './'; + +describe('Workplace Search Routes', () => { + describe('/', () => { + it('redirects to Setup Guide when enterpriseSearchUrl is not set', () => { + (useContext as jest.Mock).mockImplementationOnce(() => ({ enterpriseSearchUrl: '' })); + const wrapper = shallow(); + + expect(wrapper.find(Redirect)).toHaveLength(1); + expect(wrapper.find(Overview)).toHaveLength(0); + }); + + it('renders Engine Overview when enterpriseSearchUrl is set', () => { + (useContext as jest.Mock).mockImplementationOnce(() => ({ + enterpriseSearchUrl: 'https://foo.bar', + })); + const wrapper = shallow(); + + expect(wrapper.find(Overview)).toHaveLength(1); + expect(wrapper.find(Redirect)).toHaveLength(0); + }); + }); + + describe('/setup_guide', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(SetupGuide)).toHaveLength(1); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx new file mode 100644 index 0000000000000..36b1a56ecba26 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx @@ -0,0 +1,29 @@ +/* + * 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, { useContext } from 'react'; +import { Route, Redirect } from 'react-router-dom'; + +import { KibanaContext, IKibanaContext } from '../index'; + +import { SETUP_GUIDE_PATH } from './routes'; + +import { SetupGuide } from './components/setup_guide'; +import { Overview } from './components/overview'; + +export const WorkplaceSearch: React.FC = () => { + const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext; + return ( + <> + + {!enterpriseSearchUrl ? : } + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts new file mode 100644 index 0000000000000..d9798d1f30cfc --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const ORG_SOURCES_PATH = '/org/sources'; +export const USERS_PATH = '/org/users'; +export const ORG_SETTINGS_PATH = '/org/settings'; +export const SETUP_GUIDE_PATH = '/setup_guide'; + +export const getSourcePath = (sourceId: string): string => `${ORG_SOURCES_PATH}/${sourceId}`; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts new file mode 100644 index 0000000000000..b448c59c52f3e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface IAccount { + id: string; + isCurated?: boolean; + isAdmin: boolean; + canCreatePersonalSources: boolean; + groups: string[]; + supportEligible: boolean; +} + +export type TSpacerSize = 'xs' | 's' | 'm' | 'l' | 'xl' | 'xxl'; diff --git a/x-pack/plugins/enterprise_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts index fbfcc303de47a..fc95828a3f4a4 100644 --- a/x-pack/plugins/enterprise_search/public/plugin.ts +++ b/x-pack/plugins/enterprise_search/public/plugin.ts @@ -22,6 +22,7 @@ import { LicensingPluginSetup } from '../../licensing/public'; import { getPublicUrl } from './applications/shared/enterprise_search_url'; import AppSearchLogo from './applications/app_search/assets/logo.svg'; +import WorkplaceSearchLogo from './applications/workplace_search/assets/logo.svg'; export interface ClientConfigType { host?: string; @@ -58,7 +59,21 @@ export class EnterpriseSearchPlugin implements Plugin { return renderApp(AppSearch, coreStart, params, config, plugins); }, }); - // TODO: Workplace Search will need to register its own plugin. + + core.application.register({ + id: 'workplaceSearch', + title: 'Workplace Search', + appRoute: '/app/enterprise_search/workplace_search', + category: DEFAULT_APP_CATEGORIES.enterpriseSearch, + mount: async (params: AppMountParameters) => { + const [coreStart] = await core.getStartServices(); + + const { renderApp } = await import('./applications'); + const { WorkplaceSearch } = await import('./applications/workplace_search'); + + return renderApp(WorkplaceSearch, coreStart, params, config, plugins); + }, + }); plugins.home.featureCatalogue.register({ id: 'appSearch', @@ -70,7 +85,17 @@ export class EnterpriseSearchPlugin implements Plugin { category: FeatureCatalogueCategory.DATA, showOnHomePage: true, }); - // TODO: Workplace Search will need to register its own feature catalogue section/card. + + plugins.home.featureCatalogue.register({ + id: 'workplaceSearch', + title: 'Workplace Search', + icon: WorkplaceSearchLogo, + description: + 'Search all documents, files, and sources available across your virtual workplace.', + path: '/app/enterprise_search/workplace_search', + category: FeatureCatalogueCategory.DATA, + showOnHomePage: true, + }); } public start(core: CoreStart) {} diff --git a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts index e95056b871324..53c6dee61cd1d 100644 --- a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts +++ b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts @@ -4,20 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { loggingSystemMock } from 'src/core/server/mocks'; +import { mockLogger } from '../../routes/__mocks__'; -jest.mock('../../../../../../src/core/server', () => ({ - SavedObjectsErrorHelpers: { - isNotFoundError: jest.fn(), - }, -})); -import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server'; - -import { registerTelemetryUsageCollector, incrementUICounter } from './telemetry'; +import { registerTelemetryUsageCollector } from './telemetry'; describe('App Search Telemetry Usage Collector', () => { - const mockLogger = loggingSystemMock.create().get(); - const makeUsageCollectorStub = jest.fn(); const registerStub = jest.fn(); const usageCollectionMock = { @@ -103,41 +94,5 @@ describe('App Search Telemetry Usage Collector', () => { }, }); }); - - it('should not throw but log a warning if saved objects errors', async () => { - const errorSavedObjectsMock = { createInternalRepository: () => ({}) } as any; - registerTelemetryUsageCollector(usageCollectionMock, errorSavedObjectsMock, mockLogger); - - // Without log warning (not found) - (SavedObjectsErrorHelpers.isNotFoundError as jest.Mock).mockImplementationOnce(() => true); - await makeUsageCollectorStub.mock.calls[0][0].fetch(); - - expect(mockLogger.warn).not.toHaveBeenCalled(); - - // With log warning - (SavedObjectsErrorHelpers.isNotFoundError as jest.Mock).mockImplementationOnce(() => false); - await makeUsageCollectorStub.mock.calls[0][0].fetch(); - - expect(mockLogger.warn).toHaveBeenCalledWith( - 'Failed to retrieve App Search telemetry data: TypeError: savedObjectsRepository.get is not a function' - ); - }); - }); - - describe('incrementUICounter', () => { - it('should increment the saved objects internal repository', async () => { - const response = await incrementUICounter({ - savedObjects: savedObjectsMock, - uiAction: 'ui_clicked', - metric: 'button', - }); - - expect(savedObjectsRepoStub.incrementCounter).toHaveBeenCalledWith( - 'app_search_telemetry', - 'app_search_telemetry', - 'ui_clicked.button' - ); - expect(response).toEqual({ success: true }); - }); }); }); diff --git a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts index a10f96907ad28..f700088cb67a0 100644 --- a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts +++ b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts @@ -5,16 +5,10 @@ */ import { get } from 'lodash'; -import { - ISavedObjectsRepository, - SavedObjectsServiceStart, - SavedObjectAttributes, - Logger, -} from 'src/core/server'; +import { SavedObjectsServiceStart, Logger } from 'src/core/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -// This throws `Error: Cannot find module 'src/core/server'` if I import it via alias ¯\_(ツ)_/¯ -import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server'; +import { getSavedObjectAttributesFromRepo } from '../lib/telemetry'; interface ITelemetry { ui_viewed: { @@ -70,10 +64,11 @@ export const registerTelemetryUsageCollector = ( const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart, log: Logger) => { const savedObjectsRepository = savedObjects.createInternalRepository(); - const savedObjectAttributes = (await getSavedObjectAttributesFromRepo( + const savedObjectAttributes = await getSavedObjectAttributesFromRepo( + AS_TELEMETRY_NAME, savedObjectsRepository, log - )) as SavedObjectAttributes; + ); const defaultTelemetrySavedObject: ITelemetry = { ui_viewed: { @@ -114,43 +109,3 @@ const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart, log }, } as ITelemetry; }; - -/** - * Helper function - fetches saved objects attributes - */ - -const getSavedObjectAttributesFromRepo = async ( - savedObjectsRepository: ISavedObjectsRepository, - log: Logger -) => { - try { - return (await savedObjectsRepository.get(AS_TELEMETRY_NAME, AS_TELEMETRY_NAME)).attributes; - } catch (e) { - if (!SavedObjectsErrorHelpers.isNotFoundError(e)) { - log.warn(`Failed to retrieve App Search telemetry data: ${e}`); - } - return null; - } -}; - -/** - * Set saved objection attributes - used by telemetry route - */ - -interface IIncrementUICounter { - savedObjects: SavedObjectsServiceStart; - uiAction: string; - metric: string; -} - -export async function incrementUICounter({ savedObjects, uiAction, metric }: IIncrementUICounter) { - const internalRepository = savedObjects.createInternalRepository(); - - await internalRepository.incrementCounter( - AS_TELEMETRY_NAME, - AS_TELEMETRY_NAME, - `${uiAction}.${metric}` // e.g., ui_viewed.setup_guide - ); - - return { success: true }; -} diff --git a/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.test.ts new file mode 100644 index 0000000000000..3ab3b03dd7725 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.test.ts @@ -0,0 +1,69 @@ +/* + * 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 { mockLogger } from '../../routes/__mocks__'; + +jest.mock('../../../../../../src/core/server', () => ({ + SavedObjectsErrorHelpers: { + isNotFoundError: jest.fn(), + }, +})); +import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server'; + +import { getSavedObjectAttributesFromRepo, incrementUICounter } from './telemetry'; + +describe('App Search Telemetry Usage Collector', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getSavedObjectAttributesFromRepo', () => { + // Note: savedObjectsRepository.get() is best tested as a whole from + // individual fetchTelemetryMetrics tests. This mostly just tests error handling + it('should not throw but log a warning if saved objects errors', async () => { + const errorSavedObjectsMock = {} as any; + + // Without log warning (not found) + (SavedObjectsErrorHelpers.isNotFoundError as jest.Mock).mockImplementationOnce(() => true); + await getSavedObjectAttributesFromRepo('some_id', errorSavedObjectsMock, mockLogger); + + expect(mockLogger.warn).not.toHaveBeenCalled(); + + // With log warning + (SavedObjectsErrorHelpers.isNotFoundError as jest.Mock).mockImplementationOnce(() => false); + await getSavedObjectAttributesFromRepo('some_id', errorSavedObjectsMock, mockLogger); + + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Failed to retrieve some_id telemetry data: TypeError: savedObjectsRepository.get is not a function' + ); + }); + }); + + describe('incrementUICounter', () => { + const incrementCounterMock = jest.fn(); + const savedObjectsMock = { + createInternalRepository: jest.fn(() => ({ + incrementCounter: incrementCounterMock, + })), + } as any; + + it('should increment the saved objects internal repository', async () => { + const response = await incrementUICounter({ + id: 'app_search_telemetry', + savedObjects: savedObjectsMock, + uiAction: 'ui_clicked', + metric: 'button', + }); + + expect(incrementCounterMock).toHaveBeenCalledWith( + 'app_search_telemetry', + 'app_search_telemetry', + 'ui_clicked.button' + ); + expect(response).toEqual({ success: true }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.ts b/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.ts new file mode 100644 index 0000000000000..f5f4fa368555f --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + ISavedObjectsRepository, + SavedObjectsServiceStart, + SavedObjectAttributes, + Logger, +} from 'src/core/server'; + +// This throws `Error: Cannot find module 'src/core/server'` if I import it via alias ¯\_(ツ)_/¯ +import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server'; + +/** + * Fetches saved objects attributes - used by collectors + */ + +export const getSavedObjectAttributesFromRepo = async ( + id: string, // Telemetry name + savedObjectsRepository: ISavedObjectsRepository, + log: Logger +): Promise => { + try { + return (await savedObjectsRepository.get(id, id)).attributes as SavedObjectAttributes; + } catch (e) { + if (!SavedObjectsErrorHelpers.isNotFoundError(e)) { + log.warn(`Failed to retrieve ${id} telemetry data: ${e}`); + } + return null; + } +}; + +/** + * Set saved objection attributes - used by telemetry route + */ + +interface IIncrementUICounter { + id: string; // Telemetry name + savedObjects: SavedObjectsServiceStart; + uiAction: string; + metric: string; +} + +export async function incrementUICounter({ + id, + savedObjects, + uiAction, + metric, +}: IIncrementUICounter) { + const internalRepository = savedObjects.createInternalRepository(); + + await internalRepository.incrementCounter( + id, + id, + `${uiAction}.${metric}` // e.g., ui_viewed.setup_guide + ); + + return { success: true }; +} diff --git a/x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.test.ts new file mode 100644 index 0000000000000..496b2f254f9a6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.test.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mockLogger } from '../../routes/__mocks__'; + +import { registerTelemetryUsageCollector } from './telemetry'; + +describe('Workplace Search Telemetry Usage Collector', () => { + const makeUsageCollectorStub = jest.fn(); + const registerStub = jest.fn(); + const usageCollectionMock = { + makeUsageCollector: makeUsageCollectorStub, + registerCollector: registerStub, + } as any; + + const savedObjectsRepoStub = { + get: () => ({ + attributes: { + 'ui_viewed.setup_guide': 10, + 'ui_viewed.overview': 20, + 'ui_error.cannot_connect': 3, + 'ui_clicked.header_launch_button': 30, + 'ui_clicked.org_name_change_button': 40, + 'ui_clicked.onboarding_card_button': 50, + 'ui_clicked.recent_activity_source_details_link': 60, + }, + }), + incrementCounter: jest.fn(), + }; + const savedObjectsMock = { + createInternalRepository: jest.fn(() => savedObjectsRepoStub), + } as any; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('registerTelemetryUsageCollector', () => { + it('should make and register the usage collector', () => { + registerTelemetryUsageCollector(usageCollectionMock, savedObjectsMock, mockLogger); + + expect(registerStub).toHaveBeenCalledTimes(1); + expect(makeUsageCollectorStub).toHaveBeenCalledTimes(1); + expect(makeUsageCollectorStub.mock.calls[0][0].type).toBe('workplace_search'); + expect(makeUsageCollectorStub.mock.calls[0][0].isReady()).toBe(true); + }); + }); + + describe('fetchTelemetryMetrics', () => { + it('should return existing saved objects data', async () => { + registerTelemetryUsageCollector(usageCollectionMock, savedObjectsMock, mockLogger); + const savedObjectsCounts = await makeUsageCollectorStub.mock.calls[0][0].fetch(); + + expect(savedObjectsCounts).toEqual({ + ui_viewed: { + setup_guide: 10, + overview: 20, + }, + ui_error: { + cannot_connect: 3, + }, + ui_clicked: { + header_launch_button: 30, + org_name_change_button: 40, + onboarding_card_button: 50, + recent_activity_source_details_link: 60, + }, + }); + }); + + it('should return a default telemetry object if no saved data exists', async () => { + const emptySavedObjectsMock = { + createInternalRepository: () => ({ + get: () => ({ attributes: null }), + }), + } as any; + + registerTelemetryUsageCollector(usageCollectionMock, emptySavedObjectsMock, mockLogger); + const savedObjectsCounts = await makeUsageCollectorStub.mock.calls[0][0].fetch(); + + expect(savedObjectsCounts).toEqual({ + ui_viewed: { + setup_guide: 0, + overview: 0, + }, + ui_error: { + cannot_connect: 0, + }, + ui_clicked: { + header_launch_button: 0, + org_name_change_button: 0, + onboarding_card_button: 0, + recent_activity_source_details_link: 0, + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.ts new file mode 100644 index 0000000000000..892de5cfee35e --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.ts @@ -0,0 +1,115 @@ +/* + * 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 { get } from 'lodash'; +import { SavedObjectsServiceStart, Logger } from 'src/core/server'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; + +import { getSavedObjectAttributesFromRepo } from '../lib/telemetry'; + +interface ITelemetry { + ui_viewed: { + setup_guide: number; + overview: number; + }; + ui_error: { + cannot_connect: number; + }; + ui_clicked: { + header_launch_button: number; + org_name_change_button: number; + onboarding_card_button: number; + recent_activity_source_details_link: number; + }; +} + +export const WS_TELEMETRY_NAME = 'workplace_search_telemetry'; + +/** + * Register the telemetry collector + */ + +export const registerTelemetryUsageCollector = ( + usageCollection: UsageCollectionSetup, + savedObjects: SavedObjectsServiceStart, + log: Logger +) => { + const telemetryUsageCollector = usageCollection.makeUsageCollector({ + type: 'workplace_search', + fetch: async () => fetchTelemetryMetrics(savedObjects, log), + isReady: () => true, + schema: { + ui_viewed: { + setup_guide: { type: 'long' }, + overview: { type: 'long' }, + }, + ui_error: { + cannot_connect: { type: 'long' }, + }, + ui_clicked: { + header_launch_button: { type: 'long' }, + org_name_change_button: { type: 'long' }, + onboarding_card_button: { type: 'long' }, + recent_activity_source_details_link: { type: 'long' }, + }, + }, + }); + usageCollection.registerCollector(telemetryUsageCollector); +}; + +/** + * Fetch the aggregated telemetry metrics from our saved objects + */ + +const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart, log: Logger) => { + const savedObjectsRepository = savedObjects.createInternalRepository(); + const savedObjectAttributes = await getSavedObjectAttributesFromRepo( + WS_TELEMETRY_NAME, + savedObjectsRepository, + log + ); + + const defaultTelemetrySavedObject: ITelemetry = { + ui_viewed: { + setup_guide: 0, + overview: 0, + }, + ui_error: { + cannot_connect: 0, + }, + ui_clicked: { + header_launch_button: 0, + org_name_change_button: 0, + onboarding_card_button: 0, + recent_activity_source_details_link: 0, + }, + }; + + // If we don't have an existing/saved telemetry object, return the default + if (!savedObjectAttributes) { + return defaultTelemetrySavedObject; + } + + return { + ui_viewed: { + setup_guide: get(savedObjectAttributes, 'ui_viewed.setup_guide', 0), + overview: get(savedObjectAttributes, 'ui_viewed.overview', 0), + }, + ui_error: { + cannot_connect: get(savedObjectAttributes, 'ui_error.cannot_connect', 0), + }, + ui_clicked: { + header_launch_button: get(savedObjectAttributes, 'ui_clicked.header_launch_button', 0), + org_name_change_button: get(savedObjectAttributes, 'ui_clicked.org_name_change_button', 0), + onboarding_card_button: get(savedObjectAttributes, 'ui_clicked.onboarding_card_button', 0), + recent_activity_source_details_link: get( + savedObjectAttributes, + 'ui_clicked.recent_activity_source_details_link', + 0 + ), + }, + } as ITelemetry; +}; diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index 70be8600862e9..a7bd68f92f78b 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -22,10 +22,15 @@ import { PluginSetupContract as FeaturesPluginSetup } from '../../features/serve import { ConfigType } from './'; import { checkAccess } from './lib/check_access'; import { registerPublicUrlRoute } from './routes/enterprise_search/public_url'; -import { registerEnginesRoute } from './routes/app_search/engines'; -import { registerTelemetryRoute } from './routes/app_search/telemetry'; -import { registerTelemetryUsageCollector } from './collectors/app_search/telemetry'; +import { registerTelemetryRoute } from './routes/enterprise_search/telemetry'; + import { appSearchTelemetryType } from './saved_objects/app_search/telemetry'; +import { registerTelemetryUsageCollector as registerASTelemetryUsageCollector } from './collectors/app_search/telemetry'; +import { registerEnginesRoute } from './routes/app_search/engines'; + +import { workplaceSearchTelemetryType } from './saved_objects/workplace_search/telemetry'; +import { registerTelemetryUsageCollector as registerWSTelemetryUsageCollector } from './collectors/workplace_search/telemetry'; +import { registerWSOverviewRoute } from './routes/workplace_search/overview'; export interface PluginsSetup { usageCollection?: UsageCollectionSetup; @@ -64,8 +69,8 @@ export class EnterpriseSearchPlugin implements Plugin { order: 0, icon: 'logoEnterpriseSearch', navLinkId: 'appSearch', // TODO - remove this once functional tests no longer rely on navLinkId - app: ['kibana', 'appSearch'], // TODO: 'enterpriseSearch', 'workplaceSearch' - catalogue: ['appSearch'], // TODO: 'enterpriseSearch', 'workplaceSearch' + app: ['kibana', 'appSearch', 'workplaceSearch'], // TODO: 'enterpriseSearch' + catalogue: ['appSearch', 'workplaceSearch'], // TODO: 'enterpriseSearch' privileges: null, }); @@ -75,15 +80,16 @@ export class EnterpriseSearchPlugin implements Plugin { capabilities.registerSwitcher(async (request: KibanaRequest) => { const dependencies = { config, security, request, log: this.logger }; - const { hasAppSearchAccess } = await checkAccess(dependencies); - // TODO: hasWorkplaceSearchAccess + const { hasAppSearchAccess, hasWorkplaceSearchAccess } = await checkAccess(dependencies); return { navLinks: { appSearch: hasAppSearchAccess, + workplaceSearch: hasWorkplaceSearchAccess, }, catalogue: { appSearch: hasAppSearchAccess, + workplaceSearch: hasWorkplaceSearchAccess, }, }; }); @@ -96,23 +102,24 @@ export class EnterpriseSearchPlugin implements Plugin { registerPublicUrlRoute(dependencies); registerEnginesRoute(dependencies); + registerWSOverviewRoute(dependencies); /** * Bootstrap the routes, saved objects, and collector for telemetry */ savedObjects.registerType(appSearchTelemetryType); + savedObjects.registerType(workplaceSearchTelemetryType); let savedObjectsStarted: SavedObjectsServiceStart; getStartServices().then(([coreStart]) => { savedObjectsStarted = coreStart.savedObjects; + if (usageCollection) { - registerTelemetryUsageCollector(usageCollection, savedObjectsStarted, this.logger); + registerASTelemetryUsageCollector(usageCollection, savedObjectsStarted, this.logger); + registerWSTelemetryUsageCollector(usageCollection, savedObjectsStarted, this.logger); } }); - registerTelemetryRoute({ - ...dependencies, - getSavedObjectsService: () => savedObjectsStarted, - }); + registerTelemetryRoute({ ...dependencies, getSavedObjectsService: () => savedObjectsStarted }); } public start() {} diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts similarity index 56% rename from x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts rename to x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts index e2d5fbcec3705..ebd84d3e0e79a 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts @@ -7,20 +7,21 @@ import { loggingSystemMock, savedObjectsServiceMock } from 'src/core/server/mocks'; import { MockRouter, mockConfig, mockLogger } from '../__mocks__'; -import { registerTelemetryRoute } from './telemetry'; - -jest.mock('../../collectors/app_search/telemetry', () => ({ +jest.mock('../../collectors/lib/telemetry', () => ({ incrementUICounter: jest.fn(), })); -import { incrementUICounter } from '../../collectors/app_search/telemetry'; +import { incrementUICounter } from '../../collectors/lib/telemetry'; + +import { registerTelemetryRoute } from './telemetry'; /** * Since these route callbacks are so thin, these serve simply as integration tests * to ensure they're wired up to the collector functions correctly. Business logic * is tested more thoroughly in the collectors/telemetry tests. */ -describe('App Search Telemetry API', () => { +describe('Enterprise Search Telemetry API', () => { let mockRouter: MockRouter; + const successResponse = { success: true }; beforeEach(() => { jest.clearAllMocks(); @@ -34,14 +35,20 @@ describe('App Search Telemetry API', () => { }); }); - describe('PUT /api/app_search/telemetry', () => { - it('increments the saved objects counter', async () => { - const successResponse = { success: true }; + describe('PUT /api/enterprise_search/telemetry', () => { + it('increments the saved objects counter for App Search', async () => { (incrementUICounter as jest.Mock).mockImplementation(jest.fn(() => successResponse)); - await mockRouter.callRoute({ body: { action: 'viewed', metric: 'setup_guide' } }); + await mockRouter.callRoute({ + body: { + product: 'app_search', + action: 'viewed', + metric: 'setup_guide', + }, + }); expect(incrementUICounter).toHaveBeenCalledWith({ + id: 'app_search_telemetry', savedObjects: expect.any(Object), uiAction: 'ui_viewed', metric: 'setup_guide', @@ -49,10 +56,36 @@ describe('App Search Telemetry API', () => { expect(mockRouter.response.ok).toHaveBeenCalledWith({ body: successResponse }); }); + it('increments the saved objects counter for Workplace Search', async () => { + (incrementUICounter as jest.Mock).mockImplementation(jest.fn(() => successResponse)); + + await mockRouter.callRoute({ + body: { + product: 'workplace_search', + action: 'clicked', + metric: 'onboarding_card_button', + }, + }); + + expect(incrementUICounter).toHaveBeenCalledWith({ + id: 'workplace_search_telemetry', + savedObjects: expect.any(Object), + uiAction: 'ui_clicked', + metric: 'onboarding_card_button', + }); + expect(mockRouter.response.ok).toHaveBeenCalledWith({ body: successResponse }); + }); + it('throws an error when incrementing fails', async () => { (incrementUICounter as jest.Mock).mockImplementation(jest.fn(() => Promise.reject('Failed'))); - await mockRouter.callRoute({ body: { action: 'error', metric: 'error' } }); + await mockRouter.callRoute({ + body: { + product: 'enterprise_search', + action: 'error', + metric: 'error', + }, + }); expect(incrementUICounter).toHaveBeenCalled(); expect(mockLogger.error).toHaveBeenCalled(); @@ -73,34 +106,50 @@ describe('App Search Telemetry API', () => { expect(mockRouter.response.internalError).toHaveBeenCalled(); expect(loggingSystemMock.collect(mockLogger).error[0][0]).toEqual( expect.stringContaining( - 'App Search UI telemetry error: Error: Could not find Saved Objects service' + 'Enterprise Search UI telemetry error: Error: Could not find Saved Objects service' ) ); }); describe('validates', () => { it('correctly', () => { - const request = { body: { action: 'viewed', metric: 'setup_guide' } }; + const request = { + body: { product: 'workplace_search', action: 'viewed', metric: 'setup_guide' }, + }; mockRouter.shouldValidate(request); }); + it('wrong product string', () => { + const request = { + body: { product: 'workspace_space_search', action: 'viewed', metric: 'setup_guide' }, + }; + mockRouter.shouldThrow(request); + }); + it('wrong action string', () => { - const request = { body: { action: 'invalid', metric: 'setup_guide' } }; + const request = { + body: { product: 'app_search', action: 'invalid', metric: 'setup_guide' }, + }; mockRouter.shouldThrow(request); }); it('wrong metric type', () => { - const request = { body: { action: 'clicked', metric: true } }; + const request = { body: { product: 'enterprise_search', action: 'clicked', metric: true } }; + mockRouter.shouldThrow(request); + }); + + it('product is missing string', () => { + const request = { body: { action: 'viewed', metric: 'setup_guide' } }; mockRouter.shouldThrow(request); }); it('action is missing', () => { - const request = { body: { metric: 'engines_overview' } }; + const request = { body: { product: 'app_search', metric: 'engines_overview' } }; mockRouter.shouldThrow(request); }); it('metric is missing', () => { - const request = { body: { action: 'error' } }; + const request = { body: { product: 'app_search', action: 'error' } }; mockRouter.shouldThrow(request); }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.ts similarity index 55% rename from x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts rename to x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.ts index 4cc9b64adc092..7ed1d7b17753c 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.ts @@ -7,7 +7,15 @@ import { schema } from '@kbn/config-schema'; import { IRouteDependencies } from '../../plugin'; -import { incrementUICounter } from '../../collectors/app_search/telemetry'; +import { incrementUICounter } from '../../collectors/lib/telemetry'; + +import { AS_TELEMETRY_NAME } from '../../collectors/app_search/telemetry'; +import { WS_TELEMETRY_NAME } from '../../collectors/workplace_search/telemetry'; +const productToTelemetryMap = { + app_search: AS_TELEMETRY_NAME, + workplace_search: WS_TELEMETRY_NAME, + enterprise_search: 'TODO', +}; export function registerTelemetryRoute({ router, @@ -16,9 +24,14 @@ export function registerTelemetryRoute({ }: IRouteDependencies) { router.put( { - path: '/api/app_search/telemetry', + path: '/api/enterprise_search/telemetry', validate: { body: schema.object({ + product: schema.oneOf([ + schema.literal('app_search'), + schema.literal('workplace_search'), + schema.literal('enterprise_search'), + ]), action: schema.oneOf([ schema.literal('viewed'), schema.literal('clicked'), @@ -29,21 +42,24 @@ export function registerTelemetryRoute({ }, }, async (ctx, request, response) => { - const { action, metric } = request.body; + const { product, action, metric } = request.body; try { if (!getSavedObjectsService) throw new Error('Could not find Saved Objects service'); return response.ok({ body: await incrementUICounter({ + id: productToTelemetryMap[product], savedObjects: getSavedObjectsService(), uiAction: `ui_${action}`, metric, }), }); } catch (e) { - log.error(`App Search UI telemetry error: ${e instanceof Error ? e.stack : e.toString()}`); - return response.internalError({ body: 'App Search UI telemetry failed' }); + log.error( + `Enterprise Search UI telemetry error: ${e instanceof Error ? e.stack : e.toString()}` + ); + return response.internalError({ body: 'Enterprise Search UI telemetry failed' }); } } ); diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts new file mode 100644 index 0000000000000..b1b5539795357 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts @@ -0,0 +1,127 @@ +/* + * 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 { MockRouter, mockConfig, mockLogger } from '../__mocks__'; + +import { registerWSOverviewRoute } from './overview'; + +jest.mock('node-fetch'); +const fetch = jest.requireActual('node-fetch'); +const { Response } = fetch; +const fetchMock = require('node-fetch') as jest.Mocked; + +const ORG_ROUTE = 'http://localhost:3002/ws/org'; + +describe('engine routes', () => { + describe('GET /api/workplace_search/overview', () => { + const AUTH_HEADER = 'Basic 123'; + const mockRequest = { + headers: { + authorization: AUTH_HEADER, + }, + query: {}, + }; + + const mockRouter = new MockRouter({ method: 'get', payload: 'query' }); + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter.createRouter(); + + registerWSOverviewRoute({ + router: mockRouter.router, + log: mockLogger, + config: mockConfig, + }); + }); + + describe('when the underlying Workplace Search API returns a 200', () => { + beforeEach(() => { + WorkplaceSearchAPI.shouldBeCalledWith(ORG_ROUTE, { + headers: { Authorization: AUTH_HEADER }, + }).andReturn({ accountsCount: 1 }); + }); + + it('should return 200 with a list of overview from the Workplace Search API', async () => { + await mockRouter.callRoute(mockRequest); + + expect(mockRouter.response.ok).toHaveBeenCalledWith({ + body: { accountsCount: 1 }, + headers: { 'content-type': 'application/json' }, + }); + }); + }); + + describe('when the Workplace Search URL is invalid', () => { + beforeEach(() => { + WorkplaceSearchAPI.shouldBeCalledWith(ORG_ROUTE, { + headers: { Authorization: AUTH_HEADER }, + }).andReturnError(); + }); + + it('should return 404 with a message', async () => { + await mockRouter.callRoute(mockRequest); + + expect(mockRouter.response.notFound).toHaveBeenCalledWith({ + body: 'cannot-connect', + }); + expect(mockLogger.error).toHaveBeenCalledWith('Cannot connect to Workplace Search: Failed'); + expect(mockLogger.debug).not.toHaveBeenCalled(); + }); + }); + + describe('when the Workplace Search API returns invalid data', () => { + beforeEach(() => { + WorkplaceSearchAPI.shouldBeCalledWith(ORG_ROUTE, { + headers: { Authorization: AUTH_HEADER }, + }).andReturnInvalidData(); + }); + + it('should return 404 with a message', async () => { + await mockRouter.callRoute(mockRequest); + + expect(mockRouter.response.notFound).toHaveBeenCalledWith({ + body: 'cannot-connect', + }); + expect(mockLogger.error).toHaveBeenCalledWith( + 'Cannot connect to Workplace Search: Error: Invalid data received from Workplace Search: {"foo":"bar"}' + ); + expect(mockLogger.debug).toHaveBeenCalled(); + }); + }); + + const WorkplaceSearchAPI = { + shouldBeCalledWith(expectedUrl: string, expectedParams: object) { + return { + andReturn(response: object) { + fetchMock.mockImplementation((url: string, params: object) => { + expect(url).toEqual(expectedUrl); + expect(params).toEqual(expectedParams); + + return Promise.resolve(new Response(JSON.stringify(response))); + }); + }, + andReturnInvalidData() { + fetchMock.mockImplementation((url: string, params: object) => { + expect(url).toEqual(expectedUrl); + expect(params).toEqual(expectedParams); + + return Promise.resolve(new Response(JSON.stringify({ foo: 'bar' }))); + }); + }, + andReturnError() { + fetchMock.mockImplementation((url: string, params: object) => { + expect(url).toEqual(expectedUrl); + expect(params).toEqual(expectedParams); + + return Promise.reject('Failed'); + }); + }, + }; + }, + }; + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.ts new file mode 100644 index 0000000000000..d1e2f4f5f180d --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.ts @@ -0,0 +1,46 @@ +/* + * 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 fetch from 'node-fetch'; + +import { IRouteDependencies } from '../../plugin'; + +export function registerWSOverviewRoute({ router, config, log }: IRouteDependencies) { + router.get( + { + path: '/api/workplace_search/overview', + validate: false, + }, + async (context, request, response) => { + try { + const entSearchUrl = config.host as string; + const url = `${encodeURI(entSearchUrl)}/ws/org`; + + const overviewResponse = await fetch(url, { + headers: { Authorization: request.headers.authorization as string }, + }); + + const body = await overviewResponse.json(); + const hasValidData = typeof body?.accountsCount === 'number'; + + if (hasValidData) { + return response.ok({ + body, + headers: { 'content-type': 'application/json' }, + }); + } else { + // Either a completely incorrect Enterprise Search host URL was configured, or Workplace Search is returning bad data + throw new Error(`Invalid data received from Workplace Search: ${JSON.stringify(body)}`); + } + } catch (e) { + log.error(`Cannot connect to Workplace Search: ${e.toString()}`); + if (e instanceof Error) log.debug(e.stack as string); + + return response.notFound({ body: 'cannot-connect' }); + } + } + ); +} diff --git a/x-pack/plugins/enterprise_search/server/saved_objects/workplace_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/saved_objects/workplace_search/telemetry.ts new file mode 100644 index 0000000000000..86315a9d617e4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/saved_objects/workplace_search/telemetry.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +/* istanbul ignore file */ + +import { SavedObjectsType } from 'src/core/server'; +import { WS_TELEMETRY_NAME } from '../../collectors/workplace_search/telemetry'; + +export const workplaceSearchTelemetryType: SavedObjectsType = { + name: WS_TELEMETRY_NAME, + hidden: false, + namespaceType: 'agnostic', + mappings: { + dynamic: false, + properties: {}, + }, +}; diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index fbef75b9aa9cc..899ece7bce312 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -41,6 +41,43 @@ } } }, + "workplace_search": { + "properties": { + "ui_viewed": { + "properties": { + "setup_guide": { + "type": "long" + }, + "overview": { + "type": "long" + } + } + }, + "ui_error": { + "properties": { + "cannot_connect": { + "type": "long" + } + } + }, + "ui_clicked": { + "properties": { + "header_launch_button": { + "type": "long" + }, + "org_name_change_button": { + "type": "long" + }, + "onboarding_card_button": { + "type": "long" + }, + "recent_activity_source_details_link": { + "type": "long" + } + } + } + } + }, "fileUploadTelemetry": { "properties": { "filesUploadedTotalCount": { diff --git a/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/app_search/setup_guide.ts b/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/app_search/setup_guide.ts index 1d478c6baf29c..76a47cc4a7e10 100644 --- a/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/app_search/setup_guide.ts +++ b/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/app_search/setup_guide.ts @@ -24,7 +24,7 @@ export default function enterpriseSearchSetupGuideTests({ }); describe('when no enterpriseSearch.host is configured', () => { - it('navigating to the enterprise_search plugin will redirect a user to the setup guide', async () => { + it('navigating to the plugin will redirect a user to the setup guide', async () => { await PageObjects.appSearch.navigateToPage(); await retry.try(async function () { const currentUrl = await browser.getCurrentUrl(); diff --git a/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/index.ts b/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/index.ts index 31a92e752fcf4..ebfdca780c127 100644 --- a/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/index.ts +++ b/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/index.ts @@ -11,5 +11,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { this.tags('ciGroup10'); loadTestFile(require.resolve('./app_search/setup_guide')); + loadTestFile(require.resolve('./workplace_search/setup_guide')); }); } diff --git a/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/workplace_search/setup_guide.ts b/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/workplace_search/setup_guide.ts new file mode 100644 index 0000000000000..20145306b21c8 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/workplace_search/setup_guide.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; + +export default function enterpriseSearchSetupGuideTests({ + getService, + getPageObjects, +}: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const browser = getService('browser'); + const retry = getService('retry'); + + const PageObjects = getPageObjects(['workplaceSearch']); + + describe('Setup Guide', function () { + before(async () => await esArchiver.load('empty_kibana')); + after(async () => { + await esArchiver.unload('empty_kibana'); + }); + + describe('when no enterpriseSearch.host is configured', () => { + it('navigating to the plugin will redirect a user to the setup guide', async () => { + await PageObjects.workplaceSearch.navigateToPage(); + await retry.try(async function () { + const currentUrl = await browser.getCurrentUrl(); + expect(currentUrl).to.contain('/workplace_search/setup_guide'); + }); + }); + }); + }); +} diff --git a/x-pack/test/functional_enterprise_search/page_objects/index.ts b/x-pack/test/functional_enterprise_search/page_objects/index.ts index 009fb26482419..87de26b6feda0 100644 --- a/x-pack/test/functional_enterprise_search/page_objects/index.ts +++ b/x-pack/test/functional_enterprise_search/page_objects/index.ts @@ -6,8 +6,10 @@ import { pageObjects as basePageObjects } from '../../functional/page_objects'; import { AppSearchPageProvider } from './app_search'; +import { WorkplaceSearchPageProvider } from './workplace_search'; export const pageObjects = { ...basePageObjects, appSearch: AppSearchPageProvider, + workplaceSearch: WorkplaceSearchPageProvider, }; diff --git a/x-pack/test/functional_enterprise_search/page_objects/workplace_search.ts b/x-pack/test/functional_enterprise_search/page_objects/workplace_search.ts new file mode 100644 index 0000000000000..f97ad2af58111 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/page_objects/workplace_search.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../ftr_provider_context'; + +export function WorkplaceSearchPageProvider({ getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects(['common']); + + return { + async navigateToPage(): Promise { + return await PageObjects.common.navigateToApp('enterprise_search/workplace_search'); + }, + }; +} diff --git a/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts b/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts index 0e0d46c6ce2cd..0d5c553a786fa 100644 --- a/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts +++ b/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts @@ -50,9 +50,10 @@ export default function catalogueTests({ getService }: FtrProviderContext) { expect(uiCapabilities.success).to.be(true); expect(uiCapabilities.value).to.have.property('catalogue'); // everything except ml and monitoring and enterprise search is enabled + const exceptions = ['ml', 'monitoring', 'appSearch', 'workplaceSearch']; const expected = mapValues( uiCapabilities.value!.catalogue, - (enabled, catalogueId) => !['ml', 'monitoring', 'appSearch'].includes(catalogueId) + (enabled, catalogueId) => !exceptions.includes(catalogueId) ); expect(uiCapabilities.value!.catalogue).to.eql(expected); break; diff --git a/x-pack/test/ui_capabilities/security_and_spaces/tests/nav_links.ts b/x-pack/test/ui_capabilities/security_and_spaces/tests/nav_links.ts index 08a7d789153e7..0133a2fafb129 100644 --- a/x-pack/test/ui_capabilities/security_and_spaces/tests/nav_links.ts +++ b/x-pack/test/ui_capabilities/security_and_spaces/tests/nav_links.ts @@ -51,7 +51,13 @@ export default function navLinksTests({ getService }: FtrProviderContext) { expect(uiCapabilities.success).to.be(true); expect(uiCapabilities.value).to.have.property('navLinks'); expect(uiCapabilities.value!.navLinks).to.eql( - navLinksBuilder.except('ml', 'monitoring', 'enterpriseSearch', 'appSearch') + navLinksBuilder.except( + 'ml', + 'monitoring', + 'enterpriseSearch', + 'appSearch', + 'workplaceSearch' + ) ); break; case 'superuser at nothing_space': diff --git a/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts b/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts index 99f91407dc1d2..9ed1c890bf57f 100644 --- a/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts +++ b/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts @@ -48,9 +48,10 @@ export default function catalogueTests({ getService }: FtrProviderContext) { expect(uiCapabilities.success).to.be(true); expect(uiCapabilities.value).to.have.property('catalogue'); // everything except ml and monitoring and enterprise search is enabled + const exceptions = ['ml', 'monitoring', 'appSearch', 'workplaceSearch']; const expected = mapValues( uiCapabilities.value!.catalogue, - (enabled, catalogueId) => !['ml', 'monitoring', 'appSearch'].includes(catalogueId) + (enabled, catalogueId) => !exceptions.includes(catalogueId) ); expect(uiCapabilities.value!.catalogue).to.eql(expected); break; diff --git a/x-pack/test/ui_capabilities/security_only/tests/nav_links.ts b/x-pack/test/ui_capabilities/security_only/tests/nav_links.ts index d3bd2e1afd357..18838e536cf96 100644 --- a/x-pack/test/ui_capabilities/security_only/tests/nav_links.ts +++ b/x-pack/test/ui_capabilities/security_only/tests/nav_links.ts @@ -49,7 +49,7 @@ export default function navLinksTests({ getService }: FtrProviderContext) { expect(uiCapabilities.success).to.be(true); expect(uiCapabilities.value).to.have.property('navLinks'); expect(uiCapabilities.value!.navLinks).to.eql( - navLinksBuilder.except('ml', 'monitoring', 'appSearch') + navLinksBuilder.except('ml', 'monitoring', 'appSearch', 'workplaceSearch') ); break; case 'foo_all': From fd510ca303fe44b2c67b44bd7ded1ec175892f05 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 13 Jul 2020 19:13:38 +0100 Subject: [PATCH 27/40] skip flaky suite (#71501) --- .../functional/apps/management/_create_index_pattern_wizard.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/functional/apps/management/_create_index_pattern_wizard.js b/test/functional/apps/management/_create_index_pattern_wizard.js index cb8b5a6ddc65f..97f2641b51d13 100644 --- a/test/functional/apps/management/_create_index_pattern_wizard.js +++ b/test/functional/apps/management/_create_index_pattern_wizard.js @@ -25,7 +25,8 @@ export default function ({ getService, getPageObjects }) { const es = getService('legacyEs'); const PageObjects = getPageObjects(['settings', 'common']); - describe('"Create Index Pattern" wizard', function () { + // Flaky: https://github.com/elastic/kibana/issues/71501 + describe.skip('"Create Index Pattern" wizard', function () { before(async function () { // delete .kibana index and then wait for Kibana to re-create it await kibanaServer.uiSettings.replace({}); From 1afb0c476b158cc5509ba9f259f635bb1a7ba00b Mon Sep 17 00:00:00 2001 From: Ryland Herrick Date: Mon, 13 Jul 2020 13:18:47 -0500 Subject: [PATCH 28/40] [Security Solution][Detections] Adoption telemetry (#71102) * style: sort plugin interface * WIP: UsageCollector for Security Adoption This uses ML and raw ES calls to query our ML Jobs and Rules, and parse them into a format to be consumed by telemetry. Still to come: * initialization * tests * Initialize usage collectors during plugin setup * Rename usage key The service seems to convert colons to underscores, so let's just use an underscure. * Collector is ready if we have a kibana index * Refactor collector to generate options in a function This allows us to test our adherence to the collector API, focusing particularly on the fetch function. * Refactor usage collector in anticipation of endpoint data We're going to have our usage data under one key corresponding to the app, so this nests the existing data under a 'detections' key while allowing another fetching function to be plugged into the main collector under a separate key. * Update our collector to satisfy telemetry tooling * inlines collector options * inlines schema object * makes DetectionsUsage an interface instead of a type alias * Extracts telemetry mappings via scripts/telemetry_extract * Refactor detections usage logic to perform one loop instead of two We were previously performing two loops over each set of data: one to format it down to just the data we need, and another to convert that into usage data. We now perform both steps within a single loop. * Refactor detections telemetry to be nested * Extract new nested detections telemetry mappings Co-authored-by: Elastic Machine --- .../security_solution/server/plugin.ts | 13 +- .../server/usage/collector.ts | 54 +++++ .../server/usage/detections.mocks.ts | 162 +++++++++++++++ .../server/usage/detections.test.ts | 107 ++++++++++ .../server/usage/detections.ts | 39 ++++ .../server/usage/detections_helpers.ts | 188 ++++++++++++++++++ .../security_solution/server/usage/index.ts | 14 ++ .../security_solution/server/usage/types.ts | 12 ++ .../schema/xpack_plugins.json | 56 ++++++ 9 files changed, 643 insertions(+), 2 deletions(-) create mode 100644 x-pack/plugins/security_solution/server/usage/collector.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections.mocks.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections.test.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections_helpers.ts create mode 100644 x-pack/plugins/security_solution/server/usage/index.ts create mode 100644 x-pack/plugins/security_solution/server/usage/types.ts diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index d4935f1aabc1c..ebd95fe79ebf5 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -16,6 +16,7 @@ import { PluginInitializerContext, SavedObjectsClient, } from '../../../../src/core/server'; +import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; import { PluginSetupContract as AlertingSetup } from '../../alerts/server'; import { SecurityPluginSetup as SecuritySetup } from '../../security/server'; import { PluginSetupContract as FeaturesSetup } from '../../features/server'; @@ -46,17 +47,19 @@ import { ArtifactClient, ManifestManager } from './endpoint/services'; import { EndpointAppContextService } from './endpoint/endpoint_app_context_services'; import { EndpointAppContext } from './endpoint/types'; import { registerDownloadExceptionListRoute } from './endpoint/routes/artifacts'; +import { initUsageCollectors } from './usage'; export interface SetupPlugins { alerts: AlertingSetup; encryptedSavedObjects?: EncryptedSavedObjectsSetup; features: FeaturesSetup; licensing: LicensingPluginSetup; + lists?: ListPluginSetup; + ml?: MlSetup; security?: SecuritySetup; spaces?: SpacesSetup; taskManager?: TaskManagerSetupContract; - ml?: MlSetup; - lists?: ListPluginSetup; + usageCollection?: UsageCollectionSetup; } export interface StartPlugins { @@ -106,9 +109,15 @@ export class Plugin implements IPlugin void; +export interface UsageData { + detections: DetectionsUsage; +} + +export const registerCollector: RegisterCollector = ({ kibanaIndex, ml, usageCollection }) => { + if (!usageCollection) { + return; + } + + const collector = usageCollection.makeUsageCollector({ + type: 'security_solution', + schema: { + detections: { + detection_rules: { + custom: { + enabled: { type: 'long' }, + disabled: { type: 'long' }, + }, + elastic: { + enabled: { type: 'long' }, + disabled: { type: 'long' }, + }, + }, + ml_jobs: { + custom: { + enabled: { type: 'long' }, + disabled: { type: 'long' }, + }, + elastic: { + enabled: { type: 'long' }, + disabled: { type: 'long' }, + }, + }, + }, + }, + isReady: () => kibanaIndex.length > 0, + fetch: async (callCluster: LegacyAPICaller): Promise => ({ + detections: await fetchDetectionsUsage(kibanaIndex, callCluster, ml), + }), + }); + + usageCollection.registerCollector(collector); +}; diff --git a/x-pack/plugins/security_solution/server/usage/detections.mocks.ts b/x-pack/plugins/security_solution/server/usage/detections.mocks.ts new file mode 100644 index 0000000000000..c80dc6936ec7b --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections.mocks.ts @@ -0,0 +1,162 @@ +/* + * 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 { INTERNAL_IMMUTABLE_KEY } from '../../common/constants'; + +export const getMockJobSummaryResponse = () => [ + { + id: 'linux_anomalous_network_activity_ecs', + description: + 'SIEM Auditbeat: Looks for unusual processes using the network which could indicate command-and-control, lateral movement, persistence, or data exfiltration activity (beta)', + groups: ['auditbeat', 'process', 'siem'], + processed_record_count: 141889, + memory_status: 'ok', + jobState: 'opened', + hasDatafeed: true, + datafeedId: 'datafeed-linux_anomalous_network_activity_ecs', + datafeedIndices: ['auditbeat-*'], + datafeedState: 'started', + latestTimestampMs: 1594085401911, + earliestTimestampMs: 1593054845656, + latestResultsTimestampMs: 1594085401911, + isSingleMetricViewerJob: true, + nodeName: 'node', + }, + { + id: 'linux_anomalous_network_port_activity_ecs', + description: + 'SIEM Auditbeat: Looks for unusual destination port activity that could indicate command-and-control, persistence mechanism, or data exfiltration activity (beta)', + groups: ['auditbeat', 'process', 'siem'], + processed_record_count: 0, + memory_status: 'ok', + jobState: 'closed', + hasDatafeed: true, + datafeedId: 'datafeed-linux_anomalous_network_port_activity_ecs', + datafeedIndices: ['auditbeat-*'], + datafeedState: 'stopped', + isSingleMetricViewerJob: true, + }, + { + id: 'other_job', + description: 'a job that is custom', + groups: ['auditbeat', 'process'], + processed_record_count: 0, + memory_status: 'ok', + jobState: 'closed', + hasDatafeed: true, + datafeedId: 'datafeed-other', + datafeedIndices: ['auditbeat-*'], + datafeedState: 'stopped', + isSingleMetricViewerJob: true, + }, + { + id: 'another_job', + description: 'another job that is custom', + groups: ['auditbeat', 'process'], + processed_record_count: 0, + memory_status: 'ok', + jobState: 'opened', + hasDatafeed: true, + datafeedId: 'datafeed-another', + datafeedIndices: ['auditbeat-*'], + datafeedState: 'started', + isSingleMetricViewerJob: true, + }, +]; + +export const getMockListModulesResponse = () => [ + { + id: 'siem_auditbeat', + title: 'SIEM Auditbeat', + description: + 'Detect suspicious network activity and unusual processes in Auditbeat data (beta).', + type: 'Auditbeat data', + logoFile: 'logo.json', + defaultIndexPattern: 'auditbeat-*', + query: { + bool: { + filter: [ + { + term: { + 'agent.type': 'auditbeat', + }, + }, + ], + }, + }, + jobs: [ + { + id: 'linux_anomalous_network_activity_ecs', + config: { + job_type: 'anomaly_detector', + description: + 'SIEM Auditbeat: Looks for unusual processes using the network which could indicate command-and-control, lateral movement, persistence, or data exfiltration activity (beta)', + groups: ['siem', 'auditbeat', 'process'], + analysis_config: { + bucket_span: '15m', + detectors: [ + { + detector_description: 'rare by "process.name"', + function: 'rare', + by_field_name: 'process.name', + }, + ], + influencers: ['host.name', 'process.name', 'user.name', 'destination.ip'], + }, + allow_lazy_open: true, + analysis_limits: { + model_memory_limit: '64mb', + }, + data_description: { + time_field: '@timestamp', + }, + }, + }, + { + id: 'linux_anomalous_network_port_activity_ecs', + config: { + job_type: 'anomaly_detector', + description: + 'SIEM Auditbeat: Looks for unusual destination port activity that could indicate command-and-control, persistence mechanism, or data exfiltration activity (beta)', + groups: ['siem', 'auditbeat', 'network'], + analysis_config: { + bucket_span: '15m', + detectors: [ + { + detector_description: 'rare by "destination.port"', + function: 'rare', + by_field_name: 'destination.port', + }, + ], + influencers: ['host.name', 'process.name', 'user.name', 'destination.ip'], + }, + allow_lazy_open: true, + analysis_limits: { + model_memory_limit: '32mb', + }, + data_description: { + time_field: '@timestamp', + }, + }, + }, + ], + datafeeds: [], + kibana: {}, + }, +]; + +export const getMockRulesResponse = () => ({ + hits: { + hits: [ + { _source: { alert: { enabled: true, tags: [`${INTERNAL_IMMUTABLE_KEY}:true`] } } }, + { _source: { alert: { enabled: true, tags: [`${INTERNAL_IMMUTABLE_KEY}:false`] } } }, + { _source: { alert: { enabled: false, tags: [`${INTERNAL_IMMUTABLE_KEY}:true`] } } }, + { _source: { alert: { enabled: true, tags: [`${INTERNAL_IMMUTABLE_KEY}:true`] } } }, + { _source: { alert: { enabled: false, tags: [`${INTERNAL_IMMUTABLE_KEY}:false`] } } }, + { _source: { alert: { enabled: false, tags: [`${INTERNAL_IMMUTABLE_KEY}:true`] } } }, + { _source: { alert: { enabled: false, tags: [`${INTERNAL_IMMUTABLE_KEY}:true`] } } }, + ], + }, +}); diff --git a/x-pack/plugins/security_solution/server/usage/detections.test.ts b/x-pack/plugins/security_solution/server/usage/detections.test.ts new file mode 100644 index 0000000000000..7fd2d3eb9ff27 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections.test.ts @@ -0,0 +1,107 @@ +/* + * 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 { LegacyAPICaller } from '../../../../../src/core/server'; +import { elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; +import { jobServiceProvider } from '../../../ml/server/models/job_service'; +import { DataRecognizer } from '../../../ml/server/models/data_recognizer'; +import { mlServicesMock } from '../lib/machine_learning/mocks'; +import { + getMockJobSummaryResponse, + getMockListModulesResponse, + getMockRulesResponse, +} from './detections.mocks'; +import { fetchDetectionsUsage } from './detections'; + +jest.mock('../../../ml/server/models/job_service'); +jest.mock('../../../ml/server/models/data_recognizer'); + +describe('Detections Usage', () => { + describe('fetchDetectionsUsage()', () => { + let callClusterMock: jest.Mocked; + let mlMock: ReturnType; + + beforeEach(() => { + callClusterMock = elasticsearchServiceMock.createLegacyClusterClient().callAsInternalUser; + mlMock = mlServicesMock.create(); + }); + + it('returns zeroed counts if both calls are empty', async () => { + const result = await fetchDetectionsUsage('', callClusterMock, mlMock); + + expect(result).toEqual({ + detection_rules: { + custom: { + enabled: 0, + disabled: 0, + }, + elastic: { + enabled: 0, + disabled: 0, + }, + }, + ml_jobs: { + custom: { + enabled: 0, + disabled: 0, + }, + elastic: { + enabled: 0, + disabled: 0, + }, + }, + }); + }); + + it('tallies rules data given rules results', async () => { + (callClusterMock as jest.Mock).mockResolvedValue(getMockRulesResponse()); + const result = await fetchDetectionsUsage('', callClusterMock, mlMock); + + expect(result).toEqual( + expect.objectContaining({ + detection_rules: { + custom: { + enabled: 1, + disabled: 1, + }, + elastic: { + enabled: 2, + disabled: 3, + }, + }, + }) + ); + }); + + it('tallies jobs data given jobs results', async () => { + const mockJobSummary = jest.fn().mockResolvedValue(getMockJobSummaryResponse()); + const mockListModules = jest.fn().mockResolvedValue(getMockListModulesResponse()); + (jobServiceProvider as jest.Mock).mockImplementation(() => ({ + jobsSummary: mockJobSummary, + })); + (DataRecognizer as jest.Mock).mockImplementation(() => ({ + listModules: mockListModules, + })); + + const result = await fetchDetectionsUsage('', callClusterMock, mlMock); + + expect(result).toEqual( + expect.objectContaining({ + ml_jobs: { + custom: { + enabled: 1, + disabled: 1, + }, + elastic: { + enabled: 1, + disabled: 1, + }, + }, + }) + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/usage/detections.ts b/x-pack/plugins/security_solution/server/usage/detections.ts new file mode 100644 index 0000000000000..1475a8ae34625 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections.ts @@ -0,0 +1,39 @@ +/* + * 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 { LegacyAPICaller } from '../../../../../src/core/server'; +import { getMlJobsUsage, getRulesUsage } from './detections_helpers'; +import { MlPluginSetup } from '../../../ml/server'; + +interface FeatureUsage { + enabled: number; + disabled: number; +} + +export interface DetectionRulesUsage { + custom: FeatureUsage; + elastic: FeatureUsage; +} + +export interface MlJobsUsage { + custom: FeatureUsage; + elastic: FeatureUsage; +} + +export interface DetectionsUsage { + detection_rules: DetectionRulesUsage; + ml_jobs: MlJobsUsage; +} + +export const fetchDetectionsUsage = async ( + kibanaIndex: string, + callCluster: LegacyAPICaller, + ml: MlPluginSetup | undefined +): Promise => { + const rulesUsage = await getRulesUsage(kibanaIndex, callCluster); + const mlJobsUsage = await getMlJobsUsage(ml); + return { detection_rules: rulesUsage, ml_jobs: mlJobsUsage }; +}; diff --git a/x-pack/plugins/security_solution/server/usage/detections_helpers.ts b/x-pack/plugins/security_solution/server/usage/detections_helpers.ts new file mode 100644 index 0000000000000..18a90b12991b2 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections_helpers.ts @@ -0,0 +1,188 @@ +/* + * 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 { SearchParams } from 'elasticsearch'; + +import { LegacyAPICaller, SavedObjectsClient } from '../../../../../src/core/server'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { jobServiceProvider } from '../../../ml/server/models/job_service'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { DataRecognizer } from '../../../ml/server/models/data_recognizer'; +import { MlPluginSetup } from '../../../ml/server'; +import { SIGNALS_ID, INTERNAL_IMMUTABLE_KEY } from '../../common/constants'; +import { DetectionRulesUsage, MlJobsUsage } from './detections'; +import { isJobStarted } from '../../common/machine_learning/helpers'; + +interface DetectionsMetric { + isElastic: boolean; + isEnabled: boolean; +} + +const isElasticRule = (tags: string[]) => tags.includes(`${INTERNAL_IMMUTABLE_KEY}:true`); + +const initialRulesUsage: DetectionRulesUsage = { + custom: { + enabled: 0, + disabled: 0, + }, + elastic: { + enabled: 0, + disabled: 0, + }, +}; + +const initialMlJobsUsage: MlJobsUsage = { + custom: { + enabled: 0, + disabled: 0, + }, + elastic: { + enabled: 0, + disabled: 0, + }, +}; + +const updateRulesUsage = ( + ruleMetric: DetectionsMetric, + usage: DetectionRulesUsage +): DetectionRulesUsage => { + const { isEnabled, isElastic } = ruleMetric; + if (isEnabled && isElastic) { + return { + ...usage, + elastic: { + ...usage.elastic, + enabled: usage.elastic.enabled + 1, + }, + }; + } else if (!isEnabled && isElastic) { + return { + ...usage, + elastic: { + ...usage.elastic, + disabled: usage.elastic.disabled + 1, + }, + }; + } else if (isEnabled && !isElastic) { + return { + ...usage, + custom: { + ...usage.custom, + enabled: usage.custom.enabled + 1, + }, + }; + } else if (!isEnabled && !isElastic) { + return { + ...usage, + custom: { + ...usage.custom, + disabled: usage.custom.disabled + 1, + }, + }; + } else { + return usage; + } +}; + +const updateMlJobsUsage = (jobMetric: DetectionsMetric, usage: MlJobsUsage): MlJobsUsage => { + const { isEnabled, isElastic } = jobMetric; + if (isEnabled && isElastic) { + return { + ...usage, + elastic: { + ...usage.elastic, + enabled: usage.elastic.enabled + 1, + }, + }; + } else if (!isEnabled && isElastic) { + return { + ...usage, + elastic: { + ...usage.elastic, + disabled: usage.elastic.disabled + 1, + }, + }; + } else if (isEnabled && !isElastic) { + return { + ...usage, + custom: { + ...usage.custom, + enabled: usage.custom.enabled + 1, + }, + }; + } else if (!isEnabled && !isElastic) { + return { + ...usage, + custom: { + ...usage.custom, + disabled: usage.custom.disabled + 1, + }, + }; + } else { + return usage; + } +}; + +export const getRulesUsage = async ( + index: string, + callCluster: LegacyAPICaller +): Promise => { + let rulesUsage: DetectionRulesUsage = initialRulesUsage; + const ruleSearchOptions: SearchParams = { + body: { query: { bool: { filter: { term: { 'alert.alertTypeId': SIGNALS_ID } } } } }, + filterPath: ['hits.hits._source.alert.enabled', 'hits.hits._source.alert.tags'], + ignoreUnavailable: true, + index, + size: 10000, // elasticsearch index.max_result_window default value + }; + + try { + const ruleResults = await callCluster<{ alert: { enabled: boolean; tags: string[] } }>( + 'search', + ruleSearchOptions + ); + + if (ruleResults.hits?.hits?.length > 0) { + rulesUsage = ruleResults.hits.hits.reduce((usage, hit) => { + const isElastic = isElasticRule(hit._source.alert.tags); + const isEnabled = hit._source.alert.enabled; + + return updateRulesUsage({ isElastic, isEnabled }, usage); + }, initialRulesUsage); + } + } catch (e) { + // ignore failure, usage will be zeroed + } + + return rulesUsage; +}; + +export const getMlJobsUsage = async (ml: MlPluginSetup | undefined): Promise => { + let jobsUsage: MlJobsUsage = initialMlJobsUsage; + + if (ml) { + try { + const mlCaller = ml.mlClient.callAsInternalUser; + const modules = await new DataRecognizer( + mlCaller, + ({} as unknown) as SavedObjectsClient + ).listModules(); + const moduleJobs = modules.flatMap((module) => module.jobs); + const jobs = await jobServiceProvider(mlCaller).jobsSummary(['siem']); + + jobsUsage = jobs.reduce((usage, job) => { + const isElastic = moduleJobs.some((moduleJob) => moduleJob.id === job.id); + const isEnabled = isJobStarted(job.jobState, job.datafeedState); + + return updateMlJobsUsage({ isElastic, isEnabled }, usage); + }, initialMlJobsUsage); + } catch (e) { + // ignore failure, usage will be zeroed + } + } + + return jobsUsage; +}; diff --git a/x-pack/plugins/security_solution/server/usage/index.ts b/x-pack/plugins/security_solution/server/usage/index.ts new file mode 100644 index 0000000000000..4d8749a83be80 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/index.ts @@ -0,0 +1,14 @@ +/* + * 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 { CollectorDependencies } from './types'; +import { registerCollector } from './collector'; + +export type InitUsageCollectors = (deps: CollectorDependencies) => void; + +export const initUsageCollectors: InitUsageCollectors = (dependencies) => { + registerCollector(dependencies); +}; diff --git a/x-pack/plugins/security_solution/server/usage/types.ts b/x-pack/plugins/security_solution/server/usage/types.ts new file mode 100644 index 0000000000000..955a4eaf4be5a --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/types.ts @@ -0,0 +1,12 @@ +/* + * 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 { SetupPlugins } from '../plugin'; + +export type CollectorDependencies = { kibanaIndex: string } & Pick< + SetupPlugins, + 'ml' | 'usageCollection' +>; diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index 899ece7bce312..c5d528cbcce23 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -164,6 +164,62 @@ } } }, + "security_solution": { + "properties": { + "detections": { + "properties": { + "detection_rules": { + "properties": { + "custom": { + "properties": { + "enabled": { + "type": "long" + }, + "disabled": { + "type": "long" + } + } + }, + "elastic": { + "properties": { + "enabled": { + "type": "long" + }, + "disabled": { + "type": "long" + } + } + } + } + }, + "ml_jobs": { + "properties": { + "custom": { + "properties": { + "enabled": { + "type": "long" + }, + "disabled": { + "type": "long" + } + } + }, + "elastic": { + "properties": { + "enabled": { + "type": "long" + }, + "disabled": { + "type": "long" + } + } + } + } + } + } + } + } + }, "spaces": { "properties": { "usesFeatureControls": { From cd43bbc3654922835276063d039ab9d5b9cc45b0 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> Date: Mon, 13 Jul 2020 14:22:17 -0400 Subject: [PATCH 29/40] Increasing limits for resolver (#71483) --- .../common/endpoint/schema/resolver.ts | 16 ++++++++-------- .../api_integration/apis/endpoint/resolver.ts | 12 ++++++++---- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts b/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts index 42cbc2327fc28..c67ad3665d004 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts @@ -12,10 +12,10 @@ import { schema } from '@kbn/config-schema'; export const validateTree = { params: schema.object({ id: schema.string() }), query: schema.object({ - children: schema.number({ defaultValue: 10, min: 0, max: 100 }), - ancestors: schema.number({ defaultValue: 3, min: 0, max: 5 }), - events: schema.number({ defaultValue: 100, min: 0, max: 1000 }), - alerts: schema.number({ defaultValue: 100, min: 0, max: 1000 }), + children: schema.number({ defaultValue: 200, min: 0, max: 10000 }), + ancestors: schema.number({ defaultValue: 200, min: 0, max: 10000 }), + events: schema.number({ defaultValue: 1000, min: 0, max: 10000 }), + alerts: schema.number({ defaultValue: 1000, min: 0, max: 10000 }), afterEvent: schema.maybe(schema.string()), afterAlert: schema.maybe(schema.string()), afterChild: schema.maybe(schema.string()), @@ -29,7 +29,7 @@ export const validateTree = { export const validateEvents = { params: schema.object({ id: schema.string() }), query: schema.object({ - events: schema.number({ defaultValue: 100, min: 1, max: 1000 }), + events: schema.number({ defaultValue: 1000, min: 1, max: 10000 }), afterEvent: schema.maybe(schema.string()), legacyEndpointID: schema.maybe(schema.string()), }), @@ -41,7 +41,7 @@ export const validateEvents = { export const validateAlerts = { params: schema.object({ id: schema.string() }), query: schema.object({ - alerts: schema.number({ defaultValue: 100, min: 1, max: 1000 }), + alerts: schema.number({ defaultValue: 1000, min: 1, max: 10000 }), afterAlert: schema.maybe(schema.string()), legacyEndpointID: schema.maybe(schema.string()), }), @@ -53,7 +53,7 @@ export const validateAlerts = { export const validateAncestry = { params: schema.object({ id: schema.string() }), query: schema.object({ - ancestors: schema.number({ defaultValue: 0, min: 0, max: 10 }), + ancestors: schema.number({ defaultValue: 200, min: 0, max: 10000 }), legacyEndpointID: schema.maybe(schema.string()), }), }; @@ -64,7 +64,7 @@ export const validateAncestry = { export const validateChildren = { params: schema.object({ id: schema.string() }), query: schema.object({ - children: schema.number({ defaultValue: 10, min: 1, max: 100 }), + children: schema.number({ defaultValue: 200, min: 1, max: 10000 }), afterChild: schema.maybe(schema.string()), legacyEndpointID: schema.maybe(schema.string()), }), diff --git a/x-pack/test/api_integration/apis/endpoint/resolver.ts b/x-pack/test/api_integration/apis/endpoint/resolver.ts index ace32111005f4..c8217f2b6872a 100644 --- a/x-pack/test/api_integration/apis/endpoint/resolver.ts +++ b/x-pack/test/api_integration/apis/endpoint/resolver.ts @@ -366,7 +366,7 @@ export default function resolverAPIIntegrationTests({ getService }: FtrProviderC it('should error on invalid pagination values', async () => { await supertest.get(`/api/endpoint/resolver/${entityID}/events?events=0`).expect(400); - await supertest.get(`/api/endpoint/resolver/${entityID}/events?events=2000`).expect(400); + await supertest.get(`/api/endpoint/resolver/${entityID}/events?events=20000`).expect(400); await supertest.get(`/api/endpoint/resolver/${entityID}/events?events=-1`).expect(400); }); }); @@ -444,14 +444,18 @@ export default function resolverAPIIntegrationTests({ getService }: FtrProviderC it('should have a populated next parameter', async () => { const { body }: { body: ResolverAncestry } = await supertest - .get(`/api/endpoint/resolver/${entityID}/ancestry?legacyEndpointID=${endpointID}`) + .get( + `/api/endpoint/resolver/${entityID}/ancestry?legacyEndpointID=${endpointID}&ancestors=0` + ) .expect(200); expect(body.nextAncestor).to.eql('94041'); }); it('should handle an ancestors param request', async () => { let { body }: { body: ResolverAncestry } = await supertest - .get(`/api/endpoint/resolver/${entityID}/ancestry?legacyEndpointID=${endpointID}`) + .get( + `/api/endpoint/resolver/${entityID}/ancestry?legacyEndpointID=${endpointID}&ancestors=0` + ) .expect(200); const next = body.nextAncestor; @@ -579,7 +583,7 @@ export default function resolverAPIIntegrationTests({ getService }: FtrProviderC it('errors on invalid pagination values', async () => { await supertest.get(`/api/endpoint/resolver/${entityID}/children?children=0`).expect(400); await supertest - .get(`/api/endpoint/resolver/${entityID}/children?children=2000`) + .get(`/api/endpoint/resolver/${entityID}/children?children=20000`) .expect(400); await supertest .get(`/api/endpoint/resolver/${entityID}/children?children=-1`) From 649a16bd8813af13f1837d6207e8977c151b4346 Mon Sep 17 00:00:00 2001 From: Madison Caldwell Date: Mon, 13 Jul 2020 14:25:04 -0400 Subject: [PATCH 30/40] [Security Solution][Endpoint][Ingest Manager] Improved testing for user manifest consistency (#71381) * Test user artifacts for all OSes. Test unicode. * Test hashes and sizes pre- and post- decoding * Clean up types in ingestManager common mocks * Fix type in package config mock * Add test for conflict on dispatch * Test package config conflict resolution --- x-pack/plugins/ingest_manager/common/mocks.ts | 11 +- .../server/services/package_config.test.ts | 33 +++- .../manifest_manager/manifest_manager.mock.ts | 2 +- .../manifest_manager/manifest_manager.test.ts | 32 +++- .../apis/endpoint/artifacts/index.ts | 180 +++++++++++++++++- .../endpoint/artifacts/api_feature/data.json | 52 ++++- 6 files changed, 291 insertions(+), 19 deletions(-) diff --git a/x-pack/plugins/ingest_manager/common/mocks.ts b/x-pack/plugins/ingest_manager/common/mocks.ts index 131917af44595..e85364f2bb672 100644 --- a/x-pack/plugins/ingest_manager/common/mocks.ts +++ b/x-pack/plugins/ingest_manager/common/mocks.ts @@ -6,7 +6,7 @@ import { NewPackageConfig, PackageConfig } from './types/models/package_config'; -export const createNewPackageConfigMock = () => { +export const createNewPackageConfigMock = (): NewPackageConfig => { return { name: 'endpoint-1', description: '', @@ -20,10 +20,10 @@ export const createNewPackageConfigMock = () => { version: '0.9.0', }, inputs: [], - } as NewPackageConfig; + }; }; -export const createPackageConfigMock = () => { +export const createPackageConfigMock = (): PackageConfig => { const newPackageConfig = createNewPackageConfigMock(); return { ...newPackageConfig, @@ -37,7 +37,10 @@ export const createPackageConfigMock = () => { inputs: [ { config: {}, + enabled: true, + type: 'endpoint', + streams: [], }, ], - } as PackageConfig; + }; }; diff --git a/x-pack/plugins/ingest_manager/server/services/package_config.test.ts b/x-pack/plugins/ingest_manager/server/services/package_config.test.ts index f8dd1c65e3e72..e86e2608e252d 100644 --- a/x-pack/plugins/ingest_manager/server/services/package_config.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/package_config.test.ts @@ -4,8 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ +import { savedObjectsClientMock } from 'src/core/server/mocks'; +import { createPackageConfigMock } from '../../common/mocks'; import { packageConfigService } from './package_config'; -import { PackageInfo } from '../types'; +import { PackageInfo, PackageConfigSOAttributes } from '../types'; +import { SavedObjectsUpdateResponse } from 'src/core/server'; async function mockedGetAssetsData(_a: any, _b: any, dataset: string) { if (dataset === 'dataset1') { @@ -161,4 +164,32 @@ describe('Package config service', () => { ]); }); }); + + describe('update', () => { + it('should fail to update on version conflict', async () => { + const savedObjectsClient = savedObjectsClientMock.create(); + savedObjectsClient.get.mockResolvedValue({ + id: 'test', + type: 'abcd', + references: [], + version: 'test', + attributes: createPackageConfigMock(), + }); + savedObjectsClient.update.mockImplementation( + async ( + type: string, + id: string + ): Promise> => { + throw savedObjectsClient.errors.createConflictError('abc', '123'); + } + ); + await expect( + packageConfigService.update( + savedObjectsClient, + 'the-package-config-id', + createPackageConfigMock() + ) + ).rejects.toThrow('Saved object [abc/123] conflict'); + }); + }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.mock.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.mock.ts index 3bdc5dfbcbd45..3e4fee8871b8a 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.mock.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.mock.ts @@ -64,7 +64,7 @@ export const getManifestManagerMock = (opts?: { } packageConfigService.list = jest.fn().mockResolvedValue({ total: 1, - items: [createPackageConfigMock()], + items: [{ version: 'abcd', ...createPackageConfigMock() }], }); let savedObjectsClient = savedObjectsClientMock.create(); diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts index d092e7060f8aa..80d325ece765c 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts @@ -77,8 +77,36 @@ describe('manifest_manager', () => { const packageConfigService = createPackageConfigServiceMock(); const manifestManager = getManifestManagerMock({ packageConfigService }); const snapshot = await manifestManager.getSnapshot(); - const dispatched = await manifestManager.dispatch(snapshot!.manifest); - expect(dispatched).toEqual([]); + const dispatchErrors = await manifestManager.dispatch(snapshot!.manifest); + expect(dispatchErrors).toEqual([]); + const entries = snapshot!.manifest.getEntries(); + const artifact = Object.values(entries)[0].getArtifact(); + expect( + packageConfigService.update.mock.calls[0][2].inputs[0].config!.artifact_manifest.value + ).toEqual({ + manifest_version: ManifestConstants.INITIAL_VERSION, + schema_version: 'v1', + artifacts: { + [artifact.identifier]: { + compression_algorithm: 'none', + encryption_algorithm: 'none', + decoded_sha256: artifact.decodedSha256, + encoded_sha256: artifact.encodedSha256, + decoded_size: artifact.decodedSize, + encoded_size: artifact.encodedSize, + relative_url: `/api/endpoint/artifacts/download/${artifact.identifier}/${artifact.decodedSha256}`, + }, + }, + }); + }); + + test('ManifestManager fails to dispatch on conflict', async () => { + const packageConfigService = createPackageConfigServiceMock(); + const manifestManager = getManifestManagerMock({ packageConfigService }); + const snapshot = await manifestManager.getSnapshot(); + packageConfigService.update.mockRejectedValue({ status: 409 }); + const dispatchErrors = await manifestManager.dispatch(snapshot!.manifest); + expect(dispatchErrors).toEqual([{ status: 409 }]); const entries = snapshot!.manifest.getEntries(); const artifact = Object.values(entries)[0].getArtifact(); expect( diff --git a/x-pack/test/api_integration/apis/endpoint/artifacts/index.ts b/x-pack/test/api_integration/apis/endpoint/artifacts/index.ts index ca59d396839ae..ba68b9b7ba6ee 100644 --- a/x-pack/test/api_integration/apis/endpoint/artifacts/index.ts +++ b/x-pack/test/api_integration/apis/endpoint/artifacts/index.ts @@ -5,6 +5,7 @@ */ import expect from '@kbn/expect'; +import { createHash } from 'crypto'; import { inflateSync } from 'zlib'; import { FtrProviderContext } from '../../../ftr_provider_context'; @@ -69,7 +70,18 @@ export default function (providerContext: FtrProviderContext) { .expect(404); }); - it('should download an artifact with correct hash', async () => { + it('should fail on invalid api key with 401', async () => { + await supertestWithoutAuth + .get( + '/api/endpoint/artifacts/download/endpoint-exceptionlist-macos-v1/1825fb19fcc6dc391cae0bc4a2e96dd7f728a0c3ae9e1469251ada67f9e1b975' + ) + .set('kbn-xsrf', 'xxx') + .set('authorization', `ApiKey iNvAlId`) + .send() + .expect(401); + }); + + it('should download an artifact with list items', async () => { await supertestWithoutAuth .get( '/api/endpoint/artifacts/download/endpoint-exceptionlist-linux-v1/d2a9c760005b08d43394e59a8701ae75c80881934ccf15a006944452b80f7f9f' @@ -79,7 +91,18 @@ export default function (providerContext: FtrProviderContext) { .send() .expect(200) .expect((response) => { - const artifactJson = JSON.parse(inflateSync(response.body).toString()); + expect(response.body.byteLength).to.equal(160); + const encodedHash = createHash('sha256').update(response.body).digest('hex'); + expect(encodedHash).to.equal( + '5caaeabcb7864d47157fc7c28d5a7398b4f6bbaaa565d789c02ee809253b7613' + ); + const decodedBody = inflateSync(response.body); + const decodedHash = createHash('sha256').update(decodedBody).digest('hex'); + expect(decodedHash).to.equal( + 'd2a9c760005b08d43394e59a8701ae75c80881934ccf15a006944452b80f7f9f' + ); + expect(decodedBody.byteLength).to.equal(358); + const artifactJson = JSON.parse(decodedBody.toString()); expect(artifactJson).to.eql({ entries: [ { @@ -116,10 +139,10 @@ export default function (providerContext: FtrProviderContext) { }); }); - it('should download an artifact with correct hash from cache', async () => { + it('should download an artifact with unicode characters', async () => { await supertestWithoutAuth .get( - '/api/endpoint/artifacts/download/endpoint-exceptionlist-linux-v1/d2a9c760005b08d43394e59a8701ae75c80881934ccf15a006944452b80f7f9f' + '/api/endpoint/artifacts/download/endpoint-exceptionlist-windows-v1/8d2bcc37e82fad5d06e2c9e4bd96793ea8905ace1d528a57d0d0579ecc8c647e' ) .set('kbn-xsrf', 'xxx') .set('authorization', `ApiKey ${agentAccessAPIKey}`) @@ -131,14 +154,25 @@ export default function (providerContext: FtrProviderContext) { .then(async () => { await supertestWithoutAuth .get( - '/api/endpoint/artifacts/download/endpoint-exceptionlist-linux-v1/d2a9c760005b08d43394e59a8701ae75c80881934ccf15a006944452b80f7f9f' + '/api/endpoint/artifacts/download/endpoint-exceptionlist-windows-v1/8d2bcc37e82fad5d06e2c9e4bd96793ea8905ace1d528a57d0d0579ecc8c647e' ) .set('kbn-xsrf', 'xxx') .set('authorization', `ApiKey ${agentAccessAPIKey}`) .send() .expect(200) .expect((response) => { - const artifactJson = JSON.parse(inflateSync(response.body).toString()); + const encodedHash = createHash('sha256').update(response.body).digest('hex'); + expect(encodedHash).to.equal( + '73015ee5131dabd1b48aa4776d3e766d836f8dd8c9fa8999c9b931f60027f07f' + ); + expect(response.body.byteLength).to.equal(191); + const decodedBody = inflateSync(response.body); + const decodedHash = createHash('sha256').update(decodedBody).digest('hex'); + expect(decodedHash).to.equal( + '8d2bcc37e82fad5d06e2c9e4bd96793ea8905ace1d528a57d0d0579ecc8c647e' + ); + expect(decodedBody.byteLength).to.equal(704); + const artifactJson = JSON.parse(decodedBody.toString()); expect(artifactJson).to.eql({ entries: [ { @@ -150,6 +184,35 @@ export default function (providerContext: FtrProviderContext) { type: 'exact_cased', value: 'Elastic, N.V.', }, + { + entries: [ + { + field: 'signer', + operator: 'included', + type: 'exact_cased', + value: '😈', + }, + { + field: 'trusted', + operator: 'included', + type: 'exact_cased', + value: 'true', + }, + ], + field: 'file.signature', + type: 'nested', + }, + ], + }, + { + type: 'simple', + entries: [ + { + field: 'actingProcess.file.signer', + operator: 'included', + type: 'exact_cased', + value: 'Another signer', + }, { entries: [ { @@ -176,15 +239,112 @@ export default function (providerContext: FtrProviderContext) { }); }); - it('should fail on invalid api key', async () => { + it('should download an artifact with empty exception list', async () => { await supertestWithoutAuth .get( - '/api/endpoint/artifacts/download/endpoint-exceptionlist-macos-v1/1825fb19fcc6dc391cae0bc4a2e96dd7f728a0c3ae9e1469251ada67f9e1b975' + '/api/endpoint/artifacts/download/endpoint-exceptionlist-macos-v1/d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658' ) .set('kbn-xsrf', 'xxx') - .set('authorization', `ApiKey iNvAlId`) + .set('authorization', `ApiKey ${agentAccessAPIKey}`) .send() - .expect(401); + .expect(200) + .expect((response) => { + JSON.parse(inflateSync(response.body).toString()); + }) + .then(async () => { + await supertestWithoutAuth + .get( + '/api/endpoint/artifacts/download/endpoint-exceptionlist-macos-v1/d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658' + ) + .set('kbn-xsrf', 'xxx') + .set('authorization', `ApiKey ${agentAccessAPIKey}`) + .send() + .expect(200) + .expect((response) => { + const encodedHash = createHash('sha256').update(response.body).digest('hex'); + expect(encodedHash).to.equal( + 'f8e6afa1d5662f5b37f83337af774b5785b5b7f1daee08b7b00c2d6813874cda' + ); + expect(response.body.byteLength).to.equal(22); + const decodedBody = inflateSync(response.body); + const decodedHash = createHash('sha256').update(decodedBody).digest('hex'); + expect(decodedHash).to.equal( + 'd801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658' + ); + expect(decodedBody.byteLength).to.equal(14); + const artifactJson = JSON.parse(decodedBody.toString()); + expect(artifactJson.entries.length).to.equal(0); + }); + }); + }); + + it('should download an artifact from cache', async () => { + await supertestWithoutAuth + .get( + '/api/endpoint/artifacts/download/endpoint-exceptionlist-linux-v1/d2a9c760005b08d43394e59a8701ae75c80881934ccf15a006944452b80f7f9f' + ) + .set('kbn-xsrf', 'xxx') + .set('authorization', `ApiKey ${agentAccessAPIKey}`) + .send() + .expect(200) + .expect((response) => { + JSON.parse(inflateSync(response.body).toString()); + }) + .then(async () => { + await supertestWithoutAuth + .get( + '/api/endpoint/artifacts/download/endpoint-exceptionlist-linux-v1/d2a9c760005b08d43394e59a8701ae75c80881934ccf15a006944452b80f7f9f' + ) + .set('kbn-xsrf', 'xxx') + .set('authorization', `ApiKey ${agentAccessAPIKey}`) + .send() + .expect(200) + .expect((response) => { + const encodedHash = createHash('sha256').update(response.body).digest('hex'); + expect(encodedHash).to.equal( + '5caaeabcb7864d47157fc7c28d5a7398b4f6bbaaa565d789c02ee809253b7613' + ); + const decodedBody = inflateSync(response.body); + const decodedHash = createHash('sha256').update(decodedBody).digest('hex'); + expect(decodedHash).to.equal( + 'd2a9c760005b08d43394e59a8701ae75c80881934ccf15a006944452b80f7f9f' + ); + const artifactJson = JSON.parse(decodedBody.toString()); + expect(artifactJson).to.eql({ + entries: [ + { + type: 'simple', + entries: [ + { + field: 'actingProcess.file.signer', + operator: 'included', + type: 'exact_cased', + value: 'Elastic, N.V.', + }, + { + entries: [ + { + field: 'signer', + operator: 'included', + type: 'exact_cased', + value: 'Evil', + }, + { + field: 'trusted', + operator: 'included', + type: 'exact_cased', + value: 'true', + }, + ], + field: 'file.signature', + type: 'nested', + }, + ], + }, + ], + }); + }); + }); }); }); } diff --git a/x-pack/test/functional/es_archives/endpoint/artifacts/api_feature/data.json b/x-pack/test/functional/es_archives/endpoint/artifacts/api_feature/data.json index ab476660e3ffc..47390f0428742 100644 --- a/x-pack/test/functional/es_archives/endpoint/artifacts/api_feature/data.json +++ b/x-pack/test/functional/es_archives/endpoint/artifacts/api_feature/data.json @@ -23,6 +23,56 @@ } } +{ + "type": "doc", + "value": { + "id": "endpoint:user-artifact:endpoint-exceptionlist-macos-v1-d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658", + "index": ".kibana", + "source": { + "references": [ + ], + "endpoint:user-artifact": { + "body": "eJyrVkrNKynKTC1WsoqOrQUAJxkFKQ==", + "created": 1594402653532, + "compressionAlgorithm": "zlib", + "encryptionAlgorithm": "none", + "identifier": "endpoint-exceptionlist-macos-v1", + "encodedSha256": "f8e6afa1d5662f5b37f83337af774b5785b5b7f1daee08b7b00c2d6813874cda", + "encodedSize": 14, + "decodedSha256": "d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658", + "decodedSize": 22 + }, + "type": "endpoint:user-artifact", + "updated_at": "2020-07-10T17:38:47.584Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "endpoint:user-artifact:endpoint-exceptionlist-windows-v1-8d2bcc37e82fad5d06e2c9e4bd96793ea8905ace1d528a57d0d0579ecc8c647e", + "index": ".kibana", + "source": { + "references": [ + ], + "endpoint:user-artifact": { + "body": "eJzFkL0KwjAUhV+lZA55gG4OXcXJRYqE9LZeiElJbotSsvsIbr6ij2AaakVwUqTr+fkOnIGBIYfgWb4bGJ1bYDnzeGw1MP7m1Qi6iqZUhKbZOKvAe1GjBuGxMeBi3rbgJFkXY2iU7iqoojpR4RSreyV9Enupu1EttPSEimdrsRUs8OHj6C8L99v1ksBPGLnOU4p8QYtlYKHkM21+QFLn4FU3kEZCOU4vcOzKWDqAyybGP54tetSLPluGB+Nu8h4=", + "created": 1594402653532, + "compressionAlgorithm": "zlib", + "encryptionAlgorithm": "none", + "identifier": "endpoint-exceptionlist-windows-v1", + "encodedSha256": "73015ee5131dabd1b48aa4776d3e766d836f8dd8c9fa8999c9b931f60027f07f", + "encodedSize": 191, + "decodedSha256": "8d2bcc37e82fad5d06e2c9e4bd96793ea8905ace1d528a57d0d0579ecc8c647e", + "decodedSize": 704 + }, + "type": "endpoint:user-artifact", + "updated_at": "2020-07-10T17:38:47.584Z" + } + } +} + { "type": "doc", "value": { @@ -36,7 +86,7 @@ "ids": [ "endpoint-exceptionlist-linux-v1-d2a9c760005b08d43394e59a8701ae75c80881934ccf15a006944452b80f7f9f", "endpoint-exceptionlist-macos-v1-d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658", - "endpoint-exceptionlist-windows-v1-d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658" + "endpoint-exceptionlist-windows-v1-8d2bcc37e82fad5d06e2c9e4bd96793ea8905ace1d528a57d0d0579ecc8c647e" ] }, "type": "endpoint:user-artifact-manifest", From 3031ff7447a33229dc487c77d079fdbea226a81e Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Mon, 13 Jul 2020 11:40:21 -0700 Subject: [PATCH 31/40] Allow enrollment flyout to load well on slow networks (#71487) --- .../config_selection.tsx | 18 +++++++++++++----- .../agent_enrollment_flyout/index.tsx | 4 ++-- .../managed_instructions.tsx | 6 +++--- .../standalone_instructions.tsx | 4 ++-- .../agent_enrollment_flyout/steps.tsx | 2 +- 5 files changed, 21 insertions(+), 13 deletions(-) diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/config_selection.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/config_selection.tsx index 6f53a237187e5..09b00240dc127 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/config_selection.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/config_selection.tsx @@ -13,7 +13,7 @@ import { sendGetEnrollmentAPIKeys, useCore } from '../../../../hooks'; import { AgentConfigPackageBadges } from '../agent_config_package_badges'; type Props = { - agentConfigs: AgentConfig[]; + agentConfigs?: AgentConfig[]; onConfigChange?: (key: string) => void; } & ( | { @@ -37,9 +37,16 @@ export const EnrollmentStepAgentConfig: React.FC = (props) => { const [selectedState, setSelectedState] = useState<{ agentConfigId?: string; enrollmentAPIKeyId?: string; - }>({ - agentConfigId: agentConfigs.length ? agentConfigs[0].id : undefined, - }); + }>({}); + + useEffect(() => { + if (agentConfigs && agentConfigs.length && !selectedState.agentConfigId) { + setSelectedState({ + ...selectedState, + agentConfigId: agentConfigs[0].id, + }); + } + }, [agentConfigs, selectedState]); useEffect(() => { if (onConfigChange && selectedState.agentConfigId) { @@ -110,7 +117,8 @@ export const EnrollmentStepAgentConfig: React.FC = (props) => { /> } - options={agentConfigs.map((config) => ({ + isLoading={!agentConfigs} + options={(agentConfigs || []).map((config) => ({ value: config.id, text: config.name, }))} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/index.tsx index 5a9d3b7efe1bb..2c66001cc8c08 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/index.tsx @@ -24,12 +24,12 @@ import { StandaloneInstructions } from './standalone_instructions'; interface Props { onClose: () => void; - agentConfigs: AgentConfig[]; + agentConfigs?: AgentConfig[]; } export const AgentEnrollmentFlyout: React.FunctionComponent = ({ onClose, - agentConfigs = [], + agentConfigs, }) => { const [mode, setMode] = useState<'managed' | 'standalone'>('managed'); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/managed_instructions.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/managed_instructions.tsx index aabbd37e809a8..eefb7f1bb7b5f 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/managed_instructions.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/managed_instructions.tsx @@ -21,10 +21,10 @@ import { ManualInstructions } from '../../../../components/enrollment_instructio import { DownloadStep, AgentConfigSelectionStep } from './steps'; interface Props { - agentConfigs: AgentConfig[]; + agentConfigs?: AgentConfig[]; } -export const ManagedInstructions: React.FunctionComponent = ({ agentConfigs = [] }) => { +export const ManagedInstructions: React.FunctionComponent = ({ agentConfigs }) => { const { getHref } = useLink(); const core = useCore(); const fleetStatus = useFleetStatus(); @@ -85,7 +85,7 @@ export const ManagedInstructions: React.FunctionComponent = ({ agentConfi }} /> - )}{' '} + )} ); }; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/standalone_instructions.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/standalone_instructions.tsx index 27f64059deb84..d5f79563f33c4 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/standalone_instructions.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/standalone_instructions.tsx @@ -25,12 +25,12 @@ import { DownloadStep, AgentConfigSelectionStep } from './steps'; import { configToYaml, agentConfigRouteService } from '../../../../services'; interface Props { - agentConfigs: AgentConfig[]; + agentConfigs?: AgentConfig[]; } const RUN_INSTRUCTIONS = './elastic-agent run'; -export const StandaloneInstructions: React.FunctionComponent = ({ agentConfigs = [] }) => { +export const StandaloneInstructions: React.FunctionComponent = ({ agentConfigs }) => { const core = useCore(); const { notifications } = core; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/steps.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/steps.tsx index 267f9027a094a..d01e207169920 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/steps.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/steps.tsx @@ -46,7 +46,7 @@ export const AgentConfigSelectionStep = ({ setSelectedAPIKeyId, setSelectedConfigId, }: { - agentConfigs: AgentConfig[]; + agentConfigs?: AgentConfig[]; setSelectedAPIKeyId?: (key: string) => void; setSelectedConfigId?: (configId: string) => void; }) => { From f95ab33cbe2690474a3d32542268359ec635cdef Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Mon, 13 Jul 2020 12:53:00 -0600 Subject: [PATCH 32/40] [Maps] use EuiColorPalettePicker (#69190) * [Maps] use EuiColorPalettePicker and Eui palettes * use new ramps to create mb style * update ColorMapSelect to use EuiColorPalettePicker * move color_utils test to color_palettes * clean up heatmap constants * tslint * fix test expects * fix merge mistake * update jest expects * remove .chromium folder * another jest expect update * remove charts from kibana.json * remove unneeded jest.mock Co-authored-by: Elastic Machine --- x-pack/plugins/maps/kibana.json | 1 - .../clusters_layer_wizard.tsx | 4 +- .../point_2_point_layer_wizard.tsx | 4 +- .../maps/public/classes/styles/_index.scss | 2 +- .../classes/styles/color_palettes.test.ts | 58 ++++++ .../public/classes/styles/color_palettes.ts | 172 +++++++++++++++++ .../public/classes/styles/color_utils.test.ts | 104 ----------- .../public/classes/styles/color_utils.tsx | 174 ------------------ .../styles/components/color_gradient.tsx | 30 --- .../heatmap_style_editor.test.tsx.snap | 132 +++++++++---- .../heatmap/components/heatmap_constants.ts | 11 -- .../components/heatmap_style_editor.tsx | 29 +-- .../components/legend}/_color_gradient.scss | 0 .../components/legend/color_gradient.tsx | 19 ++ .../components/legend/heatmap_legend.js | 18 +- .../classes/styles/heatmap/heatmap_style.js | 41 +---- .../components/color/color_map_select.js | 56 +++--- .../components/color/dynamic_color_form.js | 14 +- .../extract_color_from_style_property.test.ts | 4 +- .../extract_color_from_style_property.ts | 3 +- .../vector/components/vector_style_editor.js | 2 +- .../dynamic_color_property.test.js.snap | 16 +- .../properties/dynamic_color_property.js | 14 +- .../properties/dynamic_color_property.test.js | 16 +- .../styles/vector/vector_style_defaults.ts | 10 +- .../functional/apps/maps/mapbox_styles.js | 32 ++-- 26 files changed, 446 insertions(+), 520 deletions(-) create mode 100644 x-pack/plugins/maps/public/classes/styles/color_palettes.test.ts create mode 100644 x-pack/plugins/maps/public/classes/styles/color_palettes.ts delete mode 100644 x-pack/plugins/maps/public/classes/styles/color_utils.test.ts delete mode 100644 x-pack/plugins/maps/public/classes/styles/color_utils.tsx delete mode 100644 x-pack/plugins/maps/public/classes/styles/components/color_gradient.tsx rename x-pack/plugins/maps/public/classes/styles/{components => heatmap/components/legend}/_color_gradient.scss (100%) create mode 100644 x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/color_gradient.tsx diff --git a/x-pack/plugins/maps/kibana.json b/x-pack/plugins/maps/kibana.json index e422efb31cb0d..fbf45aee02125 100644 --- a/x-pack/plugins/maps/kibana.json +++ b/x-pack/plugins/maps/kibana.json @@ -21,7 +21,6 @@ "server": true, "extraPublicDirs": ["common/constants"], "requiredBundles": [ - "charts", "kibanaReact", "kibanaUtils", "savedObjects" 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 715c16b22dc51..ee97fdd0a2bf6 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 @@ -28,7 +28,7 @@ import { VECTOR_STYLES, STYLE_TYPE, } from '../../../../common/constants'; -import { COLOR_GRADIENTS } from '../../styles/color_utils'; +import { NUMERICAL_COLOR_PALETTES } from '../../styles/color_palettes'; export const clustersLayerWizardConfig: LayerWizard = { categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH], @@ -57,7 +57,7 @@ export const clustersLayerWizardConfig: LayerWizard = { name: COUNT_PROP_NAME, origin: FIELD_ORIGIN.SOURCE, }, - color: COLOR_GRADIENTS[0].value, + color: NUMERICAL_COLOR_PALETTES[0].value, type: COLOR_MAP_TYPE.ORDINAL, }, }, 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 ae7414b827c8d..fee84d0208978 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 @@ -18,7 +18,7 @@ import { VECTOR_STYLES, STYLE_TYPE, } from '../../../../common/constants'; -import { COLOR_GRADIENTS } from '../../styles/color_utils'; +import { NUMERICAL_COLOR_PALETTES } from '../../styles/color_palettes'; // @ts-ignore import { CreateSourceEditor } from './create_source_editor'; import { LayerWizard, RenderWizardArguments } from '../../layers/layer_wizard_registry'; @@ -50,7 +50,7 @@ export const point2PointLayerWizardConfig: LayerWizard = { name: COUNT_PROP_NAME, origin: FIELD_ORIGIN.SOURCE, }, - color: COLOR_GRADIENTS[0].value, + color: NUMERICAL_COLOR_PALETTES[0].value, }, }, [VECTOR_STYLES.LINE_WIDTH]: { diff --git a/x-pack/plugins/maps/public/classes/styles/_index.scss b/x-pack/plugins/maps/public/classes/styles/_index.scss index 3ee713ffc1a02..bd1467bed9d4e 100644 --- a/x-pack/plugins/maps/public/classes/styles/_index.scss +++ b/x-pack/plugins/maps/public/classes/styles/_index.scss @@ -1,4 +1,4 @@ -@import 'components/color_gradient'; +@import 'heatmap/components/legend/color_gradient'; @import 'vector/components/style_prop_editor'; @import 'vector/components/color/color_stops'; @import 'vector/components/symbol/icon_select'; diff --git a/x-pack/plugins/maps/public/classes/styles/color_palettes.test.ts b/x-pack/plugins/maps/public/classes/styles/color_palettes.test.ts new file mode 100644 index 0000000000000..b964ecf6d6b63 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/styles/color_palettes.test.ts @@ -0,0 +1,58 @@ +/* + * 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 { + getColorRampCenterColor, + getOrdinalMbColorRampStops, + getColorPalette, +} from './color_palettes'; + +describe('getColorPalette', () => { + it('Should create RGB color ramp', () => { + expect(getColorPalette('Blues')).toEqual([ + '#ecf1f7', + '#d9e3ef', + '#c5d5e7', + '#b2c7df', + '#9eb9d8', + '#8bacd0', + '#769fc8', + '#6092c0', + ]); + }); +}); + +describe('getColorRampCenterColor', () => { + it('Should get center color from color ramp', () => { + expect(getColorRampCenterColor('Blues')).toBe('#9eb9d8'); + }); +}); + +describe('getOrdinalMbColorRampStops', () => { + it('Should create color stops for custom range', () => { + expect(getOrdinalMbColorRampStops('Blues', 0, 1000)).toEqual([ + 0, + '#ecf1f7', + 125, + '#d9e3ef', + 250, + '#c5d5e7', + 375, + '#b2c7df', + 500, + '#9eb9d8', + 625, + '#8bacd0', + 750, + '#769fc8', + 875, + '#6092c0', + ]); + }); + + it('Should snap to end of color stops for identical range', () => { + expect(getOrdinalMbColorRampStops('Blues', 23, 23)).toEqual([23, '#6092c0']); + }); +}); diff --git a/x-pack/plugins/maps/public/classes/styles/color_palettes.ts b/x-pack/plugins/maps/public/classes/styles/color_palettes.ts new file mode 100644 index 0000000000000..e7574b4e7b3e4 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/styles/color_palettes.ts @@ -0,0 +1,172 @@ +/* + * 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 tinycolor from 'tinycolor2'; +import { + // @ts-ignore + euiPaletteForStatus, + // @ts-ignore + euiPaletteForTemperature, + // @ts-ignore + euiPaletteCool, + // @ts-ignore + euiPaletteWarm, + // @ts-ignore + euiPaletteNegative, + // @ts-ignore + euiPalettePositive, + // @ts-ignore + euiPaletteGray, + // @ts-ignore + euiPaletteColorBlind, +} from '@elastic/eui/lib/services'; +import { EuiColorPalettePickerPaletteProps } from '@elastic/eui'; + +export const DEFAULT_HEATMAP_COLOR_RAMP_NAME = 'theclassic'; + +export const DEFAULT_FILL_COLORS: string[] = euiPaletteColorBlind(); +export const DEFAULT_LINE_COLORS: string[] = [ + ...DEFAULT_FILL_COLORS.map((color: string) => tinycolor(color).darken().toHexString()), + // Explicitly add black & white as border color options + '#000', + '#FFF', +]; + +const COLOR_PALETTES: EuiColorPalettePickerPaletteProps[] = [ + { + value: 'Blues', + palette: euiPaletteCool(8), + type: 'gradient', + }, + { + value: 'Greens', + palette: euiPalettePositive(8), + type: 'gradient', + }, + { + value: 'Greys', + palette: euiPaletteGray(8), + type: 'gradient', + }, + { + value: 'Reds', + palette: euiPaletteNegative(8), + type: 'gradient', + }, + { + value: 'Yellow to Red', + palette: euiPaletteWarm(8), + type: 'gradient', + }, + { + value: 'Green to Red', + palette: euiPaletteForStatus(8), + type: 'gradient', + }, + { + value: 'Blue to Red', + palette: euiPaletteForTemperature(8), + type: 'gradient', + }, + { + value: DEFAULT_HEATMAP_COLOR_RAMP_NAME, + palette: [ + 'rgb(65, 105, 225)', // royalblue + 'rgb(0, 256, 256)', // cyan + 'rgb(0, 256, 0)', // lime + 'rgb(256, 256, 0)', // yellow + 'rgb(256, 0, 0)', // red + ], + type: 'gradient', + }, + { + value: 'palette_0', + palette: euiPaletteColorBlind(), + type: 'fixed', + }, + { + value: 'palette_20', + palette: euiPaletteColorBlind({ rotations: 2 }), + type: 'fixed', + }, + { + value: 'palette_30', + palette: euiPaletteColorBlind({ rotations: 3 }), + type: 'fixed', + }, +]; + +export const NUMERICAL_COLOR_PALETTES = COLOR_PALETTES.filter( + (palette: EuiColorPalettePickerPaletteProps) => { + return palette.type === 'gradient'; + } +); + +export const CATEGORICAL_COLOR_PALETTES = COLOR_PALETTES.filter( + (palette: EuiColorPalettePickerPaletteProps) => { + return palette.type === 'fixed'; + } +); + +export function getColorPalette(colorPaletteId: string): string[] { + const colorPalette = COLOR_PALETTES.find(({ value }: EuiColorPalettePickerPaletteProps) => { + return value === colorPaletteId; + }); + return colorPalette ? (colorPalette.palette as string[]) : []; +} + +export function getColorRampCenterColor(colorPaletteId: string): string | null { + if (!colorPaletteId) { + return null; + } + const palette = getColorPalette(colorPaletteId); + return palette.length === 0 ? null : palette[Math.floor(palette.length / 2)]; +} + +// Returns an array of color stops +// [ stop_input_1: number, stop_output_1: color, stop_input_n: number, stop_output_n: color ] +export function getOrdinalMbColorRampStops( + colorPaletteId: string, + min: number, + max: number +): Array | null { + if (!colorPaletteId) { + return null; + } + + if (min > max) { + return null; + } + + const palette = getColorPalette(colorPaletteId); + if (palette.length === 0) { + return null; + } + + if (max === min) { + // just return single stop value + return [max, palette[palette.length - 1]]; + } + + const delta = max - min; + return palette.reduce( + (accu: Array, stopColor: string, idx: number, srcArr: string[]) => { + const stopNumber = min + (delta * idx) / srcArr.length; + return [...accu, stopNumber, stopColor]; + }, + [] + ); +} + +export function getLinearGradient(colorStrings: string[]): string { + const intervals = colorStrings.length; + let linearGradient = `linear-gradient(to right, ${colorStrings[0]} 0%,`; + for (let i = 1; i < intervals - 1; i++) { + linearGradient = `${linearGradient} ${colorStrings[i]} \ + ${Math.floor((100 * i) / (intervals - 1))}%,`; + } + return `${linearGradient} ${colorStrings[colorStrings.length - 1]} 100%)`; +} diff --git a/x-pack/plugins/maps/public/classes/styles/color_utils.test.ts b/x-pack/plugins/maps/public/classes/styles/color_utils.test.ts deleted file mode 100644 index ed7cafd53a6fc..0000000000000 --- a/x-pack/plugins/maps/public/classes/styles/color_utils.test.ts +++ /dev/null @@ -1,104 +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 { - COLOR_GRADIENTS, - getColorRampCenterColor, - getOrdinalMbColorRampStops, - getHexColorRangeStrings, - getLinearGradient, - getRGBColorRangeStrings, -} from './color_utils'; - -jest.mock('ui/new_platform'); - -describe('COLOR_GRADIENTS', () => { - it('Should contain EuiSuperSelect options list of color ramps', () => { - expect(COLOR_GRADIENTS.length).toBe(6); - const colorGradientOption = COLOR_GRADIENTS[0]; - expect(colorGradientOption.value).toBe('Blues'); - }); -}); - -describe('getRGBColorRangeStrings', () => { - it('Should create RGB color ramp', () => { - expect(getRGBColorRangeStrings('Blues', 8)).toEqual([ - 'rgb(247,250,255)', - 'rgb(221,234,247)', - 'rgb(197,218,238)', - 'rgb(157,201,224)', - 'rgb(106,173,213)', - 'rgb(65,145,197)', - 'rgb(32,112,180)', - 'rgb(7,47,107)', - ]); - }); -}); - -describe('getHexColorRangeStrings', () => { - it('Should create HEX color ramp', () => { - expect(getHexColorRangeStrings('Blues')).toEqual([ - '#f7faff', - '#ddeaf7', - '#c5daee', - '#9dc9e0', - '#6aadd5', - '#4191c5', - '#2070b4', - '#072f6b', - ]); - }); -}); - -describe('getColorRampCenterColor', () => { - it('Should get center color from color ramp', () => { - expect(getColorRampCenterColor('Blues')).toBe('rgb(106,173,213)'); - }); -}); - -describe('getColorRampStops', () => { - it('Should create color stops for custom range', () => { - expect(getOrdinalMbColorRampStops('Blues', 0, 1000, 8)).toEqual([ - 0, - '#f7faff', - 125, - '#ddeaf7', - 250, - '#c5daee', - 375, - '#9dc9e0', - 500, - '#6aadd5', - 625, - '#4191c5', - 750, - '#2070b4', - 875, - '#072f6b', - ]); - }); - - it('Should snap to end of color stops for identical range', () => { - expect(getOrdinalMbColorRampStops('Blues', 23, 23, 8)).toEqual([23, '#072f6b']); - }); -}); - -describe('getLinearGradient', () => { - it('Should create linear gradient from color ramp', () => { - const colorRamp = [ - 'rgb(247,250,255)', - 'rgb(221,234,247)', - 'rgb(197,218,238)', - 'rgb(157,201,224)', - 'rgb(106,173,213)', - 'rgb(65,145,197)', - 'rgb(32,112,180)', - 'rgb(7,47,107)', - ]; - expect(getLinearGradient(colorRamp)).toBe( - 'linear-gradient(to right, rgb(247,250,255) 0%, rgb(221,234,247) 14%, rgb(197,218,238) 28%, rgb(157,201,224) 42%, rgb(106,173,213) 57%, rgb(65,145,197) 71%, rgb(32,112,180) 85%, rgb(7,47,107) 100%)' - ); - }); -}); diff --git a/x-pack/plugins/maps/public/classes/styles/color_utils.tsx b/x-pack/plugins/maps/public/classes/styles/color_utils.tsx deleted file mode 100644 index 0192a9d7ca68f..0000000000000 --- a/x-pack/plugins/maps/public/classes/styles/color_utils.tsx +++ /dev/null @@ -1,174 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import tinycolor from 'tinycolor2'; -import chroma from 'chroma-js'; -// @ts-ignore -import { euiPaletteColorBlind } from '@elastic/eui/lib/services'; -import { ColorGradient } from './components/color_gradient'; -import { RawColorSchema, vislibColorMaps } from '../../../../../../src/plugins/charts/public'; - -export const GRADIENT_INTERVALS = 8; - -export const DEFAULT_FILL_COLORS: string[] = euiPaletteColorBlind(); -export const DEFAULT_LINE_COLORS: string[] = [ - ...DEFAULT_FILL_COLORS.map((color: string) => tinycolor(color).darken().toHexString()), - // Explicitly add black & white as border color options - '#000', - '#FFF', -]; - -function getRGBColors(colorRamp: Array<[number, number[]]>, numLegendColors: number = 4): string[] { - const colors = []; - colors[0] = getRGBColor(colorRamp, 0); - for (let i = 1; i < numLegendColors - 1; i++) { - colors[i] = getRGBColor(colorRamp, Math.floor((colorRamp.length * i) / numLegendColors)); - } - colors[numLegendColors - 1] = getRGBColor(colorRamp, colorRamp.length - 1); - return colors; -} - -function getRGBColor(colorRamp: Array<[number, number[]]>, i: number): string { - const rgbArray = colorRamp[i][1]; - const red = Math.floor(rgbArray[0] * 255); - const green = Math.floor(rgbArray[1] * 255); - const blue = Math.floor(rgbArray[2] * 255); - return `rgb(${red},${green},${blue})`; -} - -function getColorSchema(colorRampName: string): RawColorSchema { - const colorSchema = vislibColorMaps[colorRampName]; - if (!colorSchema) { - throw new Error( - `${colorRampName} not found. Expected one of following values: ${Object.keys( - vislibColorMaps - )}` - ); - } - return colorSchema; -} - -export function getRGBColorRangeStrings( - colorRampName: string, - numberColors: number = GRADIENT_INTERVALS -): string[] { - const colorSchema = getColorSchema(colorRampName); - return getRGBColors(colorSchema.value, numberColors); -} - -export function getHexColorRangeStrings( - colorRampName: string, - numberColors: number = GRADIENT_INTERVALS -): string[] { - return getRGBColorRangeStrings(colorRampName, numberColors).map((rgbColor) => - chroma(rgbColor).hex() - ); -} - -export function getColorRampCenterColor(colorRampName: string): string | null { - if (!colorRampName) { - return null; - } - const colorSchema = getColorSchema(colorRampName); - const centerIndex = Math.floor(colorSchema.value.length / 2); - return getRGBColor(colorSchema.value, centerIndex); -} - -// Returns an array of color stops -// [ stop_input_1: number, stop_output_1: color, stop_input_n: number, stop_output_n: color ] -export function getOrdinalMbColorRampStops( - colorRampName: string, - min: number, - max: number, - numberColors: number -): Array | null { - if (!colorRampName) { - return null; - } - - if (min > max) { - return null; - } - - const hexColors = getHexColorRangeStrings(colorRampName, numberColors); - if (max === min) { - // just return single stop value - return [max, hexColors[hexColors.length - 1]]; - } - - const delta = max - min; - return hexColors.reduce( - (accu: Array, stopColor: string, idx: number, srcArr: string[]) => { - const stopNumber = min + (delta * idx) / srcArr.length; - return [...accu, stopNumber, stopColor]; - }, - [] - ); -} - -export const COLOR_GRADIENTS = Object.keys(vislibColorMaps).map((colorRampName) => ({ - value: colorRampName, - inputDisplay: , -})); - -export const COLOR_RAMP_NAMES = Object.keys(vislibColorMaps); - -export function getLinearGradient(colorStrings: string[]): string { - const intervals = colorStrings.length; - let linearGradient = `linear-gradient(to right, ${colorStrings[0]} 0%,`; - for (let i = 1; i < intervals - 1; i++) { - linearGradient = `${linearGradient} ${colorStrings[i]} \ - ${Math.floor((100 * i) / (intervals - 1))}%,`; - } - return `${linearGradient} ${colorStrings[colorStrings.length - 1]} 100%)`; -} - -export interface ColorPalette { - id: string; - colors: string[]; -} - -const COLOR_PALETTES_CONFIGS: ColorPalette[] = [ - { - id: 'palette_0', - colors: euiPaletteColorBlind(), - }, - { - id: 'palette_20', - colors: euiPaletteColorBlind({ rotations: 2 }), - }, - { - id: 'palette_30', - colors: euiPaletteColorBlind({ rotations: 3 }), - }, -]; - -export function getColorPalette(paletteId: string): string[] | null { - const palette = COLOR_PALETTES_CONFIGS.find(({ id }: ColorPalette) => id === paletteId); - return palette ? palette.colors : null; -} - -export const COLOR_PALETTES = COLOR_PALETTES_CONFIGS.map((palette) => { - const paletteDisplay = palette.colors.map((color) => { - const style: React.CSSProperties = { - backgroundColor: color, - width: `${100 / palette.colors.length}%`, - position: 'relative', - height: '100%', - display: 'inline-block', - }; - return ( -
-   -
- ); - }); - return { - value: palette.id, - inputDisplay:
{paletteDisplay}
, - }; -}); diff --git a/x-pack/plugins/maps/public/classes/styles/components/color_gradient.tsx b/x-pack/plugins/maps/public/classes/styles/components/color_gradient.tsx deleted file mode 100644 index b29146062e46d..0000000000000 --- a/x-pack/plugins/maps/public/classes/styles/components/color_gradient.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { - COLOR_RAMP_NAMES, - GRADIENT_INTERVALS, - getRGBColorRangeStrings, - getLinearGradient, -} from '../color_utils'; - -interface Props { - colorRamp?: string[]; - colorRampName?: string; -} - -export const ColorGradient = ({ colorRamp, colorRampName }: Props) => { - if (!colorRamp && (!colorRampName || !COLOR_RAMP_NAMES.includes(colorRampName))) { - return null; - } - - const rgbColorStrings = colorRampName - ? getRGBColorRangeStrings(colorRampName, GRADIENT_INTERVALS) - : colorRamp!; - const background = getLinearGradient(rgbColorStrings); - return
; -}; diff --git a/x-pack/plugins/maps/public/classes/styles/heatmap/components/__snapshots__/heatmap_style_editor.test.tsx.snap b/x-pack/plugins/maps/public/classes/styles/heatmap/components/__snapshots__/heatmap_style_editor.test.tsx.snap index 9d07b9c641e0f..7c42b78fdc552 100644 --- a/x-pack/plugins/maps/public/classes/styles/heatmap/components/__snapshots__/heatmap_style_editor.test.tsx.snap +++ b/x-pack/plugins/maps/public/classes/styles/heatmap/components/__snapshots__/heatmap_style_editor.test.tsx.snap @@ -10,66 +10,120 @@ exports[`HeatmapStyleEditor is rendered 1`] = ` label="Color range" labelType="label" > - , - "text": "theclassic", - "value": "theclassic", - }, - Object { - "inputDisplay": , + "palette": Array [ + "#ecf1f7", + "#d9e3ef", + "#c5d5e7", + "#b2c7df", + "#9eb9d8", + "#8bacd0", + "#769fc8", + "#6092c0", + ], + "type": "gradient", "value": "Blues", }, Object { - "inputDisplay": , + "palette": Array [ + "#e6f1ee", + "#cce4de", + "#b3d6cd", + "#9ac8bd", + "#80bbae", + "#65ad9e", + "#47a08f", + "#209280", + ], + "type": "gradient", "value": "Greens", }, Object { - "inputDisplay": , + "palette": Array [ + "#e0e4eb", + "#c2c9d5", + "#a6afbf", + "#8c95a5", + "#757c8b", + "#5e6471", + "#494d58", + "#343741", + ], + "type": "gradient", "value": "Greys", }, Object { - "inputDisplay": , + "palette": Array [ + "#fdeae5", + "#f9d5cc", + "#f4c0b4", + "#eeab9c", + "#e79685", + "#df816e", + "#d66c58", + "#cc5642", + ], + "type": "gradient", "value": "Reds", }, Object { - "inputDisplay": , + "palette": Array [ + "#f9eac5", + "#f6d9af", + "#f3c89a", + "#efb785", + "#eba672", + "#e89361", + "#e58053", + "#e7664c", + ], + "type": "gradient", "value": "Yellow to Red", }, Object { - "inputDisplay": , + "palette": Array [ + "#209280", + "#3aa38d", + "#54b399", + "#95b978", + "#df9352", + "#e7664c", + "#da5e47", + "#cc5642", + ], + "type": "gradient", "value": "Green to Red", }, + Object { + "palette": Array [ + "#6092c0", + "#84a9cd", + "#a8bfda", + "#cad7e8", + "#f0d3b0", + "#ecb385", + "#ea8d69", + "#e7664c", + ], + "type": "gradient", + "value": "Blue to Red", + }, + Object { + "palette": Array [ + "rgb(65, 105, 225)", + "rgb(0, 256, 256)", + "rgb(0, 256, 0)", + "rgb(256, 256, 0)", + "rgb(256, 0, 0)", + ], + "type": "gradient", + "value": "theclassic", + }, ] } valueOfSelected="Blues" diff --git a/x-pack/plugins/maps/public/classes/styles/heatmap/components/heatmap_constants.ts b/x-pack/plugins/maps/public/classes/styles/heatmap/components/heatmap_constants.ts index 583c78e56581b..b043c2791b146 100644 --- a/x-pack/plugins/maps/public/classes/styles/heatmap/components/heatmap_constants.ts +++ b/x-pack/plugins/maps/public/classes/styles/heatmap/components/heatmap_constants.ts @@ -6,17 +6,6 @@ import { i18n } from '@kbn/i18n'; -// Color stops from default Mapbox heatmap-color -export const DEFAULT_RGB_HEATMAP_COLOR_RAMP = [ - 'rgb(65, 105, 225)', // royalblue - 'rgb(0, 256, 256)', // cyan - 'rgb(0, 256, 0)', // lime - 'rgb(256, 256, 0)', // yellow - 'rgb(256, 0, 0)', // red -]; - -export const DEFAULT_HEATMAP_COLOR_RAMP_NAME = 'theclassic'; - export const HEATMAP_COLOR_RAMP_LABEL = i18n.translate('xpack.maps.heatmap.colorRampLabel', { defaultMessage: 'Color range', }); diff --git a/x-pack/plugins/maps/public/classes/styles/heatmap/components/heatmap_style_editor.tsx b/x-pack/plugins/maps/public/classes/styles/heatmap/components/heatmap_style_editor.tsx index d15fdbd79de75..48713f1ddfd4b 100644 --- a/x-pack/plugins/maps/public/classes/styles/heatmap/components/heatmap_style_editor.tsx +++ b/x-pack/plugins/maps/public/classes/styles/heatmap/components/heatmap_style_editor.tsx @@ -6,14 +6,9 @@ import React from 'react'; -import { EuiFormRow, EuiSuperSelect } from '@elastic/eui'; -import { COLOR_GRADIENTS } from '../../color_utils'; -import { ColorGradient } from '../../components/color_gradient'; -import { - DEFAULT_RGB_HEATMAP_COLOR_RAMP, - DEFAULT_HEATMAP_COLOR_RAMP_NAME, - HEATMAP_COLOR_RAMP_LABEL, -} from './heatmap_constants'; +import { EuiFormRow, EuiColorPalettePicker } from '@elastic/eui'; +import { NUMERICAL_COLOR_PALETTES } from '../../color_palettes'; +import { HEATMAP_COLOR_RAMP_LABEL } from './heatmap_constants'; interface Props { colorRampName: string; @@ -21,28 +16,18 @@ interface Props { } export function HeatmapStyleEditor({ colorRampName, onHeatmapColorChange }: Props) { - const onColorRampChange = (selectedColorRampName: string) => { + const onColorRampChange = (selectedPaletteId: string) => { onHeatmapColorChange({ - colorRampName: selectedColorRampName, + colorRampName: selectedPaletteId, }); }; - const colorRampOptions = [ - { - value: DEFAULT_HEATMAP_COLOR_RAMP_NAME, - text: DEFAULT_HEATMAP_COLOR_RAMP_NAME, - inputDisplay: , - }, - ...COLOR_GRADIENTS, - ]; - return ( - diff --git a/x-pack/plugins/maps/public/classes/styles/components/_color_gradient.scss b/x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/_color_gradient.scss similarity index 100% rename from x-pack/plugins/maps/public/classes/styles/components/_color_gradient.scss rename to x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/_color_gradient.scss diff --git a/x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/color_gradient.tsx b/x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/color_gradient.tsx new file mode 100644 index 0000000000000..b4a241f625683 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/color_gradient.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { getColorPalette, getLinearGradient } from '../../../color_palettes'; + +interface Props { + colorPaletteId: string; +} + +export const ColorGradient = ({ colorPaletteId }: Props) => { + const palette = getColorPalette(colorPaletteId); + return palette.length ? ( +
+ ) : null; +}; diff --git a/x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/heatmap_legend.js b/x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/heatmap_legend.js index 1d8dfe9c7bdbf..5c3600a149afe 100644 --- a/x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/heatmap_legend.js +++ b/x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/heatmap_legend.js @@ -7,13 +7,9 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { ColorGradient } from '../../../components/color_gradient'; +import { ColorGradient } from './color_gradient'; import { RangedStyleLegendRow } from '../../../components/ranged_style_legend_row'; -import { - DEFAULT_RGB_HEATMAP_COLOR_RAMP, - DEFAULT_HEATMAP_COLOR_RAMP_NAME, - HEATMAP_COLOR_RAMP_LABEL, -} from '../heatmap_constants'; +import { HEATMAP_COLOR_RAMP_LABEL } from '../heatmap_constants'; export class HeatmapLegend extends React.Component { constructor() { @@ -41,17 +37,9 @@ export class HeatmapLegend extends React.Component { } render() { - const colorRampName = this.props.colorRampName; - const header = - colorRampName === DEFAULT_HEATMAP_COLOR_RAMP_NAME ? ( - - ) : ( - - ); - return ( } minLabel={i18n.translate('xpack.maps.heatmapLegend.coldLabel', { defaultMessage: 'cold', })} diff --git a/x-pack/plugins/maps/public/classes/styles/heatmap/heatmap_style.js b/x-pack/plugins/maps/public/classes/styles/heatmap/heatmap_style.js index 5f920d0ba52d3..55bbbc9319dfb 100644 --- a/x-pack/plugins/maps/public/classes/styles/heatmap/heatmap_style.js +++ b/x-pack/plugins/maps/public/classes/styles/heatmap/heatmap_style.js @@ -8,15 +8,15 @@ import React from 'react'; import { AbstractStyle } from '../style'; import { HeatmapStyleEditor } from './components/heatmap_style_editor'; import { HeatmapLegend } from './components/legend/heatmap_legend'; -import { DEFAULT_HEATMAP_COLOR_RAMP_NAME } from './components/heatmap_constants'; +import { DEFAULT_HEATMAP_COLOR_RAMP_NAME, getOrdinalMbColorRampStops } from '../color_palettes'; import { LAYER_STYLE_TYPE, GRID_RESOLUTION } from '../../../../common/constants'; -import { getOrdinalMbColorRampStops, GRADIENT_INTERVALS } from '../color_utils'; + import { i18n } from '@kbn/i18n'; import { EuiIcon } from '@elastic/eui'; //The heatmap range chosen hear runs from 0 to 1. It is arbitrary. //Weighting is on the raw count/sum values. -const MIN_RANGE = 0; +const MIN_RANGE = 0.1; // 0 to 0.1 is displayed as transparent color stop const MAX_RANGE = 1; export class HeatmapStyle extends AbstractStyle { @@ -83,40 +83,19 @@ export class HeatmapStyle extends AbstractStyle { property: propertyName, }); - const { colorRampName } = this._descriptor; - if (colorRampName && colorRampName !== DEFAULT_HEATMAP_COLOR_RAMP_NAME) { - const colorStops = getOrdinalMbColorRampStops( - colorRampName, - MIN_RANGE, - MAX_RANGE, - GRADIENT_INTERVALS - ); - // TODO handle null - mbMap.setPaintProperty(layerId, 'heatmap-color', [ - 'interpolate', - ['linear'], - ['heatmap-density'], - 0, - 'rgba(0, 0, 255, 0)', - ...colorStops.slice(2), // remove first stop from colorStops to avoid conflict with transparent stop at zero - ]); - } else { + const colorStops = getOrdinalMbColorRampStops( + this._descriptor.colorRampName, + MIN_RANGE, + MAX_RANGE + ); + if (colorStops) { mbMap.setPaintProperty(layerId, 'heatmap-color', [ 'interpolate', ['linear'], ['heatmap-density'], 0, 'rgba(0, 0, 255, 0)', - 0.1, - 'royalblue', - 0.3, - 'cyan', - 0.5, - 'lime', - 0.7, - 'yellow', - 1, - 'red', + ...colorStops, ]); } } diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/color/color_map_select.js b/x-pack/plugins/maps/public/classes/styles/vector/components/color/color_map_select.js index fe2f302504a15..a7d849265d815 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/color/color_map_select.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/color/color_map_select.js @@ -6,10 +6,17 @@ import React, { Component, Fragment } from 'react'; -import { EuiSpacer, EuiSelect, EuiSuperSelect, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { + EuiSpacer, + EuiSelect, + EuiColorPalettePicker, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; import { ColorStopsOrdinal } from './color_stops_ordinal'; import { COLOR_MAP_TYPE } from '../../../../../../common/constants'; import { ColorStopsCategorical } from './color_stops_categorical'; +import { CATEGORICAL_COLOR_PALETTES, NUMERICAL_COLOR_PALETTES } from '../../../color_palettes'; import { i18n } from '@kbn/i18n'; const CUSTOM_COLOR_MAP = 'CUSTOM_COLOR_MAP'; @@ -65,10 +72,10 @@ export class ColorMapSelect extends Component { ); } - _onColorMapSelect = (selectedValue) => { - const useCustomColorMap = selectedValue === CUSTOM_COLOR_MAP; + _onColorPaletteSelect = (selectedPaletteId) => { + const useCustomColorMap = selectedPaletteId === CUSTOM_COLOR_MAP; this.props.onChange({ - color: useCustomColorMap ? null : selectedValue, + color: useCustomColorMap ? null : selectedPaletteId, useCustomColorMap, type: this.props.colorMapType, }); @@ -126,26 +133,28 @@ export class ColorMapSelect extends Component { return null; } - const colorMapOptionsWithCustom = [ + const palettes = + this.props.colorMapType === COLOR_MAP_TYPE.ORDINAL + ? NUMERICAL_COLOR_PALETTES + : CATEGORICAL_COLOR_PALETTES; + + const palettesWithCustom = [ { value: CUSTOM_COLOR_MAP, - inputDisplay: this.props.customOptionLabel, + title: + this.props.colorMapType === COLOR_MAP_TYPE.ORDINAL + ? i18n.translate('xpack.maps.style.customColorRampLabel', { + defaultMessage: 'Custom color ramp', + }) + : i18n.translate('xpack.maps.style.customColorPaletteLabel', { + defaultMessage: 'Custom color palette', + }), + type: 'text', 'data-test-subj': `colorMapSelectOption_${CUSTOM_COLOR_MAP}`, }, - ...this.props.colorMapOptions, + ...palettes, ]; - let valueOfSelected; - if (this.props.useCustomColorMap) { - valueOfSelected = CUSTOM_COLOR_MAP; - } else { - valueOfSelected = this.props.colorMapOptions.find( - (option) => option.value === this.props.color - ) - ? this.props.color - : ''; - } - const toggle = this.props.showColorMapTypeToggle ? ( {this._renderColorMapToggle()} ) : null; @@ -155,12 +164,13 @@ export class ColorMapSelect extends Component { {toggle} - diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/color/dynamic_color_form.js b/x-pack/plugins/maps/public/classes/styles/vector/components/color/dynamic_color_form.js index 90070343a1b48..1034e8f5d6525 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/color/dynamic_color_form.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/color/dynamic_color_form.js @@ -10,8 +10,6 @@ import { FieldSelect } from '../field_select'; import { ColorMapSelect } from './color_map_select'; import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { CATEGORICAL_DATA_TYPES, COLOR_MAP_TYPE } from '../../../../../../common/constants'; -import { COLOR_GRADIENTS, COLOR_PALETTES } from '../../../color_utils'; -import { i18n } from '@kbn/i18n'; export function DynamicColorForm({ fields, @@ -91,14 +89,10 @@ export function DynamicColorForm({ return ( { fieldMetaOptions, } as ColorDynamicOptions, } as ColorDynamicStylePropertyDescriptor; - expect(extractColorFromStyleProperty(colorStyleProperty, defaultColor)).toBe( - 'rgb(106,173,213)' - ); + expect(extractColorFromStyleProperty(colorStyleProperty, defaultColor)).toBe('#9eb9d8'); }); }); diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/legend/extract_color_from_style_property.ts b/x-pack/plugins/maps/public/classes/styles/vector/components/legend/extract_color_from_style_property.ts index dadb3f201fa33..4a3f45a929fd1 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/legend/extract_color_from_style_property.ts +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/legend/extract_color_from_style_property.ts @@ -4,8 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -// @ts-ignore -import { getColorRampCenterColor, getColorPalette } from '../../../color_utils'; +import { getColorRampCenterColor, getColorPalette } from '../../../color_palettes'; import { COLOR_MAP_TYPE, STYLE_TYPE } from '../../../../../../common/constants'; import { ColorDynamicOptions, diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.js b/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.js index 6528648eff552..53a3fc95adbeb 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.js @@ -15,7 +15,7 @@ import { VectorStyleLabelEditor } from './label/vector_style_label_editor'; import { VectorStyleLabelBorderSizeEditor } from './label/vector_style_label_border_size_editor'; import { OrientationEditor } from './orientation/orientation_editor'; import { getDefaultDynamicProperties, getDefaultStaticProperties } from '../vector_style_defaults'; -import { DEFAULT_FILL_COLORS, DEFAULT_LINE_COLORS } from '../../color_utils'; +import { DEFAULT_FILL_COLORS, DEFAULT_LINE_COLORS } from '../../color_palettes'; import { i18n } from '@kbn/i18n'; import { EuiSpacer, EuiButtonGroup, EuiFormRow, EuiSwitch } from '@elastic/eui'; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/__snapshots__/dynamic_color_property.test.js.snap b/x-pack/plugins/maps/public/classes/styles/vector/properties/__snapshots__/dynamic_color_property.test.js.snap index 29eb52897a50e..402eab355406b 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/__snapshots__/dynamic_color_property.test.js.snap +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/__snapshots__/dynamic_color_property.test.js.snap @@ -175,7 +175,7 @@ exports[`ordinal Should render only single band of last color when delta is 0 1` key="0" > { - const rawStopValue = rangeFieldMeta.min + rangeFieldMeta.delta * (index / GRADIENT_INTERVALS); + const rawStopValue = rangeFieldMeta.min + rangeFieldMeta.delta * (index / colors.length); return { color, stop: dynamicRound(rawStopValue), diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.test.js b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.test.js index 1879b260da2e2..7992ee5b3aeaf 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.test.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.test.js @@ -323,21 +323,21 @@ describe('get mapbox color expression (via internal _getMbColor)', () => { -1, 'rgba(0,0,0,0)', 0, - '#f7faff', + '#ecf1f7', 12.5, - '#ddeaf7', + '#d9e3ef', 25, - '#c5daee', + '#c5d5e7', 37.5, - '#9dc9e0', + '#b2c7df', 50, - '#6aadd5', + '#9eb9d8', 62.5, - '#4191c5', + '#8bacd0', 75, - '#2070b4', + '#769fc8', 87.5, - '#072f6b', + '#6092c0', ]); }); }); diff --git a/x-pack/plugins/maps/public/classes/styles/vector/vector_style_defaults.ts b/x-pack/plugins/maps/public/classes/styles/vector/vector_style_defaults.ts index a6878a0d760c7..a3ae80e0a5935 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/vector_style_defaults.ts +++ b/x-pack/plugins/maps/public/classes/styles/vector/vector_style_defaults.ts @@ -12,11 +12,11 @@ import { STYLE_TYPE, } from '../../../../common/constants'; import { - COLOR_GRADIENTS, - COLOR_PALETTES, DEFAULT_FILL_COLORS, DEFAULT_LINE_COLORS, -} from '../color_utils'; + NUMERICAL_COLOR_PALETTES, + CATEGORICAL_COLOR_PALETTES, +} from '../color_palettes'; import { VectorStylePropertiesDescriptor } from '../../../../common/descriptor_types'; // @ts-ignore import { getUiSettings } from '../../../kibana_services'; @@ -28,8 +28,8 @@ export const DEFAULT_MAX_SIZE = 32; export const DEFAULT_SIGMA = 3; export const DEFAULT_LABEL_SIZE = 14; export const DEFAULT_ICON_SIZE = 6; -export const DEFAULT_COLOR_RAMP = COLOR_GRADIENTS[0].value; -export const DEFAULT_COLOR_PALETTE = COLOR_PALETTES[0].value; +export const DEFAULT_COLOR_RAMP = NUMERICAL_COLOR_PALETTES[0].value; +export const DEFAULT_COLOR_PALETTE = CATEGORICAL_COLOR_PALETTES[0].value; export const LINE_STYLES = [VECTOR_STYLES.LINE_COLOR, VECTOR_STYLES.LINE_WIDTH]; export const POLYGON_STYLES = [ diff --git a/x-pack/test/functional/apps/maps/mapbox_styles.js b/x-pack/test/functional/apps/maps/mapbox_styles.js index 63bfc331d8886..744eb4ac74bf6 100644 --- a/x-pack/test/functional/apps/maps/mapbox_styles.js +++ b/x-pack/test/functional/apps/maps/mapbox_styles.js @@ -52,21 +52,21 @@ export const MAPBOX_STYLES = { 2, 'rgba(0,0,0,0)', 3, - '#f7faff', + '#ecf1f7', 4.125, - '#ddeaf7', + '#d9e3ef', 5.25, - '#c5daee', + '#c5d5e7', 6.375, - '#9dc9e0', + '#b2c7df', 7.5, - '#6aadd5', + '#9eb9d8', 8.625, - '#4191c5', + '#8bacd0', 9.75, - '#2070b4', + '#769fc8', 10.875, - '#072f6b', + '#6092c0', ], 'circle-opacity': 0.75, 'circle-stroke-color': '#41937c', @@ -122,21 +122,21 @@ export const MAPBOX_STYLES = { 2, 'rgba(0,0,0,0)', 3, - '#f7faff', + '#ecf1f7', 4.125, - '#ddeaf7', + '#d9e3ef', 5.25, - '#c5daee', + '#c5d5e7', 6.375, - '#9dc9e0', + '#b2c7df', 7.5, - '#6aadd5', + '#9eb9d8', 8.625, - '#4191c5', + '#8bacd0', 9.75, - '#2070b4', + '#769fc8', 10.875, - '#072f6b', + '#6092c0', ], 'fill-opacity': 0.75, }, From e51b92de325409818f69c1cefd91354f4be7e5dc Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Mon, 13 Jul 2020 12:17:16 -0700 Subject: [PATCH 33/40] Fix fleet back link copy (#71488) --- .../ingest_manager/sections/fleet/agent_details_page/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/index.tsx index 15086879ce80b..ae9b1e1f6f433 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/index.tsx @@ -86,7 +86,7 @@ export const AgentDetailsPage: React.FunctionComponent = () => { > From 0ea414c13a458d521b5ac9f3b181e12396837009 Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Mon, 13 Jul 2020 22:26:34 +0300 Subject: [PATCH 34/40] [KP] Separate onPreAuth & onPreRouting http interceptors (#70775) Co-authored-by: Aleh Zasypkin Co-authored-by: Josh Dover --- ...ana-plugin-core-server.httpservicesetup.md | 5 +- ...ver.httpservicesetup.registeronpostauth.md | 4 +- ...rver.httpservicesetup.registeronpreauth.md | 4 +- ...r.httpservicesetup.registeronprerouting.md | 18 + .../core/server/kibana-plugin-core-server.md | 6 +- ...ana-plugin-core-server.onpreauthtoolkit.md | 1 - ...core-server.onpreauthtoolkit.rewriteurl.md | 13 - ...plugin-core-server.onpreresponsehandler.md | 2 +- ...plugin-core-server.onpreresponsetoolkit.md | 2 +- ...-plugin-core-server.onpreroutinghandler.md | 13 + ...-plugin-core-server.onpreroutingtoolkit.md | 21 ++ ...in-core-server.onpreroutingtoolkit.next.md | 13 + ...e-server.onpreroutingtoolkit.rewriteurl.md | 13 + src/core/server/http/http_server.mocks.ts | 4 +- src/core/server/http/http_server.test.ts | 10 + src/core/server/http/http_server.ts | 34 +- src/core/server/http/http_service.mock.ts | 8 +- src/core/server/http/index.ts | 3 +- .../integration_tests/core_services.test.ts | 2 +- .../http/integration_tests/lifecycle.test.ts | 318 +++++++++++++++++- src/core/server/http/lifecycle/on_pre_auth.ts | 28 +- .../server/http/lifecycle/on_pre_response.ts | 4 +- .../server/http/lifecycle/on_pre_routing.ts | 125 +++++++ src/core/server/http/types.ts | 28 +- src/core/server/index.ts | 2 + src/core/server/legacy/legacy_service.ts | 1 + src/core/server/plugins/plugin_context.ts | 1 + src/core/server/server.api.md | 13 +- .../on_request_interceptor.ts | 6 +- 29 files changed, 605 insertions(+), 97 deletions(-) create mode 100644 docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronprerouting.md delete mode 100644 docs/development/core/server/kibana-plugin-core-server.onpreauthtoolkit.rewriteurl.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.onpreroutinghandler.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.onpreroutingtoolkit.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.onpreroutingtoolkit.next.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.onpreroutingtoolkit.rewriteurl.md create mode 100644 src/core/server/http/lifecycle/on_pre_routing.ts diff --git a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.md b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.md index b12983836d9e5..474dc6b7d6f28 100644 --- a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.md +++ b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.md @@ -88,8 +88,9 @@ async (context, request, response) => { | [csp](./kibana-plugin-core-server.httpservicesetup.csp.md) | ICspConfig | The CSP config used for Kibana. | | [getServerInfo](./kibana-plugin-core-server.httpservicesetup.getserverinfo.md) | () => HttpServerInfo | Provides common [information](./kibana-plugin-core-server.httpserverinfo.md) about the running http server. | | [registerAuth](./kibana-plugin-core-server.httpservicesetup.registerauth.md) | (handler: AuthenticationHandler) => void | To define custom authentication and/or authorization mechanism for incoming requests. | -| [registerOnPostAuth](./kibana-plugin-core-server.httpservicesetup.registeronpostauth.md) | (handler: OnPostAuthHandler) => void | To define custom logic to perform for incoming requests. | -| [registerOnPreAuth](./kibana-plugin-core-server.httpservicesetup.registeronpreauth.md) | (handler: OnPreAuthHandler) => void | To define custom logic to perform for incoming requests. | +| [registerOnPostAuth](./kibana-plugin-core-server.httpservicesetup.registeronpostauth.md) | (handler: OnPostAuthHandler) => void | To define custom logic after Auth interceptor did make sure a user has access to the requested resource. | +| [registerOnPreAuth](./kibana-plugin-core-server.httpservicesetup.registeronpreauth.md) | (handler: OnPreAuthHandler) => void | To define custom logic to perform for incoming requests before the Auth interceptor performs a check that user has access to requested resources. | | [registerOnPreResponse](./kibana-plugin-core-server.httpservicesetup.registeronpreresponse.md) | (handler: OnPreResponseHandler) => void | To define custom logic to perform for the server response. | +| [registerOnPreRouting](./kibana-plugin-core-server.httpservicesetup.registeronprerouting.md) | (handler: OnPreRoutingHandler) => void | To define custom logic to perform for incoming requests before server performs a route lookup. | | [registerRouteHandlerContext](./kibana-plugin-core-server.httpservicesetup.registerroutehandlercontext.md) | <T extends keyof RequestHandlerContext>(contextName: T, provider: RequestHandlerContextProvider<T>) => RequestHandlerContextContainer | Register a context provider for a route handler. | diff --git a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronpostauth.md b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronpostauth.md index 01294693e282f..eff53b7b75fa5 100644 --- a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronpostauth.md +++ b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronpostauth.md @@ -4,7 +4,7 @@ ## HttpServiceSetup.registerOnPostAuth property -To define custom logic to perform for incoming requests. +To define custom logic after Auth interceptor did make sure a user has access to the requested resource. Signature: @@ -14,5 +14,5 @@ registerOnPostAuth: (handler: OnPostAuthHandler) => void; ## Remarks -Runs the handler after Auth interceptor did make sure a user has access to the requested resource. The auth state is available at stage via http.auth.get(..) Can register any number of registerOnPreAuth, which are called in sequence (from the first registered to the last). See [OnPostAuthHandler](./kibana-plugin-core-server.onpostauthhandler.md). +The auth state is available at stage via http.auth.get(..) Can register any number of registerOnPreRouting, which are called in sequence (from the first registered to the last). See [OnPostAuthHandler](./kibana-plugin-core-server.onpostauthhandler.md). diff --git a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronpreauth.md b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronpreauth.md index f11453c8cda98..ce4cacb1c8749 100644 --- a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronpreauth.md +++ b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronpreauth.md @@ -4,7 +4,7 @@ ## HttpServiceSetup.registerOnPreAuth property -To define custom logic to perform for incoming requests. +To define custom logic to perform for incoming requests before the Auth interceptor performs a check that user has access to requested resources. Signature: @@ -14,5 +14,5 @@ registerOnPreAuth: (handler: OnPreAuthHandler) => void; ## Remarks -Runs the handler before Auth interceptor performs a check that user has access to requested resources, so it's the only place when you can forward a request to another URL right on the server. Can register any number of registerOnPostAuth, which are called in sequence (from the first registered to the last). See [OnPreAuthHandler](./kibana-plugin-core-server.onpreauthhandler.md). +Can register any number of registerOnPostAuth, which are called in sequence (from the first registered to the last). See [OnPreRoutingHandler](./kibana-plugin-core-server.onpreroutinghandler.md). diff --git a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronprerouting.md b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronprerouting.md new file mode 100644 index 0000000000000..bdf5f15828669 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronprerouting.md @@ -0,0 +1,18 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [HttpServiceSetup](./kibana-plugin-core-server.httpservicesetup.md) > [registerOnPreRouting](./kibana-plugin-core-server.httpservicesetup.registeronprerouting.md) + +## HttpServiceSetup.registerOnPreRouting property + +To define custom logic to perform for incoming requests before server performs a route lookup. + +Signature: + +```typescript +registerOnPreRouting: (handler: OnPreRoutingHandler) => void; +``` + +## Remarks + +It's the only place when you can forward a request to another URL right on the server. Can register any number of registerOnPreRouting, which are called in sequence (from the first registered to the last). See [OnPreRoutingHandler](./kibana-plugin-core-server.onpreroutinghandler.md). + diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index 8d4c0c915437e..a665327454c1a 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -122,7 +122,8 @@ The plugin integrates with the core system via lifecycle events: `setup` | [OnPreAuthToolkit](./kibana-plugin-core-server.onpreauthtoolkit.md) | A tool set defining an outcome of OnPreAuth interceptor for incoming request. | | [OnPreResponseExtensions](./kibana-plugin-core-server.onpreresponseextensions.md) | Additional data to extend a response. | | [OnPreResponseInfo](./kibana-plugin-core-server.onpreresponseinfo.md) | Response status code. | -| [OnPreResponseToolkit](./kibana-plugin-core-server.onpreresponsetoolkit.md) | A tool set defining an outcome of OnPreAuth interceptor for incoming request. | +| [OnPreResponseToolkit](./kibana-plugin-core-server.onpreresponsetoolkit.md) | A tool set defining an outcome of OnPreRouting interceptor for incoming request. | +| [OnPreRoutingToolkit](./kibana-plugin-core-server.onpreroutingtoolkit.md) | A tool set defining an outcome of OnPreRouting interceptor for incoming request. | | [OpsMetrics](./kibana-plugin-core-server.opsmetrics.md) | Regroups metrics gathered by all the collectors. This contains metrics about the os/runtime, the kibana process and the http server. | | [OpsOsMetrics](./kibana-plugin-core-server.opsosmetrics.md) | OS related metrics | | [OpsProcessMetrics](./kibana-plugin-core-server.opsprocessmetrics.md) | Process related metrics | @@ -256,7 +257,8 @@ The plugin integrates with the core system via lifecycle events: `setup` | [MutatingOperationRefreshSetting](./kibana-plugin-core-server.mutatingoperationrefreshsetting.md) | Elasticsearch Refresh setting for mutating operation | | [OnPostAuthHandler](./kibana-plugin-core-server.onpostauthhandler.md) | See [OnPostAuthToolkit](./kibana-plugin-core-server.onpostauthtoolkit.md). | | [OnPreAuthHandler](./kibana-plugin-core-server.onpreauthhandler.md) | See [OnPreAuthToolkit](./kibana-plugin-core-server.onpreauthtoolkit.md). | -| [OnPreResponseHandler](./kibana-plugin-core-server.onpreresponsehandler.md) | See [OnPreAuthToolkit](./kibana-plugin-core-server.onpreauthtoolkit.md). | +| [OnPreResponseHandler](./kibana-plugin-core-server.onpreresponsehandler.md) | See [OnPreRoutingToolkit](./kibana-plugin-core-server.onpreroutingtoolkit.md). | +| [OnPreRoutingHandler](./kibana-plugin-core-server.onpreroutinghandler.md) | See [OnPreRoutingToolkit](./kibana-plugin-core-server.onpreroutingtoolkit.md). | | [PluginConfigSchema](./kibana-plugin-core-server.pluginconfigschema.md) | Dedicated type for plugin configuration schema. | | [PluginInitializer](./kibana-plugin-core-server.plugininitializer.md) | The plugin export at the root of a plugin's server directory should conform to this interface. | | [PluginName](./kibana-plugin-core-server.pluginname.md) | Dedicated type for plugin name/id that is supposed to make Map/Set/Arrays that use it as a key or value more obvious. | diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreauthtoolkit.md b/docs/development/core/server/kibana-plugin-core-server.onpreauthtoolkit.md index 4097cb32c397a..8031dbc64fa6d 100644 --- a/docs/development/core/server/kibana-plugin-core-server.onpreauthtoolkit.md +++ b/docs/development/core/server/kibana-plugin-core-server.onpreauthtoolkit.md @@ -17,5 +17,4 @@ export interface OnPreAuthToolkit | Property | Type | Description | | --- | --- | --- | | [next](./kibana-plugin-core-server.onpreauthtoolkit.next.md) | () => OnPreAuthResult | To pass request to the next handler | -| [rewriteUrl](./kibana-plugin-core-server.onpreauthtoolkit.rewriteurl.md) | (url: string) => OnPreAuthResult | Rewrite requested resources url before is was authenticated and routed to a handler | diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreauthtoolkit.rewriteurl.md b/docs/development/core/server/kibana-plugin-core-server.onpreauthtoolkit.rewriteurl.md deleted file mode 100644 index 7ecde62f88302..0000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.onpreauthtoolkit.rewriteurl.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [OnPreAuthToolkit](./kibana-plugin-core-server.onpreauthtoolkit.md) > [rewriteUrl](./kibana-plugin-core-server.onpreauthtoolkit.rewriteurl.md) - -## OnPreAuthToolkit.rewriteUrl property - -Rewrite requested resources url before is was authenticated and routed to a handler - -Signature: - -```typescript -rewriteUrl: (url: string) => OnPreAuthResult; -``` diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreresponsehandler.md b/docs/development/core/server/kibana-plugin-core-server.onpreresponsehandler.md index e7eab8ee34d6f..10696fb79a2f6 100644 --- a/docs/development/core/server/kibana-plugin-core-server.onpreresponsehandler.md +++ b/docs/development/core/server/kibana-plugin-core-server.onpreresponsehandler.md @@ -4,7 +4,7 @@ ## OnPreResponseHandler type -See [OnPreAuthToolkit](./kibana-plugin-core-server.onpreauthtoolkit.md). +See [OnPreRoutingToolkit](./kibana-plugin-core-server.onpreroutingtoolkit.md). Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.md b/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.md index 8e33e945b4ef9..306c375ba4a3c 100644 --- a/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.md +++ b/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.md @@ -4,7 +4,7 @@ ## OnPreResponseToolkit interface -A tool set defining an outcome of OnPreAuth interceptor for incoming request. +A tool set defining an outcome of OnPreRouting interceptor for incoming request. Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreroutinghandler.md b/docs/development/core/server/kibana-plugin-core-server.onpreroutinghandler.md new file mode 100644 index 0000000000000..46016bcd5476a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.onpreroutinghandler.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [OnPreRoutingHandler](./kibana-plugin-core-server.onpreroutinghandler.md) + +## OnPreRoutingHandler type + +See [OnPreRoutingToolkit](./kibana-plugin-core-server.onpreroutingtoolkit.md). + +Signature: + +```typescript +export declare type OnPreRoutingHandler = (request: KibanaRequest, response: LifecycleResponseFactory, toolkit: OnPreRoutingToolkit) => OnPreRoutingResult | KibanaResponse | Promise; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreroutingtoolkit.md b/docs/development/core/server/kibana-plugin-core-server.onpreroutingtoolkit.md new file mode 100644 index 0000000000000..c564896b46a27 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.onpreroutingtoolkit.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [OnPreRoutingToolkit](./kibana-plugin-core-server.onpreroutingtoolkit.md) + +## OnPreRoutingToolkit interface + +A tool set defining an outcome of OnPreRouting interceptor for incoming request. + +Signature: + +```typescript +export interface OnPreRoutingToolkit +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [next](./kibana-plugin-core-server.onpreroutingtoolkit.next.md) | () => OnPreRoutingResult | To pass request to the next handler | +| [rewriteUrl](./kibana-plugin-core-server.onpreroutingtoolkit.rewriteurl.md) | (url: string) => OnPreRoutingResult | Rewrite requested resources url before is was authenticated and routed to a handler | + diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreroutingtoolkit.next.md b/docs/development/core/server/kibana-plugin-core-server.onpreroutingtoolkit.next.md new file mode 100644 index 0000000000000..7fb0b2ce67ba5 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.onpreroutingtoolkit.next.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [OnPreRoutingToolkit](./kibana-plugin-core-server.onpreroutingtoolkit.md) > [next](./kibana-plugin-core-server.onpreroutingtoolkit.next.md) + +## OnPreRoutingToolkit.next property + +To pass request to the next handler + +Signature: + +```typescript +next: () => OnPreRoutingResult; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreroutingtoolkit.rewriteurl.md b/docs/development/core/server/kibana-plugin-core-server.onpreroutingtoolkit.rewriteurl.md new file mode 100644 index 0000000000000..346a12711c723 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.onpreroutingtoolkit.rewriteurl.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [OnPreRoutingToolkit](./kibana-plugin-core-server.onpreroutingtoolkit.md) > [rewriteUrl](./kibana-plugin-core-server.onpreroutingtoolkit.rewriteurl.md) + +## OnPreRoutingToolkit.rewriteUrl property + +Rewrite requested resources url before is was authenticated and routed to a handler + +Signature: + +```typescript +rewriteUrl: (url: string) => OnPreRoutingResult; +``` diff --git a/src/core/server/http/http_server.mocks.ts b/src/core/server/http/http_server.mocks.ts index bbef0a105c089..7d37af833d4c1 100644 --- a/src/core/server/http/http_server.mocks.ts +++ b/src/core/server/http/http_server.mocks.ts @@ -33,7 +33,7 @@ import { } from './router'; import { OnPreResponseToolkit } from './lifecycle/on_pre_response'; import { OnPostAuthToolkit } from './lifecycle/on_post_auth'; -import { OnPreAuthToolkit } from './lifecycle/on_pre_auth'; +import { OnPreRoutingToolkit } from './lifecycle/on_pre_routing'; interface RequestFixtureOptions

{ auth?: { isAuthenticated: boolean }; @@ -161,7 +161,7 @@ const createLifecycleResponseFactoryMock = (): jest.Mocked; +type ToolkitMock = jest.Mocked; const createToolkitMock = (): ToolkitMock => { return { diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts index 72cb0b2821c5c..601eba835a54e 100644 --- a/src/core/server/http/http_server.test.ts +++ b/src/core/server/http/http_server.test.ts @@ -1089,6 +1089,16 @@ describe('setup contract', () => { }); }); + describe('#registerOnPreRouting', () => { + test('does not throw if called after stop', async () => { + const { registerOnPreRouting } = await server.setup(config); + await server.stop(); + expect(() => { + registerOnPreRouting((req, res) => res.unauthorized()); + }).not.toThrow(); + }); + }); + describe('#registerOnPreAuth', () => { test('does not throw if called after stop', async () => { const { registerOnPreAuth } = await server.setup(config); diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index 1abf5c0c133bb..9c16162d69334 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -24,8 +24,9 @@ import { Logger, LoggerFactory } from '../logging'; import { HttpConfig } from './http_config'; import { createServer, getListenerOptions, getServerOptions } from './http_tools'; import { adoptToHapiAuthFormat, AuthenticationHandler } from './lifecycle/auth'; +import { adoptToHapiOnPreAuth, OnPreAuthHandler } from './lifecycle/on_pre_auth'; import { adoptToHapiOnPostAuthFormat, OnPostAuthHandler } from './lifecycle/on_post_auth'; -import { adoptToHapiOnPreAuthFormat, OnPreAuthHandler } from './lifecycle/on_pre_auth'; +import { adoptToHapiOnRequest, OnPreRoutingHandler } from './lifecycle/on_pre_routing'; import { adoptToHapiOnPreResponseFormat, OnPreResponseHandler } from './lifecycle/on_pre_response'; import { IRouter, RouteConfigOptions, KibanaRouteState, isSafeMethod } from './router'; import { @@ -49,8 +50,9 @@ export interface HttpServerSetup { basePath: HttpServiceSetup['basePath']; csp: HttpServiceSetup['csp']; createCookieSessionStorageFactory: HttpServiceSetup['createCookieSessionStorageFactory']; - registerAuth: HttpServiceSetup['registerAuth']; + registerOnPreRouting: HttpServiceSetup['registerOnPreRouting']; registerOnPreAuth: HttpServiceSetup['registerOnPreAuth']; + registerAuth: HttpServiceSetup['registerAuth']; registerOnPostAuth: HttpServiceSetup['registerOnPostAuth']; registerOnPreResponse: HttpServiceSetup['registerOnPreResponse']; getAuthHeaders: GetAuthHeaders; @@ -64,7 +66,11 @@ export interface HttpServerSetup { /** @internal */ export type LifecycleRegistrar = Pick< HttpServerSetup, - 'registerAuth' | 'registerOnPreAuth' | 'registerOnPostAuth' | 'registerOnPreResponse' + | 'registerOnPreRouting' + | 'registerOnPreAuth' + | 'registerAuth' + | 'registerOnPostAuth' + | 'registerOnPreResponse' >; export class HttpServer { @@ -113,12 +119,13 @@ export class HttpServer { return { registerRouter: this.registerRouter.bind(this), registerStaticDir: this.registerStaticDir.bind(this), + registerOnPreRouting: this.registerOnPreRouting.bind(this), registerOnPreAuth: this.registerOnPreAuth.bind(this), + registerAuth: this.registerAuth.bind(this), registerOnPostAuth: this.registerOnPostAuth.bind(this), registerOnPreResponse: this.registerOnPreResponse.bind(this), createCookieSessionStorageFactory: (cookieOptions: SessionStorageCookieOptions) => this.createCookieSessionStorageFactory(cookieOptions, config.basePath), - registerAuth: this.registerAuth.bind(this), basePath: basePathService, csp: config.csp, auth: { @@ -222,7 +229,7 @@ export class HttpServer { return; } - this.registerOnPreAuth((request, response, toolkit) => { + this.registerOnPreRouting((request, response, toolkit) => { const oldUrl = request.url.href!; const newURL = basePathService.remove(oldUrl); const shouldRedirect = newURL !== oldUrl; @@ -263,6 +270,17 @@ export class HttpServer { } } + private registerOnPreAuth(fn: OnPreAuthHandler) { + if (this.server === undefined) { + throw new Error('Server is not created yet'); + } + if (this.stopped) { + this.log.warn(`registerOnPreAuth called after stop`); + } + + this.server.ext('onPreAuth', adoptToHapiOnPreAuth(fn, this.log)); + } + private registerOnPostAuth(fn: OnPostAuthHandler) { if (this.server === undefined) { throw new Error('Server is not created yet'); @@ -274,15 +292,15 @@ export class HttpServer { this.server.ext('onPostAuth', adoptToHapiOnPostAuthFormat(fn, this.log)); } - private registerOnPreAuth(fn: OnPreAuthHandler) { + private registerOnPreRouting(fn: OnPreRoutingHandler) { if (this.server === undefined) { throw new Error('Server is not created yet'); } if (this.stopped) { - this.log.warn(`registerOnPreAuth called after stop`); + this.log.warn(`registerOnPreRouting called after stop`); } - this.server.ext('onRequest', adoptToHapiOnPreAuthFormat(fn, this.log)); + this.server.ext('onRequest', adoptToHapiOnRequest(fn, this.log)); } private registerOnPreResponse(fn: OnPreResponseHandler) { diff --git a/src/core/server/http/http_service.mock.ts b/src/core/server/http/http_service.mock.ts index 5e7ee7b658eca..51f11b15f2e09 100644 --- a/src/core/server/http/http_service.mock.ts +++ b/src/core/server/http/http_service.mock.ts @@ -29,7 +29,7 @@ import { } from './types'; import { HttpService } from './http_service'; import { AuthStatus } from './auth_state_storage'; -import { OnPreAuthToolkit } from './lifecycle/on_pre_auth'; +import { OnPreRoutingToolkit } from './lifecycle/on_pre_routing'; import { AuthToolkit } from './lifecycle/auth'; import { sessionStorageMock } from './cookie_session_storage.mocks'; import { OnPostAuthToolkit } from './lifecycle/on_post_auth'; @@ -87,6 +87,7 @@ const createInternalSetupContractMock = () => { config: jest.fn().mockReturnValue(configMock.create()), } as unknown) as jest.MockedClass, createCookieSessionStorageFactory: jest.fn(), + registerOnPreRouting: jest.fn(), registerOnPreAuth: jest.fn(), registerAuth: jest.fn(), registerOnPostAuth: jest.fn(), @@ -117,7 +118,8 @@ const createSetupContractMock = () => { const mock: HttpServiceSetupMock = { createCookieSessionStorageFactory: internalMock.createCookieSessionStorageFactory, - registerOnPreAuth: internalMock.registerOnPreAuth, + registerOnPreRouting: internalMock.registerOnPreRouting, + registerOnPreAuth: jest.fn(), registerAuth: internalMock.registerAuth, registerOnPostAuth: internalMock.registerOnPostAuth, registerOnPreResponse: internalMock.registerOnPreResponse, @@ -173,7 +175,7 @@ const createHttpServiceMock = () => { return mocked; }; -const createOnPreAuthToolkitMock = (): jest.Mocked => ({ +const createOnPreAuthToolkitMock = (): jest.Mocked => ({ next: jest.fn(), rewriteUrl: jest.fn(), }); diff --git a/src/core/server/http/index.ts b/src/core/server/http/index.ts index 65d633260a791..e91f7d9375842 100644 --- a/src/core/server/http/index.ts +++ b/src/core/server/http/index.ts @@ -64,7 +64,7 @@ export { SafeRouteMethod, } from './router'; export { BasePathProxyServer } from './base_path_proxy_server'; -export { OnPreAuthHandler, OnPreAuthToolkit } from './lifecycle/on_pre_auth'; +export { OnPreRoutingHandler, OnPreRoutingToolkit } from './lifecycle/on_pre_routing'; export { AuthenticationHandler, AuthHeaders, @@ -78,6 +78,7 @@ export { AuthResultType, } from './lifecycle/auth'; export { OnPostAuthHandler, OnPostAuthToolkit } from './lifecycle/on_post_auth'; +export { OnPreAuthHandler, OnPreAuthToolkit } from './lifecycle/on_pre_auth'; export { OnPreResponseHandler, OnPreResponseToolkit, diff --git a/src/core/server/http/integration_tests/core_services.test.ts b/src/core/server/http/integration_tests/core_services.test.ts index 0ee53a04d9f87..3c5f22500e5e0 100644 --- a/src/core/server/http/integration_tests/core_services.test.ts +++ b/src/core/server/http/integration_tests/core_services.test.ts @@ -337,7 +337,7 @@ describe('http service', () => { it('basePath information for an incoming request is available in legacy server', async () => { const reqBasePath = '/requests-specific-base-path'; const { http } = await root.setup(); - http.registerOnPreAuth((req, res, toolkit) => { + http.registerOnPreRouting((req, res, toolkit) => { http.basePath.set(req, reqBasePath); return toolkit.next(); }); diff --git a/src/core/server/http/integration_tests/lifecycle.test.ts b/src/core/server/http/integration_tests/lifecycle.test.ts index cbab14115ba6b..b9548bf7a8d70 100644 --- a/src/core/server/http/integration_tests/lifecycle.test.ts +++ b/src/core/server/http/integration_tests/lifecycle.test.ts @@ -57,20 +57,22 @@ interface StorageData { expires: number; } -describe('OnPreAuth', () => { +describe('OnPreRouting', () => { it('supports registering a request interceptor', async () => { - const { registerOnPreAuth, server: innerServer, createRouter } = await server.setup(setupDeps); + const { registerOnPreRouting, server: innerServer, createRouter } = await server.setup( + setupDeps + ); const router = createRouter('/'); router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: 'ok' })); const callingOrder: string[] = []; - registerOnPreAuth((req, res, t) => { + registerOnPreRouting((req, res, t) => { callingOrder.push('first'); return t.next(); }); - registerOnPreAuth((req, res, t) => { + registerOnPreRouting((req, res, t) => { callingOrder.push('second'); return t.next(); }); @@ -82,7 +84,9 @@ describe('OnPreAuth', () => { }); it('supports request forwarding to specified url', async () => { - const { registerOnPreAuth, server: innerServer, createRouter } = await server.setup(setupDeps); + const { registerOnPreRouting, server: innerServer, createRouter } = await server.setup( + setupDeps + ); const router = createRouter('/'); router.get({ path: '/initial', validate: false }, (context, req, res) => @@ -93,13 +97,13 @@ describe('OnPreAuth', () => { ); let urlBeforeForwarding; - registerOnPreAuth((req, res, t) => { + registerOnPreRouting((req, res, t) => { urlBeforeForwarding = ensureRawRequest(req).raw.req.url; return t.rewriteUrl('/redirectUrl'); }); let urlAfterForwarding; - registerOnPreAuth((req, res, t) => { + registerOnPreRouting((req, res, t) => { // used by legacy platform urlAfterForwarding = ensureRawRequest(req).raw.req.url; return t.next(); @@ -113,6 +117,152 @@ describe('OnPreAuth', () => { expect(urlAfterForwarding).toBe('/redirectUrl'); }); + it('supports redirection from the interceptor', async () => { + const { registerOnPreRouting, server: innerServer, createRouter } = await server.setup( + setupDeps + ); + const router = createRouter('/'); + + const redirectUrl = '/redirectUrl'; + router.get({ path: '/initial', validate: false }, (context, req, res) => res.ok()); + + registerOnPreRouting((req, res, t) => + res.redirected({ + headers: { + location: redirectUrl, + }, + }) + ); + await server.start(); + + const result = await supertest(innerServer.listener).get('/initial').expect(302); + + expect(result.header.location).toBe(redirectUrl); + }); + + it('supports rejecting request and adjusting response headers', async () => { + const { registerOnPreRouting, server: innerServer, createRouter } = await server.setup( + setupDeps + ); + const router = createRouter('/'); + + router.get({ path: '/', validate: false }, (context, req, res) => res.ok()); + + registerOnPreRouting((req, res, t) => + res.unauthorized({ + headers: { + 'www-authenticate': 'challenge', + }, + }) + ); + await server.start(); + + const result = await supertest(innerServer.listener).get('/').expect(401); + + expect(result.header['www-authenticate']).toBe('challenge'); + }); + + it('does not expose error details if interceptor throws', async () => { + const { registerOnPreRouting, server: innerServer, createRouter } = await server.setup( + setupDeps + ); + const router = createRouter('/'); + + router.get({ path: '/', validate: false }, (context, req, res) => res.ok()); + + registerOnPreRouting((req, res, t) => { + throw new Error('reason'); + }); + await server.start(); + + const result = await supertest(innerServer.listener).get('/').expect(500); + + expect(result.body.message).toBe('An internal server error occurred.'); + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` + Array [ + Array [ + [Error: reason], + ], + ] + `); + }); + + it('returns internal error if interceptor returns unexpected result', async () => { + const { registerOnPreRouting, server: innerServer, createRouter } = await server.setup( + setupDeps + ); + const router = createRouter('/'); + + router.get({ path: '/', validate: false }, (context, req, res) => res.ok()); + + registerOnPreRouting((req, res, t) => ({} as any)); + await server.start(); + + const result = await supertest(innerServer.listener).get('/').expect(500); + + expect(result.body.message).toBe('An internal server error occurred.'); + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` + Array [ + Array [ + [Error: Unexpected result from OnPreRouting. Expected OnPreRoutingResult or KibanaResponse, but given: [object Object].], + ], + ] + `); + }); + + it(`doesn't share request object between interceptors`, async () => { + const { registerOnPreRouting, server: innerServer, createRouter } = await server.setup( + setupDeps + ); + const router = createRouter('/'); + + registerOnPreRouting((req, res, t) => { + // don't complain customField is not defined on Request type + (req as any).customField = { value: 42 }; + return t.next(); + }); + registerOnPreRouting((req, res, t) => { + // don't complain customField is not defined on Request type + if (typeof (req as any).customField !== 'undefined') { + throw new Error('Request object was mutated'); + } + return t.next(); + }); + router.get({ path: '/', validate: false }, (context, req, res) => + // don't complain customField is not defined on Request type + res.ok({ body: { customField: String((req as any).customField) } }) + ); + + await server.start(); + + await supertest(innerServer.listener).get('/').expect(200, { customField: 'undefined' }); + }); +}); + +describe('OnPreAuth', () => { + it('supports registering a request interceptor', async () => { + const { registerOnPreAuth, server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: 'ok' })); + + const callingOrder: string[] = []; + registerOnPreAuth((req, res, t) => { + callingOrder.push('first'); + return t.next(); + }); + + registerOnPreAuth((req, res, t) => { + callingOrder.push('second'); + return t.next(); + }); + await server.start(); + + await supertest(innerServer.listener).get('/').expect(200, 'ok'); + + expect(callingOrder).toEqual(['first', 'second']); + }); + it('supports redirection from the interceptor', async () => { const { registerOnPreAuth, server: innerServer, createRouter } = await server.setup(setupDeps); const router = createRouter('/'); @@ -203,20 +353,20 @@ describe('OnPreAuth', () => { const router = createRouter('/'); registerOnPreAuth((req, res, t) => { - // don't complain customField is not defined on Request type - (req as any).customField = { value: 42 }; + // @ts-expect-error customField property is not defined on request object + req.customField = { value: 42 }; return t.next(); }); registerOnPreAuth((req, res, t) => { - // don't complain customField is not defined on Request type - if (typeof (req as any).customField !== 'undefined') { + // @ts-expect-error customField property is not defined on request object + if (typeof req.customField !== 'undefined') { throw new Error('Request object was mutated'); } return t.next(); }); router.get({ path: '/', validate: false }, (context, req, res) => - // don't complain customField is not defined on Request type - res.ok({ body: { customField: String((req as any).customField) } }) + // @ts-expect-error customField property is not defined on request object + res.ok({ body: { customField: String(req.customField) } }) ); await server.start(); @@ -664,7 +814,7 @@ describe('Auth', () => { it.skip('is the only place with access to the authorization header', async () => { const { - registerOnPreAuth, + registerOnPreRouting, registerAuth, registerOnPostAuth, server: innerServer, @@ -672,9 +822,9 @@ describe('Auth', () => { } = await server.setup(setupDeps); const router = createRouter('/'); - let fromRegisterOnPreAuth; - await registerOnPreAuth((req, res, toolkit) => { - fromRegisterOnPreAuth = req.headers.authorization; + let fromregisterOnPreRouting; + await registerOnPreRouting((req, res, toolkit) => { + fromregisterOnPreRouting = req.headers.authorization; return toolkit.next(); }); @@ -701,7 +851,7 @@ describe('Auth', () => { const token = 'Basic: user:password'; await supertest(innerServer.listener).get('/').set('Authorization', token).expect(200); - expect(fromRegisterOnPreAuth).toEqual({}); + expect(fromregisterOnPreRouting).toEqual({}); expect(fromRegisterAuth).toEqual({ authorization: token }); expect(fromRegisterOnPostAuth).toEqual({}); expect(fromRouteHandler).toEqual({}); @@ -1137,3 +1287,135 @@ describe('OnPreResponse', () => { expect(requestBody).toStrictEqual({}); }); }); + +describe('run interceptors in the right order', () => { + it('with Auth registered', async () => { + const { + registerOnPreRouting, + registerOnPreAuth, + registerAuth, + registerOnPostAuth, + registerOnPreResponse, + server: innerServer, + createRouter, + } = await server.setup(setupDeps); + + const router = createRouter('/'); + + const executionOrder: string[] = []; + registerOnPreRouting((req, res, t) => { + executionOrder.push('onPreRouting'); + return t.next(); + }); + registerOnPreAuth((req, res, t) => { + executionOrder.push('onPreAuth'); + return t.next(); + }); + registerAuth((req, res, t) => { + executionOrder.push('auth'); + return t.authenticated({}); + }); + registerOnPostAuth((req, res, t) => { + executionOrder.push('onPostAuth'); + return t.next(); + }); + registerOnPreResponse((req, res, t) => { + executionOrder.push('onPreResponse'); + return t.next(); + }); + + router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: 'ok' })); + + await server.start(); + + await supertest(innerServer.listener).get('/').expect(200); + expect(executionOrder).toEqual([ + 'onPreRouting', + 'onPreAuth', + 'auth', + 'onPostAuth', + 'onPreResponse', + ]); + }); + + it('with no Auth registered', async () => { + const { + registerOnPreRouting, + registerOnPreAuth, + registerOnPostAuth, + registerOnPreResponse, + server: innerServer, + createRouter, + } = await server.setup(setupDeps); + + const router = createRouter('/'); + + const executionOrder: string[] = []; + registerOnPreRouting((req, res, t) => { + executionOrder.push('onPreRouting'); + return t.next(); + }); + registerOnPreAuth((req, res, t) => { + executionOrder.push('onPreAuth'); + return t.next(); + }); + registerOnPostAuth((req, res, t) => { + executionOrder.push('onPostAuth'); + return t.next(); + }); + registerOnPreResponse((req, res, t) => { + executionOrder.push('onPreResponse'); + return t.next(); + }); + + router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: 'ok' })); + + await server.start(); + + await supertest(innerServer.listener).get('/').expect(200); + expect(executionOrder).toEqual(['onPreRouting', 'onPreAuth', 'onPostAuth', 'onPreResponse']); + }); + + it('when a user failed auth', async () => { + const { + registerOnPreRouting, + registerOnPreAuth, + registerOnPostAuth, + registerAuth, + registerOnPreResponse, + server: innerServer, + createRouter, + } = await server.setup(setupDeps); + + const router = createRouter('/'); + + const executionOrder: string[] = []; + registerOnPreRouting((req, res, t) => { + executionOrder.push('onPreRouting'); + return t.next(); + }); + registerOnPreAuth((req, res, t) => { + executionOrder.push('onPreAuth'); + return t.next(); + }); + registerAuth((req, res, t) => { + executionOrder.push('auth'); + return res.forbidden(); + }); + registerOnPostAuth((req, res, t) => { + executionOrder.push('onPostAuth'); + return t.next(); + }); + registerOnPreResponse((req, res, t) => { + executionOrder.push('onPreResponse'); + return t.next(); + }); + + router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: 'ok' })); + + await server.start(); + + await supertest(innerServer.listener).get('/').expect(403); + expect(executionOrder).toEqual(['onPreRouting', 'onPreAuth', 'auth', 'onPreResponse']); + }); +}); diff --git a/src/core/server/http/lifecycle/on_pre_auth.ts b/src/core/server/http/lifecycle/on_pre_auth.ts index dc2ae6922fb94..f76fe87fd14a3 100644 --- a/src/core/server/http/lifecycle/on_pre_auth.ts +++ b/src/core/server/http/lifecycle/on_pre_auth.ts @@ -29,33 +29,21 @@ import { enum ResultType { next = 'next', - rewriteUrl = 'rewriteUrl', } interface Next { type: ResultType.next; } -interface RewriteUrl { - type: ResultType.rewriteUrl; - url: string; -} - -type OnPreAuthResult = Next | RewriteUrl; +type OnPreAuthResult = Next; const preAuthResult = { next(): OnPreAuthResult { return { type: ResultType.next }; }, - rewriteUrl(url: string): OnPreAuthResult { - return { type: ResultType.rewriteUrl, url }; - }, isNext(result: OnPreAuthResult): result is Next { return result && result.type === ResultType.next; }, - isRewriteUrl(result: OnPreAuthResult): result is RewriteUrl { - return result && result.type === ResultType.rewriteUrl; - }, }; /** @@ -65,13 +53,10 @@ const preAuthResult = { export interface OnPreAuthToolkit { /** To pass request to the next handler */ next: () => OnPreAuthResult; - /** Rewrite requested resources url before is was authenticated and routed to a handler */ - rewriteUrl: (url: string) => OnPreAuthResult; } const toolkit: OnPreAuthToolkit = { next: preAuthResult.next, - rewriteUrl: preAuthResult.rewriteUrl, }; /** @@ -88,9 +73,9 @@ export type OnPreAuthHandler = ( * @public * Adopt custom request interceptor to Hapi lifecycle system. * @param fn - an extension point allowing to perform custom logic for - * incoming HTTP requests. + * incoming HTTP requests before a user has been authenticated. */ -export function adoptToHapiOnPreAuthFormat(fn: OnPreAuthHandler, log: Logger) { +export function adoptToHapiOnPreAuth(fn: OnPreAuthHandler, log: Logger) { return async function interceptPreAuthRequest( request: Request, responseToolkit: HapiResponseToolkit @@ -107,13 +92,6 @@ export function adoptToHapiOnPreAuthFormat(fn: OnPreAuthHandler, log: Logger) { return responseToolkit.continue; } - if (preAuthResult.isRewriteUrl(result)) { - const { url } = result; - request.setUrl(url); - // We should update raw request as well since it can be proxied to the old platform - request.raw.req.url = url; - return responseToolkit.continue; - } throw new Error( `Unexpected result from OnPreAuth. Expected OnPreAuthResult or KibanaResponse, but given: ${result}.` ); diff --git a/src/core/server/http/lifecycle/on_pre_response.ts b/src/core/server/http/lifecycle/on_pre_response.ts index 9c8c6fba690d1..4d1b53313a51f 100644 --- a/src/core/server/http/lifecycle/on_pre_response.ts +++ b/src/core/server/http/lifecycle/on_pre_response.ts @@ -64,7 +64,7 @@ const preResponseResult = { }; /** - * A tool set defining an outcome of OnPreAuth interceptor for incoming request. + * A tool set defining an outcome of OnPreResponse interceptor for incoming request. * @public */ export interface OnPreResponseToolkit { @@ -77,7 +77,7 @@ const toolkit: OnPreResponseToolkit = { }; /** - * See {@link OnPreAuthToolkit}. + * See {@link OnPreRoutingToolkit}. * @public */ export type OnPreResponseHandler = ( diff --git a/src/core/server/http/lifecycle/on_pre_routing.ts b/src/core/server/http/lifecycle/on_pre_routing.ts new file mode 100644 index 0000000000000..e62eb54f2398f --- /dev/null +++ b/src/core/server/http/lifecycle/on_pre_routing.ts @@ -0,0 +1,125 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Lifecycle, Request, ResponseToolkit as HapiResponseToolkit } from 'hapi'; +import { Logger } from '../../logging'; +import { + HapiResponseAdapter, + KibanaRequest, + KibanaResponse, + lifecycleResponseFactory, + LifecycleResponseFactory, +} from '../router'; + +enum ResultType { + next = 'next', + rewriteUrl = 'rewriteUrl', +} + +interface Next { + type: ResultType.next; +} + +interface RewriteUrl { + type: ResultType.rewriteUrl; + url: string; +} + +type OnPreRoutingResult = Next | RewriteUrl; + +const preRoutingResult = { + next(): OnPreRoutingResult { + return { type: ResultType.next }; + }, + rewriteUrl(url: string): OnPreRoutingResult { + return { type: ResultType.rewriteUrl, url }; + }, + isNext(result: OnPreRoutingResult): result is Next { + return result && result.type === ResultType.next; + }, + isRewriteUrl(result: OnPreRoutingResult): result is RewriteUrl { + return result && result.type === ResultType.rewriteUrl; + }, +}; + +/** + * @public + * A tool set defining an outcome of OnPreRouting interceptor for incoming request. + */ +export interface OnPreRoutingToolkit { + /** To pass request to the next handler */ + next: () => OnPreRoutingResult; + /** Rewrite requested resources url before is was authenticated and routed to a handler */ + rewriteUrl: (url: string) => OnPreRoutingResult; +} + +const toolkit: OnPreRoutingToolkit = { + next: preRoutingResult.next, + rewriteUrl: preRoutingResult.rewriteUrl, +}; + +/** + * See {@link OnPreRoutingToolkit}. + * @public + */ +export type OnPreRoutingHandler = ( + request: KibanaRequest, + response: LifecycleResponseFactory, + toolkit: OnPreRoutingToolkit +) => OnPreRoutingResult | KibanaResponse | Promise; + +/** + * @public + * Adopt custom request interceptor to Hapi lifecycle system. + * @param fn - an extension point allowing to perform custom logic for + * incoming HTTP requests. + */ +export function adoptToHapiOnRequest(fn: OnPreRoutingHandler, log: Logger) { + return async function interceptPreRoutingRequest( + request: Request, + responseToolkit: HapiResponseToolkit + ): Promise { + const hapiResponseAdapter = new HapiResponseAdapter(responseToolkit); + + try { + const result = await fn(KibanaRequest.from(request), lifecycleResponseFactory, toolkit); + if (result instanceof KibanaResponse) { + return hapiResponseAdapter.handle(result); + } + + if (preRoutingResult.isNext(result)) { + return responseToolkit.continue; + } + + if (preRoutingResult.isRewriteUrl(result)) { + const { url } = result; + request.setUrl(url); + // We should update raw request as well since it can be proxied to the old platform + request.raw.req.url = url; + return responseToolkit.continue; + } + throw new Error( + `Unexpected result from OnPreRouting. Expected OnPreRoutingResult or KibanaResponse, but given: ${result}.` + ); + } catch (error) { + log.error(error); + return hapiResponseAdapter.toInternalError(); + } + }; +} diff --git a/src/core/server/http/types.ts b/src/core/server/http/types.ts index 241af1a3020cb..3df098a1df00d 100644 --- a/src/core/server/http/types.ts +++ b/src/core/server/http/types.ts @@ -25,6 +25,7 @@ import { HttpServerSetup } from './http_server'; import { SessionStorageCookieOptions } from './cookie_session_storage'; import { SessionStorageFactory } from './session_storage'; import { AuthenticationHandler } from './lifecycle/auth'; +import { OnPreRoutingHandler } from './lifecycle/on_pre_routing'; import { OnPreAuthHandler } from './lifecycle/on_pre_auth'; import { OnPostAuthHandler } from './lifecycle/on_post_auth'; import { OnPreResponseHandler } from './lifecycle/on_pre_response'; @@ -145,15 +146,26 @@ export interface HttpServiceSetup { ) => Promise>; /** - * To define custom logic to perform for incoming requests. + * To define custom logic to perform for incoming requests before server performs a route lookup. * * @remarks - * Runs the handler before Auth interceptor performs a check that user has access to requested resources, so it's the - * only place when you can forward a request to another URL right on the server. - * Can register any number of registerOnPostAuth, which are called in sequence + * It's the only place when you can forward a request to another URL right on the server. + * Can register any number of registerOnPreRouting, which are called in sequence + * (from the first registered to the last). See {@link OnPreRoutingHandler}. + * + * @param handler {@link OnPreRoutingHandler} - function to call. + */ + registerOnPreRouting: (handler: OnPreRoutingHandler) => void; + + /** + * To define custom logic to perform for incoming requests before + * the Auth interceptor performs a check that user has access to requested resources. + * + * @remarks + * Can register any number of registerOnPreAuth, which are called in sequence * (from the first registered to the last). See {@link OnPreAuthHandler}. * - * @param handler {@link OnPreAuthHandler} - function to call. + * @param handler {@link OnPreRoutingHandler} - function to call. */ registerOnPreAuth: (handler: OnPreAuthHandler) => void; @@ -170,13 +182,11 @@ export interface HttpServiceSetup { registerAuth: (handler: AuthenticationHandler) => void; /** - * To define custom logic to perform for incoming requests. + * To define custom logic after Auth interceptor did make sure a user has access to the requested resource. * * @remarks - * Runs the handler after Auth interceptor - * did make sure a user has access to the requested resource. * The auth state is available at stage via http.auth.get(..) - * Can register any number of registerOnPreAuth, which are called in sequence + * Can register any number of registerOnPostAuth, which are called in sequence * (from the first registered to the last). See {@link OnPostAuthHandler}. * * @param handler {@link OnPostAuthHandler} - function to call. diff --git a/src/core/server/index.ts b/src/core/server/index.ts index dcaa5f2367214..706ec88c6ebfd 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -148,6 +148,8 @@ export { LegacyRequest, OnPreAuthHandler, OnPreAuthToolkit, + OnPreRoutingHandler, + OnPreRoutingToolkit, OnPostAuthHandler, OnPostAuthToolkit, OnPreResponseHandler, diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index 6b34a4eb58319..fada40e773f12 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -301,6 +301,7 @@ export class LegacyService implements CoreService { ), createRouter: () => router, resources: setupDeps.core.httpResources.createRegistrar(router), + registerOnPreRouting: setupDeps.core.http.registerOnPreRouting, registerOnPreAuth: setupDeps.core.http.registerOnPreAuth, registerAuth: setupDeps.core.http.registerAuth, registerOnPostAuth: setupDeps.core.http.registerOnPostAuth, diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index a6dd13a12b527..c17b8df8bb52c 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -157,6 +157,7 @@ export function createPluginSetupContext( ), createRouter: () => router, resources: deps.httpResources.createRegistrar(router), + registerOnPreRouting: deps.http.registerOnPreRouting, registerOnPreAuth: deps.http.registerOnPreAuth, registerAuth: deps.http.registerAuth, registerOnPostAuth: deps.http.registerOnPostAuth, diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 3d3e1905577d9..886544a4df317 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -811,6 +811,7 @@ export interface HttpServiceSetup { registerOnPostAuth: (handler: OnPostAuthHandler) => void; registerOnPreAuth: (handler: OnPreAuthHandler) => void; registerOnPreResponse: (handler: OnPreResponseHandler) => void; + registerOnPreRouting: (handler: OnPreRoutingHandler) => void; registerRouteHandlerContext: (contextName: T, provider: RequestHandlerContextProvider) => RequestHandlerContextContainer; } @@ -1536,7 +1537,6 @@ export type OnPreAuthHandler = (request: KibanaRequest, response: LifecycleRespo // @public export interface OnPreAuthToolkit { next: () => OnPreAuthResult; - rewriteUrl: (url: string) => OnPreAuthResult; } // @public @@ -1560,6 +1560,17 @@ export interface OnPreResponseToolkit { next: (responseExtensions?: OnPreResponseExtensions) => OnPreResponseResult; } +// Warning: (ae-forgotten-export) The symbol "OnPreRoutingResult" needs to be exported by the entry point index.d.ts +// +// @public +export type OnPreRoutingHandler = (request: KibanaRequest, response: LifecycleResponseFactory, toolkit: OnPreRoutingToolkit) => OnPreRoutingResult | KibanaResponse | Promise; + +// @public +export interface OnPreRoutingToolkit { + next: () => OnPreRoutingResult; + rewriteUrl: (url: string) => OnPreRoutingResult; +} + // @public export interface OpsMetrics { concurrent_connections: OpsServerMetrics['concurrent_connections']; diff --git a/x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.ts b/x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.ts index 18e9da25576eb..4b3a5d662f12d 100644 --- a/x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.ts +++ b/x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.ts @@ -5,7 +5,7 @@ */ import { KibanaRequest, - OnPreAuthToolkit, + OnPreRoutingToolkit, LifecycleResponseFactory, CoreSetup, } from 'src/core/server'; @@ -18,10 +18,10 @@ export interface OnRequestInterceptorDeps { http: CoreSetup['http']; } export function initSpacesOnRequestInterceptor({ http }: OnRequestInterceptorDeps) { - http.registerOnPreAuth(async function spacesOnPreAuthHandler( + http.registerOnPreRouting(async function spacesOnPreRoutingHandler( request: KibanaRequest, response: LifecycleResponseFactory, - toolkit: OnPreAuthToolkit + toolkit: OnPreRoutingToolkit ) { const serverBasePath = http.basePath.serverBasePath; const path = request.url.pathname; From ec43d45b511fbae15b6a8dc016ea49299b054301 Mon Sep 17 00:00:00 2001 From: Spencer Date: Mon, 13 Jul 2020 12:29:29 -0700 Subject: [PATCH 35/40] [scripts/report_failed_tests] fix report_failed_tests integration on CI (#71131) Co-authored-by: spalger Co-authored-by: Elastic Machine --- .../kbn-test/src/failed_tests_reporter/README.md | 6 +++--- .../run_failed_tests_reporter_cli.ts | 12 ++++++++++-- vars/kibanaPipeline.groovy | 2 +- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/kbn-test/src/failed_tests_reporter/README.md b/packages/kbn-test/src/failed_tests_reporter/README.md index 20592ecd733b6..0473ae7357def 100644 --- a/packages/kbn-test/src/failed_tests_reporter/README.md +++ b/packages/kbn-test/src/failed_tests_reporter/README.md @@ -7,15 +7,15 @@ A little CLI that runs in CI to find the failed tests in the JUnit reports, then To fetch some JUnit reports from a recent build on CI, visit its `Google Cloud Storage Upload Report` and execute the following in the JS Console: ```js -copy(`wget "${Array.from($$('a[href$=".xml"]')).filter(a => a.innerText === 'Download').map(a => a.href.replace('https://storage.cloud.google.com/', 'https://storage.googleapis.com/')).join('" "')}"`) +copy(`wget -x -nH --cut-dirs 5 -P "target/downloaded_junit" "${Array.from($$('a[href$=".xml"]')).filter(a => a.innerText === 'Download').map(a => a.href.replace('https://storage.cloud.google.com/', 'https://storage.googleapis.com/')).join('" "')}"`) ``` -This copies a script to download the reports, which you should execute in the `test/junit` directory. +This copies a script to download the reports, which you should execute in the root of the Kibana repository. Next, run the CLI in `--no-github-update` mode so that it doesn't actually communicate with Github and `--no-report-update` to prevent the script from mutating the reports on disk and instead log the updated report. ```sh -node scripts/report_failed_tests.js --verbose --no-github-update --no-report-update +node scripts/report_failed_tests.js --verbose --no-github-update --no-report-update target/downloaded_junit/**/*.xml ``` Unless you specify the `GITHUB_TOKEN` environment variable requests to read existing issues will use anonymous access which is limited to 60 requests per hour. \ No newline at end of file diff --git a/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts b/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts index 3bcea44cf73b6..8a951ac969199 100644 --- a/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts +++ b/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts @@ -17,6 +17,8 @@ * under the License. */ +import Path from 'path'; + import { REPO_ROOT, run, createFailError, createFlagError } from '@kbn/dev-utils'; import globby from 'globby'; @@ -28,6 +30,8 @@ import { readTestReport } from './test_report'; import { addMessagesToReport } from './add_messages_to_report'; import { getReportMessageIter } from './report_metadata'; +const DEFAULT_PATTERNS = [Path.resolve(REPO_ROOT, 'target/junit/**/*.xml')]; + export function runFailedTestsReporterCli() { run( async ({ log, flags }) => { @@ -67,11 +71,15 @@ export function runFailedTestsReporterCli() { throw createFlagError('Missing --build-url or process.env.BUILD_URL'); } - const reportPaths = await globby(['target/junit/**/*.xml'], { - cwd: REPO_ROOT, + const patterns = flags._.length ? flags._ : DEFAULT_PATTERNS; + const reportPaths = await globby(patterns, { absolute: true, }); + if (!reportPaths.length) { + throw createFailError(`Unable to find any junit reports with patterns [${patterns}]`); + } + const newlyCreatedIssues: Array<{ failure: TestFailure; newIssue: GithubIssueMini; diff --git a/vars/kibanaPipeline.groovy b/vars/kibanaPipeline.groovy index f3fc5f84583c9..f43fe9f96c3ef 100644 --- a/vars/kibanaPipeline.groovy +++ b/vars/kibanaPipeline.groovy @@ -209,7 +209,7 @@ def runErrorReporter() { bash( """ source src/dev/ci_setup/setup_env.sh - node scripts/report_failed_tests ${dryRun} + node scripts/report_failed_tests ${dryRun} target/junit/**/*.xml """, "Report failed tests, if necessary" ) From 7282597a297b859b27e0bd9921d385198cc11e04 Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Mon, 13 Jul 2020 12:46:00 -0700 Subject: [PATCH 36/40] [Ingest Manager] Rename `settings.monitoring` to `agent.monitoring` (#71467) * Rename settings.monitoring to agent.monitoring; simplify default file name for downloaded agent yaml * Fix test --- .../ingest_manager/common/services/config_to_yaml.ts | 2 +- .../ingest_manager/common/types/models/agent_config.ts | 2 +- .../ingest_manager/server/routes/agent_config/handlers.ts | 2 +- .../ingest_manager/server/services/agent_config.test.ts | 6 +++--- .../plugins/ingest_manager/server/services/agent_config.ts | 4 ++-- .../apps/endpoint/policy_details.ts | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) 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 7e03e4572f9ee..1fb6fead454ef 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 @@ -12,7 +12,7 @@ const CONFIG_KEYS_ORDER = [ 'revision', 'type', 'outputs', - 'settings', + 'agent', 'inputs', 'enabled', 'use_output', 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 a6040742e45fc..00ba51fc1843a 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 @@ -62,7 +62,7 @@ export interface FullAgentConfig { }; inputs: FullAgentConfigInput[]; revision?: number; - settings?: { + agent?: { monitoring: { use_output?: string; enabled: boolean; diff --git a/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts index 2aaf889296bd6..718aca89ea4fd 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts @@ -283,7 +283,7 @@ export const downloadFullAgentConfig: RequestHandler< const body = configToYaml(fullAgentConfig); const headers: ResponseHeaders = { 'content-type': 'text/x-yaml', - 'content-disposition': `attachment; filename="elastic-agent-config-${fullAgentConfig.id}.yml"`, + 'content-disposition': `attachment; filename="elastic-agent.yml"`, }; return response.ok({ body, 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 c46e648ad088a..225251b061e58 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 @@ -61,7 +61,7 @@ describe('agent config', () => { }, inputs: [], revision: 1, - settings: { + agent: { monitoring: { enabled: false, logs: false, @@ -90,7 +90,7 @@ describe('agent config', () => { }, inputs: [], revision: 1, - settings: { + agent: { monitoring: { use_output: 'default', enabled: true, @@ -120,7 +120,7 @@ describe('agent config', () => { }, inputs: [], revision: 1, - settings: { + agent: { monitoring: { use_output: 'default', enabled: true, diff --git a/x-pack/plugins/ingest_manager/server/services/agent_config.ts b/x-pack/plugins/ingest_manager/server/services/agent_config.ts index 5f98c8881388d..c068b594318c1 100644 --- a/x-pack/plugins/ingest_manager/server/services/agent_config.ts +++ b/x-pack/plugins/ingest_manager/server/services/agent_config.ts @@ -417,7 +417,7 @@ class AgentConfigService { revision: config.revision, ...(config.monitoring_enabled && config.monitoring_enabled.length > 0 ? { - settings: { + agent: { monitoring: { use_output: defaultOutput.name, enabled: true, @@ -427,7 +427,7 @@ class AgentConfigService { }, } : { - settings: { + agent: { monitoring: { enabled: false, logs: false, metrics: false }, }, }), 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 7207bb3fc37b3..9a0a819f68b62 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 @@ -195,7 +195,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }, }, revision: 3, - settings: { + agent: { monitoring: { enabled: false, logs: false, From b3c6ce9aea01047c85b990a0349a27b89570ac6d Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Mon, 13 Jul 2020 14:47:16 -0500 Subject: [PATCH 37/40] rm index: false from binary mappings (#71343) * rm index: false from binary mappings * test against unverified snapshot * two more * Mapping adjustments * Revert "Mapping adjustments" This reverts commit 52d68dcd6d9f63f847f393de242e184b3d7704c8. * Revert "test against unverified snapshot" This reverts commit 4284ac37f100f4a928ed436b7a09bd53b8d60699. Co-authored-by: Madison Caldwell --- .../ingest_manager/server/saved_objects/index.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/ingest_manager/server/saved_objects/index.ts b/x-pack/plugins/ingest_manager/server/saved_objects/index.ts index 6c360fdeda460..4c58ac57a54a2 100644 --- a/x-pack/plugins/ingest_manager/server/saved_objects/index.ts +++ b/x-pack/plugins/ingest_manager/server/saved_objects/index.ts @@ -67,7 +67,7 @@ const savedObjectTypes: { [key: string]: SavedObjectsType } = { last_checkin_status: { type: 'keyword' }, config_revision: { type: 'integer' }, default_api_key_id: { type: 'keyword' }, - default_api_key: { type: 'binary', index: false }, + default_api_key: { type: 'binary' }, updated_at: { type: 'date' }, current_error_events: { type: 'text', index: false }, packages: { type: 'keyword' }, @@ -85,7 +85,7 @@ const savedObjectTypes: { [key: string]: SavedObjectsType } = { properties: { agent_id: { type: 'keyword' }, type: { type: 'keyword' }, - data: { type: 'binary', index: false }, + data: { type: 'binary' }, sent_at: { type: 'date' }, created_at: { type: 'date' }, }, @@ -146,7 +146,7 @@ const savedObjectTypes: { [key: string]: SavedObjectsType } = { properties: { name: { type: 'keyword' }, type: { type: 'keyword' }, - api_key: { type: 'binary', index: false }, + api_key: { type: 'binary' }, api_key_id: { type: 'keyword' }, config_id: { type: 'keyword' }, created_at: { type: 'date' }, @@ -170,8 +170,8 @@ const savedObjectTypes: { [key: string]: SavedObjectsType } = { is_default: { type: 'boolean' }, hosts: { type: 'keyword' }, ca_sha256: { type: 'keyword', index: false }, - fleet_enroll_username: { type: 'binary', index: false }, - fleet_enroll_password: { type: 'binary', index: false }, + fleet_enroll_username: { type: 'binary' }, + fleet_enroll_password: { type: 'binary' }, config: { type: 'flattened' }, }, }, From 1d23a48f98a49eaed359caca5aec43a0b867a2d0 Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Mon, 13 Jul 2020 12:56:57 -0700 Subject: [PATCH 38/40] Fix create agent config flyout being covered by bottom bar (#71502) --- .../step_select_config.tsx | 1 + .../list_page/components/create_config.tsx | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/step_select_config.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/step_select_config.tsx index d3120f9051f45..91c80b7eee4c8 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/step_select_config.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/step_select_config.tsx @@ -148,6 +148,7 @@ export const StepSelectConfig: React.FunctionComponent<{ setSelectedConfigId(newAgentConfig.id); } }} + ownFocus={true} /> ) : null} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/create_config.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/create_config.tsx index 795c46ec282c5..37fce340da6ea 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/create_config.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/create_config.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import React, { useState } from 'react'; +import styled from 'styled-components'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -17,16 +18,24 @@ import { EuiButtonEmpty, EuiButton, EuiText, + EuiFlyoutProps, } from '@elastic/eui'; import { NewAgentConfig, AgentConfig } from '../../../../types'; import { useCapabilities, useCore, sendCreateAgentConfig } from '../../../../hooks'; import { AgentConfigForm, agentConfigFormValidation } from '../../components'; -interface Props { +const FlyoutWithHigherZIndex = styled(EuiFlyout)` + z-index: ${(props) => props.theme.eui.euiZLevel5}; +`; + +interface Props extends EuiFlyoutProps { onClose: (createdAgentConfig?: AgentConfig) => void; } -export const CreateAgentConfigFlyout: React.FunctionComponent = ({ onClose }) => { +export const CreateAgentConfigFlyout: React.FunctionComponent = ({ + onClose, + ...restOfProps +}) => { const { notifications } = useCore(); const hasWriteCapabilites = useCapabilities().write; const [agentConfig, setAgentConfig] = useState({ @@ -147,10 +156,10 @@ export const CreateAgentConfigFlyout: React.FunctionComponent = ({ onClos ); return ( - + {header} {body} {footer} - + ); }; From 8d86a74ba8319420131e1d5187f616b90eeca233 Mon Sep 17 00:00:00 2001 From: spalger Date: Mon, 13 Jul 2020 13:17:42 -0700 Subject: [PATCH 39/40] Revert "Bump lodash package version (#71392)" This reverts commit 60032b81ca698ac18daef5c7fcb210453e1377a2. --- package.json | 1 - yarn.lock | 13 +++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 7ab6bfb91a376..55a099b4e5c0c 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,6 @@ "**/@types/hoist-non-react-statics": "^3.3.1", "**/@types/chai": "^4.2.11", "**/cypress/@types/lodash": "^4.14.155", - "**/cypress/lodash": "^4.15.19", "**/typescript": "3.9.5", "**/graphql-toolkit/lodash": "^4.17.15", "**/hoist-non-react-statics": "^3.3.2", diff --git a/yarn.lock b/yarn.lock index 290713d32d333..bd6c2031d0ec8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20916,16 +20916,21 @@ lodash.uniq@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= -lodash@4.17.11, lodash@4.17.15, lodash@>4.17.4, lodash@^4, lodash@^4.0.0, lodash@^4.0.1, lodash@^4.10.0, lodash@^4.11.1, lodash@^4.14.0, lodash@^4.15.0, lodash@^4.15.19, lodash@^4.17.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.16, lodash@^4.17.2, lodash@^4.17.4, lodash@^4.2.0, lodash@^4.2.1, lodash@^4.3.0, lodash@^4.6.1, lodash@~4.17.10, lodash@~4.17.15, lodash@~4.17.5: - version "4.17.19" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b" - integrity sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ== +lodash@4.17.11, lodash@4.17.15, lodash@>4.17.4, lodash@^4, lodash@^4.0.0, lodash@^4.0.1, lodash@^4.10.0, lodash@^4.11.1, lodash@^4.14.0, lodash@^4.15.0, lodash@^4.17.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.2, lodash@^4.17.4, lodash@^4.2.0, lodash@^4.2.1, lodash@^4.3.0, lodash@^4.6.1, lodash@~4.17.10, lodash@~4.17.15, lodash@~4.17.5: + version "4.17.15" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" + integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== lodash@^3.10.1: version "3.10.1" resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz#5bf45e8e49ba4189e17d482789dfd15bd140b7b6" integrity sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y= +lodash@^4.17.16: + version "4.17.19" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b" + integrity sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ== + "lodash@npm:@elastic/lodash@3.10.1-kibana4": version "3.10.1-kibana4" resolved "https://registry.yarnpkg.com/@elastic/lodash/-/lodash-3.10.1-kibana4.tgz#d491228fd659b4a1b0dfa08ba9c67a4979b9746d" From d7a679ba8c9f9863ae3e6d7f5a6e7fe427ba3f9b Mon Sep 17 00:00:00 2001 From: Aaron Caldwell Date: Mon, 13 Jul 2020 14:27:19 -0600 Subject: [PATCH 40/40] [Maps] Fix proxy handling issues (#71182) --- src/plugins/maps_legacy/server/index.ts | 33 +++++-- x-pack/plugins/maps/public/meta.test.js | 5 + x-pack/plugins/maps/public/meta.ts | 17 ++-- x-pack/plugins/maps/server/plugin.ts | 7 +- x-pack/plugins/maps/server/routes.js | 126 ++++++++++++------------ 5 files changed, 108 insertions(+), 80 deletions(-) diff --git a/src/plugins/maps_legacy/server/index.ts b/src/plugins/maps_legacy/server/index.ts index 18f58189fc607..5da3ce1a84408 100644 --- a/src/plugins/maps_legacy/server/index.ts +++ b/src/plugins/maps_legacy/server/index.ts @@ -17,8 +17,9 @@ * under the License. */ -import { PluginConfigDescriptor } from 'kibana/server'; -import { PluginInitializerContext } from 'kibana/public'; +import { Plugin, PluginConfigDescriptor } from 'kibana/server'; +import { PluginInitializerContext } from 'src/core/server'; +import { Observable } from 'rxjs'; import { configSchema, ConfigSchema } from '../config'; export const config: PluginConfigDescriptor = { @@ -37,13 +38,27 @@ export const config: PluginConfigDescriptor = { schema: configSchema, }; -export const plugin = (initializerContext: PluginInitializerContext) => ({ - setup() { +export interface MapsLegacyPluginSetup { + config$: Observable; +} + +export class MapsLegacyPlugin implements Plugin { + readonly _initializerContext: PluginInitializerContext; + + constructor(initializerContext: PluginInitializerContext) { + this._initializerContext = initializerContext; + } + + public setup() { // @ts-ignore - const config$ = initializerContext.config.create(); + const config$ = this._initializerContext.config.create(); return { - config: config$, + config$, }; - }, - start() {}, -}); + } + + public start() {} +} + +export const plugin = (initializerContext: PluginInitializerContext) => + new MapsLegacyPlugin(initializerContext); diff --git a/x-pack/plugins/maps/public/meta.test.js b/x-pack/plugins/maps/public/meta.test.js index 5c04a57c00058..3486bf003aee0 100644 --- a/x-pack/plugins/maps/public/meta.test.js +++ b/x-pack/plugins/maps/public/meta.test.js @@ -36,6 +36,11 @@ describe('getGlyphUrl', () => { beforeAll(() => { require('./kibana_services').getIsEmsEnabled = () => true; require('./kibana_services').getEmsFontLibraryUrl = () => EMS_FONTS_URL_MOCK; + require('./kibana_services').getHttp = () => ({ + basePath: { + prepend: (url) => url, // No need to actually prepend a dev basepath for test + }, + }); }); describe('EMS proxy enabled', () => { diff --git a/x-pack/plugins/maps/public/meta.ts b/x-pack/plugins/maps/public/meta.ts index 54c5eac7fe1b0..34c5f004fd7f3 100644 --- a/x-pack/plugins/maps/public/meta.ts +++ b/x-pack/plugins/maps/public/meta.ts @@ -30,8 +30,6 @@ import { getKibanaVersion, } from './kibana_services'; -const GIS_API_RELATIVE = `../${GIS_API_PATH}`; - export function getKibanaRegionList(): unknown[] { return getRegionmapLayers(); } @@ -69,10 +67,14 @@ export function getEMSClient(): EMSClient { const proxyElasticMapsServiceInMaps = getProxyElasticMapsServiceInMaps(); const proxyPath = ''; const tileApiUrl = proxyElasticMapsServiceInMaps - ? relativeToAbsolute(`${GIS_API_RELATIVE}/${EMS_TILES_CATALOGUE_PATH}`) + ? relativeToAbsolute( + getHttp().basePath.prepend(`/${GIS_API_PATH}/${EMS_TILES_CATALOGUE_PATH}`) + ) : getEmsTileApiUrl(); const fileApiUrl = proxyElasticMapsServiceInMaps - ? relativeToAbsolute(`${GIS_API_RELATIVE}/${EMS_FILES_CATALOGUE_PATH}`) + ? relativeToAbsolute( + getHttp().basePath.prepend(`/${GIS_API_PATH}/${EMS_FILES_CATALOGUE_PATH}`) + ) : getEmsFileApiUrl(); emsClient = new EMSClient({ @@ -101,8 +103,11 @@ export function getGlyphUrl(): string { return getHttp().basePath.prepend(`/${FONTS_API_PATH}/{fontstack}/{range}`); } return getProxyElasticMapsServiceInMaps() - ? relativeToAbsolute(`../${GIS_API_PATH}/${EMS_TILES_CATALOGUE_PATH}/${EMS_GLYPHS_PATH}`) + - `/{fontstack}/{range}` + ? relativeToAbsolute( + getHttp().basePath.prepend( + `/${GIS_API_PATH}/${EMS_TILES_CATALOGUE_PATH}/${EMS_GLYPHS_PATH}` + ) + ) + `/{fontstack}/{range}` : getEmsFontLibraryUrl(); } diff --git a/x-pack/plugins/maps/server/plugin.ts b/x-pack/plugins/maps/server/plugin.ts index dbcce50ac2b9a..7d091099c1aaa 100644 --- a/x-pack/plugins/maps/server/plugin.ts +++ b/x-pack/plugins/maps/server/plugin.ts @@ -26,12 +26,14 @@ import { initRoutes } from './routes'; import { ILicense } from '../../licensing/common/types'; import { LicensingPluginSetup } from '../../licensing/server'; import { HomeServerPluginSetup } from '../../../../src/plugins/home/server'; +import { MapsLegacyPluginSetup } from '../../../../src/plugins/maps_legacy/server'; interface SetupDeps { features: FeaturesPluginSetupContract; usageCollection: UsageCollectionSetup; home: HomeServerPluginSetup; licensing: LicensingPluginSetup; + mapsLegacy: MapsLegacyPluginSetup; } export class MapsPlugin implements Plugin { @@ -129,9 +131,10 @@ export class MapsPlugin implements Plugin { // @ts-ignore async setup(core: CoreSetup, plugins: SetupDeps) { - const { usageCollection, home, licensing, features } = plugins; + const { usageCollection, home, licensing, features, mapsLegacy } = plugins; // @ts-ignore const config$ = this._initializerContext.config.create(); + const mapsLegacyConfig = await mapsLegacy.config$.pipe(take(1)).toPromise(); const currentConfig = await config$.pipe(take(1)).toPromise(); // @ts-ignore @@ -150,7 +153,7 @@ export class MapsPlugin implements Plugin { initRoutes( core.http.createRouter(), license.uid, - currentConfig, + mapsLegacyConfig, this.kibanaVersion, this._logger ); diff --git a/x-pack/plugins/maps/server/routes.js b/x-pack/plugins/maps/server/routes.js index ad66712eb3ad6..1876c0de19c56 100644 --- a/x-pack/plugins/maps/server/routes.js +++ b/x-pack/plugins/maps/server/routes.js @@ -73,9 +73,10 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { validate: { query: schema.object({ id: schema.maybe(schema.string()), - x: schema.maybe(schema.number()), - y: schema.maybe(schema.number()), - z: schema.maybe(schema.number()), + elastic_tile_service_tos: schema.maybe(schema.string()), + my_app_name: schema.maybe(schema.string()), + my_app_version: schema.maybe(schema.string()), + license: schema.maybe(schema.string()), }), }, }, @@ -111,9 +112,9 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { path: `${ROOT}/${EMS_TILES_API_PATH}/${EMS_TILES_RASTER_TILE_PATH}`, validate: false, }, - async (context, request, { ok, badRequest }) => { + async (context, request, response) => { if (!checkEMSProxyEnabled()) { - return badRequest('map.proxyElasticMapsServiceInMaps disabled'); + return response.badRequest('map.proxyElasticMapsServiceInMaps disabled'); } if ( @@ -138,7 +139,7 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { .replace('{y}', request.query.y) .replace('{z}', request.query.z); - return await proxyResource({ url, contentType: 'image/png' }, { ok, badRequest }); + return await proxyResource({ url, contentType: 'image/png' }, response); } ); @@ -203,7 +204,9 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { }); //rewrite return ok({ - body: layers, + body: { + layers, + }, }); } ); @@ -293,7 +296,11 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { path: `${ROOT}/${EMS_TILES_API_PATH}/${EMS_TILES_VECTOR_STYLE_PATH}`, validate: { query: schema.object({ - id: schema.maybe(schema.string()), + id: schema.string(), + elastic_tile_service_tos: schema.maybe(schema.string()), + my_app_name: schema.maybe(schema.string()), + my_app_version: schema.maybe(schema.string()), + license: schema.maybe(schema.string()), }), }, }, @@ -302,11 +309,6 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { return badRequest('map.proxyElasticMapsServiceInMaps disabled'); } - if (!request.query.id) { - logger.warn('Must supply id parameter to retrieve EMS vector style'); - return null; - } - const tmsServices = await emsClient.getTMSServices(); const tmsService = tmsServices.find((layer) => layer.getId() === request.query.id); if (!tmsService) { @@ -342,8 +344,12 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { path: `${ROOT}/${EMS_TILES_API_PATH}/${EMS_TILES_VECTOR_SOURCE_PATH}`, validate: { query: schema.object({ - id: schema.maybe(schema.string()), + id: schema.string(), sourceId: schema.maybe(schema.string()), + elastic_tile_service_tos: schema.maybe(schema.string()), + my_app_name: schema.maybe(schema.string()), + my_app_version: schema.maybe(schema.string()), + license: schema.maybe(schema.string()), }), }, }, @@ -352,11 +358,6 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { return badRequest('map.proxyElasticMapsServiceInMaps disabled'); } - if (!request.query.id || !request.query.sourceId) { - logger.warn('Must supply id and sourceId parameter to retrieve EMS vector source'); - return null; - } - const tmsServices = await emsClient.getTMSServices(); const tmsService = tmsServices.find((layer) => layer.getId() === request.query.id); if (!tmsService) { @@ -381,28 +382,21 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { path: `${ROOT}/${EMS_TILES_API_PATH}/${EMS_TILES_VECTOR_TILE_PATH}`, validate: { query: schema.object({ - id: schema.maybe(schema.string()), - sourceId: schema.maybe(schema.string()), - x: schema.maybe(schema.number()), - y: schema.maybe(schema.number()), - z: schema.maybe(schema.number()), + id: schema.string(), + sourceId: schema.string(), + x: schema.number(), + y: schema.number(), + z: schema.number(), + elastic_tile_service_tos: schema.maybe(schema.string()), + my_app_name: schema.maybe(schema.string()), + my_app_version: schema.maybe(schema.string()), + license: schema.maybe(schema.string()), }), }, }, - async (context, request, { ok, badRequest }) => { + async (context, request, response) => { if (!checkEMSProxyEnabled()) { - return badRequest('map.proxyElasticMapsServiceInMaps disabled'); - } - - if ( - !request.query.id || - !request.query.sourceId || - typeof parseInt(request.query.x, 10) !== 'number' || - typeof parseInt(request.query.y, 10) !== 'number' || - typeof parseInt(request.query.z, 10) !== 'number' - ) { - logger.warn('Must supply id/sourceId/x/y/z parameters to retrieve EMS vector tile'); - return null; + return response.badRequest('map.proxyElasticMapsServiceInMaps disabled'); } const tmsServices = await emsClient.getTMSServices(); @@ -417,24 +411,29 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { .replace('{y}', request.query.y) .replace('{z}', request.query.z); - return await proxyResource({ url }, { ok, badRequest }); + return await proxyResource({ url }, response); } ); router.get( { path: `${ROOT}/${EMS_TILES_API_PATH}/${EMS_GLYPHS_PATH}/{fontstack}/{range}`, - validate: false, + validate: { + params: schema.object({ + fontstack: schema.string(), + range: schema.string(), + }), + }, }, - async (context, request, { ok, badRequest }) => { + async (context, request, response) => { if (!checkEMSProxyEnabled()) { - return badRequest('map.proxyElasticMapsServiceInMaps disabled'); + return response.badRequest('map.proxyElasticMapsServiceInMaps disabled'); } const url = mapConfig.emsFontLibraryUrl .replace('{fontstack}', request.params.fontstack) .replace('{range}', request.params.range); - return await proxyResource({ url }, { ok, badRequest }); + return await proxyResource({ url }, response); } ); @@ -442,19 +441,22 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { { path: `${ROOT}/${EMS_TILES_API_PATH}/${EMS_SPRITES_PATH}/{id}/sprite{scaling?}.{extension}`, validate: { + query: schema.object({ + elastic_tile_service_tos: schema.maybe(schema.string()), + my_app_name: schema.maybe(schema.string()), + my_app_version: schema.maybe(schema.string()), + license: schema.maybe(schema.string()), + }), params: schema.object({ id: schema.string(), + scaling: schema.maybe(schema.string()), + extension: schema.string(), }), }, }, - async (context, request, { ok, badRequest }) => { + async (context, request, response) => { if (!checkEMSProxyEnabled()) { - return badRequest('map.proxyElasticMapsServiceInMaps disabled'); - } - - if (!request.params.id) { - logger.warn('Must supply id parameter to retrieve EMS vector source sprite'); - return null; + return response.badRequest('map.proxyElasticMapsServiceInMaps disabled'); } const tmsServices = await emsClient.getTMSServices(); @@ -479,7 +481,7 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { url: proxyPathUrl, contentType: request.params.extension === 'png' ? 'image/png' : '', }, - { ok, badRequest } + response ); } ); @@ -570,25 +572,23 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { return proxyEMSInMaps; } - async function proxyResource({ url, contentType }, { ok, badRequest }) { + async function proxyResource({ url, contentType }, response) { try { const resource = await fetch(url); const arrayBuffer = await resource.arrayBuffer(); - const bufferedResponse = Buffer.from(arrayBuffer); - const headers = { - 'Content-Disposition': 'inline', - }; - if (contentType) { - headers['Content-type'] = contentType; - } - - return ok({ - body: bufferedResponse, - headers, + const buffer = Buffer.from(arrayBuffer); + + return response.ok({ + body: buffer, + headers: { + 'content-disposition': 'inline', + 'content-length': buffer.length, + ...(contentType ? { 'Content-type': contentType } : {}), + }, }); } catch (e) { logger.warn(`Cannot connect to EMS for resource, error: ${e.message}`); - return badRequest(`Cannot connect to EMS`); + return response.badRequest(`Cannot connect to EMS`); } } }