diff --git a/x-pack/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.test.tsx b/x-pack/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.test.tsx index 6aec6e9bf181a..2c19356a7fd52 100644 --- a/x-pack/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.test.tsx +++ b/x-pack/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.test.tsx @@ -16,6 +16,7 @@ import { } from '../../../context/ApmPluginContext/MockApmPluginContext'; const setBreadcrumbs = jest.fn(); +const changeTitle = jest.fn(); function mountBreadcrumb(route: string, params = '') { mount( @@ -27,6 +28,7 @@ function mountBreadcrumb(route: string, params = '') { ...mockApmPluginContextValue.core, chrome: { ...mockApmPluginContextValue.core.chrome, + docTitle: { change: changeTitle }, setBreadcrumbs, }, }, @@ -42,23 +44,14 @@ function mountBreadcrumb(route: string, params = '') { } describe('UpdateBreadcrumbs', () => { - let realDoc: Document; - beforeEach(() => { - realDoc = window.document; - (window.document as any) = { - title: 'Kibana', - }; setBreadcrumbs.mockReset(); + changeTitle.mockReset(); }); - afterEach(() => { - (window.document as any) = realDoc; - }); - - it('Homepage', () => { + it('Changes the homepage title', () => { mountBreadcrumb('/'); - expect(window.document.title).toMatchInlineSnapshot(`"APM"`); + expect(changeTitle).toHaveBeenCalledWith(['APM']); }); it('/services/:serviceName/errors/:groupId', () => { @@ -90,9 +83,13 @@ describe('UpdateBreadcrumbs', () => { }, { text: 'myGroupId', href: undefined }, ]); - expect(window.document.title).toMatchInlineSnapshot( - `"myGroupId | Errors | opbeans-node | Services | APM"` - ); + expect(changeTitle).toHaveBeenCalledWith([ + 'myGroupId', + 'Errors', + 'opbeans-node', + 'Services', + 'APM', + ]); }); it('/services/:serviceName/errors', () => { @@ -104,9 +101,12 @@ describe('UpdateBreadcrumbs', () => { { text: 'opbeans-node', href: '#/services/opbeans-node?kuery=myKuery' }, { text: 'Errors', href: undefined }, ]); - expect(window.document.title).toMatchInlineSnapshot( - `"Errors | opbeans-node | Services | APM"` - ); + expect(changeTitle).toHaveBeenCalledWith([ + 'Errors', + 'opbeans-node', + 'Services', + 'APM', + ]); }); it('/services/:serviceName/transactions', () => { @@ -118,9 +118,12 @@ describe('UpdateBreadcrumbs', () => { { text: 'opbeans-node', href: '#/services/opbeans-node?kuery=myKuery' }, { text: 'Transactions', href: undefined }, ]); - expect(window.document.title).toMatchInlineSnapshot( - `"Transactions | opbeans-node | Services | APM"` - ); + expect(changeTitle).toHaveBeenCalledWith([ + 'Transactions', + 'opbeans-node', + 'Services', + 'APM', + ]); }); it('/services/:serviceName/transactions/view?transactionName=my-transaction-name', () => { @@ -139,8 +142,12 @@ describe('UpdateBreadcrumbs', () => { }, { text: 'my-transaction-name', href: undefined }, ]); - expect(window.document.title).toMatchInlineSnapshot( - `"my-transaction-name | Transactions | opbeans-node | Services | APM"` - ); + expect(changeTitle).toHaveBeenCalledWith([ + 'my-transaction-name', + 'Transactions', + 'opbeans-node', + 'Services', + 'APM', + ]); }); }); diff --git a/x-pack/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.tsx b/x-pack/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.tsx index 7a27eae6e89f7..e7657c63f41bb 100644 --- a/x-pack/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.tsx +++ b/x-pack/plugins/apm/public/components/app/Main/UpdateBreadcrumbs.tsx @@ -22,10 +22,7 @@ interface Props { } function getTitleFromBreadCrumbs(breadcrumbs: Breadcrumb[]) { - return breadcrumbs - .map(({ value }) => value) - .reverse() - .join(' | '); + return breadcrumbs.map(({ value }) => value).reverse(); } class UpdateBreadcrumbsComponent extends React.Component { @@ -43,7 +40,9 @@ class UpdateBreadcrumbsComponent extends React.Component { } ); - document.title = getTitleFromBreadCrumbs(this.props.breadcrumbs); + this.props.core.chrome.docTitle.change( + getTitleFromBreadCrumbs(this.props.breadcrumbs) + ); this.props.core.chrome.setBreadcrumbs(breadcrumbs); } diff --git a/x-pack/plugins/infra/server/lib/log_analysis/common.ts b/x-pack/plugins/infra/server/lib/log_analysis/common.ts index 218281d875a46..4d2be94c7cd62 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/common.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/common.ts @@ -14,7 +14,6 @@ import { logEntryDatasetsResponseRT, } from './queries/log_entry_data_sets'; import { decodeOrThrow } from '../../../common/runtime_types'; -import { NoLogAnalysisResultsIndexError } from './errors'; import { startTracingSpan, TracingSpan } from '../../../common/performance_tracing'; export async function fetchMlJob(mlAnomalyDetectors: MlAnomalyDetectors, jobId: string) { @@ -67,16 +66,8 @@ export async function getLogEntryDatasets( ) ); - if (logEntryDatasetsResponse._shards.total === 0) { - throw new NoLogAnalysisResultsIndexError( - `Failed to find ml indices for jobs: ${jobIds.join(', ')}.` - ); - } - - const { - after_key: afterKey, - buckets: latestBatchBuckets, - } = logEntryDatasetsResponse.aggregations.dataset_buckets; + const { after_key: afterKey, buckets: latestBatchBuckets = [] } = + logEntryDatasetsResponse.aggregations?.dataset_buckets ?? {}; logEntryDatasetBuckets = [...logEntryDatasetBuckets, ...latestBatchBuckets]; afterLatestBatchKey = afterKey; 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 09fee8844fbc5..a6d0db25084e8 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/errors.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/errors.ts @@ -6,13 +6,6 @@ /* eslint-disable max-classes-per-file */ -export class NoLogAnalysisResultsIndexError extends Error { - constructor(message?: string) { - super(message); - Object.setPrototypeOf(this, new.target.prototype); - } -} - export class NoLogAnalysisMlJobError extends Error { constructor(message?: string) { super(message); 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 a455a03d936a5..ff9e3c7d2167c 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 @@ -15,11 +15,7 @@ import { import { startTracingSpan } from '../../../common/performance_tracing'; import { decodeOrThrow } from '../../../common/runtime_types'; import type { MlAnomalyDetectors, MlSystem } from '../../types'; -import { - InsufficientLogAnalysisMlJobConfigurationError, - NoLogAnalysisResultsIndexError, - UnknownCategoryError, -} from './errors'; +import { InsufficientLogAnalysisMlJobConfigurationError, UnknownCategoryError } from './errors'; import { createLogEntryCategoriesQuery, logEntryCategoriesResponseRT, @@ -235,38 +231,33 @@ async function fetchTopLogEntryCategories( const esSearchSpan = finalizeEsSearchSpan(); - if (topLogEntryCategoriesResponse._shards.total === 0) { - throw new NoLogAnalysisResultsIndexError( - `Failed to find ml result index for job ${logEntryCategoriesCountJobId}.` - ); - } - - const topLogEntryCategories = topLogEntryCategoriesResponse.aggregations.terms_category_id.buckets.map( - (topCategoryBucket) => { - const maximumAnomalyScoresByDataset = topCategoryBucket.filter_record.terms_dataset.buckets.reduce< - Record - >( - (accumulatedMaximumAnomalyScores, datasetFromRecord) => ({ - ...accumulatedMaximumAnomalyScores, - [datasetFromRecord.key]: datasetFromRecord.maximum_record_score.value ?? 0, - }), - {} - ); - - return { - categoryId: parseCategoryId(topCategoryBucket.key), - logEntryCount: topCategoryBucket.filter_model_plot.sum_actual.value ?? 0, - datasets: topCategoryBucket.filter_model_plot.terms_dataset.buckets - .map((datasetBucket) => ({ - name: datasetBucket.key, - maximumAnomalyScore: maximumAnomalyScoresByDataset[datasetBucket.key] ?? 0, - })) - .sort(compareDatasetsByMaximumAnomalyScore) - .reverse(), - maximumAnomalyScore: topCategoryBucket.filter_record.maximum_record_score.value ?? 0, - }; - } - ); + const topLogEntryCategories = + topLogEntryCategoriesResponse.aggregations?.terms_category_id.buckets.map( + (topCategoryBucket) => { + const maximumAnomalyScoresByDataset = topCategoryBucket.filter_record.terms_dataset.buckets.reduce< + Record + >( + (accumulatedMaximumAnomalyScores, datasetFromRecord) => ({ + ...accumulatedMaximumAnomalyScores, + [datasetFromRecord.key]: datasetFromRecord.maximum_record_score.value ?? 0, + }), + {} + ); + + return { + categoryId: parseCategoryId(topCategoryBucket.key), + logEntryCount: topCategoryBucket.filter_model_plot.sum_actual.value ?? 0, + datasets: topCategoryBucket.filter_model_plot.terms_dataset.buckets + .map((datasetBucket) => ({ + name: datasetBucket.key, + maximumAnomalyScore: maximumAnomalyScoresByDataset[datasetBucket.key] ?? 0, + })) + .sort(compareDatasetsByMaximumAnomalyScore) + .reverse(), + maximumAnomalyScore: topCategoryBucket.filter_record.maximum_record_score.value ?? 0, + }; + } + ) ?? []; return { topLogEntryCategories, 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 7bfc85ba78a0e..ce3acd0dba8cf 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 @@ -4,10 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { pipe } from 'fp-ts/lib/pipeable'; -import { map, fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; -import { throwErrors, createPlainError } from '../../../common/runtime_types'; +import { decodeOrThrow } from '../../../common/runtime_types'; import { logRateModelPlotResponseRT, createLogEntryRateQuery, @@ -15,7 +12,6 @@ import { CompositeTimestampPartitionKey, } from './queries'; import { getJobId } from '../../../common/log_analysis'; -import { NoLogAnalysisResultsIndexError } from './errors'; import type { MlSystem } from '../../types'; const COMPOSITE_AGGREGATION_BATCH_SIZE = 1000; @@ -50,22 +46,14 @@ export async function getLogEntryRateBuckets( ) ); - if (mlModelPlotResponse._shards.total === 0) { - throw new NoLogAnalysisResultsIndexError( - `Failed to query ml result index for job ${logRateJobId}.` - ); - } - - const { after_key: afterKey, buckets: latestBatchBuckets } = pipe( - logRateModelPlotResponseRT.decode(mlModelPlotResponse), - map((response) => response.aggregations.timestamp_partition_buckets), - fold(throwErrors(createPlainError), identity) - ); + const { after_key: afterKey, buckets: latestBatchBuckets = [] } = + decodeOrThrow(logRateModelPlotResponseRT)(mlModelPlotResponse).aggregations + ?.timestamp_partition_buckets ?? {}; mlModelPlotBuckets = [...mlModelPlotBuckets, ...latestBatchBuckets]; afterLatestBatchKey = afterKey; - if (latestBatchBuckets.length < COMPOSITE_AGGREGATION_BATCH_SIZE) { + if (afterKey == null || latestBatchBuckets.length < COMPOSITE_AGGREGATION_BATCH_SIZE) { break; } } diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_data_sets.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_data_sets.ts index 7627ccd8c4996..53971a91d86b1 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_data_sets.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_data_sets.ts @@ -67,7 +67,7 @@ export type LogEntryDatasetBucket = rt.TypeOf; export const logEntryDatasetsResponseRT = rt.intersection([ commonSearchSuccessResponseFieldsRT, - rt.type({ + rt.partial({ aggregations: rt.type({ dataset_buckets: rt.intersection([ rt.type({ diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate.ts index 52edcf09cdfc2..e82dd8ef4443c 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate.ts @@ -162,7 +162,7 @@ export const logRateModelPlotBucketRT = rt.type({ export type LogRateModelPlotBucket = rt.TypeOf; -export const logRateModelPlotResponseRT = rt.type({ +export const logRateModelPlotResponseRT = rt.partial({ aggregations: rt.type({ timestamp_partition_buckets: rt.intersection([ rt.type({ diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts index 355dde9ec7c4a..5d3d9bc8b4036 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts @@ -159,7 +159,7 @@ export type LogEntryCategoryBucket = rt.TypeOf; export const topLogEntryCategoriesResponseRT = rt.intersection([ commonSearchSuccessResponseFieldsRT, - rt.type({ + rt.partial({ aggregations: rt.type({ terms_category_id: rt.type({ buckets: rt.array(logEntryCategoryBucketRT), diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_anomalies_datasets.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_anomalies_datasets.ts index d3d0862eee9aa..f1f1a1681a901 100644 --- a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_anomalies_datasets.ts +++ b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_anomalies_datasets.ts @@ -12,10 +12,7 @@ import { } from '../../../../common/http_api/log_analysis'; import { createValidationFunction } from '../../../../common/runtime_types'; import type { InfraBackendLibs } from '../../../lib/infra_types'; -import { - getLogEntryAnomaliesDatasets, - NoLogAnalysisResultsIndexError, -} from '../../../lib/log_analysis'; +import { getLogEntryAnomaliesDatasets } from '../../../lib/log_analysis'; import { assertHasInfraMlPlugins } from '../../../utils/request_context'; export const initGetLogEntryAnomaliesDatasetsRoute = ({ framework }: InfraBackendLibs) => { @@ -58,10 +55,6 @@ export const initGetLogEntryAnomaliesDatasetsRoute = ({ framework }: InfraBacken throw error; } - if (error instanceof NoLogAnalysisResultsIndexError) { - return response.notFound({ body: { message: error.message } }); - } - return response.customError({ statusCode: error.statusCode ?? 500, body: { diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_categories.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_categories.ts index f9f31f28dffeb..f57132ef1b505 100644 --- a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_categories.ts +++ b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_categories.ts @@ -12,10 +12,7 @@ import { } from '../../../../common/http_api/log_analysis'; import { createValidationFunction } from '../../../../common/runtime_types'; import type { InfraBackendLibs } from '../../../lib/infra_types'; -import { - getTopLogEntryCategories, - NoLogAnalysisResultsIndexError, -} from '../../../lib/log_analysis'; +import { getTopLogEntryCategories } from '../../../lib/log_analysis'; import { assertHasInfraMlPlugins } from '../../../utils/request_context'; export const initGetLogEntryCategoriesRoute = ({ framework }: InfraBackendLibs) => { @@ -69,10 +66,6 @@ export const initGetLogEntryCategoriesRoute = ({ framework }: InfraBackendLibs) throw error; } - if (error instanceof NoLogAnalysisResultsIndexError) { - return response.notFound({ body: { message: error.message } }); - } - return response.customError({ statusCode: error.statusCode ?? 500, body: { diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_category_datasets.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_category_datasets.ts index 69b1e942464fd..b99ff920f81e4 100644 --- a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_category_datasets.ts +++ b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_category_datasets.ts @@ -12,10 +12,7 @@ import { } from '../../../../common/http_api/log_analysis'; import { createValidationFunction } from '../../../../common/runtime_types'; import type { InfraBackendLibs } from '../../../lib/infra_types'; -import { - getLogEntryCategoryDatasets, - NoLogAnalysisResultsIndexError, -} from '../../../lib/log_analysis'; +import { getLogEntryCategoryDatasets } from '../../../lib/log_analysis'; import { assertHasInfraMlPlugins } from '../../../utils/request_context'; export const initGetLogEntryCategoryDatasetsRoute = ({ framework }: InfraBackendLibs) => { @@ -58,10 +55,6 @@ export const initGetLogEntryCategoryDatasetsRoute = ({ framework }: InfraBackend throw error; } - if (error instanceof NoLogAnalysisResultsIndexError) { - return response.notFound({ body: { message: error.message } }); - } - return response.customError({ statusCode: error.statusCode ?? 500, body: { diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_category_examples.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_category_examples.ts index 8baeaac3d1699..11098ebe5c65b 100644 --- a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_category_examples.ts +++ b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_category_examples.ts @@ -12,10 +12,7 @@ import { } from '../../../../common/http_api/log_analysis'; import { createValidationFunction } from '../../../../common/runtime_types'; import type { InfraBackendLibs } from '../../../lib/infra_types'; -import { - getLogEntryCategoryExamples, - NoLogAnalysisResultsIndexError, -} from '../../../lib/log_analysis'; +import { getLogEntryCategoryExamples } from '../../../lib/log_analysis'; import { assertHasInfraMlPlugins } from '../../../utils/request_context'; export const initGetLogEntryCategoryExamplesRoute = ({ framework, sources }: InfraBackendLibs) => { @@ -68,10 +65,6 @@ export const initGetLogEntryCategoryExamplesRoute = ({ framework, sources }: Inf throw error; } - if (error instanceof NoLogAnalysisResultsIndexError) { - return response.notFound({ body: { message: error.message } }); - } - return response.customError({ statusCode: error.statusCode ?? 500, body: { diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_examples.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_examples.ts index be4caee769506..7838a64a6045e 100644 --- a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_examples.ts +++ b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_examples.ts @@ -7,7 +7,7 @@ import Boom from 'boom'; import { createValidationFunction } from '../../../../common/runtime_types'; import { InfraBackendLibs } from '../../../lib/infra_types'; -import { NoLogAnalysisResultsIndexError, getLogEntryExamples } from '../../../lib/log_analysis'; +import { getLogEntryExamples } from '../../../lib/log_analysis'; import { assertHasInfraMlPlugins } from '../../../utils/request_context'; import { getLogEntryExamplesRequestPayloadRT, @@ -68,10 +68,6 @@ export const initGetLogEntryExamplesRoute = ({ framework, sources }: InfraBacken throw error; } - if (error instanceof NoLogAnalysisResultsIndexError) { - return response.notFound({ body: { message: error.message } }); - } - return response.customError({ statusCode: error.statusCode ?? 500, body: { diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_rate.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_rate.ts index 3b05f6ed23aae..cd23c0193e291 100644 --- a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_rate.ts +++ b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_rate.ts @@ -13,7 +13,7 @@ import { GetLogEntryRateSuccessResponsePayload, } from '../../../../common/http_api/log_analysis'; import { createValidationFunction } from '../../../../common/runtime_types'; -import { NoLogAnalysisResultsIndexError, getLogEntryRateBuckets } from '../../../lib/log_analysis'; +import { getLogEntryRateBuckets } from '../../../lib/log_analysis'; import { assertHasInfraMlPlugins } from '../../../utils/request_context'; export const initGetLogEntryRateRoute = ({ framework }: InfraBackendLibs) => { @@ -56,10 +56,6 @@ export const initGetLogEntryRateRoute = ({ framework }: InfraBackendLibs) => { throw error; } - if (error instanceof NoLogAnalysisResultsIndexError) { - return response.notFound({ body: { message: error.message } }); - } - return response.customError({ statusCode: error.statusCode ?? 500, body: { diff --git a/x-pack/plugins/monitoring/public/lib/internal_monitoring_toasts.tsx b/x-pack/plugins/monitoring/public/lib/internal_monitoring_toasts.tsx new file mode 100644 index 0000000000000..b6ecb631d005a --- /dev/null +++ b/x-pack/plugins/monitoring/public/lib/internal_monitoring_toasts.tsx @@ -0,0 +1,123 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiSpacer, EuiLink } from '@elastic/eui'; +import { Legacy } from '../legacy_shims'; +import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; +import { isInSetupMode, toggleSetupMode } from './setup_mode'; + +export interface MonitoringIndicesTypes { + legacyIndices: number; + metricbeatIndices: number; +} + +const enterSetupModeLabel = () => + i18n.translate('xpack.monitoring.internalMonitoringToast.enterSetupMode', { + defaultMessage: 'Enter setup mode', + }); + +const learnMoreLabel = () => + i18n.translate('xpack.monitoring.internalMonitoringToast.learnMoreAction', { + defaultMessage: 'Learn more', + }); + +const showIfLegacyOnlyIndices = () => { + const { ELASTIC_WEBSITE_URL } = Legacy.shims.docLinks; + const toast = Legacy.shims.toastNotifications.addWarning({ + title: toMountPoint( + + ), + text: toMountPoint( +
+

+ {i18n.translate('xpack.monitoring.internalMonitoringToast.description', { + defaultMessage: `It appears you are using "Legacy Collection" for Stack Monitoring. + This method of monitoring will no longer be supported in the next major release (8.0.0). + Please follow the steps in setup mode to start monitoring with Metricbeat.`, + })} +

+ { + Legacy.shims.toastNotifications.remove(toast); + toggleSetupMode(true); + }} + > + {enterSetupModeLabel()} + + + + + {learnMoreLabel()} + +
+ ), + }); +}; + +const showIfLegacyAndMetricbeatIndices = () => { + const { ELASTIC_WEBSITE_URL } = Legacy.shims.docLinks; + const toast = Legacy.shims.toastNotifications.addWarning({ + title: toMountPoint( + + ), + text: toMountPoint( +
+

+ {i18n.translate('xpack.monitoring.internalAndMetricbeatMonitoringToast.description', { + defaultMessage: `It appears you are using both Metricbeat and "Legacy Collection" for Stack Monitoring. + In 8.0.0, you must use Metricbeat to collect monitoring data. + Please follow the steps in setup mode to migrate the rest of the monitoring to Metricbeat.`, + })} +

+ { + Legacy.shims.toastNotifications.remove(toast); + toggleSetupMode(true); + }} + > + {enterSetupModeLabel()} + + + + + {learnMoreLabel()} + +
+ ), + }); +}; + +export const showInternalMonitoringToast = ({ + legacyIndices, + metricbeatIndices, +}: MonitoringIndicesTypes) => { + if (isInSetupMode()) { + return; + } + + if (legacyIndices && !metricbeatIndices) { + showIfLegacyOnlyIndices(); + } else if (legacyIndices && metricbeatIndices) { + showIfLegacyAndMetricbeatIndices(); + } +}; diff --git a/x-pack/plugins/monitoring/public/services/clusters.js b/x-pack/plugins/monitoring/public/services/clusters.js index 5173984dbe868..7f772ac1e1bcd 100644 --- a/x-pack/plugins/monitoring/public/services/clusters.js +++ b/x-pack/plugins/monitoring/public/services/clusters.js @@ -7,6 +7,7 @@ import { ajaxErrorHandlersProvider } from '../lib/ajax_error_handler'; import { Legacy } from '../legacy_shims'; import { STANDALONE_CLUSTER_CLUSTER_UUID } from '../../common/constants'; +import { showInternalMonitoringToast } from '../lib/internal_monitoring_toasts'; import { showSecurityToast } from '../alerts/lib/security_toasts'; function formatClusters(clusters) { @@ -21,6 +22,7 @@ function formatCluster(cluster) { } let once = false; +let inTransit = false; export function monitoringClustersProvider($injector) { return (clusterUuid, ccs, codePaths) => { @@ -63,19 +65,39 @@ export function monitoringClustersProvider($injector) { }); } - if (!once) { + function ensureMetricbeatEnabled() { + if (Legacy.shims.isCloud) { + return Promise.resolve(); + } + + return $http + .get('../api/monitoring/v1/elasticsearch_settings/check/internal_monitoring') + .then(({ data }) => { + showInternalMonitoringToast({ + legacyIndices: data.legacy_indices, + metricbeatIndices: data.mb_indices, + }); + }) + .catch((err) => { + const Private = $injector.get('Private'); + const ajaxErrorHandlers = Private(ajaxErrorHandlersProvider); + return ajaxErrorHandlers(err); + }); + } + + if (!once && !inTransit) { + inTransit = true; return getClusters().then((clusters) => { if (clusters.length) { - return ensureAlertsEnabled() - .then(({ data }) => { + Promise.all([ensureAlertsEnabled(), ensureMetricbeatEnabled()]) + .then(([{ data }]) => { showSecurityToast(data); once = true; - return clusters; }) .catch(() => { // Intentionally swallow the error as this will retry the next page load - return clusters; - }); + }) + .finally(() => (inTransit = false)); } return clusters; }); diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/internal_monitoring.ts b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/internal_monitoring.ts new file mode 100644 index 0000000000000..4473d824c9e30 --- /dev/null +++ b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/check/internal_monitoring.ts @@ -0,0 +1,85 @@ +/* + * 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 'kibana/server'; +// @ts-ignore +import { getIndexPatterns } from '../../../../../lib/cluster/get_index_patterns'; +// @ts-ignore +import { handleError } from '../../../../../lib/errors'; +import { RouteDependencies } from '../../../../../types'; + +const queryBody = { + size: 0, + aggs: { + types: { + terms: { + field: '_index', + size: 10, + }, + }, + }, +}; + +const checkLatestMonitoringIsLegacy = async (context: RequestHandlerContext, index: string) => { + const { client: esClient } = context.core.elasticsearch.legacy; + const result = await esClient.callAsCurrentUser('search', { + index, + body: queryBody, + }); + + const { aggregations } = result; + const counts = { + legacyIndicesCount: 0, + mbIndicesCount: 0, + }; + + if (!aggregations) { + return counts; + } + + const { + types: { buckets }, + } = aggregations; + counts.mbIndicesCount = buckets.filter(({ key }: { key: string }) => key.includes('-mb-')).length; + + counts.legacyIndicesCount = buckets.length - counts.mbIndicesCount; + return counts; +}; + +export function internalMonitoringCheckRoute(server: unknown, npRoute: RouteDependencies) { + npRoute.router.get( + { + path: '/api/monitoring/v1/elasticsearch_settings/check/internal_monitoring', + validate: false, + }, + async (context, _request, response) => { + try { + const typeCount = { + legacy_indices: 0, + mb_indices: 0, + }; + + const { esIndexPattern, kbnIndexPattern, lsIndexPattern } = getIndexPatterns(server); + const indexCounts = await Promise.all([ + checkLatestMonitoringIsLegacy(context, esIndexPattern), + checkLatestMonitoringIsLegacy(context, kbnIndexPattern), + checkLatestMonitoringIsLegacy(context, lsIndexPattern), + ]); + + indexCounts.forEach((counts) => { + typeCount.legacy_indices += counts.legacyIndicesCount; + typeCount.mb_indices += counts.mbIndicesCount; + }); + + return response.ok({ + body: typeCount, + }); + } catch (err) { + throw handleError(err); + } + } + ); +} diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/index.js b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/index.js index d7ef71efc0b51..906057d221868 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/index.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch_settings/index.js @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +export { internalMonitoringCheckRoute } from './check/internal_monitoring'; export { clusterSettingsCheckRoute } from './check/cluster'; export { nodesSettingsCheckRoute } from './check/nodes'; export { setCollectionEnabledRoute } from './set/collection_enabled'; diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/ui.js b/x-pack/plugins/monitoring/server/routes/api/v1/ui.js index de0213ec84689..e8daf52582437 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/ui.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/ui.js @@ -20,6 +20,7 @@ export { ccrShardRoute, } from './elasticsearch'; export { + internalMonitoringCheckRoute, clusterSettingsCheckRoute, nodesSettingsCheckRoute, setCollectionEnabledRoute, diff --git a/x-pack/plugins/observability/public/application/application.test.tsx b/x-pack/plugins/observability/public/application/application.test.tsx new file mode 100644 index 0000000000000..db7fca140be89 --- /dev/null +++ b/x-pack/plugins/observability/public/application/application.test.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 from 'react'; +import { renderApp } from './'; +import { Observable } from 'rxjs'; +import { CoreStart, AppMountParameters } from 'src/core/public'; + +describe('renderApp', () => { + it('renders', () => { + const core = ({ + application: { currentAppId$: new Observable(), navigateToUrl: () => {} }, + chrome: { docTitle: { change: () => {} }, setBreadcrumbs: () => {} }, + i18n: { Context: ({ children }: { children: React.ReactNode }) => children }, + uiSettings: { get: () => false }, + } as unknown) as CoreStart; + const params = ({ + element: window.document.createElement('div'), + } as unknown) as AppMountParameters; + + expect(() => { + const unmount = renderApp(core, params); + unmount(); + }).not.toThrowError(); + }); +}); diff --git a/x-pack/plugins/observability/public/application/index.tsx b/x-pack/plugins/observability/public/application/index.tsx index b0134ed8b746b..4c0147dc3cd51 100644 --- a/x-pack/plugins/observability/public/application/index.tsx +++ b/x-pack/plugins/observability/public/application/index.tsx @@ -23,10 +23,7 @@ const observabilityLabelBreadcrumb = { }; function getTitleFromBreadCrumbs(breadcrumbs: Breadcrumbs) { - return breadcrumbs - .map(({ text }) => text) - .reverse() - .join(' | '); + return breadcrumbs.map(({ text }) => text).reverse(); } function App() { @@ -42,7 +39,7 @@ function App() { const breadcrumb = [observabilityLabelBreadcrumb, ...route.breadcrumb]; useEffect(() => { core.chrome.setBreadcrumbs(breadcrumb); - document.title = getTitleFromBreadCrumbs(breadcrumb); + core.chrome.docTitle.change(getTitleFromBreadCrumbs(breadcrumb)); }, [core, breadcrumb]); const { query, path: pathParams } = useRouteParams(route.params); diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx index 23cabd6778cc0..f5ed151ebac3c 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx @@ -14,6 +14,8 @@ import { TestProviders } from '../../../common/mock'; import { useGetCasesMockState } from '../../containers/mock'; import * as i18n from './translations'; +import { useKibana } from '../../../common/lib/kibana'; +import { createUseKibanaMock } from '../../../common/mock/kibana_react'; import { getEmptyTagValue } from '../../../common/components/empty_value'; import { useDeleteCases } from '../../containers/use_delete_cases'; import { useGetCases } from '../../containers/use_get_cases'; @@ -26,6 +28,7 @@ jest.mock('../../containers/use_delete_cases'); jest.mock('../../containers/use_get_cases'); jest.mock('../../containers/use_get_cases_status'); +const useKibanaMock = useKibana as jest.Mock; const useDeleteCasesMock = useDeleteCases as jest.Mock; const useGetCasesMock = useGetCases as jest.Mock; const useGetCasesStatusMock = useGetCasesStatus as jest.Mock; @@ -33,6 +36,8 @@ const useUpdateCasesMock = useUpdateCases as jest.Mock; jest.mock('../../../common/components/link_to'); +jest.mock('../../../common/lib/kibana'); + describe('AllCases', () => { const dispatchResetIsDeleted = jest.fn(); const dispatchResetIsUpdated = jest.fn(); @@ -45,6 +50,7 @@ describe('AllCases', () => { const setSelectedCases = jest.fn(); const updateBulkStatus = jest.fn(); const fetchCasesStatus = jest.fn(); + const onRowClick = jest.fn(); const emptyTag = getEmptyTagValue().props.children; const defaultGetCases = { @@ -77,6 +83,9 @@ describe('AllCases', () => { dispatchResetIsUpdated, updateBulkStatus, }; + + let navigateToApp: jest.Mock; + /* eslint-disable no-console */ // Silence until enzyme fixed to use ReactTestUtils.act() const originalError = console.error; @@ -89,10 +98,20 @@ describe('AllCases', () => { /* eslint-enable no-console */ beforeEach(() => { jest.resetAllMocks(); - useUpdateCasesMock.mockImplementation(() => defaultUpdateCases); - useGetCasesMock.mockImplementation(() => defaultGetCases); - useDeleteCasesMock.mockImplementation(() => defaultDeleteCases); - useGetCasesStatusMock.mockImplementation(() => defaultCasesStatus); + navigateToApp = jest.fn(); + const kibanaMock = createUseKibanaMock()(); + useKibanaMock.mockReturnValue({ + ...kibanaMock, + services: { + application: { + navigateToApp, + }, + }, + }); + useUpdateCasesMock.mockReturnValue(defaultUpdateCases); + useGetCasesMock.mockReturnValue(defaultGetCases); + useDeleteCasesMock.mockReturnValue(defaultDeleteCases); + useGetCasesStatusMock.mockReturnValue(defaultCasesStatus); moment.tz.setDefault('UTC'); }); it('should render AllCases', () => { @@ -125,7 +144,7 @@ describe('AllCases', () => { ); }); it('should render empty fields', () => { - useGetCasesMock.mockImplementation(() => ({ + useGetCasesMock.mockReturnValue({ ...defaultGetCases, data: { ...defaultGetCases.data, @@ -141,7 +160,7 @@ describe('AllCases', () => { }, ], }, - })); + }); const wrapper = mount( @@ -202,10 +221,10 @@ describe('AllCases', () => { }); }); it('opens case when row action icon clicked', () => { - useGetCasesMock.mockImplementation(() => ({ + useGetCasesMock.mockReturnValue({ ...defaultGetCases, filterOptions: { ...defaultGetCases.filterOptions, status: 'closed' }, - })); + }); const wrapper = mount( @@ -223,10 +242,11 @@ describe('AllCases', () => { }); }); it('Bulk delete', () => { - useGetCasesMock.mockImplementation(() => ({ + useGetCasesMock.mockReturnValue({ ...defaultGetCases, selectedCases: useGetCasesMockState.data.cases, - })); + }); + useDeleteCasesMock .mockReturnValueOnce({ ...defaultDeleteCases, @@ -257,10 +277,10 @@ describe('AllCases', () => { ); }); it('Bulk close status update', () => { - useGetCasesMock.mockImplementation(() => ({ + useGetCasesMock.mockReturnValue({ ...defaultGetCases, selectedCases: useGetCasesMockState.data.cases, - })); + }); const wrapper = mount( @@ -272,14 +292,14 @@ describe('AllCases', () => { expect(updateBulkStatus).toBeCalledWith(useGetCasesMockState.data.cases, 'closed'); }); it('Bulk open status update', () => { - useGetCasesMock.mockImplementation(() => ({ + useGetCasesMock.mockReturnValue({ ...defaultGetCases, selectedCases: useGetCasesMockState.data.cases, filterOptions: { ...defaultGetCases.filterOptions, status: 'closed', }, - })); + }); const wrapper = mount( @@ -291,10 +311,10 @@ describe('AllCases', () => { expect(updateBulkStatus).toBeCalledWith(useGetCasesMockState.data.cases, 'open'); }); it('isDeleted is true, refetch', () => { - useDeleteCasesMock.mockImplementation(() => ({ + useDeleteCasesMock.mockReturnValue({ ...defaultDeleteCases, isDeleted: true, - })); + }); mount( @@ -306,10 +326,10 @@ describe('AllCases', () => { expect(dispatchResetIsDeleted).toBeCalled(); }); it('isUpdated is true, refetch', () => { - useUpdateCasesMock.mockImplementation(() => ({ + useUpdateCasesMock.mockReturnValue({ ...defaultUpdateCases, isUpdated: true, - })); + }); mount( @@ -320,4 +340,96 @@ describe('AllCases', () => { expect(fetchCasesStatus).toBeCalled(); expect(dispatchResetIsUpdated).toBeCalled(); }); + + it('should not render header when modal=true', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="all-cases-header"]').exists()).toBe(false); + }); + + it('should not render table utility bar when modal=true', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="case-table-utility-bar-actions"]').exists()).toBe(false); + }); + + it('case table should not be selectable when modal=true', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="cases-table"]').first().prop('isSelectable')).toBe(false); + }); + + it('should call onRowClick with no cases and modal=true', () => { + useGetCasesMock.mockReturnValue({ + ...defaultGetCases, + data: { + ...defaultGetCases.data, + total: 0, + cases: [], + }, + }); + + const wrapper = mount( + + + + ); + + wrapper.find('[data-test-subj="cases-table-add-case"]').first().simulate('click'); + expect(onRowClick).toHaveBeenCalled(); + }); + + it('should call navigateToApp with no cases and modal=false', () => { + useGetCasesMock.mockReturnValue({ + ...defaultGetCases, + data: { + ...defaultGetCases.data, + total: 0, + cases: [], + }, + }); + + const wrapper = mount( + + + + ); + + wrapper.find('[data-test-subj="cases-table-add-case"]').first().simulate('click'); + expect(navigateToApp).toHaveBeenCalledWith('securitySolution:case', { path: '/create' }); + }); + + it('should call onRowClick when clicking a case with modal=true', () => { + const wrapper = mount( + + + + ); + + wrapper.find('[data-test-subj="cases-table-row-1"]').first().simulate('click'); + expect(onRowClick).toHaveBeenCalledWith('1'); + }); + + it('should NOT call onRowClick when clicking a case with modal=true', () => { + const wrapper = mount( + + + + ); + + wrapper.find('[data-test-subj="cases-table-row-1"]').first().simulate('click'); + expect(onRowClick).not.toHaveBeenCalled(); + }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx index f46dd9e858c7f..42a87de2aa07b 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.tsx @@ -3,7 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -/* eslint-disable react-hooks/exhaustive-deps */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { EuiBasicTable, @@ -16,7 +15,7 @@ import { EuiTableSortingType, } from '@elastic/eui'; import { EuiTableSelectionType } from '@elastic/eui/src/components/basic_table/table_types'; -import { isEmpty } from 'lodash/fp'; +import { isEmpty, memoize } from 'lodash/fp'; import styled, { css } from 'styled-components'; import * as i18n from './translations'; @@ -137,7 +136,7 @@ export const AllCases = React.memo( (refetchFilter: () => void) => { filterRefetch.current = refetchFilter; }, - [filterRefetch.current] + [filterRefetch] ); const refreshCases = useCallback( (dataRefresh = true) => { @@ -149,7 +148,7 @@ export const AllCases = React.memo( filterRefetch.current(); } }, - [filterOptions, queryParams, filterRefetch.current] + [filterRefetch, refetchCases, setSelectedCases, fetchCasesStatus] ); useEffect(() => { @@ -161,7 +160,7 @@ export const AllCases = React.memo( refreshCases(); dispatchResetIsUpdated(); } - }, [isDeleted, isUpdated]); + }, [isDeleted, isUpdated, refreshCases, dispatchResetIsDeleted, dispatchResetIsUpdated]); const confirmDeleteModal = useMemo( () => ( ( )} /> ), - [deleteBulk, deleteThisCase, isDisplayConfirmDeleteModal] + [ + deleteBulk, + deleteThisCase, + isDisplayConfirmDeleteModal, + handleToggleModal, + handleOnDeleteConfirm, + ] ); - const toggleDeleteModal = useCallback((deleteCase: Case) => { - handleToggleModal(); - setDeleteThisCase(deleteCase); - }, []); + const toggleDeleteModal = useCallback( + (deleteCase: Case) => { + handleToggleModal(); + setDeleteThisCase(deleteCase); + }, + [handleToggleModal] + ); const toggleBulkDeleteModal = useCallback( (caseIds: string[]) => { @@ -195,14 +203,14 @@ export const AllCases = React.memo( const convertToDeleteCases: DeleteCase[] = caseIds.map((id) => ({ id })); setDeleteBulk(convertToDeleteCases); }, - [selectedCases] + [selectedCases, setDeleteBulk, handleToggleModal] ); const handleUpdateCaseStatus = useCallback( (status: string) => { updateBulkStatus(selectedCases, status); }, - [selectedCases] + [selectedCases, updateBulkStatus] ); const selectedCaseIds = useMemo( @@ -223,7 +231,7 @@ export const AllCases = React.memo( })} /> ), - [selectedCaseIds, filterOptions.status, toggleBulkDeleteModal] + [selectedCaseIds, filterOptions.status, toggleBulkDeleteModal, handleUpdateCaseStatus] ); const handleDispatchUpdate = useCallback( (args: Omit) => { @@ -278,7 +286,7 @@ export const AllCases = React.memo( setQueryParams(newQueryParams); refreshCases(false); }, - [queryParams] + [queryParams, refreshCases, setQueryParams] ); const onFilterChangedCallback = useCallback( @@ -291,7 +299,7 @@ export const AllCases = React.memo( setFilters(newFilterOptions); refreshCases(false); }, - [filterOptions, queryParams] + [refreshCases, setQueryParams, setFilters] ); const memoizedGetCasesColumns = useMemo( @@ -311,9 +319,10 @@ export const AllCases = React.memo( const sorting: EuiTableSortingType = { sort: { field: queryParams.sortField, direction: queryParams.sortOrder }, }; + const euiBasicTableSelectionProps = useMemo>( () => ({ onSelectionChange: setSelectedCases }), - [selectedCases] + [setSelectedCases] ); const isCasesLoading = useMemo( () => loading.indexOf('cases') > -1 || loading.indexOf('caseUpdate') > -1, @@ -322,6 +331,35 @@ export const AllCases = React.memo( const isDataEmpty = useMemo(() => data.total === 0, [data]); const TableWrap = useMemo(() => (isModal ? 'span' : Panel), [isModal]); + + const onTableRowClick = useMemo( + () => + memoize<(id: string) => () => void>((id) => () => { + if (onRowClick) { + onRowClick(id); + } + }), + [onRowClick] + ); + + const tableRowProps = useCallback( + (item) => { + const rowProps = { + 'data-test-subj': `cases-table-row-${item.id}`, + }; + + if (isModal) { + return { + ...rowProps, + onClick: onTableRowClick(item.id), + }; + } + + return rowProps; + }, + [isModal, onTableRowClick] + ); + return ( <> {!isEmpty(actionsErrors) && ( @@ -329,7 +367,13 @@ export const AllCases = React.memo( )} {!isModal && ( - + ( {!isModal && ( - + {i18n.SHOWING_SELECTED_CASES(selectedCases.length)} @@ -441,6 +485,7 @@ export const AllCases = React.memo( onClick={goToCreateCase} href={formatUrl(getCreateCaseUrl())} iconType="plusInCircle" + data-test-subj="cases-table-add-case" > {i18n.ADD_NEW_CASE} @@ -449,17 +494,7 @@ export const AllCases = React.memo( } onChange={tableOnChangeCallback} pagination={memoizedPagination} - rowProps={(item) => - isModal - ? { - onClick: () => { - if (onRowClick != null) { - onRowClick(item.id); - } - }, - } - : {} - } + rowProps={tableRowProps} selection={userCanCrud && !isModal ? euiBasicTableSelectionProps : undefined} sorting={sorting} /> diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases_modal/index.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases_modal/index.tsx index d8f2e5293ee1b..efbe3e667c27b 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases_modal/index.tsx @@ -12,6 +12,7 @@ import { EuiModalHeaderTitle, EuiOverlayMask, } from '@elastic/eui'; + import { useGetUserSavedObjectPermissions } from '../../../common/lib/kibana'; import { AllCases } from '../all_cases'; import * as i18n from './translations'; diff --git a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.test.tsx b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.test.tsx new file mode 100644 index 0000000000000..6039fec2464cc --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.test.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 { mount } from 'enzyme'; +import React from 'react'; +import '../../../common/mock/match_media'; +import { AllCasesModal } from './all_cases_modal'; +import { TestProviders } from '../../../common/mock'; + +jest.mock('../all_cases', () => { + const AllCases = () => { + return <>; + }; + return { AllCases }; +}); + +jest.mock('../../../common/lib/kibana', () => { + const originalModule = jest.requireActual('../../../common/lib/kibana'); + return { + ...originalModule, + useGetUserSavedObjectPermissions: jest.fn(), + }; +}); + +const onCloseCaseModal = jest.fn(); +const onRowClick = jest.fn(); +const defaultProps = { + onCloseCaseModal, + onRowClick, +}; + +describe('AllCasesModal', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('renders', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj='all-cases-modal']`).exists()).toBeTruthy(); + }); + + it('Closing modal calls onCloseCaseModal', () => { + const wrapper = mount( + + + + ); + + wrapper.find('.euiModal__closeIcon').first().simulate('click'); + expect(onCloseCaseModal).toBeCalled(); + }); + + it('pass the correct props to AllCases component', () => { + const wrapper = mount( + + + + ); + + const props = wrapper.find('AllCases').props(); + expect(props).toEqual({ + userCanCrud: false, + onRowClick, + isModal: true, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.tsx b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.tsx new file mode 100644 index 0000000000000..7a12f9211e969 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/all_cases_modal.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo } from 'react'; +import { + EuiModal, + EuiModalBody, + EuiModalHeader, + EuiModalHeaderTitle, + EuiOverlayMask, +} from '@elastic/eui'; + +import { useGetUserSavedObjectPermissions } from '../../../common/lib/kibana'; +import { AllCases } from '../all_cases'; +import * as i18n from './translations'; + +export interface AllCasesModalProps { + onCloseCaseModal: () => void; + onRowClick: (id?: string) => void; +} + +const AllCasesModalComponent: React.FC = ({ + onCloseCaseModal, + onRowClick, +}: AllCasesModalProps) => { + const userPermissions = useGetUserSavedObjectPermissions(); + const userCanCrud = userPermissions?.crud ?? false; + return ( + + + + {i18n.SELECT_CASE_TITLE} + + + + + + + ); +}; + +export const AllCasesModal = memo(AllCasesModalComponent); + +AllCasesModal.displayName = 'AllCasesModal'; diff --git a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.test.tsx new file mode 100644 index 0000000000000..b5bf68cbf6dc8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.test.tsx @@ -0,0 +1,143 @@ +/* + * 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. + */ + +/* eslint-disable react/display-name */ + +import React from 'react'; +import { renderHook, act } from '@testing-library/react-hooks'; + +import { useKibana } from '../../../common/lib/kibana'; +import '../../../common/mock/match_media'; +import { TimelineId } from '../../../../common/types/timeline'; +import { useAllCasesModal, UseAllCasesModalProps, UseAllCasesModalReturnedValues } from '.'; +import { TestProviders } from '../../../common/mock'; +import { createUseKibanaMock } from '../../../common/mock/kibana_react'; + +jest.mock('../../../common/lib/kibana'); + +const useKibanaMock = useKibana as jest.Mock; + +describe('useAllCasesModal', () => { + const navigateToApp = jest.fn(() => Promise.resolve()); + + beforeEach(() => { + jest.clearAllMocks(); + const kibanaMock = createUseKibanaMock()(); + useKibanaMock.mockImplementation(() => ({ + ...kibanaMock, + services: { + application: { + navigateToApp, + }, + }, + })); + }); + + it('init', async () => { + const { result } = renderHook( + () => useAllCasesModal({ timelineId: TimelineId.test }), + { + wrapper: ({ children }) => {children}, + } + ); + + expect(result.current.showModal).toBe(false); + }); + + it('opens the modal', async () => { + const { result } = renderHook( + () => useAllCasesModal({ timelineId: TimelineId.test }), + { + wrapper: ({ children }) => {children}, + } + ); + + act(() => { + result.current.onOpenModal(); + }); + + expect(result.current.showModal).toBe(true); + }); + + it('closes the modal', async () => { + const { result } = renderHook( + () => useAllCasesModal({ timelineId: TimelineId.test }), + { + wrapper: ({ children }) => {children}, + } + ); + + act(() => { + result.current.onOpenModal(); + result.current.onCloseModal(); + }); + + expect(result.current.showModal).toBe(false); + }); + + it('returns a memoized value', async () => { + const { result, rerender } = renderHook( + () => useAllCasesModal({ timelineId: TimelineId.test }), + { + wrapper: ({ children }) => {children}, + } + ); + + const result1 = result.current; + act(() => rerender()); + const result2 = result.current; + + expect(result1).toBe(result2); + }); + + it('closes the modal when clicking a row', async () => { + const { result } = renderHook( + () => useAllCasesModal({ timelineId: TimelineId.test }), + { + wrapper: ({ children }) => {children}, + } + ); + + act(() => { + result.current.onOpenModal(); + result.current.onRowClick(); + }); + + expect(result.current.showModal).toBe(false); + }); + + it('navigates to the correct path without id', async () => { + const { result } = renderHook( + () => useAllCasesModal({ timelineId: TimelineId.test }), + { + wrapper: ({ children }) => {children}, + } + ); + + act(() => { + result.current.onOpenModal(); + result.current.onRowClick(); + }); + + expect(navigateToApp).toHaveBeenCalledWith('securitySolution:case', { path: '/create' }); + }); + + it('navigates to the correct path with id', async () => { + const { result } = renderHook( + () => useAllCasesModal({ timelineId: TimelineId.test }), + { + wrapper: ({ children }) => {children}, + } + ); + + act(() => { + result.current.onOpenModal(); + result.current.onRowClick('case-id'); + }); + + expect(navigateToApp).toHaveBeenCalledWith('securitySolution:case', { path: '/case-id' }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.tsx b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.tsx new file mode 100644 index 0000000000000..f7fc7963b3844 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/index.tsx @@ -0,0 +1,85 @@ +/* + * 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, useCallback, useMemo } from 'react'; + +import { useDispatch, useSelector } from 'react-redux'; +import { APP_ID } from '../../../../common/constants'; +import { SecurityPageName } from '../../../app/types'; +import { useKibana } from '../../../common/lib/kibana'; +import { getCaseDetailsUrl, getCreateCaseUrl } from '../../../common/components/link_to'; +import { State } from '../../../common/store'; +import { setInsertTimeline } from '../../../timelines/store/timeline/actions'; +import { timelineSelectors } from '../../../timelines/store/timeline'; + +import { AllCasesModal } from './all_cases_modal'; + +export interface UseAllCasesModalProps { + timelineId: string; +} + +export interface UseAllCasesModalReturnedValues { + Modal: React.FC; + showModal: boolean; + onCloseModal: () => void; + onOpenModal: () => void; + onRowClick: (id?: string) => void; +} + +export const useAllCasesModal = ({ + timelineId, +}: UseAllCasesModalProps): UseAllCasesModalReturnedValues => { + const dispatch = useDispatch(); + const { navigateToApp } = useKibana().services.application; + const timeline = useSelector((state: State) => + timelineSelectors.selectTimeline(state, timelineId) + ); + + const [showModal, setShowModal] = useState(false); + const onCloseModal = useCallback(() => setShowModal(false), []); + const onOpenModal = useCallback(() => setShowModal(true), []); + + const onRowClick = useCallback( + async (id?: string) => { + onCloseModal(); + + await navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { + path: id != null ? getCaseDetailsUrl({ id }) : getCreateCaseUrl(), + }); + + dispatch( + setInsertTimeline({ + graphEventId: timeline.graphEventId ?? '', + timelineId, + timelineSavedObjectId: timeline.savedObjectId ?? '', + timelineTitle: timeline.title, + }) + ); + }, + // dispatch causes unnecessary rerenders + // eslint-disable-next-line react-hooks/exhaustive-deps + [timeline, navigateToApp, onCloseModal, timelineId] + ); + + const Modal: React.FC = useCallback( + () => + showModal ? : null, + [onCloseModal, onRowClick, showModal] + ); + + const state = useMemo( + () => ({ + Modal, + showModal, + onCloseModal, + onOpenModal, + onRowClick, + }), + [showModal, onCloseModal, onOpenModal, onRowClick, Modal] + ); + + return state; +}; diff --git a/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/translations.ts b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/translations.ts new file mode 100644 index 0000000000000..e0f84d8541424 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/use_all_cases_modal/translations.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +export const SELECT_CASE_TITLE = i18n.translate('xpack.securitySolution.case.caseModal.title', { + defaultMessage: 'Select case to attach timeline', +}); diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/__mocks__/index.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/__mocks__/index.ts index 6ada887ece175..2c52acd3ec747 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/__mocks__/index.ts @@ -24,3 +24,4 @@ export const useToasts = jest.fn(() => notificationServiceMock.createStartContra export const useCurrentUser = jest.fn(); export const withKibana = jest.fn(createWithKibanaMock()); export const KibanaContextProvider = jest.fn(createKibanaContextProviderMock()); +export const useGetUserSavedObjectPermissions = jest.fn(); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts index 4c01b3644cf63..621fab2e4ee11 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/action.ts @@ -12,6 +12,7 @@ import { import { ServerApiError } from '../../../../common/types'; import { GetPolicyListResponse } from '../../policy/types'; import { GetPackagesResponse } from '../../../../../../ingest_manager/common'; +import { HostState } from '../types'; interface ServerReturnedHostList { type: 'serverReturnedHostList'; @@ -75,6 +76,11 @@ interface ServerReturnedEndpointPackageInfo { payload: GetPackagesResponse['response'][0]; } +interface ServerReturnedHostNonExistingPolicies { + type: 'serverReturnedHostNonExistingPolicies'; + payload: HostState['nonExistingPolicies']; +} + export type HostAction = | ServerReturnedHostList | ServerFailedToReturnHostList @@ -87,4 +93,5 @@ export type HostAction = | UserSelectedEndpointPolicy | ServerCancelledHostListLoading | ServerCancelledPolicyItemsLoading - | ServerReturnedEndpointPackageInfo; + | ServerReturnedEndpointPackageInfo + | ServerReturnedHostNonExistingPolicies; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts index f2c205661b32c..b6e18506b6111 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/index.test.ts @@ -50,6 +50,7 @@ describe('HostList store concerns', () => { selectedPolicyId: undefined, policyItemsLoading: false, endpointPackageInfo: undefined, + nonExistingPolicies: {}, }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts index 12fa3dc47beac..edeca5659ee38 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/middleware.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { HostResultList } from '../../../../../common/endpoint/types'; +import { HttpSetup } from 'kibana/public'; +import { HostInfo, HostResultList } from '../../../../../common/endpoint/types'; import { GetPolicyListResponse } from '../../policy/types'; import { ImmutableMiddlewareFactory } from '../../../../common/store'; import { @@ -13,12 +14,15 @@ import { uiQueryParams, listData, endpointPackageInfo, + nonExistingPolicies, } from './selectors'; import { HostState } from '../types'; import { sendGetEndpointSpecificPackageConfigs, sendGetEndpointSecurityPackage, + sendGetAgentConfigList, } from '../../policy/store/policy_list/services/ingest'; +import { AGENT_CONFIG_SAVED_OBJECT_TYPE } from '../../../../../../ingest_manager/common'; export const hostMiddlewareFactory: ImmutableMiddlewareFactory = (coreStart) => { return ({ getState, dispatch }) => (next) => async (action) => { @@ -58,6 +62,23 @@ export const hostMiddlewareFactory: ImmutableMiddlewareFactory = (cor type: 'serverReturnedHostList', payload: hostResponse, }); + + getNonExistingPoliciesForHostsList( + coreStart.http, + hostResponse.hosts, + nonExistingPolicies(state) + ) + .then((missingPolicies) => { + if (missingPolicies !== undefined) { + dispatch({ + type: 'serverReturnedHostNonExistingPolicies', + payload: missingPolicies, + }); + } + }) + // Ignore Errors, since this should not hinder the user's ability to use the UI + // eslint-disable-next-line no-console + .catch((error) => console.error(error)); } catch (error) { dispatch({ type: 'serverFailedToReturnHostList', @@ -117,6 +138,23 @@ export const hostMiddlewareFactory: ImmutableMiddlewareFactory = (cor type: 'serverReturnedHostList', payload: response, }); + + getNonExistingPoliciesForHostsList( + coreStart.http, + response.hosts, + nonExistingPolicies(state) + ) + .then((missingPolicies) => { + if (missingPolicies !== undefined) { + dispatch({ + type: 'serverReturnedHostNonExistingPolicies', + payload: missingPolicies, + }); + } + }) + // Ignore Errors, since this should not hinder the user's ability to use the UI + // eslint-disable-next-line no-console + .catch((error) => console.error(error)); } catch (error) { dispatch({ type: 'serverFailedToReturnHostList', @@ -133,11 +171,25 @@ export const hostMiddlewareFactory: ImmutableMiddlewareFactory = (cor // call the host details api const { selected_host: selectedHost } = uiQueryParams(state); try { - const response = await coreStart.http.get(`/api/endpoint/metadata/${selectedHost}`); + const response = await coreStart.http.get( + `/api/endpoint/metadata/${selectedHost}` + ); dispatch({ type: 'serverReturnedHostDetails', payload: response, }); + getNonExistingPoliciesForHostsList(coreStart.http, [response], nonExistingPolicies(state)) + .then((missingPolicies) => { + if (missingPolicies !== undefined) { + dispatch({ + type: 'serverReturnedHostNonExistingPolicies', + payload: missingPolicies, + }); + } + }) + // Ignore Errors, since this should not hinder the user's ability to use the UI + // eslint-disable-next-line no-console + .catch((error) => console.error(error)); } catch (error) { dispatch({ type: 'serverFailedToReturnHostDetails', @@ -163,3 +215,62 @@ export const hostMiddlewareFactory: ImmutableMiddlewareFactory = (cor } }; }; + +const getNonExistingPoliciesForHostsList = async ( + http: HttpSetup, + hosts: HostResultList['hosts'], + currentNonExistingPolicies: HostState['nonExistingPolicies'] +): Promise => { + if (hosts.length === 0) { + return; + } + + // Create an array of unique policy IDs that are not yet known to be non-existing. + const policyIdsToCheck = Array.from( + new Set( + hosts + .filter((host) => !currentNonExistingPolicies[host.metadata.Endpoint.policy.applied.id]) + .map((host) => host.metadata.Endpoint.policy.applied.id) + ) + ); + + if (policyIdsToCheck.length === 0) { + return; + } + + // We use the Agent Config API here, instead of the Package Config, because we can't use + // filter by ID of the Saved Object. Agent Config, however, keeps a reference (array) of + // Package Ids that it uses, thus if a reference exists there, then the package config (policy) + // exists. + const policiesFound = ( + await sendGetAgentConfigList(http, { + query: { + kuery: `${AGENT_CONFIG_SAVED_OBJECT_TYPE}.package_configs: (${policyIdsToCheck.join( + ' or ' + )})`, + }, + }) + ).items.reduce((list, agentConfig) => { + (agentConfig.package_configs as string[]).forEach((packageConfig) => { + list[packageConfig as string] = true; + }); + return list; + }, {}); + + const nonExisting = policyIdsToCheck.reduce( + (list, policyId) => { + if (policiesFound[policyId]) { + return list; + } + list[policyId] = true; + return list; + }, + {} + ); + + if (Object.keys(nonExisting).length === 0) { + return; + } + + return nonExisting; +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts index 993267cf1a704..7f68baa4b85bd 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/reducer.ts @@ -28,6 +28,7 @@ export const initialHostListState: Immutable = { selectedPolicyId: undefined, policyItemsLoading: false, endpointPackageInfo: undefined, + nonExistingPolicies: {}, }; /* eslint-disable-next-line complexity */ @@ -57,6 +58,14 @@ export const hostListReducer: ImmutableReducer = ( error: action.payload, loading: false, }; + } else if (action.type === 'serverReturnedHostNonExistingPolicies') { + return { + ...state, + nonExistingPolicies: { + ...state.nonExistingPolicies, + ...action.payload, + }, + }; } else if (action.type === 'serverReturnedHostDetails') { return { ...state, diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts index 4f47eaf565d8c..6e0823a920413 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/store/selectors.ts @@ -195,3 +195,11 @@ export const policyResponseStatus: (state: Immutable) => string = cre return (policyResponse && policyResponse?.Endpoint?.policy?.applied?.status) || ''; } ); + +/** + * returns the list of known non-existing polices that may have been in the Host API response. + * @param state + */ +export const nonExistingPolicies: ( + state: Immutable +) => Immutable = (state) => state.nonExistingPolicies; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts index a5f37a0b49e8f..582a59cfd7605 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/types.ts @@ -50,6 +50,8 @@ export interface HostState { selectedPolicyId?: string; /** Endpoint package info */ endpointPackageInfo?: GetPackagesResponse['response'][0]; + /** tracks the list of policies IDs used in Host metadata that may no longer exist */ + nonExistingPolicies: Record; } /** diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/host_policy_link.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/host_policy_link.tsx new file mode 100644 index 0000000000000..ec4d7e87b721d --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/components/host_policy_link.tsx @@ -0,0 +1,53 @@ +/* + * 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, { memo, useMemo } from 'react'; +import { EuiLink, EuiLinkAnchorProps } from '@elastic/eui'; +import { useHostSelector } from '../hooks'; +import { nonExistingPolicies } from '../../store/selectors'; +import { getPolicyDetailPath } from '../../../../common/routing'; +import { useFormatUrl } from '../../../../../common/components/link_to'; +import { SecurityPageName } from '../../../../../../common/constants'; +import { useNavigateByRouterEventHandler } from '../../../../../common/hooks/endpoint/use_navigate_by_router_event_handler'; + +/** + * A policy link (to details) that first checks to see if the policy id exists against + * the `nonExistingPolicies` value in the store. If it does not exist, then regular + * text is returned. + */ +export const HostPolicyLink = memo< + Omit & { + policyId: string; + } +>(({ policyId, children, onClick, ...otherProps }) => { + const missingPolicies = useHostSelector(nonExistingPolicies); + const { formatUrl } = useFormatUrl(SecurityPageName.administration); + const { toRoutePath, toRouteUrl } = useMemo(() => { + const toPath = getPolicyDetailPath(policyId); + return { + toRoutePath: toPath, + toRouteUrl: formatUrl(toPath), + }; + }, [formatUrl, policyId]); + const clickHandler = useNavigateByRouterEventHandler(toRoutePath, onClick); + + if (missingPolicies[policyId]) { + return ( + + {children} + + ); + } + + return ( + // eslint-disable-next-line @elastic/eui/href-or-on-click + + {children} + + ); +}); + +HostPolicyLink.displayName = 'HostPolicyLink'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx index 62efa621e6e3b..cea66acbef8ca 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx @@ -26,10 +26,11 @@ import { POLICY_STATUS_TO_HEALTH_COLOR } from '../host_constants'; import { FormattedDateAndTime } from '../../../../../common/components/endpoint/formatted_date_time'; import { useNavigateByRouterEventHandler } from '../../../../../common/hooks/endpoint/use_navigate_by_router_event_handler'; import { LinkToApp } from '../../../../../common/components/endpoint/link_to_app'; -import { getHostDetailsPath, getPolicyDetailPath } from '../../../../common/routing'; +import { getHostDetailsPath } from '../../../../common/routing'; import { SecurityPageName } from '../../../../../app/types'; import { useFormatUrl } from '../../../../../common/components/link_to'; import { AgentDetailsReassignConfigAction } from '../../../../../../../ingest_manager/public'; +import { HostPolicyLink } from '../components/host_policy_link'; const HostIds = styled(EuiListGroupItem)` margin-top: 0; @@ -116,15 +117,6 @@ export const HostDetails = memo(({ details }: { details: HostMetadata }) => { const policyStatusClickHandler = useNavigateByRouterEventHandler(policyResponseRoutePath); - const [policyDetailsRoutePath, policyDetailsRouteUrl] = useMemo(() => { - return [ - getPolicyDetailPath(details.Endpoint.policy.applied.id), - formatUrl(getPolicyDetailPath(details.Endpoint.policy.applied.id)), - ]; - }, [details.Endpoint.policy.applied.id, formatUrl]); - - const policyDetailsClickHandler = useNavigateByRouterEventHandler(policyDetailsRoutePath); - const detailsResultsPolicy = useMemo(() => { return [ { @@ -133,14 +125,12 @@ export const HostDetails = memo(({ details }: { details: HostMetadata }) => { }), description: ( <> - {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} - {details.Endpoint.policy.applied.name} - + ), }, @@ -171,14 +161,7 @@ export const HostDetails = memo(({ details }: { details: HostMetadata }) => { ), }, ]; - }, [ - details, - policyResponseUri, - policyStatus, - policyStatusClickHandler, - policyDetailsRouteUrl, - policyDetailsClickHandler, - ]); + }, [details, policyResponseUri, policyStatus, policyStatusClickHandler]); const detailsResultsLower = useMemo(() => { return [ { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index c5ed71cba46d9..e38ef1bd5fe86 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -46,9 +46,10 @@ import { AgentConfigDetailsDeployAgentAction, } from '../../../../../../ingest_manager/public'; import { SecurityPageName } from '../../../../app/types'; -import { getHostListPath, getHostDetailsPath, getPolicyDetailPath } from '../../../common/routing'; +import { getHostListPath, getHostDetailsPath } from '../../../common/routing'; import { useFormatUrl } from '../../../../common/components/link_to'; import { HostAction } from '../store/action'; +import { HostPolicyLink } from './components/host_policy_link'; const HostListNavLink = memo<{ name: string; @@ -241,15 +242,14 @@ export const HostList = () => { truncateText: true, // eslint-disable-next-line react/display-name render: (policy: HostInfo['metadata']['Endpoint']['policy']['applied']) => { - const toRoutePath = getPolicyDetailPath(policy.id); - const toRouteUrl = formatUrl(toRoutePath); return ( - + + {policy.name} + ); }, }, diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/services/ingest.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/services/ingest.ts index 48b6bedf50fd8..c6e6146f4d5e4 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/services/ingest.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_list/services/ingest.ts @@ -12,12 +12,15 @@ import { DeletePackageConfigsRequest, PACKAGE_CONFIG_SAVED_OBJECT_TYPE, GetPackagesResponse, + GetAgentConfigsRequest, + GetAgentConfigsResponse, } from '../../../../../../../../ingest_manager/common'; import { GetPolicyListResponse, GetPolicyResponse, UpdatePolicyResponse } from '../../../types'; import { NewPolicyData } from '../../../../../../../common/endpoint/types'; const INGEST_API_ROOT = `/api/ingest_manager`; export const INGEST_API_PACKAGE_CONFIGS = `${INGEST_API_ROOT}/package_configs`; +const INGEST_API_AGENT_CONFIGS = `${INGEST_API_ROOT}/agent_configs`; const INGEST_API_FLEET = `${INGEST_API_ROOT}/fleet`; const INGEST_API_FLEET_AGENT_STATUS = `${INGEST_API_FLEET}/agent-status`; export const INGEST_API_EPM_PACKAGES = `${INGEST_API_ROOT}/epm/packages`; @@ -75,6 +78,18 @@ export const sendDeletePackageConfig = ( }); }; +/** + * Retrieve a list of Agent Configurations + * @param http + * @param options + */ +export const sendGetAgentConfigList = ( + http: HttpStart, + options: HttpFetchOptions & GetAgentConfigsRequest +) => { + return http.get(INGEST_API_AGENT_CONFIGS, options); +}; + /** * Updates a package config * diff --git a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx index 54b30aca44a1f..97d1d11395c7f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx @@ -13,18 +13,14 @@ import { EuiToolTip, } from '@elastic/eui'; import { noop } from 'lodash/fp'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { connect, ConnectedProps, useDispatch, useSelector } from 'react-redux'; import styled from 'styled-components'; -import { SecurityPageName } from '../../../app/types'; import { FULL_SCREEN } from '../timeline/body/column_headers/translations'; -import { AllCasesModal } from '../../../cases/components/all_cases_modal'; import { EXIT_FULL_SCREEN } from '../../../common/components/exit_full_screen/translations'; -import { APP_ID, FULL_SCREEN_TOGGLED_CLASS_NAME } from '../../../../common/constants'; +import { FULL_SCREEN_TOGGLED_CLASS_NAME } from '../../../../common/constants'; import { useFullScreen } from '../../../common/containers/use_full_screen'; -import { getCaseDetailsUrl, getCreateCaseUrl } from '../../../common/components/link_to'; -import { useKibana } from '../../../common/lib/kibana'; import { State } from '../../../common/store'; import { TimelineId, TimelineType } from '../../../../common/types/timeline'; import { timelineSelectors } from '../../store/timeline'; @@ -32,12 +28,9 @@ import { timelineDefaults } from '../../store/timeline/defaults'; import { TimelineModel } from '../../store/timeline/model'; import { isFullScreen } from '../timeline/body/column_headers'; import { NewCase, ExistingCase } from '../timeline/properties/helpers'; -import { UNTITLED_TIMELINE } from '../timeline/properties/translations'; -import { - setInsertTimeline, - updateTimelineGraphEventId, -} from '../../../timelines/store/timeline/actions'; +import { updateTimelineGraphEventId } from '../../../timelines/store/timeline/actions'; import { Resolver } from '../../../resolver/view'; +import { useAllCasesModal } from '../../../cases/components/use_all_cases_modal'; import * as i18n from './translations'; @@ -112,35 +105,16 @@ const GraphOverlayComponent = ({ timelineType, }: OwnProps & PropsFromRedux) => { const dispatch = useDispatch(); - const { navigateToApp } = useKibana().services.application; const onCloseOverlay = useCallback(() => { dispatch(updateTimelineGraphEventId({ id: timelineId, graphEventId: '' })); }, [dispatch, timelineId]); - const [showCaseModal, setShowCaseModal] = useState(false); - const onOpenCaseModal = useCallback(() => setShowCaseModal(true), []); - const onCloseCaseModal = useCallback(() => setShowCaseModal(false), [setShowCaseModal]); + const currentTimeline = useSelector((state: State) => timelineSelectors.selectTimeline(state, timelineId) ); - const onRowClick = useCallback( - (id?: string) => { - onCloseCaseModal(); - - navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { - path: id != null ? getCaseDetailsUrl({ id }) : getCreateCaseUrl(), - }).then(() => { - dispatch( - setInsertTimeline({ - graphEventId, - timelineId, - timelineSavedObjectId: currentTimeline.savedObjectId, - timelineTitle: title.length > 0 ? title : UNTITLED_TIMELINE, - }) - ); - }); - }, - [currentTimeline, dispatch, graphEventId, navigateToApp, onCloseCaseModal, timelineId, title] - ); + + const { Modal: AllCasesModal, onOpenModal: onOpenCaseModal } = useAllCasesModal({ timelineId }); + const { timelineFullScreen, setTimelineFullScreen, @@ -210,11 +184,7 @@ const GraphOverlayComponent = ({ databaseDocumentID={graphEventId} resolverComponentInstanceID={currentTimeline.id} /> - + ); }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.test.tsx index 58c213dc884ea..e0043f3b232da 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.test.tsx @@ -85,5 +85,100 @@ describe('Header', () => { expect(wrapper.find('[data-test-subj="timelineCallOutUnauthorized"]').exists()).toEqual(true); }); + + test('it renders the unauthorized call out with correct icon', () => { + const testProps = { + ...props, + filterManager: new FilterManager(mockUiSettingsForFilterManager), + showCallOutUnauthorizedMsg: true, + }; + + const wrapper = mount( + + + + ); + + expect( + wrapper.find('[data-test-subj="timelineCallOutUnauthorized"]').first().prop('iconType') + ).toEqual('alert'); + }); + + test('it renders the unauthorized call out with correct message', () => { + const testProps = { + ...props, + filterManager: new FilterManager(mockUiSettingsForFilterManager), + showCallOutUnauthorizedMsg: true, + }; + + const wrapper = mount( + + + + ); + + expect( + wrapper.find('[data-test-subj="timelineCallOutUnauthorized"]').first().prop('title') + ).toEqual( + 'You can use Timeline to investigate events, but you do not have the required permissions to save timelines for future use. If you need to save timelines, contact your Kibana administrator.' + ); + }); + + test('it renders the immutable timeline call out providers', () => { + const testProps = { + ...props, + filterManager: new FilterManager(mockUiSettingsForFilterManager), + showCallOutUnauthorizedMsg: false, + status: TimelineStatus.immutable, + }; + + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="timelineImmutableCallOut"]').exists()).toEqual(true); + }); + + test('it renders the immutable timeline call out with correct icon', () => { + const testProps = { + ...props, + filterManager: new FilterManager(mockUiSettingsForFilterManager), + showCallOutUnauthorizedMsg: false, + status: TimelineStatus.immutable, + }; + + const wrapper = mount( + + + + ); + + expect( + wrapper.find('[data-test-subj="timelineImmutableCallOut"]').first().prop('iconType') + ).toEqual('alert'); + }); + + test('it renders the immutable timeline call out with correct message', () => { + const testProps = { + ...props, + filterManager: new FilterManager(mockUiSettingsForFilterManager), + showCallOutUnauthorizedMsg: false, + status: TimelineStatus.immutable, + }; + + const wrapper = mount( + + + + ); + + expect( + wrapper.find('[data-test-subj="timelineImmutableCallOut"]').first().prop('title') + ).toEqual( + 'This timeline is immutable, therefore not allowed to save it within the security application, though you may continue to use the timeline to search and filter security events' + ); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx index aa3ce88acc200..75bfb52f2756b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx @@ -75,7 +75,7 @@ const TimelineHeaderComponent: React.FC = ({ data-test-subj="timelineImmutableCallOut" title={i18n.CALL_OUT_IMMUTIABLE} color="primary" - iconType="info" + iconType="alert" size="s" /> )} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx index 96a773507a30a..9eea95a0a9b1a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx @@ -6,7 +6,6 @@ import React, { useState, useCallback, useMemo } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; import { TimelineStatusLiteral, TimelineTypeLiteral } from '../../../../../common/types/timeline'; import { useThrottledResizeObserver } from '../../../../common/components/utils'; import { Note } from '../../../../common/lib/note'; @@ -17,15 +16,7 @@ import { AssociateNote, UpdateNote } from '../../notes/helpers'; import { TimelineProperties } from './styles'; import { PropertiesRight } from './properties_right'; import { PropertiesLeft } from './properties_left'; -import { AllCasesModal } from '../../../../cases/components/all_cases_modal'; -import { SecurityPageName } from '../../../../app/types'; -import * as i18n from './translations'; -import { State } from '../../../../common/store'; -import { timelineSelectors } from '../../../store/timeline'; -import { setInsertTimeline } from '../../../store/timeline/actions'; -import { useKibana } from '../../../../common/lib/kibana'; -import { APP_ID } from '../../../../../common/constants'; -import { getCaseDetailsUrl, getCreateCaseUrl } from '../../../../common/components/link_to'; +import { useAllCasesModal } from '../../../../cases/components/use_all_cases_modal'; type UpdateIsFavorite = ({ id, isFavorite }: { id: string; isFavorite: boolean }) => void; type UpdateTitle = ({ id, title }: { id: string; title: string }) => void; @@ -86,12 +77,10 @@ export const Properties = React.memo( updateTitle, usersViewing, }) => { - const { navigateToApp } = useKibana().services.application; const { ref, width = 0 } = useThrottledResizeObserver(300); const [showActions, setShowActions] = useState(false); const [showNotes, setShowNotes] = useState(false); const [showTimelineModal, setShowTimelineModal] = useState(false); - const dispatch = useDispatch(); const onButtonClick = useCallback(() => setShowActions(!showActions), [showActions]); const onToggleShowNotes = useCallback(() => setShowNotes(!showNotes), [showNotes]); @@ -103,32 +92,7 @@ export const Properties = React.memo( setShowTimelineModal(true); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const [showCaseModal, setShowCaseModal] = useState(false); - const onCloseCaseModal = useCallback(() => setShowCaseModal(false), []); - const onOpenCaseModal = useCallback(() => setShowCaseModal(true), []); - const currentTimeline = useSelector((state: State) => - timelineSelectors.selectTimeline(state, timelineId) - ); - - const onRowClick = useCallback( - (id?: string) => { - onCloseCaseModal(); - - navigateToApp(`${APP_ID}:${SecurityPageName.case}`, { - path: id != null ? getCaseDetailsUrl({ id }) : getCreateCaseUrl(), - }).then(() => - dispatch( - setInsertTimeline({ - graphEventId, - timelineId, - timelineSavedObjectId: currentTimeline.savedObjectId, - timelineTitle: title.length > 0 ? title : i18n.UNTITLED_TIMELINE, - }) - ) - ); - }, - [currentTimeline, dispatch, graphEventId, navigateToApp, onCloseCaseModal, timelineId, title] - ); + const { Modal: AllCasesModal, onOpenModal: onOpenCaseModal } = useAllCasesModal({ timelineId }); const datePickerWidth = useMemo( () => @@ -195,11 +159,7 @@ export const Properties = React.memo( updateNote={updateNote} usersViewing={usersViewing} /> - + ); } diff --git a/x-pack/test/functional/apps/monitoring/time_filter.js b/x-pack/test/functional/apps/monitoring/time_filter.js index d7ffdb4a7900d..11557d995218e 100644 --- a/x-pack/test/functional/apps/monitoring/time_filter.js +++ b/x-pack/test/functional/apps/monitoring/time_filter.js @@ -7,6 +7,8 @@ import expect from '@kbn/expect'; import { getLifecycleMethods } from './_get_lifecycle_methods'; +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + export default function ({ getService, getPageObjects }) { const PageObjects = getPageObjects(['header', 'timePicker']); const testSubjects = getService('testSubjects'); @@ -35,6 +37,11 @@ export default function ({ getService, getPageObjects }) { }); it('should send another request when changing the time picker', async () => { + /** + * TODO: The value should either be removed or lowered after: + * https://github.com/elastic/kibana/issues/72997 is resolved + */ + await delay(3000); await PageObjects.timePicker.setAbsoluteRange( 'Aug 15, 2016 @ 21:00:00.000', 'Aug 16, 2016 @ 00:00:00.000'