From 865420091985d345d75a995ab6ffb370c329a994 Mon Sep 17 00:00:00 2001 From: Nathan L Smith Date: Fri, 4 Sep 2020 11:37:17 -0500 Subject: [PATCH 01/17] Add CSM app to CODEOWNERS (#76793) --- .github/CODEOWNERS | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b4563dd1f9a9c..bcb4774475849 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -63,6 +63,13 @@ /src/plugins/apm_oss/ @elastic/apm-ui /src/apm.js @watson @vigneshshanmugam +# Client Side Monitoring (lives in APM directories but owned by Uptime) +/x-pack/plugins/apm/e2e/cypress/support/step_definitions/rum @elastic/uptime +/x-pack/plugins/apm/public/application/csmApp.tsx @elastic/uptime +/x-pack/plugins/apm/public/components/app/RumDashboard @elastic/uptime +/x-pack/plugins/apm/server/lib/rum_client @elastic/uptime +/x-pack/plugins/apm/server/routes/rum_client.ts @elastic/uptime + # Beats /x-pack/legacy/plugins/beats_management/ @elastic/beats From ee3c8d081ad3cfe09aca104320c0f601c5ef8752 Mon Sep 17 00:00:00 2001 From: Quynh Nguyen <43350163+qn895@users.noreply.github.com> Date: Fri, 4 Sep 2020 11:46:17 -0500 Subject: [PATCH 02/17] [ML] Add option to Advanced Settings to set default time range filter for AD jobs (#76347) --- .../plugins/ml/common/constants/settings.ts | 8 +++ .../custom_hooks/use_create_ad_links.ts | 37 ++++++++++ .../job_selector/job_selector_flyout.tsx | 2 +- .../job_selector_table/job_selector_table.js | 2 +- .../public/application/explorer/explorer.js | 18 ++++- .../components/job_actions/results.js | 16 ++--- .../anomaly_detection_panel/actions.tsx | 6 +- .../application/routing/routes/explorer.tsx | 6 +- .../routing/routes/timeseriesexplorer.tsx | 5 ++ .../application/services/job_service.d.ts | 3 +- .../application/services/job_service.js | 68 ++++++++++++++----- .../timeseriesexplorer/timeseriesexplorer.js | 17 +++++ .../ml/public/application/util/date_utils.ts | 10 ++- .../ml/server/lib/register_settings.ts | 43 +++++++++++- 14 files changed, 202 insertions(+), 39 deletions(-) create mode 100644 x-pack/plugins/ml/public/application/components/custom_hooks/use_create_ad_links.ts diff --git a/x-pack/plugins/ml/common/constants/settings.ts b/x-pack/plugins/ml/common/constants/settings.ts index 2df2ecd22e078..bab2aa2f2a0ae 100644 --- a/x-pack/plugins/ml/common/constants/settings.ts +++ b/x-pack/plugins/ml/common/constants/settings.ts @@ -5,3 +5,11 @@ */ export const FILE_DATA_VISUALIZER_MAX_FILE_SIZE = 'ml:fileDataVisualizerMaxFileSize'; +export const ANOMALY_DETECTION_ENABLE_TIME_RANGE = 'ml:anomalyDetection:results:enableTimeDefaults'; +export const ANOMALY_DETECTION_DEFAULT_TIME_RANGE = 'ml:anomalyDetection:results:timeDefaults'; + +export const DEFAULT_AD_RESULTS_TIME_FILTER = { + from: 'now-15m', + to: 'now', +}; +export const DEFAULT_ENABLE_AD_RESULTS_TIME_FILTER = false; diff --git a/x-pack/plugins/ml/public/application/components/custom_hooks/use_create_ad_links.ts b/x-pack/plugins/ml/public/application/components/custom_hooks/use_create_ad_links.ts new file mode 100644 index 0000000000000..368e758a027c4 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/custom_hooks/use_create_ad_links.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { useCallback } from 'react'; +import { useMlKibana, useUiSettings } from '../../contexts/kibana'; +import { + ANOMALY_DETECTION_DEFAULT_TIME_RANGE, + ANOMALY_DETECTION_ENABLE_TIME_RANGE, +} from '../../../../common/constants/settings'; +import { mlJobService } from '../../services/job_service'; + +export const useCreateADLinks = () => { + const { + services: { + http: { basePath }, + }, + } = useMlKibana(); + + const useUserTimeSettings = useUiSettings().get(ANOMALY_DETECTION_ENABLE_TIME_RANGE); + const userTimeSettings = useUiSettings().get(ANOMALY_DETECTION_DEFAULT_TIME_RANGE); + const createLinkWithUserDefaults = useCallback( + (location, jobList) => { + const resultsPageUrl = mlJobService.createResultsUrlForJobs( + jobList, + location, + useUserTimeSettings === true && userTimeSettings !== undefined + ? userTimeSettings + : undefined + ); + return `${basePath.get()}/app/ml${resultsPageUrl}`; + }, + [basePath] + ); + return { createLinkWithUserDefaults }; +}; diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx index 62a74ed142ccf..6c57b3d08180d 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx +++ b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx @@ -237,7 +237,7 @@ export const JobSelectorFlyout: FC = ({ { @@ -298,7 +315,6 @@ export class Explorer extends React.Component {
- {stoppedPartitions && ( {singleMetricVisible && ( = ({ jobsList }) => { values: { jobsCount: jobsList.length, jobId: jobsList[0] && jobsList[0].id }, } ); + const { createLinkWithUserDefaults } = useCreateADLinks(); return ( = ({ jobsWithTim const [globalState, setGlobalState] = useUrlState('_g'); const [lastRefresh, setLastRefresh] = useState(0); const [stoppedPartitions, setStoppedPartitions] = useState(); - + const [invalidTimeRangeError, setInValidTimeRangeError] = useState(false); const timefilter = useTimefilter({ timeRangeSelector: true, autoRefreshSelector: true }); const { jobIds } = useJobSelection(jobsWithTimeRange); @@ -99,6 +99,9 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim // `timefilter.getBounds()` to update `bounds` in this component's state. useEffect(() => { if (globalState?.time !== undefined) { + if (globalState.time.mode === 'invalid') { + setInValidTimeRangeError(true); + } timefilter.setTime({ from: globalState.time.from, to: globalState.time.to, @@ -236,6 +239,7 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim showCharts, severity: tableSeverity.val, stoppedPartitions, + invalidTimeRangeError, }} />
diff --git a/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx b/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx index 1f122ed18a851..817c975415997 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx @@ -91,6 +91,7 @@ export const TimeSeriesExplorerUrlStateManager: FC(); const timefilter = useTimefilter({ timeRangeSelector: true, autoRefreshSelector: true }); + const [invalidTimeRangeError, setInValidTimeRangeError] = useState(false); const refresh = useRefresh(); useEffect(() => { @@ -114,6 +115,9 @@ export const TimeSeriesExplorerUrlStateManager: FC(undefined); useEffect(() => { if (globalState?.time !== undefined) { + if (globalState.time.mode === 'invalid') { + setInValidTimeRangeError(true); + } timefilter.setTime({ from: globalState.time.from, to: globalState.time.to, @@ -300,6 +304,7 @@ export const TimeSeriesExplorerUrlStateManager: FC ); diff --git a/x-pack/plugins/ml/public/application/services/job_service.d.ts b/x-pack/plugins/ml/public/application/services/job_service.d.ts index 2134d157e1baa..30b2ec044285a 100644 --- a/x-pack/plugins/ml/public/application/services/job_service.d.ts +++ b/x-pack/plugins/ml/public/application/services/job_service.d.ts @@ -5,6 +5,7 @@ */ import { SearchResponse } from 'elasticsearch'; +import { TimeRange } from 'src/plugins/data/common/query/timefilter/types'; import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; import { Calendar } from '../../../common/types/calendars'; @@ -15,7 +16,7 @@ export interface ExistingJobsAndGroups { declare interface JobService { jobs: CombinedJob[]; - createResultsUrlForJobs: (jobs: any[], target: string) => string; + createResultsUrlForJobs: (jobs: any[], target: string, timeRange?: TimeRange) => string; tempJobCloningObjects: { job: any; skipTimeRangeStep: boolean; diff --git a/x-pack/plugins/ml/public/application/services/job_service.js b/x-pack/plugins/ml/public/application/services/job_service.js index 704d76059f75c..640f63617b7d4 100644 --- a/x-pack/plugins/ml/public/application/services/job_service.js +++ b/x-pack/plugins/ml/public/application/services/job_service.js @@ -21,7 +21,7 @@ import { ML_DATA_PREVIEW_COUNT } from '../../../common/util/job_utils'; import { TIME_FORMAT } from '../../../common/constants/time_format'; import { parseInterval } from '../../../common/util/parse_interval'; import { toastNotificationServiceProvider } from '../services/toast_notification_service'; - +import { validateTimeRange } from '../util/date_utils'; const msgs = mlMessageBarService; let jobs = []; let datafeedIds = {}; @@ -790,8 +790,8 @@ class JobService { return groups; } - createResultsUrlForJobs(jobsList, resultsPage) { - return createResultsUrlForJobs(jobsList, resultsPage); + createResultsUrlForJobs(jobsList, resultsPage, timeRange) { + return createResultsUrlForJobs(jobsList, resultsPage, timeRange); } createResultsUrl(jobIds, from, to, resultsPage) { @@ -932,31 +932,54 @@ function createJobStats(jobsList, jobStats) { jobStats.activeNodes.value = Object.keys(mlNodes).length; } -function createResultsUrlForJobs(jobsList, resultsPage) { +function createResultsUrlForJobs(jobsList, resultsPage, userTimeRange) { let from = undefined; let to = undefined; - if (jobsList.length === 1) { - from = jobsList[0].earliestTimestampMs; - to = jobsList[0].latestResultsTimestampMs; // Will be max(latest source data, latest bucket results) + let mode = 'absolute'; + const jobIds = jobsList.map((j) => j.id); + + // if the custom default time filter is set and enabled in advanced settings + // if time is either absolute date or proper datemath format + if (validateTimeRange(userTimeRange)) { + from = userTimeRange.from; + to = userTimeRange.to; + // if both pass datemath's checks but are not technically absolute dates, use 'quick' + // e.g. "now-15m" "now+1d" + const fromFieldAValidDate = moment(userTimeRange.from).isValid(); + const toFieldAValidDate = moment(userTimeRange.to).isValid(); + if (!fromFieldAValidDate && !toFieldAValidDate) { + return createResultsUrl(jobIds, from, to, resultsPage, 'quick'); + } } else { - const jobsWithData = jobsList.filter((j) => j.earliestTimestampMs !== undefined); - if (jobsWithData.length > 0) { - from = Math.min(...jobsWithData.map((j) => j.earliestTimestampMs)); - to = Math.max(...jobsWithData.map((j) => j.latestResultsTimestampMs)); + // if time range is specified but with incorrect format + // change back to the default time range but alert the user + // that the advanced setting config is invalid + if (userTimeRange) { + mode = 'invalid'; + } + + if (jobsList.length === 1) { + from = jobsList[0].earliestTimestampMs; + to = jobsList[0].latestResultsTimestampMs; // Will be max(latest source data, latest bucket results) + } else { + const jobsWithData = jobsList.filter((j) => j.earliestTimestampMs !== undefined); + if (jobsWithData.length > 0) { + from = Math.min(...jobsWithData.map((j) => j.earliestTimestampMs)); + to = Math.max(...jobsWithData.map((j) => j.latestResultsTimestampMs)); + } } } const fromString = moment(from).format(TIME_FORMAT); // Defaults to 'now' if 'from' is undefined const toString = moment(to).format(TIME_FORMAT); // Defaults to 'now' if 'to' is undefined - const jobIds = jobsList.map((j) => j.id); - return createResultsUrl(jobIds, fromString, toString, resultsPage); + return createResultsUrl(jobIds, fromString, toString, resultsPage, mode); } -function createResultsUrl(jobIds, start, end, resultsPage) { +function createResultsUrl(jobIds, start, end, resultsPage, mode = 'absolute') { const idString = jobIds.map((j) => `'${j}'`).join(','); - const from = moment(start).toISOString(); - const to = moment(end).toISOString(); + let from; + let to; let path = ''; if (resultsPage !== undefined) { @@ -964,9 +987,20 @@ function createResultsUrl(jobIds, start, end, resultsPage) { path += resultsPage; } + if (mode === 'quick') { + from = start; + to = end; + } else { + from = moment(start).toISOString(); + to = moment(end).toISOString(); + } + path += `?_g=(ml:(jobIds:!(${idString}))`; path += `,refreshInterval:(display:Off,pause:!f,value:0),time:(from:'${from}'`; - path += `,mode:absolute,to:'${to}'`; + path += `,to:'${to}'`; + if (mode === 'invalid') { + path += `,mode:invalid`; + } path += "))&_a=(query:(query_string:(analyze_wildcard:!t,query:'*')))"; return path; diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js index 83a789074d353..0e99d64cf202f 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js @@ -83,6 +83,7 @@ import { getFocusData, } from './timeseriesexplorer_utils'; import { EMPTY_FIELD_VALUE_LABEL } from './components/entity_control/entity_control'; +import { ANOMALY_DETECTION_DEFAULT_TIME_RANGE } from '../../../common/constants/settings'; // Used to indicate the chart is being plotted across // all partition field values, where the cardinality of the field cannot be @@ -833,6 +834,22 @@ export class TimeSeriesExplorer extends React.Component { } componentDidMount() { + // if timeRange used in the url is incorrect + // perhaps due to user's advanced setting using incorrect date-maths + const { invalidTimeRangeError } = this.props; + if (invalidTimeRangeError) { + const toastNotifications = getToastNotifications(); + toastNotifications.addWarning( + i18n.translate('xpack.ml.timeSeriesExplorer.invalidTimeRangeInUrlCallout', { + defaultMessage: + 'The time filter was changed to the full range for this job due to an invalid default time filter. Check the advanced settings for {field}.', + values: { + field: ANOMALY_DETECTION_DEFAULT_TIME_RANGE, + }, + }) + ); + } + // Required to redraw the time series chart when the container is resized. this.resizeChecker = new ResizeChecker(this.resizeRef.current); this.resizeChecker.on('resize', () => { diff --git a/x-pack/plugins/ml/public/application/util/date_utils.ts b/x-pack/plugins/ml/public/application/util/date_utils.ts index 8f3215b6cd211..21adc0b4b9c66 100644 --- a/x-pack/plugins/ml/public/application/util/date_utils.ts +++ b/x-pack/plugins/ml/public/application/util/date_utils.ts @@ -8,7 +8,8 @@ // @ts-ignore import { formatDate } from '@elastic/eui/lib/services/format'; - +import dateMath from '@elastic/datemath'; +import { TimeRange } from '../../../../../../src/plugins/data/common'; export function formatHumanReadableDate(ts: number) { return formatDate(ts, 'MMMM Do YYYY'); } @@ -20,3 +21,10 @@ export function formatHumanReadableDateTime(ts: number): string { export function formatHumanReadableDateTimeSeconds(ts: number) { return formatDate(ts, 'MMMM Do YYYY, HH:mm:ss'); } + +export function validateTimeRange(time?: TimeRange): boolean { + if (!time) return false; + const momentDateFrom = dateMath.parse(time.from); + const momentDateTo = dateMath.parse(time.to); + return !!(momentDateFrom && momentDateFrom.isValid() && momentDateTo && momentDateTo.isValid()); +} diff --git a/x-pack/plugins/ml/server/lib/register_settings.ts b/x-pack/plugins/ml/server/lib/register_settings.ts index 38b1f5e3fc083..a9ee24fbb5cea 100644 --- a/x-pack/plugins/ml/server/lib/register_settings.ts +++ b/x-pack/plugins/ml/server/lib/register_settings.ts @@ -7,7 +7,13 @@ import { CoreSetup } from 'kibana/server'; import { i18n } from '@kbn/i18n'; import { schema } from '@kbn/config-schema'; -import { FILE_DATA_VISUALIZER_MAX_FILE_SIZE } from '../../common/constants/settings'; +import { + FILE_DATA_VISUALIZER_MAX_FILE_SIZE, + ANOMALY_DETECTION_DEFAULT_TIME_RANGE, + ANOMALY_DETECTION_ENABLE_TIME_RANGE, + DEFAULT_AD_RESULTS_TIME_FILTER, + DEFAULT_ENABLE_AD_RESULTS_TIME_FILTER, +} from '../../common/constants/settings'; import { MAX_FILE_SIZE } from '../../common/constants/file_datavisualizer'; export function registerKibanaSettings(coreSetup: CoreSetup) { @@ -30,5 +36,40 @@ export function registerKibanaSettings(coreSetup: CoreSetup) { }), }, }, + [ANOMALY_DETECTION_ENABLE_TIME_RANGE]: { + name: i18n.translate('xpack.ml.advancedSettings.enableAnomalyDetectionDefaultTimeRangeName', { + defaultMessage: 'Enable time filter defaults for anomaly detection results', + }), + value: DEFAULT_ENABLE_AD_RESULTS_TIME_FILTER, + schema: schema.boolean(), + description: i18n.translate( + 'xpack.ml.advancedSettings.enableAnomalyDetectionDefaultTimeRangeDesc', + { + defaultMessage: + 'Use the default time filter in the Single Metric Viewer and Anomaly Explorer. If not enabled, the results for the full time range of the job are displayed.', + } + ), + category: ['Machine Learning'], + }, + [ANOMALY_DETECTION_DEFAULT_TIME_RANGE]: { + name: i18n.translate('xpack.ml.advancedSettings.anomalyDetectionDefaultTimeRangeName', { + defaultMessage: 'Time filter defaults for anomaly detection results', + }), + type: 'json', + value: JSON.stringify(DEFAULT_AD_RESULTS_TIME_FILTER, null, 2), + description: i18n.translate( + 'xpack.ml.advancedSettings.anomalyDetectionDefaultTimeRangeDesc', + { + defaultMessage: + 'The time filter selection to use when viewing anomaly detection job results.', + } + ), + schema: schema.object({ + from: schema.string(), + to: schema.string(), + }), + requiresPageReload: true, + category: ['Machine Learning'], + }, }); } From bf3f2fba994ba0ebe9766e7406350d0058e1aa12 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Fri, 4 Sep 2020 18:56:15 +0200 Subject: [PATCH 03/17] [Dashboard] Update Index Patterns when Child Index Patterns Change (#76356) Added the ability for Dashboard to react to index pattern changes in its children's output. --- .../application/dashboard_app_controller.tsx | 49 ++++++++++++++----- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/src/plugins/dashboard/public/application/dashboard_app_controller.tsx b/src/plugins/dashboard/public/application/dashboard_app_controller.tsx index 0d20fdee07df5..212b54be9ae04 100644 --- a/src/plugins/dashboard/public/application/dashboard_app_controller.tsx +++ b/src/plugins/dashboard/public/application/dashboard_app_controller.tsx @@ -24,9 +24,18 @@ import { EuiCheckboxGroupIdToSelectedMap } from '@elastic/eui/src/components/for import React, { useState, ReactElement } from 'react'; import ReactDOM from 'react-dom'; import angular from 'angular'; +import deepEqual from 'fast-deep-equal'; import { Observable, pipe, Subscription, merge } from 'rxjs'; -import { filter, map, debounceTime, mapTo, startWith, switchMap } from 'rxjs/operators'; +import { + filter, + map, + debounceTime, + mapTo, + startWith, + switchMap, + distinctUntilChanged, +} from 'rxjs/operators'; import { History } from 'history'; import { SavedObjectSaveOpts } from 'src/plugins/saved_objects/public'; import { NavigationPublicPluginStart as NavigationStart } from 'src/plugins/navigation/public'; @@ -279,6 +288,12 @@ export class DashboardAppController { const updateIndexPatternsOperator = pipe( filter((container: DashboardContainer) => !!container && !isErrorEmbeddable(container)), map(getDashboardIndexPatterns), + distinctUntilChanged((a, b) => + deepEqual( + a.map((ip) => ip.id), + b.map((ip) => ip.id) + ) + ), // using switchMap for previous task cancellation switchMap((panelIndexPatterns: IndexPattern[]) => { return new Observable((observer) => { @@ -405,17 +420,29 @@ export class DashboardAppController { ) : null; }; - outputSubscription = new Subscription(); - outputSubscription.add( - dashboardContainer - .getOutput$() - .pipe( - mapTo(dashboardContainer), - startWith(dashboardContainer), // to trigger initial index pattern update - updateIndexPatternsOperator + outputSubscription = merge( + // output of dashboard container itself + dashboardContainer.getOutput$(), + // plus output of dashboard container children, + // children may change, so make sure we subscribe/unsubscribe with switchMap + dashboardContainer.getOutput$().pipe( + map(() => dashboardContainer!.getChildIds()), + distinctUntilChanged(deepEqual), + switchMap((newChildIds: string[]) => + merge( + ...newChildIds.map((childId) => + dashboardContainer!.getChild(childId).getOutput$() + ) + ) ) - .subscribe() - ); + ) + ) + .pipe( + mapTo(dashboardContainer), + startWith(dashboardContainer), // to trigger initial index pattern update + updateIndexPatternsOperator + ) + .subscribe(); inputSubscription = dashboardContainer.getInput$().subscribe(() => { let dirty = false; From 8a9c486e43c981127d6ea4247dd77eb2e7154522 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20Kopyci=C5=84ski?= Date: Fri, 4 Sep 2020 18:59:10 +0200 Subject: [PATCH 04/17] [Security Solution] Refactor NetworkTopNFlow to use Search Strategy (#76249) --- .../common/search_strategy/common/index.ts | 4 - .../security_solution/index.ts | 6 + .../security_solution/network/common/index.ts | 21 ++ .../security_solution/network/index.ts | 2 + .../network/top_countries/index.ts | 26 +- .../network/top_n_flow/index.ts | 124 ++++++++ .../network_top_countries/index.tsx | 4 +- .../containers/network_top_n_flow/index.tsx | 289 ++++++++++-------- .../network_top_n_flow/translations.ts | 21 ++ .../network_top_n_flow_query_table.tsx | 74 ++--- .../pages/navigation/ips_query_tab_body.tsx | 72 ++--- .../factory/network/index.ts | 2 + .../factory/network/top_n_flow/helpers.ts | 126 ++++++++ .../factory/network/top_n_flow/index.ts | 58 ++++ .../query.top_n_flow_network.dsl.ts | 157 ++++++++++ 15 files changed, 751 insertions(+), 235 deletions(-) create mode 100644 x-pack/plugins/security_solution/common/search_strategy/security_solution/network/top_n_flow/index.ts create mode 100644 x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/translations.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/top_n_flow/helpers.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/top_n_flow/index.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/top_n_flow/query.top_n_flow_network.dsl.ts diff --git a/x-pack/plugins/security_solution/common/search_strategy/common/index.ts b/x-pack/plugins/security_solution/common/search_strategy/common/index.ts index 0576871a2bf81..b55226b08b800 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/common/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/common/index.ts @@ -80,10 +80,6 @@ export interface Explanation { details: Explanation[]; } -export interface TotalValue { - value: number; - relation: string; -} export interface ShardsResponse { total: number; successful: number; diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts index 17adf38559a9e..7721f2ae97d75 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts @@ -27,6 +27,8 @@ import { NetworkHttpRequestOptions, NetworkTopCountriesStrategyResponse, NetworkTopCountriesRequestOptions, + NetworkTopNFlowStrategyResponse, + NetworkTopNFlowRequestOptions, } from './network'; import { DocValueFields, @@ -77,6 +79,8 @@ export type StrategyResponseType = T extends HostsQ ? NetworkHttpStrategyResponse : T extends NetworkQueries.topCountries ? NetworkTopCountriesStrategyResponse + : T extends NetworkQueries.topNFlow + ? NetworkTopNFlowStrategyResponse : never; export type StrategyRequestType = T extends HostsQueries.hosts @@ -95,4 +99,6 @@ export type StrategyRequestType = T extends HostsQu ? NetworkHttpRequestOptions : T extends NetworkQueries.topCountries ? NetworkTopCountriesRequestOptions + : T extends NetworkQueries.topNFlow + ? NetworkTopNFlowRequestOptions : never; diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/common/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/common/index.ts index a6ae956a42187..66676569b3c9e 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/common/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/common/index.ts @@ -4,7 +4,28 @@ * you may not use this file except in compliance with the Elastic License. */ +import { GeoEcs } from '../../../../ecs/geo'; +import { Maybe } from '../../..'; + +export enum NetworkTopTablesFields { + bytes_in = 'bytes_in', + bytes_out = 'bytes_out', + flows = 'flows', + destination_ips = 'destination_ips', + source_ips = 'source_ips', +} + export enum FlowTargetSourceDest { destination = 'destination', source = 'source', } + +export interface TopNetworkTablesEcsField { + bytes_in?: Maybe; + bytes_out?: Maybe; +} + +export interface GeoItem { + geo?: Maybe; + flowTarget?: Maybe; +} diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/index.ts index ac5e6fdacc94b..2992ee32f8ac7 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/index.ts @@ -8,9 +8,11 @@ export * from './common'; export * from './http'; export * from './tls'; export * from './top_countries'; +export * from './top_n_flow'; export enum NetworkQueries { http = 'http', tls = 'tls', topCountries = 'topCountries', + topNFlow = 'topNFlow', } diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/top_countries/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/top_countries/index.ts index 3188a26dd69fd..f499db82d6479 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/top_countries/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/top_countries/index.ts @@ -5,18 +5,14 @@ */ import { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; -import { GeoEcs } from '../../../../ecs/geo'; import { CursorType, Inspect, Maybe, PageInfoPaginated } from '../../../common'; import { RequestOptionsPaginated } from '../..'; -import { FlowTargetSourceDest } from '../common'; - -export enum NetworkTopTablesFields { - bytes_in = 'bytes_in', - bytes_out = 'bytes_out', - flows = 'flows', - destination_ips = 'destination_ips', - source_ips = 'source_ips', -} +import { + GeoItem, + FlowTargetSourceDest, + NetworkTopTablesFields, + TopNetworkTablesEcsField, +} from '../common'; export enum NetworkDnsFields { dnsName = 'dnsName', @@ -33,11 +29,6 @@ export enum FlowTarget { source = 'source', } -export interface GeoItem { - geo?: Maybe; - flowTarget?: Maybe; -} - export interface TopCountriesItemSource { country?: Maybe; destination_ips?: Maybe; @@ -79,11 +70,6 @@ export interface TopCountriesItemDestination { source_ips?: Maybe; } -export interface TopNetworkTablesEcsField { - bytes_in?: Maybe; - bytes_out?: Maybe; -} - export interface NetworkTopCountriesBuckets { country: string; key: string; diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/top_n_flow/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/top_n_flow/index.ts new file mode 100644 index 0000000000000..d6be2d29c6eda --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/top_n_flow/index.ts @@ -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 { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; +import { + GeoItem, + FlowTargetSourceDest, + TopNetworkTablesEcsField, + NetworkTopTablesFields, +} from '../common'; +import { + CursorType, + Inspect, + Maybe, + PageInfoPaginated, + TotalValue, + GenericBuckets, +} from '../../../common'; +import { RequestOptionsPaginated } from '../..'; + +export interface NetworkTopNFlowRequestOptions + extends RequestOptionsPaginated { + flowTarget: FlowTargetSourceDest; + ip?: Maybe; +} + +export interface NetworkTopNFlowStrategyResponse extends IEsSearchResponse { + edges: NetworkTopNFlowEdges[]; + totalCount: number; + pageInfo: PageInfoPaginated; + inspect?: Maybe; +} + +export interface NetworkTopNFlowEdges { + node: NetworkTopNFlowItem; + cursor: CursorType; +} + +export interface NetworkTopNFlowItem { + _id?: Maybe; + source?: Maybe; + destination?: Maybe; + network?: Maybe; +} + +export interface TopNFlowItemSource { + autonomous_system?: Maybe; + domain?: Maybe; + ip?: Maybe; + location?: Maybe; + flows?: Maybe; + destination_ips?: Maybe; +} + +export interface AutonomousSystemItem { + name?: Maybe; + number?: Maybe; +} + +export interface TopNFlowItemDestination { + autonomous_system?: Maybe; + domain?: Maybe; + ip?: Maybe; + location?: Maybe; + flows?: Maybe; + source_ips?: Maybe; +} + +export interface AutonomousSystemHit { + doc_count: number; + top_as: { + hits: { + total: TotalValue | number; + max_score: number | null; + hits: Array<{ + _source: T; + sort?: [number]; + _index?: string; + _type?: string; + _id?: string; + _score?: number | null; + }>; + }; + }; +} + +export interface NetworkTopNFlowBuckets { + key: string; + autonomous_system: AutonomousSystemHit; + bytes_in: { + value: number; + }; + bytes_out: { + value: number; + }; + domain: { + buckets: GenericBuckets[]; + }; + location: LocationHit; + flows: number; + destination_ips?: number; + source_ips?: number; +} + +export interface LocationHit { + doc_count: number; + top_geo: { + hits: { + total: TotalValue | number; + max_score: number | null; + hits: Array<{ + _source: T; + sort?: [number]; + _index?: string; + _type?: string; + _id?: string; + _score?: number | null; + }>; + }; + }; +} diff --git a/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.tsx b/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.tsx index 6bed779d49638..747f5e4f502dd 100644 --- a/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.tsx @@ -50,14 +50,12 @@ interface UseNetworkTopCountries { endDate: string; startDate: string; skip: boolean; - id?: string; } export const useNetworkTopCountries = ({ endDate, filterQuery, flowTarget, - id = ID, skip, startDate, type, @@ -101,7 +99,7 @@ export const useNetworkTopCountries = ({ NetworkTopCountriesArgs >({ networkTopCountries: [], - id: ID, + id: `${ID}-${flowTarget}`, inspect: { dsl: [], response: [], diff --git a/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.tsx b/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.tsx index 770574b0813c1..cc0da816c57ec 100644 --- a/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.tsx @@ -4,161 +4,196 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getOr } from 'lodash/fp'; -import React from 'react'; -import { Query } from 'react-apollo'; -import { connect } from 'react-redux'; -import { compose } from 'redux'; +import { noop } from 'lodash/fp'; +import { useState, useEffect, useCallback, useRef } from 'react'; +import { shallowEqual, useSelector } from 'react-redux'; +import deepEqual from 'fast-deep-equal'; +import { ESTermQuery } from '../../../../common/typed_json'; import { DEFAULT_INDEX_KEY } from '../../../../common/constants'; +import { inputsModel, State } from '../../../common/store'; +import { useKibana } from '../../../common/lib/kibana'; +import { createFilter } from '../../../common/containers/helpers'; +import { generateTablePaginationOptions } from '../../../common/components/paginated_table/helpers'; +import { networkModel, networkSelectors } from '../../store'; import { FlowTargetSourceDest, - GetNetworkTopNFlowQuery, + NetworkQueries, NetworkTopNFlowEdges, - NetworkTopTablesSortField, + NetworkTopNFlowRequestOptions, + NetworkTopNFlowStrategyResponse, PageInfoPaginated, -} from '../../../graphql/types'; -import { withKibana, WithKibanaProps } from '../../../common/lib/kibana'; -import { inputsModel, inputsSelectors, State } from '../../../common/store'; -import { generateTablePaginationOptions } from '../../../common/components/paginated_table/helpers'; -import { createFilter, getDefaultFetchPolicy } from '../../../common/containers/helpers'; -import { - QueryTemplatePaginated, - QueryTemplatePaginatedProps, -} from '../../../common/containers/query_template_paginated'; -import { networkTopNFlowQuery } from './index.gql_query'; -import { networkModel, networkSelectors } from '../../store'; +} from '../../../../common/search_strategy'; +import { AbortError } from '../../../../../../../src/plugins/data/common'; +import { getInspectResponse } from '../../../helpers'; +import { InspectResponse } from '../../../types'; +import * as i18n from './translations'; const ID = 'networkTopNFlowQuery'; export interface NetworkTopNFlowArgs { id: string; - ip?: string; - inspect: inputsModel.InspectQuery; + inspect: InspectResponse; isInspected: boolean; - loading: boolean; loadPage: (newActivePage: number) => void; - networkTopNFlow: NetworkTopNFlowEdges[]; pageInfo: PageInfoPaginated; refetch: inputsModel.Refetch; + networkTopNFlow: NetworkTopNFlowEdges[]; totalCount: number; } -export interface OwnProps extends QueryTemplatePaginatedProps { - children: (args: NetworkTopNFlowArgs) => React.ReactNode; +interface UseNetworkTopNFlow { flowTarget: FlowTargetSourceDest; ip?: string; type: networkModel.NetworkType; + filterQuery?: ESTermQuery | string; + endDate: string; + startDate: string; + skip: boolean; } -export interface NetworkTopNFlowComponentReduxProps { - activePage: number; - isInspected: boolean; - limit: number; - sort: NetworkTopTablesSortField; -} +export const useNetworkTopNFlow = ({ + endDate, + filterQuery, + flowTarget, + skip, + startDate, + type, +}: UseNetworkTopNFlow): [boolean, NetworkTopNFlowArgs] => { + const getTopNFlowSelector = networkSelectors.topNFlowSelector(); + const { activePage, limit, sort } = useSelector( + (state: State) => getTopNFlowSelector(state, type, flowTarget), + shallowEqual + ); + const { data, notifications, uiSettings } = useKibana().services; + const refetch = useRef(noop); + const abortCtrl = useRef(new AbortController()); + const defaultIndex = uiSettings.get(DEFAULT_INDEX_KEY); + const [loading, setLoading] = useState(false); + + const [networkTopNFlowRequest, setTopNFlowRequest] = useState({ + defaultIndex, + factoryQueryType: NetworkQueries.topNFlow, + filterQuery: createFilter(filterQuery), + flowTarget, + pagination: generateTablePaginationOptions(activePage, limit), + sort, + timerange: { + interval: '12h', + from: startDate ? startDate : '', + to: endDate ? endDate : new Date(Date.now()).toISOString(), + }, + }); + + const wrappedLoadMore = useCallback( + (newActivePage: number) => { + setTopNFlowRequest((prevRequest) => ({ + ...prevRequest, + pagination: generateTablePaginationOptions(newActivePage, limit), + })); + }, + [limit] + ); -type NetworkTopNFlowProps = OwnProps & NetworkTopNFlowComponentReduxProps & WithKibanaProps; + const [networkTopNFlowResponse, setNetworkTopNFlowResponse] = useState({ + networkTopNFlow: [], + id: `${ID}-${flowTarget}`, + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + loadPage: wrappedLoadMore, + pageInfo: { + activePage: 0, + fakeTotalCount: 0, + showMorePagesIndicator: false, + }, + refetch: refetch.current, + totalCount: -1, + }); -class NetworkTopNFlowComponentQuery extends QueryTemplatePaginated< - NetworkTopNFlowProps, - GetNetworkTopNFlowQuery.Query, - GetNetworkTopNFlowQuery.Variables -> { - public render() { - const { - activePage, - children, - endDate, - flowTarget, - filterQuery, - kibana, - id = `${ID}-${flowTarget}`, - ip, - isInspected, - limit, - skip, - sourceId, - startDate, - sort, - } = this.props; - const variables: GetNetworkTopNFlowQuery.Variables = { - defaultIndex: kibana.services.uiSettings.get(DEFAULT_INDEX_KEY), - filterQuery: createFilter(filterQuery), - flowTarget, - inspect: isInspected, - ip, - pagination: generateTablePaginationOptions(activePage, limit), - sort, - sourceId, - timerange: { - interval: '12h', - from: startDate!, - to: endDate!, - }, - }; - return ( - - fetchPolicy={getDefaultFetchPolicy()} - notifyOnNetworkStatusChange - query={networkTopNFlowQuery} - skip={skip} - variables={variables} - > - {({ data, loading, fetchMore, networkStatus, refetch }) => { - const networkTopNFlow = getOr([], `source.NetworkTopNFlow.edges`, data); - this.setFetchMore(fetchMore); - this.setFetchMoreOptions((newActivePage: number) => ({ - variables: { - pagination: generateTablePaginationOptions(newActivePage, limit), + const networkTopNFlowSearch = useCallback( + (request: NetworkTopNFlowRequestOptions) => { + let didCancel = false; + const asyncSearch = async () => { + abortCtrl.current = new AbortController(); + setLoading(true); + + const searchSubscription$ = data.search + .search(request, { + strategy: 'securitySolutionSearchStrategy', + abortSignal: abortCtrl.current.signal, + }) + .subscribe({ + next: (response) => { + if (!response.isPartial && !response.isRunning) { + if (!didCancel) { + setLoading(false); + setNetworkTopNFlowResponse((prevResponse) => ({ + ...prevResponse, + networkTopNFlow: response.edges, + inspect: getInspectResponse(response, prevResponse.inspect), + pageInfo: response.pageInfo, + refetch: refetch.current, + totalCount: response.totalCount, + })); + } + searchSubscription$.unsubscribe(); + } else if (response.isPartial && !response.isRunning) { + if (!didCancel) { + setLoading(false); + } + // TODO: Make response error status clearer + notifications.toasts.addWarning(i18n.ERROR_NETWORK_TOP_N_FLOW); + searchSubscription$.unsubscribe(); + } }, - updateQuery: (prev, { fetchMoreResult }) => { - if (!fetchMoreResult) { - return prev; + error: (msg) => { + if (!(msg instanceof AbortError)) { + notifications.toasts.addDanger({ + title: i18n.FAIL_NETWORK_TOP_N_FLOW, + text: msg.message, + }); } - return { - ...fetchMoreResult, - source: { - ...fetchMoreResult.source, - NetworkTopNFlow: { - ...fetchMoreResult.source.NetworkTopNFlow, - edges: [...fetchMoreResult.source.NetworkTopNFlow.edges], - }, - }, - }; }, - })); - const isLoading = this.isItAValidLoading(loading, variables, networkStatus); - return children({ - id, - inspect: getOr(null, 'source.NetworkTopNFlow.inspect', data), - isInspected, - loading: isLoading, - loadPage: this.wrappedLoadMore, - networkTopNFlow, - pageInfo: getOr({}, 'source.NetworkTopNFlow.pageInfo', data), - refetch: this.memoizedRefetchQuery(variables, limit, refetch), - totalCount: getOr(-1, 'source.NetworkTopNFlow.totalCount', data), }); - }} - - ); - } -} + }; + abortCtrl.current.abort(); + asyncSearch(); + refetch.current = asyncSearch; + return () => { + didCancel = true; + abortCtrl.current.abort(); + }; + }, + [data.search, notifications.toasts] + ); -const makeMapStateToProps = () => { - const getTopNFlowSelector = networkSelectors.topNFlowSelector(); - const getQuery = inputsSelectors.globalQueryByIdSelector(); - return (state: State, { flowTarget, id = `${ID}-${flowTarget}`, type }: OwnProps) => { - const { isInspected } = getQuery(state, id); - return { - ...getTopNFlowSelector(state, type, flowTarget), - isInspected, - }; - }; -}; + useEffect(() => { + setTopNFlowRequest((prevRequest) => { + const myRequest = { + ...prevRequest, + defaultIndex, + filterQuery: createFilter(filterQuery), + pagination: generateTablePaginationOptions(activePage, limit), + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + sort, + }; + if (!skip && !deepEqual(prevRequest, myRequest)) { + return myRequest; + } + return prevRequest; + }); + }, [activePage, defaultIndex, endDate, filterQuery, limit, startDate, sort, skip]); -export const NetworkTopNFlowQuery = compose>( - connect(makeMapStateToProps), - withKibana -)(NetworkTopNFlowComponentQuery); + useEffect(() => { + networkTopNFlowSearch(networkTopNFlowRequest); + }, [networkTopNFlowRequest, networkTopNFlowSearch]); + + return [loading, networkTopNFlowResponse]; +}; diff --git a/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/translations.ts b/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/translations.ts new file mode 100644 index 0000000000000..4ea704571cf2e --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/translations.ts @@ -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 { i18n } from '@kbn/i18n'; + +export const ERROR_NETWORK_TOP_N_FLOW = i18n.translate( + 'xpack.securitySolution.networkTopNFlow.errorSearchDescription', + { + defaultMessage: `An error has occurred on network top n flow search`, + } +); + +export const FAIL_NETWORK_TOP_N_FLOW = i18n.translate( + 'xpack.securitySolution.networkTopNFlow.failSearchDescription', + { + defaultMessage: `Failed to run search on network top n flow`, + } +); diff --git a/x-pack/plugins/security_solution/public/network/pages/ip_details/network_top_n_flow_query_table.tsx b/x-pack/plugins/security_solution/public/network/pages/ip_details/network_top_n_flow_query_table.tsx index 158b4057a7d5e..821452201b78b 100644 --- a/x-pack/plugins/security_solution/public/network/pages/ip_details/network_top_n_flow_query_table.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/ip_details/network_top_n_flow_query_table.tsx @@ -8,7 +8,7 @@ import { getOr } from 'lodash/fp'; import React from 'react'; import { manageQuery } from '../../../common/components/page/manage_query'; import { NetworkTopNFlowTable } from '../../components/network_top_n_flow_table'; -import { NetworkTopNFlowQuery } from '../../containers/network_top_n_flow'; +import { useNetworkTopNFlow } from '../../containers/network_top_n_flow'; import { NetworkWithIndexComponentsQueryTableProps } from './types'; const NetworkTopNFlowTableManage = manageQuery(NetworkTopNFlowTable); @@ -22,45 +22,37 @@ export const NetworkTopNFlowQueryTable = ({ skip, startDate, type, -}: NetworkWithIndexComponentsQueryTableProps) => ( - - {({ - id, - inspect, - isInspected, - loading, - loadPage, - networkTopNFlow, - pageInfo, - refetch, - totalCount, - }) => ( - - )} - -); +}: NetworkWithIndexComponentsQueryTableProps) => { + const [ + loading, + { id, inspect, isInspected, loadPage, networkTopNFlow, pageInfo, refetch, totalCount }, + ] = useNetworkTopNFlow({ + endDate, + filterQuery, + flowTarget, + ip, + skip, + startDate, + type, + }); + + return ( + + ); +}; NetworkTopNFlowQueryTable.displayName = 'NetworkTopNFlowQueryTable'; diff --git a/x-pack/plugins/security_solution/public/network/pages/navigation/ips_query_tab_body.tsx b/x-pack/plugins/security_solution/public/network/pages/navigation/ips_query_tab_body.tsx index a9f4d504847a0..c83bf6ff80901 100644 --- a/x-pack/plugins/security_solution/public/network/pages/navigation/ips_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/navigation/ips_query_tab_body.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { getOr } from 'lodash/fp'; import { NetworkTopNFlowTable } from '../../components/network_top_n_flow_table'; -import { NetworkTopNFlowQuery } from '../../containers/network_top_n_flow'; +import { useNetworkTopNFlow } from '../../containers/network_top_n_flow'; import { networkModel } from '../../store'; import { manageQuery } from '../../../common/components/page/manage_query'; @@ -23,44 +23,36 @@ export const IPsQueryTabBody = ({ startDate, setQuery, flowTarget, -}: IPsQueryTabBodyProps) => ( - - {({ - id, - inspect, - isInspected, - loading, - loadPage, - networkTopNFlow, - pageInfo, - refetch, - totalCount, - }) => ( - - )} - -); +}: IPsQueryTabBodyProps) => { + const [ + loading, + { id, inspect, isInspected, loadPage, networkTopNFlow, pageInfo, refetch, totalCount }, + ] = useNetworkTopNFlow({ + endDate, + flowTarget, + filterQuery, + skip, + startDate, + type: networkModel.NetworkType.page, + }); + + return ( + + ); +}; IPsQueryTabBody.displayName = 'IPsQueryTabBody'; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/index.ts index 9e73312bdb8e1..c5c98e5facbdf 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/index.ts @@ -13,9 +13,11 @@ import { SecuritySolutionFactory } from '../types'; import { networkHttp } from './http'; import { networkTls } from './tls'; import { networkTopCountries } from './top_countries'; +import { networkTopNFlow } from './top_n_flow'; export const networkFactory: Record> = { [NetworkQueries.http]: networkHttp, [NetworkQueries.tls]: networkTls, [NetworkQueries.topCountries]: networkTopCountries, + [NetworkQueries.topNFlow]: networkTopNFlow, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/top_n_flow/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/top_n_flow/helpers.ts new file mode 100644 index 0000000000000..720661e12bd96 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/top_n_flow/helpers.ts @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getOr } from 'lodash/fp'; + +import { assertUnreachable } from '../../../../../../common/utility_types'; +import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; +import { + Direction, + GeoItem, + SortField, + NetworkTopNFlowBuckets, + NetworkTopNFlowEdges, + NetworkTopNFlowRequestOptions, + NetworkTopTablesFields, + FlowTargetSourceDest, + AutonomousSystemItem, +} from '../../../../../../common/search_strategy'; +import { getOppositeField } from '../helpers'; + +export const getTopNFlowEdges = ( + response: IEsSearchResponse, + options: NetworkTopNFlowRequestOptions +): NetworkTopNFlowEdges[] => + formatTopNFlowEdges( + getOr([], `aggregations.${options.flowTarget}.buckets`, response.rawResponse), + options.flowTarget + ); + +const formatTopNFlowEdges = ( + buckets: NetworkTopNFlowBuckets[], + flowTarget: FlowTargetSourceDest +): NetworkTopNFlowEdges[] => + buckets.map((bucket: NetworkTopNFlowBuckets) => ({ + node: { + _id: bucket.key, + [flowTarget]: { + domain: bucket.domain.buckets.map((bucketDomain) => bucketDomain.key), + ip: bucket.key, + location: getGeoItem(bucket), + autonomous_system: getAsItem(bucket), + flows: getOr(0, 'flows.value', bucket), + [`${getOppositeField(flowTarget)}_ips`]: getOr( + 0, + `${getOppositeField(flowTarget)}_ips.value`, + bucket + ), + }, + network: { + bytes_in: getOr(0, 'bytes_in.value', bucket), + bytes_out: getOr(0, 'bytes_out.value', bucket), + }, + }, + cursor: { + value: bucket.key, + tiebreaker: null, + }, + })); + +const getFlowTargetFromString = (flowAsString: string) => + flowAsString === 'source' ? FlowTargetSourceDest.source : FlowTargetSourceDest.destination; + +const getGeoItem = (result: NetworkTopNFlowBuckets): GeoItem | null => + result.location.top_geo.hits.hits.length > 0 && result.location.top_geo.hits.hits[0]._source + ? { + geo: getOr( + '', + `location.top_geo.hits.hits[0]._source.${ + Object.keys(result.location.top_geo.hits.hits[0]._source)[0] + }.geo`, + result + ), + flowTarget: getFlowTargetFromString( + Object.keys(result.location.top_geo.hits.hits[0]._source)[0] + ), + } + : null; + +const getAsItem = (result: NetworkTopNFlowBuckets): AutonomousSystemItem | null => + result.autonomous_system.top_as.hits.hits.length > 0 && + result.autonomous_system.top_as.hits.hits[0]._source + ? { + number: getOr( + null, + `autonomous_system.top_as.hits.hits[0]._source.${ + Object.keys(result.autonomous_system.top_as.hits.hits[0]._source)[0] + }.as.number`, + result + ), + name: getOr( + '', + `autonomous_system.top_as.hits.hits[0]._source.${ + Object.keys(result.autonomous_system.top_as.hits.hits[0]._source)[0] + }.as.organization.name`, + result + ), + } + : null; + +type QueryOrder = + | { bytes_in: Direction } + | { bytes_out: Direction } + | { flows: Direction } + | { destination_ips: Direction } + | { source_ips: Direction }; + +export const getQueryOrder = ( + networkTopNFlowSortField: SortField +): QueryOrder => { + switch (networkTopNFlowSortField.field) { + case NetworkTopTablesFields.bytes_in: + return { bytes_in: networkTopNFlowSortField.direction }; + case NetworkTopTablesFields.bytes_out: + return { bytes_out: networkTopNFlowSortField.direction }; + case NetworkTopTablesFields.flows: + return { flows: networkTopNFlowSortField.direction }; + case NetworkTopTablesFields.destination_ips: + return { destination_ips: networkTopNFlowSortField.direction }; + case NetworkTopTablesFields.source_ips: + return { source_ips: networkTopNFlowSortField.direction }; + } + assertUnreachable(networkTopNFlowSortField.field); +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/top_n_flow/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/top_n_flow/index.ts new file mode 100644 index 0000000000000..198368d981800 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/top_n_flow/index.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 { getOr } from 'lodash/fp'; + +import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; + +import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../../common/constants'; +import { + NetworkTopNFlowStrategyResponse, + NetworkQueries, + NetworkTopNFlowRequestOptions, + NetworkTopNFlowEdges, +} from '../../../../../../common/search_strategy/security_solution/network'; + +import { inspectStringifyObject } from '../../../../../utils/build_query'; +import { SecuritySolutionFactory } from '../../types'; + +import { getTopNFlowEdges } from './helpers'; +import { buildTopNFlowQuery } from './query.top_n_flow_network.dsl'; + +export const networkTopNFlow: SecuritySolutionFactory = { + buildDsl: (options: NetworkTopNFlowRequestOptions) => { + if (options.pagination && options.pagination.querySize >= DEFAULT_MAX_TABLE_QUERY_SIZE) { + throw new Error(`No query size above ${DEFAULT_MAX_TABLE_QUERY_SIZE}`); + } + return buildTopNFlowQuery(options); + }, + parse: async ( + options: NetworkTopNFlowRequestOptions, + response: IEsSearchResponse + ): Promise => { + const { activePage, cursorStart, fakePossibleCount, querySize } = options.pagination; + const totalCount = getOr(0, 'aggregations.top_n_flow_count.value', response.rawResponse); + const networkTopNFlowEdges: NetworkTopNFlowEdges[] = getTopNFlowEdges(response, options); + const fakeTotalCount = fakePossibleCount <= totalCount ? fakePossibleCount : totalCount; + const edges = networkTopNFlowEdges.splice(cursorStart, querySize - cursorStart); + const inspect = { + dsl: [inspectStringifyObject(buildTopNFlowQuery(options))], + }; + const showMorePagesIndicator = totalCount > fakeTotalCount; + + return { + ...response, + edges, + inspect, + pageInfo: { + activePage: activePage ? activePage : 0, + fakeTotalCount, + showMorePagesIndicator, + }, + totalCount, + }; + }, +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/top_n_flow/query.top_n_flow_network.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/top_n_flow/query.top_n_flow_network.dsl.ts new file mode 100644 index 0000000000000..374dfa4d485fa --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/top_n_flow/query.top_n_flow_network.dsl.ts @@ -0,0 +1,157 @@ +/* + * 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 { + SortField, + FlowTargetSourceDest, + NetworkTopTablesFields, + NetworkTopNFlowRequestOptions, +} from '../../../../../../common/search_strategy'; +import { createQueryFilterClauses } from '../../../../../utils/build_query'; +import { getOppositeField } from '../helpers'; +import { getQueryOrder } from './helpers'; + +const getCountAgg = (flowTarget: FlowTargetSourceDest) => ({ + top_n_flow_count: { + cardinality: { + field: `${flowTarget}.ip`, + }, + }, +}); + +export const buildTopNFlowQuery = ({ + defaultIndex, + filterQuery, + flowTarget, + sort, + pagination: { querySize }, + timerange: { from, to }, + ip, +}: NetworkTopNFlowRequestOptions) => { + const filter = [ + ...createQueryFilterClauses(filterQuery), + { + range: { + '@timestamp': { gte: from, lte: to, format: 'strict_date_optional_time' }, + }, + }, + ]; + + const dslQuery = { + allowNoIndices: true, + index: defaultIndex, + ignoreUnavailable: true, + body: { + aggregations: { + ...getCountAgg(flowTarget), + ...getFlowTargetAggs(sort, flowTarget, querySize), + }, + query: { + bool: ip + ? { + filter, + should: [ + { + term: { + [`${getOppositeField(flowTarget)}.ip`]: ip, + }, + }, + ], + minimum_should_match: 1, + } + : { + filter, + }, + }, + }, + size: 0, + track_total_hits: false, + }; + return dslQuery; +}; + +const getFlowTargetAggs = ( + sort: SortField, + flowTarget: FlowTargetSourceDest, + querySize: number +) => ({ + [flowTarget]: { + terms: { + field: `${flowTarget}.ip`, + size: querySize, + order: { + ...getQueryOrder(sort), + }, + }, + aggs: { + bytes_in: { + sum: { + field: `${getOppositeField(flowTarget)}.bytes`, + }, + }, + bytes_out: { + sum: { + field: `${flowTarget}.bytes`, + }, + }, + domain: { + terms: { + field: `${flowTarget}.domain`, + order: { + timestamp: 'desc', + }, + }, + aggs: { + timestamp: { + max: { + field: '@timestamp', + }, + }, + }, + }, + location: { + filter: { + exists: { + field: `${flowTarget}.geo`, + }, + }, + aggs: { + top_geo: { + top_hits: { + _source: `${flowTarget}.geo.*`, + size: 1, + }, + }, + }, + }, + autonomous_system: { + filter: { + exists: { + field: `${flowTarget}.as`, + }, + }, + aggs: { + top_as: { + top_hits: { + _source: `${flowTarget}.as.*`, + size: 1, + }, + }, + }, + }, + flows: { + cardinality: { + field: 'network.community_id', + }, + }, + [`${getOppositeField(flowTarget)}_ips`]: { + cardinality: { + field: `${getOppositeField(flowTarget)}.ip`, + }, + }, + }, + }, +}); From 4c2ef986e1302bfc4baa285cc6c65974941afba5 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Fri, 4 Sep 2020 09:59:21 -0700 Subject: [PATCH 05/17] [DOCS] Add default time range filter to advanced settings (#76414) * [DOCS] Add default time range filter to advanced settings * [DOCS] More edits * [DOCS] Adds behaviour when disabled * [DOCS] Synchs setting name changes * [DOCS] More edits --- docs/management/advanced-options.asciidoc | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/management/advanced-options.asciidoc b/docs/management/advanced-options.asciidoc index 9f13c152b4cbe..a64a0330ae43f 100644 --- a/docs/management/advanced-options.asciidoc +++ b/docs/management/advanced-options.asciidoc @@ -150,6 +150,12 @@ working on big documents. ==== Machine learning [horizontal] +`ml:anomalyDetection:results:enableTimeDefaults`:: Use the default time filter +in the *Single Metric Viewer* and *Anomaly Explorer*. If this setting is +disabled, the results for the full time range are shown. +`ml:anomalyDetection:results:timeDefaults`:: Sets the default time filter for +viewing {anomaly-job} results. This setting must contain `from` and `to` values (see {ref}/common-options.html#date-math[accepted formats]). It is ignored +unless `ml:anomalyDetection:results:enableTimeDefaults` is enabled. `ml:fileDataVisualizerMaxFileSize`:: Sets the file size limit when importing data in the {data-viz}. The default value is `100MB`. The highest supported value for this setting is `1GB`. From c7786463183141f08f72deaefc75a5707b55b5b6 Mon Sep 17 00:00:00 2001 From: Spencer Date: Fri, 4 Sep 2020 10:07:13 -0700 Subject: [PATCH 06/17] [eslint] convert to @typescript-eslint/no-unused-expressions (#76471) * [eslint] convert to @typescript-eslint/no-unused-expressions * fix lint errors Co-authored-by: spalger Co-authored-by: Elastic Machine --- .../elastic-eslint-config-kibana/typescript.js | 3 ++- .../apps/visualize/_tsvb_markdown.ts | 18 ++++++++++-------- .../index.stories.tsx | 2 +- .../logging/log_text_stream/log_entry_row.tsx | 2 +- .../page_results_content.tsx | 1 - .../log_entry_rate/page_results_content.tsx | 1 - .../components/row_renderers_browser/index.tsx | 2 -- .../row_renderers_browser.tsx | 2 +- 8 files changed, 15 insertions(+), 16 deletions(-) diff --git a/packages/elastic-eslint-config-kibana/typescript.js b/packages/elastic-eslint-config-kibana/typescript.js index 18b11eb62beef..d3e80b7448151 100644 --- a/packages/elastic-eslint-config-kibana/typescript.js +++ b/packages/elastic-eslint-config-kibana/typescript.js @@ -223,7 +223,8 @@ module.exports = { 'no-undef-init': 'error', 'no-unsafe-finally': 'error', 'no-unsanitized/property': 'error', - 'no-unused-expressions': 'error', + 'no-unused-expressions': 'off', + '@typescript-eslint/no-unused-expressions': 'error', 'no-unused-labels': 'error', 'no-var': 'error', 'object-shorthand': 'error', diff --git a/test/functional/apps/visualize/_tsvb_markdown.ts b/test/functional/apps/visualize/_tsvb_markdown.ts index ba60aa83d92da..4f12a45cf5f6b 100644 --- a/test/functional/apps/visualize/_tsvb_markdown.ts +++ b/test/functional/apps/visualize/_tsvb_markdown.ts @@ -30,10 +30,11 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await visualBuilder.markdownSwitchSubTab('markdown'); const rerenderedTable = await visualBuilder.getMarkdownTableVariables(); rerenderedTable.forEach((row) => { - // eslint-disable-next-line no-unused-expressions - variableName === 'label' - ? expect(row.key).to.include.string(checkedValue) - : expect(row.key).to.not.include.string(checkedValue); + if (variableName === 'label') { + expect(row.key).to.include.string(checkedValue); + } else { + expect(row.key).to.not.include.string(checkedValue); + } }); } @@ -107,10 +108,11 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { table.forEach((row, index) => { // exception: last index for variable is always: {{count.label}} - // eslint-disable-next-line no-unused-expressions - index === table.length - 1 - ? expect(row.key).to.not.include.string(VARIABLE) - : expect(row.key).to.include.string(VARIABLE); + if (index === table.length - 1) { + expect(row.key).to.not.include.string(VARIABLE); + } else { + expect(row.key).to.include.string(VARIABLE); + } }); await cleanupMarkdownData(VARIABLE, VARIABLE); diff --git a/x-pack/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.stories.tsx b/x-pack/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.stories.tsx index da9adbb8dfead..0816541865362 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.stories.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.stories.tsx @@ -19,7 +19,7 @@ import { MockUrlParamsContextProvider } from '../../../context/UrlParamsContext/ // our current storybook setup has core-js-related problems when trying to import // it. // storiesOf('app/TransactionDurationAlertTrigger', module).add('example', -// eslint-disable-next-line no-unused-expressions +// eslint-disable-next-line @typescript-eslint/no-unused-expressions () => { const params = { threshold: 1500, diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx index 5077bccdc1ca2..c1d4fc8b8d3c8 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx @@ -85,7 +85,7 @@ export const LogEntryRow = memo( ]); const handleOpenViewLogInContext = useCallback(() => { - openViewLogInContext?.(logEntry); // eslint-disable-line no-unused-expressions + openViewLogInContext?.(logEntry); trackMetric({ metric: 'view_in_context__stream' }); }, [openViewLogInContext, logEntry, trackMetric]); diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx index 028dd0d3a1a7b..740fc8b7bafcd 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx @@ -73,7 +73,6 @@ export const LogEntryCategoriesResultsContent: React.FunctionComponent { - // eslint-disable-next-line no-unused-expressions services.notifications?.toasts.addError(error, { title: loadDataErrorTitle, }); 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 de72ac5c5a574..b33eaf7e77bc3 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 @@ -103,7 +103,6 @@ export const LogEntryRateResultsContent: React.FunctionComponent = () => { }), }; - // eslint-disable-next-line no-unused-expressions navigateToApp?.('logs', { path: `/stream?${stringify(params)}` }); }, [queryTimeRange, navigateToApp] diff --git a/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/index.tsx index 2792b264ba7e2..d01e8634a489f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/index.tsx @@ -102,12 +102,10 @@ const StatefulRowRenderersBrowserComponent: React.FC setShow(false), []); const handleDisableAll = useCallback(() => { - // eslint-disable-next-line no-unused-expressions tableRef?.current?.setSelection([]); }, [tableRef]); const handleEnableAll = useCallback(() => { - // eslint-disable-next-line no-unused-expressions tableRef?.current?.setSelection(renderers); }, [tableRef]); diff --git a/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/row_renderers_browser.tsx b/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/row_renderers_browser.tsx index 7baa7c42fb45e..f1414724e243f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/row_renderers_browser.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/row_renderers_browser.tsx @@ -88,7 +88,7 @@ const RowRenderersBrowserComponent = React.forwardRef( (item: RowRendererOption) => () => { const newSelection = xor([item], notExcludedRowRenderers); // @ts-expect-error - ref?.current?.setSelection(newSelection); // eslint-disable-line no-unused-expressions + ref?.current?.setSelection(newSelection); }, [notExcludedRowRenderers, ref] ); From a7326e683ae00daaf60847425103b4303e965c6b Mon Sep 17 00:00:00 2001 From: Madison Caldwell Date: Fri, 4 Sep 2020 13:11:51 -0400 Subject: [PATCH 07/17] [Security Solution][Detections] Handle conflicts on alert status update (#75492) * Proceed on conflict when updating alert status * Handle conflicts * Don't let the user retry * Tweak error messages * Fix route * Update add exception modal * Reapply changes after fixing conflicts * Type errors * types * Fix remaining conflicts * Fix tests * More test fixes * Simplify onConflict evaluation * Add callback return types * Update translation paths * Add missing import Co-authored-by: Elastic Machine --- .../schemas/common/schemas.ts | 3 ++ .../request/set_signal_status_schema.ts | 3 +- .../exceptions/add_exception_modal/index.tsx | 50 ++++++++++++------- .../exceptions/use_add_exception.test.tsx | 2 +- .../exceptions/use_add_exception.tsx | 23 +++++++-- .../common/hooks/use_app_toasts.test.ts | 3 ++ .../public/common/hooks/use_app_toasts.ts | 5 +- .../public/common/translations.ts | 14 ++++++ .../components/alerts_table/actions.tsx | 14 +++++- .../components/alerts_table/index.tsx | 38 ++++++++------ .../timeline_actions/alert_context_menu.tsx | 38 +++++++++----- .../components/alerts_table/types.ts | 2 +- .../detection_engine/alerts/api.test.ts | 4 +- .../containers/detection_engine/alerts/api.ts | 2 +- .../signals/open_close_signals_route.ts | 4 +- 15 files changed, 145 insertions(+), 60 deletions(-) diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts index e13aa3b8980e9..498b561a818f2 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts @@ -283,6 +283,9 @@ export type Status = t.TypeOf; export const job_status = t.keyof({ succeeded: null, failed: null, 'going to run': null }); export type JobStatus = t.TypeOf; +export const conflicts = t.keyof({ abort: null, proceed: null }); +export type Conflicts = t.TypeOf; + // TODO: Create a regular expression type or custom date math part type here export const to = t.string; export type To = t.TypeOf; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/set_signal_status_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/set_signal_status_schema.ts index 1464896e50294..b039558d827bb 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/set_signal_status_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/set_signal_status_schema.ts @@ -6,13 +6,14 @@ import * as t from 'io-ts'; -import { signal_ids, signal_status_query, status } from '../common/schemas'; +import { conflicts, signal_ids, signal_status_query, status } from '../common/schemas'; export const setSignalsStatusSchema = t.intersection([ t.type({ status, }), t.partial({ + conflicts, signal_ids, query: signal_status_query, }), diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx index c46eb1b6b59cc..c1befabdd7809 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx @@ -26,6 +26,7 @@ import { CreateExceptionListItemSchema, ExceptionListType, } from '../../../../../public/lists_plugin_deps'; +import * as i18nCommon from '../../../translations'; import * as i18n from './translations'; import * as sharedI18n from '../translations'; import { TimelineNonEcsData, Ecs } from '../../../../graphql/types'; @@ -49,6 +50,7 @@ import { } from '../helpers'; import { ErrorInfo, ErrorCallout } from '../error_callout'; import { useFetchIndexPatterns } from '../../../../detections/containers/detection_engine/rules'; +import { ExceptionsBuilderExceptionItem } from '../types'; export interface AddExceptionModalBaseProps { ruleName: string; @@ -117,7 +119,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({ Array >([]); const [fetchOrCreateListError, setFetchOrCreateListError] = useState(null); - const { addError, addSuccess } = useAppToasts(); + const { addError, addSuccess, addWarning } = useAppToasts(); const { loading: isSignalIndexLoading, signalIndexName } = useSignalIndex(); const [ { isLoading: isSignalIndexPatternLoading, indexPatterns: signalIndexPatterns }, @@ -129,16 +131,26 @@ export const AddExceptionModal = memo(function AddExceptionModal({ ); const onError = useCallback( - (error: Error) => { + (error: Error): void => { addError(error, { title: i18n.ADD_EXCEPTION_ERROR }); onCancel(); }, [addError, onCancel] ); - const onSuccess = useCallback(() => { - addSuccess(i18n.ADD_EXCEPTION_SUCCESS); - onConfirm(shouldCloseAlert, shouldBulkCloseAlert); - }, [addSuccess, onConfirm, shouldBulkCloseAlert, shouldCloseAlert]); + + const onSuccess = useCallback( + (updated: number, conflicts: number): void => { + addSuccess(i18n.ADD_EXCEPTION_SUCCESS); + onConfirm(shouldCloseAlert, shouldBulkCloseAlert); + if (conflicts > 0) { + addWarning({ + title: i18nCommon.UPDATE_ALERT_STATUS_FAILED(conflicts), + text: i18nCommon.UPDATE_ALERT_STATUS_FAILED_DETAILED(updated, conflicts), + }); + } + }, + [addSuccess, addWarning, onConfirm, shouldBulkCloseAlert, shouldCloseAlert] + ); const [{ isLoading: addExceptionIsLoading }, addOrUpdateExceptionItems] = useAddOrUpdateException( { @@ -153,7 +165,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({ exceptionItems, }: { exceptionItems: Array; - }) => { + }): void => { setExceptionItemsToAdd(exceptionItems); }, [setExceptionItemsToAdd] @@ -186,7 +198,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({ ); const handleFetchOrCreateExceptionListError = useCallback( - (error: Error, statusCode: number | null, message: string | null) => { + (error: Error, statusCode: number | null, message: string | null): void => { setFetchOrCreateListError({ reason: error.message, code: statusCode, @@ -205,7 +217,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({ onSuccess: handleRuleChange, }); - const initialExceptionItems = useMemo(() => { + const initialExceptionItems = useMemo((): ExceptionsBuilderExceptionItem[] => { if (exceptionListType === 'endpoint' && alertData !== undefined && ruleExceptionList) { return defaultEndpointExceptionItems( exceptionListType, @@ -218,7 +230,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({ } }, [alertData, exceptionListType, ruleExceptionList, ruleName]); - useEffect(() => { + useEffect((): void => { if (isSignalIndexPatternLoading === false && isSignalIndexLoading === false) { setShouldDisableBulkClose( entryHasListType(exceptionItemsToAdd) || @@ -234,34 +246,34 @@ export const AddExceptionModal = memo(function AddExceptionModal({ signalIndexPatterns, ]); - useEffect(() => { + useEffect((): void => { if (shouldDisableBulkClose === true) { setShouldBulkCloseAlert(false); } }, [shouldDisableBulkClose]); const onCommentChange = useCallback( - (value: string) => { + (value: string): void => { setComment(value); }, [setComment] ); const onCloseAlertCheckboxChange = useCallback( - (event: React.ChangeEvent) => { + (event: React.ChangeEvent): void => { setShouldCloseAlert(event.currentTarget.checked); }, [setShouldCloseAlert] ); const onBulkCloseAlertCheckboxChange = useCallback( - (event: React.ChangeEvent) => { + (event: React.ChangeEvent): void => { setShouldBulkCloseAlert(event.currentTarget.checked); }, [setShouldBulkCloseAlert] ); - const retrieveAlertOsTypes = useCallback(() => { + const retrieveAlertOsTypes = useCallback((): string[] => { const osDefaults = ['windows', 'macos']; if (alertData) { const osTypes = getMappedNonEcsValue({ @@ -276,7 +288,9 @@ export const AddExceptionModal = memo(function AddExceptionModal({ return osDefaults; }, [alertData]); - const enrichExceptionItems = useCallback(() => { + const enrichExceptionItems = useCallback((): Array< + ExceptionListItemSchema | CreateExceptionListItemSchema + > => { let enriched: Array = []; enriched = comment !== '' @@ -289,7 +303,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({ return enriched; }, [comment, exceptionItemsToAdd, exceptionListType, retrieveAlertOsTypes]); - const onAddExceptionConfirm = useCallback(() => { + const onAddExceptionConfirm = useCallback((): void => { if (addOrUpdateExceptionItems !== null) { const alertIdToClose = shouldCloseAlert && alertData ? alertData.ecsData._id : undefined; const bulkCloseIndex = @@ -306,7 +320,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({ ]); const isSubmitButtonDisabled = useMemo( - () => + (): boolean => fetchOrCreateListError != null || exceptionItemsToAdd.every((item) => item.entries.length === 0), [fetchOrCreateListError, exceptionItemsToAdd] diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx index 46923e07d225a..2398f8d799c2c 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx @@ -49,7 +49,7 @@ describe('useAddOrUpdateException', () => { const onError = jest.fn(); const onSuccess = jest.fn(); const alertIdToClose = 'idToClose'; - const bulkCloseIndex = ['.signals']; + const bulkCloseIndex = ['.custom']; const itemsToAdd: CreateExceptionListItemSchema[] = [ { ...getCreateExceptionListItemSchemaMock(), diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx index be289b0e85e66..dbd634e97a328 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx @@ -5,6 +5,7 @@ */ import { useEffect, useRef, useState, useCallback } from 'react'; +import { UpdateDocumentByQueryResponse } from 'elasticsearch'; import { HttpStart } from '../../../../../../../src/core/public'; import { @@ -43,7 +44,7 @@ export type ReturnUseAddOrUpdateException = [ export interface UseAddOrUpdateExceptionProps { http: HttpStart; onError: (arg: Error, code: number | null, message: string | null) => void; - onSuccess: () => void; + onSuccess: (updated: number, conficts: number) => void; } /** @@ -122,8 +123,10 @@ export const useAddOrUpdateException = ({ ) => { try { setIsLoading(true); - if (alertIdToClose !== null && alertIdToClose !== undefined) { - await updateAlertStatus({ + let alertIdResponse: UpdateDocumentByQueryResponse | undefined; + let bulkResponse: UpdateDocumentByQueryResponse | undefined; + if (alertIdToClose != null) { + alertIdResponse = await updateAlertStatus({ query: getUpdateAlertsQuery([alertIdToClose]), status: 'closed', signal: abortCtrl.signal, @@ -139,7 +142,8 @@ export const useAddOrUpdateException = ({ prepareExceptionItemsForBulkClose(exceptionItemsToAddOrUpdate), false ); - await updateAlertStatus({ + + bulkResponse = await updateAlertStatus({ query: { query: filter, }, @@ -150,9 +154,18 @@ export const useAddOrUpdateException = ({ await addOrUpdateItems(exceptionItemsToAddOrUpdate); + // NOTE: there could be some overlap here... it's possible that the first response had conflicts + // but that the alert was closed in the second call. In this case, a conflict will be reported even + // though it was already resolved. I'm not sure that there's an easy way to solve this, but it should + // have minimal impact on the user... they'd see a warning that indicates a possible conflict, but the + // state of the alerts and their representation in the UI would be consistent. + const updated = (alertIdResponse?.updated ?? 0) + (bulkResponse?.updated ?? 0); + const conflicts = + alertIdResponse?.version_conflicts ?? 0 + (bulkResponse?.version_conflicts ?? 0); + if (isSubscribed) { setIsLoading(false); - onSuccess(); + onSuccess(updated, conflicts); } } catch (error) { if (isSubscribed) { diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.test.ts b/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.test.ts index e0e629793952a..da43d0c510997 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.test.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.test.ts @@ -14,13 +14,16 @@ jest.mock('../lib/kibana'); describe('useDeleteList', () => { let addErrorMock: jest.Mock; let addSuccessMock: jest.Mock; + let addWarningMock: jest.Mock; beforeEach(() => { addErrorMock = jest.fn(); addSuccessMock = jest.fn(); + addWarningMock = jest.fn(); (useToasts as jest.Mock).mockImplementation(() => ({ addError: addErrorMock, addSuccess: addSuccessMock, + addWarning: addWarningMock, })); }); diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts b/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts index bc59d87100058..ae811e7400737 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts @@ -10,7 +10,7 @@ import { ErrorToastOptions, ToastsStart, Toast } from '../../../../../../src/cor import { useToasts } from '../lib/kibana'; import { isAppError, AppError } from '../utils/api'; -export type UseAppToasts = Pick & { +export type UseAppToasts = Pick & { api: ToastsStart; addError: (error: unknown, options: ErrorToastOptions) => Toast; }; @@ -19,6 +19,7 @@ export const useAppToasts = (): UseAppToasts => { const toasts = useToasts(); const addError = useRef(toasts.addError.bind(toasts)).current; const addSuccess = useRef(toasts.addSuccess.bind(toasts)).current; + const addWarning = useRef(toasts.addWarning.bind(toasts)).current; const addAppError = useCallback( (error: AppError, options: ErrorToastOptions) => @@ -44,5 +45,5 @@ export const useAppToasts = (): UseAppToasts => { [addAppError, addError] ); - return { api: toasts, addError: _addError, addSuccess }; + return { api: toasts, addError: _addError, addSuccess, addWarning }; }; diff --git a/x-pack/plugins/security_solution/public/common/translations.ts b/x-pack/plugins/security_solution/public/common/translations.ts index 3b94ac8959496..c4a9540f62914 100644 --- a/x-pack/plugins/security_solution/public/common/translations.ts +++ b/x-pack/plugins/security_solution/public/common/translations.ts @@ -61,3 +61,17 @@ export const EMPTY_ACTION_ENDPOINT_DESCRIPTION = i18n.translate( 'Protect your hosts with threat prevention, detection, and deep security data visibility.', } ); + +export const UPDATE_ALERT_STATUS_FAILED = (conflicts: number) => + i18n.translate('xpack.securitySolution.pages.common.updateAlertStatusFailed', { + values: { conflicts }, + defaultMessage: + 'Failed to update { conflicts } {conflicts, plural, =1 {alert} other {alerts}}.', + }); + +export const UPDATE_ALERT_STATUS_FAILED_DETAILED = (updated: number, conflicts: number) => + i18n.translate('xpack.securitySolution.pages.common.updateAlertStatusFailedDetailed', { + values: { updated, conflicts }, + defaultMessage: `{ updated } {updated, plural, =1 {alert was} other {alerts were}} updated successfully, but { conflicts } failed to update + because { conflicts, plural, =1 {it was} other {they were}} already being modified.`, + }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx index 3545bfd91e553..972a8aa4b0871 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx @@ -9,6 +9,7 @@ import dateMath from '@elastic/datemath'; import { get, getOr, isEmpty, find } from 'lodash/fp'; import moment from 'moment'; +import { i18n } from '@kbn/i18n'; import { TimelineId } from '../../../../common/types/timeline'; import { updateAlertStatus } from '../../containers/detection_engine/alerts/api'; @@ -83,7 +84,18 @@ export const updateAlertStatusAction = async ({ // TODO: Only delete those that were successfully updated from updatedRules setEventsDeleted({ eventIds: alertIds, isDeleted: true }); - onAlertStatusUpdateSuccess(response.updated, selectedStatus); + if (response.version_conflicts > 0 && alertIds.length === 1) { + throw new Error( + i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.updateAlertStatusFailedSingleAlert', + { + defaultMessage: 'Failed to update alert because it was already being modified.', + } + ) + ); + } + + onAlertStatusUpdateSuccess(response.updated, response.version_conflicts, selectedStatus); } catch (error) { onAlertStatusUpdateFailure(selectedStatus, error); } finally { diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx index 63e1c8aca9082..0416b3d2a459f 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx @@ -9,10 +9,10 @@ import { isEmpty } from 'lodash/fp'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { Dispatch } from 'redux'; - import { Status } from '../../../../common/detection_engine/schemas/common/schemas'; import { Filter, esQuery } from '../../../../../../../src/plugins/data/public'; import { TimelineIdLiteral } from '../../../../common/types/timeline'; +import { useAppToasts } from '../../../common/hooks/use_app_toasts'; import { useFetchIndexPatterns } from '../../containers/detection_engine/rules/fetch_index_patterns'; import { StatefulEventsViewer } from '../../../common/components/events_viewer'; import { HeaderSection } from '../../../common/components/header_section'; @@ -32,6 +32,7 @@ import { } from './default_config'; import { FILTER_OPEN, AlertsTableFilterGroup } from './alerts_filter_group'; import { AlertsUtilityBar } from './alerts_utility_bar'; +import * as i18nCommon from '../../../common/translations'; import * as i18n from './translations'; import { SetEventsDeletedProps, @@ -90,6 +91,7 @@ export const AlertsTableComponent: React.FC = ({ ); const kibana = useKibana(); const [, dispatchToaster] = useStateToaster(); + const { addWarning } = useAppToasts(); const { initializeTimeline, setSelectAll, setIndexToAdd } = useManageTimeline(); const getGlobalQuery = useCallback( @@ -130,21 +132,29 @@ export const AlertsTableComponent: React.FC = ({ ); const onAlertStatusUpdateSuccess = useCallback( - (count: number, status: Status) => { - let title: string; - switch (status) { - case 'closed': - title = i18n.CLOSED_ALERT_SUCCESS_TOAST(count); - break; - case 'open': - title = i18n.OPENED_ALERT_SUCCESS_TOAST(count); - break; - case 'in-progress': - title = i18n.IN_PROGRESS_ALERT_SUCCESS_TOAST(count); + (updated: number, conflicts: number, status: Status) => { + if (conflicts > 0) { + // Partial failure + addWarning({ + title: i18nCommon.UPDATE_ALERT_STATUS_FAILED(conflicts), + text: i18nCommon.UPDATE_ALERT_STATUS_FAILED_DETAILED(updated, conflicts), + }); + } else { + let title: string; + switch (status) { + case 'closed': + title = i18n.CLOSED_ALERT_SUCCESS_TOAST(updated); + break; + case 'open': + title = i18n.OPENED_ALERT_SUCCESS_TOAST(updated); + break; + case 'in-progress': + title = i18n.IN_PROGRESS_ALERT_SUCCESS_TOAST(updated); + } + displaySuccessToast(title, dispatchToaster); } - displaySuccessToast(title, dispatchToaster); }, - [dispatchToaster] + [addWarning, dispatchToaster] ); const onAlertStatusUpdateFailure = useCallback( diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx index 216ed0cbe264d..cbf0e08fef5cd 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx @@ -15,6 +15,7 @@ import { } from '@elastic/eui'; import styled from 'styled-components'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import { TimelineId } from '../../../../../common/types/timeline'; import { DEFAULT_INDEX_PATTERN } from '../../../../../common/constants'; import { Status, Type } from '../../../../../common/detection_engine/schemas/common/schemas'; @@ -32,6 +33,7 @@ import { AddExceptionModalBaseProps, } from '../../../../common/components/exceptions/add_exception_modal'; import { getMappedNonEcsValue } from '../../../../common/components/exceptions/helpers'; +import * as i18nCommon from '../../../../common/translations'; import * as i18n from '../translations'; import { useStateToaster, @@ -72,6 +74,8 @@ const AlertContextMenuComponent: React.FC = ({ ); const eventId = ecsRowData._id; + const { addWarning } = useAppToasts(); + const onButtonClick = useCallback(() => { setPopover(!isPopoverOpen); }, [isPopoverOpen]); @@ -124,22 +128,30 @@ const AlertContextMenuComponent: React.FC = ({ ); const onAlertStatusUpdateSuccess = useCallback( - (count: number, newStatus: Status) => { - let title: string; - switch (newStatus) { - case 'closed': - title = i18n.CLOSED_ALERT_SUCCESS_TOAST(count); - break; - case 'open': - title = i18n.OPENED_ALERT_SUCCESS_TOAST(count); - break; - case 'in-progress': - title = i18n.IN_PROGRESS_ALERT_SUCCESS_TOAST(count); + (updated: number, conflicts: number, newStatus: Status) => { + if (conflicts > 0) { + // Partial failure + addWarning({ + title: i18nCommon.UPDATE_ALERT_STATUS_FAILED(conflicts), + text: i18nCommon.UPDATE_ALERT_STATUS_FAILED_DETAILED(updated, conflicts), + }); + } else { + let title: string; + switch (newStatus) { + case 'closed': + title = i18n.CLOSED_ALERT_SUCCESS_TOAST(updated); + break; + case 'open': + title = i18n.OPENED_ALERT_SUCCESS_TOAST(updated); + break; + case 'in-progress': + title = i18n.IN_PROGRESS_ALERT_SUCCESS_TOAST(updated); + } + displaySuccessToast(title, dispatchToaster); } - displaySuccessToast(title, dispatchToaster); setAlertStatus(newStatus); }, - [dispatchToaster] + [dispatchToaster, addWarning] ); const onAlertStatusUpdateFailure = useCallback( diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/types.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_table/types.ts index d8ba0ab2d40b9..f8b3cd6af8b8a 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/types.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/types.ts @@ -44,7 +44,7 @@ export interface UpdateAlertStatusActionProps { selectedStatus: Status; setEventsLoading: ({ eventIds, isLoading }: SetEventsLoadingProps) => void; setEventsDeleted: ({ eventIds, isDeleted }: SetEventsDeletedProps) => void; - onAlertStatusUpdateSuccess: (count: number, status: Status) => void; + onAlertStatusUpdateSuccess: (updated: number, conflicts: number, status: Status) => void; onAlertStatusUpdateFailure: (status: Status, error: Error) => void; } diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.test.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.test.ts index 3cd819b55685c..19007c4d2e432 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.test.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.test.ts @@ -67,7 +67,7 @@ describe('Detections Alerts API', () => { }); expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/signals/status', { body: - '{"status":"closed","bool":{"filter":{"terms":{"_id":["b4ee5c32e3a321057edcc953ca17228c6fdfe5ba43fdbbdaffa8cefa11605cc5"]}}}}', + '{"conflicts":"proceed","status":"closed","bool":{"filter":{"terms":{"_id":["b4ee5c32e3a321057edcc953ca17228c6fdfe5ba43fdbbdaffa8cefa11605cc5"]}}}}', method: 'POST', signal: abortCtrl.signal, }); @@ -81,7 +81,7 @@ describe('Detections Alerts API', () => { }); expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/signals/status', { body: - '{"status":"open","bool":{"filter":{"terms":{"_id":["b4ee5c32e3a321057edcc953ca17228c6fdfe5ba43fdbbdaffa8cefa11605cc5"]}}}}', + '{"conflicts":"proceed","status":"open","bool":{"filter":{"terms":{"_id":["b4ee5c32e3a321057edcc953ca17228c6fdfe5ba43fdbbdaffa8cefa11605cc5"]}}}}', method: 'POST', signal: abortCtrl.signal, }); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts index 3fe676fe2c7d6..a8a2ae10a3bbd 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts @@ -58,7 +58,7 @@ export const updateAlertStatus = async ({ }: UpdateAlertStatusProps): Promise => KibanaServices.get().http.fetch(DETECTION_ENGINE_SIGNALS_STATUS_URL, { method: 'POST', - body: JSON.stringify({ status, ...query }), + body: JSON.stringify({ conflicts: 'proceed', status, ...query }), signal, }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/open_close_signals_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/open_close_signals_route.ts index ac5132d93a069..be6e57aee6d0c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/open_close_signals_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/open_close_signals_route.ts @@ -28,11 +28,12 @@ export const setSignalsStatusRoute = (router: IRouter) => { }, }, async (context, request, response) => { - const { signal_ids: signalIds, query, status } = request.body; + const { conflicts, signal_ids: signalIds, query, status } = request.body; const clusterClient = context.core.elasticsearch.legacy.client; const siemClient = context.securitySolution?.getAppClient(); const siemResponse = buildSiemResponse(response); const validationErrors = setSignalStatusValidateTypeDependents(request.body); + if (validationErrors.length) { return siemResponse.error({ statusCode: 400, body: validationErrors }); } @@ -55,6 +56,7 @@ export const setSignalsStatusRoute = (router: IRouter) => { try { const result = await clusterClient.callAsCurrentUser('updateByQuery', { index: siemClient.getSignalsIndex(), + conflicts: conflicts ?? 'abort', refresh: 'wait_for', body: { script: { From b3eaf9b629fbceb50642d27a5ebbcb1a62115c3a Mon Sep 17 00:00:00 2001 From: Spencer Date: Fri, 4 Sep 2020 10:12:52 -0700 Subject: [PATCH 08/17] [ci-metrics] unify distributable file count metrics (#76448) Co-authored-by: spalger Co-authored-by: Elastic Machine --- src/dev/build/tasks/create_archives_task.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/dev/build/tasks/create_archives_task.ts b/src/dev/build/tasks/create_archives_task.ts index 0083881e9f748..a05e383394ecf 100644 --- a/src/dev/build/tasks/create_archives_task.ts +++ b/src/dev/build/tasks/create_archives_task.ts @@ -92,8 +92,8 @@ export const CreateArchives: Task = { }); metrics.push({ - group: `${build.isOss() ? 'oss ' : ''}distributable file count`, - id: 'total', + group: 'distributable file count', + id: build.isOss() ? 'oss' : 'default', value: fileCount, }); } From 260643483eb4c662d294b9e6d6a03322c4558bd3 Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Fri, 4 Sep 2020 13:30:50 -0400 Subject: [PATCH 09/17] [Dashboard First] Genericize Attribute Service (#76057) Genericized attribute service, with custom save and unwrap methods and added unit tests. --- .../public/book/book_embeddable.tsx | 17 +- .../public/book/book_embeddable_factory.tsx | 12 +- .../public/book/edit_book_action.tsx | 12 +- .../attribute_service.test.ts | 193 ++++++++++++++++++ .../attribute_service/attribute_service.tsx | 121 ++++++----- .../attribute_service_mock.tsx | 49 +++++ src/plugins/dashboard/public/index.ts | 2 +- src/plugins/dashboard/public/plugin.tsx | 20 +- 8 files changed, 349 insertions(+), 77 deletions(-) create mode 100644 src/plugins/dashboard/public/attribute_service/attribute_service.test.ts create mode 100644 src/plugins/dashboard/public/attribute_service/attribute_service_mock.tsx diff --git a/examples/embeddable_examples/public/book/book_embeddable.tsx b/examples/embeddable_examples/public/book/book_embeddable.tsx index b033fe86cd1c7..48b81c27e8b8d 100644 --- a/examples/embeddable_examples/public/book/book_embeddable.tsx +++ b/examples/embeddable_examples/public/book/book_embeddable.tsx @@ -71,11 +71,7 @@ export class BookEmbeddable constructor( initialInput: BookEmbeddableInput, - private attributeService: AttributeService< - BookSavedObjectAttributes, - BookByValueInput, - BookByReferenceInput - >, + private attributeService: AttributeService, { parent, }: { @@ -99,18 +95,21 @@ export class BookEmbeddable }); } - inputIsRefType = (input: BookEmbeddableInput): input is BookByReferenceInput => { + readonly inputIsRefType = (input: BookEmbeddableInput): input is BookByReferenceInput => { return this.attributeService.inputIsRefType(input); }; - getInputAsValueType = async (): Promise => { + readonly getInputAsValueType = async (): Promise => { const input = this.attributeService.getExplicitInputFromEmbeddable(this); return this.attributeService.getInputAsValueType(input); }; - getInputAsRefType = async (): Promise => { + readonly getInputAsRefType = async (): Promise => { const input = this.attributeService.getExplicitInputFromEmbeddable(this); - return this.attributeService.getInputAsRefType(input, { showSaveModal: true }); + return this.attributeService.getInputAsRefType(input, { + showSaveModal: true, + saveModalTitle: this.getTitle(), + }); }; public render(node: HTMLElement) { diff --git a/examples/embeddable_examples/public/book/book_embeddable_factory.tsx b/examples/embeddable_examples/public/book/book_embeddable_factory.tsx index 4c144c3843c47..292261ee16c59 100644 --- a/examples/embeddable_examples/public/book/book_embeddable_factory.tsx +++ b/examples/embeddable_examples/public/book/book_embeddable_factory.tsx @@ -31,8 +31,6 @@ import { BOOK_EMBEDDABLE, BookEmbeddableInput, BookEmbeddableOutput, - BookByValueInput, - BookByReferenceInput, } from './book_embeddable'; import { CreateEditBookComponent } from './create_edit_book_component'; import { OverlayStart } from '../../../../src/core/public'; @@ -66,11 +64,7 @@ export class BookEmbeddableFactoryDefinition getIconForSavedObject: () => 'pencil', }; - private attributeService?: AttributeService< - BookSavedObjectAttributes, - BookByValueInput, - BookByReferenceInput - >; + private attributeService?: AttributeService; constructor(private getStartServices: () => Promise) {} @@ -126,9 +120,7 @@ export class BookEmbeddableFactoryDefinition private async getAttributeService() { if (!this.attributeService) { this.attributeService = await (await this.getStartServices()).getAttributeService< - BookSavedObjectAttributes, - BookByValueInput, - BookByReferenceInput + BookSavedObjectAttributes >(this.type); } return this.attributeService!; diff --git a/examples/embeddable_examples/public/book/edit_book_action.tsx b/examples/embeddable_examples/public/book/edit_book_action.tsx index 5b14dc85b1fc7..3541ace1e5e7e 100644 --- a/examples/embeddable_examples/public/book/edit_book_action.tsx +++ b/examples/embeddable_examples/public/book/edit_book_action.tsx @@ -57,13 +57,13 @@ export const createEditBookAction = (getStartServices: () => Promise { const { openModal, getAttributeService } = await getStartServices(); - const attributeService = getAttributeService< - BookSavedObjectAttributes, - BookByValueInput, - BookByReferenceInput - >(BOOK_SAVED_OBJECT); + const attributeService = getAttributeService(BOOK_SAVED_OBJECT); const onSave = async (attributes: BookSavedObjectAttributes, useRefType: boolean) => { - const newInput = await attributeService.wrapAttributes(attributes, useRefType, embeddable); + const newInput = await attributeService.wrapAttributes( + attributes, + useRefType, + attributeService.getExplicitInputFromEmbeddable(embeddable) + ); if (!useRefType && (embeddable.getInput() as SavedObjectEmbeddableInput).savedObjectId) { // Set the saved object ID to null so that update input will remove the existing savedObjectId... (newInput as BookByValueInput & { savedObjectId: unknown }).savedObjectId = null; diff --git a/src/plugins/dashboard/public/attribute_service/attribute_service.test.ts b/src/plugins/dashboard/public/attribute_service/attribute_service.test.ts new file mode 100644 index 0000000000000..06f380ca3862b --- /dev/null +++ b/src/plugins/dashboard/public/attribute_service/attribute_service.test.ts @@ -0,0 +1,193 @@ +/* + * 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 { ATTRIBUTE_SERVICE_KEY } from './attribute_service'; +import { mockAttributeService } from './attribute_service_mock'; +import { coreMock } from '../../../../core/public/mocks'; + +interface TestAttributes { + title: string; + testAttr1?: string; + testAttr2?: { array: unknown[]; testAttr3: string }; +} + +interface TestByValueInput { + id: string; + [ATTRIBUTE_SERVICE_KEY]: TestAttributes; +} + +describe('attributeService', () => { + const defaultTestType = 'defaultTestType'; + let attributes: TestAttributes; + let byValueInput: TestByValueInput; + let byReferenceInput: { id: string; savedObjectId: string }; + + beforeEach(() => { + attributes = { + title: 'ultra title', + testAttr1: 'neat first attribute', + testAttr2: { array: [1, 2, 3], testAttr3: 'super attribute' }, + }; + byValueInput = { + id: '456', + attributes, + }; + byReferenceInput = { + id: '456', + savedObjectId: '123', + }; + }); + + describe('determining input type', () => { + const defaultAttributeService = mockAttributeService(defaultTestType); + const customAttributeService = mockAttributeService( + defaultTestType + ); + + it('can determine input type given default types', () => { + expect( + defaultAttributeService.inputIsRefType({ id: '456', savedObjectId: '123' }) + ).toBeTruthy(); + expect( + defaultAttributeService.inputIsRefType({ + id: '456', + attributes: { title: 'wow I am by value' }, + }) + ).toBeFalsy(); + }); + it('can determine input type given custom types', () => { + expect( + customAttributeService.inputIsRefType({ id: '456', savedObjectId: '123' }) + ).toBeTruthy(); + expect( + customAttributeService.inputIsRefType({ + id: '456', + [ATTRIBUTE_SERVICE_KEY]: { title: 'wow I am by value' }, + }) + ).toBeFalsy(); + }); + }); + + describe('unwrapping attributes', () => { + it('can unwrap all default attributes when given reference type input', async () => { + const core = coreMock.createStart(); + core.savedObjects.client.get = jest.fn().mockResolvedValueOnce({ + attributes, + }); + const attributeService = mockAttributeService( + defaultTestType, + undefined, + core + ); + expect(await attributeService.unwrapAttributes(byReferenceInput)).toEqual(attributes); + }); + + it('returns attributes when when given value type input', async () => { + const attributeService = mockAttributeService(defaultTestType); + expect(await attributeService.unwrapAttributes(byValueInput)).toEqual(attributes); + }); + + it('runs attributes through a custom unwrap method', async () => { + const core = coreMock.createStart(); + core.savedObjects.client.get = jest.fn().mockResolvedValueOnce({ + attributes, + }); + const attributeService = mockAttributeService( + defaultTestType, + { + customUnwrapMethod: (savedObject) => ({ + ...savedObject.attributes, + testAttr2: { array: [1, 2, 3, 4, 5], testAttr3: 'kibanana' }, + }), + }, + core + ); + expect(await attributeService.unwrapAttributes(byReferenceInput)).toEqual({ + ...attributes, + testAttr2: { array: [1, 2, 3, 4, 5], testAttr3: 'kibanana' }, + }); + }); + }); + + describe('wrapping attributes', () => { + it('returns given attributes when use ref type is false', async () => { + const attributeService = mockAttributeService(defaultTestType); + expect(await attributeService.wrapAttributes(attributes, false)).toEqual({ attributes }); + }); + + it('updates existing saved object with new attributes when given id', async () => { + const core = coreMock.createStart(); + const attributeService = mockAttributeService( + defaultTestType, + undefined, + core + ); + expect(await attributeService.wrapAttributes(attributes, true, byReferenceInput)).toEqual( + byReferenceInput + ); + expect(core.savedObjects.client.update).toHaveBeenCalledWith( + defaultTestType, + '123', + attributes + ); + }); + + it('creates new saved object with attributes when given no id', async () => { + const core = coreMock.createStart(); + core.savedObjects.client.create = jest.fn().mockResolvedValueOnce({ + id: '678', + }); + const attributeService = mockAttributeService( + defaultTestType, + undefined, + core + ); + expect(await attributeService.wrapAttributes(attributes, true)).toEqual({ + savedObjectId: '678', + }); + expect(core.savedObjects.client.create).toHaveBeenCalledWith(defaultTestType, attributes); + }); + + it('uses custom save method when given an id', async () => { + const customSaveMethod = jest.fn().mockReturnValue({ id: '123' }); + const attributeService = mockAttributeService(defaultTestType, { + customSaveMethod, + }); + expect(await attributeService.wrapAttributes(attributes, true, byReferenceInput)).toEqual( + byReferenceInput + ); + expect(customSaveMethod).toHaveBeenCalledWith( + defaultTestType, + attributes, + byReferenceInput.savedObjectId + ); + }); + + it('uses custom save method given no id', async () => { + const customSaveMethod = jest.fn().mockReturnValue({ id: '678' }); + const attributeService = mockAttributeService(defaultTestType, { + customSaveMethod, + }); + expect(await attributeService.wrapAttributes(attributes, true)).toEqual({ + savedObjectId: '678', + }); + expect(customSaveMethod).toHaveBeenCalledWith(defaultTestType, attributes, undefined); + }); + }); +}); diff --git a/src/plugins/dashboard/public/attribute_service/attribute_service.tsx b/src/plugins/dashboard/public/attribute_service/attribute_service.tsx index fe5f6a0c8e2bd..a36363d22d87d 100644 --- a/src/plugins/dashboard/public/attribute_service/attribute_service.tsx +++ b/src/plugins/dashboard/public/attribute_service/attribute_service.tsx @@ -19,11 +19,16 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; +import { get } from 'lodash'; import { EmbeddableInput, SavedObjectEmbeddableInput, isSavedObjectEmbeddableInput, IEmbeddable, + Container, + EmbeddableStart, + EmbeddableFactory, + EmbeddableFactoryNotFoundError, } from '../embeddable_plugin'; import { SavedObjectsClientContract, @@ -34,17 +39,10 @@ import { } from '../../../../core/public'; import { SavedObjectSaveModal, - showSaveModal, OnSaveProps, SaveResult, checkForDuplicateTitle, } from '../../../saved_objects/public'; -import { - EmbeddableStart, - EmbeddableFactory, - EmbeddableFactoryNotFoundError, - Container, -} from '../../../embeddable/public'; /** * The attribute service is a shared, generic service that embeddables can use to provide the functionality @@ -52,26 +50,46 @@ import { * can also be used as a higher level wrapper to transform an embeddable input shape that references a saved object * into an embeddable input shape that contains that saved object's attributes by value. */ +export const ATTRIBUTE_SERVICE_KEY = 'attributes'; + +export interface AttributeServiceOptions { + customSaveMethod?: ( + type: string, + attributes: A, + savedObjectId?: string + ) => Promise<{ id: string }>; + customUnwrapMethod?: (savedObject: SimpleSavedObject) => A; +} + export class AttributeService< SavedObjectAttributes extends { title: string }, - ValType extends EmbeddableInput & { attributes: SavedObjectAttributes }, - RefType extends SavedObjectEmbeddableInput + ValType extends EmbeddableInput & { + [ATTRIBUTE_SERVICE_KEY]: SavedObjectAttributes; + } = EmbeddableInput & { [ATTRIBUTE_SERVICE_KEY]: SavedObjectAttributes }, + RefType extends SavedObjectEmbeddableInput = SavedObjectEmbeddableInput > { - private embeddableFactory: EmbeddableFactory; + private embeddableFactory?: EmbeddableFactory; constructor( private type: string, + private showSaveModal: ( + saveModal: React.ReactElement, + I18nContext: I18nStart['Context'] + ) => void, private savedObjectsClient: SavedObjectsClientContract, private overlays: OverlayStart, private i18nContext: I18nStart['Context'], private toasts: NotificationsStart['toasts'], - getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory'] + getEmbeddableFactory?: EmbeddableStart['getEmbeddableFactory'], + private options?: AttributeServiceOptions ) { - const factory = getEmbeddableFactory(this.type); - if (!factory) { - throw new EmbeddableFactoryNotFoundError(this.type); + if (getEmbeddableFactory) { + const factory = getEmbeddableFactory(this.type); + if (!factory) { + throw new EmbeddableFactoryNotFoundError(this.type); + } + this.embeddableFactory = factory; } - this.embeddableFactory = factory; } public async unwrapAttributes(input: RefType | ValType): Promise { @@ -79,43 +97,54 @@ export class AttributeService< const savedObject: SimpleSavedObject = await this.savedObjectsClient.get< SavedObjectAttributes >(this.type, input.savedObjectId); - return savedObject.attributes; + return this.options?.customUnwrapMethod + ? this.options?.customUnwrapMethod(savedObject) + : { ...savedObject.attributes }; } - return input.attributes; + return input[ATTRIBUTE_SERVICE_KEY]; } public async wrapAttributes( newAttributes: SavedObjectAttributes, useRefType: boolean, - embeddable?: IEmbeddable + input?: ValType | RefType ): Promise> { + const originalInput = input ? input : {}; const savedObjectId = - embeddable && isSavedObjectEmbeddableInput(embeddable.getInput()) - ? (embeddable.getInput() as SavedObjectEmbeddableInput).savedObjectId + input && this.inputIsRefType(input) + ? (input as SavedObjectEmbeddableInput).savedObjectId : undefined; if (!useRefType) { - return { attributes: newAttributes } as ValType; - } else { - try { - if (savedObjectId) { - await this.savedObjectsClient.update(this.type, savedObjectId, newAttributes); - return { savedObjectId } as RefType; - } else { - const savedItem = await this.savedObjectsClient.create(this.type, newAttributes); - return { savedObjectId: savedItem.id } as RefType; - } - } catch (error) { - this.toasts.addDanger({ - title: i18n.translate('dashboard.attributeService.saveToLibraryError', { - defaultMessage: `Panel was not saved to the library. Error: {errorMessage}`, - values: { - errorMessage: error.message, - }, - }), - 'data-test-subj': 'saveDashboardFailure', - }); - return Promise.reject({ error }); + return { [ATTRIBUTE_SERVICE_KEY]: newAttributes } as ValType; + } + try { + if (this.options?.customSaveMethod) { + const savedItem = await this.options.customSaveMethod( + this.type, + newAttributes, + savedObjectId + ); + return { ...originalInput, savedObjectId: savedItem.id } as RefType; + } + + if (savedObjectId) { + await this.savedObjectsClient.update(this.type, savedObjectId, newAttributes); + return { ...originalInput, savedObjectId } as RefType; } + + const savedItem = await this.savedObjectsClient.create(this.type, newAttributes); + return { ...originalInput, savedObjectId: savedItem.id } as RefType; + } catch (error) { + this.toasts.addDanger({ + title: i18n.translate('dashboard.attributeService.saveToLibraryError', { + defaultMessage: `Panel was not saved to the library. Error: {errorMessage}`, + values: { + errorMessage: error.message, + }, + }), + 'data-test-subj': 'saveDashboardFailure', + }); + return Promise.reject({ error }); } } @@ -146,7 +175,7 @@ export class AttributeService< getInputAsRefType = async ( input: ValType | RefType, - saveOptions?: { showSaveModal: boolean } | { title: string } + saveOptions?: { showSaveModal: boolean; saveModalTitle?: string } | { title: string } ): Promise => { if (this.inputIsRefType(input)) { return input; @@ -159,7 +188,7 @@ export class AttributeService< copyOnSave: false, lastSavedTitle: '', getEsType: () => this.type, - getDisplayName: this.embeddableFactory.getDisplayName, + getDisplayName: this.embeddableFactory?.getDisplayName || (() => this.type), }, props.isTitleDuplicateConfirmed, props.onTitleDuplicate, @@ -169,7 +198,7 @@ export class AttributeService< } ); try { - const newAttributes = { ...input.attributes }; + const newAttributes = { ...input[ATTRIBUTE_SERVICE_KEY] }; newAttributes.title = props.newTitle; const wrappedInput = (await this.wrapAttributes(newAttributes, true)) as RefType; resolve(wrappedInput); @@ -181,11 +210,11 @@ export class AttributeService< }; if (saveOptions && (saveOptions as { showSaveModal: boolean }).showSaveModal) { - showSaveModal( + this.showSaveModal( reject()} - title={input.attributes.title} + title={get(saveOptions, 'saveModalTitle', input[ATTRIBUTE_SERVICE_KEY].title)} showCopyOnSave={false} objectType={this.type} showDescription={false} diff --git a/src/plugins/dashboard/public/attribute_service/attribute_service_mock.tsx b/src/plugins/dashboard/public/attribute_service/attribute_service_mock.tsx new file mode 100644 index 0000000000000..321a53361fc7a --- /dev/null +++ b/src/plugins/dashboard/public/attribute_service/attribute_service_mock.tsx @@ -0,0 +1,49 @@ +/* + * 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 { EmbeddableInput, SavedObjectEmbeddableInput } from '../embeddable_plugin'; +import { coreMock } from '../../../../core/public/mocks'; +import { AttributeServiceOptions } from './attribute_service'; +import { CoreStart } from '../../../../core/public'; +import { AttributeService, ATTRIBUTE_SERVICE_KEY } from '..'; + +export const mockAttributeService = < + A extends { title: string }, + V extends EmbeddableInput & { [ATTRIBUTE_SERVICE_KEY]: A } = EmbeddableInput & { + [ATTRIBUTE_SERVICE_KEY]: A; + }, + R extends SavedObjectEmbeddableInput = SavedObjectEmbeddableInput +>( + type: string, + options?: AttributeServiceOptions, + customCore?: jest.Mocked +): AttributeService => { + const core = customCore ? customCore : coreMock.createStart(); + const service = new AttributeService( + type, + jest.fn(), + core.savedObjects.client, + core.overlays, + core.i18n.Context, + core.notifications.toasts, + jest.fn().mockReturnValue(() => ({ getDisplayName: () => type })), + options + ); + return service; +}; diff --git a/src/plugins/dashboard/public/index.ts b/src/plugins/dashboard/public/index.ts index 8a9954cc77a2e..e22d1f038a456 100644 --- a/src/plugins/dashboard/public/index.ts +++ b/src/plugins/dashboard/public/index.ts @@ -40,7 +40,7 @@ export { export { addEmbeddableToDashboardUrl } from './url_utils/url_helper'; export { SavedObjectDashboard } from './saved_dashboards'; export { SavedDashboardPanel } from './types'; -export { AttributeService } from './attribute_service/attribute_service'; +export { AttributeService, ATTRIBUTE_SERVICE_KEY } from './attribute_service/attribute_service'; export function plugin(initializerContext: PluginInitializerContext) { return new DashboardPlugin(initializerContext); diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index 3df52f4e7a205..0ce6f9489ea02 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -52,6 +52,7 @@ import { getSavedObjectFinder, SavedObjectLoader, SavedObjectsStart, + showSaveModal, } from '../../saved_objects/public'; import { ExitFullScreenButton as ExitFullScreenButtonUi, @@ -102,6 +103,10 @@ import { addEmbeddableToDashboardUrl } from './url_utils/url_helper'; import { PlaceholderEmbeddableFactory } from './application/embeddable/placeholder'; import { UrlGeneratorState } from '../../share/public'; import { AttributeService } from '.'; +import { + AttributeServiceOptions, + ATTRIBUTE_SERVICE_KEY, +} from './attribute_service/attribute_service'; declare module '../../share/public' { export interface UrlGeneratorStateMapping { @@ -150,10 +155,13 @@ export interface DashboardStart { DashboardContainerByValueRenderer: ReturnType; getAttributeService: < A extends { title: string }, - V extends EmbeddableInput & { attributes: A }, - R extends SavedObjectEmbeddableInput + V extends EmbeddableInput & { [ATTRIBUTE_SERVICE_KEY]: A } = EmbeddableInput & { + [ATTRIBUTE_SERVICE_KEY]: A; + }, + R extends SavedObjectEmbeddableInput = SavedObjectEmbeddableInput >( - type: string + type: string, + options?: AttributeServiceOptions ) => AttributeService; } @@ -465,14 +473,16 @@ export class DashboardPlugin DashboardContainerByValueRenderer: createDashboardContainerByValueRenderer({ factory: dashboardContainerFactory, }), - getAttributeService: (type: string) => + getAttributeService: (type: string, options) => new AttributeService( type, + showSaveModal, core.savedObjects.client, core.overlays, core.i18n.Context, core.notifications.toasts, - embeddable.getEmbeddableFactory + embeddable.getEmbeddableFactory, + options ), }; } From 6e0b20cbe3a2a0645cec4421265e165b76afe2df Mon Sep 17 00:00:00 2001 From: Yuliia Naumenko Date: Fri, 4 Sep 2020 10:33:49 -0700 Subject: [PATCH 10/17] Replaced whitelistedHosts with allowedHosts in actions ascii docs (#76731) --- docs/settings/alert-action-settings.asciidoc | 6 +++--- docs/user/alerting/action-types/email.asciidoc | 2 +- docs/user/alerting/action-types/pagerduty.asciidoc | 2 +- docs/user/alerting/action-types/slack.asciidoc | 2 +- docs/user/alerting/action-types/webhook.asciidoc | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index 88858c36643ec..13c1d20552fa1 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -37,10 +37,10 @@ You can configure the following settings in the `kibana.yml` file. [cols="2*<"] |=== -| `xpack.actions.whitelistedHosts` {ess-icon} - | A list of hostnames that {kib} is allowed to connect to when built-in actions are triggered. It defaults to `[*]`, allowing any host, but keep in mind the potential for SSRF attacks when hosts are not explicitly whitelisted. An empty list `[]` can be used to block built-in actions from making any external connections. + +| `xpack.actions.allowedHosts` {ess-icon} + | A list of hostnames that {kib} is allowed to connect to when built-in actions are triggered. It defaults to `[*]`, allowing any host, but keep in mind the potential for SSRF attacks when hosts are not explicitly added to the allowed hosts. An empty list `[]` can be used to block built-in actions from making any external connections. + + - Note that hosts associated with built-in actions, such as Slack and PagerDuty, are not automatically whitelisted. If you are not using the default `[*]` setting, you must ensure that the corresponding endpoints are whitelisted as well. + Note that hosts associated with built-in actions, such as Slack and PagerDuty, are not automatically added to allowed hosts. If you are not using the default `[*]` setting, you must ensure that the corresponding endpoints are added to the allowed hosts as well. | `xpack.actions.enabledActionTypes` {ess-icon} | A list of action types that are enabled. It defaults to `[*]`, enabling all types. The names for built-in {kib} action types are prefixed with a `.` and include: `.server-log`, `.slack`, `.email`, `.index`, `.pagerduty`, and `.webhook`. An empty list `[]` will disable all action types. + diff --git a/docs/user/alerting/action-types/email.asciidoc b/docs/user/alerting/action-types/email.asciidoc index f6a02b9038c02..83e7edc5a016a 100644 --- a/docs/user/alerting/action-types/email.asciidoc +++ b/docs/user/alerting/action-types/email.asciidoc @@ -12,7 +12,7 @@ Email connectors have the following configuration properties: Name:: The name of the connector. The name is used to identify a connector in the management UI connector listing, or in the connector list when configuring an action. Sender:: The from address for all emails sent with this connector, specified in `user@host-name` format. -Host:: Host name of the service provider. If you are using the <> setting, make sure this hostname is whitelisted. +Host:: Host name of the service provider. If you are using the <> setting, make sure this hostname is added to the allowed hosts. Port:: The port to connect to on the service provider. Secure:: If true the connection will use TLS when connecting to the service provider. See https://nodemailer.com/smtp/#tls-options[nodemailer TLS documentation] for more information. Username:: username for 'login' type authentication. diff --git a/docs/user/alerting/action-types/pagerduty.asciidoc b/docs/user/alerting/action-types/pagerduty.asciidoc index 5fd85a1045265..2c9add5233c91 100644 --- a/docs/user/alerting/action-types/pagerduty.asciidoc +++ b/docs/user/alerting/action-types/pagerduty.asciidoc @@ -132,7 +132,7 @@ This is an irreversible action and impacts all alerts that use this connector. PagerDuty connectors have the following configuration properties: Name:: The name of the connector. The name is used to identify a connector in the management UI connector listing, or in the connector list when configuring an action. -API URL:: An optional PagerDuty event URL. Defaults to `https://events.pagerduty.com/v2/enqueue`. If you are using the <> setting, make sure the hostname is whitelisted. +API URL:: An optional PagerDuty event URL. Defaults to `https://events.pagerduty.com/v2/enqueue`. If you are using the <> setting, make sure the hostname is added to the allowed hosts. Integration Key:: A 32 character PagerDuty Integration Key for an integration on a service, also referred to as the routing key. [float] diff --git a/docs/user/alerting/action-types/slack.asciidoc b/docs/user/alerting/action-types/slack.asciidoc index 99bf73c0f5597..a1fe7a2521b22 100644 --- a/docs/user/alerting/action-types/slack.asciidoc +++ b/docs/user/alerting/action-types/slack.asciidoc @@ -11,7 +11,7 @@ The Slack action type uses https://api.slack.com/incoming-webhooks[Slack Incomin Slack connectors have the following configuration properties: Name:: The name of the connector. The name is used to identify a connector in the management UI connector listing, or in the connector list when configuring an action. -Webhook URL:: The URL of the incoming webhook. See https://api.slack.com/messaging/webhooks#getting_started[Slack Incoming Webhooks] for instructions on generating this URL. If you are using the <> setting, make sure the hostname is whitelisted. +Webhook URL:: The URL of the incoming webhook. See https://api.slack.com/messaging/webhooks#getting_started[Slack Incoming Webhooks] for instructions on generating this URL. If you are using the <> setting, make sure the hostname is added to the allowed hosts. [float] [[Preconfigured-slack-configuration]] diff --git a/docs/user/alerting/action-types/webhook.asciidoc b/docs/user/alerting/action-types/webhook.asciidoc index c91c24430e982..659c3afad6bd1 100644 --- a/docs/user/alerting/action-types/webhook.asciidoc +++ b/docs/user/alerting/action-types/webhook.asciidoc @@ -11,7 +11,7 @@ The Webhook action type uses https://github.com/axios/axios[axios] to send a POS Webhook connectors have the following configuration properties: Name:: The name of the connector. The name is used to identify a connector in the management UI connector listing, or in the connector list when configuring an action. -URL:: The request URL. If you are using the <> setting, make sure the hostname is whitelisted. +URL:: The request URL. If you are using the <> setting, make sure the hostname is added to the allowed hosts. Method:: HTTP request method, either `post`(default) or `put`. Headers:: A set of key-value pairs sent as headers with the request User:: An optional username. If set, HTTP basic authentication is used. Currently only basic authentication is supported. From b3a85c546d6a6f0db0d45ab91df3799ca9683ebd Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Fri, 4 Sep 2020 21:05:29 +0300 Subject: [PATCH 11/17] Remove commented src/core/tsconfig file (#76792) VSCode pick it up and reports problems, however `tsc` ignores it and doesn't show any errors --- src/core/tsconfig.json | 23 ----------------------- 1 file changed, 23 deletions(-) delete mode 100644 src/core/tsconfig.json diff --git a/src/core/tsconfig.json b/src/core/tsconfig.json deleted file mode 100644 index 1a9e6253bff70..0000000000000 --- a/src/core/tsconfig.json +++ /dev/null @@ -1,23 +0,0 @@ -// { -// "extends": "../../tsconfig.base.json", -// "compilerOptions": { -// // "composite": true, -// "outDir": "./target", -// "emitDeclarationOnly": true, -// "declaration": true, -// "declarationMap": true -// }, -// "include": [ -// "public", -// "server", -// "types", -// "test_helpers", -// "utils", -// "index.ts", -// "../../kibana.d.ts", -// "../../typings/**/*" -// ], -// "references": [ -// { "path": "../test_utils" } -// ] -// } From db67eb0b29bbc794dccfeb64615c317818cf39c8 Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Fri, 4 Sep 2020 21:28:59 +0300 Subject: [PATCH 12/17] Do not require id & description when creating a logstash pipeline (#76616) * do not requie id & desc * remove id validation, it's not used in logic anyway * no id in tests * remove another wrong id usage --- .../components/pipeline_editor/pipeline_editor.js | 4 ++-- x-pack/plugins/logstash/server/models/pipeline/pipeline.ts | 6 +++--- x-pack/plugins/logstash/server/routes/pipeline/save.ts | 3 +-- .../test/api_integration/apis/logstash/pipeline/delete.ts | 1 - x-pack/test/api_integration/apis/logstash/pipeline/save.ts | 1 - 5 files changed, 6 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/logstash/public/application/components/pipeline_editor/pipeline_editor.js b/x-pack/plugins/logstash/public/application/components/pipeline_editor/pipeline_editor.js index 694e50cfe3e36..6cb1f87648da1 100644 --- a/x-pack/plugins/logstash/public/application/components/pipeline_editor/pipeline_editor.js +++ b/x-pack/plugins/logstash/public/application/components/pipeline_editor/pipeline_editor.js @@ -125,11 +125,11 @@ class PipelineEditorUi extends React.Component { onPipelineSave = () => { const { pipelineService, toastNotifications, intl } = this.props; - const { id } = this.state.pipeline; + const { id, ...pipelineToStore } = this.state.pipeline; return pipelineService .savePipeline({ id, - upstreamJSON: this.state.pipeline, + upstreamJSON: pipelineToStore, }) .then(() => { toastNotifications.addSuccess( diff --git a/x-pack/plugins/logstash/server/models/pipeline/pipeline.ts b/x-pack/plugins/logstash/server/models/pipeline/pipeline.ts index 8ce04c83afdbf..0b7c3888b6f03 100755 --- a/x-pack/plugins/logstash/server/models/pipeline/pipeline.ts +++ b/x-pack/plugins/logstash/server/models/pipeline/pipeline.ts @@ -11,14 +11,14 @@ import { i18n } from '@kbn/i18n'; interface PipelineOptions { id: string; - description: string; + description?: string; pipeline: string; username?: string; settings?: Record; } interface DownstreamPipeline { - description: string; + description?: string; pipeline: string; settings?: Record; } @@ -27,7 +27,7 @@ interface DownstreamPipeline { */ export class Pipeline { public readonly id: string; - public readonly description: string; + public readonly description?: string; public readonly username?: string; public readonly pipeline: string; private readonly settings: Record; diff --git a/x-pack/plugins/logstash/server/routes/pipeline/save.ts b/x-pack/plugins/logstash/server/routes/pipeline/save.ts index e484d0e221b6d..755a82e670a2a 100644 --- a/x-pack/plugins/logstash/server/routes/pipeline/save.ts +++ b/x-pack/plugins/logstash/server/routes/pipeline/save.ts @@ -22,8 +22,7 @@ export function registerPipelineSaveRoute(router: IRouter, security?: SecurityPl id: schema.string(), }), body: schema.object({ - id: schema.string(), - description: schema.string(), + description: schema.maybe(schema.string()), pipeline: schema.string(), settings: schema.maybe(schema.object({}, { unknowns: 'allow' })), }), diff --git a/x-pack/test/api_integration/apis/logstash/pipeline/delete.ts b/x-pack/test/api_integration/apis/logstash/pipeline/delete.ts index 2a8cde85ee3cd..e3d68ef690359 100644 --- a/x-pack/test/api_integration/apis/logstash/pipeline/delete.ts +++ b/x-pack/test/api_integration/apis/logstash/pipeline/delete.ts @@ -18,7 +18,6 @@ export default function ({ getService }: FtrProviderContext) { .put('/api/logstash/pipeline/fast_generator') .set('kbn-xsrf', 'xxx') .send({ - id: 'fast_generator', description: 'foobar baz', pipeline: 'input { generator {} }\n\n output { stdout {} }', }) diff --git a/x-pack/test/api_integration/apis/logstash/pipeline/save.ts b/x-pack/test/api_integration/apis/logstash/pipeline/save.ts index f44c5e6252d5f..1c2f23e81eafc 100644 --- a/x-pack/test/api_integration/apis/logstash/pipeline/save.ts +++ b/x-pack/test/api_integration/apis/logstash/pipeline/save.ts @@ -26,7 +26,6 @@ export default function ({ getService }: FtrProviderContext) { .put('/api/logstash/pipeline/fast_generator') .set('kbn-xsrf', 'xxx') .send({ - id: 'fast_generator', description: 'foobar baz', pipeline: 'input { generator {} }\n\n output { stdout {} }', }) From 6d4e772ceced8f450ffdb173248452afe013588c Mon Sep 17 00:00:00 2001 From: Adam Locke Date: Fri, 4 Sep 2020 15:26:05 -0400 Subject: [PATCH 13/17] [Ingest Pipelines] Add descriptions for ingest processors A-D (#75975) * Fixing formatting issues identified by Prettier, part 2. * Fixing helpText labels. * Adding {value} object for dissect processor. * Incorporating reviewer feedback. * fix dropdown not rendering * Fixing typo. * add support for FormattedMessage in help text * fix TS * Updating some strings and trying to add code formatting. * fix formatted message * Editing some field descriptions. * Apply suggestions from code review Co-authored-by: James Rodewig <40268737+jrodewig@users.noreply.github.com> * Trying to add EuiLink, plus edits. * fix help text for dissect processor * Incorporating reviewer feedback. * Trying to add another EUI element, plus edits. * fix date_index_name description text * Minor edit. * Fixing linter error. * Removing FunctionComponent, which was not read and caused build errors. Co-authored-by: Alison Goryachev Co-authored-by: Elastic Machine Co-authored-by: James Rodewig <40268737+jrodewig@users.noreply.github.com> --- .../processors/append.tsx | 6 +- .../processors/bytes.tsx | 5 +- .../processors/circle.tsx | 21 ++-- .../common_fields/common_processor_fields.tsx | 4 +- .../common_fields/processor_type_field.tsx | 21 ++-- .../processors/common_fields/target_field.tsx | 2 +- .../processors/convert.tsx | 13 +-- .../manage_processor_form/processors/csv.tsx | 14 +-- .../manage_processor_form/processors/date.tsx | 10 +- .../processors/date_index_name.tsx | 15 +-- .../processors/dissect.tsx | 109 +++++++++++------- .../processors/dot_expander.tsx | 5 +- .../shared/map_processor_type_to_form.tsx | 43 +++++++ 13 files changed, 171 insertions(+), 97 deletions(-) diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/append.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/append.tsx index 23425297f3420..52750529684b0 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/append.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/append.tsx @@ -28,13 +28,13 @@ const fieldsConfig: FieldsConfig = { defaultMessage: 'Value', }), helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.appendForm.valueFieldHelpText', { - defaultMessage: 'The value to be appended by this processor.', + defaultMessage: 'Values to append.', }), validations: [ { validator: emptyField( i18n.translate('xpack.ingestPipelines.pipelineEditor.appendForm.valueRequiredError', { - defaultMessage: 'A value to set is required.', + defaultMessage: 'A value is required.', }) ), }, @@ -47,7 +47,7 @@ export const Append: FunctionComponent = () => { <> diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/bytes.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/bytes.tsx index a76e1a6f3ce9a..6633f9e5de94b 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/bytes.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/bytes.tsx @@ -17,7 +17,10 @@ export const Bytes: FunctionComponent = () => { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/circle.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/circle.tsx index 599d2fdbfd413..70df18acfd0a9 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/circle.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/circle.tsx @@ -5,7 +5,9 @@ */ import React, { FunctionComponent } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import { EuiCode } from '@elastic/eui'; import { FIELD_TYPES, @@ -34,12 +36,15 @@ const fieldsConfig: FieldsConfig = { defaultMessage: 'Error distance', } ), - helpText: i18n.translate( - 'xpack.ingestPipelines.pipelineEditor.circleForm.errorDistanceHelpText', - { - defaultMessage: - 'The difference between the resulting inscribed distance from center to side and the circle’s radius (measured in meters for geo_shape, unit-less for shape).', - } + helpText: () => ( + {'geo_shape'}, + shape: {'shape'}, + }} + /> ), validations: [ { @@ -66,7 +71,7 @@ const fieldsConfig: FieldsConfig = { }), helpText: i18n.translate( 'xpack.ingestPipelines.pipelineEditor.circleForm.shapeTypeFieldHelpText', - { defaultMessage: 'Which field mapping type is to be used.' } + { defaultMessage: 'Field mapping type to use when processing the output polygon.' } ), validations: [ { @@ -86,7 +91,7 @@ export const Circle: FunctionComponent = () => { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/common_fields/common_processor_fields.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/common_fields/common_processor_fields.tsx index e1048d627f66c..1777cac2a5615 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/common_fields/common_processor_fields.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/common_fields/common_processor_fields.tsx @@ -40,7 +40,7 @@ const ifConfig: FieldConfig = { defaultMessage: 'Condition (optional)', }), helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.commonFields.ifFieldHelpText', { - defaultMessage: 'Conditionally execute this processor.', + defaultMessage: 'Conditionally run this processor.', }), type: FIELD_TYPES.TEXT, }; @@ -50,7 +50,7 @@ const tagConfig: FieldConfig = { defaultMessage: 'Tag (optional)', }), helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.commonFields.tagFieldHelpText', { - defaultMessage: 'An identifier for this processor. Useful for debugging and metrics.', + defaultMessage: 'Identifier for the processor. Useful for debugging and metrics.', }), type: FIELD_TYPES.TEXT, }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/common_fields/processor_type_field.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/common_fields/processor_type_field.tsx index e4ad90f61af0a..3264923442886 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/common_fields/processor_type_field.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/common_fields/processor_type_field.tsx @@ -5,7 +5,7 @@ */ import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React, { FunctionComponent } from 'react'; +import React, { FunctionComponent, ReactNode } from 'react'; import { flow } from 'fp-ts/lib/function'; import { map } from 'fp-ts/lib/Array'; @@ -68,13 +68,18 @@ export const ProcessorTypeField: FunctionComponent = ({ initialType }) => config={typeConfig} defaultValue={initialType} path="type"> {(typeField) => { let selectedOptions: ProcessorTypeAndLabel[]; + let description: string | ReactNode = ''; + if (typeField.value?.length) { const type = typeField.value; - const descriptor = getProcessorDescriptor(type); - selectedOptions = descriptor - ? [{ label: descriptor.label, value: type }] - : // If there is no label for this processor type, just use the type as the label - [{ label: type, value: type }]; + const processorDescriptor = getProcessorDescriptor(type); + if (processorDescriptor) { + description = processorDescriptor.description || ''; + selectedOptions = [{ label: processorDescriptor.label, value: type }]; + } else { + // If there is no label for this processor type, just use the type as the label + selectedOptions = [{ label: type, value: type }]; + } } else { selectedOptions = []; } @@ -102,9 +107,7 @@ export const ProcessorTypeField: FunctionComponent = ({ initialType }) => { @@ -115,14 +115,7 @@ export const Convert: FunctionComponent = () => { path="fields.type" /> - + diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/csv.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/csv.tsx index 835177dd861d5..471efaa56dea0 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/csv.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/csv.tsx @@ -36,7 +36,7 @@ const isStringLengthOne: ValidationFunc = ({ value }) => { message: i18n.translate( 'xpack.ingestPipelines.pipelineEditor.convertForm.separatorLengthError', { - defaultMessage: 'A separator value must be 1 character.', + defaultMessage: 'Must be a single character.', } ), } @@ -52,7 +52,7 @@ const fieldsConfig: FieldsConfig = { defaultMessage: 'Target fields', }), helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.csvForm.targetFieldsHelpText', { - defaultMessage: 'The array of fields to assign extracted values to.', + defaultMessage: 'Output fields. Extracted values are mapped to these fields.', }), validations: [ { @@ -83,7 +83,7 @@ const fieldsConfig: FieldsConfig = { helpText: ( {','} }} /> ), @@ -102,7 +102,7 @@ const fieldsConfig: FieldsConfig = { helpText: ( {'"'} }} /> ), @@ -115,7 +115,7 @@ const fieldsConfig: FieldsConfig = { defaultMessage: 'Trim', }), helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.csvForm.trimFieldHelpText', { - defaultMessage: 'Trim whitespaces in unquoted fields', + defaultMessage: 'Remove whitespaces in unquoted CSV data.', }), }, empty_value: { @@ -127,7 +127,7 @@ const fieldsConfig: FieldsConfig = { 'xpack.ingestPipelines.pipelineEditor.convertForm.emptyValueFieldHelpText', { defaultMessage: - 'Value used to fill empty fields, empty fields will be skipped if this is not provided.', + 'Used to fill empty fields. If no value is provided, empty fields are skipped.', } ), }, @@ -138,7 +138,7 @@ export const CSV: FunctionComponent = () => { <> diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/date.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/date.tsx index 7e3f8e0d7cd70..8d6d88d2b0662 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/date.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/date.tsx @@ -33,7 +33,7 @@ const fieldsConfig: FieldsConfig = { }), helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.dateForm.formatsFieldHelpText', { defaultMessage: - 'An array of the expected date formats. Can be a java time pattern or one of the following formats: ISO8601, UNIX, UNIX_MS, or TAI64N.', + 'Expected date formats. Provided formats are applied sequentially. Accepts a Java time pattern, ISO8601, UNIX, UNIX_MS, or TAI64N formats.', }), validations: [ { @@ -59,7 +59,7 @@ const fieldsConfig: FieldsConfig = { helpText: ( {'UTC'} }} /> ), @@ -73,7 +73,7 @@ const fieldsConfig: FieldsConfig = { helpText: ( {'ENGLISH'} }} /> ), @@ -89,7 +89,7 @@ export const DateProcessor: FunctionComponent = () => { @@ -99,7 +99,7 @@ export const DateProcessor: FunctionComponent = () => { helpText={ {'@timestamp'}, }} diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/date_index_name.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/date_index_name.tsx index 8cbc064c1c90c..73fa54429734f 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/date_index_name.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/date_index_name.tsx @@ -36,7 +36,8 @@ const fieldsConfig: FieldsConfig = { helpText: i18n.translate( 'xpack.ingestPipelines.pipelineEditor.dateIndexNameForm.dateRoundingFieldHelpText', { - defaultMessage: 'How to round the date when formatting the date into the index name.', + defaultMessage: + 'Time period used to round the date when formatting the date into the index name.', } ), validations: [ @@ -64,7 +65,7 @@ const fieldsConfig: FieldsConfig = { ), helpText: i18n.translate( 'xpack.ingestPipelines.pipelineEditor.dateIndexNameForm.indexNamePrefixFieldHelpText', - { defaultMessage: 'A prefix of the index name to be prepended before the printed date.' } + { defaultMessage: 'Prefix to add before the printed date in the index name.' } ), }, index_name_format: { @@ -79,7 +80,7 @@ const fieldsConfig: FieldsConfig = { helpText: ( {'yyyy-MM-dd'} }} /> ), @@ -99,7 +100,7 @@ const fieldsConfig: FieldsConfig = { helpText: ( {"yyyy-MM-dd'T'HH:mm:ss.SSSXX"} }} /> ), @@ -116,7 +117,7 @@ const fieldsConfig: FieldsConfig = { helpText: ( {'UTC'} }} /> ), @@ -133,7 +134,7 @@ const fieldsConfig: FieldsConfig = { helpText: ( {'ENGLISH'} }} /> ), @@ -149,7 +150,7 @@ export const DateIndexName: FunctionComponent = () => { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/dissect.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/dissect.tsx index 344855304e4fd..51bc54c5b372c 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/dissect.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/dissect.tsx @@ -5,7 +5,7 @@ */ import React, { FunctionComponent } from 'react'; -import { EuiCode } from '@elastic/eui'; +import { EuiCode, EuiLink } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { TextEditor } from '../field_components'; @@ -16,6 +16,7 @@ import { fieldValidators, UseField, Field, + useKibana, } from '../../../../../../shared_imports'; import { FieldNameField } from './common_fields/field_name_field'; @@ -24,55 +25,79 @@ import { EDITOR_PX_HEIGHT } from './shared'; const { emptyField } = fieldValidators; -const fieldsConfig: Record = { - /* Required field config */ - pattern: { - type: FIELD_TYPES.TEXT, - label: i18n.translate('xpack.ingestPipelines.pipelineEditor.dissectForm.patternFieldLabel', { - defaultMessage: 'Pattern', - }), - helpText: i18n.translate( - 'xpack.ingestPipelines.pipelineEditor.dissectForm.patternFieldHelpText', - { - defaultMessage: 'The pattern to apply to the field.', - } - ), - validations: [ - { - validator: emptyField( - i18n.translate('xpack.ingestPipelines.pipelineEditor.dissectForm.patternRequiredError', { - defaultMessage: 'A pattern value is required.', - }) - ), - }, - ], - }, - /* Optional field config */ - append_separator: { - type: FIELD_TYPES.TEXT, - label: i18n.translate( - 'xpack.ingestPipelines.pipelineEditor.dissectForm.appendSeparatorparaotrFieldLabel', - { - defaultMessage: 'Append separator (optional)', - } - ), - helpText: ( - {'""'} }} - /> - ), - }, +const getFieldsConfig = (esDocUrl: string): Record => { + return { + /* Required field config */ + pattern: { + type: FIELD_TYPES.TEXT, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.dissectForm.patternFieldLabel', { + defaultMessage: 'Pattern', + }), + helpText: ( + + {i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.dissectForm.patternFieldHelpText.dissectProcessorLink', + { + defaultMessage: 'key modifier', + } + )} + + ), + }} + /> + ), + validations: [ + { + validator: emptyField( + i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.dissectForm.patternRequiredError', + { + defaultMessage: 'A pattern value is required.', + } + ) + ), + }, + ], + }, + /* Optional field config */ + append_separator: { + type: FIELD_TYPES.TEXT, + label: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.dissectForm.appendSeparatorparaotrFieldLabel', + { + defaultMessage: 'Append separator (optional)', + } + ), + helpText: ( + {'""'} }} + /> + ), + }, + }; }; export const Dissect: FunctionComponent = () => { + const { services } = useKibana(); + const fieldsConfig = getFieldsConfig(services.documentation.getEsDocsBasePath()); + return ( <> diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/dot_expander.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/dot_expander.tsx index 4e50c61ac930c..4f2aa2915fdec 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/dot_expander.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/dot_expander.tsx @@ -18,7 +18,8 @@ const fieldsConfig: Record = { defaultMessage: 'Path', }), helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.dotExpanderForm.pathHelpText', { - defaultMessage: 'Only required if the field to expand is part another object field.', + defaultMessage: + 'Output field. Only required if the field to expand is part another object field.', }), }, }; @@ -29,7 +30,7 @@ export const DotExpander: FunctionComponent = () => { ; @@ -58,6 +62,10 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.append', { defaultMessage: 'Append', }), + description: i18n.translate('xpack.ingestPipelines.processors.description.append', { + defaultMessage: + "Appends values to a field's array. If the field contains a single value, the processor first converts it to an array. If the field doesn't exist, the processor creates an array containing the appended values.", + }), }, bytes: { FieldsComponent: Bytes, @@ -65,6 +73,10 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.bytes', { defaultMessage: 'Bytes', }), + description: i18n.translate('xpack.ingestPipelines.processors.description.bytes', { + defaultMessage: + 'Converts digital storage units to bytes. For example, 1KB becomes 1024 bytes.', + }), }, circle: { FieldsComponent: Circle, @@ -72,6 +84,9 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.circle', { defaultMessage: 'Circle', }), + description: i18n.translate('xpack.ingestPipelines.processors.description.circle', { + defaultMessage: 'Converts a circle definition into an approximate polygon.', + }), }, convert: { FieldsComponent: Convert, @@ -79,6 +94,10 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.convert', { defaultMessage: 'Convert', }), + description: i18n.translate('xpack.ingestPipelines.processors.description.convert', { + defaultMessage: + 'Converts a field to a different data type. For example, you can convert a string to an long.', + }), }, csv: { FieldsComponent: CSV, @@ -86,6 +105,9 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.csv', { defaultMessage: 'CSV', }), + description: i18n.translate('xpack.ingestPipelines.processors.description.csv', { + defaultMessage: 'Extracts fields values from CSV data.', + }), }, date: { FieldsComponent: DateProcessor, @@ -93,6 +115,9 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.date', { defaultMessage: 'Date', }), + description: i18n.translate('xpack.ingestPipelines.processors.description.date', { + defaultMessage: 'Converts a date to a document timestamp.', + }), }, date_index_name: { FieldsComponent: DateIndexName, @@ -100,6 +125,13 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.dateIndexName', { defaultMessage: 'Date index name', }), + description: () => ( + {'my-index-yyyy-MM-dd'} }} + /> + ), }, dissect: { FieldsComponent: Dissect, @@ -107,6 +139,9 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.dissect', { defaultMessage: 'Dissect', }), + description: i18n.translate('xpack.ingestPipelines.processors.description.dissect', { + defaultMessage: 'Uses dissect patterns to extract matches from a field.', + }), }, dot_expander: { FieldsComponent: DotExpander, @@ -114,6 +149,10 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.dotExpander', { defaultMessage: 'Dot expander', }), + description: i18n.translate('xpack.ingestPipelines.processors.description.dotExpander', { + defaultMessage: + 'Expands a field containing dot notation into an object field. The object field is then accessible by other processors in the pipeline.', + }), }, drop: { FieldsComponent: Drop, @@ -121,6 +160,10 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.drop', { defaultMessage: 'Drop', }), + description: i18n.translate('xpack.ingestPipelines.processors.description.drop', { + defaultMessage: + 'Drops documents without returning an error. Used to only index documents that meet specified conditions.', + }), }, enrich: { FieldsComponent: Enrich, From 4d4eae44131ffb80313def3097cc6d97ba5c8092 Mon Sep 17 00:00:00 2001 From: Nathan L Smith Date: Fri, 4 Sep 2020 15:10:52 -0500 Subject: [PATCH 14/17] [APM] Fix overlapping transaction names (#76083) ...in the table and the header. Did this by adding `word-break: break-all` to them. Also: * Rename List to TransactionList * Add stories for TransactionList and ApmHeader * Add missing type information to transactions based on sample transaction Fixes #73960. --- .../TransactionList.stories.tsx | 119 ++++++++++++++++++ .../{List => TransactionList}/index.tsx | 33 +++-- .../app/TransactionOverview/index.tsx | 3 +- .../shared/ApmHeader/ApmHeader.stories.tsx | 30 +++++ .../components/shared/ApmHeader/index.tsx | 14 ++- .../server/lib/transaction_groups/fetcher.ts | 4 +- .../apm/typings/es_schemas/raw/fields/host.ts | 6 + .../typings/es_schemas/raw/fields/observer.ts | 4 + .../typings/es_schemas/raw/fields/process.ts | 6 +- .../typings/es_schemas/raw/fields/service.ts | 6 +- .../typings/es_schemas/raw/transaction_raw.ts | 1 + .../apm/typings/es_schemas/ui/fields/agent.ts | 1 + 12 files changed, 206 insertions(+), 21 deletions(-) create mode 100644 x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionList/TransactionList.stories.tsx rename x-pack/plugins/apm/public/components/app/TransactionOverview/{List => TransactionList}/index.tsx (85%) create mode 100644 x-pack/plugins/apm/public/components/shared/ApmHeader/ApmHeader.stories.tsx diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionList/TransactionList.stories.tsx b/x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionList/TransactionList.stories.tsx new file mode 100644 index 0000000000000..bc9df71c534ef --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionList/TransactionList.stories.tsx @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { storiesOf } from '@storybook/react'; +import React from 'react'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { TransactionGroup } from '../../../../../server/lib/transaction_groups/fetcher'; +import { TransactionList } from './'; + +storiesOf('app/TransactionOverview/TransactionList', module).add( + 'Single Row', + () => { + const items: TransactionGroup[] = [ + { + name: + 'GET /api/v1/regions/azure-eastus2/clusters/elasticsearch/xc18de071deb4262be54baebf5f6a1ce/proxy/_snapshot/found-snapshots/_all', + sample: { + container: { + id: + 'xa802046074071c9c828e8db3b7ef92ea0484d9fe783b9c518f65a7b45dfdd2c', + }, + agent: { + name: 'java', + ephemeral_id: 'x787d6b7-3241-4b55-ba49-0c96bc9857d1', + version: '1.17.0', + }, + process: { + pid: 28, + title: '/usr/lib/jvm/java-8-openjdk-amd64/jre/bin/java', + }, + processor: { + name: 'transaction', + event: 'transaction', + }, + labels: { + path: + '/api/v1/regions/azure-eastus2/clusters/elasticsearch/xc18de071deb4262be54baebf5f6a1ce/proxy/_snapshot/found-snapshots/_all', + status_code: '200', + request_method: 'GET', + request_id: 'x273dc2477e021979125e0ec67e8d778', + }, + observer: { + hostname: 'x840922c967b', + name: 'instance-000000000x', + id: 'xb384baf-c16a-415a-928a-a10635a04b81', + ephemeral_id: 'x9227f0e-848d-423e-a65a-5fdee321f4a9', + type: 'apm-server', + version: '7.8.1', + version_major: 7, + }, + trace: { + id: 'x998d7e5db84aa8341b358a264a78984', + }, + '@timestamp': '2020-08-26T14:40:31.472Z', + ecs: { + version: '1.5.0', + }, + service: { + node: { + name: + 'xa802046074071c9c828e8db3b7ef92ea0484d9fe783b9c518f65a7b45dfdd2c', + }, + environment: 'qa', + framework: { + name: 'API', + }, + name: 'adminconsole', + runtime: { + name: 'Java', + version: '1.8.0_265', + }, + language: { + name: 'Java', + version: '1.8.0_265', + }, + version: 'ms-44.1-BC_1', + }, + host: { + hostname: 'xa8020460740', + os: { + platform: 'Linux', + }, + ip: '3.83.239.24', + name: 'xa8020460740', + architecture: 'amd64', + }, + transaction: { + duration: { + us: 8260617, + }, + result: 'HTTP 2xx', + name: + 'GET /api/v1/regions/azure-eastus2/clusters/elasticsearch/xc18de071deb4262be54baebf5f6a1ce/proxy/_snapshot/found-snapshots/_all', + span_count: { + dropped: 0, + started: 8, + }, + id: 'xaa3cae6fd4f7023', + type: 'request', + sampled: true, + }, + timestamp: { + us: 1598452831472001, + }, + }, + p95: 11974156, + averageResponseTime: 8087434.558974359, + transactionsPerMinute: 0.40625, + impact: 100, + impactRelative: 100, + }, + ]; + + return ; + } +); diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/List/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionList/index.tsx similarity index 85% rename from x-pack/plugins/apm/public/components/app/TransactionOverview/List/index.tsx rename to x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionList/index.tsx index 2b1c1b8e8c11c..d8c6d7d28fa9f 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionOverview/List/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionList/index.tsx @@ -19,9 +19,16 @@ import { LoadingStatePrompt } from '../../../shared/LoadingStatePrompt'; import { EmptyMessage } from '../../../shared/EmptyMessage'; import { TransactionDetailLink } from '../../../shared/Links/apm/TransactionDetailLink'; +// Truncate both the link and the child span (the tooltip anchor.) The link so +// it doesn't overflow, and the anchor so we get the ellipsis. const TransactionNameLink = styled(TransactionDetailLink)` - ${truncate('100%')}; font-family: ${fontFamilyCode}; + white-space: nowrap; + ${truncate('100%')}; + + > span { + ${truncate('100%')}; + } `; interface Props { @@ -41,20 +48,20 @@ export function TransactionList({ items, isLoading }: Props) { sortable: true, render: (_, { sample }: TransactionGroup) => { return ( - - - {sample.transaction.name || NOT_AVAILABLE_LABEL} - - + <>{sample.transaction.name || NOT_AVAILABLE_LABEL} + + ); }, }, diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx index 81d8a6f807375..5999988abe848 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx @@ -33,7 +33,7 @@ import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink'; import { fromQuery, toQuery } from '../../shared/Links/url_helpers'; import { LocalUIFilters } from '../../shared/LocalUIFilters'; import { TransactionTypeFilter } from '../../shared/LocalUIFilters/TransactionTypeFilter'; -import { TransactionList } from './List'; +import { TransactionList } from './TransactionList'; import { useRedirect } from './useRedirect'; function getRedirectLocation({ @@ -62,6 +62,7 @@ function getRedirectLocation({ export function TransactionOverview() { const location = useLocation(); const { urlParams } = useUrlParams(); + const { serviceName, transactionType } = urlParams; // TODO: fetching of transaction types should perhaps be lifted since it is needed in several places. Context? diff --git a/x-pack/plugins/apm/public/components/shared/ApmHeader/ApmHeader.stories.tsx b/x-pack/plugins/apm/public/components/shared/ApmHeader/ApmHeader.stories.tsx new file mode 100644 index 0000000000000..c9b7c77409840 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/ApmHeader/ApmHeader.stories.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiTitle } from '@elastic/eui'; +import { storiesOf } from '@storybook/react'; +import React from 'react'; +import { MockApmPluginContextWrapper } from '../../../context/ApmPluginContext/MockApmPluginContext'; +import { ApmHeader } from './'; + +storiesOf('shared/ApmHeader', module) + .addDecorator((storyFn) => { + return ( + {storyFn()} + ); + }) + .add('Example', () => { + return ( + + +

+ GET + /api/v1/regions/azure-eastus2/clusters/elasticsearch/xc18de071deb4262be54baebf5f6a1ce/proxy/_snapshot/found-snapshots/_all +

+
+
+ ); + }); diff --git a/x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx b/x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx index 4ffd422801816..9f67ba99103ec 100644 --- a/x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx @@ -6,15 +6,25 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import React, { ReactNode } from 'react'; -import { KueryBar } from '../KueryBar'; +import styled from 'styled-components'; import { DatePicker } from '../DatePicker'; import { EnvironmentFilter } from '../EnvironmentFilter'; +import { KueryBar } from '../KueryBar'; + +// Header titles with long, unbroken words, like you would see for a long URL in +// a transaction name, with the default `work-break`, don't break, and that ends +// up pushing the date picker off of the screen. Setting `break-all` here lets +// it wrap even if it has a long, unbroken work. The wrapped result is not great +// looking, since it wraps, but it doesn't push any controls off of the screen. +const ChildrenContainerFlexItem = styled(EuiFlexItem)` + word-break: break-all; +`; export function ApmHeader({ children }: { children: ReactNode }) { return ( <> - {children} + {children} diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts b/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts index d0ba31f42c536..5c1e1839d9c53 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts @@ -185,10 +185,12 @@ export async function transactionGroupsFetcher( } export interface TransactionGroup { - key: Record | string; + name?: string; + key?: Record | string; averageResponseTime: number | null | undefined; transactionsPerMinute: number; p95: number | null | undefined; impact: number; + impactRelative?: number; sample: Transaction; } diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/fields/host.ts b/x-pack/plugins/apm/typings/es_schemas/raw/fields/host.ts index 51c09e59d9b68..1869a4fc1bef5 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/fields/host.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/fields/host.ts @@ -5,5 +5,11 @@ */ export interface Host { + architecture?: string; hostname?: string; + name?: string; + ip?: string; + os?: { + platform?: string; + }; } diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/fields/observer.ts b/x-pack/plugins/apm/typings/es_schemas/raw/fields/observer.ts index 0815b7cd88163..823d12cbd8095 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/fields/observer.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/fields/observer.ts @@ -5,7 +5,11 @@ */ export interface Observer { + ephemeral_id?: string; + hostname?: string; + id?: string; name?: string; + type?: string; version: string; version_major: number; } diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/fields/process.ts b/x-pack/plugins/apm/typings/es_schemas/raw/fields/process.ts index 63e1faa382163..898ef04ed6a00 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/fields/process.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/fields/process.ts @@ -5,8 +5,8 @@ */ export interface Process { - args: string[]; + args?: string[]; pid: number; - ppid: number; - title: string; + ppid?: number; + title?: string; } diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/fields/service.ts b/x-pack/plugins/apm/typings/es_schemas/raw/fields/service.ts index 3ef852ebf6dd6..00795d69e13b6 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/fields/service.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/fields/service.ts @@ -9,7 +9,10 @@ export interface Service { environment?: string; framework?: { name: string; - version: string; + version?: string; + }; + node?: { + name?: string; }; runtime?: { name: string; @@ -19,4 +22,5 @@ export interface Service { name: string; version?: string; }; + version?: string; } diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts b/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts index b8ebb4cf8da51..cdfe4183c96f5 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts @@ -54,6 +54,7 @@ export interface TransactionRaw extends APMBaseDoc { // Shared by errors and transactions container?: Container; + ecs?: { version?: string }; host?: Host; http?: Http; kubernetes?: Kubernetes; diff --git a/x-pack/plugins/apm/typings/es_schemas/ui/fields/agent.ts b/x-pack/plugins/apm/typings/es_schemas/ui/fields/agent.ts index 1e542dec06a72..4d98825f36b50 100644 --- a/x-pack/plugins/apm/typings/es_schemas/ui/fields/agent.ts +++ b/x-pack/plugins/apm/typings/es_schemas/ui/fields/agent.ts @@ -19,6 +19,7 @@ export type AgentName = | 'ruby'; export interface Agent { + ephemeral_id?: string; name: AgentName; version: string; } From fef21ab373321629b6cd4e13ca2896a84168c766 Mon Sep 17 00:00:00 2001 From: Joel Griffith Date: Fri, 4 Sep 2020 13:16:15 -0700 Subject: [PATCH 15/17] Adds back in custom images for reporting + tests (#76810) --- .../plugins/reporting/server/plugin.test.ts | 9 ++++++ x-pack/plugins/reporting/server/plugin.ts | 31 +++++++++++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/reporting/server/plugin.test.ts b/x-pack/plugins/reporting/server/plugin.test.ts index 420fa8347cdeb..e0d018869cef1 100644 --- a/x-pack/plugins/reporting/server/plugin.test.ts +++ b/x-pack/plugins/reporting/server/plugin.test.ts @@ -86,6 +86,15 @@ describe('Reporting Plugin', () => { expect(plugin.start(coreStart, pluginStart)).not.toHaveProperty('then'); }); + it('registers an advanced setting for PDF logos', async () => { + const plugin = new ReportingPlugin(initContext); + plugin.setup(coreSetup, pluginSetup); + expect(coreSetup.uiSettings.register).toHaveBeenCalled(); + expect(coreSetup.uiSettings.register.mock.calls[0][0]).toHaveProperty( + 'xpackReporting:customPdfLogo' + ); + }); + it('logs start issues', async () => { const plugin = new ReportingPlugin(initContext); // @ts-ignore overloading error logger diff --git a/x-pack/plugins/reporting/server/plugin.ts b/x-pack/plugins/reporting/server/plugin.ts index 20e22c2db00e3..8c0e352aa06c5 100644 --- a/x-pack/plugins/reporting/server/plugin.ts +++ b/x-pack/plugins/reporting/server/plugin.ts @@ -4,16 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ +import { schema } from '@kbn/config-schema'; +import { i18n } from '@kbn/i18n'; import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/server'; +import { PLUGIN_ID, UI_SETTINGS_CUSTOM_PDF_LOGO } from '../common/constants'; import { ReportingCore } from './'; import { initializeBrowserDriverFactory } from './browsers'; import { buildConfig, ReportingConfigType } from './config'; -import { createQueueFactory, LevelLogger, runValidations, ReportingStore } from './lib'; +import { createQueueFactory, LevelLogger, ReportingStore, runValidations } from './lib'; import { registerRoutes } from './routes'; import { setFieldFormats } from './services'; import { ReportingSetup, ReportingSetupDeps, ReportingStart, ReportingStartDeps } from './types'; import { registerReportingUsageCollector } from './usage'; +const kbToBase64Length = (kb: number) => Math.floor((kb * 1024 * 8) / 6); + declare module 'src/core/server' { interface RequestHandlerContext { reporting?: ReportingStart | null; @@ -34,7 +39,7 @@ export class ReportingPlugin public setup(core: CoreSetup, plugins: ReportingSetupDeps) { // prevent throwing errors in route handlers about async deps not being initialized - core.http.registerRouteHandlerContext('reporting', () => { + core.http.registerRouteHandlerContext(PLUGIN_ID, () => { if (this.reportingCore.pluginIsStarted()) { return {}; // ReportingStart contract } else { @@ -42,6 +47,28 @@ export class ReportingPlugin } }); + core.uiSettings.register({ + [UI_SETTINGS_CUSTOM_PDF_LOGO]: { + name: i18n.translate('xpack.reporting.pdfFooterImageLabel', { + defaultMessage: 'PDF footer image', + }), + value: null, + description: i18n.translate('xpack.reporting.pdfFooterImageDescription', { + defaultMessage: `Custom image to use in the PDF's footer`, + }), + type: 'image', + schema: schema.nullable(schema.byteSize({ max: '200kb' })), + category: [PLUGIN_ID], + // Used client-side for size validation + validation: { + maxSize: { + length: kbToBase64Length(200), + description: '200 kB', + }, + }, + }, + }); + const { elasticsearch, http } = core; const { licensing, security } = plugins; const { initializerContext: initContext, reportingCore } = this; From 1b9030feb09e1e2ac2f4d6c4a64d9f8e2781c7b3 Mon Sep 17 00:00:00 2001 From: Ryland Herrick Date: Fri, 4 Sep 2020 16:18:51 -0500 Subject: [PATCH 16/17] [Security Solution][Detections] Rule forms cleanup (#76138) * Remove unused isNew field Digging through the git history, it looks like this was replaced with the isUpdateView prop at some point. There's a small chance that we're indirectly leveraging the effect of this value being changed, but I think we're safe. * WIP: Making rule form type safe We have lots of anys and unknowns in here, this is my attempt to fix that. I started by defining better types for the state/refs in our parent component; everything else mostly flowed out of that: * Step components now type their form hook for their step's data * Removes lots of unneeded `as` casts * Renames uses of `accordionId` with `step` and `activeStep`, since they are also values of our `RuleStep` enum * Step components now export their default values * The data flow is simpler when the parent passes these values in, rather than trying to merge props with some internal defaults. * The internal defaulting is still there, but I think it'll be unnecessary once I audit the `edit` forms. I've only done this work for the Define step for now, the rest are next. * Make defaultValues a required prop of the define step Now that the create step is passing in the default values, we no longer need to merge with internal state. The one exception is the default indexes; since we need that data for our "reset to default indexes" behavior, we'll keep that functionality within our DefineStep component. * Refactor rule creation forms to not require default values We don't gain much by forcing the parent to pass in default values. The slightly cleaner types are not worth the burden to the parent; instead, we add a type guard to be used in our parent to ensure our values are present before working with them. Previously, we were circumventing this logic with an `as` cast. * Remove unnecessary "deep" comparison These are arrays of strings, so a shallow comparison should suffice here. Also reorders conditions to short-circuit on simple booleans first. * Make StepRuleDescription generic on its schema * Fixes bug introduced by form lib updates There's currently a bug on master where returning to a previous form step does not populate its previous values. After some investigation it appears that this is due to form values being reset on submission (form.reset()). Previously, we kept a separate copy of data in each step's state, and had a useEffect that would repopulate the form's values if they ever became out of sync. Once that was removed I believe this bug was introduced. For now the fix is effectively reimplementing the above behavior, albeit a little more elegantly with `reset()`. If we can restructure the form logic to only require the form data at the end (big if), then we can remove the need to "repopulate" these values to the form. For now, this does remove the local copy of data in the step component as I believe that is no longer needed. Data is stored in the parent, copied/modified to the form, and pushed back up when one clicks on to the next step. * Rename typed hook to obviate eslint exception The linter was complaining because it didn't think that `typedUseKibana` was a hook. But it is, and we should name it as such. * WIP: Fixing type errors in the other form steps Things still aren't quite working, state gets lost when moving through steps but I believe this is addressed in an outstanding PR so I'm not sweating it right now. * Removes as much state in Step components as possible * We shouldn't need this as the form holds all the state as well. If we need to "watch" for a change, we can subscribe to the form's observable to replace FormDataProvider and local state (TODO) * Removes setting of default values in form components * I believe that this is redundant with defaultValues provided to useForm, but I need to verify. * More form cleanup * Removes redundant uses of field's defaultValue * Most are redundant with the form's defaultValue; added calls to field.setValue in cases where the default is generated internally * Removes calls to reset() after submitting * These were needed due to a bug in the form lib; once #75796 is merged these will no longer be necessary. * Fix some leftover type errors * Remove duplicated useEffect hook This exists identically earlier in the component; I'm guessing it was the result of a bad merge conflict. * Fix Rule edit form * Makes data structures more similar to rule creation form * Adds type guards for asserting which step is "active" * Simplifies logic around the active tabe/step/form * Fixes About Step jest tests * Removes use of wait() in favor of act() * Fixes mock call assertion now that we're no longer setting our data to null (which was a now-unnecessary form lib workaround). * Fix bug with going to a previous step after editing actions We never send our actions data back down to the actions form, so it was lost if you went to a previous step. Since the actions UI still had any connectors you created, you merely had to reselect the throttle and the connector, but this prevents you from having to do that. * Add assertions to our rule creation test Asserts that our rule form repopulates with the provided values when going back to a previous step. This is to cover a regression that was not caught by CI (but which has now been fixed). * Simplify Rule Creation logic * Validation and data collection are performed in the parent, not the step component * Step component provides a form ref and notifies the parent when it's being submitted; the rest is the parent's responsibility * Renames some internal helper functions to be more declarative: submitStep, editStep, etc. * Don't persist empty form data when leaving a form step If the active step form is invalid we will receive no data, so we must not persist that lest the form blows up on absent values when we later navigate back. * Skip About Step tests for now These exercise functionality that was moved into the parent, so they need a new home. * Remove unnecessary calls to setValue * Instead of setting our kibana url after the form is created, we add it to the form's default state * We do not need to set the throttle field value, the field component already does this * Style: logic cleanup * Prevent users from navigating away from an invalid step on rule edit We can go against the form lib conventions and persist this invalid data ourselves on transition, but for now this brings the create/edit forms into alignment so that adding the aforementioned behavior should be nearly identical on both. * Display callout if attempting to navigate away from an invalid tab We already do this if you try to _submit_ the form, but not when you click on another tab. * Persist our form submit() rather than the entire form Making the entire stateful form a useEffect dependency was likely causing unnecessary render cycles. This may also have been part of why both the hooks and the data are refs instead of normal state; to prevent these rerenders. * Replace FormDataProvider with useFormData hook We have to do a type cast here because the hook's data is not typed, but other than that this is a solid win and cleans things up immensely; the side effects that result from these values changing are much more apparent (IMO). * Move fetch of fields data _after_ form initialization This ensures that our first fetch of fields will use the index patterns on the form, not necessarily the default ones. * Replace FormDataProvider on About step * This fixes a bug where changing the default severity no longer updated the default risk score. It looks like this was broken when the severity/riskScore overrides were added, and the values of these fields changed from primitives to objects. * Replace local state with useFormData By watching the value directly from the form we no longer have a need for local state, as we were just using it to determine whether the throttle had changed from the default. * Types cleanup Remove some unneeded casts, add some needed ones. * Rewrite About Step tests Rather than asserting that the form is invalid through the UI, we instead retrieve data out of the form hook and assert on that instead. * Add memoization back to StepRuleDescription I'm not sure that it's necessary, but best to leave it until we have time to audit/remove multiple of these. * Do not fetch ML Jobs if StepRuleDescription is not rendering ML Data We were incorrectly invoking the useSecurityJobs hook any time the StepRuleDescription component was rendered, regardless of whether we actually needed that data. This moves the useSecurityJobs hook into the component that uses it, MlJobDescription. If we end up having multiple of these on the page we can evaluate caching/sharing this data somehow, but for now this prevents: * 3x3=9 unnecessary ML calls on the Rule Create page. * 1x3=3 unnecessary ML calls on Rule Details * 1x3=3 unnecessary ML Calls on the Rule Edit page. * Fix bug where revisiting the About step could modify the user's Risk Score With the severity/risk score link back in place, there was a bug where a user who had previously set a manual risk score would have it rewritten on edit (or edit during rule creation). This was due to a poorly-written useEffect that basically said "if there is a severity, set a risk score." This has now been amended to say "if the user changes the severity, set a risk score." * Clean up About Step tests * We don't need act(), it's not doing anything. * We don't need to click Continue since we're just talking to the form hook * Fix local form data when form isn't mounted If the form isn't on the page (e.g. if we're read-only), then useFormData will return no values. In these cases, we can simply fall back to the initialState values, as they'll either be: the default values on a new form, or: the current values on an active create/edit form. Updates the manual type of useFormData to reflect this "maybe" fact. * Allow user to navigate between invalid tabs on Edit Rule * Form hooks now _always_ return the form's data, regardless of validity * Edit Rule now: * persists invalid data * submits both the active form and the destination form on navigation. This is necessary to refresh validations on the destination form, since the form lib assumes a newly-mounted form is valid * simplifies "invalid tab" logic to be derived from our persisted data * Fix logical error If the rule is immutable, they can only edit actions. * Remove unneeded eslint exception Fixed by upstream #76471 * Make 21 the default risk score for a new rule Since the default severity is 'low,' these two defaults now coincide. * Remove duplicated type in favor of common one --- .../alerts_detection_rules_custom.spec.ts | 4 + .../cypress/screens/create_new_rule.ts | 4 + .../cypress/tasks/create_new_rule.ts | 16 + .../public/common/lib/kibana/kibana_react.ts | 5 +- .../rules/description_step/index.test.tsx | 13 +- .../rules/description_step/index.tsx | 21 +- .../ml_job_description.test.tsx | 2 +- .../description_step/ml_job_description.tsx | 33 +- .../components/rules/next_step/index.tsx | 2 +- .../components/rules/step_about_rule/data.tsx | 8 +- .../rules/step_about_rule/default_value.ts | 3 +- .../rules/step_about_rule/index.test.tsx | 217 +++++++------ .../rules/step_about_rule/index.tsx | 78 +++-- .../rules/step_about_rule/schema.tsx | 4 +- .../rules/step_define_rule/index.tsx | 107 +++---- .../rules/step_define_rule/schema.tsx | 3 +- .../rules/step_rule_actions/index.tsx | 106 +++--- .../rules/step_rule_actions/schema.tsx | 5 +- .../rules/step_schedule_rule/index.tsx | 46 +-- .../rules/step_schedule_rule/schema.tsx | 3 +- .../rules/all/__mocks__/mock.ts | 12 +- .../detection_engine/rules/create/helpers.ts | 34 +- .../detection_engine/rules/create/index.tsx | 294 ++++++++--------- .../detection_engine/rules/edit/index.tsx | 303 +++++++----------- .../detection_engine/rules/helpers.test.tsx | 9 +- .../pages/detection_engine/rules/helpers.tsx | 7 +- .../pages/detection_engine/rules/types.ts | 47 ++- .../pages/detection_engine/rules/utils.ts | 8 + .../public/shared_imports.ts | 1 + 29 files changed, 686 insertions(+), 709 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts index ba1de0e40e270..d9d9fde8fc8cc 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts @@ -58,6 +58,8 @@ import { createAndActivateRule, fillAboutRuleAndContinue, fillDefineCustomRuleWithImportedQueryAndContinue, + expectDefineFormToRepopulateAndContinue, + expectAboutFormToRepopulateAndContinue, } from '../tasks/create_new_rule'; import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver'; import { loginAndWaitForPageWithoutDateRange } from '../tasks/login'; @@ -82,6 +84,8 @@ describe('Detection rules, custom', () => { goToCreateNewRule(); fillDefineCustomRuleWithImportedQueryAndContinue(newRule); fillAboutRuleAndContinue(newRule); + expectDefineFormToRepopulateAndContinue(newRule); + expectAboutFormToRepopulateAndContinue(newRule); createAndActivateRule(); cy.get(CUSTOM_RULES_BTN).invoke('text').should('eql', 'Custom rules (1)'); diff --git a/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts index 83ace877cd51d..397d0c0142179 100644 --- a/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts @@ -6,6 +6,8 @@ export const ABOUT_CONTINUE_BTN = '[data-test-subj="about-continue"]'; +export const ABOUT_EDIT_BUTTON = '[data-test-subj="edit-about-rule"]'; + export const ADD_FALSE_POSITIVE_BTN = '[data-test-subj="detectionEngineStepAboutRuleFalsePositives"] .euiButtonEmpty__text'; @@ -26,6 +28,8 @@ export const CUSTOM_QUERY_INPUT = '[data-test-subj="queryInput"]'; export const DEFINE_CONTINUE_BUTTON = '[data-test-subj="define-continue"]'; +export const DEFINE_EDIT_BUTTON = '[data-test-subj="edit-define-rule"]'; + export const IMPORT_QUERY_FROM_SAVED_TIMELINE_LINK = '[data-test-subj="importQueryFromSavedTimeline"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts index 1cce72a48e0f0..3fa300ce9d8d0 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts @@ -48,6 +48,8 @@ import { THRESHOLD_FIELD_SELECTION, THRESHOLD_INPUT_AREA, THRESHOLD_TYPE, + DEFINE_EDIT_BUTTON, + ABOUT_EDIT_BUTTON, } from '../screens/create_new_rule'; import { TIMELINE } from '../screens/timeline'; @@ -175,6 +177,20 @@ export const fillDefineCustomRuleWithImportedQueryAndContinue = ( cy.get(CUSTOM_QUERY_INPUT).should('not.exist'); }; +export const expectDefineFormToRepopulateAndContinue = (rule: CustomRule) => { + cy.get(DEFINE_EDIT_BUTTON).click(); + cy.get(CUSTOM_QUERY_INPUT).invoke('text').should('eq', rule.customQuery); + cy.get(DEFINE_CONTINUE_BUTTON).should('exist').click({ force: true }); + cy.get(DEFINE_CONTINUE_BUTTON).should('not.exist'); +}; + +export const expectAboutFormToRepopulateAndContinue = (rule: CustomRule) => { + cy.get(ABOUT_EDIT_BUTTON).click(); + cy.get(RULE_NAME_INPUT).invoke('val').should('eq', rule.name); + cy.get(ABOUT_CONTINUE_BTN).should('exist').click({ force: true }); + cy.get(ABOUT_CONTINUE_BTN).should('not.exist'); +}; + export const fillDefineThresholdRuleAndContinue = (rule: ThresholdRule) => { const thresholdField = 0; const threshold = 1; diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.ts index 075f06084384b..b4fb307a62b6c 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.ts @@ -19,12 +19,11 @@ export interface WithKibanaProps { kibana: KibanaContext; } -// eslint-disable-next-line react-hooks/rules-of-hooks -const typedUseKibana = () => useKibana(); +const useTypedKibana = () => useKibana(); export { KibanaContextProvider, - typedUseKibana as useKibana, + useTypedKibana as useKibana, useUiSetting, useUiSetting$, withKibana, diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.test.tsx index 8b3d05ce5a574..8179e5865e4ef 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { shallow, mount } from 'enzyme'; import { - StepRuleDescriptionComponent, + StepRuleDescription, addFilterStateIfNotThere, buildListItems, getDescriptionItem, @@ -52,24 +52,24 @@ describe('description_step', () => { mockAboutStep = mockAboutStepRule(); }); - describe('StepRuleDescriptionComponent', () => { + describe('StepRuleDescription', () => { test('renders tow columns when "columns" is "multi"', () => { const wrapper = shallow( - + ); expect(wrapper.find('[data-test-subj="listItemColumnStepRuleDescription"]')).toHaveLength(2); }); test('renders single column when "columns" is "single"', () => { const wrapper = shallow( - + ); expect(wrapper.find('[data-test-subj="listItemColumnStepRuleDescription"]')).toHaveLength(1); }); test('renders one column with title and description split when "columns" is "singleSplit', () => { const wrapper = shallow( - + ); expect(wrapper.find('[data-test-subj="listItemColumnStepRuleDescription"]')).toHaveLength(1); expect( @@ -299,7 +299,6 @@ describe('description_step', () => { describe('queryBar', () => { test('returns array of ListItems when queryBar exist', () => { const mockQueryBar = { - isNew: false, queryBar: { query: { query: 'user.name: root or user.name: admin', @@ -369,7 +368,6 @@ describe('description_step', () => { describe('threshold', () => { test('returns threshold description when threshold exist and field is empty', () => { const mockThreshold = { - isNew: false, threshold: { field: [''], value: 100, @@ -391,7 +389,6 @@ describe('description_step', () => { test('returns threshold description when threshold exist and field is set', () => { const mockThreshold = { - isNew: false, threshold: { field: ['user.name'], value: 100, diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx index cf27fa97b1479..99e36669f78bb 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx @@ -37,7 +37,6 @@ import { buildRuleTypeDescription, buildThresholdDescription, } from './helpers'; -import { useSecurityJobs } from '../../../../common/components/ml_popover/hooks/use_security_jobs'; import { buildMlJobDescription } from './ml_job_description'; import { buildActionsDescription } from './actions_description'; import { buildThrottleDescription } from './throttle_description'; @@ -52,22 +51,21 @@ const DescriptionListContainer = styled(EuiDescriptionList)` } `; -interface StepRuleDescriptionProps { +interface StepRuleDescriptionProps { columns?: 'multi' | 'single' | 'singleSplit'; data: unknown; indexPatterns?: IIndexPattern; - schema: FormSchema; + schema: FormSchema; } -export const StepRuleDescriptionComponent: React.FC = ({ +export const StepRuleDescriptionComponent = ({ data, columns = 'multi', indexPatterns, schema, -}) => { +}: StepRuleDescriptionProps) => { const kibana = useKibana(); const [filterManager] = useState(new FilterManager(kibana.services.uiSettings)); - const { jobs } = useSecurityJobs(false); const keys = Object.keys(schema); const listItems = keys.reduce((acc: ListItems[], key: string) => { @@ -76,8 +74,7 @@ export const StepRuleDescriptionComponent: React.FC = ...acc, buildMlJobDescription( get(key, data) as string, - (get(key, schema) as { label: string }).label, - jobs + (get(key, schema) as { label: string }).label ), ]; } @@ -125,11 +122,13 @@ export const StepRuleDescriptionComponent: React.FC = ); }; -export const StepRuleDescription = memo(StepRuleDescriptionComponent); +export const StepRuleDescription = memo( + StepRuleDescriptionComponent +) as typeof StepRuleDescriptionComponent; -export const buildListItems = ( +export const buildListItems = ( data: unknown, - schema: FormSchema, + schema: FormSchema, filterManager: FilterManager, indexPatterns?: IIndexPattern ): ListItems[] => diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/ml_job_description.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/ml_job_description.test.tsx index 3152fef12c652..ec46610286b46 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/ml_job_description.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/ml_job_description.test.tsx @@ -14,7 +14,7 @@ jest.mock('../../../../common/lib/kibana'); describe('MlJobDescription', () => { it('renders correctly', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find('[data-test-subj="machineLearningJobId"]')).toHaveLength(1); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/ml_job_description.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/ml_job_description.tsx index 6baa2abab33d1..414f6f2c2d3bb 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/ml_job_description.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/ml_job_description.tsx @@ -10,6 +10,7 @@ import { EuiBadge, EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui'; import { MlSummaryJob } from '../../../../../../ml/public'; import { isJobStarted } from '../../../../../common/machine_learning/helpers'; +import { useSecurityJobs } from '../../../../common/components/ml_popover/hooks/use_security_jobs'; import { useKibana } from '../../../../common/lib/kibana'; import { ListItems } from './types'; import { ML_JOB_STARTED, ML_JOB_STOPPED } from './translations'; @@ -69,35 +70,33 @@ const Wrapper = styled.div` overflow: hidden; `; -const MlJobDescriptionComponent: React.FC<{ job: MlSummaryJob }> = ({ job }) => { +const MlJobDescriptionComponent: React.FC<{ jobId: string }> = ({ jobId }) => { + const { jobs } = useSecurityJobs(false); const jobUrl = useKibana().services.application.getUrlForApp( - `ml#/jobs?mlManagement=(jobId:${encodeURI(job.id)})` + `ml#/jobs?mlManagement=(jobId:${encodeURI(jobId)})` ); + const job = jobs.find(({ id }) => id === jobId); - return ( + const jobIdSpan = {jobId}; + + return job != null ? (
- - {job.id} + + {jobIdSpan}
+ ) : ( + jobIdSpan ); }; export const MlJobDescription = React.memo(MlJobDescriptionComponent); -export const buildMlJobDescription = ( - jobId: string, - label: string, - jobs: MlSummaryJob[] -): ListItems => { - const job = jobs.find(({ id }) => id === jobId); - - return { - title: label, - description: job ? : jobId, - }; -}; +export const buildMlJobDescription = (jobId: string, label: string): ListItems => ({ + title: label, + description: , +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/next_step/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/next_step/index.tsx index d97c2b4c8c0aa..7c8f5230cc8ba 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/next_step/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/next_step/index.tsx @@ -9,7 +9,7 @@ import { EuiHorizontalRule, EuiFlexGroup, EuiFlexItem, EuiButton } from '@elasti import * as RuleI18n from '../../../pages/detection_engine/rules/translations'; interface NextStepProps { - onClick: () => Promise; + onClick: () => void; isDisabled: boolean; dataTestSubj?: string; } diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/data.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/data.tsx index 1ef3edf8c720e..08cea23d89e1e 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/data.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/data.tsx @@ -8,12 +8,12 @@ import styled from 'styled-components'; import { EuiHealth } from '@elastic/eui'; import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import React from 'react'; -import * as I18n from './translations'; -export type SeverityValue = 'low' | 'medium' | 'high' | 'critical'; +import { Severity } from '../../../../../common/detection_engine/schemas/common/schemas'; +import * as I18n from './translations'; export interface SeverityOptionItem { - value: SeverityValue; + value: Severity; inputDisplay: React.ReactElement; } @@ -44,7 +44,7 @@ export const severityOptions: SeverityOptionItem[] = [ }, ]; -export const defaultRiskScoreBySeverity: Record = { +export const defaultRiskScoreBySeverity: Record = { low: 21, medium: 47, high: 73, diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/default_value.ts b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/default_value.ts index b9c3e4f84c18e..56c61c2ad6766 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/default_value.ts +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/default_value.ts @@ -21,9 +21,8 @@ export const stepAboutDefaultValue: AboutStepRule = { description: '', isAssociatedToEndpointList: false, isBuildingBlock: false, - isNew: true, severity: { value: 'low', mapping: fillEmptySeverityMappings([]), isMappingChecked: false }, - riskScore: { value: 50, mapping: [], isMappingChecked: false }, + riskScore: { value: 21, mapping: [], isMappingChecked: false }, references: [''], falsePositives: [''], license: '', diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx index 0c834b9fff33a..cb25785eaa5b2 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ import React from 'react'; -import { act } from 'react-dom/test-utils'; import { mount, shallow } from 'enzyme'; import { ThemeProvider } from 'styled-components'; import euiDarkVars from '@elastic/eui/dist/eui_theme_light.json'; @@ -14,9 +13,11 @@ import { StepAboutRule } from '.'; import { mockAboutStepRule } from '../../../pages/detection_engine/rules/all/__mocks__/mock'; import { StepRuleDescription } from '../description_step'; import { stepAboutDefaultValue } from './default_value'; -// we don't have the types for waitFor just yet, so using "as waitFor" until when we do -import { wait as waitFor } from '@testing-library/react'; -import { AboutStepRule } from '../../../pages/detection_engine/rules/types'; +import { + AboutStepRule, + RuleStepsFormHooks, + RuleStep, +} from '../../../pages/detection_engine/rules/types'; import { fillEmptySeverityMappings } from '../../../pages/detection_engine/rules/helpers'; const theme = () => ({ eui: euiDarkVars, darkMode: true }); @@ -33,6 +34,18 @@ afterAll(() => { /* eslint-enable no-console */ describe('StepAboutRuleComponent', () => { + let formHook: RuleStepsFormHooks[RuleStep.aboutRule] | null = null; + const setFormHook = ( + step: K, + hook: RuleStepsFormHooks[K] + ) => { + formHook = hook as typeof formHook; + }; + + beforeEach(() => { + formHook = null; + }); + test('it renders StepRuleDescription if isReadOnlyView is true and "name" property exists', () => { const wrapper = shallow( { expect(wrapper.find(StepRuleDescription).exists()).toBeTruthy(); }); - test('it prevents user from clicking continue if no "description" defined', () => { + it('is invalid if description is not present', async () => { const wrapper = mount( { defaultValues={stepAboutDefaultValue} descriptionColumns="multi" isReadOnlyView={false} + setForm={setFormHook} isLoading={false} - setForm={jest.fn()} - setStepData={jest.fn()} /> ); + if (!formHook) { + throw new Error('Form hook not set, but tests depend on it'); + } + wrapper .find('[data-test-subj="detectionEngineStepAboutRuleName"] input') .first() .simulate('change', { target: { value: 'Test name text' } }); - const descriptionInput = wrapper - .find('[data-test-subj="detectionEngineStepAboutRuleDescription"] textarea') - .first(); - wrapper.find('button[data-test-subj="about-continue"]').first().simulate('click'); - - expect( - wrapper.find('[data-test-subj="detectionEngineStepAboutRuleName"] input').first().props() - .value - ).toEqual('Test name text'); - expect(descriptionInput.props().value).toEqual(''); - expect( - wrapper - .find('[data-test-subj="detectionEngineStepAboutRuleDescription"] label') - .first() - .hasClass('euiFormLabel-isInvalid') - ).toBeTruthy(); - expect( - wrapper - .find('[data-test-subj="detectionEngineStepAboutRuleDescription"] EuiTextArea') - .first() - .prop('isInvalid') - ).toBeTruthy(); + const result = await formHook(); + expect(result?.isValid).toEqual(false); }); - test('it prevents user from clicking continue if no "name" defined', () => { + it('is invalid if no "name" is present', async () => { const wrapper = mount( { defaultValues={stepAboutDefaultValue} descriptionColumns="multi" isReadOnlyView={false} + setForm={setFormHook} isLoading={false} - setForm={jest.fn()} - setStepData={jest.fn()} /> ); + if (!formHook) { + throw new Error('Form hook not set, but tests depend on it'); + } + wrapper .find('[data-test-subj="detectionEngineStepAboutRuleDescription"] textarea') .first() .simulate('change', { target: { value: 'Test description text' } }); - const nameInput = wrapper - .find('[data-test-subj="detectionEngineStepAboutRuleName"] input') - .first(); - - wrapper.find('button[data-test-subj="about-continue"]').first().simulate('click'); - - expect( - wrapper - .find('[data-test-subj="detectionEngineStepAboutRuleDescription"] textarea') - .first() - .props().value - ).toEqual('Test description text'); - expect(nameInput.props().value).toEqual(''); - expect( - wrapper - .find('[data-test-subj="detectionEngineStepAboutRuleName"] label') - .first() - .hasClass('euiFormLabel-isInvalid') - ).toBeTruthy(); - expect( - wrapper - .find('[data-test-subj="detectionEngineStepAboutRuleName"] EuiFieldText') - .first() - .prop('isInvalid') - ).toBeTruthy(); + const result = await formHook(); + expect(result?.isValid).toEqual(false); }); - test('it allows user to click continue if "name" and "description" are defined', async () => { - const stepDataMock = jest.fn(); + it('is valid if both "name" and "description" are present', async () => { const wrapper = mount( { defaultValues={stepAboutDefaultValue} descriptionColumns="multi" isReadOnlyView={false} + setForm={setFormHook} isLoading={false} - setForm={jest.fn()} - setStepData={stepDataMock} /> ); + if (!formHook) { + throw new Error('Form hook not set, but tests depend on it'); + } + wrapper .find('[data-test-subj="detectionEngineStepAboutRuleDescription"] textarea') .first() .simulate('change', { target: { value: 'Test description text' } }); - wrapper .find('[data-test-subj="detectionEngineStepAboutRuleName"] input') .first() .simulate('change', { target: { value: 'Test name text' } }); - wrapper.find('button[data-test-subj="about-continue"]').first().simulate('click').update(); - await waitFor(() => { - const expected: Omit = { - author: [], - isAssociatedToEndpointList: false, - isBuildingBlock: false, - license: '', - ruleNameOverride: '', - timestampOverride: '', - description: 'Test description text', - falsePositives: [''], - name: 'Test name text', - note: '', - references: [''], - riskScore: { value: 50, mapping: [], isMappingChecked: false }, - severity: { value: 'low', mapping: fillEmptySeverityMappings([]), isMappingChecked: false }, - tags: [], - threat: [ - { - framework: 'MITRE ATT&CK', - tactic: { id: 'none', name: 'none', reference: 'none' }, - technique: [], - }, - ], - }; - expect(stepDataMock.mock.calls[1][1]).toEqual(expected); - }); + const expected: AboutStepRule = { + author: [], + isAssociatedToEndpointList: false, + isBuildingBlock: false, + license: '', + ruleNameOverride: '', + timestampOverride: '', + description: 'Test description text', + falsePositives: [''], + name: 'Test name text', + note: '', + references: [''], + riskScore: { value: 21, mapping: [], isMappingChecked: false }, + severity: { value: 'low', mapping: fillEmptySeverityMappings([]), isMappingChecked: false }, + tags: [], + threat: [ + { + framework: 'MITRE ATT&CK', + tactic: { id: 'none', name: 'none', reference: 'none' }, + technique: [], + }, + ], + }; + + const result = await formHook(); + expect(result?.isValid).toEqual(true); + expect(result?.data).toEqual(expected); }); test('it allows user to set the risk score as a number (and not a string)', async () => { - const stepDataMock = jest.fn(); const wrapper = mount( { defaultValues={stepAboutDefaultValue} descriptionColumns="multi" isReadOnlyView={false} + setForm={setFormHook} isLoading={false} - setForm={jest.fn()} - setStepData={stepDataMock} /> ); + if (!formHook) { + throw new Error('Form hook not set, but tests depend on it'); + } + wrapper .find('[data-test-subj="detectionEngineStepAboutRuleName"] input') .first() @@ -224,11 +203,7 @@ describe('StepAboutRuleComponent', () => { .first() .simulate('change', { target: { value: '80' } }); - await act(async () => { - wrapper.find('[data-test-subj="about-continue"]').first().simulate('click').update(); - }); - - const expected: Omit = { + const expected: AboutStepRule = { author: [], isAssociatedToEndpointList: false, isBuildingBlock: false, @@ -251,6 +226,52 @@ describe('StepAboutRuleComponent', () => { }, ], }; - expect(stepDataMock.mock.calls[1][1]).toEqual(expected); + + const result = await formHook(); + expect(result?.isValid).toEqual(true); + expect(result?.data).toEqual(expected); + }); + + it('does not modify the provided risk score until the user changes the severity', async () => { + const wrapper = mount( + + + + ); + + if (!formHook) { + throw new Error('Form hook not set, but tests depend on it'); + } + + wrapper + .find('[data-test-subj="detectionEngineStepAboutRuleName"] input') + .first() + .simulate('change', { target: { value: 'Test name text' } }); + + wrapper + .find('[data-test-subj="detectionEngineStepAboutRuleDescription"] textarea') + .first() + .simulate('change', { target: { value: 'Test description text' } }); + + const result = await formHook(); + expect(result?.isValid).toEqual(true); + expect(result?.data?.riskScore.value).toEqual(21); + + wrapper + .find('[data-test-subj="detectionEngineStepAboutRuleSeverity"] [data-test-subj="select"]') + .last() + .simulate('click'); + wrapper.find('button#medium').simulate('click'); + + const result2 = await formHook(); + expect(result2?.isValid).toEqual(true); + expect(result2?.data?.riskScore.value).toEqual(47); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx index 295b13717f076..d2c84883fa99b 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx @@ -22,13 +22,14 @@ import { AddMitreThreat } from '../mitre'; import { Field, Form, - FormDataProvider, getUseField, UseField, useForm, + useFormData, + FieldHook, } from '../../../../shared_imports'; -import { defaultRiskScoreBySeverity, severityOptions, SeverityValue } from './data'; +import { defaultRiskScoreBySeverity, severityOptions } from './data'; import { stepAboutDefaultValue } from './default_value'; import { isUrlInvalid } from '../../../../common/utils/validators'; import { schema } from './schema'; @@ -68,47 +69,69 @@ const StepAboutRuleComponent: FC = ({ isReadOnlyView, isUpdateView = false, isLoading, + onSubmit, setForm, - setStepData, }) => { const initialState = defaultValues ?? stepAboutDefaultValue; - const [myStepData, setMyStepData] = useState(initialState); + const [severityValue, setSeverityValue] = useState(initialState.severity.value); const [{ isLoading: indexPatternLoading, indexPatterns }] = useFetchIndexPatterns( defineRuleData?.index ?? [], - 'step_about_rule' + RuleStep.aboutRule ); const canUseExceptions = defineRuleData?.ruleType && !isMlRule(defineRuleData.ruleType) && !isThresholdRule(defineRuleData.ruleType); - const { form } = useForm({ + const { form } = useForm({ defaultValue: initialState, options: { stripEmptyFields: false }, schema, }); - const { getFields, submit } = form; + const { getFields, getFormData, submit } = form; + const [{ severity: formSeverity }] = (useFormData({ + form, + watch: ['severity'], + }) as unknown) as [Partial]; - const onSubmit = useCallback(async () => { - if (setStepData) { - setStepData(RuleStep.aboutRule, null, false); - const { isValid, data } = await submit(); - if (isValid) { - setStepData(RuleStep.aboutRule, data, isValid); - setMyStepData({ ...data, isNew: false } as AboutStepRule); + useEffect(() => { + const formSeverityValue = formSeverity?.value; + if (formSeverityValue != null && formSeverityValue !== severityValue) { + setSeverityValue(formSeverityValue); + + const newRiskScoreValue = defaultRiskScoreBySeverity[formSeverityValue]; + if (newRiskScoreValue != null) { + const riskScoreField = getFields().riskScore as FieldHook; + riskScoreField.setValue({ ...riskScoreField.value, value: newRiskScoreValue }); } } - }, [setStepData, submit]); + }, [formSeverity?.value, getFields, severityValue]); + + const getData = useCallback(async () => { + const result = await submit(); + return result?.isValid + ? result + : { + isValid: false, + data: getFormData(), + }; + }, [getFormData, submit]); + + const handleSubmit = useCallback(() => { + if (onSubmit) { + onSubmit(); + } + }, [onSubmit]); useEffect(() => { if (setForm) { - setForm(RuleStep.aboutRule, form); + setForm(RuleStep.aboutRule, getData); } - }, [setForm, form]); + }, [getData, setForm]); - return isReadOnlyView && myStepData.name != null ? ( + return isReadOnlyView ? ( - + ) : ( <> @@ -305,26 +328,11 @@ const StepAboutRuleComponent: FC = ({ }} /> - - {({ severity }) => { - const newRiskScore = defaultRiskScoreBySeverity[severity as SeverityValue]; - const severityField = getFields().severity; - const riskScoreField = getFields().riskScore; - if ( - severityField.value !== severity && - newRiskScore != null && - riskScoreField.value !== newRiskScore - ) { - riskScoreField.setValue(newRiskScore); - } - return null; - }} - {!isUpdateView && ( - + )} ); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/schema.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/schema.tsx index 2264a11341eb8..6df94eca429c5 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/schema.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/schema.tsx @@ -13,7 +13,7 @@ import { ValidationFunc, ERROR_CODE, } from '../../../../shared_imports'; -import { IMitreEnterpriseAttack } from '../../../pages/detection_engine/rules/types'; +import { IMitreEnterpriseAttack, AboutStepRule } from '../../../pages/detection_engine/rules/types'; import { isMitreAttackInvalid } from '../mitre/helpers'; import { OptionalFieldLabel } from '../optional_field_label'; import { isUrlInvalid } from '../../../../common/utils/validators'; @@ -21,7 +21,7 @@ import * as I18n from './translations'; const { emptyField } = fieldValidators; -export const schema: FormSchema = { +export const schema: FormSchema = { author: { type: FIELD_TYPES.COMBO_BOX, label: i18n.translate( diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx index 8a1f961883519..158f323b739e6 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx @@ -7,15 +7,14 @@ import { EuiButtonEmpty, EuiFormRow } from '@elastic/eui'; import React, { FC, memo, useCallback, useState, useEffect } from 'react'; import styled from 'styled-components'; -import deepEqual from 'fast-deep-equal'; +import isEqual from 'lodash/isEqual'; import { DEFAULT_INDEX_KEY } from '../../../../../common/constants'; +import { DEFAULT_TIMELINE_TITLE } from '../../../../timelines/components/timeline/translations'; import { isMlRule } from '../../../../../common/machine_learning/helpers'; import { hasMlAdminPermissions } from '../../../../../common/machine_learning/has_ml_admin_permissions'; import { hasMlLicense } from '../../../../../common/machine_learning/has_ml_license'; -import { IIndexPattern } from '../../../../../../../../src/plugins/data/public'; import { useFetchIndexPatterns } from '../../../containers/detection_engine/rules'; -import { DEFAULT_TIMELINE_TITLE } from '../../../../timelines/components/timeline/translations'; import { useMlCapabilities } from '../../../../common/components/ml/hooks/use_ml_capabilities'; import { useUiSetting$ } from '../../../../common/lib/kibana'; import { @@ -42,9 +41,8 @@ import { getUseField, UseField, UseMultiFields, - FormDataProvider, useForm, - FormSchema, + useFormData, } from '../../../../shared_imports'; import { schema } from './schema'; import * as i18n from './translations'; @@ -52,13 +50,12 @@ import * as i18n from './translations'; const CommonUseField = getUseField({ component: Field }); interface StepDefineRuleProps extends RuleStepProps { - defaultValues?: DefineStepRule | null; + defaultValues?: DefineStepRule; } const stepDefineDefaultValue: DefineStepRule = { anomalyThreshold: 50, index: [], - isNew: true, machineLearningJobId: '', ruleType: 'query', queryBar: { @@ -103,8 +100,8 @@ const StepDefineRuleComponent: FC = ({ isReadOnlyView, isLoading, isUpdateView = false, + onSubmit, setForm, - setStepData, }) => { const mlCapabilities = useMlCapabilities(); const [openTimelineSearch, setOpenTimelineSearch] = useState(false); @@ -112,38 +109,54 @@ const StepDefineRuleComponent: FC = ({ const [indicesConfig] = useUiSetting$(DEFAULT_INDEX_KEY); const initialState = defaultValues ?? { ...stepDefineDefaultValue, - index: indicesConfig ?? [], + index: indicesConfig, }; - const [localRuleType, setLocalRuleType] = useState(initialState.ruleType); - const [myStepData, setMyStepData] = useState(initialState); - const [ - { browserFields, indexPatterns: indexPatternQueryBar, isLoading: indexPatternLoadingQueryBar }, - ] = useFetchIndexPatterns(myStepData.index, 'step_define_rule'); - - const { form } = useForm({ + const { form } = useForm({ defaultValue: initialState, options: { stripEmptyFields: false }, schema, }); - const { getFields, reset, submit } = form; - const clearErrors = useCallback(() => reset({ resetValues: false }), [reset]); + const { getFields, getFormData, reset, submit } = form; + const [{ index: formIndex, ruleType: formRuleType }] = (useFormData({ + form, + watch: ['index', 'ruleType'], + }) as unknown) as [Partial]; + const index = formIndex || initialState.index; + const ruleType = formRuleType || initialState.ruleType; + const [ + { browserFields, indexPatterns: indexPatternQueryBar, isLoading: indexPatternLoadingQueryBar }, + ] = useFetchIndexPatterns(index, RuleStep.defineRule); - const onSubmit = useCallback(async () => { - if (setStepData) { - setStepData(RuleStep.defineRule, null, false); - const { isValid, data } = await submit(); - if (isValid && setStepData) { - setStepData(RuleStep.defineRule, data, isValid); - setMyStepData({ ...data, isNew: false } as DefineStepRule); - } + // reset form when rule type changes + useEffect(() => { + reset({ resetValues: false }); + }, [reset, ruleType]); + + useEffect(() => { + setIndexModified(!isEqual(index, indicesConfig)); + }, [index, indicesConfig]); + + const handleSubmit = useCallback(() => { + if (onSubmit) { + onSubmit(); } - }, [setStepData, submit]); + }, [onSubmit]); + + const getData = useCallback(async () => { + const result = await submit(); + return result?.isValid + ? result + : { + isValid: false, + data: getFormData(), + }; + }, [getFormData, submit]); useEffect(() => { if (setForm) { - setForm(RuleStep.defineRule, form); + setForm(RuleStep.defineRule, getData); } - }, [form, setForm]); + }, [getData, setForm]); const handleResetIndices = useCallback(() => { const indexField = getFields().index; @@ -173,9 +186,9 @@ const StepDefineRuleComponent: FC = ({ ) : ( @@ -192,7 +205,7 @@ const StepDefineRuleComponent: FC = ({ isMlAdmin: hasMlAdminPermissions(mlCapabilities), }} /> - + <> = ({ /> - + <> = ({ @@ -269,11 +282,9 @@ const StepDefineRuleComponent: FC = ({ fields={{ thresholdField: { path: 'threshold.field', - defaultValue: initialState.threshold.field, }, thresholdValue: { path: 'threshold.value', - defaultValue: initialState.threshold.value, }, }} > @@ -290,31 +301,11 @@ const StepDefineRuleComponent: FC = ({ dataTestSubj: 'detectionEngineStepDefineRuleTimeline', }} /> - - {({ index, ruleType }) => { - if (index != null) { - if (deepEqual(index, indicesConfig) && indexModified) { - setIndexModified(false); - } else if (!deepEqual(index, indicesConfig) && !indexModified) { - setIndexModified(true); - } - if (myStepData.index !== index) { - setMyStepData((prevValue) => ({ ...prevValue, index })); - } - } - - if (ruleType !== localRuleType) { - setLocalRuleType(ruleType); - clearErrors(); - } - return null; - }} - {!isUpdateView && ( - + )} ); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/schema.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/schema.tsx index 333b28bf27bbf..07eff94bbbef4 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/schema.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/schema.tsx @@ -19,9 +19,10 @@ import { FormSchema, ValidationFunc, } from '../../../../shared_imports'; +import { DefineStepRule } from '../../../pages/detection_engine/rules/types'; import { CUSTOM_QUERY_REQUIRED, INVALID_CUSTOM_QUERY, INDEX_HELPER_TEXT } from './translations'; -export const schema: FormSchema = { +export const schema: FormSchema = { index: { type: FIELD_TYPES.COMBO_BOX, label: i18n.translate( diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx index 5b4f7677dbc30..e6f1c25bf9dac 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx @@ -13,7 +13,7 @@ import { EuiSpacer, } from '@elastic/eui'; import { findIndex } from 'lodash/fp'; -import React, { FC, memo, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { FC, memo, useCallback, useEffect, useMemo } from 'react'; import { ActionVariable } from '../../../../../../triggers_actions_ui/public'; import { @@ -22,7 +22,7 @@ import { ActionsStepRule, } from '../../../pages/detection_engine/rules/types'; import { StepRuleDescription } from '../description_step'; -import { Form, UseField, useForm } from '../../../../shared_imports'; +import { Form, UseField, useForm, useFormData } from '../../../../shared_imports'; import { StepContentWrapper } from '../step_content_wrapper'; import { ThrottleSelectField, @@ -40,9 +40,8 @@ interface StepRuleActionsProps extends RuleStepProps { actionMessageParams: ActionVariable[]; } -const stepActionsDefaultValue = { +const stepActionsDefaultValue: ActionsStepRule = { enabled: true, - isNew: true, actions: [], kibanaSiemAppUrl: '', throttle: DEFAULT_THROTTLE_OPTION.value, @@ -65,27 +64,16 @@ const StepRuleActionsComponent: FC = ({ isReadOnlyView, isLoading, isUpdateView = false, - setStepData, + onSubmit, setForm, actionMessageParams, }) => { - const initialState = defaultValues ?? stepActionsDefaultValue; - const [myStepData, setMyStepData] = useState(initialState); const { services: { application, triggers_actions_ui: { actionTypeRegistry }, }, } = useKibana(); - const schema = useMemo(() => getSchema({ actionTypeRegistry }), [actionTypeRegistry]); - - const { form } = useForm({ - defaultValue: initialState, - options: { stripEmptyFields: false }, - schema, - }); - const { submit } = form; - const kibanaAbsoluteUrl = useMemo( () => application.getUrlForApp(`${APP_ID}`, { @@ -93,37 +81,52 @@ const StepRuleActionsComponent: FC = ({ }), [application] ); - - const onSubmit = useCallback( - async (enabled: boolean) => { - if (setStepData) { - setStepData(RuleStep.ruleActions, null, false); - const { isValid: newIsValid, data } = await submit(); - if (newIsValid) { - setStepData(RuleStep.ruleActions, { ...data, enabled }, newIsValid); - setMyStepData({ ...data, isNew: false } as ActionsStepRule); - } + const initialState = { + ...(defaultValues ?? stepActionsDefaultValue), + kibanaSiemAppUrl: kibanaAbsoluteUrl, + }; + const schema = useMemo(() => getSchema({ actionTypeRegistry }), [actionTypeRegistry]); + const { form } = useForm({ + defaultValue: initialState, + options: { stripEmptyFields: false }, + schema, + }); + const { getFields, getFormData, submit } = form; + const [{ throttle: formThrottle }] = (useFormData({ + form, + watch: ['throttle'], + }) as unknown) as [Partial]; + const throttle = formThrottle || initialState.throttle; + + const handleSubmit = useCallback( + (enabled: boolean) => { + getFields().enabled.setValue(enabled); + if (onSubmit) { + onSubmit(); } }, - [setStepData, submit] + [getFields, onSubmit] ); + const getData = useCallback(async () => { + const result = await submit(); + return result?.isValid + ? result + : { + isValid: false, + data: getFormData(), + }; + }, [getFormData, submit]); + useEffect(() => { if (setForm) { - setForm(RuleStep.ruleActions, form); + setForm(RuleStep.ruleActions, getData); } - }, [form, setForm]); - - const updateThrottle = useCallback((throttle) => setMyStepData({ ...myStepData, throttle }), [ - myStepData, - setMyStepData, - ]); + }, [getData, setForm]); const throttleOptions = useMemo(() => { - const throttle = myStepData.throttle; - return getThrottleOptions(throttle); - }, [myStepData]); + }, [throttle]); const throttleFieldComponentProps = useMemo( () => ({ @@ -131,18 +134,16 @@ const StepRuleActionsComponent: FC = ({ isDisabled: isLoading, dataTestSubj: 'detectionEngineStepRuleActionsThrottle', hasNoInitialSelection: false, - handleChange: updateThrottle, euiFieldProps: { options: throttleOptions, }, }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [isLoading, updateThrottle] + [isLoading, throttleOptions] ); - return isReadOnlyView && myStepData != null ? ( + return isReadOnlyView ? ( - + ) : ( <> @@ -154,12 +155,11 @@ const StepRuleActionsComponent: FC = ({ component={ThrottleSelectField} componentProps={throttleFieldComponentProps} /> - {myStepData.throttle !== stepActionsDefaultValue.throttle ? ( + {throttle !== stepActionsDefaultValue.throttle ? ( <> = ({ /> ) : ( - + )} - - + + @@ -197,7 +189,7 @@ const StepRuleActionsComponent: FC = ({ fill={false} isDisabled={isLoading} isLoading={isLoading} - onClick={onSubmit.bind(null, false)} + onClick={() => handleSubmit(false)} > {I18n.COMPLETE_WITHOUT_ACTIVATING} @@ -207,7 +199,7 @@ const StepRuleActionsComponent: FC = ({ fill isDisabled={isLoading} isLoading={isLoading} - onClick={onSubmit.bind(null, true)} + onClick={() => handleSubmit(true)} data-test-subj="create-activate" > {I18n.COMPLETE_WITH_ACTIVATING} diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.tsx index a093f991afaf7..38de3a2026eca 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.tsx @@ -12,7 +12,8 @@ import { AlertAction, ActionTypeRegistryContract, } from '../../../../../../triggers_actions_ui/public'; -import { FormSchema, FormData, ValidationFunc, ERROR_CODE } from '../../../../shared_imports'; +import { FormSchema, ValidationFunc, ERROR_CODE } from '../../../../shared_imports'; +import { ActionsStepRule } from '../../../pages/detection_engine/rules/types'; import * as I18n from './translations'; import { isUuidv4, getActionTypeName, validateMustache, validateActionParams } from './utils'; @@ -61,7 +62,7 @@ export const getSchema = ({ actionTypeRegistry, }: { actionTypeRegistry: ActionTypeRegistryContract; -}): FormSchema => ({ +}): FormSchema => ({ actions: { validations: [ { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/index.tsx index 52f04f8423bec..d451932a6b634 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, memo, useCallback, useEffect, useState } from 'react'; +import React, { FC, memo, useCallback, useEffect } from 'react'; import { RuleStep, @@ -22,9 +22,8 @@ interface StepScheduleRuleProps extends RuleStepProps { defaultValues?: ScheduleStepRule | null; } -const stepScheduleDefaultValue = { +const stepScheduleDefaultValue: ScheduleStepRule = { interval: '5m', - isNew: true, from: '1m', }; @@ -35,39 +34,44 @@ const StepScheduleRuleComponent: FC = ({ isReadOnlyView, isLoading, isUpdateView = false, - setStepData, + onSubmit, setForm, }) => { const initialState = defaultValues ?? stepScheduleDefaultValue; - const [myStepData, setMyStepData] = useState(initialState); - const { form } = useForm({ + const { form } = useForm({ defaultValue: initialState, options: { stripEmptyFields: false }, schema, }); - const { submit } = form; - const onSubmit = useCallback(async () => { - if (setStepData) { - setStepData(RuleStep.scheduleRule, null, false); - const { isValid: newIsValid, data } = await submit(); - if (newIsValid) { - setStepData(RuleStep.scheduleRule, { ...data }, newIsValid); - setMyStepData({ ...data, isNew: false } as ScheduleStepRule); - } + const { getFormData, submit } = form; + + const handleSubmit = useCallback(() => { + if (onSubmit) { + onSubmit(); } - }, [setStepData, submit]); + }, [onSubmit]); + + const getData = useCallback(async () => { + const result = await submit(); + return result?.isValid + ? result + : { + isValid: false, + data: getFormData(), + }; + }, [getFormData, submit]); useEffect(() => { if (setForm) { - setForm(RuleStep.scheduleRule, form); + setForm(RuleStep.scheduleRule, getData); } - }, [form, setForm]); + }, [getData, setForm]); - return isReadOnlyView && myStepData != null ? ( + return isReadOnlyView ? ( - + ) : ( <> @@ -96,7 +100,7 @@ const StepScheduleRuleComponent: FC = ({ {!isUpdateView && ( - + )} ); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/schema.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/schema.tsx index f4c371a2364f6..cf93a9b61710c 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/schema.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/schema.tsx @@ -9,9 +9,10 @@ import { i18n } from '@kbn/i18n'; import { OptionalFieldLabel } from '../optional_field_label'; +import { ScheduleStepRule } from '../../../pages/detection_engine/rules/types'; import { FormSchema } from '../../../../shared_imports'; -export const schema: FormSchema = { +export const schema: FormSchema = { interval: { label: i18n.translate( 'xpack.securitySolution.detectionEngine.createRule.stepScheduleRule.fieldIntervalLabel', diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts index 8c6e91254314e..5a626ce0ff00a 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts @@ -165,8 +165,7 @@ export const mockRuleWithEverything = (id: string): Rule => ({ }); // TODO: update types mapping -export const mockAboutStepRule = (isNew = false): AboutStepRule => ({ - isNew, +export const mockAboutStepRule = (): AboutStepRule => ({ author: ['Elastic'], isAssociatedToEndpointList: false, isBuildingBlock: false, @@ -200,16 +199,14 @@ export const mockAboutStepRule = (isNew = false): AboutStepRule => ({ note: '# this is some markdown documentation', }); -export const mockActionsStepRule = (isNew = false, enabled = false): ActionsStepRule => ({ - isNew, +export const mockActionsStepRule = (enabled = false): ActionsStepRule => ({ actions: [], kibanaSiemAppUrl: 'http://localhost:5601/app/siem', enabled, throttle: 'no_actions', }); -export const mockDefineStepRule = (isNew = false): DefineStepRule => ({ - isNew, +export const mockDefineStepRule = (): DefineStepRule => ({ ruleType: 'query', anomalyThreshold: 50, machineLearningJobId: '', @@ -225,8 +222,7 @@ export const mockDefineStepRule = (isNew = false): DefineStepRule => ({ }, }); -export const mockScheduleStepRule = (isNew = false): ScheduleStepRule => ({ - isNew, +export const mockScheduleStepRule = (): ScheduleStepRule => ({ interval: '5m', from: '6m', to: 'now', diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts index 874ef032b7c5e..0137777f8f8f2 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts @@ -13,7 +13,7 @@ import { transformAlertToRuleAction } from '../../../../../../common/detection_e import { isMlRule } from '../../../../../../common/machine_learning/helpers'; import { isThresholdRule } from '../../../../../../common/detection_engine/utils'; import { List } from '../../../../../../common/detection_engine/schemas/types'; -import { ENDPOINT_LIST_ID } from '../../../../../shared_imports'; +import { ENDPOINT_LIST_ID, ExceptionListType, NamespaceType } from '../../../../../shared_imports'; import { Rule } from '../../../../containers/detection_engine/rules'; import { Type } from '../../../../../../common/detection_engine/schemas/common/schemas'; @@ -26,6 +26,8 @@ import { ScheduleStepRuleJson, AboutStepRuleJson, ActionsStepRuleJson, + RuleStepsFormData, + RuleStep, } from '../types'; export const getTimeTypeValue = (time: string): { unit: string; value: number } => { @@ -33,8 +35,8 @@ export const getTimeTypeValue = (time: string): { unit: string; value: number } unit: '', value: 0, }; - const filterTimeVal = (time as string).match(/\d+/g); - const filterTimeType = (time as string).match(/[a-zA-Z]+/g); + const filterTimeVal = time.match(/\d+/g); + const filterTimeType = time.match(/[a-zA-Z]+/g); if (!isEmpty(filterTimeVal) && filterTimeVal != null && !isNaN(Number(filterTimeVal[0]))) { timeObj.value = Number(filterTimeVal[0]); } @@ -48,6 +50,23 @@ export const getTimeTypeValue = (time: string): { unit: string; value: number } return timeObj; }; +export const stepIsValid = ( + formData?: T +): formData is { [K in keyof T]: Exclude } => + !!formData?.isValid && !!formData.data; + +export const isDefineStep = (input: unknown): input is RuleStepsFormData[RuleStep.defineRule] => + has('data.ruleType', input); + +export const isAboutStep = (input: unknown): input is RuleStepsFormData[RuleStep.aboutRule] => + has('data.name', input); + +export const isScheduleStep = (input: unknown): input is RuleStepsFormData[RuleStep.scheduleRule] => + has('data.interval', input); + +export const isActionsStep = (input: unknown): input is RuleStepsFormData[RuleStep.ruleActions] => + has('data.actions', input); + export interface RuleFields { anomalyThreshold: unknown; machineLearningJobId: unknown; @@ -129,7 +148,7 @@ export const formatDefineStepData = (defineStepData: DefineStepRule): DefineStep }; export const formatScheduleStepData = (scheduleData: ScheduleStepRule): ScheduleStepRuleJson => { - const { isNew, ...formatScheduleData } = scheduleData; + const { ...formatScheduleData } = scheduleData; if (!isEmpty(formatScheduleData.interval) && !isEmpty(formatScheduleData.from)) { const { unit: intervalUnit, value: intervalValue } = getTimeTypeValue( formatScheduleData.interval @@ -161,7 +180,6 @@ export const formatAboutStepData = ( threat, isAssociatedToEndpointList, isBuildingBlock, - isNew, note, ruleNameOverride, timestampOverride, @@ -180,11 +198,11 @@ export const formatAboutStepData = ( { id: ENDPOINT_LIST_ID, list_id: ENDPOINT_LIST_ID, - namespace_type: 'agnostic', - type: 'endpoint', + namespace_type: 'agnostic' as NamespaceType, + type: 'endpoint' as ExceptionListType, }, ...detectionExceptionLists, - ] as AboutStepRuleJson['exceptions_list'], + ], } : exceptionsList != null ? { diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx index 22d84c593b415..48247392dfe7f 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx @@ -22,32 +22,20 @@ import { displaySuccessToast, useStateToaster } from '../../../../../common/comp import { SpyRoute } from '../../../../../common/utils/route/spy_routes'; import { useUserData } from '../../../../components/user_info'; import { AccordionTitle } from '../../../../components/rules/accordion_title'; -import { FormData, FormHook } from '../../../../../shared_imports'; -import { StepAboutRule } from '../../../../components/rules/step_about_rule'; import { StepDefineRule } from '../../../../components/rules/step_define_rule'; +import { StepAboutRule } from '../../../../components/rules/step_about_rule'; import { StepScheduleRule } from '../../../../components/rules/step_schedule_rule'; import { StepRuleActions } from '../../../../components/rules/step_rule_actions'; import { DetectionEngineHeaderPage } from '../../../../components/detection_engine_header_page'; import * as RuleI18n from '../translations'; import { redirectToDetections, getActionMessageParams, userHasNoPermissions } from '../helpers'; -import { - AboutStepRule, - DefineStepRule, - RuleStep, - RuleStepData, - ScheduleStepRule, - ActionsStepRule, -} from '../types'; -import { formatRule } from './helpers'; +import { RuleStep, RuleStepsFormData, RuleStepsFormHooks } from '../types'; +import { formatRule, stepIsValid } from './helpers'; import * as i18n from './translations'; import { SecurityPageName } from '../../../../../app/types'; +import { ruleStepsOrder } from '../utils'; -const stepsRuleOrder = [ - RuleStep.defineRule, - RuleStep.aboutRule, - RuleStep.scheduleRule, - RuleStep.ruleActions, -]; +const formHookNoop = async (): Promise => undefined; const MyEuiPanel = styled(EuiPanel)<{ zindex?: number; @@ -100,95 +88,137 @@ const CreateRulePageComponent: React.FC = () => { } = useListsConfig(); const loading = userInfoLoading || listsConfigLoading; const [, dispatchToaster] = useStateToaster(); - const [openAccordionId, setOpenAccordionId] = useState(RuleStep.defineRule); + const [activeStep, setActiveStep] = useState(RuleStep.defineRule); + const getNextStep = (step: RuleStep): RuleStep | undefined => + ruleStepsOrder[ruleStepsOrder.indexOf(step) + 1]; const defineRuleRef = useRef(null); const aboutRuleRef = useRef(null); const scheduleRuleRef = useRef(null); const ruleActionsRef = useRef(null); - const stepsForm = useRef | null>>({ - [RuleStep.defineRule]: null, - [RuleStep.aboutRule]: null, - [RuleStep.scheduleRule]: null, - [RuleStep.ruleActions]: null, + const formHooks = useRef({ + [RuleStep.defineRule]: formHookNoop, + [RuleStep.aboutRule]: formHookNoop, + [RuleStep.scheduleRule]: formHookNoop, + [RuleStep.ruleActions]: formHookNoop, }); - const stepsData = useRef>({ + const setFormHook = useCallback( + (step: K, hook: RuleStepsFormHooks[K]) => { + formHooks.current[step] = hook; + }, + [] + ); + const stepsData = useRef({ [RuleStep.defineRule]: { isValid: false, data: undefined }, [RuleStep.aboutRule]: { isValid: false, data: undefined }, [RuleStep.scheduleRule]: { isValid: false, data: undefined }, [RuleStep.ruleActions]: { isValid: false, data: undefined }, }); - const [isStepRuleInReadOnlyView, setIsStepRuleInEditView] = useState>({ + const setStepData = ( + step: K, + data: RuleStepsFormData[K] + ): void => { + stepsData.current[step] = data; + }; + const [openSteps, setOpenSteps] = useState({ [RuleStep.defineRule]: false, [RuleStep.aboutRule]: false, [RuleStep.scheduleRule]: false, [RuleStep.ruleActions]: false, }); const [{ isLoading, isSaved }, setRule] = useCreateRule(); - const actionMessageParams = useMemo( - () => - getActionMessageParams((stepsData.current['define-rule'].data as DefineStepRule)?.ruleType), - // eslint-disable-next-line react-hooks/exhaustive-deps - [stepsData.current['define-rule'].data] - ); + const ruleType = stepsData.current[RuleStep.defineRule].data?.ruleType; + const ruleName = stepsData.current[RuleStep.aboutRule].data?.name; + const actionMessageParams = useMemo(() => getActionMessageParams(ruleType), [ruleType]); const history = useHistory(); - const setStepData = useCallback( - (step: RuleStep, data: unknown, isValid: boolean) => { - stepsData.current[step] = { ...stepsData.current[step], data, isValid }; - if (isValid) { - const stepRuleIdx = stepsRuleOrder.findIndex((item) => step === item); - if ([0, 1, 2].includes(stepRuleIdx)) { - if (isStepRuleInReadOnlyView[stepsRuleOrder[stepRuleIdx + 1]]) { - setOpenAccordionId(stepsRuleOrder[stepRuleIdx + 1]); - setIsStepRuleInEditView({ - ...isStepRuleInReadOnlyView, - [step]: true, - [stepsRuleOrder[stepRuleIdx + 1]]: false, - }); - } else if (openAccordionId !== stepsRuleOrder[stepRuleIdx + 1]) { - setIsStepRuleInEditView({ - ...isStepRuleInReadOnlyView, - [step]: true, - }); - openCloseAccordion(stepsRuleOrder[stepRuleIdx + 1]); - setOpenAccordionId(stepsRuleOrder[stepRuleIdx + 1]); + const handleAccordionToggle = useCallback( + (step: RuleStep, isOpen: boolean) => + setOpenSteps((_openSteps) => ({ + ..._openSteps, + [step]: isOpen, + })), + [] + ); + const goToStep = useCallback( + (step: RuleStep) => { + if (ruleStepsOrder.indexOf(step) > ruleStepsOrder.indexOf(activeStep) && !openSteps[step]) { + toggleStepAccordion(step); + } + setActiveStep(step); + }, + [activeStep, openSteps] + ); + + const toggleStepAccordion = (step: RuleStep | null) => { + if (step === RuleStep.defineRule) { + defineRuleRef.current?.onToggle(); + } else if (step === RuleStep.aboutRule) { + aboutRuleRef.current?.onToggle(); + } else if (step === RuleStep.scheduleRule) { + scheduleRuleRef.current?.onToggle(); + } else if (step === RuleStep.ruleActions) { + ruleActionsRef.current?.onToggle(); + } + }; + + const editStep = useCallback( + async (step: RuleStep) => { + const activeStepData = await formHooks.current[activeStep](); + + if (activeStepData?.isValid) { + setStepData(activeStep, activeStepData); + goToStep(step); + } + }, + [activeStep, goToStep] + ); + const submitStep = useCallback( + async (step: RuleStep) => { + const stepData = await formHooks.current[step](); + + if (stepData?.isValid) { + setStepData(step, stepData); + const nextStep = getNextStep(step); + + if (nextStep != null) { + goToStep(nextStep); + } else { + const defineStep = await stepsData.current[RuleStep.defineRule]; + const aboutStep = await stepsData.current[RuleStep.aboutRule]; + const scheduleStep = await stepsData.current[RuleStep.scheduleRule]; + const actionsStep = await stepsData.current[RuleStep.ruleActions]; + + if ( + stepIsValid(defineStep) && + stepIsValid(aboutStep) && + stepIsValid(scheduleStep) && + stepIsValid(actionsStep) + ) { + setRule( + formatRule( + defineStep.data, + aboutStep.data, + scheduleStep.data, + actionsStep.data + ) + ); } - } else if ( - stepRuleIdx === 3 && - stepsData.current[RuleStep.defineRule].isValid && - stepsData.current[RuleStep.aboutRule].isValid && - stepsData.current[RuleStep.scheduleRule].isValid - ) { - setRule( - formatRule( - stepsData.current[RuleStep.defineRule].data as DefineStepRule, - stepsData.current[RuleStep.aboutRule].data as AboutStepRule, - stepsData.current[RuleStep.scheduleRule].data as ScheduleStepRule, - stepsData.current[RuleStep.ruleActions].data as ActionsStepRule - ) - ); } } }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [isStepRuleInReadOnlyView, openAccordionId, stepsData.current, setRule] + [goToStep, setRule] ); - const setStepsForm = useCallback((step: RuleStep, form: FormHook) => { - stepsForm.current[step] = form; - }, []); - const getAccordionType = useCallback( - (accordionId: RuleStep) => { - if (accordionId === openAccordionId) { + (step: RuleStep) => { + if (step === activeStep) { return 'active'; - } else if (stepsData.current[accordionId].isValid) { + } else if (stepsData.current[step].isValid) { return 'valid'; } return 'passive'; }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [openAccordionId, stepsData.current] + [activeStep] ); const defineRuleButton = ( @@ -198,7 +228,6 @@ const CreateRulePageComponent: React.FC = () => { type={getAccordionType(RuleStep.defineRule)} /> ); - const aboutRuleButton = ( { type={getAccordionType(RuleStep.aboutRule)} /> ); - const scheduleRuleButton = ( { type={getAccordionType(RuleStep.scheduleRule)} /> ); - const ruleActionsButton = ( { /> ); - const openCloseAccordion = (accordionId: RuleStep | null) => { - if (accordionId != null) { - if (accordionId === RuleStep.defineRule && defineRuleRef.current != null) { - defineRuleRef.current.onToggle(); - } else if (accordionId === RuleStep.aboutRule && aboutRuleRef.current != null) { - aboutRuleRef.current.onToggle(); - } else if (accordionId === RuleStep.scheduleRule && scheduleRuleRef.current != null) { - scheduleRuleRef.current.onToggle(); - } else if (accordionId === RuleStep.ruleActions && ruleActionsRef.current != null) { - ruleActionsRef.current.onToggle(); - } - } - }; - - const manageAccordions = useCallback( - (id: RuleStep, isOpen: boolean) => { - const activeRuleIdx = stepsRuleOrder.findIndex((step) => step === openAccordionId); - const stepRuleIdx = stepsRuleOrder.findIndex((step) => step === id); - - if ((id === openAccordionId || stepRuleIdx < activeRuleIdx) && !isOpen) { - openCloseAccordion(id); - } else if (stepRuleIdx >= activeRuleIdx) { - if ( - openAccordionId !== id && - !stepsData.current[openAccordionId].isValid && - !isStepRuleInReadOnlyView[id] && - isOpen - ) { - openCloseAccordion(id); - } - } - }, - [isStepRuleInReadOnlyView, openAccordionId, stepsData] - ); - - const manageIsEditable = useCallback( - async (id: RuleStep) => { - const activeForm = await stepsForm.current[openAccordionId]?.submit(); - if (activeForm != null && activeForm?.isValid) { - stepsData.current[openAccordionId] = { - ...stepsData.current[openAccordionId], - data: activeForm.data, - isValid: activeForm.isValid, - }; - setOpenAccordionId(id); - setIsStepRuleInEditView({ - ...isStepRuleInReadOnlyView, - [openAccordionId]: true, - [id]: false, - }); - } - }, - [isStepRuleInReadOnlyView, openAccordionId] - ); - - if (isSaved) { - const ruleName = (stepsData.current[RuleStep.aboutRule].data as AboutStepRule).name; + if (isSaved && ruleName) { displaySuccessToast(i18n.SUCCESSFULLY_CREATED_RULES(ruleName), dispatchToaster); history.replace(getRulesUrl()); return null; @@ -320,13 +291,14 @@ const CreateRulePageComponent: React.FC = () => { buttonContent={defineRuleButton} paddingSize="xs" ref={defineRuleRef} - onToggle={manageAccordions.bind(null, RuleStep.defineRule)} + onToggle={handleAccordionToggle.bind(null, RuleStep.defineRule)} extraAction={ stepsData.current[RuleStep.defineRule].isValid && ( editStep(RuleStep.defineRule)} > {i18n.EDIT_RULE} @@ -336,11 +308,11 @@ const CreateRulePageComponent: React.FC = () => { submitStep(RuleStep.defineRule)} descriptionColumns="singleSplit" /> @@ -353,13 +325,14 @@ const CreateRulePageComponent: React.FC = () => { buttonContent={aboutRuleButton} paddingSize="xs" ref={aboutRuleRef} - onToggle={manageAccordions.bind(null, RuleStep.aboutRule)} + onToggle={handleAccordionToggle.bind(null, RuleStep.aboutRule)} extraAction={ stepsData.current[RuleStep.aboutRule].isValid && ( editStep(RuleStep.aboutRule)} > {i18n.EDIT_RULE} @@ -369,13 +342,13 @@ const CreateRulePageComponent: React.FC = () => { submitStep(RuleStep.aboutRule)} /> @@ -387,13 +360,13 @@ const CreateRulePageComponent: React.FC = () => { buttonContent={scheduleRuleButton} paddingSize="xs" ref={scheduleRuleRef} - onToggle={manageAccordions.bind(null, RuleStep.scheduleRule)} + onToggle={handleAccordionToggle.bind(null, RuleStep.scheduleRule)} extraAction={ stepsData.current[RuleStep.scheduleRule].isValid && ( editStep(RuleStep.scheduleRule)} > {i18n.EDIT_RULE} @@ -403,12 +376,12 @@ const CreateRulePageComponent: React.FC = () => { submitStep(RuleStep.scheduleRule)} /> @@ -420,13 +393,13 @@ const CreateRulePageComponent: React.FC = () => { buttonContent={ruleActionsButton} paddingSize="xs" ref={ruleActionsRef} - onToggle={manageAccordions.bind(null, RuleStep.ruleActions)} + onToggle={handleAccordionToggle.bind(null, RuleStep.ruleActions)} extraAction={ stepsData.current[RuleStep.ruleActions].isValid && ( editStep(RuleStep.ruleActions)} > {i18n.EDIT_RULE} @@ -436,10 +409,11 @@ const CreateRulePageComponent: React.FC = () => { submitStep(RuleStep.ruleActions)} actionMessageParams={actionMessageParams} /> diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx index b251b2eba10ae..5f4fd59669103 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx @@ -17,6 +17,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import React, { FC, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useParams, useHistory } from 'react-router-dom'; +import { UpdateRulesSchema } from '../../../../../../common/detection_engine/schemas/request'; import { useRule, useUpdateRule } from '../../../../containers/detection_engine/rules'; import { useListsConfig } from '../../../../containers/detection_engine/lists/use_lists_config'; import { WrapperPage } from '../../../../../common/components/wrapper_page'; @@ -28,13 +29,19 @@ import { displaySuccessToast, useStateToaster } from '../../../../../common/comp import { SpyRoute } from '../../../../../common/utils/route/spy_routes'; import { useUserData } from '../../../../components/user_info'; import { DetectionEngineHeaderPage } from '../../../../components/detection_engine_header_page'; -import { FormHook, FormData } from '../../../../../shared_imports'; import { StepPanel } from '../../../../components/rules/step_panel'; import { StepAboutRule } from '../../../../components/rules/step_about_rule'; import { StepDefineRule } from '../../../../components/rules/step_define_rule'; import { StepScheduleRule } from '../../../../components/rules/step_schedule_rule'; import { StepRuleActions } from '../../../../components/rules/step_rule_actions'; -import { formatRule } from '../create/helpers'; +import { + formatRule, + stepIsValid, + isDefineStep, + isAboutStep, + isScheduleStep, + isActionsStep, +} from '../create/helpers'; import { getStepsData, redirectToDetections, @@ -42,33 +49,12 @@ import { userHasNoPermissions, } from '../helpers'; import * as ruleI18n from '../translations'; -import { - RuleStep, - DefineStepRule, - AboutStepRule, - ScheduleStepRule, - ActionsStepRule, -} from '../types'; +import { RuleStep, RuleStepsFormHooks, RuleStepsFormData, RuleStepsData } from '../types'; import * as i18n from './translations'; import { SecurityPageName } from '../../../../../app/types'; -import { UpdateRulesSchema } from '../../../../../../common/detection_engine/schemas/request'; - -interface StepRuleForm { - isValid: boolean; -} -interface AboutStepRuleForm extends StepRuleForm { - data: AboutStepRule | null; -} -interface DefineStepRuleForm extends StepRuleForm { - data: DefineStepRule | null; -} -interface ScheduleStepRuleForm extends StepRuleForm { - data: ScheduleStepRule | null; -} +import { ruleStepsOrder } from '../utils'; -interface ActionsStepRuleForm extends StepRuleForm { - data: ActionsStepRule | null; -} +const formHookNoop = async (): Promise => undefined; const EditRulePageComponent: FC = () => { const history = useHistory(); @@ -86,49 +72,49 @@ const EditRulePageComponent: FC = () => { loading: listsConfigLoading, needsConfiguration: needsListsConfiguration, } = useListsConfig(); - const initLoading = userInfoLoading || listsConfigLoading; const { detailName: ruleId } = useParams<{ detailName: string | undefined }>(); - const [loading, rule] = useRule(ruleId); + const [ruleLoading, rule] = useRule(ruleId); + const loading = ruleLoading || userInfoLoading || listsConfigLoading; - const [initForm, setInitForm] = useState(false); - const [myAboutRuleForm, setMyAboutRuleForm] = useState({ - data: null, - isValid: false, - }); - const [myDefineRuleForm, setMyDefineRuleForm] = useState({ - data: null, - isValid: false, + const formHooks = useRef({ + [RuleStep.defineRule]: formHookNoop, + [RuleStep.aboutRule]: formHookNoop, + [RuleStep.scheduleRule]: formHookNoop, + [RuleStep.ruleActions]: formHookNoop, }); - const [myScheduleRuleForm, setMyScheduleRuleForm] = useState({ - data: null, - isValid: false, + const stepsData = useRef({ + [RuleStep.defineRule]: { isValid: false, data: undefined }, + [RuleStep.aboutRule]: { isValid: false, data: undefined }, + [RuleStep.scheduleRule]: { isValid: false, data: undefined }, + [RuleStep.ruleActions]: { isValid: false, data: undefined }, }); - const [myActionsRuleForm, setMyActionsRuleForm] = useState({ - data: null, - isValid: false, - }); - const [selectedTab, setSelectedTab] = useState(); - const stepsForm = useRef | null>>({ - [RuleStep.defineRule]: null, - [RuleStep.aboutRule]: null, - [RuleStep.scheduleRule]: null, - [RuleStep.ruleActions]: null, + const defineStep = stepsData.current[RuleStep.defineRule]; + const aboutStep = stepsData.current[RuleStep.aboutRule]; + const scheduleStep = stepsData.current[RuleStep.scheduleRule]; + const actionsStep = stepsData.current[RuleStep.ruleActions]; + const [activeStep, setActiveStep] = useState(RuleStep.defineRule); + const invalidSteps = ruleStepsOrder.filter((step) => { + const stepData = stepsData.current[step]; + return stepData.data != null && !stepIsValid(stepData); }); const [{ isLoading, isSaved }, setRule] = useUpdateRule(); - const [tabHasError, setTabHasError] = useState([]); - // eslint-disable-next-line react-hooks/exhaustive-deps - const actionMessageParams = useMemo(() => getActionMessageParams(rule?.type), [rule]); - const setStepsForm = useCallback( - (step: RuleStep, form: FormHook) => { - stepsForm.current[step] = form; - if (initForm && step === (selectedTab?.id as RuleStep) && form.isSubmitted === false) { - setInitForm(false); - form.submit(); + const actionMessageParams = useMemo(() => getActionMessageParams(rule?.type), [rule?.type]); + const setFormHook = useCallback( + (step: K, hook: RuleStepsFormHooks[K]) => { + formHooks.current[step] = hook; + if (step === activeStep) { + hook(); } }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [initForm, selectedTab] + [activeStep] + ); + const setStepData = useCallback( + (step: K, data: RuleStepsData[K], isValid: boolean) => { + stepsData.current[step] = { ...stepsData.current[step], data, isValid }; + }, + [] ); + const tabs = useMemo( () => [ { @@ -138,14 +124,14 @@ const EditRulePageComponent: FC = () => { content: ( <> - - {myDefineRuleForm.data != null && ( + + {defineStep.data != null && ( )} @@ -160,15 +146,15 @@ const EditRulePageComponent: FC = () => { content: ( <> - - {myAboutRuleForm.data != null && myDefineRuleForm.data != null && ( + + {aboutStep.data != null && defineStep.data != null && ( )} @@ -183,14 +169,14 @@ const EditRulePageComponent: FC = () => { content: ( <> - - {myScheduleRuleForm.data != null && ( + + {scheduleStep.data != null && ( )} @@ -204,14 +190,14 @@ const EditRulePageComponent: FC = () => { content: ( <> - - {myActionsRuleForm.data != null && ( + + {actionsStep.data != null && ( )} @@ -221,76 +207,56 @@ const EditRulePageComponent: FC = () => { ), }, ], - // eslint-disable-next-line react-hooks/exhaustive-deps [ - rule, + rule?.immutable, loading, - initLoading, + defineStep.data, isLoading, - myAboutRuleForm, - myDefineRuleForm, - myScheduleRuleForm, - myActionsRuleForm, - setStepsForm, - stepsForm, + setFormHook, + aboutStep.data, + scheduleStep.data, + actionsStep.data, actionMessageParams, ] ); const onSubmit = useCallback(async () => { - const activeFormId = selectedTab?.id as RuleStep; - const activeForm = await stepsForm.current[activeFormId]?.submit(); - - const invalidForms = [ - RuleStep.aboutRule, - RuleStep.defineRule, - RuleStep.scheduleRule, - RuleStep.ruleActions, - ].reduce((acc, step) => { - if ( - (step === activeFormId && activeForm != null && !activeForm?.isValid) || - (step === RuleStep.aboutRule && !myAboutRuleForm.isValid) || - (step === RuleStep.defineRule && !myDefineRuleForm.isValid) || - (step === RuleStep.scheduleRule && !myScheduleRuleForm.isValid) || - (step === RuleStep.ruleActions && !myActionsRuleForm.isValid) - ) { - return [...acc, step]; - } - return acc; - }, []); + const activeStepData = await formHooks.current[activeStep](); + if (activeStepData?.data != null) { + setStepData(activeStep, activeStepData.data, activeStepData.isValid); + } + const define = isDefineStep(activeStepData) ? activeStepData : defineStep; + const about = isAboutStep(activeStepData) ? activeStepData : aboutStep; + const schedule = isScheduleStep(activeStepData) ? activeStepData : scheduleStep; + const actions = isActionsStep(activeStepData) ? activeStepData : actionsStep; - if (invalidForms.length === 0 && activeForm != null) { - setTabHasError([]); + if ( + stepIsValid(define) && + stepIsValid(about) && + stepIsValid(schedule) && + stepIsValid(actions) + ) { setRule({ ...formatRule( - (activeFormId === RuleStep.defineRule - ? activeForm.data - : myDefineRuleForm.data) as DefineStepRule, - (activeFormId === RuleStep.aboutRule - ? activeForm.data - : myAboutRuleForm.data) as AboutStepRule, - (activeFormId === RuleStep.scheduleRule - ? activeForm.data - : myScheduleRuleForm.data) as ScheduleStepRule, - (activeFormId === RuleStep.ruleActions - ? activeForm.data - : myActionsRuleForm.data) as ActionsStepRule, + define.data, + about.data, + schedule.data, + actions.data, rule ), ...(ruleId ? { id: ruleId } : {}), }); - } else { - setTabHasError(invalidForms); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - stepsForm, - myAboutRuleForm, - myDefineRuleForm, - myScheduleRuleForm, - myActionsRuleForm, - selectedTab, + aboutStep, + actionsStep, + activeStep, + defineStep, + rule, ruleId, + scheduleStep, + setRule, + setStepData, ]); useEffect(() => { @@ -298,48 +264,29 @@ const EditRulePageComponent: FC = () => { const { aboutRuleData, defineRuleData, scheduleRuleData, ruleActionsData } = getStepsData({ rule, }); - setMyAboutRuleForm({ data: aboutRuleData, isValid: true }); - setMyDefineRuleForm({ data: defineRuleData, isValid: true }); - setMyScheduleRuleForm({ data: scheduleRuleData, isValid: true }); - setMyActionsRuleForm({ data: ruleActionsData, isValid: true }); + setStepData(RuleStep.defineRule, defineRuleData, true); + setStepData(RuleStep.aboutRule, aboutRuleData, true); + setStepData(RuleStep.scheduleRule, scheduleRuleData, true); + setStepData(RuleStep.ruleActions, ruleActionsData, true); } - }, [rule]); + }, [rule, setStepData]); + + const goToStep = useCallback(async (step: RuleStep) => { + setActiveStep(step); + await formHooks.current[step](); + }, []); const onTabClick = useCallback( async (tab: EuiTabbedContentTab) => { - if (selectedTab != null) { - const ruleStep = selectedTab.id as RuleStep; - const respForm = await stepsForm.current[ruleStep]?.submit(); + const targetStep = tab.id as RuleStep; + const activeStepData = await formHooks.current[activeStep](); - if (respForm != null) { - if (ruleStep === RuleStep.aboutRule) { - setMyAboutRuleForm({ - data: respForm.data as AboutStepRule, - isValid: respForm.isValid, - }); - } else if (ruleStep === RuleStep.defineRule) { - setMyDefineRuleForm({ - data: respForm.data as DefineStepRule, - isValid: respForm.isValid, - }); - } else if (ruleStep === RuleStep.scheduleRule) { - setMyScheduleRuleForm({ - data: respForm.data as ScheduleStepRule, - isValid: respForm.isValid, - }); - } else if (ruleStep === RuleStep.ruleActions) { - setMyActionsRuleForm({ - data: respForm.data as ActionsStepRule, - isValid: respForm.isValid, - }); - } - } + if (activeStepData?.data != null) { + setStepData(activeStep, activeStepData.data, activeStepData.isValid); + goToStep(targetStep); } - setInitForm(true); - setSelectedTab(tab); }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [selectedTab, stepsForm.current] + [activeStep, goToStep, setStepData] ); const goToDetailsRule = useCallback( @@ -351,23 +298,13 @@ const EditRulePageComponent: FC = () => { ); useEffect(() => { - if (rule != null) { - const { aboutRuleData, defineRuleData, scheduleRuleData, ruleActionsData } = getStepsData({ - rule, - }); - setMyAboutRuleForm({ data: aboutRuleData, isValid: true }); - setMyDefineRuleForm({ data: defineRuleData, isValid: true }); - setMyScheduleRuleForm({ data: scheduleRuleData, isValid: true }); - setMyActionsRuleForm({ data: ruleActionsData, isValid: true }); + if (rule?.immutable) { + setActiveStep(RuleStep.ruleActions); + } else { + setActiveStep(RuleStep.defineRule); } }, [rule]); - useEffect(() => { - const tabIndex = rule?.immutable ? 3 : 0; - setSelectedTab(tabs[tabIndex]); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [rule]); - if (isSaved) { displaySuccessToast(i18n.SUCCESSFULLY_SAVED_RULE(rule?.name ?? ''), dispatchToaster); history.replace(getRuleDetailsUrl(ruleId ?? '')); @@ -401,14 +338,14 @@ const EditRulePageComponent: FC = () => { isLoading={isLoading} title={i18n.PAGE_TITLE} /> - {tabHasError.length > 0 && ( + {invalidSteps.length > 0 && ( { if (t === RuleStep.aboutRule) { return ruleI18n.ABOUT; @@ -429,7 +366,7 @@ const EditRulePageComponent: FC = () => { t.id === selectedTab?.id)} + selectedTab={tabs.find((t) => t.id === activeStep)} onTabClick={onTabClick} tabs={tabs} /> @@ -454,7 +391,7 @@ const EditRulePageComponent: FC = () => { onClick={onSubmit} iconType="save" isLoading={isLoading} - isDisabled={initLoading} + isDisabled={loading} > {i18n.SAVE_CHANGES} diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx index 10a20807d6f87..f11b0ac4ec3f8 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx @@ -43,7 +43,6 @@ describe('rule helpers', () => { rule: mockRuleWithEverything('test-id'), }); const defineRuleStepData = { - isNew: false, ruleType: 'saved_query', anomalyThreshold: 50, index: ['auditbeat-*'], @@ -93,7 +92,6 @@ describe('rule helpers', () => { falsePositives: ['test'], isAssociatedToEndpointList: false, isBuildingBlock: false, - isNew: false, license: 'Elastic License', name: 'Query with rule-id', note: '# this is some markdown documentation', @@ -121,11 +119,10 @@ describe('rule helpers', () => { ], timestampOverride: 'event.ingested', }; - const scheduleRuleStepData = { from: '0s', interval: '5m', isNew: false }; + const scheduleRuleStepData = { from: '0s', interval: '5m' }; const ruleActionsStepData = { enabled: true, throttle: 'no_actions', - isNew: false, actions: [], }; const aboutRuleDataDetailsData = { @@ -202,7 +199,6 @@ describe('rule helpers', () => { test('returns with saved_id if value exists on rule', () => { const result: DefineStepRule = getDefineStepsData(mockRule('test-id')); const expected = { - isNew: false, ruleType: 'saved_query', anomalyThreshold: 50, machineLearningJobId: '', @@ -235,7 +231,6 @@ describe('rule helpers', () => { delete mockedRule.saved_id; const result: DefineStepRule = getDefineStepsData(mockedRule); const expected = { - isNew: false, ruleType: 'saved_query', anomalyThreshold: 50, machineLearningJobId: '', @@ -311,7 +306,6 @@ describe('rule helpers', () => { }; const result: ScheduleStepRule = getScheduleStepsData(mockedRule); const expected = { - isNew: false, interval: mockedRule.interval, from: '0s', }; @@ -344,7 +338,6 @@ describe('rule helpers', () => { }, ], enabled: mockedRule.enabled, - isNew: false, throttle: 'no_actions', }; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx index 8178f5ae5ba1d..aab73c5d5a1e5 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx @@ -27,6 +27,7 @@ import { import { SeverityMapping, Type, + Severity, } from '../../../../../common/detection_engine/schemas/common/schemas'; import { severityOptions } from '../../../components/rules/step_about_rule/data'; @@ -67,7 +68,6 @@ export const getActionsStepsData = ( return { actions: actions?.map(transformRuleToAlertAction), - isNew: false, throttle, kibanaSiemAppUrl: meta?.kibana_siem_app_url, enabled, @@ -75,7 +75,6 @@ export const getActionsStepsData = ( }; export const getDefineStepsData = (rule: Rule): DefineStepRule => ({ - isNew: false, ruleType: rule.type, anomalyThreshold: rule.anomaly_threshold ?? 50, machineLearningJobId: rule.machine_learning_job_id ?? '', @@ -100,7 +99,6 @@ export const getScheduleStepsData = (rule: Rule): ScheduleStepRule => { const fromHumanizedValue = getHumanizedDuration(from, interval); return { - isNew: false, interval, from: fromHumanizedValue, }; @@ -142,7 +140,6 @@ export const getAboutStepsData = (rule: Rule, detailsView: boolean): AboutStepRu } = rule; return { - isNew: false, author, isAssociatedToEndpointList: exceptionsList?.some(({ id }) => id === ENDPOINT_LIST_ID) ?? false, isBuildingBlock: buildingBlockType !== undefined, @@ -154,7 +151,7 @@ export const getAboutStepsData = (rule: Rule, detailsView: boolean): AboutStepRu note: note!, references, severity: { - value: severity, + value: severity as Severity, mapping: fillEmptySeverityMappings(severityMapping), isMappingChecked: severityMapping.length > 0, }, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts index a7603051add49..e3d0ea123872a 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts @@ -7,7 +7,6 @@ import { RuleAlertAction } from '../../../../../common/detection_engine/types'; import { AlertAction } from '../../../../../../alerts/common'; import { Filter } from '../../../../../../../../src/plugins/data/common'; -import { FormData, FormHook } from '../../../../shared_imports'; import { FieldValueQueryBar } from '../../../components/rules/query_bar'; import { FieldValueTimeline } from '../../../components/rules/pick_timeline'; import { FieldValueThreshold } from '../../../components/rules/threshold_input'; @@ -21,6 +20,7 @@ import { SortOrder, TimestampOverride, Type, + Severity, } from '../../../../../common/detection_engine/schemas/common/schemas'; import { List } from '../../../../../common/detection_engine/schemas/types'; @@ -37,34 +37,51 @@ export interface EuiBasicTableOnChange { sort?: EuiBasicTableSortTypes; } +export type RuleStatusType = 'passive' | 'active' | 'valid'; + export enum RuleStep { defineRule = 'define-rule', aboutRule = 'about-rule', scheduleRule = 'schedule-rule', ruleActions = 'rule-actions', } -export type RuleStatusType = 'passive' | 'active' | 'valid'; +export type RuleStepsOrder = [ + RuleStep.defineRule, + RuleStep.aboutRule, + RuleStep.scheduleRule, + RuleStep.ruleActions +]; -export interface RuleStepData { - data: unknown; - isValid: boolean; +export interface RuleStepsData { + [RuleStep.defineRule]: DefineStepRule; + [RuleStep.aboutRule]: AboutStepRule; + [RuleStep.scheduleRule]: ScheduleStepRule; + [RuleStep.ruleActions]: ActionsStepRule; } +export type RuleStepsFormData = { + [K in keyof RuleStepsData]: { + data: RuleStepsData[K] | undefined; + isValid: boolean; + }; +}; + +export type RuleStepsFormHooks = { + [K in keyof RuleStepsData]: () => Promise; +}; + export interface RuleStepProps { addPadding?: boolean; descriptionColumns?: 'multi' | 'single' | 'singleSplit'; - setStepData?: (step: RuleStep, data: unknown, isValid: boolean) => void; isReadOnlyView: boolean; isUpdateView?: boolean; isLoading: boolean; + onSubmit?: () => void; resizeParentContainer?: (height: number) => void; - setForm?: (step: RuleStep, form: FormHook) => void; + setForm?: (step: K, hook: RuleStepsFormHooks[K]) => void; } -interface StepRuleData { - isNew: boolean; -} -export interface AboutStepRule extends StepRuleData { +export interface AboutStepRule { author: string[]; name: string; description: string; @@ -88,7 +105,7 @@ export interface AboutStepRuleDetails { } export interface AboutStepSeverity { - value: string; + value: Severity; mapping: SeverityMapping; isMappingChecked: boolean; } @@ -99,7 +116,7 @@ export interface AboutStepRiskScore { isMappingChecked: boolean; } -export interface DefineStepRule extends StepRuleData { +export interface DefineStepRule { anomalyThreshold: number; index: string[]; machineLearningJobId: string; @@ -109,13 +126,13 @@ export interface DefineStepRule extends StepRuleData { threshold: FieldValueThreshold; } -export interface ScheduleStepRule extends StepRuleData { +export interface ScheduleStepRule { interval: string; from: string; to?: string; } -export interface ActionsStepRule extends StepRuleData { +export interface ActionsStepRule { actions: AlertAction[]; enabled: boolean; kibanaSiemAppUrl?: string; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.ts index f862a06807e6f..890746838b0d0 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.ts @@ -20,6 +20,14 @@ import { RouteSpyState } from '../../../../common/utils/route/types'; import { GetUrlForApp } from '../../../../common/components/navigation/types'; import { SecurityPageName } from '../../../../app/types'; import { APP_ID } from '../../../../../common/constants'; +import { RuleStep, RuleStepsOrder } from './types'; + +export const ruleStepsOrder: RuleStepsOrder = [ + RuleStep.defineRule, + RuleStep.aboutRule, + RuleStep.scheduleRule, + RuleStep.ruleActions, +]; const getTabBreadcrumb = (pathname: string, search: string[], getUrlForApp: GetUrlForApp) => { const tabPath = pathname.split('/')[1]; diff --git a/x-pack/plugins/security_solution/public/shared_imports.ts b/x-pack/plugins/security_solution/public/shared_imports.ts index 097166a9c866a..08e9fb854e5a2 100644 --- a/x-pack/plugins/security_solution/public/shared_imports.ts +++ b/x-pack/plugins/security_solution/public/shared_imports.ts @@ -21,6 +21,7 @@ export { UseMultiFields, useForm, useFormContext, + useFormData, ValidationFunc, VALIDATION_TYPES, } from '../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; From 19bf9ed072ca15157e11671745e3783efe27d925 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Sun, 6 Sep 2020 19:43:54 +0200 Subject: [PATCH 17/17] Remove legacy deprecation adapter (#76753) --- src/core/server/legacy/config/index.ts | 1 - .../legacy_deprecation_adapters.test.ts | 106 ------------------ .../config/legacy_deprecation_adapters.ts | 57 ---------- src/core/server/legacy/legacy_service.test.ts | 44 +------- src/core/server/legacy/legacy_service.ts | 15 +-- .../log_legacy_plugins_warning.test.ts | 1 - src/core/server/legacy/types.ts | 31 ----- src/core/server/server.api.md | 10 +- 8 files changed, 8 insertions(+), 257 deletions(-) delete mode 100644 src/core/server/legacy/config/legacy_deprecation_adapters.test.ts delete mode 100644 src/core/server/legacy/config/legacy_deprecation_adapters.ts diff --git a/src/core/server/legacy/config/index.ts b/src/core/server/legacy/config/index.ts index f10e3f22d53c5..b56b83ca324cb 100644 --- a/src/core/server/legacy/config/index.ts +++ b/src/core/server/legacy/config/index.ts @@ -19,4 +19,3 @@ export { ensureValidConfiguration } from './ensure_valid_configuration'; export { LegacyObjectToConfigAdapter } from './legacy_object_to_config_adapter'; -export { convertLegacyDeprecationProvider } from './legacy_deprecation_adapters'; diff --git a/src/core/server/legacy/config/legacy_deprecation_adapters.test.ts b/src/core/server/legacy/config/legacy_deprecation_adapters.test.ts deleted file mode 100644 index b09f9d00b3bed..0000000000000 --- a/src/core/server/legacy/config/legacy_deprecation_adapters.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -/* - * 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 { ConfigDeprecation } from '../../config'; -import { configDeprecationFactory } from '../../config/deprecation/deprecation_factory'; -import { applyDeprecations } from '../../config/deprecation/apply_deprecations'; -import { LegacyConfigDeprecationProvider } from '../types'; -import { convertLegacyDeprecationProvider } from './legacy_deprecation_adapters'; - -jest.spyOn(configDeprecationFactory, 'unusedFromRoot'); -jest.spyOn(configDeprecationFactory, 'renameFromRoot'); - -const executeHandlers = (handlers: ConfigDeprecation[]) => { - handlers.forEach((handler) => { - handler({}, '', () => null); - }); -}; - -describe('convertLegacyDeprecationProvider', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('returns the same number of handlers', async () => { - const legacyProvider: LegacyConfigDeprecationProvider = ({ rename, unused }) => [ - rename('a', 'b'), - unused('c'), - unused('d'), - ]; - - const migrated = await convertLegacyDeprecationProvider(legacyProvider); - const handlers = migrated(configDeprecationFactory); - expect(handlers).toHaveLength(3); - }); - - it('invokes the factory "unusedFromRoot" when using legacy "unused"', async () => { - const legacyProvider: LegacyConfigDeprecationProvider = ({ rename, unused }) => [ - rename('a', 'b'), - unused('c'), - unused('d'), - ]; - - const migrated = await convertLegacyDeprecationProvider(legacyProvider); - const handlers = migrated(configDeprecationFactory); - executeHandlers(handlers); - - expect(configDeprecationFactory.unusedFromRoot).toHaveBeenCalledTimes(2); - expect(configDeprecationFactory.unusedFromRoot).toHaveBeenCalledWith('c'); - expect(configDeprecationFactory.unusedFromRoot).toHaveBeenCalledWith('d'); - }); - - it('invokes the factory "renameFromRoot" when using legacy "rename"', async () => { - const legacyProvider: LegacyConfigDeprecationProvider = ({ rename, unused }) => [ - rename('a', 'b'), - unused('c'), - rename('d', 'e'), - ]; - - const migrated = await convertLegacyDeprecationProvider(legacyProvider); - const handlers = migrated(configDeprecationFactory); - executeHandlers(handlers); - - expect(configDeprecationFactory.renameFromRoot).toHaveBeenCalledTimes(2); - expect(configDeprecationFactory.renameFromRoot).toHaveBeenCalledWith('a', 'b'); - expect(configDeprecationFactory.renameFromRoot).toHaveBeenCalledWith('d', 'e'); - }); - - it('properly works in a real use case', async () => { - const legacyProvider: LegacyConfigDeprecationProvider = ({ rename, unused }) => [ - rename('old', 'new'), - unused('unused'), - unused('notpresent'), - ]; - - const convertedProvider = await convertLegacyDeprecationProvider(legacyProvider); - const handlers = convertedProvider(configDeprecationFactory); - - const rawConfig = { - old: 'oldvalue', - unused: 'unused', - goodValue: 'good', - }; - - const migrated = applyDeprecations( - rawConfig, - handlers.map((handler) => ({ deprecation: handler, path: '' })) - ); - expect(migrated).toEqual({ new: 'oldvalue', goodValue: 'good' }); - }); -}); diff --git a/src/core/server/legacy/config/legacy_deprecation_adapters.ts b/src/core/server/legacy/config/legacy_deprecation_adapters.ts deleted file mode 100644 index 1e0733969e662..0000000000000 --- a/src/core/server/legacy/config/legacy_deprecation_adapters.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* - * 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 { ConfigDeprecation, ConfigDeprecationProvider } from '../../config/deprecation'; -import { configDeprecationFactory } from '../../config/deprecation/deprecation_factory'; -import { LegacyConfigDeprecation, LegacyConfigDeprecationProvider } from '../types'; - -const convertLegacyDeprecation = ( - legacyDeprecation: LegacyConfigDeprecation -): ConfigDeprecation => (config, fromPath, logger) => { - legacyDeprecation(config, logger); - return config; -}; - -const legacyUnused = (unusedKey: string): LegacyConfigDeprecation => (settings, log) => { - const deprecation = configDeprecationFactory.unusedFromRoot(unusedKey); - deprecation(settings, '', log); -}; - -const legacyRename = (oldKey: string, newKey: string): LegacyConfigDeprecation => ( - settings, - log -) => { - const deprecation = configDeprecationFactory.renameFromRoot(oldKey, newKey); - deprecation(settings, '', log); -}; - -/** - * Async deprecation provider converter for legacy deprecation implementation - * - * @internal - */ -export const convertLegacyDeprecationProvider = async ( - legacyProvider: LegacyConfigDeprecationProvider -): Promise => { - const legacyDeprecations = await legacyProvider({ - rename: legacyRename, - unused: legacyUnused, - }); - return () => legacyDeprecations.map(convertLegacyDeprecation); -}; diff --git a/src/core/server/legacy/legacy_service.test.ts b/src/core/server/legacy/legacy_service.test.ts index 45869fd12d2b4..d0492ea88c5e8 100644 --- a/src/core/server/legacy/legacy_service.test.ts +++ b/src/core/server/legacy/legacy_service.test.ts @@ -19,9 +19,7 @@ jest.mock('../../../legacy/server/kbn_server'); jest.mock('../../../cli/cluster/cluster_manager'); -jest.mock('./config/legacy_deprecation_adapters', () => ({ - convertLegacyDeprecationProvider: (provider: any) => Promise.resolve(provider), -})); + import { findLegacyPluginSpecsMock, logLegacyThirdPartyPluginDeprecationWarningMock, @@ -446,46 +444,8 @@ describe('#discoverPlugins()', () => { expect(findLegacyPluginSpecs).toHaveBeenCalledWith(expect.any(Object), logger, env.packageInfo); }); - it(`register legacy plugin's deprecation providers`, async () => { - findLegacyPluginSpecsMock.mockImplementation( - (settings) => - Promise.resolve({ - pluginSpecs: [ - { - getDeprecationsProvider: () => undefined, - }, - { - getDeprecationsProvider: () => 'providerA', - }, - { - getDeprecationsProvider: () => 'providerB', - }, - ], - pluginExtendedConfig: settings, - disabledPluginSpecs: [], - uiExports: {}, - navLinks: [], - }) as any - ); - - const legacyService = new LegacyService({ - coreId, - env, - logger, - configService: configService as any, - }); - - await legacyService.discoverPlugins(); - expect(configService.addDeprecationProvider).toHaveBeenCalledTimes(2); - expect(configService.addDeprecationProvider).toHaveBeenCalledWith('', 'providerA'); - expect(configService.addDeprecationProvider).toHaveBeenCalledWith('', 'providerB'); - }); - it(`logs deprecations for legacy third party plugins`, async () => { - const pluginSpecs = [ - { getId: () => 'pluginA', getDeprecationsProvider: () => undefined }, - { getId: () => 'pluginB', getDeprecationsProvider: () => undefined }, - ]; + const pluginSpecs = [{ getId: () => 'pluginA' }, { getId: () => 'pluginB' }]; findLegacyPluginSpecsMock.mockImplementation( (settings) => Promise.resolve({ diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index adfdecdd7c976..880011d2e1923 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -21,7 +21,7 @@ import { combineLatest, ConnectableObservable, EMPTY, Observable, Subscription } import { first, map, publishReplay, tap } from 'rxjs/operators'; import { CoreService } from '../../types'; -import { Config, ConfigDeprecationProvider } from '../config'; +import { Config } from '../config'; import { CoreContext } from '../core_context'; import { CspConfigType, config as cspConfig } from '../csp'; import { DevConfig, DevConfigType, config as devConfig } from '../dev'; @@ -29,7 +29,6 @@ import { BasePathProxyServer, HttpConfig, HttpConfigType, config as httpConfig } import { Logger } from '../logging'; import { PathConfigType } from '../path'; import { findLegacyPluginSpecs, logLegacyThirdPartyPluginDeprecationWarning } from './plugins'; -import { convertLegacyDeprecationProvider } from './config'; import { ILegacyInternals, LegacyServiceSetupDeps, @@ -145,18 +144,6 @@ export class LegacyService implements CoreService { navLinks, }; - const deprecationProviders = await pluginSpecs - .map((spec) => spec.getDeprecationsProvider()) - .reduce(async (providers, current) => { - if (current) { - return [...(await providers), await convertLegacyDeprecationProvider(current)]; - } - return providers; - }, Promise.resolve([] as ConfigDeprecationProvider[])); - deprecationProviders.forEach((provider) => - this.coreContext.configService.addDeprecationProvider('', provider) - ); - this.legacyRawConfig = pluginExtendedConfig; // check for unknown uiExport types diff --git a/src/core/server/legacy/plugins/log_legacy_plugins_warning.test.ts b/src/core/server/legacy/plugins/log_legacy_plugins_warning.test.ts index dfa2396d5904b..2317f1036ce42 100644 --- a/src/core/server/legacy/plugins/log_legacy_plugins_warning.test.ts +++ b/src/core/server/legacy/plugins/log_legacy_plugins_warning.test.ts @@ -26,7 +26,6 @@ const createPluginSpec = ({ id, path }: { id: string; path: string }): LegacyPlu getId: () => id, getExpectedKibanaVersion: () => 'kibana', getConfigPrefix: () => 'plugin.config', - getDeprecationsProvider: () => undefined, getPack: () => ({ getPath: () => path, }), diff --git a/src/core/server/legacy/types.ts b/src/core/server/legacy/types.ts index 98f8d874c7088..cf08689a6d0d4 100644 --- a/src/core/server/legacy/types.ts +++ b/src/core/server/legacy/types.ts @@ -51,36 +51,6 @@ export interface LegacyConfig { set(config: LegacyVars): void; } -/** - * Representation of a legacy configuration deprecation factory used for - * legacy plugin deprecations. - * - * @internal - * @deprecated - */ -export interface LegacyConfigDeprecationFactory { - rename(oldKey: string, newKey: string): LegacyConfigDeprecation; - unused(unusedKey: string): LegacyConfigDeprecation; -} - -/** - * Representation of a legacy configuration deprecation. - * - * @internal - * @deprecated - */ -export type LegacyConfigDeprecation = (settings: LegacyVars, log: (msg: string) => void) => void; - -/** - * Representation of a legacy configuration deprecation provider. - * - * @internal - * @deprecated - */ -export type LegacyConfigDeprecationProvider = ( - factory: LegacyConfigDeprecationFactory -) => LegacyConfigDeprecation[] | Promise; - /** * @internal * @deprecated @@ -97,7 +67,6 @@ export interface LegacyPluginSpec { getId: () => unknown; getExpectedKibanaVersion: () => string; getConfigPrefix: () => string; - getDeprecationsProvider: () => LegacyConfigDeprecationProvider | undefined; getPack: () => LegacyPluginPack; } diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 1123433e30ac5..3270e5a09afde 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -2949,11 +2949,11 @@ export const validBodyOutput: readonly ["data", "stream"]; // Warnings were encountered during analysis: // // src/core/server/http/router/response.ts:316:3 - (ae-forgotten-export) The symbol "KibanaResponse" needs to be exported by the entry point index.d.ts -// src/core/server/legacy/types.ts:163:3 - (ae-forgotten-export) The symbol "VarsProvider" needs to be exported by the entry point index.d.ts -// src/core/server/legacy/types.ts:164:3 - (ae-forgotten-export) The symbol "VarsReplacer" needs to be exported by the entry point index.d.ts -// src/core/server/legacy/types.ts:165:3 - (ae-forgotten-export) The symbol "LegacyNavLinkSpec" needs to be exported by the entry point index.d.ts -// src/core/server/legacy/types.ts:166:3 - (ae-forgotten-export) The symbol "LegacyAppSpec" needs to be exported by the entry point index.d.ts -// src/core/server/legacy/types.ts:167:16 - (ae-forgotten-export) The symbol "LegacyPluginSpec" needs to be exported by the entry point index.d.ts +// src/core/server/legacy/types.ts:132:3 - (ae-forgotten-export) The symbol "VarsProvider" needs to be exported by the entry point index.d.ts +// src/core/server/legacy/types.ts:133:3 - (ae-forgotten-export) The symbol "VarsReplacer" needs to be exported by the entry point index.d.ts +// src/core/server/legacy/types.ts:134:3 - (ae-forgotten-export) The symbol "LegacyNavLinkSpec" needs to be exported by the entry point index.d.ts +// src/core/server/legacy/types.ts:135:3 - (ae-forgotten-export) The symbol "LegacyAppSpec" needs to be exported by the entry point index.d.ts +// src/core/server/legacy/types.ts:136:16 - (ae-forgotten-export) The symbol "LegacyPluginSpec" needs to be exported by the entry point index.d.ts // src/core/server/plugins/types.ts:266:3 - (ae-forgotten-export) The symbol "KibanaConfigType" needs to be exported by the entry point index.d.ts // src/core/server/plugins/types.ts:266:3 - (ae-forgotten-export) The symbol "SharedGlobalConfigKeys" needs to be exported by the entry point index.d.ts // src/core/server/plugins/types.ts:268:3 - (ae-forgotten-export) The symbol "PathConfigType" needs to be exported by the entry point index.d.ts