From b7660c2c17f441df5ec235685da213cf35a0ec4b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 14 Mar 2022 13:30:07 +0100 Subject: [PATCH 01/44] Update dependency @types/node-forge to ^1.0.1 (#127473) Co-authored-by: Renovate Bot Co-authored-by: Larry Gregory Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 1859756cfe0a7..9c1a9df9f4446 100644 --- a/package.json +++ b/package.json @@ -650,7 +650,7 @@ "@types/nock": "^10.0.3", "@types/node": "16.10.2", "@types/node-fetch": "^2.6.0", - "@types/node-forge": "^1.0.0", + "@types/node-forge": "^1.0.1", "@types/nodemailer": "^6.4.0", "@types/normalize-path": "^3.0.0", "@types/object-hash": "^1.3.0", diff --git a/yarn.lock b/yarn.lock index f74eab57c56a8..1b2c7cdcf2f4d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6324,10 +6324,10 @@ "@types/node" "*" form-data "^2.3.3" -"@types/node-forge@^1.0.0": - version "1.0.0" - resolved "https://registry.yarnpkg.com/@types/node-forge/-/node-forge-1.0.0.tgz#0b4e9507209485945115a4db4879f39632230593" - integrity sha512-h0bgwPKq5u99T9Gor4qtV1lCZ41xNkai0pie1n/a2mh2/4+jENWOlo7AJ4YKxTZAnSZ8FRurUpdIN7ohaPPuHA== +"@types/node-forge@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@types/node-forge/-/node-forge-1.0.1.tgz#0df103639da9d5ec6a708d462020f0df70679f37" + integrity sha512-96ELNKv9tQJ19afdBUiM5iDw7OYEc53iUc51gAPR2aGaqRsO1DBROjqgZRjZa1tkPj7TnEOR0EnyAX6iryGkzA== dependencies: "@types/node" "*" From 8c43615f8eb9667d9dc89e4da9741a893ac41225 Mon Sep 17 00:00:00 2001 From: Tomasz Ciecierski Date: Mon, 14 Mar 2022 13:37:01 +0100 Subject: [PATCH 02/44] [Osquery]: Fix multiple bugs (#126072) --- .../cypress/integration/superuser/packs.spec.ts | 17 +++++++++++++++++ .../packs/queries/ecs_mapping_editor_field.tsx | 4 ++-- .../public/packs/queries/query_flyout.tsx | 11 ++++++++--- .../saved_queries/form/playground_flyout.tsx | 6 +++--- .../server/routes/action/create_action_route.ts | 7 +++++-- 5 files changed, 35 insertions(+), 10 deletions(-) diff --git a/x-pack/plugins/osquery/cypress/integration/superuser/packs.spec.ts b/x-pack/plugins/osquery/cypress/integration/superuser/packs.spec.ts index a674eb4d96829..4c72a871b5b58 100644 --- a/x-pack/plugins/osquery/cypress/integration/superuser/packs.spec.ts +++ b/x-pack/plugins/osquery/cypress/integration/superuser/packs.spec.ts @@ -77,13 +77,30 @@ describe('SuperUser - Packs', () => { findAndClickButton('Add query'); cy.contains('Attach next query'); inputQuery('select * from uptime'); + findFormFieldByRowsLabelAndType('ID', SAVED_QUERY_ID); + cy.contains('ID must be unique').should('exist'); findFormFieldByRowsLabelAndType('ID', NEW_QUERY_NAME); + cy.contains('ID must be unique').should('not.exist'); cy.react('EuiFlyoutFooter').react('EuiButton').contains('Save').click(); cy.react('EuiTableRow').contains(NEW_QUERY_NAME); findAndClickButton('Update pack'); cy.contains('Save and deploy changes'); findAndClickButton('Save and deploy changes'); }); + + it('should trigger validation when saved query is being chosen', () => { + preparePack(PACK_NAME, SAVED_QUERY_ID); + findAndClickButton('Edit'); + findAndClickButton('Add query'); + cy.contains('Attach next query'); + cy.contains('ID must be unique').should('not.exist'); + cy.react('EuiComboBox', { props: { placeholder: 'Search for saved queries' } }) + .click() + .type(SAVED_QUERY_ID); + cy.react('List').first().click(); + cy.contains('ID must be unique').should('exist'); + cy.react('EuiFlyoutFooter').react('EuiButtonEmpty').contains('Cancel').click(); + }); // THIS TESTS TAKES TOO LONG FOR NOW - LET ME THINK IT THROUGH it.skip('to click the icon and visit discover', () => { preparePack(PACK_NAME, SAVED_QUERY_ID); diff --git a/x-pack/plugins/osquery/public/packs/queries/ecs_mapping_editor_field.tsx b/x-pack/plugins/osquery/public/packs/queries/ecs_mapping_editor_field.tsx index bb63d733f36c8..c0f3a33e8d42d 100644 --- a/x-pack/plugins/osquery/public/packs/queries/ecs_mapping_editor_field.tsx +++ b/x-pack/plugins/osquery/public/packs/queries/ecs_mapping_editor_field.tsx @@ -590,7 +590,7 @@ export const ECSMappingEditorForm = forwardRef ({ key: { type: FIELD_TYPES.COMBO_BOX, - fieldsToValidateOnChange: ['result.value'], + fieldsToValidateOnChange: ['result.value', 'key'], validations: [ { validator: getEcsFieldValidator(editForm), @@ -638,7 +638,7 @@ export const ECSMappingEditorForm = forwardRef { validate(); - validateFields(['result.value']); + validateFields(['result.value', 'key']); const { data, isValid } = await submit(); if (isValid) { diff --git a/x-pack/plugins/osquery/public/packs/queries/query_flyout.tsx b/x-pack/plugins/osquery/public/packs/queries/query_flyout.tsx index c5000c1044588..8ddd2d14bf145 100644 --- a/x-pack/plugins/osquery/public/packs/queries/query_flyout.tsx +++ b/x-pack/plugins/osquery/public/packs/queries/query_flyout.tsx @@ -53,9 +53,12 @@ const QueryFlyoutComponent: React.FC = ({ defaultValue, handleSubmit: async (payload, isValid) => { const ecsFieldValue = await ecsFieldRef?.current?.validate(); + const isEcsFieldValueValid = + ecsFieldValue && + Object.values(ecsFieldValue).every((field) => !isEmpty(Object.values(field)[0])); return new Promise((resolve) => { - if (isValid && ecsFieldValue) { + if (isValid && isEcsFieldValueValid) { onSave({ ...payload, ...(isEmpty(ecsFieldValue) ? {} : { ecs_mapping: ecsFieldValue }), @@ -67,7 +70,7 @@ const QueryFlyoutComponent: React.FC = ({ }, }); - const { submit, setFieldValue, reset, isSubmitting } = form; + const { submit, setFieldValue, reset, isSubmitting, validate } = form; const [{ query }] = useFormData({ form, @@ -102,8 +105,10 @@ const QueryFlyoutComponent: React.FC = ({ setFieldValue('ecs_mapping', savedQuery.ecs_mapping); } } + + validate(); }, - [setFieldValue, reset] + [reset, validate, setFieldValue] ); /* Avoids accidental closing of the flyout when the user clicks outside of the flyout */ const maskProps = useMemo(() => ({ onClick: () => ({}) }), []); diff --git a/x-pack/plugins/osquery/public/saved_queries/form/playground_flyout.tsx b/x-pack/plugins/osquery/public/saved_queries/form/playground_flyout.tsx index f0b9fc1e935cb..60f1dff400867 100644 --- a/x-pack/plugins/osquery/public/saved_queries/form/playground_flyout.tsx +++ b/x-pack/plugins/osquery/public/saved_queries/form/playground_flyout.tsx @@ -27,7 +27,7 @@ interface PlaygroundFlyoutProps { const PlaygroundFlyoutComponent: React.FC = ({ enabled, onClose }) => { // eslint-disable-next-line @typescript-eslint/naming-convention - const [{ query, ecs_mapping, savedQueryId }] = useFormData({ + const [{ query, ecs_mapping, id }] = useFormData({ watch: ['query', 'ecs_mapping', 'savedQueryId'], }); @@ -45,11 +45,11 @@ const PlaygroundFlyoutComponent: React.FC = ({ enabled, o diff --git a/x-pack/plugins/osquery/server/routes/action/create_action_route.ts b/x-pack/plugins/osquery/server/routes/action/create_action_route.ts index a4672e46dcce1..37c08d712e3f6 100644 --- a/x-pack/plugins/osquery/server/routes/action/create_action_route.ts +++ b/x-pack/plugins/osquery/server/routes/action/create_action_route.ts @@ -39,13 +39,16 @@ export const createActionRoute = (router: IRouter, osqueryContext: OsqueryAppCon }, async (context, request, response) => { const esClient = context.core.elasticsearch.client.asInternalUser; - const soClient = context.core.savedObjects.client; const internalSavedObjectsClient = await getInternalSavedObjectsClient( osqueryContext.getStartServices ); const { agentSelection } = request.body as { agentSelection: AgentSelection }; - const selectedAgents = await parseAgentSelection(soClient, osqueryContext, agentSelection); + const selectedAgents = await parseAgentSelection( + internalSavedObjectsClient, + osqueryContext, + agentSelection + ); incrementCount(internalSavedObjectsClient, 'live_query'); if (!selectedAgents.length) { incrementCount(internalSavedObjectsClient, 'live_query', 'errors'); From 2020f49dac3b2ccaa09abf90b9f5b52e4c0a3ac7 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Mon, 14 Mar 2022 13:48:35 +0100 Subject: [PATCH 03/44] [ML] Anomaly Explorer performance enhancements (#126274) --- x-pack/plugins/ml/common/types/annotations.ts | 1 + x-pack/plugins/ml/common/types/locator.ts | 5 + .../checkbox_showcharts.tsx | 23 +- .../select_interval/select_interval.tsx | 6 +- .../select_severity/select_severity.tsx | 3 +- .../influencers_list/influencers_list.tsx | 2 +- .../components/job_selector/job_selector.tsx | 2 +- .../explorer/actions/load_explorer_data.ts | 114 +-- .../explorer/anomaly_explorer_common_state.ts | 128 ++++ .../explorer/anomaly_explorer_context.tsx | 78 ++ .../application/explorer/anomaly_timeline.tsx | 210 ++--- .../anomaly_timeline_state_service.ts | 717 ++++++++++++++++++ .../{index.js => index.ts} | 0 ...found.js => explorer_no_results_found.tsx} | 19 +- .../{index.js => index.ts} | 0 .../explorer_query_bar/explorer_query_bar.tsx | 28 +- .../components/{index.js => index.ts} | 0 .../public/application/explorer/explorer.d.ts | 19 - .../public/application/explorer/explorer.js | 520 ------------- .../public/application/explorer/explorer.tsx | 547 +++++++++++++ .../explorer/explorer_constants.ts | 14 +- .../explorer/explorer_dashboard_service.ts | 104 +-- .../application/explorer/explorer_utils.d.ts | 208 ----- .../{explorer_utils.js => explorer_utils.ts} | 377 ++++----- .../explorer/has_matching_points.ts | 10 +- .../explorer/hooks/use_explorer_url_state.ts | 4 +- .../explorer/hooks/use_selected_cells.test.ts | 168 ---- .../explorer/hooks/use_selected_cells.ts | 126 --- .../explorer_reducer/check_selected_cells.ts | 59 -- .../clear_influencer_filter_settings.ts | 4 - .../explorer_reducer/job_selection_change.ts | 13 +- .../reducers/explorer_reducer/reducer.ts | 130 +--- .../set_influencer_filter_settings.ts | 63 -- .../reducers/explorer_reducer/state.ts | 61 +- .../application/routing/routes/explorer.tsx | 222 +++--- .../anomaly_explorer_charts_service.ts | 1 + .../services/anomaly_timeline_service.ts | 4 +- .../application/services/job_service.d.ts | 2 + .../services/ml_api_service/results.ts | 3 +- .../results_service/results_service.d.ts | 2 +- .../services/timefilter_refresh_service.tsx | 2 +- .../ml/public/application/util/url_state.tsx | 110 ++- .../ml/anomaly_detection/anomaly_explorer.ts | 2 +- .../services/ml/anomaly_explorer.ts | 10 +- 44 files changed, 2096 insertions(+), 2025 deletions(-) create mode 100644 x-pack/plugins/ml/public/application/explorer/anomaly_explorer_common_state.ts create mode 100644 x-pack/plugins/ml/public/application/explorer/anomaly_explorer_context.tsx create mode 100644 x-pack/plugins/ml/public/application/explorer/anomaly_timeline_state_service.ts rename x-pack/plugins/ml/public/application/explorer/components/explorer_no_influencers_found/{index.js => index.ts} (100%) rename x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/{explorer_no_results_found.js => explorer_no_results_found.tsx} (87%) rename x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/{index.js => index.ts} (100%) rename x-pack/plugins/ml/public/application/explorer/components/{index.js => index.ts} (100%) delete mode 100644 x-pack/plugins/ml/public/application/explorer/explorer.d.ts delete mode 100644 x-pack/plugins/ml/public/application/explorer/explorer.js create mode 100644 x-pack/plugins/ml/public/application/explorer/explorer.tsx delete mode 100644 x-pack/plugins/ml/public/application/explorer/explorer_utils.d.ts rename x-pack/plugins/ml/public/application/explorer/{explorer_utils.js => explorer_utils.ts} (65%) delete mode 100644 x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.test.ts delete mode 100644 x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/check_selected_cells.ts delete mode 100644 x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/set_influencer_filter_settings.ts diff --git a/x-pack/plugins/ml/common/types/annotations.ts b/x-pack/plugins/ml/common/types/annotations.ts index 57b7551c2308a..50d7c2d3fcd2b 100644 --- a/x-pack/plugins/ml/common/types/annotations.ts +++ b/x-pack/plugins/ml/common/types/annotations.ts @@ -128,4 +128,5 @@ export interface GetAnnotationsResponse { export interface AnnotationsTable { annotationsData: Annotations; error?: string; + totalCount?: number; } diff --git a/x-pack/plugins/ml/common/types/locator.ts b/x-pack/plugins/ml/common/types/locator.ts index e13dbf7c5b271..c6a825fc6e9d2 100644 --- a/x-pack/plugins/ml/common/types/locator.ts +++ b/x-pack/plugins/ml/common/types/locator.ts @@ -72,6 +72,11 @@ export type AnomalyDetectionUrlState = MLPageState< typeof ML_PAGES.ANOMALY_DETECTION_JOBS_MANAGE, AnomalyDetectionQueryState | undefined >; + +export type AnomalyExplorerSwimLaneUrlState = ExplorerAppState['mlExplorerSwimlane']; + +export type AnomalyExplorerFilterUrlState = ExplorerAppState['mlExplorerFilter']; + export interface ExplorerAppState { mlExplorerSwimlane: { selectedType?: 'overall' | 'viewBy'; diff --git a/x-pack/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.tsx b/x-pack/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.tsx index e24242c3ca7f4..aec95f51b52c3 100644 --- a/x-pack/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.tsx +++ b/x-pack/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.tsx @@ -8,23 +8,24 @@ import React, { FC, useCallback, useMemo } from 'react'; import { EuiCheckbox, htmlIdGenerator } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; - -export interface CheckboxShowChartsProps { - showCharts: boolean; - setShowCharts: (update: boolean) => void; -} +import useObservable from 'react-use/lib/useObservable'; +import { useAnomalyExplorerContext } from '../../../explorer/anomaly_explorer_context'; /* * React component for a checkbox element to toggle charts display. */ -export const CheckboxShowCharts: FC = ({ showCharts, setShowCharts }) => { - const onChange = useCallback( - (e: React.ChangeEvent) => { - setShowCharts(e.target.checked); - }, - [setShowCharts] +export const CheckboxShowCharts: FC = () => { + const { anomalyExplorerCommonStateService } = useAnomalyExplorerContext(); + + const showCharts = useObservable( + anomalyExplorerCommonStateService.getShowCharts$(), + anomalyExplorerCommonStateService.getShowCharts() ); + const onChange = useCallback((e: React.ChangeEvent) => { + anomalyExplorerCommonStateService.setShowCharts(e.target.checked); + }, []); + const id = useMemo(() => htmlIdGenerator()(), []); return ( diff --git a/x-pack/plugins/ml/public/application/components/controls/select_interval/select_interval.tsx b/x-pack/plugins/ml/public/application/components/controls/select_interval/select_interval.tsx index f1ef62ddc90d4..75a51d439a73c 100644 --- a/x-pack/plugins/ml/public/application/components/controls/select_interval/select_interval.tsx +++ b/x-pack/plugins/ml/public/application/components/controls/select_interval/select_interval.tsx @@ -55,7 +55,11 @@ function optionValueToInterval(value: string) { export const TABLE_INTERVAL_DEFAULT = optionValueToInterval('auto'); export const useTableInterval = (): [TableInterval, (v: TableInterval) => void] => { - return usePageUrlState('mlSelectInterval', TABLE_INTERVAL_DEFAULT); + const [interval, updateCallback] = usePageUrlState( + 'mlSelectInterval', + TABLE_INTERVAL_DEFAULT + ); + return [interval, updateCallback]; }; /* diff --git a/x-pack/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx b/x-pack/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx index 3409642692151..5aea43a9c815a 100644 --- a/x-pack/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx +++ b/x-pack/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx @@ -82,7 +82,8 @@ export function optionValueToThreshold(value: number) { const TABLE_SEVERITY_DEFAULT = SEVERITY_OPTIONS[0]; export const useTableSeverity = (): [TableSeverity, (v: TableSeverity) => void] => { - return usePageUrlState('mlSelectSeverity', TABLE_SEVERITY_DEFAULT); + const [severity, updateCallback] = usePageUrlState('mlSelectSeverity', TABLE_SEVERITY_DEFAULT); + return [severity, updateCallback]; }; export const getSeverityOptions = () => diff --git a/x-pack/plugins/ml/public/application/components/influencers_list/influencers_list.tsx b/x-pack/plugins/ml/public/application/components/influencers_list/influencers_list.tsx index c7998ec53c1ad..5f27113a341e2 100644 --- a/x-pack/plugins/ml/public/application/components/influencers_list/influencers_list.tsx +++ b/x-pack/plugins/ml/public/application/components/influencers_list/influencers_list.tsx @@ -18,7 +18,7 @@ import { abbreviateWholeNumber } from '../../formatters/abbreviate_whole_number' import { getSeverity, getFormattedSeverityScore } from '../../../../common/util/anomaly_utils'; import { EntityCell, EntityCellFilter } from '../entity_cell'; -interface InfluencerValueData { +export interface InfluencerValueData { influencerFieldValue: string; maxAnomalyScore: number; sumAnomalyScore: number; diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector.tsx b/x-pack/plugins/ml/public/application/components/job_selector/job_selector.tsx index 66d620132458f..f09c20eda0389 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/job_selector.tsx +++ b/x-pack/plugins/ml/public/application/components/job_selector/job_selector.tsx @@ -76,7 +76,7 @@ export function getInitialGroupsMap(selectedGroups: GroupObj[]): GroupsMap { return map; } -interface JobSelectorProps { +export interface JobSelectorProps { dateFormatTz: string; singleSelection: boolean; timeseriesOnly: boolean; diff --git a/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts b/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts index 63d5855d96f41..87c331be855ef 100644 --- a/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts +++ b/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts @@ -10,7 +10,7 @@ import { isEqual } from 'lodash'; import useObservable from 'react-use/lib/useObservable'; import { forkJoin, of, Observable, Subject } from 'rxjs'; -import { mergeMap, switchMap, tap, map } from 'rxjs/operators'; +import { switchMap, tap, map } from 'rxjs/operators'; import { useCallback, useMemo } from 'react'; import { explorerService } from '../explorer_dashboard_service'; @@ -31,14 +31,12 @@ import { ExplorerState } from '../reducers'; import { useMlKibana, useTimefilter } from '../../contexts/kibana'; import { AnomalyTimelineService } from '../../services/anomaly_timeline_service'; import { MlResultsService, mlResultsServiceProvider } from '../../services/results_service'; -import { isViewBySwimLaneData } from '../swimlane_container'; -import { ANOMALY_SWIM_LANE_HARD_LIMIT } from '../explorer_constants'; import { TimefilterContract } from '../../../../../../../src/plugins/data/public'; import { AnomalyExplorerChartsService } from '../../services/anomaly_explorer_charts_service'; -import { CombinedJob } from '../../../../common/types/anomaly_detection_jobs'; -import { InfluencersFilterQuery } from '../../../../common/types/es_client'; +import type { CombinedJob } from '../../../../common/types/anomaly_detection_jobs'; +import type { InfluencersFilterQuery } from '../../../../common/types/es_client'; import { mlJobService } from '../../services/job_service'; -import { TimeBucketsInterval } from '../../util/time_buckets'; +import type { TimeBucketsInterval, TimeRangeBounds } from '../../util/time_buckets'; // Memoize the data fetching methods. // wrapWithLastRefreshArg() wraps any given function and preprends a `lastRefresh` argument @@ -53,18 +51,20 @@ const wrapWithLastRefreshArg = any>(func: T, context }; }; const memoize = any>(func: T, context?: any) => { - return memoizeOne(wrapWithLastRefreshArg(func, context) as any, memoizeIsEqual); + return memoizeOne(wrapWithLastRefreshArg(func, context) as any, memoizeIsEqual) as ( + lastRefresh: number, + ...args: Parameters + ) => ReturnType; }; -const memoizedLoadOverallAnnotations = - memoize(loadOverallAnnotations); +const memoizedLoadOverallAnnotations = memoize(loadOverallAnnotations); + +const memoizedLoadAnnotationsTableData = memoize(loadAnnotationsTableData); + +const memoizedLoadFilteredTopInfluencers = memoize(loadFilteredTopInfluencers); -const memoizedLoadAnnotationsTableData = - memoize(loadAnnotationsTableData); -const memoizedLoadFilteredTopInfluencers = memoize( - loadFilteredTopInfluencers -); const memoizedLoadTopInfluencers = memoize(loadTopInfluencers); + const memoizedLoadAnomaliesTableData = memoize(loadAnomaliesTableData); export interface LoadExplorerDataConfig { @@ -78,10 +78,7 @@ export interface LoadExplorerDataConfig { tableInterval: string; tableSeverity: number; viewBySwimlaneFieldName: string; - viewByFromPage: number; - viewByPerPage: number; swimlaneContainerWidth: number; - swimLaneSeverity: number; } export const isLoadExplorerDataConfig = (arg: any): arg is LoadExplorerDataConfig => { @@ -102,14 +99,6 @@ const loadExplorerDataProvider = ( anomalyExplorerChartsService: AnomalyExplorerChartsService, timefilter: TimefilterContract ) => { - const memoizedLoadOverallData = memoize( - anomalyTimelineService.loadOverallData, - anomalyTimelineService - ); - const memoizedLoadViewBySwimlane = memoize( - anomalyTimelineService.loadViewBySwimlane, - anomalyTimelineService - ); const memoizedAnomalyDataChange = memoize( anomalyExplorerChartsService.getAnomalyData, anomalyExplorerChartsService @@ -127,14 +116,10 @@ const loadExplorerDataProvider = ( selectedCells, selectedJobs, swimlaneBucketInterval, - swimlaneLimit, tableInterval, tableSeverity, viewBySwimlaneFieldName, swimlaneContainerWidth, - viewByFromPage, - viewByPerPage, - swimLaneSeverity, } = config; const combinedJobRecords: Record = selectedJobs.reduce((acc, job) => { @@ -144,7 +129,7 @@ const loadExplorerDataProvider = ( const selectionInfluencers = getSelectionInfluencers(selectedCells, viewBySwimlaneFieldName); const jobIds = getSelectionJobIds(selectedCells, selectedJobs); - const bounds = timefilter.getBounds(); + const bounds = timefilter.getBounds() as Required; const timerange = getSelectionTimeRange( selectedCells, @@ -155,8 +140,9 @@ const loadExplorerDataProvider = ( const dateFormatTz = getDateFormatTz(); const interval = swimlaneBucketInterval.asSeconds(); + // First get the data where we have all necessary args at hand using forkJoin: - // annotationsData, anomalyChartRecords, influencers, overallState, tableData, topFieldValues + // annotationsData, anomalyChartRecords, influencers, overallState, tableData return forkJoin({ overallAnnotations: memoizedLoadOverallAnnotations( lastRefresh, @@ -192,13 +178,6 @@ const loadExplorerDataProvider = ( influencersFilterQuery ) : Promise.resolve({}), - overallState: memoizedLoadOverallData( - lastRefresh, - selectedJobs, - swimlaneContainerWidth, - undefined, - swimLaneSeverity - ), tableData: memoizedLoadAnomaliesTableData( lastRefresh, selectedCells, @@ -211,27 +190,8 @@ const loadExplorerDataProvider = ( tableSeverity, influencersFilterQuery ), - topFieldValues: - selectedCells !== undefined && selectedCells.showTopFieldValues === true - ? anomalyTimelineService.loadViewByTopFieldValuesForSelectedTime( - timerange.earliestMs, - timerange.latestMs, - selectedJobs, - viewBySwimlaneFieldName, - swimlaneLimit, - viewByPerPage, - viewByFromPage, - swimlaneContainerWidth, - selectionInfluencers, - influencersFilterQuery - ) - : Promise.resolve([]), }).pipe( - // Trigger a side-effect action to reset view-by swimlane, - // show the view-by loading indicator - // and pass on the data we already fetched. - tap(explorerService.setViewBySwimlaneLoading), - tap(({ anomalyChartRecords, topFieldValues }) => { + tap(({ anomalyChartRecords }) => { memoizedAnomalyDataChange( lastRefresh, explorerService, @@ -246,16 +206,8 @@ const loadExplorerDataProvider = ( tableSeverity ); }), - mergeMap( - ({ - overallAnnotations, - anomalyChartRecords, - influencers, - overallState, - topFieldValues, - annotationsData, - tableData, - }) => + switchMap( + ({ overallAnnotations, anomalyChartRecords, influencers, annotationsData, tableData }) => forkJoin({ filteredTopInfluencers: (selectionInfluencers.length > 0 || influencersFilterQuery !== undefined) && @@ -273,38 +225,15 @@ const loadExplorerDataProvider = ( influencersFilterQuery ) : Promise.resolve(influencers), - viewBySwimlaneState: memoizedLoadViewBySwimlane( - lastRefresh, - topFieldValues, - { - earliest: overallState.earliest, - latest: overallState.latest, - }, - selectedJobs, - viewBySwimlaneFieldName, - ANOMALY_SWIM_LANE_HARD_LIMIT, - viewByPerPage, - viewByFromPage, - swimlaneContainerWidth, - influencersFilterQuery, - undefined, - swimLaneSeverity - ), }).pipe( - map(({ viewBySwimlaneState, filteredTopInfluencers }) => { + map(({ filteredTopInfluencers }) => { return { overallAnnotations, annotations: annotationsData, influencers: filteredTopInfluencers as any, loading: false, - viewBySwimlaneDataLoading: false, anomalyChartsDataLoading: false, - overallSwimlaneData: overallState, - viewBySwimlaneData: viewBySwimlaneState as any, tableData, - swimlaneLimit: isViewBySwimLaneData(viewBySwimlaneState) - ? viewBySwimlaneState.cardinality - : undefined, }; }) ) @@ -312,6 +241,7 @@ const loadExplorerDataProvider = ( ); }; }; + export const useExplorerData = (): [Partial | undefined, (d: any) => void] => { const timefilter = useTimefilter(); diff --git a/x-pack/plugins/ml/public/application/explorer/anomaly_explorer_common_state.ts b/x-pack/plugins/ml/public/application/explorer/anomaly_explorer_common_state.ts new file mode 100644 index 0000000000000..66c557230753a --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/anomaly_explorer_common_state.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { BehaviorSubject, Observable } from 'rxjs'; +import { distinctUntilChanged, map, skipWhile } from 'rxjs/operators'; +import { isEqual } from 'lodash'; +import type { ExplorerJob } from './explorer_utils'; +import type { InfluencersFilterQuery } from '../../../common/types/es_client'; +import type { AnomalyExplorerUrlStateService } from './hooks/use_explorer_url_state'; +import type { AnomalyExplorerFilterUrlState } from '../../../common/types/locator'; +import type { KQLFilterSettings } from './components/explorer_query_bar/explorer_query_bar'; + +export interface AnomalyExplorerState { + selectedJobs: ExplorerJob[]; +} + +export type FilterSettings = Required< + Pick +> & + Pick; + +/** + * Anomaly Explorer common state. + * Manages related values in the URL state and applies required formatting. + */ +export class AnomalyExplorerCommonStateService { + private _selectedJobs$ = new BehaviorSubject(undefined); + private _filterSettings$ = new BehaviorSubject(this._getDefaultFilterSettings()); + private _showCharts$ = new BehaviorSubject(true); + + private _getDefaultFilterSettings(): FilterSettings { + return { + filterActive: false, + filteredFields: [], + queryString: '', + influencersFilterQuery: undefined, + }; + } + + constructor(private anomalyExplorerUrlStateService: AnomalyExplorerUrlStateService) { + this._init(); + } + + private _init() { + this.anomalyExplorerUrlStateService + .getPageUrlState$() + .pipe( + map((urlState) => urlState?.mlExplorerFilter), + distinctUntilChanged(isEqual) + ) + .subscribe((v) => { + const result = { + ...this._getDefaultFilterSettings(), + ...v, + }; + this._filterSettings$.next(result); + }); + + this.anomalyExplorerUrlStateService + .getPageUrlState$() + .pipe( + map((urlState) => urlState?.mlShowCharts ?? true), + distinctUntilChanged() + ) + .subscribe(this._showCharts$); + } + + public setSelectedJobs(explorerJobs: ExplorerJob[] | undefined) { + this._selectedJobs$.next(explorerJobs); + } + + public getSelectedJobs$(): Observable { + return this._selectedJobs$.pipe( + skipWhile((v) => !v || !v.length), + distinctUntilChanged(isEqual) + ); + } + + public getSelectedJobs(): ExplorerJob[] | undefined { + return this._selectedJobs$.getValue(); + } + + public getInfluencerFilterQuery$(): Observable { + return this._filterSettings$.pipe( + map((v) => v?.influencersFilterQuery), + distinctUntilChanged(isEqual) + ); + } + + public getFilterSettings$(): Observable { + return this._filterSettings$.asObservable(); + } + + public getFilterSettings(): FilterSettings { + return this._filterSettings$.getValue(); + } + + public setFilterSettings(update: KQLFilterSettings) { + this.anomalyExplorerUrlStateService.updateUrlState({ + mlExplorerFilter: { + influencersFilterQuery: update.filterQuery, + filterActive: true, + filteredFields: update.filteredFields, + queryString: update.queryString, + }, + }); + } + + public clearFilterSettings() { + this.anomalyExplorerUrlStateService.updateUrlState({ mlExplorerFilter: {} }); + } + + public getShowCharts$(): Observable { + return this._showCharts$.asObservable(); + } + + public getShowCharts(): boolean { + return this._showCharts$.getValue(); + } + + public setShowCharts(update: boolean) { + this.anomalyExplorerUrlStateService.updateUrlState({ mlShowCharts: update }); + } +} diff --git a/x-pack/plugins/ml/public/application/explorer/anomaly_explorer_context.tsx b/x-pack/plugins/ml/public/application/explorer/anomaly_explorer_context.tsx new file mode 100644 index 0000000000000..f0d175e49dda6 --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/anomaly_explorer_context.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useContext, useMemo } from 'react'; +import { AnomalyTimelineStateService } from './anomaly_timeline_state_service'; +import { AnomalyExplorerCommonStateService } from './anomaly_explorer_common_state'; +import { useMlKibana, useTimefilter } from '../contexts/kibana'; +import { mlResultsServiceProvider } from '../services/results_service'; +import { AnomalyTimelineService } from '../services/anomaly_timeline_service'; +import type { AnomalyExplorerUrlStateService } from './hooks/use_explorer_url_state'; + +export type AnomalyExplorerContextValue = + | { + anomalyExplorerCommonStateService: AnomalyExplorerCommonStateService; + anomalyTimelineStateService: AnomalyTimelineStateService; + } + | undefined; + +/** + * Context of the Anomaly Explorer page. + */ +export const AnomalyExplorerContext = React.createContext(undefined); + +/** + * Hook for consuming {@link AnomalyExplorerContext}. + */ +export function useAnomalyExplorerContext(): + | Exclude + | never { + const context = useContext(AnomalyExplorerContext); + + if (context === undefined) { + throw new Error('AnomalyExplorerContext has not been initialized.'); + } + + return context; +} + +/** + * Creates Anomaly Explorer context. + */ +export function useAnomalyExplorerContextValue( + anomalyExplorerUrlStateService: AnomalyExplorerUrlStateService +): Exclude { + const timefilter = useTimefilter(); + + const { + services: { + mlServices: { mlApiServices }, + uiSettings, + }, + } = useMlKibana(); + + const mlResultsService = useMemo(() => mlResultsServiceProvider(mlApiServices), []); + + const anomalyTimelineService = useMemo(() => { + return new AnomalyTimelineService(timefilter, uiSettings, mlResultsService); + }, []); + + return useMemo(() => { + const anomalyExplorerCommonStateService = new AnomalyExplorerCommonStateService( + anomalyExplorerUrlStateService + ); + + return { + anomalyExplorerCommonStateService, + anomalyTimelineStateService: new AnomalyTimelineStateService( + anomalyExplorerCommonStateService, + anomalyTimelineService, + timefilter + ), + }; + }, []); +} diff --git a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx index 36c86af989a4a..b8deedf3bd369 100644 --- a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx +++ b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline.tsx @@ -12,24 +12,22 @@ import { EuiButtonIcon, EuiContextMenuItem, EuiContextMenuPanel, - EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, EuiPanel, EuiPopover, EuiSelect, EuiSpacer, + EuiText, EuiTitle, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import useDebounce from 'react-use/lib/useDebounce'; +import useObservable from 'react-use/lib/useObservable'; import { OVERALL_LABEL, SWIMLANE_TYPE, VIEW_BY_JOB_LABEL } from './explorer_constants'; import { AddSwimlaneToDashboardControl } from './dashboard_controls/add_swimlane_to_dashboard_controls'; import { useMlKibana } from '../contexts/kibana'; -import { TimeBuckets } from '../util/time_buckets'; -import { UI_SETTINGS } from '../../../../../../src/plugins/data/common'; -import { explorerService } from './explorer_dashboard_service'; import { ExplorerState } from './reducers/explorer_reducer'; import { ExplorerNoInfluencersFound } from './components/explorer_no_influencers_found/explorer_no_influencers_found'; import { SwimlaneContainer } from './swimlane_container'; @@ -41,6 +39,10 @@ import { isDefined } from '../../../common/types/guards'; import { MlTooltipComponent } from '../components/chart_tooltip'; import { SwimlaneAnnotationContainer } from './swimlane_annotation_container'; import { AnomalyTimelineService } from '../services/anomaly_timeline_service'; +import { useAnomalyExplorerContext } from './anomaly_explorer_context'; +import { useTimeBuckets } from '../components/custom_hooks/use_time_buckets'; +import { formatHumanReadableDateTime } from '../../../common/util/date_utils'; +import { getTimeBoundsFromSelection } from './hooks/use_selected_cells'; function mapSwimlaneOptionsToEuiOptions(options: string[]) { return options.map((option) => ({ @@ -51,58 +53,89 @@ function mapSwimlaneOptionsToEuiOptions(options: string[]) { interface AnomalyTimelineProps { explorerState: ExplorerState; - setSelectedCells: (cells?: any) => void; } export const AnomalyTimeline: FC = React.memo( - ({ explorerState, setSelectedCells }) => { + ({ explorerState }) => { const { services: { - uiSettings, application: { capabilities }, }, } = useMlKibana(); + const { anomalyExplorerCommonStateService, anomalyTimelineStateService } = + useAnomalyExplorerContext(); + + const setSelectedCells = anomalyTimelineStateService.setSelectedCells.bind( + anomalyTimelineStateService + ); + const [isMenuOpen, setIsMenuOpen] = useState(false); const [isAddDashboardsActive, setIsAddDashboardActive] = useState(false); const canEditDashboards = capabilities.dashboard?.createNew ?? false; - const timeBuckets = useMemo(() => { - return new TimeBuckets({ - 'histogram:maxBars': uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS), - 'histogram:barTarget': uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET), - dateFormat: uiSettings.get('dateFormat'), - 'dateFormat:scaled': uiSettings.get('dateFormat:scaled'), - }); - }, [uiSettings]); + const timeBuckets = useTimeBuckets(); - const { - filterActive, - selectedCells, - viewByLoadedForTimeFormatted, - viewBySwimlaneDataLoading, - viewBySwimlaneFieldName, - viewBySwimlaneOptions, - selectedJobs, - viewByFromPage, - viewByPerPage, - swimlaneLimit, - loading, - overallAnnotations, - swimLaneSeverity, - overallSwimlaneData, - viewBySwimlaneData, - swimlaneContainerWidth, - } = explorerState; - - const [severityUpdate, setSeverityUpdate] = useState(swimLaneSeverity); + const { overallAnnotations } = explorerState; + + const { filterActive } = useObservable( + anomalyExplorerCommonStateService.getFilterSettings$(), + anomalyExplorerCommonStateService.getFilterSettings() + ); + + const swimlaneLimit = useObservable(anomalyTimelineStateService.getSwimLaneCardinality$()); + + const selectedJobs = useObservable(anomalyExplorerCommonStateService.getSelectedJobs$()); + + const loading = useObservable(anomalyTimelineStateService.isOverallSwimLaneLoading$(), true); + + const swimlaneContainerWidth = useObservable( + anomalyTimelineStateService.getContainerWidth$(), + anomalyTimelineStateService.getContainerWidth() + ); + const viewBySwimlaneDataLoading = useObservable( + anomalyTimelineStateService.isViewBySwimLaneLoading$(), + true + ); + + const overallSwimlaneData = useObservable( + anomalyTimelineStateService.getOverallSwimLaneData$() + ); + + const viewBySwimlaneData = useObservable(anomalyTimelineStateService.getViewBySwimLaneData$()); + const selectedCells = useObservable(anomalyTimelineStateService.getSelectedCells$()); + const swimLaneSeverity = useObservable(anomalyTimelineStateService.getSwimLaneSeverity$()); + const viewBySwimlaneFieldName = useObservable( + anomalyTimelineStateService.getViewBySwimlaneFieldName$() + ); + + const viewBySwimlaneOptions = useObservable( + anomalyTimelineStateService.getViewBySwimLaneOptions$(), + anomalyTimelineStateService.getViewBySwimLaneOptions() + ); + + const { viewByPerPage, viewByFromPage } = useObservable( + anomalyTimelineStateService.getSwimLanePagination$(), + anomalyTimelineStateService.getSwimLanePagination() + ); + + const [severityUpdate, setSeverityUpdate] = useState( + anomalyTimelineStateService.getSwimLaneSeverity() + ); + + const timeRange = getTimeBoundsFromSelection(selectedCells); + + const viewByLoadedForTimeFormatted = timeRange + ? `${formatHumanReadableDateTime(timeRange.earliestMs)} - ${formatHumanReadableDateTime( + timeRange.latestMs + )}` + : null; useDebounce( () => { if (severityUpdate === swimLaneSeverity) return; - - explorerService.setSwimLaneSeverity(severityUpdate!); + anomalyTimelineStateService.setSeverity(severityUpdate!); }, 500, [severityUpdate, swimLaneSeverity] @@ -154,6 +187,10 @@ export const AnomalyTimeline: FC = React.memo( [overallSwimlaneData] ); + const onResize = useCallback((value: number) => { + anomalyTimelineStateService.setContainerWidth(value); + }, []); + return ( <> @@ -213,7 +250,9 @@ export const AnomalyTimeline: FC = React.memo( id="selectViewBy" options={mapSwimlaneOptionsToEuiOptions(viewBySwimlaneOptions)} value={viewBySwimlaneFieldName} - onChange={(e) => explorerService.setViewBySwimlaneFieldName(e.target.value)} + onChange={(e) => { + anomalyTimelineStateService.setViewBySwimLaneFieldName(e.target.value); + }} /> @@ -255,21 +294,18 @@ export const AnomalyTimeline: FC = React.memo( )} - - {selectedCells ? ( - - - - - - ) : null} + + + + + @@ -278,7 +314,7 @@ export const AnomalyTimeline: FC = React.memo( {(tooltipService) => ( = React.memo( swimlaneType={SWIMLANE_TYPE.OVERALL} selection={overallCellSelection} onCellsSelection={setSelectedCells} - onResize={explorerService.setSwimlaneContainerWidth} + onResize={onResize} isLoading={loading} noDataWarning={ - - - - } - /> + +
+ +
+
} showTimeline={false} showLegend={false} @@ -327,42 +359,42 @@ export const AnomalyTimeline: FC = React.memo( swimlaneType={SWIMLANE_TYPE.VIEW_BY} selection={selectedCells} onCellsSelection={setSelectedCells} - onResize={explorerService.setSwimlaneContainerWidth} + onResize={onResize} fromPage={viewByFromPage} perPage={viewByPerPage} swimlaneLimit={swimlaneLimit} onPaginationChange={({ perPage: perPageUpdate, fromPage: fromPageUpdate }) => { if (perPageUpdate) { - explorerService.setViewByPerPage(perPageUpdate); + anomalyTimelineStateService.setSwimLanePagination({ + viewByPerPage: perPageUpdate, + }); } if (fromPageUpdate) { - explorerService.setViewByFromPage(fromPageUpdate); + anomalyTimelineStateService.setSwimLanePagination({ + viewByFromPage: fromPageUpdate, + }); } }} isLoading={loading || viewBySwimlaneDataLoading} noDataWarning={ - - {typeof viewBySwimlaneFieldName === 'string' ? ( - viewBySwimlaneFieldName === VIEW_BY_JOB_LABEL ? ( - - ) : ( - - ) - ) : null} - - } - /> + +
+ {typeof viewBySwimlaneFieldName === 'string' ? ( + viewBySwimlaneFieldName === VIEW_BY_JOB_LABEL ? ( + + ) : ( + + ) + ) : null} +
+
} /> )} diff --git a/x-pack/plugins/ml/public/application/explorer/anomaly_timeline_state_service.ts b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline_state_service.ts new file mode 100644 index 0000000000000..19dab0be1ff9f --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/anomaly_timeline_state_service.ts @@ -0,0 +1,717 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { BehaviorSubject, combineLatest, from, Observable, of } from 'rxjs'; +import { + switchMap, + map, + skipWhile, + distinctUntilChanged, + startWith, + tap, + debounceTime, +} from 'rxjs/operators'; +import { isEqual, sortBy, uniq } from 'lodash'; +import { AnomalyTimelineService } from '../services/anomaly_timeline_service'; +import type { + AppStateSelectedCells, + ExplorerJob, + OverallSwimlaneData, + ViewBySwimLaneData, +} from './explorer_utils'; +import type { AnomalyExplorerCommonStateService } from './anomaly_explorer_common_state'; +import type { AnomalyExplorerSwimLaneUrlState } from '../../../common/types/locator'; +import type { TimefilterContract } from '../../../../../../src/plugins/data/public'; +import type { TimeRangeBounds } from '../../../../../../src/plugins/data/common'; +import { + ANOMALY_SWIM_LANE_HARD_LIMIT, + SWIMLANE_TYPE, + VIEW_BY_JOB_LABEL, +} from './explorer_constants'; +// FIXME get rid of the static import +import { mlJobService } from '../services/job_service'; +import { getSelectionInfluencers, getSelectionTimeRange } from './explorer_utils'; +import type { TimeBucketsInterval } from '../util/time_buckets'; +import { InfluencersFilterQuery } from '../../../common/types/es_client'; +// FIXME get rid of the static import +import { mlTimefilterRefresh$ } from '../services/timefilter_refresh_service'; +import type { Refresh } from '../routing/use_refresh'; + +interface SwimLanePagination { + viewByFromPage: number; + viewByPerPage: number; +} + +/** + * Service for managing anomaly timeline state. + */ +export class AnomalyTimelineStateService { + private _explorerURLStateCallback: + | ((update: AnomalyExplorerSwimLaneUrlState, replaceState?: boolean) => void) + | null = null; + + private _overallSwimLaneData$ = new BehaviorSubject(null); + private _viewBySwimLaneData$ = new BehaviorSubject(undefined); + + private _swimLaneUrlState$ = new BehaviorSubject< + AnomalyExplorerSwimLaneUrlState | undefined | null + >(null); + + private _containerWidth$ = new BehaviorSubject(0); + private _selectedCells$ = new BehaviorSubject(undefined); + private _swimLaneSeverity$ = new BehaviorSubject(0); + private _swimLanePaginations$ = new BehaviorSubject({ + viewByFromPage: 1, + viewByPerPage: 10, + }); + private _swimLaneCardinality$ = new BehaviorSubject(undefined); + private _viewBySwimlaneFieldName$ = new BehaviorSubject(undefined); + private _viewBySwimLaneOptions$ = new BehaviorSubject([]); + private _topFieldValues$ = new BehaviorSubject([]); + private _isOverallSwimLaneLoading$ = new BehaviorSubject(true); + private _isViewBySwimLaneLoading$ = new BehaviorSubject(true); + private _swimLaneBucketInterval$ = new BehaviorSubject(null); + + private _timeBounds$: Observable; + private _refreshSubject$: Observable; + + constructor( + private anomalyExplorerCommonStateService: AnomalyExplorerCommonStateService, + private anomalyTimelineService: AnomalyTimelineService, + private timefilter: TimefilterContract + ) { + this._timeBounds$ = this.timefilter.getTimeUpdate$().pipe( + startWith(null), + map(() => this.timefilter.getBounds()) + ); + this._refreshSubject$ = mlTimefilterRefresh$.pipe(startWith({ lastRefresh: 0 })); + this._init(); + } + + /** + * Initializes required subscriptions for fetching swim lanes data. + * @private + */ + private _init() { + this._initViewByData(); + + this._swimLaneUrlState$ + .pipe( + map((v) => v?.severity ?? 0), + distinctUntilChanged() + ) + .subscribe(this._swimLaneSeverity$); + + this._initSwimLanePagination(); + this._initOverallSwimLaneData(); + this._initTopFieldValues(); + this._initViewBySwimLaneData(); + + combineLatest([ + this.anomalyExplorerCommonStateService.getSelectedJobs$(), + this.getContainerWidth$(), + ]).subscribe(([selectedJobs, containerWidth]) => { + if (!selectedJobs) return; + this._swimLaneBucketInterval$.next( + this.anomalyTimelineService.getSwimlaneBucketInterval(selectedJobs, containerWidth!) + ); + }); + + this._initSelectedCells(); + } + + private _initViewByData(): void { + combineLatest([ + this._swimLaneUrlState$.pipe( + map((v) => v?.viewByFieldName), + distinctUntilChanged() + ), + this.anomalyExplorerCommonStateService.getSelectedJobs$(), + this.anomalyExplorerCommonStateService.getFilterSettings$(), + this._selectedCells$, + ]).subscribe(([currentlySelected, selectedJobs, filterSettings, selectedCells]) => { + const { viewBySwimlaneFieldName, viewBySwimlaneOptions } = this._getViewBySwimlaneOptions( + currentlySelected, + filterSettings.filterActive, + filterSettings.filteredFields as string[], + false, + selectedCells, + selectedJobs + ); + this._viewBySwimlaneFieldName$.next(viewBySwimlaneFieldName); + this._viewBySwimLaneOptions$.next(viewBySwimlaneOptions); + }); + } + + private _initSwimLanePagination() { + combineLatest([ + this._swimLaneUrlState$.pipe( + map((v) => { + return { + viewByFromPage: v?.viewByFromPage ?? 1, + viewByPerPage: v?.viewByPerPage ?? 10, + }; + }), + distinctUntilChanged(isEqual) + ), + this.anomalyExplorerCommonStateService.getInfluencerFilterQuery$(), + this._timeBounds$, + ]).subscribe(([pagination, influencersFilerQuery]) => { + let resultPaginaiton: SwimLanePagination = pagination; + if (influencersFilerQuery) { + resultPaginaiton = { viewByPerPage: pagination.viewByPerPage, viewByFromPage: 1 }; + } + this._swimLanePaginations$.next(resultPaginaiton); + }); + } + + private _initOverallSwimLaneData() { + combineLatest([ + this.anomalyExplorerCommonStateService.getSelectedJobs$(), + this._swimLaneSeverity$, + this.getContainerWidth$(), + this._timeBounds$, + this._refreshSubject$, + ]) + .pipe( + tap(() => { + this._isOverallSwimLaneLoading$.next(true); + }), + switchMap(([selectedJobs, severity, containerWidth]) => { + return from( + this.anomalyTimelineService.loadOverallData( + selectedJobs!, + containerWidth, + undefined, + severity + ) + ); + }) + ) + .subscribe((v) => { + this._overallSwimLaneData$.next(v); + this._isOverallSwimLaneLoading$.next(false); + }); + } + + private _initTopFieldValues() { + ( + combineLatest([ + this.anomalyExplorerCommonStateService.getSelectedJobs$(), + this.anomalyExplorerCommonStateService.getInfluencerFilterQuery$(), + this.getViewBySwimlaneFieldName$(), + this.getSwimLanePagination$(), + this.getSwimLaneCardinality$(), + this.getContainerWidth$(), + this.getSelectedCells$(), + this.getSwimLaneBucketInterval$(), + this._timeBounds$, + this._refreshSubject$, + ]) as Observable< + [ + ExplorerJob[], + InfluencersFilterQuery, + string, + SwimLanePagination, + number, + number, + AppStateSelectedCells, + TimeBucketsInterval + ] + > + ) + .pipe( + switchMap( + ([ + selectedJobs, + influencersFilterQuery, + viewBySwimlaneFieldName, + swimLanePagination, + swimLaneCardinality, + swimlaneContainerWidth, + selectedCells, + swimLaneBucketInterval, + ]) => { + if (!selectedCells?.showTopFieldValues) { + return of([]); + } + + const selectionInfluencers = getSelectionInfluencers( + selectedCells, + viewBySwimlaneFieldName + ); + + const timerange = getSelectionTimeRange( + selectedCells, + swimLaneBucketInterval.asSeconds(), + this.timefilter.getBounds() + ); + + return from( + this.anomalyTimelineService.loadViewByTopFieldValuesForSelectedTime( + timerange.earliestMs, + timerange.latestMs, + selectedJobs, + viewBySwimlaneFieldName!, + ANOMALY_SWIM_LANE_HARD_LIMIT, + swimLanePagination.viewByPerPage, + swimLanePagination.viewByFromPage, + swimlaneContainerWidth, + selectionInfluencers, + influencersFilterQuery + ) + ); + } + ) + ) + .subscribe(this._topFieldValues$); + } + + private _initViewBySwimLaneData() { + combineLatest([ + this._overallSwimLaneData$.pipe(skipWhile((v) => !v)), + this.anomalyExplorerCommonStateService.getSelectedJobs$(), + this.anomalyExplorerCommonStateService.getInfluencerFilterQuery$(), + this._swimLaneSeverity$, + this.getContainerWidth$(), + this.getViewBySwimlaneFieldName$(), + this.getSwimLanePagination$(), + this._topFieldValues$.pipe(distinctUntilChanged(isEqual)), + this._timeBounds$, + this._refreshSubject$, + ]) + .pipe( + tap(() => { + this._isViewBySwimLaneLoading$.next(true); + }), + switchMap( + ([ + overallSwimLaneData, + selectedJobs, + influencersFilterQuery, + severity, + swimlaneContainerWidth, + viewBySwimlaneFieldName, + swimLanePagination, + topFieldValues, + ]) => { + return from( + this.anomalyTimelineService.loadViewBySwimlane( + topFieldValues, + { + earliest: overallSwimLaneData!.earliest, + latest: overallSwimLaneData!.latest, + }, + selectedJobs, + viewBySwimlaneFieldName, + ANOMALY_SWIM_LANE_HARD_LIMIT, + swimLanePagination.viewByPerPage, + swimLanePagination.viewByFromPage, + swimlaneContainerWidth, + influencersFilterQuery, + undefined, + severity + ) + ); + } + ) + ) + .subscribe((v) => { + this._viewBySwimLaneData$.next(v); + this._isViewBySwimLaneLoading$.next(false); + this._swimLaneCardinality$.next(v?.cardinality); + }); + } + + private _initSelectedCells() { + combineLatest([ + this._viewBySwimlaneFieldName$, + this._swimLaneUrlState$, + this.getSwimLaneBucketInterval$(), + this._timeBounds$, + ]) + .pipe( + map(([viewByFieldName, swimLaneUrlState, swimLaneBucketInterval]) => { + if (!swimLaneUrlState?.selectedType) { + return; + } + + let times: AnomalyExplorerSwimLaneUrlState['selectedTimes'] = + swimLaneUrlState.selectedTimes ?? swimLaneUrlState.selectedTime!; + if (typeof times === 'number') { + times = [times, times + swimLaneBucketInterval!.asSeconds()]; + } + + let lanes = swimLaneUrlState.selectedLanes ?? swimLaneUrlState.selectedLane!; + + if (typeof lanes === 'string') { + lanes = [lanes]; + } + + times = this._getAdjustedTimeSelection(times, this.timefilter.getBounds()); + + if (!times) { + return; + } + + return { + type: swimLaneUrlState.selectedType, + lanes, + times, + showTopFieldValues: swimLaneUrlState.showTopFieldValues, + viewByFieldName, + } as AppStateSelectedCells; + }), + distinctUntilChanged(isEqual) + ) + .subscribe(this._selectedCells$); + } + + /** + * Adjust cell selection with respect to the time boundaries. + * @return adjusted time selection or undefined if out of current range entirely. + */ + private _getAdjustedTimeSelection( + times: AppStateSelectedCells['times'], + timeBounds: TimeRangeBounds + ): AppStateSelectedCells['times'] | undefined { + const [selectedFrom, selectedTo] = times; + + /** + * Because each cell on the swim lane represent the fixed bucket interval, + * the selection range could be out of the time boundaries with + * correction within the bucket interval. + */ + const bucketSpanInterval = this.getSwimLaneBucketInterval()!.asSeconds(); + + const rangeFrom = timeBounds.min!.unix() - bucketSpanInterval; + const rangeTo = timeBounds.max!.unix() + bucketSpanInterval; + + const resultFrom = Math.max(selectedFrom, rangeFrom); + const resultTo = Math.min(selectedTo, rangeTo); + + const isSelectionOutOfRange = rangeFrom > resultTo || rangeTo < resultFrom; + + if (isSelectionOutOfRange) { + // reset selection + return; + } + + if (selectedFrom === resultFrom && selectedTo === resultTo) { + // selection is correct, no need to adjust the range + return times; + } + + if (resultFrom !== rangeFrom || resultTo !== rangeTo) { + return [resultFrom, resultTo]; + } + } + + /** + * Obtain the list of 'View by' fields per job and viewBySwimlaneFieldName + * @private + * + * TODO check for possible enhancements/refactoring. Has been moved from explorer_utils as-is. + */ + private _getViewBySwimlaneOptions( + currentViewBySwimlaneFieldName: string | undefined, + filterActive: boolean, + filteredFields: string[], + isAndOperator: boolean, + selectedCells: AppStateSelectedCells | undefined, + selectedJobs: ExplorerJob[] | undefined + ) { + const selectedJobIds = selectedJobs?.map((d) => d.id) ?? []; + + // Unique influencers for the selected job(s). + const viewByOptions: string[] = sortBy( + uniq( + mlJobService.jobs.reduce((reducedViewByOptions, job) => { + if (selectedJobIds.some((jobId) => jobId === job.job_id)) { + return reducedViewByOptions.concat(job.analysis_config.influencers || []); + } + return reducedViewByOptions; + }, [] as string[]) + ), + (fieldName) => fieldName.toLowerCase() + ); + + viewByOptions.push(VIEW_BY_JOB_LABEL); + let viewBySwimlaneOptions = viewByOptions; + let viewBySwimlaneFieldName: string | undefined; + + if (viewBySwimlaneOptions.indexOf(currentViewBySwimlaneFieldName!) !== -1) { + // Set the swim lane viewBy to that stored in the state (URL) if set. + // This means we reset it to the current state because it was set by the listener + // on initialization. + viewBySwimlaneFieldName = currentViewBySwimlaneFieldName; + } else { + if (selectedJobIds.length > 1) { + // If more than one job selected, default to job ID. + viewBySwimlaneFieldName = VIEW_BY_JOB_LABEL; + } else if (mlJobService.jobs.length > 0 && selectedJobIds.length > 0) { + // For a single job, default to the first partition, over, + // by or influencer field of the first selected job. + const firstSelectedJob = mlJobService.jobs.find((job) => { + return job.job_id === selectedJobIds[0]; + }); + + const firstJobInfluencers = firstSelectedJob?.analysis_config.influencers ?? []; + firstSelectedJob?.analysis_config.detectors.forEach((detector) => { + if ( + detector.partition_field_name !== undefined && + firstJobInfluencers.indexOf(detector.partition_field_name) !== -1 + ) { + viewBySwimlaneFieldName = detector.partition_field_name; + return false; + } + + if ( + detector.over_field_name !== undefined && + firstJobInfluencers.indexOf(detector.over_field_name) !== -1 + ) { + viewBySwimlaneFieldName = detector.over_field_name; + return false; + } + + // For jobs with by and over fields, don't add the 'by' field as this + // field will only be added to the top-level fields for record type results + // if it also an influencer over the bucket. + if ( + detector.by_field_name !== undefined && + detector.over_field_name === undefined && + firstJobInfluencers.indexOf(detector.by_field_name) !== -1 + ) { + viewBySwimlaneFieldName = detector.by_field_name; + return false; + } + }); + + if (viewBySwimlaneFieldName === undefined) { + if (firstJobInfluencers.length > 0) { + viewBySwimlaneFieldName = firstJobInfluencers[0]; + } else { + // No influencers for first selected job - set to first available option. + viewBySwimlaneFieldName = + viewBySwimlaneOptions.length > 0 ? viewBySwimlaneOptions[0] : undefined; + } + } + } + } + + // filter View by options to relevant filter fields + // If it's an AND filter only show job Id view by as the rest will have no results + if (filterActive === true && isAndOperator === true && !selectedCells) { + viewBySwimlaneOptions = [VIEW_BY_JOB_LABEL]; + } else if ( + filterActive === true && + Array.isArray(viewBySwimlaneOptions) && + Array.isArray(filteredFields) + ) { + const filteredOptions = viewBySwimlaneOptions.filter((option) => { + return ( + filteredFields.includes(option) || + option === VIEW_BY_JOB_LABEL || + (selectedCells && selectedCells.viewByFieldName === option) + ); + }); + // only replace viewBySwimlaneOptions with filteredOptions if we found a relevant matching field + if (filteredOptions.length > 1) { + viewBySwimlaneOptions = filteredOptions; + if (!viewBySwimlaneOptions.includes(viewBySwimlaneFieldName!)) { + viewBySwimlaneFieldName = viewBySwimlaneOptions[0]; + } + } + } + + return { + viewBySwimlaneFieldName, + viewBySwimlaneOptions, + }; + } + + /** + * Provides overall swim lane data. + */ + public getOverallSwimLaneData$(): Observable { + return this._overallSwimLaneData$.asObservable(); + } + + public getViewBySwimLaneData$(): Observable { + return this._viewBySwimLaneData$.asObservable(); + } + + public getContainerWidth$(): Observable { + return this._containerWidth$.pipe( + debounceTime(500), + distinctUntilChanged((prev, curr) => { + const delta = Math.abs(prev - curr); + // Scrollbar appears during the page rendering, + // it causes small width change that we want to ignore. + return delta < 20; + }) + ); + } + + public getContainerWidth(): number | undefined { + return this._containerWidth$.getValue(); + } + + /** + * Provides updates for swim lanes cells selection. + */ + public getSelectedCells$(): Observable { + return this._selectedCells$.asObservable(); + } + + public getSwimLaneSeverity$(): Observable { + return this._swimLaneSeverity$.asObservable(); + } + + public getSwimLaneSeverity(): number | undefined { + return this._swimLaneSeverity$.getValue(); + } + + public getSwimLanePagination$(): Observable { + return this._swimLanePaginations$.asObservable(); + } + + public getSwimLanePagination(): SwimLanePagination { + return this._swimLanePaginations$.getValue(); + } + + public setSwimLanePagination(update: Partial) { + const resultUpdate = update; + if (resultUpdate.viewByPerPage) { + resultUpdate.viewByFromPage = 1; + } + this._explorerURLStateCallback!(resultUpdate); + } + + public getSwimLaneCardinality$(): Observable { + return this._swimLaneCardinality$.pipe(distinctUntilChanged()); + } + + public getViewBySwimlaneFieldName$(): Observable { + return this._viewBySwimlaneFieldName$.pipe(distinctUntilChanged()); + } + + public getViewBySwimLaneOptions$(): Observable { + return this._viewBySwimLaneOptions$.asObservable(); + } + + public getViewBySwimLaneOptions(): string[] { + return this._viewBySwimLaneOptions$.getValue(); + } + + public isOverallSwimLaneLoading$(): Observable { + return this._isOverallSwimLaneLoading$.asObservable(); + } + + public isViewBySwimLaneLoading$(): Observable { + return this._isViewBySwimLaneLoading$.asObservable(); + } + + /** + * Updates internal subject from the URL state. + * @param value + */ + public updateFromUrlState(value: AnomalyExplorerSwimLaneUrlState | undefined) { + this._swimLaneUrlState$.next(value); + } + + /** + * Updates callback for setting URL app state. + * @param callback + */ + public updateSetStateCallback(callback: (update: AnomalyExplorerSwimLaneUrlState) => void) { + this._explorerURLStateCallback = callback; + } + + /** + * Sets container width + * @param value + */ + public setContainerWidth(value: number) { + this._containerWidth$.next(value); + } + + /** + * Sets swim lanes severity. + * Updates the URL state. + * @param value + */ + public setSeverity(value: number) { + this._explorerURLStateCallback!({ severity: value, viewByFromPage: 1 }); + } + + /** + * Sets selected cells. + * @param swimLaneSelectedCells + */ + public setSelectedCells(swimLaneSelectedCells?: AppStateSelectedCells) { + const vall = this._swimLaneUrlState$.getValue(); + + const mlExplorerSwimlane = { + ...vall, + } as AnomalyExplorerSwimLaneUrlState; + + if (swimLaneSelectedCells !== undefined) { + swimLaneSelectedCells.showTopFieldValues = false; + + const currentSwimlaneType = this._selectedCells$.getValue()?.type; + const currentShowTopFieldValues = this._selectedCells$.getValue()?.showTopFieldValues; + const newSwimlaneType = swimLaneSelectedCells?.type; + + if ( + (currentSwimlaneType === SWIMLANE_TYPE.OVERALL && + newSwimlaneType === SWIMLANE_TYPE.VIEW_BY) || + newSwimlaneType === SWIMLANE_TYPE.OVERALL || + currentShowTopFieldValues === true + ) { + swimLaneSelectedCells.showTopFieldValues = true; + } + + mlExplorerSwimlane.selectedType = swimLaneSelectedCells.type; + mlExplorerSwimlane.selectedLanes = swimLaneSelectedCells.lanes; + mlExplorerSwimlane.selectedTimes = swimLaneSelectedCells.times; + mlExplorerSwimlane.showTopFieldValues = swimLaneSelectedCells.showTopFieldValues; + + this._explorerURLStateCallback!(mlExplorerSwimlane); + } else { + delete mlExplorerSwimlane.selectedType; + delete mlExplorerSwimlane.selectedLanes; + delete mlExplorerSwimlane.selectedTimes; + delete mlExplorerSwimlane.showTopFieldValues; + + this._explorerURLStateCallback!(mlExplorerSwimlane, true); + } + } + + /** + * Updates View by swim lane value. + * @param fieldName - Influencer field name of job id. + */ + public setViewBySwimLaneFieldName(fieldName: string) { + this._explorerURLStateCallback!( + { + viewByFromPage: 1, + viewByPerPage: this._swimLanePaginations$.getValue().viewByPerPage, + viewByFieldName: fieldName, + }, + true + ); + } + + public getSwimLaneBucketInterval$(): Observable { + return this._swimLaneBucketInterval$.pipe(skipWhile((v) => !v)); + } + + public getSwimLaneBucketInterval(): TimeBucketsInterval | null { + return this._swimLaneBucketInterval$.getValue(); + } +} diff --git a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_influencers_found/index.js b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_influencers_found/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/explorer/components/explorer_no_influencers_found/index.js rename to x-pack/plugins/ml/public/application/explorer/components/explorer_no_influencers_found/index.ts diff --git a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/explorer_no_results_found.js b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/explorer_no_results_found.tsx similarity index 87% rename from x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/explorer_no_results_found.js rename to x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/explorer_no_results_found.tsx index 5eee341af6862..39975d05d1324 100644 --- a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/explorer_no_results_found.js +++ b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/explorer_no_results_found.tsx @@ -5,16 +5,23 @@ * 2.0. */ -/* - * React component for rendering EuiEmptyPrompt when no results were found. - */ - -import React from 'react'; +import React, { FC } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiEmptyPrompt } from '@elastic/eui'; -export const ExplorerNoResultsFound = ({ hasResults, selectedJobsRunning }) => { +export interface ExplorerNoResultsFoundProps { + hasResults: boolean; + selectedJobsRunning: boolean; +} + +/* + * React component for rendering EuiEmptyPrompt when no results were found. + */ +export const ExplorerNoResultsFound: FC = ({ + hasResults, + selectedJobsRunning, +}) => { const resultsHaveNoAnomalies = hasResults === true; const noResults = hasResults === false; return ( diff --git a/x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/index.js b/x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/index.js rename to x-pack/plugins/ml/public/application/explorer/components/explorer_no_results_found/index.ts diff --git a/x-pack/plugins/ml/public/application/explorer/components/explorer_query_bar/explorer_query_bar.tsx b/x-pack/plugins/ml/public/application/explorer/components/explorer_query_bar/explorer_query_bar.tsx index f57d2c1b01d98..afdef906c416b 100644 --- a/x-pack/plugins/ml/public/application/explorer/components/explorer_query_bar/explorer_query_bar.tsx +++ b/x-pack/plugins/ml/public/application/explorer/components/explorer_query_bar/explorer_query_bar.tsx @@ -10,22 +10,30 @@ import { EuiCode, EuiInputPopover } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { fromKueryExpression, luceneStringToDsl, toElasticsearchQuery } from '@kbn/es-query'; import { Query, QueryStringInput } from '../../../../../../../../src/plugins/data/public'; -import { DataView } from '../../../../../../../../src/plugins/data_views/common'; +import type { DataView } from '../../../../../../../../src/plugins/data_views/common'; import { SEARCH_QUERY_LANGUAGE, ErrorMessage } from '../../../../../common/constants/search'; -import { explorerService } from '../../explorer_dashboard_service'; import { InfluencersFilterQuery } from '../../../../../common/types/es_client'; +import { useAnomalyExplorerContext } from '../../anomaly_explorer_context'; export const DEFAULT_QUERY_LANG = SEARCH_QUERY_LANGUAGE.KUERY; +export interface KQLFilterSettings { + filterQuery: InfluencersFilterQuery; + queryString: string; + tableQueryString: string; + isAndOperator: boolean; + filteredFields: string[]; +} + export function getKqlQueryValues({ inputString, queryLanguage, indexPattern, }: { - inputString: string | { [key: string]: any }; + inputString: string | { [key: string]: unknown }; queryLanguage: string; indexPattern: DataView; -}): { clearSettings: boolean; settings: any } { +}): { clearSettings: boolean; settings: KQLFilterSettings } { let influencersFilterQuery: InfluencersFilterQuery = {}; const filteredFields: string[] = []; const ast = fromKueryExpression(inputString); @@ -58,8 +66,8 @@ export function getKqlQueryValues({ clearSettings, settings: { filterQuery: influencersFilterQuery, - queryString: inputString, - tableQueryString: inputString, + queryString: inputString as string, + tableQueryString: inputString as string, isAndOperator, filteredFields, }, @@ -88,7 +96,7 @@ function getInitSearchInputState({ interface ExplorerQueryBarProps { filterActive: boolean; - filterPlaceHolder: string; + filterPlaceHolder?: string; indexPattern: DataView; queryString?: string; updateLanguage: (language: string) => void; @@ -101,6 +109,8 @@ export const ExplorerQueryBar: FC = ({ queryString, updateLanguage, }) => { + const { anomalyExplorerCommonStateService } = useAnomalyExplorerContext(); + // The internal state of the input query bar updated on every key stroke. const [searchInput, setSearchInput] = useState( getInitSearchInputState({ filterActive, queryString }) @@ -130,9 +140,9 @@ export const ExplorerQueryBar: FC = ({ }); if (clearSettings === true) { - explorerService.clearInfluencerFilterSettings(); + anomalyExplorerCommonStateService.clearFilterSettings(); } else { - explorerService.setInfluencerFilterSettings(settings); + anomalyExplorerCommonStateService.setFilterSettings(settings); } } catch (e) { console.log('Invalid query syntax in search bar', e); // eslint-disable-line no-console diff --git a/x-pack/plugins/ml/public/application/explorer/components/index.js b/x-pack/plugins/ml/public/application/explorer/components/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/explorer/components/index.js rename to x-pack/plugins/ml/public/application/explorer/components/index.ts diff --git a/x-pack/plugins/ml/public/application/explorer/explorer.d.ts b/x-pack/plugins/ml/public/application/explorer/explorer.d.ts deleted file mode 100644 index 44238d4d5acf2..0000000000000 --- a/x-pack/plugins/ml/public/application/explorer/explorer.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { FC } from 'react'; -import { ExplorerState } from './reducers'; -import { AppStateSelectedCells } from './explorer_utils'; - -declare interface ExplorerProps { - explorerState: ExplorerState; - severity: number; - showCharts: boolean; - setSelectedCells: (swimlaneSelectedCells: AppStateSelectedCells) => void; -} - -export const Explorer: FC; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer.js b/x-pack/plugins/ml/public/application/explorer/explorer.js deleted file mode 100644 index b96cd164e3dcd..0000000000000 --- a/x-pack/plugins/ml/public/application/explorer/explorer.js +++ /dev/null @@ -1,520 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -/* - * React component for rendering Explorer dashboard swimlanes. - */ - -import PropTypes from 'prop-types'; -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; - -import { - htmlIdGenerator, - EuiCallOut, - EuiFlexGroup, - EuiFlexItem, - EuiHorizontalRule, - EuiIconTip, - EuiPageHeader, - EuiPageHeaderSection, - EuiSpacer, - EuiTitle, - EuiLoadingContent, - EuiPanel, - EuiAccordion, - EuiBadge, -} from '@elastic/eui'; - -import { AnnotationFlyout } from '../components/annotations/annotation_flyout'; -import { AnnotationsTable } from '../components/annotations/annotations_table'; -import { ExplorerNoJobsSelected, ExplorerNoResultsFound } from './components'; -import { InfluencersList } from '../components/influencers_list'; -import { explorerService } from './explorer_dashboard_service'; -import { AnomalyResultsViewSelector } from '../components/anomaly_results_view_selector'; -import { CheckboxShowCharts } from '../components/controls/checkbox_showcharts'; -import { JobSelector } from '../components/job_selector'; -import { SelectInterval } from '../components/controls/select_interval/select_interval'; -import { SelectSeverity } from '../components/controls/select_severity/select_severity'; -import { - ExplorerQueryBar, - getKqlQueryValues, - DEFAULT_QUERY_LANG, -} from './components/explorer_query_bar/explorer_query_bar'; -import { - getDateFormatTz, - removeFilterFromQueryString, - getQueryPattern, - escapeParens, - escapeDoubleQuotes, -} from './explorer_utils'; -import { AnomalyTimeline } from './anomaly_timeline'; - -import { FILTER_ACTION } from './explorer_constants'; - -// Explorer Charts -import { ExplorerChartsContainer } from './explorer_charts/explorer_charts_container'; - -// Anomalies Table -import { AnomaliesTable } from '../components/anomalies_table/anomalies_table'; - -// Anomalies Map -import { AnomaliesMap } from './anomalies_map'; - -import { getToastNotifications } from '../util/dependency_cache'; -import { ANOMALY_DETECTION_DEFAULT_TIME_RANGE } from '../../../common/constants/settings'; -import { withKibana } from '../../../../../../src/plugins/kibana_react/public'; -import { ML_APP_LOCATOR } from '../../../common/constants/locator'; -import { AnomalyContextMenu } from './anomaly_context_menu'; -import { isDefined } from '../../../common/types/guards'; -import { MlPageHeader } from '../components/page_header'; - -const ExplorerPage = ({ - children, - jobSelectorProps, - noInfluencersConfigured, - influencers, - filterActive, - filterPlaceHolder, - indexPattern, - queryString, - updateLanguage, -}) => ( - <> - - - - - - - - - - - - - - - {noInfluencersConfigured === false && influencers !== undefined ? ( - <> - - - - - ) : null} - - - {children} - -); - -export class ExplorerUI extends React.Component { - static propTypes = { - explorerState: PropTypes.object.isRequired, - setSelectedCells: PropTypes.func.isRequired, - severity: PropTypes.number.isRequired, - showCharts: PropTypes.bool.isRequired, - selectedJobsRunning: PropTypes.bool.isRequired, - }; - - state = { language: DEFAULT_QUERY_LANG }; - htmlIdGen = htmlIdGenerator(); - - componentDidMount() { - const { invalidTimeRangeError } = this.props; - if (invalidTimeRangeError) { - const toastNotifications = getToastNotifications(); - toastNotifications.addWarning( - i18n.translate('xpack.ml.explorer.invalidTimeRangeInUrlCallout', { - defaultMessage: - 'The time filter was changed to the full range due to an invalid default time filter. Check the advanced settings for {field}.', - values: { - field: ANOMALY_DETECTION_DEFAULT_TIME_RANGE, - }, - }) - ); - } - } - - // Escape regular parens from fieldName as that portion of the query is not wrapped in double quotes - // and will cause a syntax error when called with getKqlQueryValues - applyFilter = (fieldName, fieldValue, action) => { - const { filterActive, indexPattern, queryString } = this.props.explorerState; - let newQueryString = ''; - const operator = 'and '; - const sanitizedFieldName = escapeParens(fieldName); - const sanitizedFieldValue = escapeDoubleQuotes(fieldValue); - - if (action === FILTER_ACTION.ADD) { - // Don't re-add if already exists in the query - const queryPattern = getQueryPattern(fieldName, fieldValue); - if (queryString.match(queryPattern) !== null) { - return; - } - newQueryString = `${ - queryString ? `${queryString} ${operator}` : '' - }${sanitizedFieldName}:"${sanitizedFieldValue}"`; - } else if (action === FILTER_ACTION.REMOVE) { - if (filterActive === false) { - return; - } else { - newQueryString = removeFilterFromQueryString( - queryString, - sanitizedFieldName, - sanitizedFieldValue - ); - } - } - - try { - const { clearSettings, settings } = getKqlQueryValues({ - inputString: `${newQueryString}`, - queryLanguage: this.state.language, - indexPattern, - }); - - if (clearSettings === true) { - explorerService.clearInfluencerFilterSettings(); - } else { - explorerService.setInfluencerFilterSettings(settings); - } - } catch (e) { - console.log('Invalid query syntax from table', e); // eslint-disable-line no-console - - const toastNotifications = getToastNotifications(); - toastNotifications.addDanger( - i18n.translate('xpack.ml.explorer.invalidKuerySyntaxErrorMessageFromTable', { - defaultMessage: - 'Invalid syntax in query bar. The input must be valid Kibana Query Language (KQL)', - }) - ); - } - }; - - updateLanguage = (language) => this.setState({ language }); - - render() { - const { share, charts: chartsService } = this.props.kibana.services; - - const mlLocator = share.url.locators.get(ML_APP_LOCATOR); - - const { - showCharts, - severity, - stoppedPartitions, - selectedJobsRunning, - timefilter, - timeBuckets, - } = this.props; - - const { - annotations, - chartsData, - filterActive, - filterPlaceHolder, - indexPattern, - influencers, - loading, - noInfluencersConfigured, - overallSwimlaneData, - queryString, - selectedCells, - selectedJobs, - tableData, - swimLaneSeverity, - } = this.props.explorerState; - const { annotationsData, totalCount: allAnnotationsCnt, error: annotationsError } = annotations; - - const annotationsCnt = Array.isArray(annotationsData) ? annotationsData.length : 0; - const badge = - allAnnotationsCnt > annotationsCnt ? ( - - - - ) : ( - - - - ); - - const jobSelectorProps = { - dateFormatTz: getDateFormatTz(), - }; - - const noJobsSelected = selectedJobs === null || selectedJobs.length === 0; - const hasResults = overallSwimlaneData.points && overallSwimlaneData.points.length > 0; - const hasResultsWithAnomalies = - (hasResults && overallSwimlaneData.points.some((v) => v.value > 0)) || - tableData.anomalies?.length > 0; - - const hasActiveFilter = isDefined(swimLaneSeverity); - - if (noJobsSelected && !loading) { - return ( - - - - ); - } - - if (!hasResultsWithAnomalies && !loading && !hasActiveFilter) { - return ( - - - - ); - } - const mainColumnWidthClassName = noInfluencersConfigured === true ? 'col-xs-12' : 'col-xs-10'; - const mainColumnClasses = `column ${mainColumnWidthClassName}`; - - const bounds = timefilter.getActiveBounds(); - const selectedJobIds = Array.isArray(selectedJobs) ? selectedJobs.map((job) => job.id) : []; - return ( - -
- {noInfluencersConfigured && ( -
-
- )} - - {noInfluencersConfigured === false && ( -
- - -

- - - } - position="right" - /> -

-
- {loading ? ( - - ) : ( - - )} -
- )} - -
- - {stoppedPartitions && ( - - } - /> - )} - - - - - - {annotationsError !== undefined && ( - <> - -

- -

-
- - -

{annotationsError}

-
-
- - - )} - {loading === false && tableData.anomalies?.length ? ( - - ) : null} - {annotationsCnt > 0 && ( - <> - - -

- -

- - } - > - <> - - -
-
- - - - )} - {loading === false && ( - - - - -

- -

-
-
- - - - -
- - - - - - - - - {chartsData.seriesToPlot.length > 0 && selectedCells !== undefined && ( - - - - )} - - - - -
- {showCharts ? ( - - ) : null} -
- - -
- )} -
-
-
- ); - } -} - -export const Explorer = withKibana(ExplorerUI); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer.tsx b/x-pack/plugins/ml/public/application/explorer/explorer.tsx new file mode 100644 index 0000000000000..3d9c23b97de0c --- /dev/null +++ b/x-pack/plugins/ml/public/application/explorer/explorer.tsx @@ -0,0 +1,547 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { + htmlIdGenerator, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiIconTip, + EuiPageHeader, + EuiPageHeaderSection, + EuiSpacer, + EuiTitle, + EuiLoadingContent, + EuiPanel, + EuiAccordion, + EuiBadge, +} from '@elastic/eui'; +import useObservable from 'react-use/lib/useObservable'; +import { AnnotationFlyout } from '../components/annotations/annotation_flyout'; +// @ts-ignore +import { AnnotationsTable } from '../components/annotations/annotations_table'; +import { ExplorerNoJobsSelected, ExplorerNoResultsFound } from './components'; +import { InfluencersList } from '../components/influencers_list'; +import { CheckboxShowCharts } from '../components/controls/checkbox_showcharts'; +import { JobSelector } from '../components/job_selector'; +import { SelectInterval } from '../components/controls/select_interval/select_interval'; +import { SelectSeverity } from '../components/controls/select_severity/select_severity'; +import { + ExplorerQueryBar, + getKqlQueryValues, + DEFAULT_QUERY_LANG, +} from './components/explorer_query_bar/explorer_query_bar'; +import { + getDateFormatTz, + removeFilterFromQueryString, + getQueryPattern, + escapeParens, + escapeDoubleQuotes, + OverallSwimlaneData, + AppStateSelectedCells, +} from './explorer_utils'; +import { AnomalyTimeline } from './anomaly_timeline'; +import { FILTER_ACTION, FilterAction } from './explorer_constants'; +// Explorer Charts +// @ts-ignore +import { ExplorerChartsContainer } from './explorer_charts/explorer_charts_container'; +// Anomalies Table +// @ts-ignore +import { AnomaliesTable } from '../components/anomalies_table/anomalies_table'; +// Anomalies Map +import { AnomaliesMap } from './anomalies_map'; +import { ANOMALY_DETECTION_DEFAULT_TIME_RANGE } from '../../../common/constants/settings'; +import { AnomalyContextMenu } from './anomaly_context_menu'; +import { isDefined } from '../../../common/types/guards'; +import type { DataView } from '../../../../../../src/plugins/data_views/common'; +import type { JobSelectorProps } from '../components/job_selector/job_selector'; +import type { ExplorerState } from './reducers'; +import type { TimefilterContract } from '../../../../../../src/plugins/data/public'; +import type { TimeBuckets } from '../util/time_buckets'; +import { useToastNotificationService } from '../services/toast_notification_service'; +import { useMlKibana, useMlLocator } from '../contexts/kibana'; +import { useAnomalyExplorerContext } from './anomaly_explorer_context'; + +interface ExplorerPageProps { + jobSelectorProps: JobSelectorProps; + noInfluencersConfigured?: boolean; + influencers?: ExplorerState['influencers']; + filterActive?: boolean; + filterPlaceHolder?: string; + indexPattern?: DataView; + queryString?: string; + updateLanguage?: (language: string) => void; +} + +const ExplorerPage: FC = ({ + children, + jobSelectorProps, + noInfluencersConfigured, + influencers, + filterActive, + filterPlaceHolder, + indexPattern, + queryString, + updateLanguage, +}) => ( + <> + + + + + {noInfluencersConfigured === false && + influencers !== undefined && + indexPattern && + updateLanguage ? ( + <> + + + + + ) : null} + + + {children} + +); + +interface ExplorerUIProps { + explorerState: ExplorerState; + severity: number; + showCharts: boolean; + selectedJobsRunning: boolean; + overallSwimlaneData: OverallSwimlaneData | null; + invalidTimeRangeError?: boolean; + stoppedPartitions?: string[]; + // TODO Remove + timefilter: TimefilterContract; + // TODO Remove + timeBuckets: TimeBuckets; + selectedCells: AppStateSelectedCells | undefined; + swimLaneSeverity?: number; +} + +export const Explorer: FC = ({ + invalidTimeRangeError, + showCharts, + severity, + stoppedPartitions, + selectedJobsRunning, + timefilter, + timeBuckets, + selectedCells, + swimLaneSeverity, + explorerState, + overallSwimlaneData, +}) => { + const { displayWarningToast, displayDangerToast } = useToastNotificationService(); + const { anomalyTimelineStateService, anomalyExplorerCommonStateService } = + useAnomalyExplorerContext(); + + const htmlIdGen = useMemo(() => htmlIdGenerator(), []); + + const [language, updateLanguage] = useState(DEFAULT_QUERY_LANG); + + const filterSettings = useObservable( + anomalyExplorerCommonStateService.getFilterSettings$(), + anomalyExplorerCommonStateService.getFilterSettings() + ); + + const selectedJobs = useObservable( + anomalyExplorerCommonStateService.getSelectedJobs$(), + anomalyExplorerCommonStateService.getSelectedJobs() + ); + + const applyFilter = useCallback( + (fieldName: string, fieldValue: string, action: FilterAction) => { + const { filterActive, queryString } = filterSettings; + + const indexPattern = explorerState.indexPattern; + + let newQueryString = ''; + const operator = 'and '; + const sanitizedFieldName = escapeParens(fieldName); + const sanitizedFieldValue = escapeDoubleQuotes(fieldValue); + + if (action === FILTER_ACTION.ADD) { + // Don't re-add if already exists in the query + const queryPattern = getQueryPattern(fieldName, fieldValue); + if (queryString.match(queryPattern) !== null) { + return; + } + newQueryString = `${ + queryString ? `${queryString} ${operator}` : '' + }${sanitizedFieldName}:"${sanitizedFieldValue}"`; + } else if (action === FILTER_ACTION.REMOVE) { + if (filterActive === false) { + return; + } else { + newQueryString = removeFilterFromQueryString( + queryString, + sanitizedFieldName, + sanitizedFieldValue + ); + } + } + + try { + const { clearSettings, settings } = getKqlQueryValues({ + inputString: `${newQueryString}`, + queryLanguage: language, + indexPattern: indexPattern as DataView, + }); + + if (clearSettings === true) { + anomalyExplorerCommonStateService.clearFilterSettings(); + } else { + anomalyExplorerCommonStateService.setFilterSettings(settings); + } + } catch (e) { + console.log('Invalid query syntax from table', e); // eslint-disable-line no-console + + displayDangerToast( + i18n.translate('xpack.ml.explorer.invalidKuerySyntaxErrorMessageFromTable', { + defaultMessage: + 'Invalid syntax in query bar. The input must be valid Kibana Query Language (KQL)', + }) + ); + } + }, + [explorerState, language, filterSettings] + ); + + useEffect(() => { + if (invalidTimeRangeError) { + displayWarningToast( + i18n.translate('xpack.ml.explorer.invalidTimeRangeInUrlCallout', { + defaultMessage: + 'The time filter was changed to the full range due to an invalid default time filter. Check the advanced settings for {field}.', + values: { + field: ANOMALY_DETECTION_DEFAULT_TIME_RANGE, + }, + }) + ); + } + }, []); + + const { + services: { charts: chartsService }, + } = useMlKibana(); + + const mlLocator = useMlLocator(); + + const { + annotations, + chartsData, + filterPlaceHolder, + indexPattern, + influencers, + loading, + noInfluencersConfigured, + tableData, + } = explorerState; + + const { filterActive, queryString } = filterSettings; + + const isOverallSwimLaneLoading = useObservable( + anomalyTimelineStateService.isOverallSwimLaneLoading$(), + true + ); + const isViewBySwimLaneLoading = useObservable( + anomalyTimelineStateService.isViewBySwimLaneLoading$(), + true + ); + + const isDataLoading = loading || isOverallSwimLaneLoading || isViewBySwimLaneLoading; + + const swimLaneBucketInterval = useObservable( + anomalyTimelineStateService.getSwimLaneBucketInterval$(), + anomalyTimelineStateService.getSwimLaneBucketInterval() + ); + + const { annotationsData, totalCount: allAnnotationsCnt, error: annotationsError } = annotations; + + const annotationsCnt = Array.isArray(annotationsData) ? annotationsData.length : 0; + const badge = + (allAnnotationsCnt ?? 0) > annotationsCnt ? ( + + + + ) : ( + + + + ); + + const jobSelectorProps = { + dateFormatTz: getDateFormatTz(), + } as JobSelectorProps; + + const noJobsSelected = !selectedJobs || selectedJobs.length === 0; + const hasResults: boolean = + !!overallSwimlaneData?.points && overallSwimlaneData.points.length > 0; + const hasResultsWithAnomalies = + (hasResults && overallSwimlaneData!.points.some((v) => v.value > 0)) || + tableData.anomalies?.length > 0; + + const hasActiveFilter = isDefined(swimLaneSeverity); + + if (noJobsSelected && !loading) { + return ( + + + + ); + } + + if (!hasResultsWithAnomalies && !isDataLoading && !hasActiveFilter) { + return ( + + + + ); + } + const mainColumnWidthClassName = noInfluencersConfigured === true ? 'col-xs-12' : 'col-xs-10'; + const mainColumnClasses = `column ${mainColumnWidthClassName}`; + + const bounds = timefilter.getActiveBounds(); + const selectedJobIds = Array.isArray(selectedJobs) ? selectedJobs.map((job) => job.id) : []; + + return ( + +
+ {noInfluencersConfigured && ( +
+
+ )} + + {noInfluencersConfigured === false && ( +
+ + +

+ + + } + position="right" + /> +

+
+ {loading ? ( + + ) : ( + + )} +
+ )} + +
+ + {stoppedPartitions && ( + + } + /> + )} + + + + + + {annotationsError !== undefined && ( + <> + +

+ +

+
+ + +

{annotationsError}

+
+
+ + + )} + {loading === false && tableData.anomalies?.length ? ( + + ) : null} + {annotationsCnt > 0 && ( + <> + + +

+ +

+ + } + > + <> + + +
+
+ + + + )} + {loading === false && ( + + + + +

+ +

+
+
+ + + + +
+ + + + + + + + + {chartsData.seriesToPlot.length > 0 && selectedCells !== undefined && ( + + + + )} + + + + +
+ {showCharts ? ( + + ) : null} +
+ + +
+ )} +
+
+
+ ); +}; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts b/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts index cd01de31e5e60..0a8f61fb80ff4 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_constants.ts @@ -25,22 +25,14 @@ export const EXPLORER_ACTION = { SET_CHARTS: 'setCharts', SET_CHARTS_DATA_LOADING: 'setChartsDataLoading', SET_EXPLORER_DATA: 'setExplorerData', - SET_FILTER_DATA: 'setFilterData', - SET_INFLUENCER_FILTER_SETTINGS: 'setInfluencerFilterSettings', - SET_SELECTED_CELLS: 'setSelectedCells', - SET_SWIMLANE_CONTAINER_WIDTH: 'setSwimlaneContainerWidth', - SET_VIEW_BY_SWIMLANE_FIELD_NAME: 'setViewBySwimlaneFieldName', - SET_VIEW_BY_SWIMLANE_LOADING: 'setViewBySwimlaneLoading', - SET_VIEW_BY_PER_PAGE: 'setViewByPerPage', - SET_VIEW_BY_FROM_PAGE: 'setViewByFromPage', - SET_SWIM_LANE_SEVERITY: 'setSwimLaneSeverity', - SET_SHOW_CHARTS: 'setShowCharts', }; export const FILTER_ACTION = { ADD: '+', REMOVE: '-', -}; +} as const; + +export type FilterAction = typeof FILTER_ACTION[keyof typeof FILTER_ACTION]; export const SWIMLANE_TYPE = { OVERALL: 'overall', diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts b/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts index deb1beed2d146..0517f80e27429 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer_dashboard_service.ts @@ -11,20 +11,13 @@ */ import { isEqual } from 'lodash'; - import { from, isObservable, Observable, Subject } from 'rxjs'; -import { distinctUntilChanged, flatMap, map, scan, shareReplay } from 'rxjs/operators'; - +import { distinctUntilChanged, flatMap, scan, shareReplay } from 'rxjs/operators'; import { DeepPartial } from '../../../common/types/common'; - import { jobSelectionActionCreator } from './actions'; -import { ExplorerChartsData } from './explorer_charts/explorer_charts_container_service'; +import type { ExplorerChartsData } from './explorer_charts/explorer_charts_container_service'; import { EXPLORER_ACTION } from './explorer_constants'; -import { AppStateSelectedCells } from './explorer_utils'; import { explorerReducer, getExplorerDefaultState, ExplorerState } from './reducers'; -import { ExplorerAppState } from '../../../common/types/locator'; - -export const ALLOW_CELL_RANGE_SELECTION = true; type ExplorerAction = Action | Observable; export const explorerAction$ = new Subject(); @@ -51,67 +44,13 @@ const explorerState$: Observable = explorerFilteredAction$.pipe( shareReplay(1) ); -const explorerAppState$: Observable = explorerState$.pipe( - map((state: ExplorerState): ExplorerAppState => { - const appState: ExplorerAppState = { - mlExplorerFilter: {}, - mlExplorerSwimlane: {}, - }; - - if (state.selectedCells !== undefined) { - const swimlaneSelectedCells = state.selectedCells; - appState.mlExplorerSwimlane.selectedType = swimlaneSelectedCells.type; - appState.mlExplorerSwimlane.selectedLanes = swimlaneSelectedCells.lanes; - appState.mlExplorerSwimlane.selectedTimes = swimlaneSelectedCells.times; - appState.mlExplorerSwimlane.showTopFieldValues = swimlaneSelectedCells.showTopFieldValues; - } - - if (state.viewBySwimlaneFieldName !== undefined) { - appState.mlExplorerSwimlane.viewByFieldName = state.viewBySwimlaneFieldName; - } - - if (state.viewByFromPage !== undefined) { - appState.mlExplorerSwimlane.viewByFromPage = state.viewByFromPage; - } - - if (state.viewByPerPage !== undefined) { - appState.mlExplorerSwimlane.viewByPerPage = state.viewByPerPage; - } - - if (state.swimLaneSeverity !== undefined) { - appState.mlExplorerSwimlane.severity = state.swimLaneSeverity; - } - - if (state.showCharts !== undefined) { - appState.mlShowCharts = state.showCharts; - } - - if (state.filterActive) { - appState.mlExplorerFilter.influencersFilterQuery = state.influencersFilterQuery; - appState.mlExplorerFilter.filterActive = state.filterActive; - appState.mlExplorerFilter.filteredFields = state.filteredFields; - appState.mlExplorerFilter.queryString = state.queryString; - } - - return appState; - }), - distinctUntilChanged(isEqual) -); - const setExplorerDataActionCreator = (payload: DeepPartial) => ({ type: EXPLORER_ACTION.SET_EXPLORER_DATA, payload, }); -const setFilterDataActionCreator = ( - payload: Partial> -) => ({ - type: EXPLORER_ACTION.SET_FILTER_DATA, - payload, -}); // Export observable state and action dispatchers as service export const explorerService = { - appState$: explorerAppState$, state$: explorerState$, clearExplorerData: () => { explorerAction$.next({ type: EXPLORER_ACTION.CLEAR_EXPLORER_DATA }); @@ -128,51 +67,12 @@ export const explorerService = { setCharts: (payload: ExplorerChartsData) => { explorerAction$.next({ type: EXPLORER_ACTION.SET_CHARTS, payload }); }, - setInfluencerFilterSettings: (payload: any) => { - explorerAction$.next({ - type: EXPLORER_ACTION.SET_INFLUENCER_FILTER_SETTINGS, - payload, - }); - }, - setSelectedCells: (payload: AppStateSelectedCells | undefined) => { - explorerAction$.next({ - type: EXPLORER_ACTION.SET_SELECTED_CELLS, - payload, - }); - }, setExplorerData: (payload: DeepPartial) => { explorerAction$.next(setExplorerDataActionCreator(payload)); }, - setFilterData: (payload: Partial>) => { - explorerAction$.next(setFilterDataActionCreator(payload)); - }, setChartsDataLoading: () => { explorerAction$.next({ type: EXPLORER_ACTION.SET_CHARTS_DATA_LOADING }); }, - setSwimlaneContainerWidth: (payload: number) => { - explorerAction$.next({ - type: EXPLORER_ACTION.SET_SWIMLANE_CONTAINER_WIDTH, - payload, - }); - }, - setViewBySwimlaneFieldName: (payload: string) => { - explorerAction$.next({ type: EXPLORER_ACTION.SET_VIEW_BY_SWIMLANE_FIELD_NAME, payload }); - }, - setViewBySwimlaneLoading: (payload: any) => { - explorerAction$.next({ type: EXPLORER_ACTION.SET_VIEW_BY_SWIMLANE_LOADING, payload }); - }, - setViewByFromPage: (payload: number) => { - explorerAction$.next({ type: EXPLORER_ACTION.SET_VIEW_BY_FROM_PAGE, payload }); - }, - setViewByPerPage: (payload: number) => { - explorerAction$.next({ type: EXPLORER_ACTION.SET_VIEW_BY_PER_PAGE, payload }); - }, - setSwimLaneSeverity: (payload: number) => { - explorerAction$.next({ type: EXPLORER_ACTION.SET_SWIM_LANE_SEVERITY, payload }); - }, - setShowCharts: (payload: boolean) => { - explorerAction$.next({ type: EXPLORER_ACTION.SET_SHOW_CHARTS, payload }); - }, }; export type ExplorerService = typeof explorerService; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_utils.d.ts b/x-pack/plugins/ml/public/application/explorer/explorer_utils.d.ts deleted file mode 100644 index 5dba7a9f5a931..0000000000000 --- a/x-pack/plugins/ml/public/application/explorer/explorer_utils.d.ts +++ /dev/null @@ -1,208 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { AnnotationsTable } from '../../../common/types/annotations'; -import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; -import { SwimlaneType } from './explorer_constants'; -import { TimeRangeBounds } from '../util/time_buckets'; -import { RecordForInfluencer } from '../services/results_service/results_service'; -import { InfluencersFilterQuery } from '../../../common/types/es_client'; -import { MlResultsService } from '../services/results_service'; -import { EntityField } from '../../../common/util/anomaly_utils'; - -interface ClearedSelectedAnomaliesState { - selectedCells: undefined; - viewByLoadedForTimeFormatted: null; -} - -export declare const getClearedSelectedAnomaliesState: () => ClearedSelectedAnomaliesState; - -export interface SwimlanePoint { - laneLabel: string; - time: number; - value: number; -} - -export declare interface SwimlaneData { - fieldName?: string; - laneLabels: string[]; - points: SwimlanePoint[]; - interval: number; -} -interface ChartRecord extends RecordForInfluencer { - function: string; -} - -export declare interface OverallSwimlaneData extends SwimlaneData { - /** - * Earliest timestampt in seconds - */ - earliest: number; - /** - * Latest timestampt in seconds - */ - latest: number; -} - -export interface ViewBySwimLaneData extends OverallSwimlaneData { - cardinality: number; -} - -export declare const getDateFormatTz: () => any; - -export declare const getDefaultSwimlaneData: () => SwimlaneData; - -export declare const getInfluencers: (selectedJobs: any[]) => string[]; - -export declare const getSelectionJobIds: ( - selectedCells: AppStateSelectedCells | undefined, - selectedJobs: ExplorerJob[] -) => string[]; - -export declare const getSelectionInfluencers: ( - selectedCells: AppStateSelectedCells | undefined, - fieldName: string -) => EntityField[]; - -interface SelectionTimeRange { - earliestMs: number; - latestMs: number; -} - -export declare const getSelectionTimeRange: ( - selectedCells: AppStateSelectedCells | undefined, - interval: number, - bounds: TimeRangeBounds -) => SelectionTimeRange; - -export declare const getSwimlaneBucketInterval: ( - selectedJobs: ExplorerJob[], - swimlaneContainerWidth: number -) => any; - -interface ViewBySwimlaneOptionsArgs { - currentViewBySwimlaneFieldName: string | undefined; - filterActive: boolean; - filteredFields: any[]; - isAndOperator: boolean; - selectedCells: AppStateSelectedCells; - selectedJobs: ExplorerJob[]; -} - -interface ViewBySwimlaneOptions { - viewBySwimlaneFieldName: string; - viewBySwimlaneOptions: string[]; -} - -export declare const getViewBySwimlaneOptions: ( - arg: ViewBySwimlaneOptionsArgs -) => ViewBySwimlaneOptions; - -export declare interface ExplorerJob { - id: string; - selected: boolean; - bucketSpanSeconds: number; -} - -export declare const createJobs: (jobs: CombinedJob[]) => ExplorerJob[]; - -declare interface SwimlaneBounds { - earliest: number; - latest: number; -} - -export declare const loadOverallAnnotations: ( - selectedJobs: ExplorerJob[], - interval: number, - bounds: TimeRangeBounds -) => Promise; - -export declare const loadAnnotationsTableData: ( - selectedCells: AppStateSelectedCells | undefined, - selectedJobs: ExplorerJob[], - interval: number, - bounds: TimeRangeBounds -) => Promise; - -export declare interface AnomaliesTableData { - anomalies: any[]; - interval: number; - examplesByJobId: string[]; - showViewSeriesLink: boolean; - jobIds: string[]; -} - -export declare const loadAnomaliesTableData: ( - selectedCells: AppStateSelectedCells | undefined, - selectedJobs: ExplorerJob[], - dateFormatTz: any, - interval: number, - bounds: TimeRangeBounds, - fieldName: string, - tableInterval: string, - tableSeverity: number, - influencersFilterQuery: InfluencersFilterQuery -) => Promise; - -export declare const loadDataForCharts: ( - mlResultsService: MlResultsService, - jobIds: string[], - earliestMs: number, - latestMs: number, - influencers: any[], - selectedCells: AppStateSelectedCells | undefined, - influencersFilterQuery: InfluencersFilterQuery, - // choose whether or not to keep track of the request that could be out of date - takeLatestOnly: boolean -) => Promise; - -export declare const loadFilteredTopInfluencers: ( - mlResultsService: MlResultsService, - jobIds: string[], - earliestMs: number, - latestMs: number, - records: any[], - influencers: any[], - noInfluencersConfigured: boolean, - influencersFilterQuery: InfluencersFilterQuery -) => Promise; - -export declare const loadTopInfluencers: ( - mlResultsService: MlResultsService, - selectedJobIds: string[], - earliestMs: number, - latestMs: number, - influencers: any[], - noInfluencersConfigured?: boolean, - influencersFilterQuery?: any -) => Promise; - -declare interface LoadOverallDataResponse { - loading: boolean; - overallSwimlaneData: OverallSwimlaneData; -} - -export declare interface FilterData { - influencersFilterQuery: InfluencersFilterQuery; - filterActive: boolean; - filteredFields: string[]; - queryString: string; -} - -export declare interface AppStateSelectedCells { - type: SwimlaneType; - lanes: string[]; - times: [number, number]; - showTopFieldValues?: boolean; - viewByFieldName?: string; -} - -export declare const removeFilterFromQueryString: ( - currentQueryString: string, - fieldName: string, - fieldValue: string -) => string; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_utils.js b/x-pack/plugins/ml/public/application/explorer/explorer_utils.ts similarity index 65% rename from x-pack/plugins/ml/public/application/explorer/explorer_utils.js rename to x-pack/plugins/ml/public/application/explorer/explorer_utils.ts index af2b9b07a43fb..17406d7b5eadc 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_utils.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_utils.ts @@ -9,14 +9,14 @@ * utils for Anomaly Explorer. */ -import { get, union, sortBy, uniq } from 'lodash'; +import { get, union, uniq } from 'lodash'; import moment from 'moment-timezone'; import { ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE, ANOMALIES_TABLE_DEFAULT_QUERY_SIZE, } from '../../../common/constants/search'; -import { getEntityFieldList } from '../../../common/util/anomaly_utils'; +import { EntityField, getEntityFieldList } from '../../../common/util/anomaly_utils'; import { extractErrorMessage } from '../../../common/util/errors'; import { isSourceDataChartableForDetector, @@ -26,34 +26,102 @@ import { import { parseInterval } from '../../../common/util/parse_interval'; import { ml } from '../services/ml_api_service'; import { mlJobService } from '../services/job_service'; -import { getTimeBucketsFromCache } from '../util/time_buckets'; -import { getTimefilter, getUiSettings } from '../util/dependency_cache'; +import { getUiSettings } from '../util/dependency_cache'; import { MAX_CATEGORY_EXAMPLES, MAX_INFLUENCER_FIELD_VALUES, SWIMLANE_TYPE, + SwimlaneType, VIEW_BY_JOB_LABEL, } from './explorer_constants'; +import type { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; +import { MlResultsService } from '../services/results_service'; +import { InfluencersFilterQuery } from '../../../common/types/es_client'; +import { TimeRangeBounds } from '../util/time_buckets'; +import { Annotations, AnnotationsTable } from '../../../common/types/annotations'; +import { Influencer } from '../../../common/types/anomalies'; +import { RecordForInfluencer } from '../services/results_service/results_service'; + +export interface ExplorerJob { + id: string; + selected: boolean; + bucketSpanSeconds: number; +} + +interface ClearedSelectedAnomaliesState { + selectedCells: undefined; +} + +export interface SwimlanePoint { + laneLabel: string; + time: number; + value: number; +} + +export interface SwimlaneData { + fieldName?: string; + laneLabels: string[]; + points: SwimlanePoint[]; + interval: number; +} + +export interface AppStateSelectedCells { + type: SwimlaneType; + lanes: string[]; + times: [number, number]; + showTopFieldValues?: boolean; + viewByFieldName?: string; +} + +interface SelectionTimeRange { + earliestMs: number; + latestMs: number; +} + +export interface AnomaliesTableData { + anomalies: any[]; + interval: number; + examplesByJobId: string[]; + showViewSeriesLink: boolean; + jobIds: string[]; +} + +export interface ChartRecord extends RecordForInfluencer { + function: string; +} + +export interface OverallSwimlaneData extends SwimlaneData { + /** + * Earliest timestamp in seconds + */ + earliest: number; + /** + * Latest timestamp in seconds + */ + latest: number; +} + +export interface ViewBySwimLaneData extends OverallSwimlaneData { + cardinality: number; +} // create new job objects based on standard job config objects // new job objects just contain job id, bucket span in seconds and a selected flag. -export function createJobs(jobs) { +export function createJobs(jobs: CombinedJob[]): ExplorerJob[] { return jobs.map((job) => { const bucketSpan = parseInterval(job.analysis_config.bucket_span); - return { id: job.job_id, selected: false, bucketSpanSeconds: bucketSpan.asSeconds() }; + return { id: job.job_id, selected: false, bucketSpanSeconds: bucketSpan!.asSeconds() }; }); } -export function getClearedSelectedAnomaliesState() { +export function getClearedSelectedAnomaliesState(): ClearedSelectedAnomaliesState { return { selectedCells: undefined, - viewByLoadedForTimeFormatted: null, - swimlaneLimit: undefined, }; } -export function getDefaultSwimlaneData() { +export function getDefaultSwimlaneData(): SwimlaneData { return { fieldName: '', laneLabels: [], @@ -63,18 +131,18 @@ export function getDefaultSwimlaneData() { } export async function loadFilteredTopInfluencers( - mlResultsService, - jobIds, - earliestMs, - latestMs, - records, - influencers, - noInfluencersConfigured, - influencersFilterQuery -) { + mlResultsService: MlResultsService, + jobIds: string[], + earliestMs: number, + latestMs: number, + records: any[], + influencers: any[], + noInfluencersConfigured: boolean, + influencersFilterQuery: InfluencersFilterQuery +): Promise { // Filter the Top Influencers list to show just the influencers from // the records in the selected time range. - const recordInfluencersByName = {}; + const recordInfluencersByName: Record = {}; // Add the specified influencer(s) to ensure they are used in the filter // even if their influencer score for the selected time range is zero. @@ -88,7 +156,7 @@ export async function loadFilteredTopInfluencers( // Add the influencers from the top scoring anomalies. records.forEach((record) => { - const influencersByName = record.influencers || []; + const influencersByName: Influencer[] = record.influencers || []; influencersByName.forEach((influencer) => { const fieldName = influencer.influencer_field_name; const fieldValues = influencer.influencer_field_values; @@ -99,13 +167,13 @@ export async function loadFilteredTopInfluencers( }); }); - const uniqValuesByName = {}; + const uniqValuesByName: Record = {}; Object.keys(recordInfluencersByName).forEach((fieldName) => { const fieldValues = recordInfluencersByName[fieldName]; uniqValuesByName[fieldName] = uniq(fieldValues); }); - const filterInfluencers = []; + const filterInfluencers: EntityField[] = []; Object.keys(uniqValuesByName).forEach((fieldName) => { // Find record influencers with the same field name as the clicked on cell(s). const matchingFieldName = influencers.find((influencer) => { @@ -123,7 +191,7 @@ export async function loadFilteredTopInfluencers( } }); - return await loadTopInfluencers( + return (await loadTopInfluencers( mlResultsService, jobIds, earliestMs, @@ -131,11 +199,11 @@ export async function loadFilteredTopInfluencers( filterInfluencers, noInfluencersConfigured, influencersFilterQuery - ); + )) as any[]; } -export function getInfluencers(selectedJobs = []) { - const influencers = []; +export function getInfluencers(selectedJobs: any[]): string[] { + const influencers: string[] = []; selectedJobs.forEach((selectedJob) => { const job = mlJobService.getJob(selectedJob.id); if (job !== undefined && job.analysis_config && job.analysis_config.influencers) { @@ -145,7 +213,7 @@ export function getInfluencers(selectedJobs = []) { return influencers; } -export function getDateFormatTz() { +export function getDateFormatTz(): string { const uiSettings = getUiSettings(); // Pass the timezone to the server for use when aggregating anomalies (by day / hour) for the table. const tzConfig = uiSettings.get('dateFormat:tz'); @@ -174,29 +242,39 @@ export function getFieldsByJob() { reducedfieldsForJob.push(detector.by_field_name); } return reducedfieldsForJob; - }, []) + }, [] as string[]) .concat(influencers); reducedFieldsByJob[job.job_id] = uniq(fieldsForJob); reducedFieldsByJob['*'] = union(reducedFieldsByJob['*'], reducedFieldsByJob[job.job_id]); return reducedFieldsByJob; }, - { '*': [] } + { '*': [] } as Record ); } -export function getSelectionTimeRange(selectedCells, interval, bounds) { +export function getSelectionTimeRange( + selectedCells: AppStateSelectedCells | undefined, + interval: number, + bounds: TimeRangeBounds +): SelectionTimeRange { // Returns the time range of the cell(s) currently selected in the swimlane. // If no cell(s) are currently selected, returns the dashboard time range. - let earliestMs = bounds.min.valueOf(); - let latestMs = bounds.max.valueOf(); + + // TODO check why this code always expect both min and max defined. + const requiredBounds = bounds as Required; + + let earliestMs = requiredBounds.min.valueOf(); + let latestMs = requiredBounds.max.valueOf(); if (selectedCells !== undefined && selectedCells.times !== undefined) { // time property of the cell data is an array, with the elements being // the start times of the first and last cell selected. earliestMs = - selectedCells.times[0] !== undefined ? selectedCells.times[0] * 1000 : bounds.min.valueOf(); - latestMs = bounds.max.valueOf(); + selectedCells.times[0] !== undefined + ? selectedCells.times[0] * 1000 + : requiredBounds.min.valueOf(); + latestMs = requiredBounds.max.valueOf(); if (selectedCells.times[1] !== undefined) { // Subtract 1 ms so search does not include start of next bucket. latestMs = selectedCells.times[1] * 1000 - 1; @@ -206,7 +284,10 @@ export function getSelectionTimeRange(selectedCells, interval, bounds) { return { earliestMs, latestMs }; } -export function getSelectionInfluencers(selectedCells, fieldName) { +export function getSelectionInfluencers( + selectedCells: AppStateSelectedCells | undefined, + fieldName: string +): EntityField[] { if ( selectedCells !== undefined && selectedCells.type !== SWIMLANE_TYPE.OVERALL && @@ -219,7 +300,10 @@ export function getSelectionInfluencers(selectedCells, fieldName) { return []; } -export function getSelectionJobIds(selectedCells, selectedJobs) { +export function getSelectionJobIds( + selectedCells: AppStateSelectedCells | undefined, + selectedJobs: ExplorerJob[] +): string[] { if ( selectedCells !== undefined && selectedCells.type !== SWIMLANE_TYPE.OVERALL && @@ -232,159 +316,11 @@ export function getSelectionJobIds(selectedCells, selectedJobs) { return selectedJobs.map((d) => d.id); } -export function getSwimlaneBucketInterval(selectedJobs, swimlaneContainerWidth) { - // Bucketing interval should be the maximum of the chart related interval (i.e. time range related) - // and the max bucket span for the jobs shown in the chart. - const timefilter = getTimefilter(); - const bounds = timefilter.getActiveBounds(); - const buckets = getTimeBucketsFromCache(); - buckets.setInterval('auto'); - buckets.setBounds(bounds); - - const intervalSeconds = buckets.getInterval().asSeconds(); - - // if the swimlane cell widths are too small they will not be visible - // calculate how many buckets will be drawn before the swimlanes are actually rendered - // and increase the interval to widen the cells if they're going to be smaller than 8px - // this has to be done at this stage so all searches use the same interval - const timerangeSeconds = (bounds.max.valueOf() - bounds.min.valueOf()) / 1000; - const numBuckets = parseInt(timerangeSeconds / intervalSeconds); - const cellWidth = Math.floor((swimlaneContainerWidth / numBuckets) * 100) / 100; - - // if the cell width is going to be less than 8px, double the interval - if (cellWidth < 8) { - buckets.setInterval(intervalSeconds * 2 + 's'); - } - - const maxBucketSpanSeconds = selectedJobs.reduce( - (memo, job) => Math.max(memo, job.bucketSpanSeconds), - 0 - ); - if (maxBucketSpanSeconds > intervalSeconds) { - buckets.setInterval(maxBucketSpanSeconds + 's'); - buckets.setBounds(bounds); - } - - return buckets.getInterval(); -} - -// Obtain the list of 'View by' fields per job and viewBySwimlaneFieldName -export function getViewBySwimlaneOptions({ - currentViewBySwimlaneFieldName, - filterActive, - filteredFields, - isAndOperator, - selectedCells, - selectedJobs, -}) { - const selectedJobIds = selectedJobs.map((d) => d.id); - - // Unique influencers for the selected job(s). - const viewByOptions = sortBy( - uniq( - mlJobService.jobs.reduce((reducedViewByOptions, job) => { - if (selectedJobIds.some((jobId) => jobId === job.job_id)) { - return reducedViewByOptions.concat(job.analysis_config.influencers || []); - } - return reducedViewByOptions; - }, []) - ), - (fieldName) => fieldName.toLowerCase() - ); - - viewByOptions.push(VIEW_BY_JOB_LABEL); - let viewBySwimlaneOptions = viewByOptions; - - let viewBySwimlaneFieldName = undefined; - - if (viewBySwimlaneOptions.indexOf(currentViewBySwimlaneFieldName) !== -1) { - // Set the swimlane viewBy to that stored in the state (URL) if set. - // This means we reset it to the current state because it was set by the listener - // on initialization. - viewBySwimlaneFieldName = currentViewBySwimlaneFieldName; - } else { - if (selectedJobIds.length > 1) { - // If more than one job selected, default to job ID. - viewBySwimlaneFieldName = VIEW_BY_JOB_LABEL; - } else if (mlJobService.jobs.length > 0 && selectedJobIds.length > 0) { - // For a single job, default to the first partition, over, - // by or influencer field of the first selected job. - const firstSelectedJob = mlJobService.jobs.find((job) => { - return job.job_id === selectedJobIds[0]; - }); - - const firstJobInfluencers = firstSelectedJob.analysis_config.influencers || []; - firstSelectedJob.analysis_config.detectors.forEach((detector) => { - if ( - detector.partition_field_name !== undefined && - firstJobInfluencers.indexOf(detector.partition_field_name) !== -1 - ) { - viewBySwimlaneFieldName = detector.partition_field_name; - return false; - } - - if ( - detector.over_field_name !== undefined && - firstJobInfluencers.indexOf(detector.over_field_name) !== -1 - ) { - viewBySwimlaneFieldName = detector.over_field_name; - return false; - } - - // For jobs with by and over fields, don't add the 'by' field as this - // field will only be added to the top-level fields for record type results - // if it also an influencer over the bucket. - if ( - detector.by_field_name !== undefined && - detector.over_field_name === undefined && - firstJobInfluencers.indexOf(detector.by_field_name) !== -1 - ) { - viewBySwimlaneFieldName = detector.by_field_name; - return false; - } - }); - - if (viewBySwimlaneFieldName === undefined) { - if (firstJobInfluencers.length > 0) { - viewBySwimlaneFieldName = firstJobInfluencers[0]; - } else { - // No influencers for first selected job - set to first available option. - viewBySwimlaneFieldName = - viewBySwimlaneOptions.length > 0 ? viewBySwimlaneOptions[0] : undefined; - } - } - } - } - - // filter View by options to relevant filter fields - // If it's an AND filter only show job Id view by as the rest will have no results - if (filterActive === true && isAndOperator === true && selectedCells === null) { - viewBySwimlaneOptions = [VIEW_BY_JOB_LABEL]; - } else if ( - filterActive === true && - Array.isArray(viewBySwimlaneOptions) && - Array.isArray(filteredFields) - ) { - const filteredOptions = viewBySwimlaneOptions.filter((option) => { - return ( - filteredFields.includes(option) || - option === VIEW_BY_JOB_LABEL || - (selectedCells && selectedCells.viewByFieldName === option) - ); - }); - // only replace viewBySwimlaneOptions with filteredOptions if we found a relevant matching field - if (filteredOptions.length > 1) { - viewBySwimlaneOptions = filteredOptions; - } - } - - return { - viewBySwimlaneFieldName, - viewBySwimlaneOptions, - }; -} - -export function loadOverallAnnotations(selectedJobs, interval, bounds) { +export function loadOverallAnnotations( + selectedJobs: ExplorerJob[], + interval: number, + bounds: TimeRangeBounds +): Promise { const jobIds = selectedJobs.map((d) => d.id); const timeRange = getSelectionTimeRange(undefined, interval, bounds); @@ -406,7 +342,7 @@ export function loadOverallAnnotations(selectedJobs, interval, bounds) { }); } - const annotationsData = []; + const annotationsData: Annotations = []; jobIds.forEach((jobId) => { const jobAnnotations = resp.annotations[jobId]; if (jobAnnotations !== undefined) { @@ -435,7 +371,12 @@ export function loadOverallAnnotations(selectedJobs, interval, bounds) { }); } -export function loadAnnotationsTableData(selectedCells, selectedJobs, interval, bounds) { +export function loadAnnotationsTableData( + selectedCells: AppStateSelectedCells | undefined, + selectedJobs: ExplorerJob[], + interval: number, + bounds: Required +): Promise { const jobIds = getSelectionJobIds(selectedCells, selectedJobs); const timeRange = getSelectionTimeRange(selectedCells, interval, bounds); @@ -458,7 +399,7 @@ export function loadAnnotationsTableData(selectedCells, selectedJobs, interval, }); } - const annotationsData = []; + const annotationsData: Annotations = []; jobIds.forEach((jobId) => { const jobAnnotations = resp.annotations[jobId]; if (jobAnnotations !== undefined) { @@ -490,16 +431,16 @@ export function loadAnnotationsTableData(selectedCells, selectedJobs, interval, } export async function loadAnomaliesTableData( - selectedCells, - selectedJobs, - dateFormatTz, - interval, - bounds, - fieldName, - tableInterval, - tableSeverity, - influencersFilterQuery -) { + selectedCells: AppStateSelectedCells | undefined, + selectedJobs: ExplorerJob[], + dateFormatTz: any, + interval: number, + bounds: Required, + fieldName: string, + tableInterval: string, + tableSeverity: number, + influencersFilterQuery: InfluencersFilterQuery +): Promise { const jobIds = getSelectionJobIds(selectedCells, selectedJobs); const influencers = getSelectionInfluencers(selectedCells, fieldName); const timeRange = getSelectionTimeRange(selectedCells, interval, bounds); @@ -523,6 +464,7 @@ export async function loadAnomaliesTableData( .then((resp) => { const anomalies = resp.anomalies; const detectorsByJob = mlJobService.detectorsByJob; + // @ts-ignore anomalies.forEach((anomaly) => { // Add a detector property to each anomaly. // Default to functionDescription if no description available. @@ -571,6 +513,7 @@ export async function loadAnomaliesTableData( }); }) .catch((resp) => { + // eslint-disable-next-line no-console console.log('Explorer - error loading data for anomalies table:', resp); reject(); }); @@ -578,13 +521,13 @@ export async function loadAnomaliesTableData( } export async function loadTopInfluencers( - mlResultsService, - selectedJobIds, - earliestMs, - latestMs, - influencers = [], - noInfluencersConfigured, - influencersFilterQuery + mlResultsService: MlResultsService, + selectedJobIds: string[], + earliestMs: number, + latestMs: number, + influencers: any[], + noInfluencersConfigured?: boolean, + influencersFilterQuery?: InfluencersFilterQuery ) { return new Promise((resolve) => { if (noInfluencersConfigured !== true) { @@ -611,26 +554,30 @@ export async function loadTopInfluencers( // Recommended by MDN for escaping user input to be treated as a literal string within a regular expression // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions -export function escapeRegExp(string) { +export function escapeRegExp(string: string): string { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string } -export function escapeParens(string) { +export function escapeParens(string: string): string { return string.replace(/[()]/g, '\\$&'); } -export function escapeDoubleQuotes(string) { +export function escapeDoubleQuotes(string: string): string { return string.replace(/[\\"]/g, '\\$&'); } -export function getQueryPattern(fieldName, fieldValue) { +export function getQueryPattern(fieldName: string, fieldValue: string) { const sanitizedFieldName = escapeRegExp(fieldName); const sanitizedFieldValue = escapeRegExp(fieldValue); return new RegExp(`(${sanitizedFieldName})\\s?:\\s?(")?(${sanitizedFieldValue})(")?`, 'i'); } -export function removeFilterFromQueryString(currentQueryString, fieldName, fieldValue) { +export function removeFilterFromQueryString( + currentQueryString: string, + fieldName: string, + fieldValue: string +) { let newQueryString = ''; // Remove the passed in fieldName and value from the existing filter const queryPattern = getQueryPattern(fieldName, fieldValue); diff --git a/x-pack/plugins/ml/public/application/explorer/has_matching_points.ts b/x-pack/plugins/ml/public/application/explorer/has_matching_points.ts index 21ac4429d69d6..faf2c3fc3fdd2 100644 --- a/x-pack/plugins/ml/public/application/explorer/has_matching_points.ts +++ b/x-pack/plugins/ml/public/application/explorer/has_matching_points.ts @@ -5,11 +5,11 @@ * 2.0. */ -import { ExplorerState } from './reducers'; import { OverallSwimlaneData, SwimlaneData } from './explorer_utils'; +import type { FilterSettings } from './anomaly_explorer_common_state'; interface HasMatchingPointsParams { - filteredFields?: ExplorerState['filteredFields']; + filteredFields?: FilterSettings['filteredFields']; swimlaneData: SwimlaneData | OverallSwimlaneData; } @@ -18,9 +18,11 @@ export const hasMatchingPoints = ({ swimlaneData, }: HasMatchingPointsParams): boolean => { // If filtered fields includes a wildcard search maskAll only if there are no points matching the pattern - const wildCardField = filteredFields.find((field) => /\@kuery-wildcard\@$/.test(field)); + const wildCardField = filteredFields.find((field) => /\@kuery-wildcard\@$/.test(field as string)); const substring = - wildCardField !== undefined ? wildCardField.replace(/\@kuery-wildcard\@$/, '') : null; + wildCardField !== undefined + ? (wildCardField as string).replace(/\@kuery-wildcard\@$/, '') + : null; return ( substring !== null && diff --git a/x-pack/plugins/ml/public/application/explorer/hooks/use_explorer_url_state.ts b/x-pack/plugins/ml/public/application/explorer/hooks/use_explorer_url_state.ts index 421018abb854f..5af9684c3a09b 100644 --- a/x-pack/plugins/ml/public/application/explorer/hooks/use_explorer_url_state.ts +++ b/x-pack/plugins/ml/public/application/explorer/hooks/use_explorer_url_state.ts @@ -5,10 +5,12 @@ * 2.0. */ -import { usePageUrlState } from '../../util/url_state'; +import { PageUrlStateService, usePageUrlState } from '../../util/url_state'; import { ExplorerAppState } from '../../../../common/types/locator'; import { ML_PAGES } from '../../../../common/constants/locator'; +export type AnomalyExplorerUrlStateService = PageUrlStateService; + export function useExplorerUrlState() { /** * Originally `mlExplorerSwimlane` resided directly in the app URL state (`_a` URL state key). diff --git a/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.test.ts b/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.test.ts deleted file mode 100644 index 724d85d91e30a..0000000000000 --- a/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import moment from 'moment'; -import { renderHook } from '@testing-library/react-hooks'; -import { useSelectedCells } from './use_selected_cells'; -import { ExplorerAppState } from '../../../../common/types/locator'; -import { TimefilterContract } from '../../../../../../../src/plugins/data/public'; - -import { useTimefilter } from '../../contexts/kibana'; - -jest.mock('../../contexts/kibana'); - -describe('useSelectedCells', () => { - test('should not set state when the cell selection is correct', () => { - (useTimefilter() as jest.Mocked).getBounds.mockReturnValue({ - min: moment(1498824778 * 1000), - max: moment(1502366798 * 1000), - }); - - const urlState = { - mlExplorerSwimlane: { - selectedType: 'overall', - selectedLanes: ['Overall'], - selectedTimes: [1498780800, 1498867200], - showTopFieldValues: true, - viewByFieldName: 'apache2.access.remote_ip', - viewByFromPage: 1, - viewByPerPage: 10, - }, - mlExplorerFilter: {}, - } as ExplorerAppState; - - const setUrlState = jest.fn(); - - const bucketInterval = 86400; - - renderHook(() => useSelectedCells(urlState, setUrlState, bucketInterval)); - - expect(setUrlState).not.toHaveBeenCalled(); - }); - - test('should reset cell selection when it is completely out of range', () => { - (useTimefilter() as jest.Mocked).getBounds.mockReturnValue({ - min: moment(1501071178 * 1000), - max: moment(1502366798 * 1000), - }); - - const urlState = { - mlExplorerSwimlane: { - selectedType: 'overall', - selectedLanes: ['Overall'], - selectedTimes: [1498780800, 1498867200], - showTopFieldValues: true, - viewByFieldName: 'apache2.access.remote_ip', - viewByFromPage: 1, - viewByPerPage: 10, - }, - mlExplorerFilter: {}, - } as ExplorerAppState; - - const setUrlState = jest.fn(); - - const bucketInterval = 86400; - - const { result } = renderHook(() => useSelectedCells(urlState, setUrlState, bucketInterval)); - - expect(result.current[0]).toEqual({ - lanes: ['Overall'], - showTopFieldValues: true, - times: [1498780800, 1498867200], - type: 'overall', - viewByFieldName: 'apache2.access.remote_ip', - }); - - expect(setUrlState).toHaveBeenCalledWith({ - mlExplorerSwimlane: { - viewByFieldName: 'apache2.access.remote_ip', - viewByFromPage: 1, - viewByPerPage: 10, - }, - }); - }); - - test('should adjust cell selection time boundaries based on the main time range', () => { - (useTimefilter() as jest.Mocked).getBounds.mockReturnValue({ - min: moment(1501071178 * 1000), - max: moment(1502366798 * 1000), - }); - - const urlState = { - mlExplorerSwimlane: { - selectedType: 'overall', - selectedLanes: ['Overall'], - selectedTimes: [1498780800, 1502366798], - showTopFieldValues: true, - viewByFieldName: 'apache2.access.remote_ip', - viewByFromPage: 1, - viewByPerPage: 10, - }, - mlExplorerFilter: {}, - } as ExplorerAppState; - - const setUrlState = jest.fn(); - - const bucketInterval = 86400; - - const { result } = renderHook(() => useSelectedCells(urlState, setUrlState, bucketInterval)); - - expect(result.current[0]).toEqual({ - lanes: ['Overall'], - showTopFieldValues: true, - times: [1498780800, 1502366798], - type: 'overall', - viewByFieldName: 'apache2.access.remote_ip', - }); - - expect(setUrlState).toHaveBeenCalledWith({ - mlExplorerSwimlane: { - selectedLanes: ['Overall'], - selectedTimes: [1500984778, 1502366798], - selectedType: 'overall', - showTopFieldValues: true, - viewByFieldName: 'apache2.access.remote_ip', - viewByFromPage: 1, - viewByPerPage: 10, - }, - }); - }); - - test('should extend single time point selection with a bucket interval value', () => { - (useTimefilter() as jest.Mocked).getBounds.mockReturnValue({ - min: moment(1498824778 * 1000), - max: moment(1502366798 * 1000), - }); - - const urlState = { - mlExplorerSwimlane: { - selectedType: 'overall', - selectedLanes: ['Overall'], - selectedTimes: 1498780800, - showTopFieldValues: true, - viewByFieldName: 'apache2.access.remote_ip', - viewByFromPage: 1, - viewByPerPage: 10, - }, - mlExplorerFilter: {}, - } as ExplorerAppState; - - const setUrlState = jest.fn(); - - const bucketInterval = 86400; - - const { result } = renderHook(() => useSelectedCells(urlState, setUrlState, bucketInterval)); - - expect(result.current[0]).toEqual({ - lanes: ['Overall'], - showTopFieldValues: true, - times: [1498780800, 1498867200], - type: 'overall', - viewByFieldName: 'apache2.access.remote_ip', - }); - }); -}); diff --git a/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts b/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts index 1840ffc7235d5..9b2665f8f21f8 100644 --- a/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts +++ b/x-pack/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts @@ -5,133 +5,7 @@ * 2.0. */ -import { useCallback, useEffect, useMemo } from 'react'; -import { SWIMLANE_TYPE } from '../explorer_constants'; import { AppStateSelectedCells } from '../explorer_utils'; -import { ExplorerAppState } from '../../../../common/types/locator'; -import { useTimefilter } from '../../contexts/kibana'; - -export const useSelectedCells = ( - appState: ExplorerAppState, - setAppState: (update: Partial) => void, - bucketIntervalInSeconds: number | undefined -): [AppStateSelectedCells | undefined, (swimlaneSelectedCells: AppStateSelectedCells) => void] => { - const timeFilter = useTimefilter(); - const timeBounds = timeFilter.getBounds(); - - // keep swimlane selection, restore selectedCells from AppState - const selectedCells: AppStateSelectedCells | undefined = useMemo(() => { - if (!appState?.mlExplorerSwimlane?.selectedType) { - return; - } - - let times = - appState.mlExplorerSwimlane.selectedTimes ?? appState.mlExplorerSwimlane.selectedTime!; - if (typeof times === 'number') { - times = [times, times + bucketIntervalInSeconds!]; - } - - let lanes = - appState.mlExplorerSwimlane.selectedLanes ?? appState.mlExplorerSwimlane.selectedLane!; - - if (typeof lanes === 'string') { - lanes = [lanes]; - } - - return { - type: appState.mlExplorerSwimlane.selectedType, - lanes, - times, - showTopFieldValues: appState.mlExplorerSwimlane.showTopFieldValues, - viewByFieldName: appState.mlExplorerSwimlane.viewByFieldName, - } as AppStateSelectedCells; - // TODO fix appState to use memoization - }, [JSON.stringify(appState?.mlExplorerSwimlane), bucketIntervalInSeconds]); - - const setSelectedCells = useCallback( - (swimlaneSelectedCells?: AppStateSelectedCells) => { - const mlExplorerSwimlane = { - ...appState.mlExplorerSwimlane, - } as ExplorerAppState['mlExplorerSwimlane']; - - if (swimlaneSelectedCells !== undefined) { - swimlaneSelectedCells.showTopFieldValues = false; - - const currentSwimlaneType = selectedCells?.type; - const currentShowTopFieldValues = selectedCells?.showTopFieldValues; - const newSwimlaneType = swimlaneSelectedCells?.type; - - if ( - (currentSwimlaneType === SWIMLANE_TYPE.OVERALL && - newSwimlaneType === SWIMLANE_TYPE.VIEW_BY) || - newSwimlaneType === SWIMLANE_TYPE.OVERALL || - currentShowTopFieldValues === true - ) { - swimlaneSelectedCells.showTopFieldValues = true; - } - - mlExplorerSwimlane.selectedType = swimlaneSelectedCells.type; - mlExplorerSwimlane.selectedLanes = swimlaneSelectedCells.lanes; - mlExplorerSwimlane.selectedTimes = swimlaneSelectedCells.times; - mlExplorerSwimlane.showTopFieldValues = swimlaneSelectedCells.showTopFieldValues; - setAppState({ mlExplorerSwimlane }); - } else { - delete mlExplorerSwimlane.selectedType; - delete mlExplorerSwimlane.selectedLanes; - delete mlExplorerSwimlane.selectedTimes; - delete mlExplorerSwimlane.showTopFieldValues; - setAppState({ mlExplorerSwimlane }); - } - }, - [appState?.mlExplorerSwimlane, selectedCells, setAppState] - ); - - /** - * Adjust cell selection with respect to the time boundaries. - * Reset it entirely when it out of range. - */ - useEffect( - function adjustSwimLaneTimeSelection() { - if (selectedCells?.times === undefined || bucketIntervalInSeconds === undefined) return; - - const [selectedFrom, selectedTo] = selectedCells.times; - - /** - * Because each cell on the swim lane represent the fixed bucket interval, - * the selection range could be outside of the time boundaries with - * correction within the bucket interval. - */ - const rangeFrom = timeBounds.min!.unix() - bucketIntervalInSeconds; - const rangeTo = timeBounds.max!.unix() + bucketIntervalInSeconds; - - const resultFrom = Math.max(selectedFrom, rangeFrom); - const resultTo = Math.min(selectedTo, rangeTo); - - const isSelectionOutOfRange = rangeFrom > resultTo || rangeTo < resultFrom; - - if (isSelectionOutOfRange) { - // reset selection - setSelectedCells(); - return; - } - - if (selectedFrom === resultFrom && selectedTo === resultTo) { - // selection is correct, no need to adjust the range - return; - } - - if (resultFrom !== rangeFrom || resultTo !== rangeTo) { - setSelectedCells({ - ...selectedCells, - times: [resultFrom, resultTo], - }); - } - }, - [timeBounds.min?.unix(), timeBounds.max?.unix(), selectedCells, bucketIntervalInSeconds] - ); - - return [selectedCells, setSelectedCells]; -}; export interface SelectionTimeRange { earliestMs: number; diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/check_selected_cells.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/check_selected_cells.ts deleted file mode 100644 index e41ec55c6685c..0000000000000 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/check_selected_cells.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { SWIMLANE_TYPE } from '../../explorer_constants'; -import { getClearedSelectedAnomaliesState } from '../../explorer_utils'; - -import { ExplorerState } from './state'; - -interface SwimlanePoint { - laneLabel: string; - time: number; -} - -// do a sanity check against selectedCells. It can happen that a previously -// selected lane loaded via URL/AppState is not available anymore. -// If filter is active - selectedCell may not be available due to swimlane view by change to filter fieldName -// Ok to keep cellSelection in this case -export const checkSelectedCells = (state: ExplorerState) => { - const { filterActive, loading, selectedCells, viewBySwimlaneData, viewBySwimlaneDataLoading } = - state; - - if (loading || viewBySwimlaneDataLoading) { - return {}; - } - - let clearSelection = false; - if ( - selectedCells !== undefined && - selectedCells !== null && - selectedCells.type === SWIMLANE_TYPE.VIEW_BY && - viewBySwimlaneData !== undefined && - viewBySwimlaneData.points !== undefined && - viewBySwimlaneData.points.length > 0 - ) { - clearSelection = - filterActive === false && - !selectedCells.lanes.some((lane: string) => { - return viewBySwimlaneData.points.some((point: SwimlanePoint) => { - return ( - point.laneLabel === lane && - point.time >= selectedCells.times[0] && - point.time <= selectedCells.times[1] - ); - }); - }); - } - - if (clearSelection === true) { - return { - ...getClearedSelectedAnomaliesState(), - }; - } - - return {}; -}; diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/clear_influencer_filter_settings.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/clear_influencer_filter_settings.ts index 70929681b1965..dec50d0b985eb 100644 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/clear_influencer_filter_settings.ts +++ b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/clear_influencer_filter_settings.ts @@ -12,14 +12,10 @@ import { ExplorerState } from './state'; export function clearInfluencerFilterSettings(state: ExplorerState): ExplorerState { return { ...state, - filterActive: false, - filteredFields: [], - influencersFilterQuery: undefined, isAndOperator: false, maskAll: false, queryString: '', tableQueryString: '', ...getClearedSelectedAnomaliesState(), - viewByFromPage: 1, }; } diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/job_selection_change.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/job_selection_change.ts index a6d16cd2b777f..dbf5dc2c8a8be 100644 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/job_selection_change.ts +++ b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/job_selection_change.ts @@ -5,25 +5,18 @@ * 2.0. */ -import { isEqual } from 'lodash'; -import { ActionPayload } from '../../explorer_dashboard_service'; -import { getDefaultSwimlaneData, getInfluencers } from '../../explorer_utils'; +import type { ActionPayload } from '../../explorer_dashboard_service'; +import { getInfluencers } from '../../explorer_utils'; import { getIndexPattern } from './get_index_pattern'; -import { ExplorerState } from './state'; +import type { ExplorerState } from './state'; export const jobSelectionChange = (state: ExplorerState, payload: ActionPayload): ExplorerState => { const { selectedJobs } = payload; const stateUpdate: ExplorerState = { ...state, noInfluencersConfigured: getInfluencers(selectedJobs).length === 0, - overallSwimlaneData: getDefaultSwimlaneData(), selectedJobs, - // currently job selection set asynchronously so - // we want to preserve the pagination from the url state - // on initial load - viewByFromPage: - !state.selectedJobs || isEqual(state.selectedJobs, selectedJobs) ? state.viewByFromPage : 1, }; // clear filter if selected jobs have no influencers diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts index 192699afc2cf4..632ade186a44d 100644 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts +++ b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts @@ -5,25 +5,15 @@ * 2.0. */ -import { formatHumanReadableDateTime } from '../../../../../common/util/date_utils'; - import { getDefaultChartsData } from '../../explorer_charts/explorer_charts_container_service'; -import { EXPLORER_ACTION, VIEW_BY_JOB_LABEL } from '../../explorer_constants'; +import { EXPLORER_ACTION } from '../../explorer_constants'; import { Action } from '../../explorer_dashboard_service'; -import { - getClearedSelectedAnomaliesState, - getDefaultSwimlaneData, - getSwimlaneBucketInterval, - getViewBySwimlaneOptions, -} from '../../explorer_utils'; +import { getClearedSelectedAnomaliesState } from '../../explorer_utils'; -import { checkSelectedCells } from './check_selected_cells'; import { clearInfluencerFilterSettings } from './clear_influencer_filter_settings'; import { jobSelectionChange } from './job_selection_change'; import { ExplorerState, getExplorerDefaultState } from './state'; -import { setInfluencerFilterSettings } from './set_influencer_filter_settings'; import { setKqlQueryBarPlaceholder } from './set_kql_query_bar_placeholder'; -import { getTimeBoundsFromSelection } from '../../hooks/use_selected_cells'; export const explorerReducer = (state: ExplorerState, nextAction: Action): ExplorerState => { const { type, payload } = nextAction; @@ -44,7 +34,6 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo ...state, ...getClearedSelectedAnomaliesState(), loading: false, - viewByFromPage: 1, selectedJobs: [], }; break; @@ -75,96 +64,10 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo }; break; - case EXPLORER_ACTION.SET_INFLUENCER_FILTER_SETTINGS: - nextState = setInfluencerFilterSettings(state, payload); - break; - - case EXPLORER_ACTION.SET_SELECTED_CELLS: - const selectedCells = payload; - nextState = { - ...state, - selectedCells, - }; - break; - case EXPLORER_ACTION.SET_EXPLORER_DATA: - case EXPLORER_ACTION.SET_FILTER_DATA: nextState = { ...state, ...payload }; break; - case EXPLORER_ACTION.SET_SWIMLANE_CONTAINER_WIDTH: - nextState = { ...state, swimlaneContainerWidth: payload }; - break; - - case EXPLORER_ACTION.SET_VIEW_BY_SWIMLANE_FIELD_NAME: - const { filteredFields, influencersFilterQuery } = state; - const viewBySwimlaneFieldName = payload; - - let maskAll = false; - - if (influencersFilterQuery !== undefined) { - maskAll = - viewBySwimlaneFieldName === VIEW_BY_JOB_LABEL || - filteredFields.includes(viewBySwimlaneFieldName) === false; - } - - nextState = { - ...state, - ...getClearedSelectedAnomaliesState(), - maskAll, - viewBySwimlaneFieldName, - viewBySwimlaneData: getDefaultSwimlaneData(), - viewByFromPage: 1, - viewBySwimlaneDataLoading: true, - }; - break; - - case EXPLORER_ACTION.SET_VIEW_BY_SWIMLANE_LOADING: - const { annotationsData, overallState, tableData } = payload; - nextState = { - ...state, - annotations: annotationsData, - overallSwimlaneData: overallState, - tableData, - viewBySwimlaneData: { - ...getDefaultSwimlaneData(), - }, - viewBySwimlaneDataLoading: true, - }; - break; - - case EXPLORER_ACTION.SET_VIEW_BY_FROM_PAGE: - nextState = { - ...state, - viewByFromPage: payload, - }; - break; - - case EXPLORER_ACTION.SET_VIEW_BY_PER_PAGE: - nextState = { - ...state, - // reset current page on the page size change - viewByFromPage: 1, - viewByPerPage: payload, - }; - break; - - case EXPLORER_ACTION.SET_SWIM_LANE_SEVERITY: - nextState = { - ...state, - // reset current page on the page size change - viewByFromPage: 1, - swimLaneSeverity: payload, - }; - break; - - case EXPLORER_ACTION.SET_SHOW_CHARTS: - nextState = { - ...state, - showCharts: payload, - }; - break; - default: nextState = state; } @@ -173,37 +76,8 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo return nextState; } - const swimlaneBucketInterval = getSwimlaneBucketInterval( - nextState.selectedJobs, - nextState.swimlaneContainerWidth - ); - - // Does a sanity check on the selected `viewBySwimlaneFieldName` - // and returns the available `viewBySwimlaneOptions`. - const { viewBySwimlaneFieldName, viewBySwimlaneOptions } = getViewBySwimlaneOptions({ - currentViewBySwimlaneFieldName: nextState.viewBySwimlaneFieldName, - filterActive: nextState.filterActive, - filteredFields: nextState.filteredFields, - isAndOperator: nextState.isAndOperator, - selectedJobs: nextState.selectedJobs, - selectedCells: nextState.selectedCells!, - }); - - const { selectedCells } = nextState; - - const timeRange = getTimeBoundsFromSelection(selectedCells); - return { ...nextState, - swimlaneBucketInterval, - viewByLoadedForTimeFormatted: timeRange - ? `${formatHumanReadableDateTime(timeRange.earliestMs)} - ${formatHumanReadableDateTime( - timeRange.latestMs - )}` - : null, - viewBySwimlaneFieldName, - viewBySwimlaneOptions, - ...checkSelectedCells(nextState), ...setKqlQueryBarPlaceholder(nextState), }; }; diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/set_influencer_filter_settings.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/set_influencer_filter_settings.ts deleted file mode 100644 index 4180353a2222d..0000000000000 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/set_influencer_filter_settings.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { VIEW_BY_JOB_LABEL } from '../../explorer_constants'; -import { ActionPayload } from '../../explorer_dashboard_service'; - -import { ExplorerState } from './state'; - -export function setInfluencerFilterSettings( - state: ExplorerState, - payload: ActionPayload -): ExplorerState { - const { - filterQuery: influencersFilterQuery, - isAndOperator, - filteredFields, - queryString, - tableQueryString, - } = payload; - - const { selectedCells, viewBySwimlaneOptions } = state; - let selectedViewByFieldName = state.viewBySwimlaneFieldName; - const filteredViewBySwimlaneOptions = viewBySwimlaneOptions.filter((d) => - filteredFields.includes(d) - ); - - // if it's an AND filter set view by swimlane to job ID as the others will have no results - if (isAndOperator && selectedCells === undefined) { - selectedViewByFieldName = VIEW_BY_JOB_LABEL; - } else { - // Set View by dropdown to first relevant fieldName based on incoming filter if there's no cell selection already - // or if selected cell is from overall swimlane as this won't include an additional influencer filter - for (let i = 0; i < filteredFields.length; i++) { - if ( - filteredViewBySwimlaneOptions.includes(filteredFields[i]) && - (selectedCells === undefined || (selectedCells && selectedCells.type === 'overall')) - ) { - selectedViewByFieldName = filteredFields[i]; - break; - } - } - } - - return { - ...state, - filterActive: true, - filteredFields, - influencersFilterQuery, - isAndOperator, - queryString, - tableQueryString, - maskAll: - selectedViewByFieldName === VIEW_BY_JOB_LABEL || - filteredFields.includes(selectedViewByFieldName) === false, - viewBySwimlaneFieldName: selectedViewByFieldName, - viewBySwimlaneOptions: filteredViewBySwimlaneOptions, - viewByFromPage: 1, - }; -} diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts index cfc9f076fbb3a..c9a09ad5e310d 100644 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts +++ b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts @@ -6,25 +6,14 @@ */ import { ML_RESULTS_INDEX_PATTERN } from '../../../../../common/constants/index_patterns'; -import { Dictionary } from '../../../../../common/types/common'; - import { getDefaultChartsData, ExplorerChartsData, } from '../../explorer_charts/explorer_charts_container_service'; -import { - getDefaultSwimlaneData, - AnomaliesTableData, - ExplorerJob, - AppStateSelectedCells, - OverallSwimlaneData, - SwimlaneData, - ViewBySwimLaneData, -} from '../../explorer_utils'; +import { AnomaliesTableData, ExplorerJob } from '../../explorer_utils'; import { AnnotationsTable } from '../../../../../common/types/annotations'; -import { SWIM_LANE_DEFAULT_PAGE_SIZE } from '../../explorer_constants'; -import { InfluencersFilterQuery } from '../../../../../common/types/es_client'; -import { TimeBucketsInterval } from '../../../util/time_buckets'; +import type { DataView } from '../../../../../../../../src/plugins/data_views/common'; +import type { InfluencerValueData } from '../../../components/influencers_list/influencers_list'; export interface ExplorerState { overallAnnotations: AnnotationsTable; @@ -32,38 +21,24 @@ export interface ExplorerState { anomalyChartsDataLoading: boolean; chartsData: ExplorerChartsData; fieldFormatsLoading: boolean; - filterActive: boolean; - filteredFields: any[]; - filterPlaceHolder: any; - indexPattern: { title: string; fields: any[] }; - influencersFilterQuery?: InfluencersFilterQuery; - influencers: Dictionary; + filterPlaceHolder: string | undefined; + indexPattern: { + title: string; + fields: Array<{ name: string; type: string; aggregatable: boolean; searchable: boolean }>; + }; + influencers: Record; isAndOperator: boolean; loading: boolean; maskAll: boolean; noInfluencersConfigured: boolean; - overallSwimlaneData: SwimlaneData | OverallSwimlaneData; queryString: string; - selectedCells: AppStateSelectedCells | undefined; selectedJobs: ExplorerJob[] | null; - swimlaneBucketInterval: TimeBucketsInterval | undefined; - swimlaneContainerWidth: number; tableData: AnomaliesTableData; tableQueryString: string; - viewByLoadedForTimeFormatted: string | null; - viewBySwimlaneData: SwimlaneData | ViewBySwimLaneData; - viewBySwimlaneDataLoading: boolean; - viewBySwimlaneFieldName?: string; - viewByPerPage: number; - viewByFromPage: number; - viewBySwimlaneOptions: string[]; - swimlaneLimit?: number; - swimLaneSeverity?: number; - showCharts: boolean; } function getDefaultIndexPattern() { - return { title: ML_RESULTS_INDEX_PATTERN, fields: [] }; + return { title: ML_RESULTS_INDEX_PATTERN, fields: [] } as unknown as DataView; } export function getExplorerDefaultState(): ExplorerState { @@ -79,22 +54,15 @@ export function getExplorerDefaultState(): ExplorerState { anomalyChartsDataLoading: true, chartsData: getDefaultChartsData(), fieldFormatsLoading: false, - filterActive: false, - filteredFields: [], filterPlaceHolder: undefined, indexPattern: getDefaultIndexPattern(), - influencersFilterQuery: undefined, influencers: {}, isAndOperator: false, loading: true, maskAll: false, noInfluencersConfigured: true, - overallSwimlaneData: getDefaultSwimlaneData(), queryString: '', - selectedCells: undefined, selectedJobs: null, - swimlaneBucketInterval: undefined, - swimlaneContainerWidth: 0, tableData: { anomalies: [], examplesByJobId: [''], @@ -103,14 +71,5 @@ export function getExplorerDefaultState(): ExplorerState { showViewSeriesLink: false, }, tableQueryString: '', - viewByLoadedForTimeFormatted: null, - viewBySwimlaneData: getDefaultSwimlaneData(), - viewBySwimlaneDataLoading: false, - viewBySwimlaneFieldName: undefined, - viewBySwimlaneOptions: [], - viewByPerPage: SWIM_LANE_DEFAULT_PAGE_SIZE, - viewByFromPage: 1, - swimlaneLimit: undefined, - showCharts: true, }; } diff --git a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx index 47e2b9babb4a2..38d4b9795abed 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx @@ -21,7 +21,6 @@ import { useRefresh } from '../use_refresh'; import { useResolver } from '../use_resolver'; import { basicResolvers } from '../resolvers'; import { Explorer } from '../../explorer'; -import { useSelectedCells } from '../../explorer/hooks/use_selected_cells'; import { mlJobService } from '../../services/job_service'; import { ml } from '../../services/ml_api_service'; import { useExplorerData } from '../../explorer/actions'; @@ -33,7 +32,6 @@ import { useTableSeverity } from '../../components/controls/select_severity'; import { useUrlState } from '../../util/url_state'; import { getBreadcrumbWithUrlForApp } from '../breadcrumbs'; import { useTimefilter } from '../../contexts/kibana'; -import { isViewBySwimLaneData } from '../../explorer/swimlane_container'; import { JOB_ID } from '../../../../common/constants/anomalies'; import { MlAnnotationUpdatesContext } from '../../contexts/ml/ml_annotation_updates_context'; import { AnnotationUpdatesService } from '../../services/annotations_service'; @@ -42,6 +40,11 @@ import { useTimeBuckets } from '../../components/custom_hooks/use_time_buckets'; import { MlPageHeader } from '../../components/page_header'; import { AnomalyResultsViewSelector } from '../../components/anomaly_results_view_selector'; import { AnomalyDetectionEmptyState } from '../../jobs/jobs_list/components/anomaly_detection_empty_state'; +import { + AnomalyExplorerContext, + useAnomalyExplorerContextValue, +} from '../../explorer/anomaly_explorer_context'; +import type { AnomalyExplorerSwimLaneUrlState } from '../../../../common/types/locator'; export const explorerRouteFactory = ( navigateToPath: NavigateToPath, @@ -94,7 +97,9 @@ interface ExplorerUrlStateManagerProps { } const ExplorerUrlStateManager: FC = ({ jobsWithTimeRange }) => { - const [explorerUrlState, setExplorerUrlState] = useExplorerUrlState(); + const [explorerUrlState, setExplorerUrlState, explorerUrlStateService] = useExplorerUrlState(); + + const anomalyExplorerContext = useAnomalyExplorerContextValue(explorerUrlStateService); const [globalState] = useUrlState('_g'); const [stoppedPartitions, setStoppedPartitions] = useState(); @@ -108,7 +113,6 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim (job) => jobIds.includes(job.id) && job.isRunning === true ); - const explorerAppState = useObservable(explorerService.appState$); const explorerState = useObservable(explorerService.state$); const refresh = useRefresh(); @@ -147,14 +151,41 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim } }, []); - useEffect(() => { - if (jobIds.length > 0) { - explorerService.updateJobSelection(jobIds); - getJobsWithStoppedPartitions(jobIds); - } else { - explorerService.clearJobs(); - } - }, [JSON.stringify(jobIds)]); + const updateSwimLaneUrlState = useCallback( + (update: AnomalyExplorerSwimLaneUrlState | undefined, replaceState = false) => { + const ccc = explorerUrlState?.mlExplorerSwimlane; + const resultUpdate = replaceState ? update : { ...ccc, ...update }; + return setExplorerUrlState({ + ...explorerUrlState, + mlExplorerSwimlane: resultUpdate, + }); + }, + [explorerUrlState, setExplorerUrlState] + ); + + useEffect( + // TODO URL state service should provide observable with updates + // and immutable method for updates + function updateAnomalyTimelineStateFromUrl() { + const { anomalyTimelineStateService } = anomalyExplorerContext; + + anomalyTimelineStateService.updateSetStateCallback(updateSwimLaneUrlState); + anomalyTimelineStateService.updateFromUrlState(explorerUrlState?.mlExplorerSwimlane); + }, + [explorerUrlState?.mlExplorerSwimlane, updateSwimLaneUrlState] + ); + + useEffect( + function handleJobSelection() { + if (jobIds.length > 0) { + explorerService.updateJobSelection(jobIds); + getJobsWithStoppedPartitions(jobIds); + } else { + explorerService.clearJobs(); + } + }, + [JSON.stringify(jobIds)] + ); useEffect(() => { return () => { @@ -164,48 +195,6 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim }; }, []); - /** - * TODO get rid of the intermediate state in explorerService. - * URL state should be the only source of truth for related props. - */ - useEffect(() => { - const filterData = explorerUrlState?.mlExplorerFilter; - if (filterData !== undefined) { - explorerService.setFilterData(filterData); - } - - const { viewByFieldName, viewByFromPage, viewByPerPage, severity } = - explorerUrlState?.mlExplorerSwimlane ?? {}; - - if (viewByFieldName !== undefined) { - explorerService.setViewBySwimlaneFieldName(viewByFieldName); - } - - if (viewByPerPage !== undefined) { - explorerService.setViewByPerPage(viewByPerPage); - } - - if (viewByFromPage !== undefined) { - explorerService.setViewByFromPage(viewByFromPage); - } - - if (severity !== undefined) { - explorerService.setSwimLaneSeverity(severity); - } - - if (explorerUrlState.mlShowCharts !== undefined) { - explorerService.setShowCharts(explorerUrlState.mlShowCharts); - } - }, []); - - /** Sync URL state with {@link explorerService} state */ - useEffect(() => { - const replaceState = explorerUrlState?.mlExplorerSwimlane?.viewByFieldName === undefined; - if (explorerAppState?.mlExplorerSwimlane?.viewByFieldName !== undefined) { - setExplorerUrlState(explorerAppState, replaceState); - } - }, [explorerAppState]); - const [explorerData, loadExplorerData] = useExplorerData(); useEffect(() => { @@ -217,60 +206,75 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim const [tableInterval] = useTableInterval(); const [tableSeverity] = useTableSeverity(); - const [selectedCells, setSelectedCells] = useSelectedCells( - explorerUrlState, - setExplorerUrlState, - explorerState?.swimlaneBucketInterval?.asSeconds() + const showCharts = useObservable( + anomalyExplorerContext.anomalyExplorerCommonStateService.getShowCharts$(), + anomalyExplorerContext.anomalyExplorerCommonStateService.getShowCharts() ); - useEffect(() => { - explorerService.setSelectedCells(selectedCells); - }, [JSON.stringify(selectedCells)]); + const selectedCells = useObservable( + anomalyExplorerContext.anomalyTimelineStateService.getSelectedCells$() + ); + + const swimlaneContainerWidth = useObservable( + anomalyExplorerContext.anomalyTimelineStateService.getContainerWidth$(), + anomalyExplorerContext.anomalyTimelineStateService.getContainerWidth() + ); + + const viewByFieldName = useObservable( + anomalyExplorerContext.anomalyTimelineStateService.getViewBySwimlaneFieldName$() + ); + + const swimLaneSeverity = useObservable( + anomalyExplorerContext.anomalyTimelineStateService.getSwimLaneSeverity$(), + anomalyExplorerContext.anomalyTimelineStateService.getSwimLaneSeverity() + ); + + const swimLaneBucketInterval = useObservable( + anomalyExplorerContext.anomalyTimelineStateService.getSwimLaneBucketInterval$(), + anomalyExplorerContext.anomalyTimelineStateService.getSwimLaneBucketInterval() + ); + + const influencersFilterQuery = useObservable( + anomalyExplorerContext.anomalyExplorerCommonStateService.getInfluencerFilterQuery$() + ); const loadExplorerDataConfig = explorerState !== undefined ? { lastRefresh, - influencersFilterQuery: explorerState.influencersFilterQuery, + influencersFilterQuery, noInfluencersConfigured: explorerState.noInfluencersConfigured, selectedCells, selectedJobs: explorerState.selectedJobs, - swimlaneBucketInterval: explorerState.swimlaneBucketInterval, + swimlaneBucketInterval: swimLaneBucketInterval, tableInterval: tableInterval.val, tableSeverity: tableSeverity.val, - viewBySwimlaneFieldName: explorerState.viewBySwimlaneFieldName, - swimlaneContainerWidth: explorerState.swimlaneContainerWidth, - viewByPerPage: explorerState.viewByPerPage, - viewByFromPage: explorerState.viewByFromPage, - swimLaneSeverity: explorerState.swimLaneSeverity, + viewBySwimlaneFieldName: viewByFieldName, + swimlaneContainerWidth, } : undefined; + useEffect( + function updateAnomalyExplorerCommonState() { + anomalyExplorerContext.anomalyExplorerCommonStateService.setSelectedJobs( + loadExplorerDataConfig?.selectedJobs! + ); + }, + [loadExplorerDataConfig] + ); + useEffect(() => { - /** - * For the "View by" swim lane the limit is the cardinality of the influencer values, - * which is known after the initial fetch. - * When looking up for top influencers for selected range in Overall swim lane - * the result is filtered by top influencers values, hence there is no need to set the limit. - */ - const swimlaneLimit = - isViewBySwimLaneData(explorerState?.viewBySwimlaneData) && !selectedCells?.showTopFieldValues - ? explorerState?.viewBySwimlaneData.cardinality - : undefined; - - if (explorerState && explorerState.swimlaneContainerWidth > 0) { - loadExplorerData({ - ...loadExplorerDataConfig, - swimlaneLimit, - }); + if (explorerState && loadExplorerDataConfig?.swimlaneContainerWidth! > 0) { + loadExplorerData(loadExplorerDataConfig); } - }, [JSON.stringify(loadExplorerDataConfig), selectedCells?.showTopFieldValues]); + }, [JSON.stringify(loadExplorerDataConfig)]); + + const overallSwimlaneData = useObservable( + anomalyExplorerContext.anomalyTimelineStateService.getOverallSwimLaneData$(), + null + ); - if ( - explorerState === undefined || - refresh === undefined || - explorerAppState?.mlShowCharts === undefined - ) { + if (explorerState === undefined || refresh === undefined) { return null; } @@ -286,23 +290,27 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim - {jobsWithTimeRange.length === 0 ? ( - - ) : ( - - )} + + {jobsWithTimeRange.length === 0 ? ( + + ) : ( + + )} + ); }; diff --git a/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts b/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts index c271c004f3668..ca1183129cfa1 100644 --- a/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts +++ b/x-pack/plugins/ml/public/application/services/anomaly_explorer_charts_service.ts @@ -729,6 +729,7 @@ export class AnomalyExplorerChartsService { config: SeriesConfigWithMetadata, range: ChartRange ) { + // FIXME performs an API call per chart. should perform 1 call for all charts return mlResultsService .getScheduledEventsByBucket( [config.jobId], diff --git a/x-pack/plugins/ml/public/application/services/anomaly_timeline_service.ts b/x-pack/plugins/ml/public/application/services/anomaly_timeline_service.ts index df6f9bcf1ac7e..e22f5680532ba 100644 --- a/x-pack/plugins/ml/public/application/services/anomaly_timeline_service.ts +++ b/x-pack/plugins/ml/public/application/services/anomaly_timeline_service.ts @@ -186,7 +186,7 @@ export class AnomalyTimelineService { influencersFilterQuery?: any, bucketInterval?: TimeBucketsInterval, swimLaneSeverity?: number - ): Promise { + ): Promise { const timefilterBounds = this.getTimeBounds(); if (timefilterBounds === undefined) { @@ -353,7 +353,7 @@ export class AnomalyTimelineService { bounds: any, viewBySwimlaneFieldName: string, interval: number - ): OverallSwimlaneData { + ): ViewBySwimLaneData { // Processes the scores for the 'view by' swim lane. // Sorts the lanes according to the supplied array of lane // values in the order in which they should be displayed, 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 b6575c48b21f2..3d49d03c1cde6 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 @@ -43,6 +43,8 @@ declare interface JobService { getJobAndGroupIds(): Promise; getJob(jobId: string): CombinedJob; loadJobsWrapper(): Promise; + customUrlsByJob: Record; + detectorsByJob: Record; } export const mlJobService: JobService; diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts index a9f6dbb45f6e3..5275680c499b1 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts @@ -21,12 +21,13 @@ import { ESSearchResponse, } from '../../../../../../../src/core/types/elasticsearch'; import { MLAnomalyDoc } from '../../../../common/types/anomalies'; +import type { EntityField } from '../../../../common/util/anomaly_utils'; export const resultsApiProvider = (httpService: HttpService) => ({ getAnomaliesTableData( jobIds: string[], criteriaFields: string[], - influencers: string[], + influencers: EntityField[], aggregationInterval: string, threshold: number, earliestMs: number, diff --git a/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts b/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts index f40db03ab4460..d6fef2f0a9657 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts +++ b/x-pack/plugins/ml/public/application/services/results_service/results_service.d.ts @@ -28,7 +28,7 @@ export function resultsServiceProvider(mlApiServices: MlApiServices): { selectedJobIds: string[], earliestMs: number, latestMs: number, - maxFieldValues: number, + maxFieldValues?: number, perPage?: number, fromPage?: number, influencers?: EntityField[], diff --git a/x-pack/plugins/ml/public/application/services/timefilter_refresh_service.tsx b/x-pack/plugins/ml/public/application/services/timefilter_refresh_service.tsx index fa11b830a386c..4fcedcba56b1a 100644 --- a/x-pack/plugins/ml/public/application/services/timefilter_refresh_service.tsx +++ b/x-pack/plugins/ml/public/application/services/timefilter_refresh_service.tsx @@ -7,6 +7,6 @@ import { Subject } from 'rxjs'; -import { Refresh } from '../routing/use_refresh'; +import type { Refresh } from '../routing/use_refresh'; export const mlTimefilterRefresh$ = new Subject(); diff --git a/x-pack/plugins/ml/public/application/util/url_state.tsx b/x-pack/plugins/ml/public/application/util/url_state.tsx index 7b20b841a9d9e..09be67a2203ef 100644 --- a/x-pack/plugins/ml/public/application/util/url_state.tsx +++ b/x-pack/plugins/ml/public/application/util/url_state.tsx @@ -6,15 +6,26 @@ */ import { parse, stringify } from 'query-string'; -import React, { createContext, useCallback, useContext, useMemo, FC } from 'react'; +import React, { + createContext, + useCallback, + useContext, + useMemo, + FC, + useRef, + useEffect, +} from 'react'; import { isEqual } from 'lodash'; import { decode, encode } from 'rison-node'; import { useHistory, useLocation } from 'react-router-dom'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { distinctUntilChanged } from 'rxjs/operators'; import { Dictionary } from '../../../common/types/common'; import { getNestedProperty } from './object_utils'; import { MlPages } from '../../../common/constants/locator'; +import { isPopulatedObject } from '../../../common'; type Accessor = '_a' | '_g'; export type SetUrlState = ( @@ -146,7 +157,12 @@ export const UrlStateProvider: FC = ({ children }) => { return {children}; }; -export const useUrlState = (accessor: Accessor) => { +export const useUrlState = ( + accessor: Accessor +): [ + Record, + (attribute: string | Dictionary, value?: unknown, replaceState?: boolean) => void +] => { const { searchString, setUrlState: setUrlStateContext } = useContext(urlStateStore); const urlState = useMemo(() => { @@ -157,7 +173,7 @@ export const useUrlState = (accessor: Accessor) => { }, [searchString]); const setUrlState = useCallback( - (attribute: string | Dictionary, value?: any, replaceState?: boolean) => { + (attribute: string | Dictionary, value?: unknown, replaceState?: boolean) => { setUrlStateContext(accessor, attribute, value, replaceState); }, [accessor, setUrlStateContext] @@ -174,26 +190,90 @@ export type AppStateKey = | MlPages | LegacyUrlKeys; +/** + * Service for managing URL state of particular page. + */ +export class PageUrlStateService { + private _pageUrlState$ = new BehaviorSubject(null); + private _pageUrlStateCallback: ((update: Partial, replaceState?: boolean) => void) | null = + null; + + /** + * Provides updates for the page URL state. + */ + public getPageUrlState$(): Observable { + return this._pageUrlState$.pipe(distinctUntilChanged(isEqual)); + } + + public updateUrlState(update: Partial, replaceState?: boolean): void { + if (!this._pageUrlStateCallback) { + throw new Error('Callback has not been initialized.'); + } + this._pageUrlStateCallback(update, replaceState); + } + + public setCurrentState(currentState: T): void { + this._pageUrlState$.next(currentState); + } + + public setUpdateCallback(callback: (update: Partial, replaceState?: boolean) => void): void { + this._pageUrlStateCallback = callback; + } +} + /** * Hook for managing the URL state of the page. */ -export const usePageUrlState = ( +export const usePageUrlState = ( pageKey: AppStateKey, defaultState?: PageUrlState -): [PageUrlState, (update: Partial, replaceState?: boolean) => void] => { +): [ + PageUrlState, + (update: Partial, replaceState?: boolean) => void, + PageUrlStateService +] => { const [appState, setAppState] = useUrlState('_a'); const pageState = appState?.[pageKey]; + const setCallback = useRef(); + + useEffect(() => { + setCallback.current = setAppState; + }, [setAppState]); + + const prevPageState = useRef(); + const resultPageState: PageUrlState = useMemo(() => { - return { + const result = { ...(defaultState ?? {}), ...(pageState ?? {}), }; + + if (isEqual(result, prevPageState.current)) { + return prevPageState.current; + } + + // Compare prev and current states to only update changed values + if (isPopulatedObject(prevPageState.current)) { + for (const key in result) { + if (isEqual(result[key], prevPageState.current[key])) { + result[key] = prevPageState.current[key]; + } + } + } + + prevPageState.current = result; + + return result; }, [pageState]); const onStateUpdate = useCallback( (update: Partial, replaceState?: boolean) => { - setAppState( + if (!setCallback?.current) { + throw new Error('Callback for URL state update has not been initialized.'); + } + + setCallback.current( pageKey, { ...resultPageState, @@ -202,10 +282,20 @@ export const usePageUrlState = ( replaceState ); }, - [pageKey, resultPageState, setAppState] + [pageKey, resultPageState] + ); + + const pageUrlStateService = useMemo(() => new PageUrlStateService(), []); + + useEffect( + function updatePageUrlService() { + pageUrlStateService.setCurrentState(resultPageState); + pageUrlStateService.setUpdateCallback(onStateUpdate); + }, + [pageUrlStateService, onStateUpdate, resultPageState] ); return useMemo(() => { - return [resultPageState, onStateUpdate]; - }, [resultPageState, onStateUpdate]); + return [resultPageState, onStateUpdate, pageUrlStateService]; + }, [resultPageState, onStateUpdate, pageUrlStateService]); }; diff --git a/x-pack/test/functional/apps/ml/anomaly_detection/anomaly_explorer.ts b/x-pack/test/functional/apps/ml/anomaly_detection/anomaly_explorer.ts index 5f6fa7c0a97ec..225c4e08be100 100644 --- a/x-pack/test/functional/apps/ml/anomaly_detection/anomaly_explorer.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection/anomaly_explorer.ts @@ -209,7 +209,7 @@ export default function ({ getService }: FtrProviderContext) { await ml.testExecution.logTestStep('updates the URL state'); await ml.navigation.assertCurrentURLContains( - 'selectedLanes%3A!(Overall)%2CselectedTimes%3A!(1454846400%2C1454860800)%2CselectedType%3Aoverall%2CshowTopFieldValues%3A!t%2CviewByFieldName%3Aairline%2CviewByFromPage%3A1%2CviewByPerPage%3A10' + 'selectedLanes%3A!(Overall)%2CselectedTimes%3A!(1454846400%2C1454860800)%2CselectedType%3Aoverall%2CshowTopFieldValues%3A!t' ); await ml.testExecution.logTestStep('clears the selection'); diff --git a/x-pack/test/functional/services/ml/anomaly_explorer.ts b/x-pack/test/functional/services/ml/anomaly_explorer.ts index 08b026bbb308f..6aae8d0f9e02f 100644 --- a/x-pack/test/functional/services/ml/anomaly_explorer.ts +++ b/x-pack/test/functional/services/ml/anomaly_explorer.ts @@ -140,9 +140,15 @@ export function MachineLearningAnomalyExplorerProvider({ async assertClearSelectionButtonVisible(expectVisible: boolean) { if (expectVisible) { - await testSubjects.existOrFail('mlAnomalyTimelineClearSelection'); + expect(await testSubjects.isDisplayed('mlAnomalyTimelineClearSelection')).to.eql( + true, + `Expected 'Clear selection' button to be displayed` + ); } else { - await testSubjects.missingOrFail('mlAnomalyTimelineClearSelection'); + expect(await testSubjects.isDisplayed('mlAnomalyTimelineClearSelection')).to.eql( + false, + `Expected 'Clear selection' button to be hidden` + ); } }, From b20628fb3ddfd22fb09ee303b2d101d9b6ad5358 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ece=20=C3=96zalp?= Date: Mon, 14 Mar 2022 09:24:36 -0400 Subject: [PATCH 04/44] [CTI] Removes CTI warning from overview page (#127066) --- .../overview/cti_link_panel.spec.ts | 7 +-- .../cypress/screens/overview.ts | 3 - .../cti_enabled_module.test.tsx | 2 +- .../overview_cti_links/cti_enabled_module.tsx | 10 +--- .../overview_cti_links/index.test.tsx | 21 +------ .../components/overview_cti_links/index.tsx | 9 +-- .../threat_intel_panel_view.tsx | 33 +---------- .../overview_cti_links/translations.ts | 7 --- .../overview_cti_links/use_ti_integrations.ts | 55 ------------------- .../public/overview/pages/overview.test.tsx | 5 -- .../public/overview/pages/overview.tsx | 7 +-- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 13 files changed, 12 insertions(+), 149 deletions(-) delete mode 100644 x-pack/plugins/security_solution/public/overview/containers/overview_cti_links/use_ti_integrations.ts diff --git a/x-pack/plugins/security_solution/cypress/integration/overview/cti_link_panel.spec.ts b/x-pack/plugins/security_solution/cypress/integration/overview/cti_link_panel.spec.ts index 75ff13b66b29c..92daa295d2701 100644 --- a/x-pack/plugins/security_solution/cypress/integration/overview/cti_link_panel.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/overview/cti_link_panel.spec.ts @@ -9,9 +9,7 @@ import { OVERVIEW_CTI_ENABLE_MODULE_BUTTON, OVERVIEW_CTI_LINKS, OVERVIEW_CTI_LINKS_ERROR_INNER_PANEL, - OVERVIEW_CTI_LINKS_INFO_INNER_PANEL, OVERVIEW_CTI_TOTAL_EVENT_COUNT, - OVERVIEW_CTI_ENABLE_INTEGRATIONS_BUTTON, } from '../../screens/overview'; import { loginAndWaitForPage } from '../../tasks/login'; @@ -47,14 +45,13 @@ describe('CTI Link Panel', () => { loginAndWaitForPage( `${OVERVIEW_URL}?sourcerer=(timerange:(from:%272021-07-08T04:00:00.000Z%27,kind:absolute,to:%272021-07-09T03:59:59.999Z%27))` ); - cy.get(`${OVERVIEW_CTI_LINKS} ${OVERVIEW_CTI_LINKS_INFO_INNER_PANEL}`).should('exist'); + cy.get(`${OVERVIEW_CTI_LINKS}`).should('exist'); cy.get(`${OVERVIEW_CTI_TOTAL_EVENT_COUNT}`).should('have.text', 'Showing: 0 indicators'); }); it('renders dashboard module as expected when there are events in the selected time period', () => { loginAndWaitForPage(OVERVIEW_URL); - cy.get(`${OVERVIEW_CTI_LINKS} ${OVERVIEW_CTI_LINKS_INFO_INNER_PANEL}`).should('exist'); - cy.get(`${OVERVIEW_CTI_LINKS} ${OVERVIEW_CTI_ENABLE_INTEGRATIONS_BUTTON}`).should('exist'); + cy.get(`${OVERVIEW_CTI_LINKS}`).should('exist'); cy.get(OVERVIEW_CTI_LINKS).should('not.contain.text', 'Anomali'); cy.get(OVERVIEW_CTI_LINKS).should('contain.text', 'AbuseCH malware'); cy.get(`${OVERVIEW_CTI_TOTAL_EVENT_COUNT}`).should('have.text', 'Showing: 1 indicator'); diff --git a/x-pack/plugins/security_solution/cypress/screens/overview.ts b/x-pack/plugins/security_solution/cypress/screens/overview.ts index bc335ff6680ee..e478f16e72844 100644 --- a/x-pack/plugins/security_solution/cypress/screens/overview.ts +++ b/x-pack/plugins/security_solution/cypress/screens/overview.ts @@ -150,9 +150,6 @@ export const OVERVIEW_REVENT_TIMELINES = '[data-test-subj="overview-recent-timel export const OVERVIEW_CTI_LINKS = '[data-test-subj="cti-dashboard-links"]'; export const OVERVIEW_CTI_LINKS_ERROR_INNER_PANEL = '[data-test-subj="cti-inner-panel-danger"]'; -export const OVERVIEW_CTI_LINKS_INFO_INNER_PANEL = '[data-test-subj="cti-inner-panel-info"]'; -export const OVERVIEW_CTI_ENABLE_INTEGRATIONS_BUTTON = - '[data-test-subj="cti-enable-integrations-button"]'; export const OVERVIEW_CTI_TOTAL_EVENT_COUNT = `${OVERVIEW_CTI_LINKS} [data-test-subj="header-panel-subtitle"]`; export const OVERVIEW_CTI_ENABLE_MODULE_BUTTON = '[data-test-subj="cti-enable-module-button"]'; diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_enabled_module.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_enabled_module.test.tsx index fc36a0c4337cf..a804e2efc4588 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_enabled_module.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_enabled_module.test.tsx @@ -49,7 +49,7 @@ describe('CtiEnabledModule', () => { - + diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_enabled_module.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_enabled_module.tsx index a339676ac361f..4341cab4ec98c 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_enabled_module.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/cti_enabled_module.tsx @@ -12,7 +12,7 @@ import { useCtiDashboardLinks } from '../../containers/overview_cti_links'; import { ThreatIntelPanelView } from './threat_intel_panel_view'; export const CtiEnabledModuleComponent: React.FC = (props) => { - const { to, from, allIntegrationsInstalled, allTiDataSources, setQuery, deleteQuery } = props; + const { to, from, allTiDataSources, setQuery, deleteQuery } = props; const { tiDataSources, totalCount } = useTiDataSources({ to, from, @@ -22,13 +22,7 @@ export const CtiEnabledModuleComponent: React.FC = (p }); const { listItems } = useCtiDashboardLinks({ to, from, tiDataSources }); - return ( - - ); + return ; }; export const CtiEnabledModule = React.memo(CtiEnabledModuleComponent); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/index.test.tsx index 71d6d5eb0c583..26c306b7a587a 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/index.test.tsx @@ -49,7 +49,7 @@ describe('ThreatIntelLinkPanel', () => { - + @@ -59,29 +59,12 @@ describe('ThreatIntelLinkPanel', () => { expect(wrapper.find('[data-test-subj="cti-enable-integrations-button"]').length).toEqual(0); }); - it('renders Enable source buttons when not all integrations installed', () => { - const wrapper = mount( - - - - - - - - ); - expect(wrapper.find('[data-test-subj="cti-enable-integrations-button"]').length).not.toBe(0); - }); - it('renders CtiDisabledModule when Threat Intel module is disabled', () => { const wrapper = mount( - + diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/index.tsx index c89199c2cb0c5..5428c8c8b032c 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/index.tsx @@ -16,20 +16,15 @@ export type ThreatIntelLinkPanelProps = Pick< GlobalTimeArgs, 'from' | 'to' | 'deleteQuery' | 'setQuery' > & { - allIntegrationsInstalled: boolean | undefined; allTiDataSources: TiDataSources[]; }; const ThreatIntelLinkPanelComponent: React.FC = (props) => { - const { allIntegrationsInstalled, allTiDataSources } = props; + const { allTiDataSources } = props; const isThreatIntelModuleEnabled = allTiDataSources.length > 0; return isThreatIntelModuleEnabled ? (
- +
) : (
diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/threat_intel_panel_view.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/threat_intel_panel_view.tsx index 3697d27015fdc..0f80185750261 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/threat_intel_panel_view.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/threat_intel_panel_view.tsx @@ -6,17 +6,16 @@ */ import React, { useMemo } from 'react'; -import { EuiButton, EuiTableFieldDataColumnType } from '@elastic/eui'; +import { EuiTableFieldDataColumnType } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import * as i18n from './translations'; -import { LinkPanel, InnerLinkPanel, LinkPanelListItem } from '../link_panel'; +import { LinkPanel, LinkPanelListItem } from '../link_panel'; import { LinkPanelViewProps } from '../link_panel/types'; import { shortenCountIntoString } from '../../../common/utils/shorten_count_into_string'; import { Link } from '../link_panel/link'; import { ID as CTIEventCountQueryId } from '../../containers/overview_cti_links/use_ti_data_sources'; import { LINK_COPY } from '../overview_risky_host_links/translations'; -import { useIntegrationsPageLink } from './use_integrations_page_link'; const columns: Array> = [ { name: 'Name', field: 'title', sortable: true, truncateText: true, width: '100%' }, @@ -43,40 +42,12 @@ export const ThreatIntelPanelView: React.FC = ({ listItems, splitPanel, totalCount = 0, - allIntegrationsInstalled, }) => { - const integrationsLink = useIntegrationsPageLink(); - return ( ( - <> - {allIntegrationsInstalled === false ? ( - - {i18n.DANGER_BUTTON} - - } - /> - ) : null} - - ), - [allIntegrationsInstalled, integrationsLink] - ), inspectQueryId: isInspectEnabled ? CTIEventCountQueryId : undefined, listItems, panelTitle: i18n.PANEL_TITLE, diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/translations.ts b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/translations.ts index e112942b09749..ab3c9559ea291 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/translations.ts +++ b/x-pack/plugins/security_solution/public/overview/components/overview_cti_links/translations.ts @@ -72,13 +72,6 @@ export const VIEW_DASHBOARD = i18n.translate('xpack.securitySolution.overview.ct defaultMessage: 'View dashboard', }); -export const SOME_MODULES_DISABLE_TITLE = i18n.translate( - 'xpack.securitySolution.overview.ctiDashboardSomeModulesDisabledTItle', - { - defaultMessage: 'Some threat intel sources are disabled', - } -); - export const OTHER_DATA_SOURCE_TITLE = i18n.translate( 'xpack.securitySolution.overview.ctiDashboardOtherDatasourceTitle', { diff --git a/x-pack/plugins/security_solution/public/overview/containers/overview_cti_links/use_ti_integrations.ts b/x-pack/plugins/security_solution/public/overview/containers/overview_cti_links/use_ti_integrations.ts deleted file mode 100644 index 24bdc191b3d66..0000000000000 --- a/x-pack/plugins/security_solution/public/overview/containers/overview_cti_links/use_ti_integrations.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { useEffect, useState } from 'react'; - -import { installationStatuses } from '../../../../../fleet/common'; -import { TI_INTEGRATION_PREFIX } from '../../../../common/cti/constants'; -import { fetchFleetIntegrations, IntegrationResponse } from './api'; - -export interface Integration { - id: string; - dashboardIds: string[]; -} - -interface TiIntegrationStatus { - allIntegrationsInstalled: boolean; -} - -export const useTiIntegrations = () => { - const [tiIntegrationsStatus, setTiIntegrationsStatus] = useState( - null - ); - - useEffect(() => { - const getPackages = async () => { - try { - const { response: integrations } = await fetchFleetIntegrations(); - const tiIntegrations = integrations.filter((integration: IntegrationResponse) => - integration.id.startsWith(TI_INTEGRATION_PREFIX) - ); - - const allIntegrationsInstalled = tiIntegrations.every( - (integration: IntegrationResponse) => - integration.status === installationStatuses.Installed - ); - - setTiIntegrationsStatus({ - allIntegrationsInstalled, - }); - } catch (e) { - setTiIntegrationsStatus({ - allIntegrationsInstalled: false, - }); - } - }; - - getPackages(); - }, []); - - return tiIntegrationsStatus; -}; diff --git a/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx b/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx index c74f3092dfd5e..200d42075180c 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx @@ -21,7 +21,6 @@ import { useUserPrivileges } from '../../common/components/user_privileges'; import { useSourcererDataView } from '../../common/containers/sourcerer'; import { useFetchIndex } from '../../common/containers/source'; import { useAllTiDataSources } from '../containers/overview_cti_links/use_all_ti_data_sources'; -import { useTiIntegrations } from '../containers/overview_cti_links/use_ti_integrations'; import { mockCtiLinksResponse, mockTiDataSources } from '../components/overview_cti_links/mock'; import { useCtiDashboardLinks } from '../containers/overview_cti_links'; import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; @@ -76,10 +75,6 @@ jest.mock('../containers/overview_cti_links/use_all_ti_data_sources'); const useAllTiDataSourcesMock = useAllTiDataSources as jest.Mock; useAllTiDataSourcesMock.mockReturnValue(mockTiDataSources); -jest.mock('../containers/overview_cti_links/use_ti_integrations'); -const useTiIntegrationsMock = useTiIntegrations as jest.Mock; -useTiIntegrationsMock.mockReturnValue({}); - jest.mock('../../risk_score/containers'); const useHostRiskScoreMock = useHostRiskScore as jest.Mock; useHostRiskScoreMock.mockReturnValue([false, { data: [], isModuleEnabled: false }]); diff --git a/x-pack/plugins/security_solution/public/overview/pages/overview.tsx b/x-pack/plugins/security_solution/public/overview/pages/overview.tsx index 91c3be79a680b..ca95f41e0ea12 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/overview.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/overview.tsx @@ -30,7 +30,6 @@ import { useSourcererDataView } from '../../common/containers/sourcerer'; import { useDeepEqualSelector } from '../../common/hooks/use_selector'; import { ThreatIntelLinkPanel } from '../components/overview_cti_links'; import { useAllTiDataSources } from '../containers/overview_cti_links/use_all_ti_data_sources'; -import { useTiIntegrations } from '../containers/overview_cti_links/use_ti_integrations'; import { useUserPrivileges } from '../../common/components/user_privileges'; import { RiskyHostLinks } from '../components/overview_risky_host_links'; import { useAlertsPrivileges } from '../../detections/containers/detection_engine/alerts/use_alerts_privileges'; @@ -67,10 +66,7 @@ const OverviewComponent = () => { endpointPrivileges: { canAccessFleet }, } = useUserPrivileges(); const { hasIndexRead, hasKibanaREAD } = useAlertsPrivileges(); - const { tiDataSources: allTiDataSources, isInitiallyLoaded: allTiDataSourcesLoaded } = - useAllTiDataSources(); - const tiIntegrationStatus = useTiIntegrations(); - const isTiLoaded = tiIntegrationStatus && allTiDataSourcesLoaded; + const { tiDataSources: allTiDataSources, isInitiallyLoaded: isTiLoaded } = useAllTiDataSources(); const riskyHostsEnabled = useIsExperimentalFeatureEnabled('riskyHostsEnabled'); @@ -149,7 +145,6 @@ const OverviewComponent = () => { {isTiLoaded && ( Date: Mon, 14 Mar 2022 13:39:27 +0000 Subject: [PATCH 05/44] skip flaky suite (#126422) --- .../apps/ml/data_frame_analytics/results_view_content.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/results_view_content.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/results_view_content.ts index e2db6ca28a6e6..41adf8dc8c49b 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/results_view_content.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/results_view_content.ts @@ -14,7 +14,8 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const ml = getService('ml'); - describe('results view content and total feature importance', function () { + // FLAKY: https://github.com/elastic/kibana/issues/126422 + describe.skip('results view content and total feature importance', function () { const testDataList: Array<{ suiteTitle: string; archive: string; From 8d7566b2262e0dfc594a506979f728f07245417e Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 14 Mar 2022 13:40:59 +0000 Subject: [PATCH 06/44] skip flaky suite (#111381) --- .../reporting_and_security/network_policy.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/network_policy.ts b/x-pack/test/reporting_api_integration/reporting_and_security/network_policy.ts index 212b3f6de97c4..0bc164227fe14 100644 --- a/x-pack/test/reporting_api_integration/reporting_and_security/network_policy.ts +++ b/x-pack/test/reporting_api_integration/reporting_and_security/network_policy.ts @@ -18,7 +18,8 @@ export default function ({ getService }: FtrProviderContext) { * The Reporting API Functional Test config implements a network policy that * is designed to disallow the following Canvas worksheet */ - describe('Network Policy', () => { + // FLAKY: https://github.com/elastic/kibana/issues/111381 + describe.skip('Network Policy', () => { before(async () => { await reportingAPI.initLogs(); // includes a canvas worksheet with an offending image URL }); From 1ef51475f014d41ae7fd1e1b6f188d316eea6642 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 14 Mar 2022 13:42:37 +0000 Subject: [PATCH 07/44] skip flaky suite (#127545) --- .../plugin_functional/test_suites/core_plugins/applications.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/plugin_functional/test_suites/core_plugins/applications.ts b/test/plugin_functional/test_suites/core_plugins/applications.ts index 0145a84423b3c..df4ac37b96464 100644 --- a/test/plugin_functional/test_suites/core_plugins/applications.ts +++ b/test/plugin_functional/test_suites/core_plugins/applications.ts @@ -49,7 +49,8 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide const navigateTo = async (path: string) => await browser.navigateTo(`${deployment.getHostPort()}${path}`); - describe('ui applications', function describeIndexTests() { + // FLAKY: https://github.com/elastic/kibana/issues/127545 + describe.skip('ui applications', function describeIndexTests() { before(async () => { await esArchiver.emptyKibanaIndex(); await PageObjects.common.navigateToApp('foo'); From acba06784e1a30e328ed586b3ea56e22e7f9d1d4 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 14 Mar 2022 13:44:06 +0000 Subject: [PATCH 08/44] skip flaky suite (#127431) --- .../functional/apps/apm/correlations/latency_correlations.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/apm/correlations/latency_correlations.ts b/x-pack/test/functional/apps/apm/correlations/latency_correlations.ts index 200b3367b9723..22b553d006303 100644 --- a/x-pack/test/functional/apps/apm/correlations/latency_correlations.ts +++ b/x-pack/test/functional/apps/apm/correlations/latency_correlations.ts @@ -26,7 +26,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }; describe('latency correlations', () => { - describe('space with no features disabled', () => { + // FLAKY: https://github.com/elastic/kibana/issues/127431 + describe.skip('space with no features disabled', () => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/infra/8.0.0/metrics_and_apm'); await spacesService.create({ From 4f0294044f476f063aa58fd654e08523cd86076d Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 14 Mar 2022 13:52:32 +0000 Subject: [PATCH 09/44] skip flaky suite (#126870) --- x-pack/test/api_integration/apis/ml/filters/get_filters.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/api_integration/apis/ml/filters/get_filters.ts b/x-pack/test/api_integration/apis/ml/filters/get_filters.ts index 87c65ff0247b9..049b6936f1893 100644 --- a/x-pack/test/api_integration/apis/ml/filters/get_filters.ts +++ b/x-pack/test/api_integration/apis/ml/filters/get_filters.ts @@ -26,7 +26,8 @@ export default ({ getService }: FtrProviderContext) => { }, ]; - describe('get_filters', function () { + // FLAKY: https://github.com/elastic/kibana/issues/126870 + describe.skip('get_filters', function () { before(async () => { await ml.testResources.setKibanaTimeZoneToUTC(); for (const filter of validFilters) { From 371c52a1ed383183ba9dd61938b7ca477bf4c365 Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Mon, 14 Mar 2022 09:06:26 -0500 Subject: [PATCH 10/44] [plugin-helpers] Skip build.test.ts log matching on ci-stats service (#127560) This copies the fix over from https://github.com/elastic/kibana/pull/123510 to be used in both plugin-helpers build calls. Closes #89079 --- .../src/integration_tests/build.test.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/kbn-plugin-helpers/src/integration_tests/build.test.ts b/packages/kbn-plugin-helpers/src/integration_tests/build.test.ts index 71a3fbe603718..2d7664aa13326 100644 --- a/packages/kbn-plugin-helpers/src/integration_tests/build.test.ts +++ b/packages/kbn-plugin-helpers/src/integration_tests/build.test.ts @@ -43,8 +43,14 @@ it('builds a generated plugin into a viable archive', async () => { all: true, } ); + const filterLogs = (logs: string | undefined) => { + return logs + ?.split('\n') + .filter((l) => !l.includes('failed to reach ci-stats service')) + .join('\n'); + }; - expect(generateProc.all).toMatchInlineSnapshot(` + expect(filterLogs(generateProc.all)).toMatchInlineSnapshot(` " succ 🎉 Your plugin has been created in plugins/foo_test_plugin @@ -60,12 +66,7 @@ it('builds a generated plugin into a viable archive', async () => { } ); - expect( - buildProc.all - ?.split('\n') - .filter((l) => !l.includes('failed to reach ci-stats service')) - .join('\n') - ).toMatchInlineSnapshot(` + expect(filterLogs(buildProc.all)).toMatchInlineSnapshot(` " info deleting the build and target directories info running @kbn/optimizer │ info initialized, 0 bundles cached From b4b08a5a839a418d28d234ef0c9570814906de9f Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Mon, 14 Mar 2022 15:35:23 +0100 Subject: [PATCH 11/44] fix ff color editor crash (#127292) --- .../common/converters/color.test.ts | 19 +++++++++++++++++++ .../field_formats/common/converters/color.tsx | 6 +++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/plugins/field_formats/common/converters/color.test.ts b/src/plugins/field_formats/common/converters/color.test.ts index 994c6d802ae3b..617945b3d1cdc 100644 --- a/src/plugins/field_formats/common/converters/color.test.ts +++ b/src/plugins/field_formats/common/converters/color.test.ts @@ -112,5 +112,24 @@ describe('Color Format', () => { expect(converter('<', HTML_CONTEXT_TYPE)).toBe('<'); }); + + test('returns original value (escaped) on regex with syntax error', () => { + const colorer = new ColorFormat( + { + fieldType: 'string', + colors: [ + { + regex: 'nogroup(', + text: 'blue', + background: 'yellow', + }, + ], + }, + jest.fn() + ); + const converter = colorer.getConverterFor(HTML_CONTEXT_TYPE) as Function; + + expect(converter('<', HTML_CONTEXT_TYPE)).toBe('<'); + }); }); }); diff --git a/src/plugins/field_formats/common/converters/color.tsx b/src/plugins/field_formats/common/converters/color.tsx index 3e5ff97830479..197468fc1592a 100644 --- a/src/plugins/field_formats/common/converters/color.tsx +++ b/src/plugins/field_formats/common/converters/color.tsx @@ -35,7 +35,11 @@ export class ColorFormat extends FieldFormat { switch (this.param('fieldType')) { case 'string': return findLast(this.param('colors'), (colorParam: typeof DEFAULT_CONVERTER_COLOR) => { - return new RegExp(colorParam.regex).test(val as string); + try { + return new RegExp(colorParam.regex).test(val as string); + } catch (e) { + return false; + } }); case 'number': From 56c38b6a857be6b5e99e028c5db40230cc124d3b Mon Sep 17 00:00:00 2001 From: Max Kovalev Date: Mon, 14 Mar 2022 17:14:02 +0200 Subject: [PATCH 12/44] [Maps] Register GeoJson upload with integrations page (#126350) * #116653 - added GeoJson upload for Intergrations page * 116653 - refactoring for messages * 116653 - fixed Checks and api tests * 116653 - fixed checks * 116653 - refactoring; Added constants and registrations for shapefile * 116653 - fixed api test * 116653 - added method for getting selected wizard * 116653 - refactoring * 116653 - refactoring; Added constants, variables renamed * 116653 - prettier fixes * 116653 - refactoring; functional test added * 116653 - test file extension changed to TS * 116653 - refactoring * 116653 - refactoring; test updated * 116653 - refactoring Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../apis/custom_integration/integrations.ts | 2 +- x-pack/plugins/maps/common/constants.ts | 21 +++++++++ .../plugins/maps/public/actions/ui_actions.ts | 13 ++++++ .../choropleth_layer_wizard.tsx | 3 +- .../wizards/file_upload_wizard/config.tsx | 2 + .../wizards/layer_wizard_registry.test.tsx | 4 ++ .../layers/wizards/layer_wizard_registry.ts | 5 +++ .../layers/wizards/load_layer_wizards.ts | 1 + .../new_vector_layer_wizard/config.tsx | 3 +- .../observability_layer_wizard.tsx | 3 +- .../security/security_layer_wizard.tsx | 3 +- .../ems_boundaries_layer_wizard.tsx | 3 +- .../ems_base_map_layer_wizard.tsx | 3 +- .../clusters_layer_wizard.tsx | 2 + .../heatmap_layer_wizard.tsx | 8 +++- .../es_geo_line_source/layer_wizard.tsx | 8 +++- .../point_2_point_layer_wizard.tsx | 2 + .../es_documents_layer_wizard.tsx | 3 +- .../es_search_source/top_hits/wizard.tsx | 3 +- .../kibana_base_map_layer_wizard.tsx | 3 +- .../layer_wizard.tsx | 3 +- .../sources/wms_source/wms_layer_wizard.tsx | 3 +- .../sources/xyz_tms_source/layer_wizard.tsx | 3 +- .../add_layer_panel/index.ts | 6 +++ .../add_layer_panel/view.tsx | 17 ++++++++ .../map_container/map_container.tsx | 2 - x-pack/plugins/maps/public/reducers/ui.ts | 5 +++ x-pack/plugins/maps/public/render_app.tsx | 2 + .../maps/public/routes/map_page/map_page.tsx | 7 ++- .../get_open_layer_wizard_url_param.ts | 23 ++++++++++ .../public/routes/map_page/saved_map/index.ts | 1 + .../routes/map_page/saved_map/saved_map.ts | 9 ++++ .../maps/public/selectors/ui_selectors.ts | 1 + .../maps/server/register_integrations.ts | 43 ++++++++++++++++++- .../apps/maps/geofile_wizard_auto_open.ts | 40 +++++++++++++++++ x-pack/test/functional/apps/maps/index.js | 1 + 36 files changed, 242 insertions(+), 19 deletions(-) create mode 100644 x-pack/plugins/maps/public/routes/map_page/saved_map/get_open_layer_wizard_url_param.ts create mode 100644 x-pack/test/functional/apps/maps/geofile_wizard_auto_open.ts diff --git a/test/api_integration/apis/custom_integration/integrations.ts b/test/api_integration/apis/custom_integration/integrations.ts index c49aefe91925f..e71cc045da321 100644 --- a/test/api_integration/apis/custom_integration/integrations.ts +++ b/test/api_integration/apis/custom_integration/integrations.ts @@ -22,7 +22,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body).to.be.an('array'); - expect(resp.body.length).to.be(36); + expect(resp.body.length).to.be(38); // Test for sample data card expect(resp.body.findIndex((c: { id: string }) => c.id === 'sample_data_all')).to.be.above( diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index 1720f0ebdb558..435b4e55b4cec 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -30,6 +30,7 @@ export const CHECK_IS_DRAWING_INDEX = `/${GIS_API_PATH}/checkIsDrawingIndex`; export const MVT_GETTILE_API_PATH = 'mvt/getTile'; export const MVT_GETGRIDTILE_API_PATH = 'mvt/getGridTile'; +export const OPEN_LAYER_WIZARD = 'openLayerWizard'; // Identifies centroid feature. // Centroids are a single point for representing lines, multiLines, polygons, and multiPolygons @@ -286,3 +287,23 @@ export const MAPS_NEW_VECTOR_LAYER_META_CREATED_BY = 'maps-new-vector-layer'; export const MAX_DRAWING_SIZE_BYTES = 10485760; // 10MB export const emsWorldLayerId = 'world_countries'; + +export enum WIZARD_ID { + CHOROPLETH = 'choropleth', + GEO_FILE = 'uploadGeoFile', + NEW_VECTOR = 'newVectorLayer', + OBSERVABILITY = 'observabilityLayer', + SECURITY = 'securityLayer', + EMS_BOUNDARIES = 'emsBoundaries', + EMS_BASEMAP = 'emsBaseMap', + CLUSTERS = 'clusters', + HEATMAP = 'heatmap', + GEO_LINE = 'geoLine', + POINT_2_POINT = 'point2Point', + ES_DOCUMENT = 'esDocument', + ES_TOP_HITS = 'esTopHits', + KIBANA_BASEMAP = 'kibanaBasemap', + MVT_VECTOR = 'mvtVector', + WMS_LAYER = 'wmsLayer', + TMS_LAYER = 'tmsLayer', +} diff --git a/x-pack/plugins/maps/public/actions/ui_actions.ts b/x-pack/plugins/maps/public/actions/ui_actions.ts index 1ffcf416f6f8f..bdc0e91556712 100644 --- a/x-pack/plugins/maps/public/actions/ui_actions.ts +++ b/x-pack/plugins/maps/public/actions/ui_actions.ts @@ -24,6 +24,7 @@ export const SET_OPEN_TOC_DETAILS = 'SET_OPEN_TOC_DETAILS'; export const SHOW_TOC_DETAILS = 'SHOW_TOC_DETAILS'; export const HIDE_TOC_DETAILS = 'HIDE_TOC_DETAILS'; export const SET_DRAW_MODE = 'SET_DRAW_MODE'; +export const SET_AUTO_OPEN_WIZARD_ID = 'SET_AUTO_OPEN_WIZARD_ID'; export const PUSH_DELETED_FEATURE_ID = 'PUSH_DELETED_FEATURE_ID'; export const CLEAR_DELETED_FEATURE_IDS = 'CLEAR_DELETED_FEATURE_IDS'; @@ -126,6 +127,18 @@ export function closeTimeslider() { }; } +export function setAutoOpenLayerWizardId(autoOpenLayerWizardId: string) { + return (dispatch: ThunkDispatch) => { + dispatch(setSelectedLayer(null)); + dispatch(updateFlyout(FLYOUT_STATE.ADD_LAYER_WIZARD)); + dispatch(setDrawMode(DRAW_MODE.NONE)); + dispatch({ + type: SET_AUTO_OPEN_WIZARD_ID, + autoOpenLayerWizardId, + }); + }; +} + export function pushDeletedFeatureId(featureId: string) { return { type: PUSH_DELETED_FEATURE_ID, diff --git a/x-pack/plugins/maps/public/classes/layers/wizards/choropleth_layer_wizard/choropleth_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/layers/wizards/choropleth_layer_wizard/choropleth_layer_wizard.tsx index 4334a34785433..9f2bbf168a083 100644 --- a/x-pack/plugins/maps/public/classes/layers/wizards/choropleth_layer_wizard/choropleth_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/layers/wizards/choropleth_layer_wizard/choropleth_layer_wizard.tsx @@ -7,12 +7,13 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { LAYER_WIZARD_CATEGORY } from '../../../../../common/constants'; +import { LAYER_WIZARD_CATEGORY, WIZARD_ID } from '../../../../../common/constants'; import { LayerWizard, RenderWizardArguments } from '../layer_wizard_registry'; import { LayerTemplate } from './layer_template'; import { ChoroplethLayerIcon } from '../icons/cloropleth_layer_icon'; export const choroplethLayerWizardConfig: LayerWizard = { + id: WIZARD_ID.CHOROPLETH, order: 10, categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH], description: i18n.translate('xpack.maps.choropleth.desc', { diff --git a/x-pack/plugins/maps/public/classes/layers/wizards/file_upload_wizard/config.tsx b/x-pack/plugins/maps/public/classes/layers/wizards/file_upload_wizard/config.tsx index 1dc94c37437eb..e0852b6f9300f 100644 --- a/x-pack/plugins/maps/public/classes/layers/wizards/file_upload_wizard/config.tsx +++ b/x-pack/plugins/maps/public/classes/layers/wizards/file_upload_wizard/config.tsx @@ -10,8 +10,10 @@ import React from 'react'; import { LayerWizard, RenderWizardArguments } from '../layer_wizard_registry'; import { ClientFileCreateSourceEditor, UPLOAD_STEPS } from './wizard'; import { getFileUpload } from '../../../../kibana_services'; +import { WIZARD_ID } from '../../../../../common/constants'; export const uploadLayerWizardConfig: LayerWizard = { + id: WIZARD_ID.GEO_FILE, order: 10, categories: [], description: i18n.translate('xpack.maps.fileUploadWizard.description', { diff --git a/x-pack/plugins/maps/public/classes/layers/wizards/layer_wizard_registry.test.tsx b/x-pack/plugins/maps/public/classes/layers/wizards/layer_wizard_registry.test.tsx index aa54ea4286f1a..0c94ab62d44bc 100644 --- a/x-pack/plugins/maps/public/classes/layers/wizards/layer_wizard_registry.test.tsx +++ b/x-pack/plugins/maps/public/classes/layers/wizards/layer_wizard_registry.test.tsx @@ -16,6 +16,7 @@ import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; describe('LayerWizardRegistryTest', () => { it('should enforce ordering', async () => { registerLayerWizardExternal({ + id: '', categories: [LAYER_WIZARD_CATEGORY.REFERENCE], description: '', icon: '', @@ -27,6 +28,7 @@ describe('LayerWizardRegistryTest', () => { }); registerLayerWizardInternal({ + id: '', order: 1, categories: [LAYER_WIZARD_CATEGORY.REFERENCE], description: '', @@ -38,6 +40,7 @@ describe('LayerWizardRegistryTest', () => { }); registerLayerWizardInternal({ + id: '', order: 1, categories: [LAYER_WIZARD_CATEGORY.REFERENCE], description: '', @@ -58,6 +61,7 @@ describe('LayerWizardRegistryTest', () => { it('external users must add order higher than 99 ', async () => { expect(() => { registerLayerWizardExternal({ + id: '', order: 99, categories: [LAYER_WIZARD_CATEGORY.REFERENCE], description: '', diff --git a/x-pack/plugins/maps/public/classes/layers/wizards/layer_wizard_registry.ts b/x-pack/plugins/maps/public/classes/layers/wizards/layer_wizard_registry.ts index 6ab8a3d9a2f56..977b72c4a2aba 100644 --- a/x-pack/plugins/maps/public/classes/layers/wizards/layer_wizard_registry.ts +++ b/x-pack/plugins/maps/public/classes/layers/wizards/layer_wizard_registry.ts @@ -12,6 +12,7 @@ import type { LayerDescriptor } from '../../../../common/descriptor_types'; import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; export type LayerWizard = { + id: string; title: string; categories: LAYER_WIZARD_CATEGORY[]; /* @@ -90,3 +91,7 @@ export async function getLayerWizards(): Promise { return wizard1.order - wizard2.order; }); } + +export function getWizardById(wizardId: string) { + return registry.find((wizard) => wizard.id === wizardId); +} diff --git a/x-pack/plugins/maps/public/classes/layers/wizards/load_layer_wizards.ts b/x-pack/plugins/maps/public/classes/layers/wizards/load_layer_wizards.ts index 3bf64d08fc845..aa772d44341e6 100644 --- a/x-pack/plugins/maps/public/classes/layers/wizards/load_layer_wizards.ts +++ b/x-pack/plugins/maps/public/classes/layers/wizards/load_layer_wizards.ts @@ -29,6 +29,7 @@ import { choroplethLayerWizardConfig } from './choropleth_layer_wizard'; import { newVectorLayerWizardConfig } from './new_vector_layer_wizard'; let registered = false; + export function registerLayerWizards() { if (registered) { return; diff --git a/x-pack/plugins/maps/public/classes/layers/wizards/new_vector_layer_wizard/config.tsx b/x-pack/plugins/maps/public/classes/layers/wizards/new_vector_layer_wizard/config.tsx index b83410a4eef04..c5aa0fc7db1fd 100644 --- a/x-pack/plugins/maps/public/classes/layers/wizards/new_vector_layer_wizard/config.tsx +++ b/x-pack/plugins/maps/public/classes/layers/wizards/new_vector_layer_wizard/config.tsx @@ -11,11 +11,12 @@ import { LayerWizard, RenderWizardArguments } from '../layer_wizard_registry'; import { NewVectorLayerEditor } from './wizard'; import { DrawLayerIcon } from '../icons/draw_layer_icon'; import { getFileUpload } from '../../../../kibana_services'; -import { LAYER_WIZARD_CATEGORY } from '../../../../../common/constants'; +import { LAYER_WIZARD_CATEGORY, WIZARD_ID } from '../../../../../common/constants'; const ADD_VECTOR_DRAWING_LAYER = 'ADD_VECTOR_DRAWING_LAYER'; export const newVectorLayerWizardConfig: LayerWizard = { + id: WIZARD_ID.NEW_VECTOR, order: 10, categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH], description: i18n.translate('xpack.maps.newVectorLayerWizard.description', { diff --git a/x-pack/plugins/maps/public/classes/layers/wizards/solution_layers/observability/observability_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/layers/wizards/solution_layers/observability/observability_layer_wizard.tsx index 2e023f7c588d3..60526cfbaded4 100644 --- a/x-pack/plugins/maps/public/classes/layers/wizards/solution_layers/observability/observability_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/layers/wizards/solution_layers/observability/observability_layer_wizard.tsx @@ -7,13 +7,14 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { LAYER_WIZARD_CATEGORY } from '../../../../../../common/constants'; +import { LAYER_WIZARD_CATEGORY, WIZARD_ID } from '../../../../../../common/constants'; import { LayerWizard, RenderWizardArguments } from '../../layer_wizard_registry'; import { ObservabilityLayerTemplate } from './observability_layer_template'; import { APM_INDEX_PATTERN_ID } from './create_layer_descriptor'; import { getIndexPatternService } from '../../../../../kibana_services'; export const ObservabilityLayerWizardConfig: LayerWizard = { + id: WIZARD_ID.OBSERVABILITY, order: 20, categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH, LAYER_WIZARD_CATEGORY.SOLUTIONS], getIsDisabled: async () => { diff --git a/x-pack/plugins/maps/public/classes/layers/wizards/solution_layers/security/security_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/layers/wizards/solution_layers/security/security_layer_wizard.tsx index 79575ea815124..625ead02e2f5f 100644 --- a/x-pack/plugins/maps/public/classes/layers/wizards/solution_layers/security/security_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/layers/wizards/solution_layers/security/security_layer_wizard.tsx @@ -7,12 +7,13 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { LAYER_WIZARD_CATEGORY } from '../../../../../../common/constants'; +import { LAYER_WIZARD_CATEGORY, WIZARD_ID } from '../../../../../../common/constants'; import { LayerWizard, RenderWizardArguments } from '../../layer_wizard_registry'; import { getSecurityIndexPatterns } from './security_index_pattern_utils'; import { SecurityLayerTemplate } from './security_layer_template'; export const SecurityLayerWizardConfig: LayerWizard = { + id: WIZARD_ID.SECURITY, order: 20, categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH, LAYER_WIZARD_CATEGORY.SOLUTIONS], getIsDisabled: async () => { diff --git a/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_boundaries_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_boundaries_layer_wizard.tsx index 8fe8f1b3a155f..27a3960bfbe1d 100644 --- a/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_boundaries_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/ems_file_source/ems_boundaries_layer_wizard.tsx @@ -15,7 +15,7 @@ import { EMSFileSource, getSourceTitle } from './ems_file_source'; // @ts-ignore import { getEMSSettings } from '../../../kibana_services'; import { EMSFileSourceDescriptor } from '../../../../common/descriptor_types'; -import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; +import { LAYER_WIZARD_CATEGORY, WIZARD_ID } from '../../../../common/constants'; import { EMSBoundariesLayerIcon } from '../../layers/wizards/icons/ems_boundaries_layer_icon'; function getDescription() { @@ -29,6 +29,7 @@ function getDescription() { } export const emsBoundariesLayerWizardConfig: LayerWizard = { + id: WIZARD_ID.EMS_BOUNDARIES, order: 10, categories: [LAYER_WIZARD_CATEGORY.REFERENCE], checkVisibility: async () => { diff --git a/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_base_map_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_base_map_layer_wizard.tsx index 27d911cc8feb9..58deab255032b 100644 --- a/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_base_map_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/ems_tms_source/ems_base_map_layer_wizard.tsx @@ -13,7 +13,7 @@ import { EmsVectorTileLayer } from '../../layers/ems_vector_tile_layer/ems_vecto import { EmsTmsSourceConfig } from './tile_service_select'; import { CreateSourceEditor } from './create_source_editor'; import { getEMSSettings } from '../../../kibana_services'; -import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; +import { LAYER_WIZARD_CATEGORY, WIZARD_ID } from '../../../../common/constants'; import { WorldMapLayerIcon } from '../../layers/wizards/icons/world_map_layer_icon'; function getDescription() { @@ -27,6 +27,7 @@ function getDescription() { } export const emsBaseMapLayerWizardConfig: LayerWizard = { + id: WIZARD_ID.EMS_BASEMAP, order: 10, categories: [LAYER_WIZARD_CATEGORY.REFERENCE], checkVisibility: async () => { diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/clusters_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/clusters_layer_wizard.tsx index e075a615d5867..422aa4ab80ec8 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/clusters_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/clusters_layer_wizard.tsx @@ -28,11 +28,13 @@ import { RENDER_AS, VECTOR_STYLES, STYLE_TYPE, + WIZARD_ID, } from '../../../../common/constants'; import { NUMERICAL_COLOR_PALETTES } from '../../styles/color_palettes'; import { ClustersLayerIcon } from '../../layers/wizards/icons/clusters_layer_icon'; export const clustersLayerWizardConfig: LayerWizard = { + id: WIZARD_ID.CLUSTERS, order: 10, categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH], description: i18n.translate('xpack.maps.source.esGridClustersDescription', { diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/heatmap_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/heatmap_layer_wizard.tsx index 5e67a83811561..53dbcfac95970 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/heatmap_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/heatmap_layer_wizard.tsx @@ -13,10 +13,16 @@ import { ESGeoGridSource, heatmapTitle } from './es_geo_grid_source'; import { LayerWizard, RenderWizardArguments } from '../../layers'; import { HeatmapLayer } from '../../layers/heatmap_layer'; import { ESGeoGridSourceDescriptor } from '../../../../common/descriptor_types'; -import { GRID_RESOLUTION, LAYER_WIZARD_CATEGORY, RENDER_AS } from '../../../../common/constants'; +import { + GRID_RESOLUTION, + LAYER_WIZARD_CATEGORY, + RENDER_AS, + WIZARD_ID, +} from '../../../../common/constants'; import { HeatmapLayerIcon } from '../../layers/wizards/icons/heatmap_layer_icon'; export const heatmapLayerWizardConfig: LayerWizard = { + id: WIZARD_ID.HEATMAP, order: 10, categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH], description: i18n.translate('xpack.maps.source.esGridHeatmapDescription', { diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/layer_wizard.tsx index 18d459ddbcb78..2957235602d7b 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_line_source/layer_wizard.tsx @@ -10,13 +10,19 @@ import React from 'react'; import { CreateSourceEditor } from './create_source_editor'; import { ESGeoLineSource, geoLineTitle, REQUIRES_GOLD_LICENSE_MSG } from './es_geo_line_source'; import { LayerWizard, RenderWizardArguments } from '../../layers'; -import { LAYER_WIZARD_CATEGORY, STYLE_TYPE, VECTOR_STYLES } from '../../../../common/constants'; +import { + LAYER_WIZARD_CATEGORY, + STYLE_TYPE, + VECTOR_STYLES, + WIZARD_ID, +} from '../../../../common/constants'; import { VectorStyle } from '../../styles/vector/vector_style'; import { GeoJsonVectorLayer } from '../../layers/vector_layer'; import { getIsGoldPlus } from '../../../licensed_features'; import { TracksLayerIcon } from '../../layers/wizards/icons/tracks_layer_icon'; export const geoLineLayerWizardConfig: LayerWizard = { + id: WIZARD_ID.GEO_LINE, order: 10, categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH], description: i18n.translate('xpack.maps.source.esGeoLineDescription', { diff --git a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx index e3522d39e892d..37ecbfdebab11 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx @@ -18,6 +18,7 @@ import { LAYER_WIZARD_CATEGORY, VECTOR_STYLES, STYLE_TYPE, + WIZARD_ID, } from '../../../../common/constants'; import { NUMERICAL_COLOR_PALETTES } from '../../styles/color_palettes'; // @ts-ignore @@ -27,6 +28,7 @@ import { ColorDynamicOptions, SizeDynamicOptions } from '../../../../common/desc import { Point2PointLayerIcon } from '../../layers/wizards/icons/point_2_point_layer_icon'; export const point2PointLayerWizardConfig: LayerWizard = { + id: WIZARD_ID.POINT_2_POINT, order: 10, categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH], description: i18n.translate('xpack.maps.source.pewPewDescription', { diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_documents_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_documents_layer_wizard.tsx index 82fb1c502ef6a..92580f92f0279 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_documents_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_documents_layer_wizard.tsx @@ -12,7 +12,7 @@ import { CreateSourceEditor } from './create_source_editor'; import { LayerWizard, RenderWizardArguments } from '../../layers'; import { ESSearchSource, sourceTitle } from './es_search_source'; import { BlendedVectorLayer, GeoJsonVectorLayer, MvtVectorLayer } from '../../layers/vector_layer'; -import { LAYER_WIZARD_CATEGORY, SCALING_TYPES } from '../../../../common/constants'; +import { LAYER_WIZARD_CATEGORY, SCALING_TYPES, WIZARD_ID } from '../../../../common/constants'; import { DocumentsLayerIcon } from '../../layers/wizards/icons/documents_layer_icon'; import { ESSearchSourceDescriptor, @@ -35,6 +35,7 @@ export function createDefaultLayerDescriptor( } export const esDocumentsLayerWizardConfig: LayerWizard = { + id: WIZARD_ID.ES_DOCUMENT, order: 10, categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH], description: i18n.translate('xpack.maps.source.esSearchDescription', { diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/top_hits/wizard.tsx b/x-pack/plugins/maps/public/classes/sources/es_search_source/top_hits/wizard.tsx index 7c01fed158b0d..051afb41ce00f 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/top_hits/wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/top_hits/wizard.tsx @@ -10,12 +10,13 @@ import React from 'react'; import { CreateSourceEditor } from './create_source_editor'; import { LayerWizard, RenderWizardArguments } from '../../../layers'; import { GeoJsonVectorLayer } from '../../../layers/vector_layer'; -import { LAYER_WIZARD_CATEGORY } from '../../../../../common/constants'; +import { LAYER_WIZARD_CATEGORY, WIZARD_ID } from '../../../../../common/constants'; import { TopHitsLayerIcon } from '../../../layers/wizards/icons/top_hits_layer_icon'; import { ESSearchSourceDescriptor } from '../../../../../common/descriptor_types'; import { ESSearchSource } from '../es_search_source'; export const esTopHitsLayerWizardConfig: LayerWizard = { + id: WIZARD_ID.ES_TOP_HITS, order: 10, categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH], description: i18n.translate('xpack.maps.source.topHitsDescription', { diff --git a/x-pack/plugins/maps/public/classes/sources/kibana_tilemap_source/kibana_base_map_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/kibana_tilemap_source/kibana_base_map_layer_wizard.tsx index 0f3475eeae9ee..ef2d7e05a5cbd 100644 --- a/x-pack/plugins/maps/public/classes/sources/kibana_tilemap_source/kibana_base_map_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/kibana_tilemap_source/kibana_base_map_layer_wizard.tsx @@ -14,9 +14,10 @@ import { CreateSourceEditor } from './create_source_editor'; import { KibanaTilemapSource, sourceTitle } from './kibana_tilemap_source'; import { RasterTileLayer } from '../../layers/raster_tile_layer/raster_tile_layer'; import { getKibanaTileMap } from '../../../util'; -import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; +import { LAYER_WIZARD_CATEGORY, WIZARD_ID } from '../../../../common/constants'; export const kibanaBasemapLayerWizardConfig: LayerWizard = { + id: WIZARD_ID.KIBANA_BASEMAP, order: 10, categories: [LAYER_WIZARD_CATEGORY.REFERENCE], checkVisibility: async () => { diff --git a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/layer_wizard.tsx index f123ed7c78054..329b00404c234 100644 --- a/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/mvt_single_layer_vector_source/layer_wizard.tsx @@ -11,11 +11,12 @@ import { MVTSingleLayerVectorSourceEditor } from './mvt_single_layer_vector_sour import { MVTSingleLayerVectorSource, sourceTitle } from './mvt_single_layer_vector_source'; import { LayerWizard, RenderWizardArguments } from '../../layers'; import { MvtVectorLayer } from '../../layers/vector_layer'; -import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; +import { LAYER_WIZARD_CATEGORY, WIZARD_ID } from '../../../../common/constants'; import { TiledSingleLayerVectorSourceSettings } from '../../../../common/descriptor_types'; import { VectorTileLayerIcon } from '../../layers/wizards/icons/vector_tile_layer_icon'; export const mvtVectorSourceWizardConfig: LayerWizard = { + id: WIZARD_ID.MVT_VECTOR, order: 10, categories: [LAYER_WIZARD_CATEGORY.REFERENCE], description: i18n.translate('xpack.maps.source.mvtVectorSourceWizard', { diff --git a/x-pack/plugins/maps/public/classes/sources/wms_source/wms_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/wms_source/wms_layer_wizard.tsx index 2f79b8d0984d0..3b1f5e728eed0 100644 --- a/x-pack/plugins/maps/public/classes/sources/wms_source/wms_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/wms_source/wms_layer_wizard.tsx @@ -13,10 +13,11 @@ import { WMSCreateSourceEditor } from './wms_create_source_editor'; import { sourceTitle, WMSSource } from './wms_source'; import { LayerWizard, RenderWizardArguments } from '../../layers'; import { RasterTileLayer } from '../../layers/raster_tile_layer/raster_tile_layer'; -import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; +import { LAYER_WIZARD_CATEGORY, WIZARD_ID } from '../../../../common/constants'; import { WebMapServiceLayerIcon } from '../../layers/wizards/icons/web_map_service_layer_icon'; export const wmsLayerWizardConfig: LayerWizard = { + id: WIZARD_ID.WMS_LAYER, order: 10, categories: [LAYER_WIZARD_CATEGORY.REFERENCE], description: i18n.translate('xpack.maps.source.wmsDescription', { diff --git a/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/layer_wizard.tsx index 7c137419f4a19..4333fbcbfff6a 100644 --- a/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/layer_wizard.tsx @@ -11,10 +11,11 @@ import { XYZTMSEditor, XYZTMSSourceConfig } from './xyz_tms_editor'; import { XYZTMSSource, sourceTitle } from './xyz_tms_source'; import { LayerWizard, RenderWizardArguments } from '../../layers'; import { RasterTileLayer } from '../../layers/raster_tile_layer/raster_tile_layer'; -import { LAYER_WIZARD_CATEGORY } from '../../../../common/constants'; +import { LAYER_WIZARD_CATEGORY, WIZARD_ID } from '../../../../common/constants'; import { WorldMapLayerIcon } from '../../layers/wizards/icons/world_map_layer_icon'; export const tmsLayerWizardConfig: LayerWizard = { + id: WIZARD_ID.TMS_LAYER, order: 10, categories: [LAYER_WIZARD_CATEGORY.REFERENCE], description: i18n.translate('xpack.maps.source.ems_xyzDescription', { diff --git a/x-pack/plugins/maps/public/connected_components/add_layer_panel/index.ts b/x-pack/plugins/maps/public/connected_components/add_layer_panel/index.ts index ed10b135899d5..b790c0c1da5be 100644 --- a/x-pack/plugins/maps/public/connected_components/add_layer_panel/index.ts +++ b/x-pack/plugins/maps/public/connected_components/add_layer_panel/index.ts @@ -18,16 +18,19 @@ import { setFirstPreviewLayerToSelectedLayer, setEditLayerToSelectedLayer, updateFlyout, + setAutoOpenLayerWizardId, } from '../../actions'; import { MapStoreState } from '../../reducers/store'; import { LayerDescriptor } from '../../../common/descriptor_types'; import { hasPreviewLayers, isLoadingPreviewLayers } from '../../selectors/map_selectors'; import { DRAW_MODE } from '../../../common/constants'; +import { getAutoOpenLayerWizardId } from '../../selectors/ui_selectors'; function mapStateToProps(state: MapStoreState) { return { hasPreviewLayers: hasPreviewLayers(state), isLoadingPreviewLayers: isLoadingPreviewLayers(state), + autoOpenLayerWizardId: getAutoOpenLayerWizardId(state), }; } @@ -49,6 +52,9 @@ function mapDispatchToProps(dispatch: ThunkDispatch { + dispatch(setAutoOpenLayerWizardId('')); + }, }; } diff --git a/x-pack/plugins/maps/public/connected_components/add_layer_panel/view.tsx b/x-pack/plugins/maps/public/connected_components/add_layer_panel/view.tsx index 6cb5c94c5cd82..578059b174454 100644 --- a/x-pack/plugins/maps/public/connected_components/add_layer_panel/view.tsx +++ b/x-pack/plugins/maps/public/connected_components/add_layer_panel/view.tsx @@ -20,6 +20,7 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { FlyoutBody } from './flyout_body'; import { LayerDescriptor } from '../../../common/descriptor_types'; import { LayerWizard } from '../../classes/layers'; +import { getWizardById } from '../../classes/layers/wizards/layer_wizard_registry'; export const ADD_LAYER_STEP_ID = 'ADD_LAYER_STEP_ID'; const ADD_LAYER_STEP_LABEL = i18n.translate('xpack.maps.addLayerPanel.addLayer', { @@ -34,6 +35,8 @@ export interface Props { isLoadingPreviewLayers: boolean; promotePreviewLayers: () => void; enableEditMode: () => void; + autoOpenLayerWizardId: string; + clearAutoOpenLayerWizardId: () => void; } interface State { @@ -59,6 +62,20 @@ export class AddLayerPanel extends Component { ...INITIAL_STATE, }; + componentDidMount() { + if (this.props.autoOpenLayerWizardId) { + this._openWizard(); + } + } + + _openWizard() { + const selectedWizard = getWizardById(this.props.autoOpenLayerWizardId); + if (selectedWizard) { + this._onWizardSelect(selectedWizard); + } + this.props.clearAutoOpenLayerWizardId(); + } + _previewLayers = (layerDescriptors: LayerDescriptor[]) => { this.props.addPreviewLayers(layerDescriptors); }; diff --git a/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx b/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx index bfc8474fec88e..581460f318583 100644 --- a/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx +++ b/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx @@ -27,7 +27,6 @@ import { RawValue } from '../../../common/constants'; import { FLYOUT_STATE } from '../../reducers/ui'; import { MapSettings } from '../../reducers/map'; import { MapSettingsPanel } from '../map_settings_panel'; -import { registerLayerWizards } from '../../classes/layers/wizards/load_layer_wizards'; import { RenderToolTipContent } from '../../classes/tooltips/tooltip_property'; import { ILayer } from '../../classes/layers/layer'; @@ -81,7 +80,6 @@ export class MapContainer extends Component { this._isMounted = true; this._loadShowFitToBoundsButton(); this._loadShowTimesliderButton(); - registerLayerWizards(); } componentDidUpdate() { diff --git a/x-pack/plugins/maps/public/reducers/ui.ts b/x-pack/plugins/maps/public/reducers/ui.ts index f0f22c5a8c4a9..1ee7dc3e38e29 100644 --- a/x-pack/plugins/maps/public/reducers/ui.ts +++ b/x-pack/plugins/maps/public/reducers/ui.ts @@ -19,6 +19,7 @@ import { SHOW_TOC_DETAILS, HIDE_TOC_DETAILS, SET_DRAW_MODE, + SET_AUTO_OPEN_WIZARD_ID, PUSH_DELETED_FEATURE_ID, CLEAR_DELETED_FEATURE_IDS, } from '../actions'; @@ -39,6 +40,7 @@ export type MapUiState = { isLayerTOCOpen: boolean; isTimesliderOpen: boolean; openTOCDetails: string[]; + autoOpenLayerWizardId: string; deletedFeatureIds: string[]; }; @@ -54,6 +56,7 @@ export const DEFAULT_MAP_UI_STATE = { // storing TOC detail visibility outside of map.layerList because its UI state and not map rendering state. // This also makes for easy read/write access for embeddables. openTOCDetails: [], + autoOpenLayerWizardId: '', deletedFeatureIds: [], }; @@ -86,6 +89,8 @@ export function ui(state: MapUiState = DEFAULT_MAP_UI_STATE, action: any) { return layerId !== action.layerId; }), }; + case SET_AUTO_OPEN_WIZARD_ID: + return { ...state, autoOpenLayerWizardId: action.autoOpenLayerWizardId }; case PUSH_DELETED_FEATURE_ID: return { ...state, diff --git a/x-pack/plugins/maps/public/render_app.tsx b/x-pack/plugins/maps/public/render_app.tsx index bf7aec7f5f15e..aa5e1ee29833d 100644 --- a/x-pack/plugins/maps/public/render_app.tsx +++ b/x-pack/plugins/maps/public/render_app.tsx @@ -27,6 +27,7 @@ import { import { ListPage, MapPage } from './routes'; import { MapByValueInput, MapByReferenceInput } from './embeddable/types'; import { APP_ID } from '../common/constants'; +import { registerLayerWizards } from './classes/layers/wizards/load_layer_wizards'; export let goToSpecifiedPath: (path: string) => void; export let kbnUrlStateStorage: IKbnUrlStateStorage; @@ -75,6 +76,7 @@ export async function renderApp( const stateTransfer = getEmbeddableService().getStateTransfer(); + registerLayerWizards(); setAppChrome(); function renderMapApp(routeProps: RouteComponentProps<{ savedMapId?: string }>) { diff --git a/x-pack/plugins/maps/public/routes/map_page/map_page.tsx b/x-pack/plugins/maps/public/routes/map_page/map_page.tsx index b382be1d506bd..dc7030a805ce1 100644 --- a/x-pack/plugins/maps/public/routes/map_page/map_page.tsx +++ b/x-pack/plugins/maps/public/routes/map_page/map_page.tsx @@ -10,7 +10,11 @@ import { Provider } from 'react-redux'; import type { AppMountParameters } from 'kibana/public'; import type { EmbeddableStateTransfer } from 'src/plugins/embeddable/public'; import { MapApp } from './map_app'; -import { SavedMap, getInitialLayersFromUrlParam } from './saved_map'; +import { + SavedMap, + getInitialLayersFromUrlParam, + getOpenLayerWizardFromUrlParam, +} from './saved_map'; import { MapEmbeddableInput } from '../../embeddable/types'; interface Props { @@ -47,6 +51,7 @@ export class MapPage extends Component { originatingPath: props.originatingPath, stateTransfer: props.stateTransfer, onSaveCallback: this.updateSaveCounter, + defaultLayerWizard: getOpenLayerWizardFromUrlParam() || '', }), saveCounter: 0, }; diff --git a/x-pack/plugins/maps/public/routes/map_page/saved_map/get_open_layer_wizard_url_param.ts b/x-pack/plugins/maps/public/routes/map_page/saved_map/get_open_layer_wizard_url_param.ts new file mode 100644 index 0000000000000..099a756b70521 --- /dev/null +++ b/x-pack/plugins/maps/public/routes/map_page/saved_map/get_open_layer_wizard_url_param.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { OPEN_LAYER_WIZARD } from '../../../../common/constants'; + +export function getOpenLayerWizardFromUrlParam() { + const locationSplit = window.location.href.split(/[?#]+/); + + if (locationSplit.length <= 1) { + return ''; + } + + const mapAppParams = new URLSearchParams(locationSplit[1]); + if (!mapAppParams.has(OPEN_LAYER_WIZARD)) { + return ''; + } + + return mapAppParams.has(OPEN_LAYER_WIZARD) ? mapAppParams.get(OPEN_LAYER_WIZARD) : ''; +} diff --git a/x-pack/plugins/maps/public/routes/map_page/saved_map/index.ts b/x-pack/plugins/maps/public/routes/map_page/saved_map/index.ts index a3e8ef96160bb..c204267e0f9a6 100644 --- a/x-pack/plugins/maps/public/routes/map_page/saved_map/index.ts +++ b/x-pack/plugins/maps/public/routes/map_page/saved_map/index.ts @@ -12,3 +12,4 @@ export { getInitialQuery } from './get_initial_query'; export { getInitialRefreshConfig } from './get_initial_refresh_config'; export { getInitialTimeFilters } from './get_initial_time_filters'; export { unsavedChangesTitle, unsavedChangesWarning } from './get_breadcrumbs'; +export { getOpenLayerWizardFromUrlParam } from './get_open_layer_wizard_url_param'; diff --git a/x-pack/plugins/maps/public/routes/map_page/saved_map/saved_map.ts b/x-pack/plugins/maps/public/routes/map_page/saved_map/saved_map.ts index a9547fe90a007..781a72aabf78b 100644 --- a/x-pack/plugins/maps/public/routes/map_page/saved_map/saved_map.ts +++ b/x-pack/plugins/maps/public/routes/map_page/saved_map/saved_map.ts @@ -48,6 +48,7 @@ import { DEFAULT_IS_LAYER_TOC_OPEN } from '../../../reducers/ui'; import { createBasemapLayerDescriptor } from '../../../classes/layers/create_basemap_layer_descriptor'; import { whenLicenseInitialized } from '../../../licensed_features'; import { SerializedMapState, SerializedUiState } from './types'; +import { setAutoOpenLayerWizardId } from '../../../actions/ui_actions'; export class SavedMap { private _attributes: MapSavedObjectAttributes | null = null; @@ -62,6 +63,7 @@ export class SavedMap { private readonly _stateTransfer?: EmbeddableStateTransfer; private readonly _store: MapStore; private _tags: string[] = []; + private _defaultLayerWizard: string; constructor({ defaultLayers = [], @@ -71,6 +73,7 @@ export class SavedMap { originatingApp, stateTransfer, originatingPath, + defaultLayerWizard, }: { defaultLayers?: LayerDescriptor[]; mapEmbeddableInput?: MapEmbeddableInput; @@ -79,6 +82,7 @@ export class SavedMap { originatingApp?: string; stateTransfer?: EmbeddableStateTransfer; originatingPath?: string; + defaultLayerWizard?: string; }) { this._defaultLayers = defaultLayers; this._mapEmbeddableInput = mapEmbeddableInput; @@ -88,6 +92,7 @@ export class SavedMap { this._originatingPath = originatingPath; this._stateTransfer = stateTransfer; this._store = createMapStore(); + this._defaultLayerWizard = defaultLayerWizard || ''; } public getStore() { @@ -204,6 +209,10 @@ export class SavedMap { this._store.dispatch(setHiddenLayers(this._mapEmbeddableInput.hiddenLayers)); } this._initialLayerListConfig = copyPersistentState(layerList); + + if (this._defaultLayerWizard) { + this._store.dispatch(setAutoOpenLayerWizardId(this._defaultLayerWizard)); + } } hasUnsavedChanges = () => { diff --git a/x-pack/plugins/maps/public/selectors/ui_selectors.ts b/x-pack/plugins/maps/public/selectors/ui_selectors.ts index 6bdf5a35679a7..1011a736e5ce9 100644 --- a/x-pack/plugins/maps/public/selectors/ui_selectors.ts +++ b/x-pack/plugins/maps/public/selectors/ui_selectors.ts @@ -17,4 +17,5 @@ export const getIsTimesliderOpen = ({ ui }: MapStoreState): boolean => ui.isTime export const getOpenTOCDetails = ({ ui }: MapStoreState): string[] => ui.openTOCDetails; export const getIsFullScreen = ({ ui }: MapStoreState): boolean => ui.isFullScreen; export const getIsReadOnly = ({ ui }: MapStoreState): boolean => ui.isReadOnly; +export const getAutoOpenLayerWizardId = ({ ui }: MapStoreState): string => ui.autoOpenLayerWizardId; export const getDeletedFeatureIds = ({ ui }: MapStoreState): string[] => ui.deletedFeatureIds; diff --git a/x-pack/plugins/maps/server/register_integrations.ts b/x-pack/plugins/maps/server/register_integrations.ts index 8832c746f6db8..3740ae6242790 100644 --- a/x-pack/plugins/maps/server/register_integrations.ts +++ b/x-pack/plugins/maps/server/register_integrations.ts @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import { CoreSetup } from 'kibana/server'; import { CustomIntegrationsPluginSetup } from '../../../../src/plugins/custom_integrations/server'; -import { APP_ID } from '../common/constants'; +import { APP_ID, OPEN_LAYER_WIZARD, getFullPath, WIZARD_ID } from '../common/constants'; export function registerIntegrations( core: CoreSetup, @@ -35,4 +35,45 @@ export function registerIntegrations( shipper: 'other', isBeta: false, }); + customIntegrations.registerCustomIntegration({ + id: 'ingest_geojson', + title: i18n.translate('xpack.maps.registerIntegrations.geojson.integrationTitle', { + defaultMessage: 'GeoJSON', + }), + description: i18n.translate('xpack.maps.registerIntegrations.geojson.integrationDescription', { + defaultMessage: 'Upload GeoJSON files with Elastic Maps.', + }), + uiInternalPath: `${getFullPath('')}#?${OPEN_LAYER_WIZARD}=${WIZARD_ID.GEO_FILE}`, + icons: [ + { + type: 'eui', + src: 'logoMaps', + }, + ], + categories: ['upload_file', 'geo'], + shipper: 'other', + isBeta: false, + }); + customIntegrations.registerCustomIntegration({ + id: 'ingest_shape', + title: i18n.translate('xpack.maps.registerIntegrations.shapefile.integrationTitle', { + defaultMessage: 'Shapefile', + }), + description: i18n.translate( + 'xpack.maps.registerIntegrations.shapefile.integrationDescription', + { + defaultMessage: 'Upload Shapefiles with Elastic Maps.', + } + ), + uiInternalPath: `${getFullPath('')}#?${OPEN_LAYER_WIZARD}=${WIZARD_ID.GEO_FILE}`, + icons: [ + { + type: 'eui', + src: 'logoMaps', + }, + ], + categories: ['upload_file', 'geo'], + shipper: 'other', + isBeta: false, + }); } diff --git a/x-pack/test/functional/apps/maps/geofile_wizard_auto_open.ts b/x-pack/test/functional/apps/maps/geofile_wizard_auto_open.ts new file mode 100644 index 0000000000000..dd69cc7882fc7 --- /dev/null +++ b/x-pack/test/functional/apps/maps/geofile_wizard_auto_open.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const PageObjects = getPageObjects(['common', 'maps']); + const find = getService('find'); + const browser = getService('browser'); + const retry = getService('retry'); + + describe('Auto open file upload wizard in maps app', () => { + before(async () => { + await PageObjects.common.navigateToUrl('integrations', 'browse', { + useActualUrl: true, + }); + const geoFileCard = await find.byCssSelector( + '[data-test-subj="integration-card:ui_link:ingest_geojson"]' + ); + geoFileCard.click(); + }); + + it('should navigate to maps app with url params', async () => { + const currentUrl = await browser.getCurrentUrl(); + expect(currentUrl).contain('openLayerWizard=uploadGeoFile'); + }); + + it('should upload form exist', async () => { + await retry.waitFor( + `Add layer panel to be visible`, + async () => await PageObjects.maps.isLayerAddPanelOpen() + ); + }); + }); +} diff --git a/x-pack/test/functional/apps/maps/index.js b/x-pack/test/functional/apps/maps/index.js index 43ab06dbb77a8..f8c5ddc37a216 100644 --- a/x-pack/test/functional/apps/maps/index.js +++ b/x-pack/test/functional/apps/maps/index.js @@ -95,6 +95,7 @@ export default function ({ loadTestFile, getService }) { loadTestFile(require.resolve('./layer_errors')); loadTestFile(require.resolve('./visualize_create_menu')); loadTestFile(require.resolve('./discover')); + loadTestFile(require.resolve('./geofile_wizard_auto_open')); }); }); } From 6d87f12f63490693f726368f4331457dea90c1fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=A1nchez?= Date: Mon, 14 Mar 2022 16:28:32 +0100 Subject: [PATCH 13/44] [Security Solution] [Endpoint] Add blocklist tab in policy view (#127458) * Adds blocklists tab in policy detail view * Fixes wrong .id on normalize function Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/management/common/constants.ts | 1 + .../public/management/common/routing.ts | 47 ++++++ .../management/pages/blocklist/constants.ts | 9 ++ .../public/management/pages/policy/index.tsx | 2 + .../selectors/policy_common_selectors.ts | 14 ++ .../selectors/policy_settings_selectors.ts | 6 +- .../pages/policy/view/policy_hooks.ts | 7 + .../view/tabs/blocklists_translations.ts | 153 ++++++++++++++++++ .../pages/policy/view/tabs/policy_tabs.tsx | 58 ++++++- 9 files changed, 295 insertions(+), 2 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/blocklists_translations.ts diff --git a/x-pack/plugins/security_solution/public/management/common/constants.ts b/x-pack/plugins/security_solution/public/management/common/constants.ts index dd3db8f3352a7..ea38414b0cb96 100644 --- a/x-pack/plugins/security_solution/public/management/common/constants.ts +++ b/x-pack/plugins/security_solution/public/management/common/constants.ts @@ -15,6 +15,7 @@ export const MANAGEMENT_ROUTING_POLICY_DETAILS_FORM_PATH = `${MANAGEMENT_PATH}/: export const MANAGEMENT_ROUTING_POLICY_DETAILS_TRUSTED_APPS_PATH = `${MANAGEMENT_PATH}/:tabName(${AdministrationSubTab.policies})/:policyId/trustedApps`; export const MANAGEMENT_ROUTING_POLICY_DETAILS_EVENT_FILTERS_PATH = `${MANAGEMENT_PATH}/:tabName(${AdministrationSubTab.policies})/:policyId/eventFilters`; export const MANAGEMENT_ROUTING_POLICY_DETAILS_HOST_ISOLATION_EXCEPTIONS_PATH = `${MANAGEMENT_PATH}/:tabName(${AdministrationSubTab.policies})/:policyId/hostIsolationExceptions`; +export const MANAGEMENT_ROUTING_POLICY_DETAILS_BLOCKLISTS_PATH = `${MANAGEMENT_PATH}/:tabName(${AdministrationSubTab.policies})/:policyId/blocklists`; /** @deprecated use the paths defined above instead */ export const MANAGEMENT_ROUTING_POLICY_DETAILS_PATH_OLD = `${MANAGEMENT_PATH}/:tabName(${AdministrationSubTab.policies})/:policyId`; export const MANAGEMENT_ROUTING_TRUSTED_APPS_PATH = `${MANAGEMENT_PATH}/:tabName(${AdministrationSubTab.trustedApps})`; diff --git a/x-pack/plugins/security_solution/public/management/common/routing.ts b/x-pack/plugins/security_solution/public/management/common/routing.ts index 2f5dbc762b5d7..d031e50152a66 100644 --- a/x-pack/plugins/security_solution/public/management/common/routing.ts +++ b/x-pack/plugins/security_solution/public/management/common/routing.ts @@ -10,6 +10,7 @@ import { isEmpty } from 'lodash/fp'; import querystring from 'querystring'; import { generatePath } from 'react-router-dom'; import { appendSearch } from '../../common/components/link_to/helpers'; +import { ArtifactListPageUrlParams } from '../components/artifact_list_page'; import { EndpointIndexUIQueryParams } from '../pages/endpoint_hosts/types'; import { EventFiltersPageLocation } from '../pages/event_filters/types'; import { HostIsolationExceptionsPageLocation } from '../pages/host_isolation_exceptions/types'; @@ -29,6 +30,8 @@ import { MANAGEMENT_ROUTING_POLICY_DETAILS_TRUSTED_APPS_PATH, MANAGEMENT_ROUTING_POLICY_DETAILS_EVENT_FILTERS_PATH, MANAGEMENT_ROUTING_TRUSTED_APPS_PATH, + MANAGEMENT_ROUTING_BLOCKLIST_PATH, + MANAGEMENT_ROUTING_POLICY_DETAILS_BLOCKLISTS_PATH, } from './constants'; // Taken from: https://github.com/microsoft/TypeScript/issues/12936#issuecomment-559034150 @@ -215,6 +218,29 @@ const normalizeEventFiltersPageLocation = ( } }; +const normalizBlocklistsPageLocation = ( + location?: Partial +): Partial => { + if (location) { + return { + ...(!isDefaultOrMissing(location.page, MANAGEMENT_DEFAULT_PAGE) + ? { page: location.page } + : {}), + ...(!isDefaultOrMissing(location.pageSize, MANAGEMENT_DEFAULT_PAGE_SIZE) + ? { pageSize: location.pageSize } + : {}), + ...(!isDefaultOrMissing(location.show, undefined) ? { show: location.show } : {}), + ...(!isDefaultOrMissing(location.itemId, undefined) ? { id: location.itemId } : {}), + ...(!isDefaultOrMissing(location.filter, '') ? { filter: location.filter } : ''), + ...(!isDefaultOrMissing(location.includedPolicies, '') + ? { includedPolicies: location.includedPolicies } + : ''), + }; + } else { + return {}; + } +}; + const normalizeHostIsolationExceptionsPageLocation = ( location?: Partial ): Partial => { @@ -407,3 +433,24 @@ export const getPolicyHostIsolationExceptionsPath = ( querystring.stringify(normalizePolicyDetailsArtifactsListPageLocation(location)) )}`; }; + +export const getBlocklistsListPath = (location?: Partial): string => { + const path = generatePath(MANAGEMENT_ROUTING_BLOCKLIST_PATH, { + tabName: AdministrationSubTab.blocklist, + }); + + return `${path}${appendSearch(querystring.stringify(normalizBlocklistsPageLocation(location)))}`; +}; + +export const getPolicyBlocklistsPath = ( + policyId: string, + location?: Partial +) => { + const path = generatePath(MANAGEMENT_ROUTING_POLICY_DETAILS_BLOCKLISTS_PATH, { + tabName: AdministrationSubTab.policies, + policyId, + }); + return `${path}${appendSearch( + querystring.stringify(normalizePolicyDetailsArtifactsListPageLocation(location)) + )}`; +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/blocklist/constants.ts b/x-pack/plugins/security_solution/public/management/pages/blocklist/constants.ts index 3fb68e4171597..0ecdeae1fe6e2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/blocklist/constants.ts +++ b/x-pack/plugins/security_solution/public/management/pages/blocklist/constants.ts @@ -24,3 +24,12 @@ export const BLOCKLISTS_LIST_DEFINITION: CreateExceptionListSchema = { list_id: ENDPOINT_BLOCKLISTS_LIST_ID, type: BLOCKLISTS_LIST_TYPE, }; + +export const SEARCHABLE_FIELDS: Readonly = [ + `name`, + `description`, + 'item_id', + `entries.value`, + `entries.entries.value`, + `comments.comment`, +]; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/index.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/index.tsx index 48356808a5043..e6680af5c7daf 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/index.tsx @@ -15,6 +15,7 @@ import { MANAGEMENT_ROUTING_POLICY_DETAILS_PATH_OLD, MANAGEMENT_ROUTING_POLICY_DETAILS_HOST_ISOLATION_EXCEPTIONS_PATH, MANAGEMENT_ROUTING_POLICIES_PATH, + MANAGEMENT_ROUTING_POLICY_DETAILS_BLOCKLISTS_PATH, } from '../../common/constants'; import { NotFoundPage } from '../../../app/404'; import { getPolicyDetailPath } from '../../common/routing'; @@ -30,6 +31,7 @@ export const PolicyContainer = memo(() => { MANAGEMENT_ROUTING_POLICY_DETAILS_TRUSTED_APPS_PATH, MANAGEMENT_ROUTING_POLICY_DETAILS_EVENT_FILTERS_PATH, MANAGEMENT_ROUTING_POLICY_DETAILS_HOST_ISOLATION_EXCEPTIONS_PATH, + MANAGEMENT_ROUTING_POLICY_DETAILS_BLOCKLISTS_PATH, ]} exact component={PolicyDetails} diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/policy_common_selectors.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/policy_common_selectors.ts index 40953b927e935..ef753e75a8391 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/policy_common_selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/policy_common_selectors.ts @@ -12,6 +12,7 @@ import { MANAGEMENT_ROUTING_POLICY_DETAILS_HOST_ISOLATION_EXCEPTIONS_PATH, MANAGEMENT_ROUTING_POLICY_DETAILS_TRUSTED_APPS_PATH, MANAGEMENT_ROUTING_POLICY_DETAILS_EVENT_FILTERS_PATH, + MANAGEMENT_ROUTING_POLICY_DETAILS_BLOCKLISTS_PATH, } from '../../../../../common/constants'; import { PolicyDetailsSelector, PolicyDetailsState } from '../../../types'; @@ -76,3 +77,16 @@ export const isOnHostIsolationExceptionsView: PolicyDetailsSelector = c ); } ); + +/** Returns a boolean of whether the user is on the blocklists page or not */ +export const isOnBlocklistsView: PolicyDetailsSelector = createSelector( + getUrlLocationPathname, + (pathname) => { + return ( + matchPath(pathname ?? '', { + path: MANAGEMENT_ROUTING_POLICY_DETAILS_BLOCKLISTS_PATH, + exact: true, + }) !== null + ); + } +); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/policy_settings_selectors.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/policy_settings_selectors.ts index a1a4c62d70734..eda993be89848 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/policy_settings_selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/policy_settings_selectors.ts @@ -23,6 +23,7 @@ import { MANAGEMENT_ROUTING_POLICY_DETAILS_HOST_ISOLATION_EXCEPTIONS_PATH, MANAGEMENT_ROUTING_POLICY_DETAILS_TRUSTED_APPS_PATH, MANAGEMENT_ROUTING_POLICY_DETAILS_EVENT_FILTERS_PATH, + MANAGEMENT_ROUTING_POLICY_DETAILS_BLOCKLISTS_PATH, } from '../../../../../common/constants'; import { ManagementRoutePolicyDetailsParams } from '../../../../../types'; import { getPolicyDataForUpdate } from '../../../../../../../common/endpoint/service/policy'; @@ -31,6 +32,7 @@ import { isOnPolicyEventFiltersView, isOnHostIsolationExceptionsView, isOnPolicyFormView, + isOnBlocklistsView, } from './policy_common_selectors'; /** Returns the policy details */ @@ -93,7 +95,8 @@ export const isOnPolicyDetailsPage = (state: Immutable) => isOnPolicyFormView(state) || isOnPolicyTrustedAppsView(state) || isOnPolicyEventFiltersView(state) || - isOnHostIsolationExceptionsView(state); + isOnHostIsolationExceptionsView(state) || + isOnBlocklistsView(state); /** Returns the license info fetched from the license service */ export const license = (state: Immutable) => { @@ -111,6 +114,7 @@ export const policyIdFromParams: (state: Immutable) => strin MANAGEMENT_ROUTING_POLICY_DETAILS_TRUSTED_APPS_PATH, MANAGEMENT_ROUTING_POLICY_DETAILS_EVENT_FILTERS_PATH, MANAGEMENT_ROUTING_POLICY_DETAILS_HOST_ISOLATION_EXCEPTIONS_PATH, + MANAGEMENT_ROUTING_POLICY_DETAILS_BLOCKLISTS_PATH, ], exact: true, })?.params?.policyId ?? '' diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_hooks.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_hooks.ts index e8f3f97c6e0c1..2716f81d3230f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_hooks.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_hooks.ts @@ -9,6 +9,7 @@ import { useCallback } from 'react'; import { useHistory } from 'react-router-dom'; import { useSelector } from 'react-redux'; import { + ENDPOINT_BLOCKLISTS_LIST_ID, ENDPOINT_EVENT_FILTERS_LIST_ID, ENDPOINT_TRUSTED_APPS_LIST_ID, } from '@kbn/securitysolution-list-constants'; @@ -19,6 +20,7 @@ import { MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE, } from '../../../common/constants'; import { + getPolicyBlocklistsPath, getPolicyDetailsArtifactsListPath, getPolicyEventFiltersPath, getPolicyHostIsolationExceptionsPath, @@ -79,6 +81,11 @@ export function usePolicyDetailsArtifactsNavigateCallback(listId: string) { ...location, ...args, }); + } else if (listId === ENDPOINT_BLOCKLISTS_LIST_ID) { + return getPolicyBlocklistsPath(policyId, { + ...location, + ...args, + }); } else { return getPolicyHostIsolationExceptionsPath(policyId, { ...location, diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/blocklists_translations.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/blocklists_translations.ts new file mode 100644 index 0000000000000..9eb2d57a506b3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/blocklists_translations.ts @@ -0,0 +1,153 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; + +export const POLICY_ARTIFACT_BLOCKLISTS_LABELS = Object.freeze({ + deleteModalTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.list.removeDialog.title', + { + defaultMessage: 'Remove blocklist from policy', + } + ), + deleteModalImpactInfo: i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.list.removeDialog.messageCallout', + { + defaultMessage: + 'This blocklist will be removed only from this policy and can still be found and managed from the artifact page.', + } + ), + deleteModalErrorMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.list.removeDialog.errorToastTitle', + { + defaultMessage: 'Error while attempting to remove blocklist', + } + ), + flyoutWarningCalloutMessage: (maxNumber: number) => + i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.layout.flyout.searchWarning.text', + { + defaultMessage: + 'Only the first {maxNumber} blocklists are displayed. Please use the search bar to refine the results.', + values: { maxNumber }, + } + ), + flyoutNoArtifactsToBeAssignedMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.layout.flyout.noAssignable', + { + defaultMessage: 'There are no blocklists that can be assigned to this policy.', + } + ), + flyoutTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.layout.flyout.title', + { + defaultMessage: 'Assign blocklists', + } + ), + flyoutSubtitle: (policyName: string): string => + i18n.translate('xpack.securitySolution.endpoint.policy.blocklists.layout.flyout.subtitle', { + defaultMessage: 'Select blocklists to add to {policyName}', + values: { policyName }, + }), + flyoutSearchPlaceholder: i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.layout.search.label', + { + defaultMessage: 'Search blocklists', + } + ), + flyoutErrorMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.layout.flyout.toastError.text', + { + defaultMessage: `An error occurred updating blocklists`, + } + ), + flyoutSuccessMessageText: (updatedExceptions: ExceptionListItemSchema[]): string => + updatedExceptions.length > 1 + ? i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.layout.flyout.toastSuccess.textMultiples', + { + defaultMessage: '{count} blocklists have been added to your list.', + values: { count: updatedExceptions.length }, + } + ) + : i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.layout.flyout.toastSuccess.textSingle', + { + defaultMessage: '"{name}" has been added to your blocklist list.', + values: { name: updatedExceptions[0].name }, + } + ), + emptyUnassignedTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.empty.unassigned.title', + { defaultMessage: 'No assigned blocklists' } + ), + emptyUnassignedMessage: (policyName: string): string => + i18n.translate('xpack.securitySolution.endpoint.policy.blocklists.empty.unassigned.content', { + defaultMessage: + 'There are currently no blocklists assigned to {policyName}. Assign blocklists now or add and manage them on the blocklists page.', + values: { policyName }, + }), + emptyUnassignedPrimaryActionButtonTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.empty.unassigned.primaryAction', + { + defaultMessage: 'Assign blocklists', + } + ), + emptyUnassignedSecondaryActionButtonTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.empty.unassigned.secondaryAction', + { + defaultMessage: 'Manage blocklists', + } + ), + emptyUnexistingTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.empty.unexisting.title', + { defaultMessage: 'No blocklists exist' } + ), + emptyUnexistingMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.empty.unexisting.content', + { + defaultMessage: 'There are currently no blocklists applied to your endpoints.', + } + ), + emptyUnexistingPrimaryActionButtonTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.empty.unexisting.action', + { defaultMessage: 'Add blocklists' } + ), + listTotalItemCountMessage: (totalItemsCount: number): string => + i18n.translate('xpack.securitySolution.endpoint.policy.blocklists.list.totalItemCount', { + defaultMessage: 'Showing {totalItemsCount, plural, one {# blocklist} other {# blocklists}}', + values: { totalItemsCount }, + }), + listRemoveActionNotAllowedMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.list.removeActionNotAllowed', + { + defaultMessage: 'Globally applied blocklist cannot be removed from policy.', + } + ), + listSearchPlaceholderMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.list.search.placeholder', + { + defaultMessage: `Search on the fields below: name, description, IP`, + } + ), + layoutTitle: i18n.translate('xpack.securitySolution.endpoint.policy.blocklists.layout.title', { + defaultMessage: 'Assigned blocklists', + }), + layoutAssignButtonTitle: i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.layout.assignToPolicy', + { + defaultMessage: 'Assign blocklists to policy', + } + ), + layoutViewAllLinkMessage: i18n.translate( + 'xpack.securitySolution.endpoint.policy.blocklists.layout.about.viewAllLinkLabel', + { + defaultMessage: 'view all blocklists', + } + ), +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/policy_tabs.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/policy_tabs.tsx index 706995974fcbc..17c880ffa6261 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/policy_tabs.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/tabs/policy_tabs.tsx @@ -20,6 +20,8 @@ import { getHostIsolationExceptionsListPath, getTrustedAppsListPath, getPolicyDetailsArtifactsListPath, + getBlocklistsListPath, + getPolicyBlocklistsPath, } from '../../../../common/routing'; import { useHttp } from '../../../../../common/lib/kibana'; import { ManagementPageLoader } from '../../../../components/management_page_loader'; @@ -29,6 +31,7 @@ import { isOnPolicyEventFiltersView, isOnPolicyFormView, isOnPolicyTrustedAppsView, + isOnBlocklistsView, policyDetails, policyIdFromParams, } from '../../store/policy_details/selectors'; @@ -38,18 +41,22 @@ import { usePolicyDetailsSelector } from '../policy_hooks'; import { POLICY_ARTIFACT_EVENT_FILTERS_LABELS } from './event_filters_translations'; import { POLICY_ARTIFACT_TRUSTED_APPS_LABELS } from './trusted_apps_translations'; import { POLICY_ARTIFACT_HOST_ISOLATION_EXCEPTIONS_LABELS } from './host_isolation_exceptions_translations'; +import { POLICY_ARTIFACT_BLOCKLISTS_LABELS } from './blocklists_translations'; import { TrustedAppsApiClient } from '../../../trusted_apps/service/trusted_apps_api_client'; import { EventFiltersApiClient } from '../../../event_filters/service/event_filters_api_client'; +import { BlocklistsApiClient } from '../../../blocklist/services/blocklists_api_client'; import { HostIsolationExceptionsApiClient } from '../../../host_isolation_exceptions/host_isolation_exceptions_api_client'; import { SEARCHABLE_FIELDS as TRUSTED_APPS_SEARCHABLE_FIELDS } from '../../../trusted_apps/constants'; import { SEARCHABLE_FIELDS as EVENT_FILTERS_SEARCHABLE_FIELDS } from '../../../event_filters/constants'; import { SEARCHABLE_FIELDS as HOST_ISOLATION_EXCEPTIONS_SEARCHABLE_FIELDS } from '../../../host_isolation_exceptions/constants'; +import { SEARCHABLE_FIELDS as BLOCKLISTS_SEARCHABLE_FIELDS } from '../../../blocklist/constants'; const enum PolicyTabKeys { SETTINGS = 'settings', TRUSTED_APPS = 'trustedApps', EVENT_FILTERS = 'eventFilters', HOST_ISOLATION_EXCEPTIONS = 'hostIsolationExceptions', + BLOCKLISTS = 'blocklists', } interface PolicyTab { @@ -65,6 +72,7 @@ export const PolicyTabs = React.memo(() => { const isInTrustedAppsTab = usePolicyDetailsSelector(isOnPolicyTrustedAppsView); const isInEventFilters = usePolicyDetailsSelector(isOnPolicyEventFiltersView); const isInHostIsolationExceptionsTab = usePolicyDetailsSelector(isOnHostIsolationExceptionsView); + const isInBlocklistsTab = usePolicyDetailsSelector(isOnBlocklistsView); const policyId = usePolicyDetailsSelector(policyIdFromParams); const policyItem = usePolicyDetailsSelector(policyDetails); const privileges = useUserPrivileges().endpointPrivileges; @@ -104,6 +112,11 @@ export const PolicyTabs = React.memo(() => { [http] ); + const getBlocklistsApiClientInstance = useCallback( + () => BlocklistsApiClient.getInstance(http), + [http] + ); + const tabs: Record = useMemo(() => { const trustedAppsLabels = { ...POLICY_ARTIFACT_TRUSTED_APPS_LABELS, @@ -138,6 +151,17 @@ export const PolicyTabs = React.memo(() => { ), }; + const blocklistsLabels = { + ...POLICY_ARTIFACT_BLOCKLISTS_LABELS, + layoutAboutMessage: (count: number, link: React.ReactElement): React.ReactNode => ( + + ), + }; + return { [PolicyTabKeys.SETTINGS]: { id: PolicyTabKeys.SETTINGS, @@ -214,11 +238,31 @@ export const PolicyTabs = React.memo(() => { ), } : undefined, + [PolicyTabKeys.BLOCKLISTS]: { + id: PolicyTabKeys.BLOCKLISTS, + name: i18n.translate('xpack.securitySolution.endpoint.policy.details.tabs.blocklists', { + defaultMessage: 'Blocklists', + }), + content: ( + <> + + + + ), + }, }; }, [ canSeeHostIsolationExceptions, getEventFiltersApiClientInstance, getHostIsolationExceptionsApiClientInstance, + getBlocklistsApiClientInstance, getTrustedAppsApiClientInstance, policyItem, privileges.canIsolateHost, @@ -242,10 +286,19 @@ export const PolicyTabs = React.memo(() => { selectedTab = tabs[PolicyTabKeys.EVENT_FILTERS]; } else if (isInHostIsolationExceptionsTab) { selectedTab = tabs[PolicyTabKeys.HOST_ISOLATION_EXCEPTIONS]; + } else if (isInBlocklistsTab) { + selectedTab = tabs[PolicyTabKeys.BLOCKLISTS]; } return selectedTab || defaultTab; - }, [tabs, isInSettingsTab, isInTrustedAppsTab, isInEventFilters, isInHostIsolationExceptionsTab]); + }, [ + tabs, + isInSettingsTab, + isInTrustedAppsTab, + isInEventFilters, + isInHostIsolationExceptionsTab, + isInBlocklistsTab, + ]); const onTabClickHandler = useCallback( (selectedTab: EuiTabbedContentTab) => { @@ -263,6 +316,9 @@ export const PolicyTabs = React.memo(() => { case PolicyTabKeys.HOST_ISOLATION_EXCEPTIONS: path = getPolicyHostIsolationExceptionsPath(policyId); break; + case PolicyTabKeys.BLOCKLISTS: + path = getPolicyBlocklistsPath(policyId); + break; } history.push(path); }, From f4358d5fbd59e756e647398e32c486bc2aac5edb Mon Sep 17 00:00:00 2001 From: Alison Goryachev Date: Mon, 14 Mar 2022 11:55:57 -0400 Subject: [PATCH 14/44] [Upgrade Assistant] Add deprecation logging as step on overview page (#126693) --- .../translations/translations/ja-JP.json | 3 - .../translations/translations/zh-CN.json | 3 - .../overview/logs_step/logs_step.test.tsx | 159 ++++++++++++ .../fix_deprecation_logs/index.ts | 1 + .../components/es_deprecation_logs/index.ts | 1 + .../fix_issues_step/fix_issues_step.tsx | 53 +--- .../components/overview/logs_step/index.ts | 8 + .../overview/logs_step/logs_step.tsx | 243 ++++++++++++++++++ .../components/overview/overview.tsx | 8 +- .../page_objects/upgrade_assistant_page.ts | 6 +- 10 files changed, 425 insertions(+), 60 deletions(-) create mode 100644 x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/logs_step/logs_step.test.tsx create mode 100644 x-pack/plugins/upgrade_assistant/public/application/components/overview/logs_step/index.ts create mode 100644 x-pack/plugins/upgrade_assistant/public/application/components/overview/logs_step/logs_step.tsx diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index eac8f27215627..d0ec673fa45c5 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -28379,7 +28379,6 @@ "xpack.upgradeAssistant.noDeprecationsPrompt.nextStepsDescription": "他のスタック廃止予定については、{overviewButton}を確認してください。", "xpack.upgradeAssistant.noDeprecationsPrompt.overviewLinkText": "概要ページ", "xpack.upgradeAssistant.noPartialDeprecationsMessage": "なし", - "xpack.upgradeAssistant.overview.accessEsDeprecationLogsLabel": "Elasticsearch APIを呼び出すアプリケーションコードがある場合は、{esDeprecationLogsLink}を確認して、廃止予定のAPIを使用していないことを確かめてください。", "xpack.upgradeAssistant.overview.analyzeTitle": "廃止予定ログを分析", "xpack.upgradeAssistant.overview.apiCompatibilityNoteBody": "アップグレード前にすべての廃止予定の問題を解決することをお勧めします。必要に応じて、廃止予定の機能を使用する要求にAPI互換性ヘッダーを適用できます。{learnMoreLink}。", "xpack.upgradeAssistant.overview.apiCompatibilityNoteLink": "詳細", @@ -28405,8 +28404,6 @@ "xpack.upgradeAssistant.overview.deprecationsCountCheckpointTitle": "廃止予定の問題を解決して変更を検証", "xpack.upgradeAssistant.overview.documentationLinkText": "ドキュメント", "xpack.upgradeAssistant.overview.errorLoadingUpgradeStatus": "アップグレードステータスの取得中にエラーが発生しました", - "xpack.upgradeAssistant.overview.esDeprecationLogsLink": "Elasticsearchの廃止予定ログ", - "xpack.upgradeAssistant.overview.fixIssuesStepDescription": "Elastic 8.xにアップグレードする前に、重大なElasticsearchおよびKibana構成の問題を解決する必要があります。警告を無視すると、アップグレード後に動作が変更される場合があります。{accessDeprecationLogsMessage}", "xpack.upgradeAssistant.overview.fixIssuesStepTitle": "廃止予定設定を確認し、問題を解決", "xpack.upgradeAssistant.overview.loadingLogsLabel": "廃止予定ログ収集状態を読み込んでいます...", "xpack.upgradeAssistant.overview.loadingUpgradeStatus": "アップグレードステータスを読み込んでいます", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 909fbe46321bd..88c0a67ecf1b7 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -28412,7 +28412,6 @@ "xpack.upgradeAssistant.noDeprecationsPrompt.nextStepsDescription": "查看{overviewButton}以了解其他 Stack 弃用。", "xpack.upgradeAssistant.noDeprecationsPrompt.overviewLinkText": "“概览”页面", "xpack.upgradeAssistant.noPartialDeprecationsMessage": "无", - "xpack.upgradeAssistant.overview.accessEsDeprecationLogsLabel": "如果有应用程序代码调用 Elasticsearch API,请复查 {esDeprecationLogsLink}以确保您未使用已弃用的 API。", "xpack.upgradeAssistant.overview.analyzeTitle": "分析弃用日志", "xpack.upgradeAssistant.overview.apiCompatibilityNoteBody": "建议您在升级之前解决所有弃用问题。如果需要,您可以将 API 兼容性标头应用于使用过时功能的请求。{learnMoreLink}。", "xpack.upgradeAssistant.overview.apiCompatibilityNoteLink": "了解详情", @@ -28438,8 +28437,6 @@ "xpack.upgradeAssistant.overview.deprecationsCountCheckpointTitle": "解决弃用问题并验证您的更改", "xpack.upgradeAssistant.overview.documentationLinkText": "文档", "xpack.upgradeAssistant.overview.errorLoadingUpgradeStatus": "检索升级状态时出错", - "xpack.upgradeAssistant.overview.esDeprecationLogsLink": "Elasticsearch 弃用日志", - "xpack.upgradeAssistant.overview.fixIssuesStepDescription": "在升级到 Elastic 8.x 之前,必须解决任何严重的 Elasticsearch 和 Kibana 配置问题。在您升级后,忽略警告可能会导致行为差异。{accessDeprecationLogsMessage}", "xpack.upgradeAssistant.overview.fixIssuesStepTitle": "复查已弃用设置并解决问题", "xpack.upgradeAssistant.overview.loadingLogsLabel": "正在加载弃用日志收集状态……", "xpack.upgradeAssistant.overview.loadingUpgradeStatus": "正在加载升级状态", diff --git a/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/logs_step/logs_step.test.tsx b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/logs_step/logs_step.test.tsx new file mode 100644 index 0000000000000..31cbb8ebef456 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/__jest__/client_integration/overview/logs_step/logs_step.test.tsx @@ -0,0 +1,159 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act } from 'react-dom/test-utils'; +import { DEPRECATION_LOGS_INDEX } from '../../../../common/constants'; +import { setupEnvironment } from '../../helpers'; +import { OverviewTestBed, setupOverviewPage } from '../overview.helpers'; + +describe('Overview - Logs Step', () => { + let testBed: OverviewTestBed; + + const { server, httpRequestsMockHelpers } = setupEnvironment(); + + afterAll(() => { + server.restore(); + }); + + describe('error state', () => { + beforeEach(async () => { + const error = { + statusCode: 500, + error: 'Internal server error', + message: 'Internal server error', + }; + + httpRequestsMockHelpers.setLoadDeprecationLogsCountResponse(undefined, error); + + await act(async () => { + testBed = await setupOverviewPage(); + }); + + testBed.component.update(); + }); + + test('is rendered', () => { + const { exists } = testBed; + expect(exists('deprecationLogsErrorCallout')).toBe(true); + expect(exists('deprecationLogsRetryButton')).toBe(true); + }); + }); + + describe('success state', () => { + describe('logging enabled', () => { + beforeEach(() => { + httpRequestsMockHelpers.setLoadDeprecationLoggingResponse({ + isDeprecationLogIndexingEnabled: true, + isDeprecationLoggingEnabled: true, + }); + }); + + test('renders step as complete when a user has 0 logs', async () => { + httpRequestsMockHelpers.setLoadDeprecationLogsCountResponse({ + count: 0, + }); + + await act(async () => { + testBed = await setupOverviewPage(); + }); + + const { component, exists } = testBed; + + component.update(); + + expect(exists('logsStep-complete')).toBe(true); + }); + + test('renders step as incomplete when a user has >0 logs', async () => { + httpRequestsMockHelpers.setLoadDeprecationLogsCountResponse({ + count: 10, + }); + + await act(async () => { + testBed = await setupOverviewPage(); + }); + + const { component, exists } = testBed; + + component.update(); + + expect(exists('logsStep-incomplete')).toBe(true); + }); + + test('renders deprecation issue count and button to view logs', async () => { + httpRequestsMockHelpers.setLoadDeprecationLogsCountResponse({ + count: 10, + }); + + await act(async () => { + testBed = await setupOverviewPage(); + }); + + const { component, find } = testBed; + + component.update(); + + expect(find('logsCountDescription').text()).toContain('You have 10 deprecation issues'); + expect(find('viewLogsLink').text()).toContain('View logs'); + }); + }); + + describe('logging disabled', () => { + beforeEach(async () => { + httpRequestsMockHelpers.setLoadDeprecationLoggingResponse({ + isDeprecationLogIndexingEnabled: false, + isDeprecationLoggingEnabled: true, + }); + + await act(async () => { + testBed = await setupOverviewPage(); + }); + + const { component } = testBed; + + component.update(); + }); + + test('renders button to enable logs', () => { + const { find, exists } = testBed; + + expect(exists('logsCountDescription')).toBe(false); + expect(find('enableLogsLink').text()).toContain('Enable logging'); + }); + }); + }); + + describe('privileges', () => { + beforeEach(async () => { + httpRequestsMockHelpers.setLoadDeprecationLoggingResponse({ + isDeprecationLogIndexingEnabled: true, + isDeprecationLoggingEnabled: true, + }); + + await act(async () => { + testBed = await setupOverviewPage({ + privileges: { + hasAllPrivileges: true, + missingPrivileges: { + index: [DEPRECATION_LOGS_INDEX], + }, + }, + }); + }); + + const { component } = testBed; + + component.update(); + }); + + test('warns the user of missing index privileges', () => { + const { exists } = testBed; + + expect(exists('missingPrivilegesCallout')).toBe(true); + }); + }); +}); diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecation_logs/fix_deprecation_logs/index.ts b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecation_logs/fix_deprecation_logs/index.ts index c0af5524e3a14..ffc1a60e8a2fb 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecation_logs/fix_deprecation_logs/index.ts +++ b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecation_logs/fix_deprecation_logs/index.ts @@ -6,3 +6,4 @@ */ export { FixDeprecationLogs } from './fix_deprecation_logs'; +export { useDeprecationLogging } from './use_deprecation_logging'; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecation_logs/index.ts b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecation_logs/index.ts index 336aa14642f7d..978fb18cbe2a7 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecation_logs/index.ts +++ b/x-pack/plugins/upgrade_assistant/public/application/components/es_deprecation_logs/index.ts @@ -6,3 +6,4 @@ */ export { EsDeprecationLogs } from './es_deprecation_logs'; +export { useDeprecationLogging } from './fix_deprecation_logs'; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/overview/fix_issues_step/fix_issues_step.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/overview/fix_issues_step/fix_issues_step.tsx index aa3fe2cf3602d..9ae309fc79336 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/overview/fix_issues_step/fix_issues_step.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/overview/fix_issues_step/fix_issues_step.tsx @@ -7,13 +7,11 @@ import React, { FunctionComponent, useState, useEffect } from 'react'; -import { EuiText, EuiFlexItem, EuiFlexGroup, EuiSpacer, EuiLink } from '@elastic/eui'; +import { EuiText, EuiFlexItem, EuiFlexGroup, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import type { EuiStepProps } from '@elastic/eui/src/components/steps/step'; -import { DEPRECATION_LOGS_INDEX } from '../../../../../common/constants'; -import { WithPrivileges } from '../../../../shared_imports'; import type { OverviewStepProps } from '../../types'; import { EsDeprecationIssuesPanel, KibanaDeprecationIssuesPanel } from './components'; @@ -51,48 +49,10 @@ const FixIssuesStep: FunctionComponent = ({ setIsComplete }) => { ); }; -interface CustomProps { - navigateToEsDeprecationLogs: () => void; -} - -const AccessDeprecationLogsMessage = ({ navigateToEsDeprecationLogs }: CustomProps) => { - return ( - - {({ hasPrivileges, isLoading }) => { - if (isLoading || !hasPrivileges) { - // Don't show the message with the link to access deprecation logs - // to users who can't access the UI anyways. - return null; - } - - return ( - - {i18n.translate('xpack.upgradeAssistant.overview.esDeprecationLogsLink', { - defaultMessage: 'Elasticsearch deprecation logs', - })} - - ), - }} - /> - ); - }} - - ); -}; - export const getFixIssuesStep = ({ isComplete, setIsComplete, - navigateToEsDeprecationLogs, -}: OverviewStepProps & CustomProps): EuiStepProps => { +}: OverviewStepProps): EuiStepProps => { const status = isComplete ? 'complete' : 'incomplete'; return { @@ -105,14 +65,7 @@ export const getFixIssuesStep = ({

- ), - }} + defaultMessage="You must resolve any critical Elasticsearch and Kibana configuration issues before upgrading to Elastic 8.x. Ignoring warnings might result in differences in behavior after you upgrade." />

diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/overview/logs_step/index.ts b/x-pack/plugins/upgrade_assistant/public/application/components/overview/logs_step/index.ts new file mode 100644 index 0000000000000..96e6cf4d71c08 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/application/components/overview/logs_step/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { getLogsStep } from './logs_step'; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/overview/logs_step/logs_step.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/overview/logs_step/logs_step.tsx new file mode 100644 index 0000000000000..bc073ef81f21e --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/public/application/components/overview/logs_step/logs_step.tsx @@ -0,0 +1,243 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect } from 'react'; + +import { + EuiText, + EuiSpacer, + EuiButton, + EuiCallOut, + EuiLoadingContent, + EuiCode, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedDate, FormattedTime, FormattedMessage } from '@kbn/i18n-react'; +import type { EuiStepProps } from '@elastic/eui/src/components/steps/step'; + +import { DEPRECATION_LOGS_INDEX } from '../../../../../common/constants'; +import { WithPrivileges, MissingPrivileges } from '../../../../shared_imports'; +import { useAppContext } from '../../../app_context'; +import { loadLogsCheckpoint } from '../../../lib/logs_checkpoint'; +import type { OverviewStepProps } from '../../types'; +import { useDeprecationLogging } from '../../es_deprecation_logs'; + +const i18nTexts = { + logsStepTitle: i18n.translate('xpack.upgradeAssistant.overview.logsStep.title', { + defaultMessage: 'Address API deprecations', + }), + logsStepDescription: i18n.translate('xpack.upgradeAssistant.overview.logsStep.description', { + defaultMessage: `Review the Elasticsearch deprecation logs to ensure you're not using deprecated APIs.`, + }), + viewLogsButtonLabel: i18n.translate( + 'xpack.upgradeAssistant.overview.logsStep.viewLogsButtonLabel', + { + defaultMessage: 'View logs', + } + ), + enableLogsButtonLabel: i18n.translate( + 'xpack.upgradeAssistant.overview.logsStep.enableLogsButtonLabel', + { + defaultMessage: 'Enable logging', + } + ), + logsCountDescription: (deprecationCount: number, checkpoint: string) => ( + + {' '} + + + ), + }} + /> + ), + missingPrivilegesTitle: i18n.translate( + 'xpack.upgradeAssistant.overview.logsStep.missingPrivilegesTitle', + { + defaultMessage: 'You require index privileges to analyze the deprecation logs', + } + ), + missingPrivilegesDescription: (privilegesMissing: MissingPrivileges) => ( + {privilegesMissing?.index?.join(', ')} + ), + privilegesCount: privilegesMissing?.index?.length, + }} + /> + ), + loadingError: i18n.translate('xpack.upgradeAssistant.overview.logsStep.loadingError', { + defaultMessage: 'An error occurred while retrieving the deprecation log count', + }), + retryButton: i18n.translate('xpack.upgradeAssistant.overview.logsStep.retryButton', { + defaultMessage: 'Try again', + }), +}; + +interface LogStepProps { + setIsComplete: (isComplete: boolean) => void; + hasPrivileges: boolean; + privilegesMissing: MissingPrivileges; + navigateToEsDeprecationLogs: () => void; +} + +const LogStepDescription = () => ( + +

{i18nTexts.logsStepDescription}

+
+); + +const LogsStep = ({ + setIsComplete, + hasPrivileges, + privilegesMissing, + navigateToEsDeprecationLogs, +}: LogStepProps) => { + const { + services: { api }, + } = useAppContext(); + + const { isDeprecationLogIndexingEnabled } = useDeprecationLogging(); + + const checkpoint = loadLogsCheckpoint(); + + const { + data: logsCount, + error, + isLoading, + resendRequest, + isInitialRequest, + } = api.getDeprecationLogsCount(checkpoint); + + useEffect(() => { + if (!isDeprecationLogIndexingEnabled) { + setIsComplete(false); + } + + setIsComplete(logsCount?.count === 0); + + // Depending upon setIsComplete would create an infinite loop. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isDeprecationLogIndexingEnabled, logsCount]); + + if (hasPrivileges === false && isDeprecationLogIndexingEnabled) { + return ( + <> + + + + + +

{i18nTexts.missingPrivilegesDescription(privilegesMissing)}

+
+ + ); + } + + if (isLoading && isInitialRequest) { + return ; + } + + if (hasPrivileges && error) { + return ( + +

+ {error.statusCode} - {error.message} +

+ + + {i18nTexts.retryButton} + +
+ ); + } + + return ( + <> + + + {isDeprecationLogIndexingEnabled && logsCount ? ( + <> + + + +

+ {i18nTexts.logsCountDescription(logsCount.count, checkpoint)} +

+
+ + + + + {i18nTexts.viewLogsButtonLabel} + + + ) : ( + <> + + + + {i18nTexts.enableLogsButtonLabel} + + + )} + + + ); +}; + +interface CustomProps { + navigateToEsDeprecationLogs: () => void; +} + +export const getLogsStep = ({ + isComplete, + setIsComplete, + navigateToEsDeprecationLogs, +}: OverviewStepProps & CustomProps): EuiStepProps => { + const status = isComplete ? 'complete' : 'incomplete'; + + return { + status, + title: i18nTexts.logsStepTitle, + 'data-test-subj': `logsStep-${status}`, + children: ( + + {({ hasPrivileges, isLoading, privilegesMissing }) => ( + + )} + + ), + }; +}; diff --git a/x-pack/plugins/upgrade_assistant/public/application/components/overview/overview.tsx b/x-pack/plugins/upgrade_assistant/public/application/components/overview/overview.tsx index 93dcc162aa6fe..99bee6ec8f4b9 100644 --- a/x-pack/plugins/upgrade_assistant/public/application/components/overview/overview.tsx +++ b/x-pack/plugins/upgrade_assistant/public/application/components/overview/overview.tsx @@ -28,8 +28,9 @@ import { getBackupStep } from './backup_step'; import { getFixIssuesStep } from './fix_issues_step'; import { getUpgradeStep } from './upgrade_step'; import { getMigrateSystemIndicesStep } from './migrate_system_indices'; +import { getLogsStep } from './logs_step'; -type OverviewStep = 'backup' | 'migrate_system_indices' | 'fix_issues'; +type OverviewStep = 'backup' | 'migrate_system_indices' | 'fix_issues' | 'logs'; export const Overview = withRouter(({ history }: RouteComponentProps) => { const { @@ -52,6 +53,7 @@ export const Overview = withRouter(({ history }: RouteComponentProps) => { backup: false, migrate_system_indices: false, fix_issues: false, + logs: false, }); const isStepComplete = (step: OverviewStep) => completedStepsMap[step]; @@ -114,6 +116,10 @@ export const Overview = withRouter(({ history }: RouteComponentProps) => { getFixIssuesStep({ isComplete: isStepComplete('fix_issues'), setIsComplete: setCompletedStep.bind(null, 'fix_issues'), + }), + getLogsStep({ + isComplete: isStepComplete('logs'), + setIsComplete: setCompletedStep.bind(null, 'logs'), navigateToEsDeprecationLogs: () => history.push('/es_deprecation_logs'), }), getUpgradeStep(), diff --git a/x-pack/test/functional/page_objects/upgrade_assistant_page.ts b/x-pack/test/functional/page_objects/upgrade_assistant_page.ts index f59cf660139b9..933880dac739e 100644 --- a/x-pack/test/functional/page_objects/upgrade_assistant_page.ts +++ b/x-pack/test/functional/page_objects/upgrade_assistant_page.ts @@ -31,9 +31,9 @@ export class UpgradeAssistantPageObject extends FtrService { async navigateToEsDeprecationLogs() { return await this.retry.try(async () => { - await this.common.navigateToApp('settings'); - await this.testSubjects.click('upgrade_assistant'); - await this.testSubjects.click('viewElasticsearchDeprecationLogs'); + await this.common.navigateToUrl('management', 'stack/upgrade_assistant/es_deprecation_logs', { + shouldUseHashForSubUrl: false, + }); await this.retry.waitFor( 'url to contain /upgrade_assistant/es_deprecation_logs', async () => { From a52ba7cefe1a04ef6eafa32d5e410a3a901169b2 Mon Sep 17 00:00:00 2001 From: Mark Hopkin Date: Mon, 14 Mar 2022 16:01:04 +0000 Subject: [PATCH 15/44] [Fleet] Move mappings from index template to component template (#124013) * cherry pick from old branch * use global template * re-add _meta to index template * simplify merge * fix component_template test * add settings to mapping component template * put mapping settings on @mapping component template * fix snapshot * fix integration tests * fix component template order and test * use mapping component template for updating backing indices * fix tests * move code to util functions * add comment * split global templates * update snaphsot * rename variables * fix tests again * reinstall bundled packages when component templates change * combine compoised_of generation statements * remove duplicated global fields * remove unnecessary rollover call * use rollover API * use simulate API to get template content * remove unused parameters * fix unit test * improve simulate error handling * re-add removed mapping assertions * fix test --- .../plugins/fleet/common/types/models/epm.ts | 19 + .../fleet/server/constants/fleet_es_assets.ts | 44 +- .../plugins/fleet/server/constants/index.ts | 10 +- .../server/services/epm/elasticsearch/meta.ts | 10 +- .../__snapshots__/template.test.ts.snap | 2249 ++++++++--------- .../template/default_settings.test.ts | 5 - .../template/default_settings.ts | 6 - .../epm/elasticsearch/template/install.ts | 138 +- .../elasticsearch/template/template.test.ts | 73 +- .../epm/elasticsearch/template/template.ts | 124 +- .../server/services/epm/packages/index.ts | 2 + x-pack/plugins/fleet/server/services/setup.ts | 53 +- x-pack/plugins/fleet/server/types/index.tsx | 2 + .../epm/__snapshots__/install_by_upload.snap | 735 ++++++ .../apis/epm/final_pipeline.ts | 3 +- .../apis/epm/install_by_upload.ts | 4 +- .../apis/epm/install_overrides.ts | 21 +- .../apis/epm/install_remove_assets.ts | 4 + .../apis/epm/template.ts | 16 - .../apis/epm/update_assets.ts | 159 +- 20 files changed, 2216 insertions(+), 1461 deletions(-) create mode 100644 x-pack/test/fleet_api_integration/apis/epm/__snapshots__/install_by_upload.snap diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index 39650bfed5da8..dcff9f503bfe0 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -483,6 +483,25 @@ export interface IndexTemplate { _meta: object; } +export interface ESAssetMetadata { + package?: { + name: string; + }; + managed_by: string; + managed: boolean; +} +export interface TemplateMapEntry { + _meta: ESAssetMetadata; + template: + | { + mappings: NonNullable; + } + | { + settings: NonNullable | object; + }; +} + +export type TemplateMap = Record; export interface IndexTemplateEntry { templateName: string; indexTemplate: IndexTemplate; diff --git a/x-pack/plugins/fleet/server/constants/fleet_es_assets.ts b/x-pack/plugins/fleet/server/constants/fleet_es_assets.ts index 8cdb93be28148..859a25a0ec7c7 100644 --- a/x-pack/plugins/fleet/server/constants/fleet_es_assets.ts +++ b/x-pack/plugins/fleet/server/constants/fleet_es_assets.ts @@ -8,12 +8,44 @@ import { getESAssetMetadata } from '../services/epm/elasticsearch/meta'; const meta = getESAssetMetadata(); +export const MAPPINGS_TEMPLATE_SUFFIX = '@mappings'; + +export const SETTINGS_TEMPLATE_SUFFIX = '@settings'; + +export const USER_SETTINGS_TEMPLATE_SUFFIX = '@custom'; export const FLEET_FINAL_PIPELINE_ID = '.fleet_final_pipeline-1'; -export const FLEET_GLOBAL_COMPONENT_TEMPLATE_NAME = '.fleet_component_template-1'; +export const FLEET_GLOBALS_COMPONENT_TEMPLATE_NAME = '.fleet_globals-1'; + +export const FLEET_GLOBALS_COMPONENT_TEMPLATE_CONTENT = { + _meta: meta, + template: { + settings: {}, + mappings: { + _meta: meta, + // All the dynamic field mappings + dynamic_templates: [ + // This makes sure all mappings are keywords by default + { + strings_as_keyword: { + mapping: { + ignore_above: 1024, + type: 'keyword', + }, + match_mapping_type: 'string', + }, + }, + ], + // As we define fields ahead, we don't need any automatic field detection + // This makes sure all the fields are mapped to keyword by default to prevent mapping conflicts + date_detection: false, + }, + }, +}; +export const FLEET_AGENT_ID_VERIFY_COMPONENT_TEMPLATE_NAME = '.fleet_agent_id_verification-1'; -export const FLEET_GLOBAL_COMPONENT_TEMPLATE_CONTENT = { +export const FLEET_AGENT_ID_VERIFY_COMPONENT_TEMPLATE_CONTENT = { _meta: meta, template: { settings: { @@ -40,6 +72,14 @@ export const FLEET_GLOBAL_COMPONENT_TEMPLATE_CONTENT = { }, }; +export const FLEET_COMPONENT_TEMPLATES = [ + { name: FLEET_GLOBALS_COMPONENT_TEMPLATE_NAME, body: FLEET_GLOBALS_COMPONENT_TEMPLATE_CONTENT }, + { + name: FLEET_AGENT_ID_VERIFY_COMPONENT_TEMPLATE_NAME, + body: FLEET_AGENT_ID_VERIFY_COMPONENT_TEMPLATE_CONTENT, + }, +]; + export const FLEET_FINAL_PIPELINE_VERSION = 2; // If the content is updated you probably need to update the FLEET_FINAL_PIPELINE_VERSION too to allow upgrade of the pipeline diff --git a/x-pack/plugins/fleet/server/constants/index.ts b/x-pack/plugins/fleet/server/constants/index.ts index 0ccbeb9f025e4..ec7a4a2664882 100644 --- a/x-pack/plugins/fleet/server/constants/index.ts +++ b/x-pack/plugins/fleet/server/constants/index.ts @@ -58,9 +58,15 @@ export { } from '../../common'; export { - FLEET_GLOBAL_COMPONENT_TEMPLATE_NAME, - FLEET_GLOBAL_COMPONENT_TEMPLATE_CONTENT, + FLEET_GLOBALS_COMPONENT_TEMPLATE_NAME, + FLEET_GLOBALS_COMPONENT_TEMPLATE_CONTENT, + FLEET_AGENT_ID_VERIFY_COMPONENT_TEMPLATE_NAME, + FLEET_AGENT_ID_VERIFY_COMPONENT_TEMPLATE_CONTENT, + FLEET_COMPONENT_TEMPLATES, FLEET_FINAL_PIPELINE_ID, FLEET_FINAL_PIPELINE_CONTENT, FLEET_FINAL_PIPELINE_VERSION, + MAPPINGS_TEMPLATE_SUFFIX, + SETTINGS_TEMPLATE_SUFFIX, + USER_SETTINGS_TEMPLATE_SUFFIX, } from './fleet_es_assets'; diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/meta.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/meta.ts index a3ceaf44100d7..d691cd8c700e5 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/meta.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/meta.ts @@ -7,15 +7,9 @@ import { safeLoad, safeDump } from 'js-yaml'; -const MANAGED_BY_DEFAULT = 'fleet'; +import type { ESAssetMetadata } from '../../../../common/types'; -export interface ESAssetMetadata { - package?: { - name: string; - }; - managed_by: string; - managed: boolean; -} +const MANAGED_BY_DEFAULT = 'fleet'; /** * Build common metadata object for Elasticsearch assets installed by Fleet. Result should be diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap index e977c41cd69d8..efd51d5e0d997 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/__snapshots__/template.test.ts.snap @@ -2,613 +2,550 @@ exports[`EPM template tests loading base.yml: base.yml 1`] = ` { - "priority": 200, - "index_patterns": [ - "foo-*" - ], - "template": { - "settings": { - "index": {} + "properties": { + "user": { + "properties": { + "auid": { + "ignore_above": 1024, + "type": "keyword" + }, + "euid": { + "ignore_above": 1024, + "type": "keyword" + } + } }, - "mappings": { - "dynamic_templates": [ - { - "strings_as_keyword": { - "mapping": { - "ignore_above": 1024, - "type": "keyword" + "long": { + "properties": { + "nested": { + "properties": { + "foo": { + "type": "text" }, - "match_mapping_type": "string" + "bar": { + "type": "long" + } } } - ], - "date_detection": false, + } + }, + "nested": { + "properties": { + "bar": { + "ignore_above": 1024, + "type": "keyword" + }, + "baz": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "myalias": { + "type": "alias", + "path": "user.euid" + }, + "validarray": { + "type": "integer" + }, + "cycle_type": { + "type": "constant_keyword", + "value": "bicycle" + } + } +} +`; + +exports[`EPM template tests loading coredns.logs.yml: coredns.logs.yml 1`] = ` +{ + "properties": { + "coredns": { "properties": { - "user": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "query": { "properties": { - "auid": { + "size": { + "type": "long" + }, + "class": { "ignore_above": 1024, "type": "keyword" }, - "euid": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { "ignore_above": 1024, "type": "keyword" } } }, - "long": { - "properties": { - "nested": { - "properties": { - "foo": { - "type": "text" - }, - "bar": { - "type": "long" - } - } - } - } - }, - "nested": { + "response": { "properties": { - "bar": { + "code": { "ignore_above": 1024, "type": "keyword" }, - "baz": { + "flags": { "ignore_above": 1024, "type": "keyword" + }, + "size": { + "type": "long" } } }, - "myalias": { - "type": "alias", - "path": "user.euid" - }, - "validarray": { - "type": "integer" - }, - "cycle_type": { - "type": "constant_keyword", - "value": "bicycle" - } - }, - "_meta": { - "managed_by": "fleet", - "managed": true, - "package": { - "name": "nginx" + "dnssec_ok": { + "type": "boolean" } } } - }, - "data_stream": {}, - "composed_of": [ - ".fleet_component_template-1" - ], - "_meta": { - "managed_by": "fleet", - "managed": true, - "package": { - "name": "nginx" - } } } `; -exports[`EPM template tests loading coredns.logs.yml: coredns.logs.yml 1`] = ` +exports[`EPM template tests loading system.yml: system.yml 1`] = ` { - "priority": 200, - "index_patterns": [ - "foo-*" - ], - "template": { - "settings": { - "index": {} - }, - "mappings": { - "dynamic_templates": [ - { - "strings_as_keyword": { - "mapping": { - "ignore_above": 1024, - "type": "keyword" - }, - "match_mapping_type": "string" - } - } - ], - "date_detection": false, + "properties": { + "system": { "properties": { - "coredns": { + "core": { "properties": { "id": { - "ignore_above": 1024, - "type": "keyword" + "type": "long" }, - "query": { + "user": { "properties": { - "size": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "ticks": { "type": "long" + } + } + }, + "system": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 }, - "class": { - "ignore_above": 1024, - "type": "keyword" + "ticks": { + "type": "long" + } + } + }, + "nice": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 }, - "name": { - "ignore_above": 1024, - "type": "keyword" + "ticks": { + "type": "long" + } + } + }, + "idle": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 }, - "type": { - "ignore_above": 1024, - "type": "keyword" + "ticks": { + "type": "long" } } }, - "response": { + "iowait": { "properties": { - "code": { - "ignore_above": 1024, - "type": "keyword" + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 }, - "flags": { - "ignore_above": 1024, - "type": "keyword" + "ticks": { + "type": "long" + } + } + }, + "irq": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 }, - "size": { + "ticks": { "type": "long" } } }, - "dnssec_ok": { - "type": "boolean" - } - } - } - }, - "_meta": { - "managed_by": "fleet", - "managed": true, - "package": { - "name": "coredns" - } - } - } - }, - "data_stream": {}, - "composed_of": [ - ".fleet_component_template-1" - ], - "_meta": { - "managed_by": "fleet", - "managed": true, - "package": { - "name": "coredns" - } - } -} -`; - -exports[`EPM template tests loading system.yml: system.yml 1`] = ` -{ - "priority": 200, - "index_patterns": [ - "whatsthis-*" - ], - "template": { - "settings": { - "index": {} - }, - "mappings": { - "dynamic_templates": [ - { - "strings_as_keyword": { - "mapping": { - "ignore_above": 1024, - "type": "keyword" + "softirq": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "ticks": { + "type": "long" + } + } }, - "match_mapping_type": "string" + "steal": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "ticks": { + "type": "long" + } + } + } } - } - ], - "date_detection": false, - "properties": { - "system": { + }, + "cpu": { "properties": { - "core": { + "cores": { + "type": "long" + }, + "user": { "properties": { - "id": { - "type": "long" + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 }, - "user": { + "norm": { "properties": { "pct": { "type": "scaled_float", "scaling_factor": 1000 - }, - "ticks": { - "type": "long" } } }, - "system": { + "ticks": { + "type": "long" + } + } + }, + "system": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "norm": { "properties": { "pct": { "type": "scaled_float", "scaling_factor": 1000 - }, - "ticks": { - "type": "long" } } }, - "nice": { + "ticks": { + "type": "long" + } + } + }, + "nice": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "norm": { "properties": { "pct": { "type": "scaled_float", "scaling_factor": 1000 - }, - "ticks": { - "type": "long" } } }, - "idle": { + "ticks": { + "type": "long" + } + } + }, + "idle": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "norm": { "properties": { "pct": { "type": "scaled_float", "scaling_factor": 1000 - }, - "ticks": { - "type": "long" } } }, - "iowait": { + "ticks": { + "type": "long" + } + } + }, + "iowait": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "norm": { "properties": { "pct": { "type": "scaled_float", "scaling_factor": 1000 - }, - "ticks": { - "type": "long" } } }, - "irq": { + "ticks": { + "type": "long" + } + } + }, + "irq": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "norm": { "properties": { "pct": { "type": "scaled_float", "scaling_factor": 1000 - }, - "ticks": { - "type": "long" } } }, - "softirq": { + "ticks": { + "type": "long" + } + } + }, + "softirq": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "norm": { "properties": { "pct": { "type": "scaled_float", "scaling_factor": 1000 - }, - "ticks": { - "type": "long" } } }, - "steal": { + "ticks": { + "type": "long" + } + } + }, + "steal": { + "properties": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "norm": { "properties": { "pct": { "type": "scaled_float", "scaling_factor": 1000 - }, - "ticks": { - "type": "long" } } + }, + "ticks": { + "type": "long" } } }, - "cpu": { + "total": { "properties": { - "cores": { - "type": "long" - }, - "user": { - "properties": { - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - }, - "norm": { - "properties": { - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - } - } - }, - "ticks": { - "type": "long" - } - } - }, - "system": { - "properties": { - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - }, - "norm": { - "properties": { - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - } - } - }, - "ticks": { - "type": "long" - } - } - }, - "nice": { - "properties": { - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - }, - "norm": { - "properties": { - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - } - } - }, - "ticks": { - "type": "long" - } - } - }, - "idle": { - "properties": { - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - }, - "norm": { - "properties": { - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - } - } - }, - "ticks": { - "type": "long" - } - } - }, - "iowait": { - "properties": { - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - }, - "norm": { - "properties": { - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - } - } - }, - "ticks": { - "type": "long" - } - } + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 }, - "irq": { + "norm": { "properties": { "pct": { "type": "scaled_float", "scaling_factor": 1000 - }, - "norm": { - "properties": { - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - } - } - }, - "ticks": { - "type": "long" - } - } - }, - "softirq": { - "properties": { - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - }, - "norm": { - "properties": { - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - } - } - }, - "ticks": { - "type": "long" } } + } + } + } + } + }, + "diskio": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "read": { + "properties": { + "count": { + "type": "long" }, - "steal": { - "properties": { - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - }, - "norm": { - "properties": { - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - } - } - }, - "ticks": { - "type": "long" - } - } + "bytes": { + "type": "long" }, - "total": { - "properties": { - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - }, - "norm": { - "properties": { - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - } - } - } - } + "time": { + "type": "long" } } }, - "diskio": { + "write": { "properties": { - "name": { - "ignore_above": 1024, - "type": "keyword" + "count": { + "type": "long" }, - "serial_number": { - "ignore_above": 1024, - "type": "keyword" + "bytes": { + "type": "long" }, + "time": { + "type": "long" + } + } + }, + "io": { + "properties": { + "time": { + "type": "long" + } + } + }, + "iostat": { + "properties": { "read": { "properties": { - "count": { - "type": "long" - }, - "bytes": { - "type": "long" - }, - "time": { - "type": "long" - } - } - }, - "write": { - "properties": { - "count": { - "type": "long" - }, - "bytes": { - "type": "long" - }, - "time": { - "type": "long" - } - } - }, - "io": { - "properties": { - "time": { - "type": "long" - } - } - }, - "iostat": { - "properties": { - "read": { + "request": { "properties": { - "request": { - "properties": { - "merges_per_sec": { - "type": "float" - }, - "per_sec": { - "type": "float" - } - } + "merges_per_sec": { + "type": "float" }, "per_sec": { - "properties": { - "bytes": { - "type": "float" - } - } - }, - "await": { "type": "float" } } }, - "write": { + "per_sec": { "properties": { - "request": { - "properties": { - "merges_per_sec": { - "type": "float" - }, - "per_sec": { - "type": "float" - } - } - }, - "per_sec": { - "properties": { - "bytes": { - "type": "float" - } - } - }, - "await": { + "bytes": { "type": "float" } } }, + "await": { + "type": "float" + } + } + }, + "write": { + "properties": { "request": { "properties": { - "avg_size": { + "merges_per_sec": { + "type": "float" + }, + "per_sec": { "type": "float" } } }, - "queue": { + "per_sec": { "properties": { - "avg_size": { + "bytes": { "type": "float" } } }, "await": { "type": "float" - }, - "service_time": { + } + } + }, + "request": { + "properties": { + "avg_size": { "type": "float" - }, - "busy": { + } + } + }, + "queue": { + "properties": { + "avg_size": { "type": "float" } } + }, + "await": { + "type": "float" + }, + "service_time": { + "type": "float" + }, + "busy": { + "type": "float" } } + } + } + }, + "entropy": { + "properties": { + "available_bits": { + "type": "long" }, - "entropy": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + } + } + }, + "filesystem": { + "properties": { + "available": { + "type": "long" + }, + "device_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "mount_point": { + "ignore_above": 1024, + "type": "keyword" + }, + "files": { + "type": "long" + }, + "free": { + "type": "long" + }, + "free_files": { + "type": "long" + }, + "total": { + "type": "long" + }, + "used": { "properties": { - "available_bits": { + "bytes": { "type": "long" }, "pct": { @@ -616,36 +553,88 @@ exports[`EPM template tests loading system.yml: system.yml 1`] = ` "scaling_factor": 1000 } } + } + } + }, + "fsstat": { + "properties": { + "count": { + "type": "long" + }, + "total_files": { + "type": "long" }, - "filesystem": { + "total_size": { "properties": { - "available": { + "free": { "type": "long" }, - "device_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "type": { - "ignore_above": 1024, - "type": "keyword" - }, - "mount_point": { - "ignore_above": 1024, - "type": "keyword" - }, - "files": { + "used": { "type": "long" }, - "free": { + "total": { "type": "long" + } + } + } + } + }, + "load": { + "properties": { + "1": { + "type": "scaled_float", + "scaling_factor": 100 + }, + "5": { + "type": "scaled_float", + "scaling_factor": 100 + }, + "15": { + "type": "scaled_float", + "scaling_factor": 100 + }, + "norm": { + "properties": { + "1": { + "type": "scaled_float", + "scaling_factor": 100 }, - "free_files": { - "type": "long" + "5": { + "type": "scaled_float", + "scaling_factor": 100 }, - "total": { + "15": { + "type": "scaled_float", + "scaling_factor": 100 + } + } + }, + "cores": { + "type": "long" + } + } + }, + "memory": { + "properties": { + "total": { + "type": "long" + }, + "used": { + "properties": { + "bytes": { "type": "long" }, + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + } + } + }, + "free": { + "type": "long" + }, + "actual": { + "properties": { "used": { "properties": { "bytes": { @@ -656,68 +645,58 @@ exports[`EPM template tests loading system.yml: system.yml 1`] = ` "scaling_factor": 1000 } } + }, + "free": { + "type": "long" } } }, - "fsstat": { + "swap": { "properties": { - "count": { - "type": "long" - }, - "total_files": { + "total": { "type": "long" }, - "total_size": { + "used": { "properties": { - "free": { - "type": "long" - }, - "used": { + "bytes": { "type": "long" }, - "total": { - "type": "long" + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 } } - } - } - }, - "load": { - "properties": { - "1": { - "type": "scaled_float", - "scaling_factor": 100 }, - "5": { - "type": "scaled_float", - "scaling_factor": 100 + "free": { + "type": "long" }, - "15": { - "type": "scaled_float", - "scaling_factor": 100 + "out": { + "properties": { + "pages": { + "type": "long" + } + } }, - "norm": { + "in": { "properties": { - "1": { - "type": "scaled_float", - "scaling_factor": 100 - }, - "5": { - "type": "scaled_float", - "scaling_factor": 100 - }, - "15": { - "type": "scaled_float", - "scaling_factor": 100 + "pages": { + "type": "long" } } }, - "cores": { - "type": "long" + "readahead": { + "properties": { + "pages": { + "type": "long" + }, + "cached": { + "type": "long" + } + } } } }, - "memory": { + "hugepages": { "properties": { "total": { "type": "long" @@ -728,296 +707,332 @@ exports[`EPM template tests loading system.yml: system.yml 1`] = ` "type": "long" }, "pct": { - "type": "scaled_float", - "scaling_factor": 1000 + "type": "long" } } }, "free": { "type": "long" }, - "actual": { + "reserved": { + "type": "long" + }, + "surplus": { + "type": "long" + }, + "default_size": { + "type": "long" + }, + "swap": { + "properties": { + "out": { + "properties": { + "pages": { + "type": "long" + }, + "fallback": { + "type": "long" + } + } + } + } + } + } + } + } + }, + "network": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "out": { + "properties": { + "bytes": { + "type": "long" + }, + "packets": { + "type": "long" + }, + "errors": { + "type": "long" + }, + "dropped": { + "type": "long" + } + } + }, + "in": { + "properties": { + "bytes": { + "type": "long" + }, + "packets": { + "type": "long" + }, + "errors": { + "type": "long" + }, + "dropped": { + "type": "long" + } + } + } + } + }, + "network_summary": { + "properties": { + "ip": { + "properties": { + "*": { + "type": "object" + } + } + }, + "tcp": { + "properties": { + "*": { + "type": "object" + } + } + }, + "udp": { + "properties": { + "*": { + "type": "object" + } + } + }, + "udp_lite": { + "properties": { + "*": { + "type": "object" + } + } + }, + "icmp": { + "properties": { + "*": { + "type": "object" + } + } + } + } + }, + "process": { + "properties": { + "state": { + "ignore_above": 1024, + "type": "keyword" + }, + "cmdline": { + "ignore_above": 2048, + "type": "keyword" + }, + "env": { + "type": "object" + }, + "cpu": { + "properties": { + "user": { "properties": { - "used": { - "properties": { - "bytes": { - "type": "long" - }, - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - } - } - }, - "free": { + "ticks": { "type": "long" } } }, - "swap": { + "total": { "properties": { - "total": { + "value": { "type": "long" }, - "used": { + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 + }, + "norm": { "properties": { - "bytes": { - "type": "long" - }, "pct": { "type": "scaled_float", "scaling_factor": 1000 } } }, - "free": { + "ticks": { "type": "long" - }, - "out": { - "properties": { - "pages": { - "type": "long" - } - } - }, - "in": { - "properties": { - "pages": { - "type": "long" - } - } - }, - "readahead": { - "properties": { - "pages": { - "type": "long" - }, - "cached": { - "type": "long" - } - } } } }, - "hugepages": { + "system": { "properties": { - "total": { - "type": "long" - }, - "used": { - "properties": { - "bytes": { - "type": "long" - }, - "pct": { - "type": "long" - } - } - }, - "free": { - "type": "long" - }, - "reserved": { - "type": "long" - }, - "surplus": { - "type": "long" - }, - "default_size": { + "ticks": { "type": "long" - }, - "swap": { - "properties": { - "out": { - "properties": { - "pages": { - "type": "long" - }, - "fallback": { - "type": "long" - } - } - } - } } } + }, + "start_time": { + "type": "date" } } }, - "network": { + "memory": { "properties": { - "name": { - "ignore_above": 1024, - "type": "keyword" + "size": { + "type": "long" }, - "out": { + "rss": { "properties": { "bytes": { "type": "long" }, - "packets": { - "type": "long" - }, - "errors": { - "type": "long" - }, - "dropped": { - "type": "long" + "pct": { + "type": "scaled_float", + "scaling_factor": 1000 } } }, - "in": { - "properties": { - "bytes": { - "type": "long" - }, - "packets": { - "type": "long" - }, - "errors": { - "type": "long" - }, - "dropped": { - "type": "long" - } - } + "share": { + "type": "long" } } }, - "network_summary": { + "fd": { "properties": { - "ip": { - "properties": { - "*": { - "type": "object" - } - } - }, - "tcp": { - "properties": { - "*": { - "type": "object" - } - } - }, - "udp": { - "properties": { - "*": { - "type": "object" - } - } - }, - "udp_lite": { - "properties": { - "*": { - "type": "object" - } - } + "open": { + "type": "long" }, - "icmp": { + "limit": { "properties": { - "*": { - "type": "object" + "soft": { + "type": "long" + }, + "hard": { + "type": "long" } } } } }, - "process": { + "cgroup": { "properties": { - "state": { + "id": { "ignore_above": 1024, "type": "keyword" }, - "cmdline": { - "ignore_above": 2048, + "path": { + "ignore_above": 1024, "type": "keyword" }, - "env": { - "type": "object" - }, "cpu": { "properties": { - "user": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "cfs": { "properties": { - "ticks": { + "period": { + "properties": { + "us": { + "type": "long" + } + } + }, + "quota": { + "properties": { + "us": { + "type": "long" + } + } + }, + "shares": { "type": "long" } } }, - "total": { + "rt": { "properties": { - "value": { - "type": "long" - }, - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 - }, - "norm": { + "period": { "properties": { - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 + "us": { + "type": "long" } } }, - "ticks": { - "type": "long" + "runtime": { + "properties": { + "us": { + "type": "long" + } + } } } }, - "system": { + "stats": { "properties": { - "ticks": { + "periods": { "type": "long" + }, + "throttled": { + "properties": { + "periods": { + "type": "long" + }, + "ns": { + "type": "long" + } + } } } - }, - "start_time": { - "type": "date" } } }, - "memory": { + "cpuacct": { "properties": { - "size": { - "type": "long" + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" }, - "rss": { + "total": { "properties": { - "bytes": { + "ns": { "type": "long" - }, - "pct": { - "type": "scaled_float", - "scaling_factor": 1000 } } }, - "share": { - "type": "long" - } - } - }, - "fd": { - "properties": { - "open": { - "type": "long" - }, - "limit": { + "stats": { "properties": { - "soft": { - "type": "long" + "user": { + "properties": { + "ns": { + "type": "long" + } + } }, - "hard": { - "type": "long" + "system": { + "properties": { + "ns": { + "type": "long" + } + } } } + }, + "percpu": { + "type": "object" } } }, - "cgroup": { + "memory": { "properties": { "id": { "ignore_above": 1024, @@ -1027,354 +1042,212 @@ exports[`EPM template tests loading system.yml: system.yml 1`] = ` "ignore_above": 1024, "type": "keyword" }, - "cpu": { + "mem": { "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "path": { - "ignore_above": 1024, - "type": "keyword" - }, - "cfs": { + "usage": { "properties": { - "period": { - "properties": { - "us": { - "type": "long" - } - } + "bytes": { + "type": "long" }, - "quota": { + "max": { "properties": { - "us": { + "bytes": { "type": "long" } } - }, - "shares": { + } + } + }, + "limit": { + "properties": { + "bytes": { "type": "long" } } }, - "rt": { + "failures": { + "type": "long" + } + } + }, + "memsw": { + "properties": { + "usage": { "properties": { - "period": { - "properties": { - "us": { - "type": "long" - } - } + "bytes": { + "type": "long" }, - "runtime": { + "max": { "properties": { - "us": { + "bytes": { "type": "long" } } } } }, - "stats": { + "limit": { "properties": { - "periods": { + "bytes": { + "type": "long" + } + } + }, + "failures": { + "type": "long" + } + } + }, + "kmem": { + "properties": { + "usage": { + "properties": { + "bytes": { "type": "long" }, - "throttled": { + "max": { "properties": { - "periods": { - "type": "long" - }, - "ns": { + "bytes": { "type": "long" } } } } - } - } - }, - "cpuacct": { - "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "path": { - "ignore_above": 1024, - "type": "keyword" }, - "total": { + "limit": { "properties": { - "ns": { + "bytes": { "type": "long" } } }, - "stats": { + "failures": { + "type": "long" + } + } + }, + "kmem_tcp": { + "properties": { + "usage": { "properties": { - "user": { - "properties": { - "ns": { - "type": "long" - } - } + "bytes": { + "type": "long" }, - "system": { + "max": { "properties": { - "ns": { + "bytes": { "type": "long" } } } } }, - "percpu": { - "type": "object" + "limit": { + "properties": { + "bytes": { + "type": "long" + } + } + }, + "failures": { + "type": "long" } } }, - "memory": { + "stats": { "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "path": { - "ignore_above": 1024, - "type": "keyword" + "active_anon": { + "properties": { + "bytes": { + "type": "long" + } + } }, - "mem": { + "active_file": { "properties": { - "usage": { - "properties": { - "bytes": { - "type": "long" - }, - "max": { - "properties": { - "bytes": { - "type": "long" - } - } - } - } - }, - "limit": { - "properties": { - "bytes": { - "type": "long" - } - } - }, - "failures": { + "bytes": { "type": "long" } } }, - "memsw": { + "cache": { "properties": { - "usage": { - "properties": { - "bytes": { - "type": "long" - }, - "max": { - "properties": { - "bytes": { - "type": "long" - } - } - } - } - }, - "limit": { - "properties": { - "bytes": { - "type": "long" - } - } - }, - "failures": { + "bytes": { "type": "long" } } }, - "kmem": { + "hierarchical_memory_limit": { "properties": { - "usage": { - "properties": { - "bytes": { - "type": "long" - }, - "max": { - "properties": { - "bytes": { - "type": "long" - } - } - } - } - }, - "limit": { - "properties": { - "bytes": { - "type": "long" - } - } - }, - "failures": { + "bytes": { "type": "long" } } }, - "kmem_tcp": { + "hierarchical_memsw_limit": { "properties": { - "usage": { - "properties": { - "bytes": { - "type": "long" - }, - "max": { - "properties": { - "bytes": { - "type": "long" - } - } - } - } - }, - "limit": { - "properties": { - "bytes": { - "type": "long" - } - } - }, - "failures": { + "bytes": { "type": "long" } } }, - "stats": { + "inactive_anon": { "properties": { - "active_anon": { - "properties": { - "bytes": { - "type": "long" - } - } - }, - "active_file": { - "properties": { - "bytes": { - "type": "long" - } - } - }, - "cache": { - "properties": { - "bytes": { - "type": "long" - } - } - }, - "hierarchical_memory_limit": { - "properties": { - "bytes": { - "type": "long" - } - } - }, - "hierarchical_memsw_limit": { - "properties": { - "bytes": { - "type": "long" - } - } - }, - "inactive_anon": { - "properties": { - "bytes": { - "type": "long" - } - } - }, - "inactive_file": { - "properties": { - "bytes": { - "type": "long" - } - } - }, - "mapped_file": { - "properties": { - "bytes": { - "type": "long" - } - } - }, - "page_faults": { + "bytes": { "type": "long" - }, - "major_page_faults": { + } + } + }, + "inactive_file": { + "properties": { + "bytes": { "type": "long" - }, - "pages_in": { + } + } + }, + "mapped_file": { + "properties": { + "bytes": { "type": "long" - }, - "pages_out": { + } + } + }, + "page_faults": { + "type": "long" + }, + "major_page_faults": { + "type": "long" + }, + "pages_in": { + "type": "long" + }, + "pages_out": { + "type": "long" + }, + "rss": { + "properties": { + "bytes": { "type": "long" - }, - "rss": { - "properties": { - "bytes": { - "type": "long" - } - } - }, - "rss_huge": { - "properties": { - "bytes": { - "type": "long" - } - } - }, - "swap": { - "properties": { - "bytes": { - "type": "long" - } - } - }, - "unevictable": { - "properties": { - "bytes": { - "type": "long" - } - } } } - } - } - }, - "blkio": { - "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" }, - "path": { - "ignore_above": 1024, - "type": "keyword" + "rss_huge": { + "properties": { + "bytes": { + "type": "long" + } + } }, - "total": { + "swap": { "properties": { "bytes": { "type": "long" - }, - "ios": { + } + } + }, + "unevictable": { + "properties": { + "bytes": { "type": "long" } } @@ -1383,286 +1256,290 @@ exports[`EPM template tests loading system.yml: system.yml 1`] = ` } } }, - "summary": { + "blkio": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "total": { + "properties": { + "bytes": { + "type": "long" + }, + "ios": { + "type": "long" + } + } + } + } + } + } + }, + "summary": { + "properties": { + "total": { + "type": "long" + }, + "running": { + "type": "long" + }, + "idle": { + "type": "long" + }, + "sleeping": { + "type": "long" + }, + "stopped": { + "type": "long" + }, + "zombie": { + "type": "long" + }, + "dead": { + "type": "long" + }, + "unknown": { + "type": "long" + } + } + } + } + }, + "raid": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "level": { + "ignore_above": 1024, + "type": "keyword" + }, + "sync_action": { + "ignore_above": 1024, + "type": "keyword" + }, + "disks": { + "properties": { + "active": { + "type": "long" + }, + "total": { + "type": "long" + }, + "spare": { + "type": "long" + }, + "failed": { + "type": "long" + }, + "states": { "properties": { - "total": { - "type": "long" - }, - "running": { - "type": "long" - }, - "idle": { - "type": "long" - }, - "sleeping": { - "type": "long" - }, - "stopped": { - "type": "long" - }, - "zombie": { - "type": "long" - }, - "dead": { - "type": "long" - }, - "unknown": { - "type": "long" + "*": { + "type": "object" } } } } }, - "raid": { + "blocks": { + "properties": { + "total": { + "type": "long" + }, + "synced": { + "type": "long" + } + } + } + } + }, + "socket": { + "properties": { + "local": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "remote": { "properties": { - "name": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + }, + "host": { "ignore_above": 1024, "type": "keyword" }, - "status": { + "etld_plus_one": { "ignore_above": 1024, "type": "keyword" }, - "level": { + "host_error": { "ignore_above": 1024, "type": "keyword" - }, - "sync_action": { + } + } + }, + "process": { + "properties": { + "cmdline": { "ignore_above": 1024, "type": "keyword" - }, - "disks": { - "properties": { - "active": { - "type": "long" - }, - "total": { - "type": "long" - }, - "spare": { - "type": "long" - }, - "failed": { - "type": "long" - }, - "states": { - "properties": { - "*": { - "type": "object" - } - } - } - } - }, - "blocks": { - "properties": { - "total": { - "type": "long" - }, - "synced": { - "type": "long" - } - } } } }, - "socket": { + "user": { + "properties": {} + }, + "summary": { "properties": { - "local": { + "all": { "properties": { - "ip": { - "type": "ip" + "count": { + "type": "long" }, - "port": { + "listening": { "type": "long" } } }, - "remote": { + "tcp": { "properties": { - "ip": { - "type": "ip" - }, - "port": { + "memory": { "type": "long" }, - "host": { - "ignore_above": 1024, - "type": "keyword" - }, - "etld_plus_one": { - "ignore_above": 1024, - "type": "keyword" - }, - "host_error": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "process": { - "properties": { - "cmdline": { - "ignore_above": 1024, - "type": "keyword" - } - } - }, - "user": { - "properties": {} - }, - "summary": { - "properties": { "all": { "properties": { + "orphan": { + "type": "long" + }, "count": { "type": "long" }, "listening": { "type": "long" - } - } - }, - "tcp": { - "properties": { - "memory": { + }, + "established": { "type": "long" }, - "all": { - "properties": { - "orphan": { - "type": "long" - }, - "count": { - "type": "long" - }, - "listening": { - "type": "long" - }, - "established": { - "type": "long" - }, - "close_wait": { - "type": "long" - }, - "time_wait": { - "type": "long" - }, - "syn_sent": { - "type": "long" - }, - "syn_recv": { - "type": "long" - }, - "fin_wait1": { - "type": "long" - }, - "fin_wait2": { - "type": "long" - }, - "last_ack": { - "type": "long" - }, - "closing": { - "type": "long" - } - } - } - } - }, - "udp": { - "properties": { - "memory": { + "close_wait": { "type": "long" }, - "all": { - "properties": { - "count": { - "type": "long" - } - } + "time_wait": { + "type": "long" + }, + "syn_sent": { + "type": "long" + }, + "syn_recv": { + "type": "long" + }, + "fin_wait1": { + "type": "long" + }, + "fin_wait2": { + "type": "long" + }, + "last_ack": { + "type": "long" + }, + "closing": { + "type": "long" } } } } - } - } - }, - "uptime": { - "properties": { - "duration": { + }, + "udp": { "properties": { - "ms": { + "memory": { "type": "long" + }, + "all": { + "properties": { + "count": { + "type": "long" + } + } } } } } - }, - "users": { + } + } + }, + "uptime": { + "properties": { + "duration": { "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "seat": { - "ignore_above": 1024, - "type": "keyword" - }, - "path": { - "ignore_above": 1024, - "type": "keyword" - }, - "type": { - "ignore_above": 1024, - "type": "keyword" - }, - "service": { - "ignore_above": 1024, - "type": "keyword" - }, - "remote": { - "type": "boolean" - }, - "state": { - "ignore_above": 1024, - "type": "keyword" - }, - "scope": { - "ignore_above": 1024, - "type": "keyword" - }, - "leader": { + "ms": { "type": "long" - }, - "remote_host": { - "ignore_above": 1024, - "type": "keyword" } } } } - } - }, - "_meta": { - "managed_by": "fleet", - "managed": true, - "package": { - "name": "system" + }, + "users": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "seat": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "service": { + "ignore_above": 1024, + "type": "keyword" + }, + "remote": { + "type": "boolean" + }, + "state": { + "ignore_above": 1024, + "type": "keyword" + }, + "scope": { + "ignore_above": 1024, + "type": "keyword" + }, + "leader": { + "type": "long" + }, + "remote_host": { + "ignore_above": 1024, + "type": "keyword" + } + } } } } - }, - "data_stream": {}, - "composed_of": [ - ".fleet_component_template-1" - ], - "_meta": { - "managed_by": "fleet", - "managed": true, - "package": { - "name": "system" - } } } `; diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/default_settings.test.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/default_settings.test.ts index ee6d7086cdd3c..ea2d0868c6d17 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/default_settings.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/default_settings.test.ts @@ -46,11 +46,6 @@ describe('buildDefaultSettings', () => { "lifecycle": Object { "name": "logs", }, - "mapping": Object { - "total_fields": Object { - "limit": "10000", - }, - }, "query": Object { "default_field": Array [ "field1Keyword", diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/default_settings.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/default_settings.ts index 84ec75b9da065..7f8e8e8544109 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/default_settings.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/default_settings.ts @@ -67,12 +67,6 @@ export function buildDefaultSettings({ }, // What should be our default for the compression? codec: 'best_compression', - mapping: { - total_fields: { - limit: '10000', - }, - }, - // All the default fields which should be queried have to be added here. // So far we add all keyword and text fields here if there are any, otherwise // this setting is skipped. diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts index 1303db1a36c0e..894c2820fa2e1 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { merge } from 'lodash'; +import { merge, cloneDeep } from 'lodash'; import Boom from '@hapi/boom'; import type { ElasticsearchClient, Logger, SavedObjectsClientContract } from 'src/core/server'; @@ -17,18 +17,23 @@ import type { InstallablePackage, IndexTemplate, PackageInfo, + IndexTemplateMappings, + TemplateMapEntry, + TemplateMap, } from '../../../../types'; + import { loadFieldsFromYaml, processFields } from '../../fields/field'; import type { Field } from '../../fields/field'; import { getPipelineNameForInstallation } from '../ingest_pipeline/install'; import { getAsset, getPathParts } from '../../archive'; import { removeAssetTypesFromInstalledEs, saveInstalledEsRefs } from '../../packages/install'; import { - FLEET_GLOBAL_COMPONENT_TEMPLATE_NAME, - FLEET_GLOBAL_COMPONENT_TEMPLATE_CONTENT, + FLEET_COMPONENT_TEMPLATES, + MAPPINGS_TEMPLATE_SUFFIX, + SETTINGS_TEMPLATE_SUFFIX, + USER_SETTINGS_TEMPLATE_SUFFIX, } from '../../../../constants'; -import type { ESAssetMetadata } from '../meta'; import { getESAssetMetadata } from '../meta'; import { retryTransientEsErrors } from '../retry'; @@ -43,6 +48,8 @@ import { } from './template'; import { buildDefaultSettings } from './default_settings'; +const FLEET_COMPONENT_TEMPLATE_NAMES = FLEET_COMPONENT_TEMPLATES.map((tmpl) => tmpl.name); + export const installTemplates = async ( installablePackage: InstallablePackage, esClient: ElasticsearchClient, @@ -202,19 +209,6 @@ export async function installTemplateForDataStream({ }); } -interface TemplateMapEntry { - _meta: ESAssetMetadata; - template: - | { - mappings: NonNullable; - } - | { - settings: NonNullable | object; - }; -} - -type TemplateMap = Record; - function putComponentTemplate( esClient: ElasticsearchClient, logger: Logger, @@ -223,7 +217,10 @@ function putComponentTemplate( name: string; create?: boolean; } -): { clusterPromise: Promise; name: string } { +): { + clusterPromise: ReturnType; + name: string; +} { const { name, body, create = false } = params; return { clusterPromise: retryTransientEsErrors( @@ -234,41 +231,59 @@ function putComponentTemplate( }; } -const mappingsSuffix = '@mappings'; -const settingsSuffix = '@settings'; -const userSettingsSuffix = '@custom'; type TemplateBaseName = string; -type UserSettingsTemplateName = `${TemplateBaseName}${typeof userSettingsSuffix}`; +type UserSettingsTemplateName = `${TemplateBaseName}${typeof USER_SETTINGS_TEMPLATE_SUFFIX}`; const isUserSettingsTemplate = (name: string): name is UserSettingsTemplateName => - name.endsWith(userSettingsSuffix); + name.endsWith(USER_SETTINGS_TEMPLATE_SUFFIX); function buildComponentTemplates(params: { + mappings: IndexTemplateMappings; templateName: string; registryElasticsearch: RegistryElasticsearch | undefined; packageName: string; defaultSettings: IndexTemplate['template']['settings']; }) { - const { templateName, registryElasticsearch, packageName, defaultSettings } = params; - const mappingsTemplateName = `${templateName}${mappingsSuffix}`; - const settingsTemplateName = `${templateName}${settingsSuffix}`; - const userSettingsTemplateName = `${templateName}${userSettingsSuffix}`; + const { templateName, registryElasticsearch, packageName, defaultSettings, mappings } = params; + const mappingsTemplateName = `${templateName}${MAPPINGS_TEMPLATE_SUFFIX}`; + const settingsTemplateName = `${templateName}${SETTINGS_TEMPLATE_SUFFIX}`; + const userSettingsTemplateName = `${templateName}${USER_SETTINGS_TEMPLATE_SUFFIX}`; const templatesMap: TemplateMap = {}; const _meta = getESAssetMetadata({ packageName }); - if (registryElasticsearch && registryElasticsearch['index_template.mappings']) { - templatesMap[mappingsTemplateName] = { - template: { - mappings: registryElasticsearch['index_template.mappings'], - }, - _meta, - }; + const indexTemplateSettings = registryElasticsearch?.['index_template.settings'] ?? {}; + // @ts-expect-error no property .mapping (yes there is) + const indexTemplateMappingSettings = indexTemplateSettings?.index?.mapping; + const indexTemplateSettingsForTemplate = cloneDeep(indexTemplateSettings); + + // index.mapping settings must go on the mapping component template otherwise + // the template may be rejected e.g if nested_fields.limit has been increased + if (indexTemplateMappingSettings) { + // @ts-expect-error no property .mapping + delete indexTemplateSettingsForTemplate.index.mapping; } + templatesMap[mappingsTemplateName] = { + template: { + settings: { + index: { + mapping: { + total_fields: { + limit: '10000', + }, + ...indexTemplateMappingSettings, + }, + }, + }, + mappings: merge(mappings, registryElasticsearch?.['index_template.mappings'] ?? {}), + }, + _meta, + }; + templatesMap[settingsTemplateName] = { template: { - settings: merge(defaultSettings, registryElasticsearch?.['index_template.settings'] ?? {}), + settings: merge(defaultSettings, indexTemplateSettingsForTemplate), }, _meta, }; @@ -285,6 +300,7 @@ function buildComponentTemplates(params: { } async function installDataStreamComponentTemplates(params: { + mappings: IndexTemplateMappings; templateName: string; registryElasticsearch: RegistryElasticsearch | undefined; esClient: ElasticsearchClient; @@ -292,16 +308,23 @@ async function installDataStreamComponentTemplates(params: { packageName: string; defaultSettings: IndexTemplate['template']['settings']; }) { - const { templateName, registryElasticsearch, esClient, packageName, defaultSettings, logger } = - params; - const templates = buildComponentTemplates({ + const { + templateName, + registryElasticsearch, + esClient, + packageName, + defaultSettings, + logger, + mappings, + } = params; + const componentTemplates = buildComponentTemplates({ + mappings, templateName, registryElasticsearch, packageName, defaultSettings, }); - const templateNames = Object.keys(templates); - const templateEntries = Object.entries(templates); + const templateEntries = Object.entries(componentTemplates); // TODO: Check return values for errors await Promise.all( templateEntries.map(async ([name, body]) => { @@ -327,18 +350,31 @@ async function installDataStreamComponentTemplates(params: { }) ); - return templateNames; + return { componentTemplateNames: Object.keys(componentTemplates) }; } -export async function ensureDefaultComponentTemplate( +export async function ensureDefaultComponentTemplates( esClient: ElasticsearchClient, logger: Logger +) { + return Promise.all( + FLEET_COMPONENT_TEMPLATES.map(({ name, body }) => + ensureComponentTemplate(esClient, logger, name, body) + ) + ); +} + +export async function ensureComponentTemplate( + esClient: ElasticsearchClient, + logger: Logger, + name: string, + body: TemplateMapEntry ) { const getTemplateRes = await retryTransientEsErrors( () => esClient.cluster.getComponentTemplate( { - name: FLEET_GLOBAL_COMPONENT_TEMPLATE_NAME, + name, }, { ignore: [404], @@ -350,8 +386,8 @@ export async function ensureDefaultComponentTemplate( const existingTemplate = getTemplateRes?.component_templates?.[0]; if (!existingTemplate) { await putComponentTemplate(esClient, logger, { - name: FLEET_GLOBAL_COMPONENT_TEMPLATE_NAME, - body: FLEET_GLOBAL_COMPONENT_TEMPLATE_CONTENT, + name, + body, }).clusterPromise; } @@ -434,7 +470,8 @@ export async function installTemplate({ ilmPolicy: dataStream.ilm_policy, }); - const composedOfTemplates = await installDataStreamComponentTemplates({ + const { componentTemplateNames } = await installDataStreamComponentTemplates({ + mappings, templateName, registryElasticsearch: dataStream.elasticsearch, esClient, @@ -444,13 +481,10 @@ export async function installTemplate({ }); const template = getTemplate({ - type: dataStream.type, templateIndexPattern, - fields: validFields, - mappings, pipelineName, packageName, - composedOfTemplates, + composedOfTemplates: componentTemplateNames, templatePriority, hidden: dataStream.hidden, }); @@ -482,7 +516,9 @@ export function getAllTemplateRefs(installedTemplates: IndexTemplateEntry[]) { ]; const componentTemplates = installedTemplate.indexTemplate.composed_of // Filter global component template shared between integrations - .filter((componentTemplateId) => componentTemplateId !== FLEET_GLOBAL_COMPONENT_TEMPLATE_NAME) + .filter( + (componentTemplateId) => !FLEET_COMPONENT_TEMPLATE_NAMES.includes(componentTemplateId) + ) .map((componentTemplateId) => ({ id: componentTemplateId, type: ElasticsearchAssetType.componentTemplate, diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts index 5474d2c09cfc5..86edf1c5e4064 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts @@ -26,7 +26,7 @@ import { updateCurrentWriteIndices, } from './template'; -const FLEET_COMPONENT_TEMPLATE = '.fleet_component_template-1'; +const FLEET_COMPONENT_TEMPLATES = ['.fleet_globals-1', '.fleet_agent_id_verification-1']; // Add our own serialiser to just do JSON.stringify expect.addSnapshotSerializer({ @@ -48,11 +48,8 @@ describe('EPM template', () => { const templateIndexPattern = 'logs-nginx.access-abcd-*'; const template = getTemplate({ - type: 'logs', templateIndexPattern, packageName: 'nginx', - fields: [], - mappings: { properties: {} }, composedOfTemplates: [], templatePriority: 200, }); @@ -63,41 +60,35 @@ describe('EPM template', () => { const composedOfTemplates = ['component1', 'component2']; const template = getTemplate({ - type: 'logs', templateIndexPattern: 'name-*', packageName: 'nginx', - fields: [], - mappings: { properties: {} }, composedOfTemplates, templatePriority: 200, }); - expect(template.composed_of).toStrictEqual([...composedOfTemplates, FLEET_COMPONENT_TEMPLATE]); + expect(template.composed_of).toStrictEqual([ + ...composedOfTemplates, + ...FLEET_COMPONENT_TEMPLATES, + ]); }); it('adds empty composed_of correctly', () => { const composedOfTemplates: string[] = []; const template = getTemplate({ - type: 'logs', templateIndexPattern: 'name-*', packageName: 'nginx', - fields: [], - mappings: { properties: {} }, composedOfTemplates, templatePriority: 200, }); - expect(template.composed_of).toStrictEqual([FLEET_COMPONENT_TEMPLATE]); + expect(template.composed_of).toStrictEqual(FLEET_COMPONENT_TEMPLATES); }); it('adds hidden field correctly', () => { const templateIndexPattern = 'logs-nginx.access-abcd-*'; const templateWithHidden = getTemplate({ - type: 'logs', templateIndexPattern, packageName: 'nginx', - fields: [], - mappings: { properties: {} }, composedOfTemplates: [], templatePriority: 200, hidden: true, @@ -105,11 +96,8 @@ describe('EPM template', () => { expect(templateWithHidden.data_stream.hidden).toEqual(true); const templateWithoutHidden = getTemplate({ - type: 'logs', templateIndexPattern, packageName: 'nginx', - fields: [], - mappings: { properties: {} }, composedOfTemplates: [], templatePriority: 200, }); @@ -123,17 +111,8 @@ describe('EPM template', () => { const processedFields = processFields(fields); const mappings = generateMappings(processedFields); - const template = getTemplate({ - type: 'logs', - templateIndexPattern: 'foo-*', - packageName: 'nginx', - fields: processedFields, - mappings, - composedOfTemplates: [], - templatePriority: 200, - }); - expect(template).toMatchSnapshot(path.basename(ymlPath)); + expect(mappings).toMatchSnapshot(path.basename(ymlPath)); }); it('tests loading coredns.logs.yml', () => { @@ -143,37 +122,19 @@ describe('EPM template', () => { const processedFields = processFields(fields); const mappings = generateMappings(processedFields); - const template = getTemplate({ - type: 'logs', - templateIndexPattern: 'foo-*', - packageName: 'coredns', - fields: processedFields, - mappings, - composedOfTemplates: [], - templatePriority: 200, - }); - expect(template).toMatchSnapshot(path.basename(ymlPath)); + expect(mappings).toMatchSnapshot(path.basename(ymlPath)); }); it('tests loading system.yml', () => { const ymlPath = path.join(__dirname, '../../fields/tests/system.yml'); const fieldsYML = readFileSync(ymlPath, 'utf-8'); const fields: Field[] = safeLoad(fieldsYML); - const processedFields = processFields(fields); + const mappings = generateMappings(processedFields); - const template = getTemplate({ - type: 'metrics', - templateIndexPattern: 'whatsthis-*', - packageName: 'system', - fields: processedFields, - mappings, - composedOfTemplates: [], - templatePriority: 200, - }); - expect(template).toMatchSnapshot(path.basename(ymlPath)); + expect(mappings).toMatchSnapshot(path.basename(ymlPath)); }); it('tests processing long field with index false', () => { @@ -874,6 +835,12 @@ describe('EPM template', () => { esClient.indices.getDataStream.mockResponse({ data_streams: [{ name: 'test.prefix1-default' }], } as any); + esClient.indices.simulateTemplate.mockResponse({ + template: { + settings: { index: {} }, + mappings: { properties: {} }, + }, + } as any); const logger = loggerMock.create(); await updateCurrentWriteIndices(esClient, logger, [ { @@ -902,6 +869,14 @@ describe('EPM template', () => { { name: 'test-replicated', replicated: true }, ], } as any); + + esClient.indices.simulateTemplate.mockResponse({ + template: { + settings: { index: {} }, + mappings: { properties: {} }, + }, + } as any); + const logger = loggerMock.create(); await updateCurrentWriteIndices(esClient, logger, [ { diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts index 4b3da8bae784d..21c7351b31384 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts @@ -6,6 +6,7 @@ */ import type { ElasticsearchClient, Logger } from 'kibana/server'; +import type { IndicesIndexSettings } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { Field, Fields } from '../../fields/field'; import type { @@ -16,7 +17,10 @@ import type { } from '../../../../types'; import { appContextService } from '../../../'; import { getRegistryDataStreamAssetBaseName } from '../index'; -import { FLEET_GLOBAL_COMPONENT_TEMPLATE_NAME } from '../../../../constants'; +import { + FLEET_GLOBALS_COMPONENT_TEMPLATE_NAME, + FLEET_AGENT_ID_VERIFY_COMPONENT_TEMPLATE_NAME, +} from '../../../../constants'; import { getESAssetMetadata } from '../meta'; import { retryTransientEsErrors } from '../retry'; @@ -51,20 +55,14 @@ const META_PROP_KEYS = ['metric_type', 'unit']; * @param indexPattern String with the index pattern */ export function getTemplate({ - type, templateIndexPattern, - fields, - mappings, pipelineName, packageName, composedOfTemplates, templatePriority, hidden, }: { - type: string; templateIndexPattern: string; - fields: Fields; - mappings: IndexTemplateMappings; pipelineName?: string | undefined; packageName: string; composedOfTemplates: string[]; @@ -72,10 +70,7 @@ export function getTemplate({ hidden?: boolean; }): IndexTemplate { const template = getBaseTemplate( - type, templateIndexPattern, - fields, - mappings, packageName, composedOfTemplates, templatePriority, @@ -88,10 +83,13 @@ export function getTemplate({ throw new Error(`Error template for ${templateIndexPattern} contains a final_pipeline`); } - if (appContextService.getConfig()?.agentIdVerificationEnabled) { - // Add fleet global assets - template.composed_of = [...(template.composed_of || []), FLEET_GLOBAL_COMPONENT_TEMPLATE_NAME]; - } + template.composed_of = [ + ...(template.composed_of || []), + FLEET_GLOBALS_COMPONENT_TEMPLATE_NAME, + ...(appContextService.getConfig()?.agentIdVerificationEnabled + ? [FLEET_AGENT_ID_VERIFY_COMPONENT_TEMPLATE_NAME] + : []), + ]; return template; } @@ -321,6 +319,14 @@ export function generateTemplateName(dataStream: RegistryDataStream): string { return getRegistryDataStreamAssetBaseName(dataStream); } +/** + * Given a data stream name, return the indexTemplate name + */ +function dataStreamNameToIndexTemplateName(dataStreamName: string): string { + const [type, dataset] = dataStreamName.split('-'); // ignore namespace at the end + return [type, dataset].join('-'); +} + export function generateTemplateIndexPattern(dataStream: RegistryDataStream): string { // undefined or explicitly set to false // See also https://github.com/elastic/package-spec/pull/102 @@ -387,45 +393,22 @@ const flattenFieldsToNameAndType = ( }; function getBaseTemplate( - type: string, templateIndexPattern: string, - fields: Fields, - mappings: IndexTemplateMappings, packageName: string, composedOfTemplates: string[], templatePriority: number, hidden?: boolean ): IndexTemplate { - // Meta information to identify Ingest Manager's managed templates and indices const _meta = getESAssetMetadata({ packageName }); return { priority: templatePriority, - // To be completed with the correct index patterns index_patterns: [templateIndexPattern], template: { settings: { index: {}, }, mappings: { - // All the dynamic field mappings - dynamic_templates: [ - // This makes sure all mappings are keywords by default - { - strings_as_keyword: { - mapping: { - ignore_above: 1024, - type: 'keyword', - }, - match_mapping_type: 'string', - }, - }, - ], - // As we define fields ahead, we don't need any automatic field detection - // This makes sure all the fields are mapped to keyword by default to prevent mapping conflicts - date_detection: false, - // All the properties we know from the fields.yml file - properties: mappings.properties, _meta, }, }, @@ -490,70 +473,81 @@ const getDataStreams = async ( })); }; +const rolloverDataStream = (dataStreamName: string, esClient: ElasticsearchClient) => { + try { + // Do no wrap rollovers in retryTransientEsErrors since it is not idempotent + return esClient.indices.rollover({ + alias: dataStreamName, + }); + } catch (error) { + throw new Error(`cannot rollover data stream [${dataStreamName}] due to error: ${error}`); + } +}; + const updateAllDataStreams = async ( indexNameWithTemplates: CurrentDataStream[], esClient: ElasticsearchClient, logger: Logger ): Promise => { - const updatedataStreamPromises = indexNameWithTemplates.map( - ({ dataStreamName, indexTemplate }) => { - return updateExistingDataStream({ dataStreamName, esClient, logger, indexTemplate }); - } - ); + const updatedataStreamPromises = indexNameWithTemplates.map((templateEntry) => { + return updateExistingDataStream({ + esClient, + logger, + dataStreamName: templateEntry.dataStreamName, + }); + }); await Promise.all(updatedataStreamPromises); }; const updateExistingDataStream = async ({ dataStreamName, esClient, logger, - indexTemplate, }: { dataStreamName: string; esClient: ElasticsearchClient; logger: Logger; - indexTemplate: IndexTemplate; }) => { - const { settings, mappings } = indexTemplate.template; - - // for now, remove from object so as not to update stream or data stream properties of the index until type and name - // are added in https://github.com/elastic/kibana/issues/66551. namespace value we will continue - // to skip updating and assume the value in the index mapping is correct - delete mappings.properties.stream; - delete mappings.properties.data_stream; - // try to update the mappings first + let settings: IndicesIndexSettings; try { + const simulateResult = await retryTransientEsErrors(() => + esClient.indices.simulateTemplate({ + name: dataStreamNameToIndexTemplateName(dataStreamName), + }) + ); + + settings = simulateResult.template.settings; + const mappings = simulateResult.template.mappings; + // for now, remove from object so as not to update stream or data stream properties of the index until type and name + // are added in https://github.com/elastic/kibana/issues/66551. namespace value we will continue + // to skip updating and assume the value in the index mapping is correct + if (mappings && mappings.properties) { + delete mappings.properties.stream; + delete mappings.properties.data_stream; + } await retryTransientEsErrors( () => esClient.indices.putMapping({ index: dataStreamName, - body: mappings, + body: mappings || {}, write_index_only: true, }), { logger } ); // if update fails, rollover data stream } catch (err) { - try { - // Do no wrap rollovers in retryTransientEsErrors since it is not idempotent - const path = `/${dataStreamName}/_rollover`; - await esClient.transport.request({ - method: 'POST', - path, - }); - } catch (error) { - throw new Error(`cannot rollover data stream ${error}`); - } + await rolloverDataStream(dataStreamName, esClient); + return; } // update settings after mappings was successful to ensure // pointing to the new pipeline is safe // for now, only update the pipeline - if (!settings.index.default_pipeline) return; + if (!settings?.index?.default_pipeline) return; try { await retryTransientEsErrors( () => esClient.indices.putSettings({ index: dataStreamName, - body: { default_pipeline: settings.index.default_pipeline }, + body: { default_pipeline: settings!.index!.default_pipeline }, }), { logger } ); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/index.ts b/x-pack/plugins/fleet/server/services/epm/packages/index.ts index fa2e5781a209e..d742103dccf12 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/index.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/index.ts @@ -25,6 +25,8 @@ export { getLimitedPackages, } from './get'; +export { getBundledPackages } from './bundled_packages'; + export type { BulkInstallResponse, IBulkInstallPackageError } from './install'; export { handleInstallPackageFailure, installPackage, ensureInstalledPackage } from './install'; export { removeInstallation } from './remove'; diff --git a/x-pack/plugins/fleet/server/services/setup.ts b/x-pack/plugins/fleet/server/services/setup.ts index ed6ba978251ff..6e3aed538fb07 100644 --- a/x-pack/plugins/fleet/server/services/setup.ts +++ b/x-pack/plugins/fleet/server/services/setup.ts @@ -10,7 +10,12 @@ import { compact } from 'lodash'; import type { ElasticsearchClient, SavedObjectsClientContract } from 'src/core/server'; import { AUTO_UPDATE_PACKAGES } from '../../common'; -import type { DefaultPackagesInstallationError, PreconfigurationError } from '../../common'; +import type { + DefaultPackagesInstallationError, + PreconfigurationError, + BundledPackage, + Installation, +} from '../../common'; import { SO_SEARCH_LIMIT } from '../constants'; import { DEFAULT_SPACE_ID } from '../../../spaces/common/constants'; @@ -25,13 +30,13 @@ import { generateEnrollmentAPIKey, hasEnrollementAPIKeysForPolicy } from './api_ import { settingsService } from '.'; import { awaitIfPending } from './setup_utils'; import { ensureFleetFinalPipelineIsInstalled } from './epm/elasticsearch/ingest_pipeline/install'; -import { ensureDefaultComponentTemplate } from './epm/elasticsearch/template/install'; +import { ensureDefaultComponentTemplates } from './epm/elasticsearch/template/install'; import { getInstallations, installPackage } from './epm/packages'; import { isPackageInstalled } from './epm/packages/install'; import { pkgToPkgKey } from './epm/registry'; import type { UpgradeManagedPackagePoliciesResult } from './managed_package_policies'; import { upgradeManagedPackagePolicies } from './managed_package_policies'; - +import { getBundledPackages } from './epm/packages'; export interface SetupStatus { isInitialized: boolean; nonFatalErrors: Array< @@ -139,21 +144,43 @@ export async function ensureFleetGlobalEsAssets( // Ensure Global Fleet ES assets are installed logger.debug('Creating Fleet component template and ingest pipeline'); const globalAssetsRes = await Promise.all([ - ensureDefaultComponentTemplate(esClient, logger), + ensureDefaultComponentTemplates(esClient, logger), // returns an array ensureFleetFinalPipelineIsInstalled(esClient, logger), ]); - - if (globalAssetsRes.some((asset) => asset.isCreated)) { + const assetResults = globalAssetsRes.flat(); + if (assetResults.some((asset) => asset.isCreated)) { // Update existing index template - const packages = await getInstallations(soClient); - + const installedPackages = await getInstallations(soClient); + const bundledPackages = await getBundledPackages(); + const findMatchingBundledPkg = (pkg: Installation) => + bundledPackages.find( + (bundledPkg: BundledPackage) => + bundledPkg.name === pkg.name && bundledPkg.version === pkg.version + ); await Promise.all( - packages.saved_objects.map(async ({ attributes: installation }) => { + installedPackages.saved_objects.map(async ({ attributes: installation }) => { if (installation.install_source !== 'registry') { - logger.error( - `Package needs to be manually reinstalled ${installation.name} after installing Fleet global assets` - ); - return; + const matchingBundledPackage = findMatchingBundledPkg(installation); + if (!matchingBundledPackage) { + logger.error( + `Package needs to be manually reinstalled ${installation.name} after installing Fleet global assets` + ); + return; + } else { + await installPackage({ + installSource: 'upload', + savedObjectsClient: soClient, + esClient, + spaceId: DEFAULT_SPACE_ID, + contentType: 'application/zip', + archiveBuffer: matchingBundledPackage.buffer, + }).catch((err) => { + logger.error( + `Bundled package needs to be manually reinstalled ${installation.name} after installing Fleet global assets: ${err.message}` + ); + }); + return; + } } await installPackage({ installSource: installation.install_source, diff --git a/x-pack/plugins/fleet/server/types/index.tsx b/x-pack/plugins/fleet/server/types/index.tsx index 91303046485d9..9024cd05e2dea 100644 --- a/x-pack/plugins/fleet/server/types/index.tsx +++ b/x-pack/plugins/fleet/server/types/index.tsx @@ -63,6 +63,8 @@ export type { RegistrySearchResult, IndexTemplateEntry, IndexTemplateMappings, + TemplateMap, + TemplateMapEntry, Settings, SettingsSOAttributes, InstallType, diff --git a/x-pack/test/fleet_api_integration/apis/epm/__snapshots__/install_by_upload.snap b/x-pack/test/fleet_api_integration/apis/epm/__snapshots__/install_by_upload.snap new file mode 100644 index 0000000000000..421a5fbdf1744 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/epm/__snapshots__/install_by_upload.snap @@ -0,0 +1,735 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Fleet Endpoints EPM Endpoints installs packages from direct upload should install a zip archive correctly and package info should return correctly after validation 1`] = ` +Object { + "assets": Object { + "elasticsearch": Object { + "ingest_pipeline": Array [ + Object { + "dataset": "access", + "file": "default.yml", + "path": "apache-0.1.4/data_stream/access/elasticsearch/ingest_pipeline/default.yml", + "pkgkey": "apache-0.1.4", + "service": "elasticsearch", + "type": "ingest_pipeline", + }, + Object { + "dataset": "error", + "file": "default.yml", + "path": "apache-0.1.4/data_stream/error/elasticsearch/ingest_pipeline/default.yml", + "pkgkey": "apache-0.1.4", + "service": "elasticsearch", + "type": "ingest_pipeline", + }, + ], + }, + "kibana": Object { + "dashboard": Array [ + Object { + "file": "apache-Logs-Apache-Dashboard-ecs.json", + "path": "apache-0.1.4/kibana/dashboard/apache-Logs-Apache-Dashboard-ecs.json", + "pkgkey": "apache-0.1.4", + "service": "kibana", + "type": "dashboard", + }, + Object { + "file": "apache-Metrics-Apache-HTTPD-server-status-ecs.json", + "path": "apache-0.1.4/kibana/dashboard/apache-Metrics-Apache-HTTPD-server-status-ecs.json", + "pkgkey": "apache-0.1.4", + "service": "kibana", + "type": "dashboard", + }, + ], + "search": Array [ + Object { + "file": "Apache-HTTPD-ecs.json", + "path": "apache-0.1.4/kibana/search/Apache-HTTPD-ecs.json", + "pkgkey": "apache-0.1.4", + "service": "kibana", + "type": "search", + }, + Object { + "file": "Apache-access-logs-ecs.json", + "path": "apache-0.1.4/kibana/search/Apache-access-logs-ecs.json", + "pkgkey": "apache-0.1.4", + "service": "kibana", + "type": "search", + }, + Object { + "file": "Apache-errors-log-ecs.json", + "path": "apache-0.1.4/kibana/search/Apache-errors-log-ecs.json", + "pkgkey": "apache-0.1.4", + "service": "kibana", + "type": "search", + }, + ], + "visualization": Array [ + Object { + "file": "Apache-HTTPD-CPU-ecs.json", + "path": "apache-0.1.4/kibana/visualization/Apache-HTTPD-CPU-ecs.json", + "pkgkey": "apache-0.1.4", + "service": "kibana", + "type": "visualization", + }, + Object { + "file": "Apache-HTTPD-Hostname-list-ecs.json", + "path": "apache-0.1.4/kibana/visualization/Apache-HTTPD-Hostname-list-ecs.json", + "pkgkey": "apache-0.1.4", + "service": "kibana", + "type": "visualization", + }, + Object { + "file": "Apache-HTTPD-Load1-slash-5-slash-15-ecs.json", + "path": "apache-0.1.4/kibana/visualization/Apache-HTTPD-Load1-slash-5-slash-15-ecs.json", + "pkgkey": "apache-0.1.4", + "service": "kibana", + "type": "visualization", + }, + Object { + "file": "Apache-HTTPD-Scoreboard-ecs.json", + "path": "apache-0.1.4/kibana/visualization/Apache-HTTPD-Scoreboard-ecs.json", + "pkgkey": "apache-0.1.4", + "service": "kibana", + "type": "visualization", + }, + Object { + "file": "Apache-HTTPD-Total-accesses-and-kbytes-ecs.json", + "path": "apache-0.1.4/kibana/visualization/Apache-HTTPD-Total-accesses-and-kbytes-ecs.json", + "pkgkey": "apache-0.1.4", + "service": "kibana", + "type": "visualization", + }, + Object { + "file": "Apache-HTTPD-Uptime-ecs.json", + "path": "apache-0.1.4/kibana/visualization/Apache-HTTPD-Uptime-ecs.json", + "pkgkey": "apache-0.1.4", + "service": "kibana", + "type": "visualization", + }, + Object { + "file": "Apache-HTTPD-Workers-ecs.json", + "path": "apache-0.1.4/kibana/visualization/Apache-HTTPD-Workers-ecs.json", + "pkgkey": "apache-0.1.4", + "service": "kibana", + "type": "visualization", + }, + Object { + "file": "Apache-access-unique-IPs-map-ecs.json", + "path": "apache-0.1.4/kibana/visualization/Apache-access-unique-IPs-map-ecs.json", + "pkgkey": "apache-0.1.4", + "service": "kibana", + "type": "visualization", + }, + Object { + "file": "Apache-browsers-ecs.json", + "path": "apache-0.1.4/kibana/visualization/Apache-browsers-ecs.json", + "pkgkey": "apache-0.1.4", + "service": "kibana", + "type": "visualization", + }, + Object { + "file": "Apache-error-logs-over-time-ecs.json", + "path": "apache-0.1.4/kibana/visualization/Apache-error-logs-over-time-ecs.json", + "pkgkey": "apache-0.1.4", + "service": "kibana", + "type": "visualization", + }, + Object { + "file": "Apache-operating-systems-ecs.json", + "path": "apache-0.1.4/kibana/visualization/Apache-operating-systems-ecs.json", + "pkgkey": "apache-0.1.4", + "service": "kibana", + "type": "visualization", + }, + Object { + "file": "Apache-response-codes-of-top-URLs-ecs.json", + "path": "apache-0.1.4/kibana/visualization/Apache-response-codes-of-top-URLs-ecs.json", + "pkgkey": "apache-0.1.4", + "service": "kibana", + "type": "visualization", + }, + Object { + "file": "Apache-response-codes-over-time-ecs.json", + "path": "apache-0.1.4/kibana/visualization/Apache-response-codes-over-time-ecs.json", + "pkgkey": "apache-0.1.4", + "service": "kibana", + "type": "visualization", + }, + ], + }, + }, + "categories": Array [ + "web", + ], + "conditions": Object { + "kibana": Object { + "version": "^7.9.0", + }, + }, + "data_streams": Array [ + Object { + "dataset": "apache.access", + "ingest_pipeline": "default", + "package": "apache", + "path": "access", + "release": "experimental", + "streams": Array [ + Object { + "description": "Collect Apache access logs", + "enabled": true, + "input": "logfile", + "template_path": "log.yml.hbs", + "title": "Apache access logs", + "vars": Array [ + Object { + "default": Array [ + "/var/log/apache2/access.log*", + "/var/log/apache2/other_vhosts_access.log*", + "/var/log/httpd/access_log*", + ], + "multi": true, + "name": "paths", + "required": true, + "show_user": true, + "title": "Paths", + "type": "text", + }, + ], + }, + ], + "title": "Apache access logs", + "type": "logs", + }, + Object { + "dataset": "apache.error", + "ingest_pipeline": "default", + "package": "apache", + "path": "error", + "release": "experimental", + "streams": Array [ + Object { + "description": "Collect Apache error logs", + "enabled": true, + "input": "logfile", + "template_path": "log.yml.hbs", + "title": "Apache error logs", + "vars": Array [ + Object { + "default": Array [ + "/var/log/apache2/error.log*", + "/var/log/httpd/error_log*", + ], + "multi": true, + "name": "paths", + "required": true, + "show_user": true, + "title": "Paths", + "type": "text", + }, + ], + }, + ], + "title": "Apache error logs", + "type": "logs", + }, + Object { + "dataset": "apache.status", + "package": "apache", + "path": "status", + "release": "experimental", + "streams": Array [ + Object { + "description": "Collect Apache status metrics", + "enabled": true, + "input": "apache/metrics", + "template_path": "stream.yml.hbs", + "title": "Apache status metrics", + "vars": Array [ + Object { + "default": "10s", + "multi": false, + "name": "period", + "required": true, + "show_user": true, + "title": "Period", + "type": "text", + }, + Object { + "default": "/server-status", + "multi": false, + "name": "server_status_path", + "required": true, + "show_user": false, + "title": "Server Status Path", + "type": "text", + }, + ], + }, + ], + "title": "Apache status metrics", + "type": "metrics", + }, + ], + "description": "Apache Integration", + "download": "/epr/apache/apache-0.1.4.zip", + "format_version": "1.0.0", + "icons": Array [ + Object { + "path": "/package/apache/0.1.4/img/logo_apache.svg", + "size": "32x32", + "src": "/img/logo_apache.svg", + "title": "Apache Logo", + "type": "image/svg+xml", + }, + ], + "keepPoliciesUpToDate": false, + "license": "basic", + "name": "apache", + "owner": Object { + "github": "elastic/integrations-services", + }, + "path": "/package/apache/0.1.4", + "policy_templates": Array [ + Object { + "description": "Collect logs and metrics from Apache instances", + "inputs": Array [ + Object { + "description": "Collecting Apache access and error logs", + "title": "Collect logs from Apache instances", + "type": "logfile", + }, + Object { + "description": "Collecting Apache status metrics", + "title": "Collect metrics from Apache instances", + "type": "apache/metrics", + "vars": Array [ + Object { + "default": Array [ + "http://127.0.0.1", + ], + "multi": true, + "name": "hosts", + "required": true, + "show_user": true, + "title": "Hosts", + "type": "text", + }, + ], + }, + ], + "multiple": true, + "name": "apache", + "title": "Apache logs and metrics", + }, + ], + "readme": "/package/apache/0.1.4/docs/README.md", + "release": "experimental", + "removable": true, + "savedObject": Object { + "attributes": Object { + "es_index_patterns": Object { + "access": "logs-apache.access-*", + "error": "logs-apache.error-*", + "status": "metrics-apache.status-*", + }, + "install_source": "upload", + "install_status": "installed", + "install_version": "0.1.4", + "installed_es": Array [ + Object { + "id": "logs-apache.access-0.1.4-default", + "type": "ingest_pipeline", + }, + Object { + "id": "logs-apache.error-0.1.4-default", + "type": "ingest_pipeline", + }, + Object { + "id": "logs-apache.access", + "type": "index_template", + }, + Object { + "id": "logs-apache.access@mappings", + "type": "component_template", + }, + Object { + "id": "logs-apache.access@settings", + "type": "component_template", + }, + Object { + "id": "logs-apache.access@custom", + "type": "component_template", + }, + Object { + "id": "metrics-apache.status", + "type": "index_template", + }, + Object { + "id": "metrics-apache.status@mappings", + "type": "component_template", + }, + Object { + "id": "metrics-apache.status@settings", + "type": "component_template", + }, + Object { + "id": "metrics-apache.status@custom", + "type": "component_template", + }, + Object { + "id": "logs-apache.error", + "type": "index_template", + }, + Object { + "id": "logs-apache.error@mappings", + "type": "component_template", + }, + Object { + "id": "logs-apache.error@settings", + "type": "component_template", + }, + Object { + "id": "logs-apache.error@custom", + "type": "component_template", + }, + ], + "installed_kibana": Array [ + Object { + "id": "apache-Logs-Apache-Dashboard-ecs", + "type": "dashboard", + }, + Object { + "id": "apache-Metrics-Apache-HTTPD-server-status-ecs", + "type": "dashboard", + }, + Object { + "id": "Apache-access-unique-IPs-map-ecs", + "type": "visualization", + }, + Object { + "id": "Apache-HTTPD-CPU-ecs", + "type": "visualization", + }, + Object { + "id": "Apache-HTTPD-Load1-slash-5-slash-15-ecs", + "type": "visualization", + }, + Object { + "id": "Apache-response-codes-over-time-ecs", + "type": "visualization", + }, + Object { + "id": "Apache-HTTPD-Workers-ecs", + "type": "visualization", + }, + Object { + "id": "Apache-HTTPD-Hostname-list-ecs", + "type": "visualization", + }, + Object { + "id": "Apache-error-logs-over-time-ecs", + "type": "visualization", + }, + Object { + "id": "Apache-HTTPD-Scoreboard-ecs", + "type": "visualization", + }, + Object { + "id": "Apache-HTTPD-Uptime-ecs", + "type": "visualization", + }, + Object { + "id": "Apache-operating-systems-ecs", + "type": "visualization", + }, + Object { + "id": "Apache-HTTPD-Total-accesses-and-kbytes-ecs", + "type": "visualization", + }, + Object { + "id": "Apache-browsers-ecs", + "type": "visualization", + }, + Object { + "id": "Apache-response-codes-of-top-URLs-ecs", + "type": "visualization", + }, + Object { + "id": "Apache-access-logs-ecs", + "type": "search", + }, + Object { + "id": "Apache-errors-log-ecs", + "type": "search", + }, + Object { + "id": "Apache-HTTPD-ecs", + "type": "search", + }, + ], + "installed_kibana_space_id": "default", + "name": "apache", + "package_assets": Array [ + Object { + "id": "2f1ab9c0-8cf6-5e83-afcd-0d12851c8108", + "type": "epm-packages-assets", + }, + Object { + "id": "841166f1-6db0-5f7a-a8d9-768e88ddf984", + "type": "epm-packages-assets", + }, + Object { + "id": "b12ae5e1-daf2-51a7-99d8-0888d1f13b5b", + "type": "epm-packages-assets", + }, + Object { + "id": "2f263b24-c36a-5ea8-a707-76d1f274c888", + "type": "epm-packages-assets", + }, + Object { + "id": "bd5ff9ad-ba4a-5215-b5af-cef58a3aa886", + "type": "epm-packages-assets", + }, + Object { + "id": "5fc59aa9-1d7e-50ae-8ce5-b875ab44cfc5", + "type": "epm-packages-assets", + }, + Object { + "id": "7c850453-346b-5010-a946-28b83fc69e48", + "type": "epm-packages-assets", + }, + Object { + "id": "f02f8adb-3e0c-5f2f-b4f2-a04dc645b713", + "type": "epm-packages-assets", + }, + Object { + "id": "889d88db-6214-5836-aeff-1a87f8513b27", + "type": "epm-packages-assets", + }, + Object { + "id": "06a6b940-a745-563c-abf4-83eb3335926b", + "type": "epm-packages-assets", + }, + Object { + "id": "e68fd7ac-302e-5b75-bbbb-d69b441c8848", + "type": "epm-packages-assets", + }, + Object { + "id": "2c57fe0f-3b1a-57da-a63b-28f9b9e82bce", + "type": "epm-packages-assets", + }, + Object { + "id": "13db43e8-f8f9-57f0-b131-a171c2f2070f", + "type": "epm-packages-assets", + }, + Object { + "id": "e8750081-1c0b-5c55-bcab-fa6d47f01a85", + "type": "epm-packages-assets", + }, + Object { + "id": "71af57fe-25c4-5935-9879-ca4a2fba730e", + "type": "epm-packages-assets", + }, + Object { + "id": "cc287718-9573-5c56-a9ed-6dfef6589506", + "type": "epm-packages-assets", + }, + Object { + "id": "8badd8ba-289a-5e60-a1c0-f3d39e15cda3", + "type": "epm-packages-assets", + }, + Object { + "id": "20300efc-10eb-5fac-ba90-f6aa9b467e84", + "type": "epm-packages-assets", + }, + Object { + "id": "047c89df-33c2-5d74-b0a4-8b441879761c", + "type": "epm-packages-assets", + }, + Object { + "id": "9838a13f-1b89-5c54-844e-978620d66a1d", + "type": "epm-packages-assets", + }, + Object { + "id": "e105414b-221d-5433-8b24-452625f59b7c", + "type": "epm-packages-assets", + }, + Object { + "id": "eb166c25-843b-5271-8d43-6fb005d2df5a", + "type": "epm-packages-assets", + }, + Object { + "id": "342dbf4d-d88d-53e8-b365-d3639ebbbb14", + "type": "epm-packages-assets", + }, + Object { + "id": "f98c44a3-eaea-505f-8598-3b7f1097ef59", + "type": "epm-packages-assets", + }, + Object { + "id": "12da8c6c-d0e3-589c-9244-88d857ea76b6", + "type": "epm-packages-assets", + }, + Object { + "id": "e2d151ed-709c-542d-b797-cb95f353b9b3", + "type": "epm-packages-assets", + }, + Object { + "id": "f434cffe-0b00-59de-a17f-c1e71bd4ab0f", + "type": "epm-packages-assets", + }, + Object { + "id": "5bd0c25f-04a5-5fd0-8298-ba9aa2f6fe5e", + "type": "epm-packages-assets", + }, + Object { + "id": "279da3a3-8e9b-589b-86e0-bd7364821bab", + "type": "epm-packages-assets", + }, + Object { + "id": "b8758fcb-08bf-50fa-89bd-24398955298a", + "type": "epm-packages-assets", + }, + Object { + "id": "96e4eb36-03c3-5856-af44-559fd5133f2b", + "type": "epm-packages-assets", + }, + Object { + "id": "a59a79c3-66bd-5cfc-91f5-ee84f7227855", + "type": "epm-packages-assets", + }, + Object { + "id": "395143f9-54bf-5b46-b1be-a7b2a6142ad9", + "type": "epm-packages-assets", + }, + Object { + "id": "3449b8d2-ffd5-5aec-bb32-4245f2fbcde4", + "type": "epm-packages-assets", + }, + Object { + "id": "ab44094e-6c9d-50b8-b5c4-2e518d89912e", + "type": "epm-packages-assets", + }, + Object { + "id": "b093bfc0-6e98-5a1b-a502-e838a36f6568", + "type": "epm-packages-assets", + }, + Object { + "id": "03d86823-b756-5b91-850d-7ad231d33546", + "type": "epm-packages-assets", + }, + Object { + "id": "a76af2f0-049b-5be1-8d20-e87c9d1c2709", + "type": "epm-packages-assets", + }, + Object { + "id": "bc2f0c1e-992e-5407-9435-fedb39ff74ea", + "type": "epm-packages-assets", + }, + Object { + "id": "84668ac1-d5ef-545b-88f3-1e49f8f1c8ad", + "type": "epm-packages-assets", + }, + Object { + "id": "69b41271-91a0-5a2e-a62c-60364d5a9c8f", + "type": "epm-packages-assets", + }, + Object { + "id": "8e4ec555-5fbf-55d3-bea3-3af12c9aca3f", + "type": "epm-packages-assets", + }, + Object { + "id": "aa18f3f9-f62a-5ab8-9b34-75696efa5c48", + "type": "epm-packages-assets", + }, + Object { + "id": "71c8c6b1-2116-5817-b65f-7a87ef5ef2b7", + "type": "epm-packages-assets", + }, + Object { + "id": "8f6d7a1f-1e7f-5a60-8fe7-ce19115ed460", + "type": "epm-packages-assets", + }, + Object { + "id": "c115dbbf-edad-59f2-b046-c65a0373a81c", + "type": "epm-packages-assets", + }, + Object { + "id": "b7d696c3-8106-585c-9ecc-94a75cf1e3da", + "type": "epm-packages-assets", + }, + Object { + "id": "639e6a78-59d8-5ce8-9687-64e8f9af7e71", + "type": "epm-packages-assets", + }, + Object { + "id": "ae60c853-7a90-58d2-ab6c-04d3be5f1847", + "type": "epm-packages-assets", + }, + Object { + "id": "0cd33163-2ae4-57eb-96f6-c50af6685cab", + "type": "epm-packages-assets", + }, + Object { + "id": "39e0f78f-1172-5e61-9446-65ef3c0cb46c", + "type": "epm-packages-assets", + }, + Object { + "id": "b08f10ee-6afd-5e89-b9b4-569064fbdd9f", + "type": "epm-packages-assets", + }, + Object { + "id": "efcbe1c6-b2d5-521c-b27a-2146f08a604d", + "type": "epm-packages-assets", + }, + Object { + "id": "f9422c02-d43f-5ebb-b7c5-9e32f9b77c21", + "type": "epm-packages-assets", + }, + Object { + "id": "c276e880-3ba8-58e7-a5d5-c07707dba6b7", + "type": "epm-packages-assets", + }, + Object { + "id": "561a3711-c386-541c-9a77-2d0fa256caf6", + "type": "epm-packages-assets", + }, + Object { + "id": "1378350d-2e2b-52dd-ab3a-d8b9a09df92f", + "type": "epm-packages-assets", + }, + Object { + "id": "94e40729-4aea-59c8-86ba-075137c000dc", + "type": "epm-packages-assets", + }, + ], + "removable": true, + "version": "0.1.4", + }, + "id": "apache", + "namespaces": Array [], + "references": Array [], + "type": "epm-packages", + }, + "screenshots": Array [ + Object { + "path": "/package/apache/0.1.4/img/kibana-apache.png", + "size": "1215x1199", + "src": "/img/kibana-apache.png", + "title": "Apache Integration", + "type": "image/png", + }, + Object { + "path": "/package/apache/0.1.4/img/apache_httpd_server_status.png", + "size": "1919x1079", + "src": "/img/apache_httpd_server_status.png", + "title": "Apache HTTPD Server Status", + "type": "image/png", + }, + ], + "status": "installed", + "title": "Apache", + "type": "integration", + "version": "0.1.4", +} +`; diff --git a/x-pack/test/fleet_api_integration/apis/epm/final_pipeline.ts b/x-pack/test/fleet_api_integration/apis/epm/final_pipeline.ts index 3b01b1f861aef..1c8e605cedd72 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/final_pipeline.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/final_pipeline.ts @@ -109,8 +109,9 @@ export default function (providerContext: FtrProviderContext) { expect(pipelineRes).to.have.property(FINAL_PIPELINE_ID); const res = await es.indices.getIndexTemplate({ name: 'logs-log.log' }); expect(res.index_templates.length).to.be(FINAL_PIPELINE_VERSION); + expect(res.index_templates[0]?.index_template?.composed_of).to.contain('.fleet_globals-1'); expect(res.index_templates[0]?.index_template?.composed_of).to.contain( - '.fleet_component_template-1' + '.fleet_agent_id_verification-1' ); }); diff --git a/x-pack/test/fleet_api_integration/apis/epm/install_by_upload.ts b/x-pack/test/fleet_api_integration/apis/epm/install_by_upload.ts index 28b68609ce15e..68cac70e8fed8 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/install_by_upload.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/install_by_upload.ts @@ -75,7 +75,7 @@ export default function (providerContext: FtrProviderContext) { .type('application/gzip') .send(buf) .expect(200); - expect(res.body.items.length).to.be(29); + expect(res.body.items.length).to.be(32); }); it('should install a zip archive correctly and package info should return correctly after validation', async function () { @@ -86,7 +86,7 @@ export default function (providerContext: FtrProviderContext) { .type('application/zip') .send(buf) .expect(200); - expect(res.body.items.length).to.be(29); + expect(res.body.items.length).to.be(32); }); it('should throw an error if the archive is zip but content type is gzip', async function () { diff --git a/x-pack/test/fleet_api_integration/apis/epm/install_overrides.ts b/x-pack/test/fleet_api_integration/apis/epm/install_overrides.ts index 603e18931c227..eee9525a4f062 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/install_overrides.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/install_overrides.ts @@ -55,7 +55,8 @@ export default function (providerContext: FtrProviderContext) { `${templateName}@mappings`, `${templateName}@settings`, `${templateName}@custom`, - '.fleet_component_template-1', + '.fleet_globals-1', + '.fleet_agent_id_verification-1', ]); ({ body } = await es.transport.request( @@ -151,6 +152,24 @@ export default function (providerContext: FtrProviderContext) { }, mappings: { dynamic: 'false', + properties: { + '@timestamp': { + type: 'date', + }, + data_stream: { + properties: { + dataset: { + type: 'constant_keyword', + }, + namespace: { + type: 'constant_keyword', + }, + type: { + type: 'constant_keyword', + }, + }, + }, + }, }, aliases: {}, }, diff --git a/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts b/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts index af807d4393daf..a44b8be478874 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts @@ -548,6 +548,10 @@ const expectAssetsInstalled = ({ id: 'logs-all_assets.test_logs@custom', type: 'component_template', }, + { + id: 'metrics-all_assets.test_metrics@mappings', + type: 'component_template', + }, { id: 'metrics-all_assets.test_metrics@settings', type: 'component_template', diff --git a/x-pack/test/fleet_api_integration/apis/epm/template.ts b/x-pack/test/fleet_api_integration/apis/epm/template.ts index d8856ec392218..9cef0d3f28415 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/template.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/template.ts @@ -14,19 +14,6 @@ export default function ({ getService }: FtrProviderContext) { const templateName = 'bar'; const templateIndexPattern = 'bar-*'; const es = getService('es'); - const mappings = { - properties: { - foo: { - type: 'keyword', - }, - }, - }; - const fields = [ - { - name: 'foo', - type: 'keyword', - }, - ]; // This test was inspired by https://github.com/elastic/kibana/blob/main/x-pack/test/api_integration/apis/monitoring/common/mappings_exist.js describe('EPM - template', async () => { @@ -43,10 +30,7 @@ export default function ({ getService }: FtrProviderContext) { it('can be loaded', async () => { const template = getTemplate({ - type: 'logs', templateIndexPattern, - fields, - mappings, packageName: 'system', composedOfTemplates: [], templatePriority: 200, diff --git a/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts b/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts index 1947396b8a2bd..7d28b04c28a53 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts @@ -81,32 +81,8 @@ export default function (providerContext: FtrProviderContext) { { meta: true } ); expect(resLogsTemplate.statusCode).equal(200); - expect( - resLogsTemplate.body.index_templates[0].index_template.template.mappings.properties - ).eql({ - '@timestamp': { - type: 'date', - }, - logs_test_name: { - type: 'text', - }, - new_field_name: { - ignore_above: 1024, - type: 'keyword', - }, - data_stream: { - properties: { - dataset: { - type: 'constant_keyword', - }, - namespace: { - type: 'constant_keyword', - }, - type: { - type: 'constant_keyword', - }, - }, - }, + expect(resLogsTemplate.body.index_templates[0].index_template.template.mappings).eql({ + _meta: { package: { name: 'all_assets' }, managed_by: 'fleet', managed: true }, }); const resMetricsTemplate = await es.transport.request( { @@ -116,27 +92,12 @@ export default function (providerContext: FtrProviderContext) { { meta: true } ); expect(resMetricsTemplate.statusCode).equal(200); - expect( - resMetricsTemplate.body.index_templates[0].index_template.template.mappings.properties - ).eql({ - '@timestamp': { - type: 'date', - }, - metrics_test_name2: { - ignore_above: 1024, - type: 'keyword', - }, - data_stream: { - properties: { - dataset: { - type: 'constant_keyword', - }, - namespace: { - type: 'constant_keyword', - }, - type: { - type: 'constant_keyword', - }, + expect(resMetricsTemplate.body.index_templates[0].index_template.template.mappings).eql({ + _meta: { + managed: true, + managed_by: 'fleet', + package: { + name: 'all_assets', }, }, }); @@ -150,8 +111,27 @@ export default function (providerContext: FtrProviderContext) { { meta: true } ); expect(resLogsTemplate.statusCode).equal(200); + expect(resLogsTemplate.body.index_templates[0].index_template.template.mappings).eql({ + _meta: { + managed: true, + managed_by: 'fleet', + package: { + name: 'all_assets', + }, + }, + }); + }); + it('should have populated the new component template with the correct mapping', async () => { + const resMappings = await es.transport.request( + { + method: 'GET', + path: `/_component_template/${logsTemplateName2}@mappings`, + }, + { meta: true } + ); + expect(resMappings.statusCode).equal(200); expect( - resLogsTemplate.body.index_templates[0].index_template.template.mappings.properties + resMappings.body.component_templates[0].component_template.template.mappings.properties ).eql({ '@timestamp': { type: 'date', @@ -223,7 +203,7 @@ export default function (providerContext: FtrProviderContext) { ); expect(resPipeline2.statusCode).equal(404); }); - it('should have updated the component templates', async function () { + it('should have updated the logs component templates', async function () { const resMappings = await es.transport.request( { method: 'GET', @@ -232,8 +212,42 @@ export default function (providerContext: FtrProviderContext) { { meta: true } ); expect(resMappings.statusCode).equal(200); + expect(resMappings.body.component_templates[0].component_template.template.settings).eql({ + index: { + mapping: { + total_fields: { + limit: '10000', + }, + }, + }, + }); expect(resMappings.body.component_templates[0].component_template.template.mappings).eql({ dynamic: true, + properties: { + '@timestamp': { + type: 'date', + }, + data_stream: { + properties: { + dataset: { + type: 'constant_keyword', + }, + namespace: { + type: 'constant_keyword', + }, + type: { + type: 'constant_keyword', + }, + }, + }, + logs_test_name: { + type: 'text', + }, + new_field_name: { + ignore_above: 1024, + type: 'keyword', + }, + }, }); const resSettings = await es.transport.request( { @@ -247,11 +261,6 @@ export default function (providerContext: FtrProviderContext) { index: { lifecycle: { name: 'reference2' }, codec: 'best_compression', - mapping: { - total_fields: { - limit: '10000', - }, - }, query: { default_field: ['logs_test_name', 'new_field_name'], }, @@ -285,6 +294,40 @@ export default function (providerContext: FtrProviderContext) { ], }); }); + it('should have updated the metrics mapping component template', async function () { + const resMappings = await es.transport.request( + { + method: 'GET', + path: `/_component_template/${metricsTemplateName}@mappings`, + }, + { meta: true } + ); + expect(resMappings.statusCode).equal(200); + expect( + resMappings.body.component_templates[0].component_template.template.mappings.properties + ).eql({ + '@timestamp': { + type: 'date', + }, + metrics_test_name2: { + ignore_above: 1024, + type: 'keyword', + }, + data_stream: { + properties: { + dataset: { + type: 'constant_keyword', + }, + namespace: { + type: 'constant_keyword', + }, + type: { + type: 'constant_keyword', + }, + }, + }, + }); + }); it('should have updated the kibana assets', async function () { const resDashboard = await kibanaServer.savedObjects.get({ type: 'dashboard', @@ -396,6 +439,10 @@ export default function (providerContext: FtrProviderContext) { id: 'logs-all_assets.test_logs2', type: 'index_template', }, + { + id: 'logs-all_assets.test_logs2@mappings', + type: 'component_template', + }, { id: 'logs-all_assets.test_logs2@settings', type: 'component_template', @@ -408,6 +455,10 @@ export default function (providerContext: FtrProviderContext) { id: 'metrics-all_assets.test_metrics', type: 'index_template', }, + { + id: 'metrics-all_assets.test_metrics@mappings', + type: 'component_template', + }, { id: 'metrics-all_assets.test_metrics@settings', type: 'component_template', From 5903c417403b55418f74c3b9bfbcba07b12d402e Mon Sep 17 00:00:00 2001 From: Josh Dover <1813008+joshdover@users.noreply.github.com> Date: Mon, 14 Mar 2022 17:10:45 +0100 Subject: [PATCH 16/44] [Fleet] Enable debug logs for functional tests (#127597) --- x-pack/test/fleet_api_integration/config.ts | 4 ++++ x-pack/test/fleet_cypress/config.ts | 4 ++++ x-pack/test/fleet_functional/config.ts | 8 +++++++- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/x-pack/test/fleet_api_integration/config.ts b/x-pack/test/fleet_api_integration/config.ts index 60b57dc400cce..3c5f19f6896d4 100644 --- a/x-pack/test/fleet_api_integration/config.ts +++ b/x-pack/test/fleet_api_integration/config.ts @@ -66,6 +66,10 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { `--xpack.fleet.packages.0.version=latest`, ...(registryPort ? [`--xpack.fleet.registryUrl=http://localhost:${registryPort}`] : []), `--xpack.fleet.developer.bundledPackageLocation=${BUNDLED_PACKAGE_DIR}`, + // Enable debug fleet logs by default + `--logging.loggers[0].name=plugins.fleet`, + `--logging.loggers[0].level=debug`, + `--logging.loggers[0].appenders=${JSON.stringify(['default'])}`, ], }, }; diff --git a/x-pack/test/fleet_cypress/config.ts b/x-pack/test/fleet_cypress/config.ts index 14898f81aac12..d2076fa940412 100644 --- a/x-pack/test/fleet_cypress/config.ts +++ b/x-pack/test/fleet_cypress/config.ts @@ -38,6 +38,10 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { '--csp.strict=false', // define custom kibana server args here `--elasticsearch.ssl.certificateAuthorities=${CA_CERT_PATH}`, + // Enable debug fleet logs by default + `--logging.loggers[0].name=plugins.fleet`, + `--logging.loggers[0].level=debug`, + `--logging.loggers[0].appenders=${JSON.stringify(['default'])}`, ], }, }; diff --git a/x-pack/test/fleet_functional/config.ts b/x-pack/test/fleet_functional/config.ts index 27fc522f03a36..60db783280aec 100644 --- a/x-pack/test/fleet_functional/config.ts +++ b/x-pack/test/fleet_functional/config.ts @@ -29,7 +29,13 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { }, kbnTestServer: { ...xpackFunctionalConfig.get('kbnTestServer'), - serverArgs: [...xpackFunctionalConfig.get('kbnTestServer.serverArgs')], + serverArgs: [ + ...xpackFunctionalConfig.get('kbnTestServer.serverArgs'), + // Enable debug fleet logs by default + `--logging.loggers[0].name=plugins.fleet`, + `--logging.loggers[0].level=debug`, + `--logging.loggers[0].appenders=${JSON.stringify(['default'])}`, + ], }, layout: { fixedHeaderHeight: 200, From 93704a7a0b596f5d6b27034188079a20418f011e Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Mon, 14 Mar 2022 12:18:42 -0400 Subject: [PATCH 17/44] [Fleet] Add UI form to edit logstash output (#126592) --- .../plugins/fleet/common/services/routes.ts | 1 + .../integration/fleet_settings.spec.ts | 42 +++- .../edit_output_flyout/confirm_update.tsx | 77 ++++++ .../edit_output_flyout/index.test.tsx | 69 ++++++ .../components/edit_output_flyout/index.tsx | 180 ++++++++++++-- .../output_form_validators.test.tsx | 38 ++- .../output_form_validators.tsx | 73 +++++- .../edit_output_flyout/use_output_form.tsx | 211 ++++++++-------- .../fleet_server_hosts_flyout/index.tsx | 14 +- .../logstash_instructions/helpers.tsx | 32 +++ .../components/logstash_instructions/hooks.ts | 47 ++++ .../logstash_instructions/index.tsx | 226 ++++++++++++++++++ .../index.stories.tsx | 4 +- .../index.test.tsx | 14 +- .../index.tsx | 197 +++++++++++---- .../settings_page/output_section.tsx | 6 +- .../plugins/fleet/public/hooks/use_input.ts | 6 +- .../fleet/public/hooks/use_request/outputs.ts | 14 +- x-pack/plugins/fleet/public/types/index.ts | 1 + .../translations/translations/fr-FR.json | 2 - .../translations/translations/ja-JP.json | 4 - .../translations/translations/zh-CN.json | 4 - 22 files changed, 1062 insertions(+), 200 deletions(-) create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/confirm_update.tsx create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/index.test.tsx create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/logstash_instructions/helpers.tsx create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/logstash_instructions/hooks.ts create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/logstash_instructions/index.tsx rename x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/{hosts_input => multi_row_input}/index.stories.tsx (94%) rename x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/{hosts_input => multi_row_input}/index.test.tsx (94%) rename x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/{hosts_input => multi_row_input}/index.tsx (59%) diff --git a/x-pack/plugins/fleet/common/services/routes.ts b/x-pack/plugins/fleet/common/services/routes.ts index b3a53cd05da4e..25c0680645f92 100644 --- a/x-pack/plugins/fleet/common/services/routes.ts +++ b/x-pack/plugins/fleet/common/services/routes.ts @@ -176,6 +176,7 @@ export const outputRoutesService = { getDeletePath: (outputId: string) => OUTPUT_API_ROUTES.DELETE_PATTERN.replace('{outputId}', outputId), getCreatePath: () => OUTPUT_API_ROUTES.CREATE_PATTERN, + getCreateLogstashApiKeyPath: () => OUTPUT_API_ROUTES.LOGSTASH_API_KEY_PATTERN, }; export const settingsRoutesService = { diff --git a/x-pack/plugins/fleet/cypress/integration/fleet_settings.spec.ts b/x-pack/plugins/fleet/cypress/integration/fleet_settings.spec.ts index ab4bf6b4a66a9..76c8f129584bd 100644 --- a/x-pack/plugins/fleet/cypress/integration/fleet_settings.spec.ts +++ b/x-pack/plugins/fleet/cypress/integration/fleet_settings.spec.ts @@ -28,7 +28,7 @@ describe('Edit settings', () => { cy.getBySel('toastCloseButton').click(); }); - it('should update hosts', () => { + it('should update Fleet server hosts', () => { cy.getBySel('editHostsBtn').click(); cy.get('[placeholder="Specify host URL"').type('http://localhost:8220'); @@ -50,6 +50,7 @@ describe('Edit settings', () => { it('should update outputs', () => { cy.getBySel('editOutputBtn').click(); cy.get('[placeholder="Specify name"').clear().type('output-1'); + cy.get('[placeholder="Specify host URL"').clear().type('http://elasticsearch:9200'); cy.intercept('/api/fleet/outputs', { items: [ @@ -65,6 +66,7 @@ describe('Edit settings', () => { cy.intercept('PUT', '/api/fleet/outputs/fleet-default-output', { name: 'output-1', type: 'elasticsearch', + hosts: ['http://elasticsearch:9200'], is_default: true, is_default_monitoring: true, }).as('updateOutputs'); @@ -76,4 +78,42 @@ describe('Edit settings', () => { expect(interception.request.body.name).to.equal('output-1'); }); }); + + it('should allow to create a logstash output', () => { + cy.getBySel('addOutputBtn').click(); + cy.get('[placeholder="Specify name"]').clear().type('output-logstash-1'); + cy.get('[placeholder="Specify type"]').select('logstash'); + cy.get('[placeholder="Specify host"').clear().type('logstash:5044'); + cy.get('[placeholder="Specify ssl certificate"]').clear().type('SSL CERTIFICATE'); + cy.get('[placeholder="Specify certificate key"]').clear().type('SSL KEY'); + + cy.intercept('/api/fleet/outputs', { + items: [ + { + id: 'fleet-default-output', + name: 'output-1', + type: 'elasticsearch', + is_default: true, + is_default_monitoring: true, + }, + ], + }); + cy.intercept('POST', '/api/fleet/outputs', { + name: 'output-logstash-1', + type: 'logstash', + hosts: ['logstash:5044'], + is_default: false, + is_default_monitoring: false, + ssl: { + certificate: "SSL CERTIFICATE');", + key: 'SSL KEY', + }, + }).as('postLogstashOutput'); + + cy.getBySel('saveApplySettingsBtn').click(); + + cy.wait('@postLogstashOutput').then((interception) => { + expect(interception.request.body.name).to.equal('output-logstash-1'); + }); + }); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/confirm_update.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/confirm_update.tsx new file mode 100644 index 0000000000000..61addb3e6d3ea --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/confirm_update.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import type { Output } from '../../../../types'; +import type { useConfirmModal } from '../../hooks/use_confirm_modal'; +import { getAgentAndPolicyCountForOutput } from '../../services/agent_and_policies_count'; + +const ConfirmTitle = () => ( + +); + +interface ConfirmDescriptionProps { + output: Output; + agentCount: number; + agentPolicyCount: number; +} + +const ConfirmDescription: React.FunctionComponent = ({ + output, + agentCount, + agentPolicyCount, +}) => ( + {output.name}, + agents: ( + + + + ), + policies: ( + + + + ), + }} + /> +); + +export async function confirmUpdate( + output: Output, + confirm: ReturnType['confirm'] +) { + const { agentCount, agentPolicyCount } = await getAgentAndPolicyCountForOutput(output); + return confirm( + , + + ); +} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/index.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/index.test.tsx new file mode 100644 index 0000000000000..46bdc6d5b1785 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/index.test.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import type { Output } from '../../../../types'; +import { createFleetTestRendererMock } from '../../../../../../mock'; + +import { EditOutputFlyout } from '.'; + +// mock yaml code editor +jest.mock('../../../../../../../../../../src/plugins/kibana_react/public/code_editor', () => ({ + CodeEditor: () => <>CODE EDITOR, +})); +jest.mock('../../../../../../hooks/use_fleet_status', () => ({ + FleetStatusProvider: (props: any) => { + return props.children; + }, +})); + +function renderFlyout(output?: Output) { + const renderer = createFleetTestRendererMock(); + + const utils = renderer.render( {}} />); + + return { utils }; +} +describe('EditOutputFlyout', () => { + it('should render the flyout if there is not output provided', async () => { + renderFlyout(); + }); + + it('should render the flyout if the output provided is a ES output', async () => { + const { utils } = renderFlyout({ + type: 'elasticsearch', + name: 'elasticsearch output', + id: 'output123', + is_default: false, + is_default_monitoring: false, + }); + + expect( + utils.queryByLabelText('Elasticsearch CA trusted fingerprint (optional)') + ).not.toBeNull(); + // Does not show logstash SSL inputs + expect(utils.queryByLabelText('Client SSL certificate key')).toBeNull(); + expect(utils.queryByLabelText('Client SSL certificate')).toBeNull(); + expect(utils.queryByLabelText('Server SSL certificate authorities')).toBeNull(); + }); + + it('should render the flyout if the output provided is a logstash output', async () => { + const { utils } = renderFlyout({ + type: 'logstash', + name: 'logstash output', + id: 'output123', + is_default: false, + is_default_monitoring: false, + }); + + // Show logstash SSL inputs + expect(utils.queryByLabelText('Client SSL certificate key')).not.toBeNull(); + expect(utils.queryByLabelText('Client SSL certificate')).not.toBeNull(); + expect(utils.queryByLabelText('Server SSL certificate authorities')).not.toBeNull(); + }); +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/index.tsx index 6190d2b13c8fe..f366f32a36b84 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/index.tsx @@ -20,16 +20,20 @@ import { EuiForm, EuiFormRow, EuiFieldText, + EuiTextArea, EuiSelect, EuiSwitch, EuiCallOut, EuiSpacer, + EuiLink, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { HostsInput } from '../hosts_input'; +import { MultiRowInput } from '../multi_row_input'; import type { Output } from '../../../../types'; import { FLYOUT_MAX_WIDTH } from '../../constants'; +import { LogstashInstructions } from '../logstash_instructions'; +import { useBreadcrumbs, useStartServices } from '../../../../hooks'; import { YamlCodeEditorWithPlaceholder } from './yaml_code_editor_with_placeholder'; import { useOutputForm } from './use_output_form'; @@ -39,12 +43,22 @@ export interface EditOutputFlyoutProps { onClose: () => void; } +const OUTPUT_TYPE_OPTIONS = [ + { value: 'elasticsearch', text: 'Elasticsearch' }, + { value: 'logstash', text: 'Logstash' }, +]; + export const EditOutputFlyout: React.FunctionComponent = ({ onClose, output, }) => { + useBreadcrumbs('settings'); const form = useOutputForm(onClose, output); const inputs = form.inputs; + const { docLinks } = useStartServices(); + + const isLogstashOutput = inputs.typeInput.value === 'logstash'; + const isESOutput = inputs.typeInput.value === 'elasticsearch'; return ( @@ -120,7 +134,7 @@ export const EditOutputFlyout: React.FunctionComponent = = )} /> - - - } - {...inputs.caTrustedFingerprintInput.formRowProps} - > - + + + + + )} + {isESOutput && ( + + )} + {isLogstashOutput && ( + + + + ), + }} + /> + } + label={i18n.translate( + 'xpack.fleet.settings.editOutputFlyout.logstashHostsInputLabel', + { + defaultMessage: 'Logstash hosts', + } + )} + {...inputs.logstashHostsInput.props} + /> + )} + {isESOutput && ( + + } + {...inputs.caTrustedFingerprintInput.formRowProps} + > + + + )} + {isLogstashOutput && ( + - + )} + {isLogstashOutput && ( + + } + {...inputs.sslCertificateInput.formRowProps} + > + + + )} + {isLogstashOutput && ( + + } + {...inputs.sslKeyInput.formRowProps} + > + + + )} = 'xpack.fleet.settings.editOutputFlyout.yamlConfigInputPlaceholder', { defaultMessage: - '# YAML settings here will be added to the Elasticsearch output section of each agent policy.', + '# YAML settings here will be added to the output section of each agent policy.', } )} /> diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.test.tsx index 4f8b147e80448..7f414a8f12deb 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.test.tsx @@ -6,39 +6,40 @@ */ import { - validateHosts, + validateESHosts, + validateLogstashHosts, validateYamlConfig, validateCATrustedFingerPrint, } from './output_form_validators'; describe('Output form validation', () => { - describe('validateHosts', () => { + describe('validateESHosts', () => { it('should work without any urls', () => { - const res = validateHosts([]); + const res = validateESHosts([]); expect(res).toBeUndefined(); }); it('should work with valid url', () => { - const res = validateHosts(['https://test.fr:9200']); + const res = validateESHosts(['https://test.fr:9200']); expect(res).toBeUndefined(); }); it('should return an error with invalid url', () => { - const res = validateHosts(['toto']); + const res = validateESHosts(['toto']); expect(res).toEqual([{ index: 0, message: 'Invalid URL' }]); }); it('should return an error with url with invalid port', () => { - const res = validateHosts(['https://test.fr:qwerty9200']); + const res = validateESHosts(['https://test.fr:qwerty9200']); expect(res).toEqual([{ index: 0, message: 'Invalid URL' }]); }); it('should return an error with multiple invalid urls', () => { - const res = validateHosts(['toto', 'tata']); + const res = validateESHosts(['toto', 'tata']); expect(res).toEqual([ { index: 0, message: 'Invalid URL' }, @@ -46,7 +47,7 @@ describe('Output form validation', () => { ]); }); it('should return an error with duplicate urls', () => { - const res = validateHosts(['http://test.fr', 'http://test.fr']); + const res = validateESHosts(['http://test.fr', 'http://test.fr']); expect(res).toEqual([ { index: 0, message: 'Duplicate URL' }, @@ -54,6 +55,27 @@ describe('Output form validation', () => { ]); }); }); + + describe('validateLogstashHosts', () => { + it('should work for valid hosts', () => { + const res = validateLogstashHosts(['test.fr:5044']); + + expect(res).toBeUndefined(); + }); + it('should throw for invalid hosts starting with http', () => { + const res = validateLogstashHosts(['https://test.fr:5044']); + + expect(res).toEqual([ + { index: 0, message: 'Invalid logstash host should not start with http(s)' }, + ]); + }); + + it('should throw for invalid host', () => { + const res = validateLogstashHosts(['$#!$!@#!@#@!#!@#@#:!@#!@#']); + + expect(res).toEqual([{ index: 0, message: 'Invalid Host' }]); + }); + }); describe('validateYamlConfig', () => { it('should work with an empty yaml', () => { const res = validateYamlConfig(``); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.tsx index 3a9e42c152cc3..64b3353c8b2cb 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.tsx @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import { safeLoad } from 'js-yaml'; -export function validateHosts(value: string[]) { +export function validateESHosts(value: string[]) { const res: Array<{ message: string; index: number }> = []; const urlIndexes: { [key: string]: number[] } = {}; value.forEach((val, idx) => { @@ -48,6 +48,57 @@ export function validateHosts(value: string[]) { } } +export function validateLogstashHosts(value: string[]) { + const res: Array<{ message: string; index: number }> = []; + const urlIndexes: { [key: string]: number[] } = {}; + value.forEach((val, idx) => { + try { + if (val.match(/^http([s]){0,1}:\/\//)) { + res.push({ + message: i18n.translate('xpack.fleet.settings.outputForm.logstashHostProtocolError', { + defaultMessage: 'Invalid logstash host should not start with http(s)', + }), + index: idx, + }); + return; + } + + const url = new URL(`http://${val}`); + + if (url.host !== val) { + throw new Error('Invalid host'); + } + } catch (error) { + res.push({ + message: i18n.translate('xpack.fleet.settings.outputForm.logstashHostError', { + defaultMessage: 'Invalid Host', + }), + index: idx, + }); + } + + const curIndexes = urlIndexes[val] || []; + urlIndexes[val] = [...curIndexes, idx]; + }); + + Object.values(urlIndexes) + .filter(({ length }) => length > 1) + .forEach((indexes) => { + indexes.forEach((index) => + res.push({ + message: i18n.translate('xpack.fleet.settings.outputForm.elasticHostDuplicateError', { + defaultMessage: 'Duplicate URL', + }), + index, + }) + ); + }); + + if (res.length) { + return res; + } +} + export function validateYamlConfig(value: string) { try { safeLoad(value); @@ -81,3 +132,23 @@ export function validateCATrustedFingerPrint(value: string) { ]; } } + +export function validateSSLCertificate(value: string) { + if (!value || value === '') { + return [ + i18n.translate('xpack.fleet.settings.outputForm.sslCertificateRequiredErrorMessage', { + defaultMessage: 'SSL certificate is required', + }), + ]; + } +} + +export function validateSSLKey(value: string) { + if (!value || value === '') { + return [ + i18n.translate('xpack.fleet.settings.outputForm.sslKeyRequiredErrorMessage', { + defaultMessage: 'SSL key is required', + }), + ]; + } +} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/use_output_form.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/use_output_form.tsx index b99caf8eba649..cd927923a4fe1 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/use_output_form.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/use_output_form.tsx @@ -5,10 +5,9 @@ * 2.0. */ -import React, { useCallback, useState } from 'react'; +import { useCallback, useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n-react'; import { sendPostOutput, @@ -20,78 +19,17 @@ import { } from '../../../../hooks'; import type { Output, PostOutputRequest } from '../../../../types'; import { useConfirmModal } from '../../hooks/use_confirm_modal'; -import { getAgentAndPolicyCountForOutput } from '../../services/agent_and_policies_count'; import { validateName, - validateHosts, + validateESHosts, + validateLogstashHosts, validateYamlConfig, validateCATrustedFingerPrint, + validateSSLCertificate, + validateSSLKey, } from './output_form_validators'; - -const ConfirmTitle = () => ( - -); - -interface ConfirmDescriptionProps { - output: Output; - agentCount: number; - agentPolicyCount: number; -} - -const ConfirmDescription: React.FunctionComponent = ({ - output, - agentCount, - agentPolicyCount, -}) => ( - {output.name}, - agents: ( - - - - ), - policies: ( - - - - ), - }} - /> -); - -async function confirmUpdate( - output: Output, - confirm: ReturnType['confirm'] -) { - const { agentCount, agentPolicyCount } = await getAgentAndPolicyCountForOutput(output); - return confirm( - , - - ); -} +import { confirmUpdate } from './confirm_update'; export function useOutputForm(onSucess: () => void, output?: Output) { const [isLoading, setIsloading] = useState(false); @@ -102,26 +40,15 @@ export function useOutputForm(onSucess: () => void, output?: Output) { const isPreconfigured = output?.is_preconfigured ?? false; // Define inputs + // Shared inputs const nameInput = useInput(output?.name ?? '', validateName, isPreconfigured); - const typeInput = useInput(output?.type ?? '', undefined, isPreconfigured); - const elasticsearchUrlInput = useComboInput( - 'esHostsComboxBox', - output?.hosts ?? [], - validateHosts, - isPreconfigured - ); + const typeInput = useInput(output?.type ?? 'elasticsearch', undefined, isPreconfigured); const additionalYamlConfigInput = useInput( output?.config_yaml ?? '', validateYamlConfig, isPreconfigured ); - const caTrustedFingerprintInput = useInput( - output?.ca_trusted_fingerprint ?? '', - validateCATrustedFingerPrint, - isPreconfigured - ); - const defaultOutputInput = useSwitchInput( output?.is_default ?? false, isPreconfigured || output?.is_default @@ -131,14 +58,53 @@ export function useOutputForm(onSucess: () => void, output?: Output) { isPreconfigured || output?.is_default_monitoring ); + // ES inputs + const caTrustedFingerprintInput = useInput( + output?.ca_trusted_fingerprint ?? '', + validateCATrustedFingerPrint, + isPreconfigured + ); + const elasticsearchUrlInput = useComboInput( + 'esHostsComboxBox', + output?.hosts ?? [], + validateESHosts, + isPreconfigured + ); + // Logstash inputs + const logstashHostsInput = useComboInput( + 'logstashHostsComboxBox', + output?.hosts ?? [], + validateLogstashHosts, + isPreconfigured + ); + const sslCertificateAuthoritiesInput = useComboInput( + 'sslCertificateAuthoritiesComboxBox', + output?.ssl?.certificate_authorities ?? [], + undefined, + isPreconfigured + ); + const sslCertificateInput = useInput( + output?.ssl?.certificate ?? '', + validateSSLCertificate, + isPreconfigured + ); + + const sslKeyInput = useInput(output?.ssl?.key ?? '', validateSSLKey, isPreconfigured); + + const isLogstash = typeInput.value === 'logstash'; + const inputs = { nameInput, typeInput, elasticsearchUrlInput, + logstashHostsInput, additionalYamlConfigInput, defaultOutputInput, defaultMonitoringOutputInput, caTrustedFingerprintInput, + sslCertificateInput, + sslKeyInput, + sslCertificateAuthoritiesInput, }; const hasChanged = Object.values(inputs).some((input) => input.hasChanged); @@ -146,20 +112,40 @@ export function useOutputForm(onSucess: () => void, output?: Output) { const validate = useCallback(() => { const nameInputValid = nameInput.validate(); const elasticsearchUrlsValid = elasticsearchUrlInput.validate(); + const logstashHostsValid = logstashHostsInput.validate(); const additionalYamlConfigValid = additionalYamlConfigInput.validate(); const caTrustedFingerprintValid = caTrustedFingerprintInput.validate(); - - if ( - !elasticsearchUrlsValid || - !additionalYamlConfigValid || - !nameInputValid || - !caTrustedFingerprintValid - ) { - return false; + const sslCertificateValid = sslCertificateInput.validate(); + const sslKeyValid = sslKeyInput.validate(); + + if (isLogstash) { + // validate logstash + return ( + logstashHostsValid && + additionalYamlConfigValid && + nameInputValid && + sslCertificateValid && + sslKeyValid + ); + } else { + // validate ES + return ( + elasticsearchUrlsValid && + additionalYamlConfigValid && + nameInputValid && + caTrustedFingerprintValid + ); } - - return true; - }, [nameInput, elasticsearchUrlInput, additionalYamlConfigInput, caTrustedFingerprintInput]); + }, [ + isLogstash, + nameInput, + sslCertificateInput, + sslKeyInput, + elasticsearchUrlInput, + logstashHostsInput, + additionalYamlConfigInput, + caTrustedFingerprintInput, + ]); const submit = useCallback(async () => { try { @@ -168,15 +154,31 @@ export function useOutputForm(onSucess: () => void, output?: Output) { } setIsloading(true); - const data: PostOutputRequest['body'] = { - name: nameInput.value, - type: 'elasticsearch', - hosts: elasticsearchUrlInput.value, - is_default: defaultOutputInput.value, - is_default_monitoring: defaultMonitoringOutputInput.value, - config_yaml: additionalYamlConfigInput.value, - ca_trusted_fingerprint: caTrustedFingerprintInput.value, - }; + const data: PostOutputRequest['body'] = isLogstash + ? { + name: nameInput.value, + type: typeInput.value as 'elasticsearch' | 'logstash', + hosts: logstashHostsInput.value, + is_default: defaultOutputInput.value, + is_default_monitoring: defaultMonitoringOutputInput.value, + config_yaml: additionalYamlConfigInput.value, + ssl: { + certificate: sslCertificateInput.value, + key: sslKeyInput.value, + certificate_authorities: sslCertificateAuthoritiesInput.value.filter( + (val) => val !== '' + ), + }, + } + : { + name: nameInput.value, + type: typeInput.value as 'elasticsearch' | 'logstash', + hosts: elasticsearchUrlInput.value, + is_default: defaultOutputInput.value, + is_default_monitoring: defaultMonitoringOutputInput.value, + config_yaml: additionalYamlConfigInput.value, + ca_trusted_fingerprint: caTrustedFingerprintInput.value, + }; if (output) { // Update @@ -208,14 +210,21 @@ export function useOutputForm(onSucess: () => void, output?: Output) { }); } }, [ + isLogstash, validate, confirm, additionalYamlConfigInput.value, defaultMonitoringOutputInput.value, defaultOutputInput.value, elasticsearchUrlInput.value, + logstashHostsInput.value, caTrustedFingerprintInput.value, + sslCertificateInput.value, + sslCertificateAuthoritiesInput.value, + sslKeyInput.value, nameInput.value, + typeInput.value, + notifications.toasts, onSucess, output, diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/fleet_server_hosts_flyout/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/fleet_server_hosts_flyout/index.tsx index 175f95b029ba0..57c9ded6609b5 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/fleet_server_hosts_flyout/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/fleet_server_hosts_flyout/index.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; import { EuiFlyout, EuiFlyoutBody, @@ -22,7 +23,7 @@ import { EuiSpacer, } from '@elastic/eui'; -import { HostsInput } from '../hosts_input'; +import { MultiRowInput } from '../multi_row_input'; import { useStartServices } from '../../../../hooks'; import { FLYOUT_MAX_WIDTH } from '../../constants'; @@ -75,7 +76,16 @@ export const FleetServerHostsFlyout: React.FunctionComponent - + diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/logstash_instructions/helpers.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/logstash_instructions/helpers.tsx new file mode 100644 index 0000000000000..aecfe39c7e328 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/logstash_instructions/helpers.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const LOGSTASH_CONFIG_PIPELINES = `- pipeline.id: elastic-agent-pipeline + path.config: "/etc/path/to/elastic-agent-pipeline.config" +`; + +export function getLogstashPipeline(apiKey?: string) { + return `input { + elastic_agent { + port => 5044 + ssl => true + ssl_certificate_authorities => [""] + ssl_certificate => "" + ssl_key => "" + ssl_verification_mode => "force-peer" + } +} + +output { + elasticsearch { + hosts => "" + api_key => "" + data_stream => true + # ca_cert: + } +}`.replace('', apiKey || ''); +} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/logstash_instructions/hooks.ts b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/logstash_instructions/hooks.ts new file mode 100644 index 0000000000000..228140ac290b6 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/logstash_instructions/hooks.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useState, useMemo, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; + +import { sendPostLogstashApiKeys, useStartServices } from '../../../../hooks'; + +export function useLogstashApiKey() { + const [isLoading, setIsLoading] = useState(false); + const [apiKey, setApiKey] = useState(); + const { notifications } = useStartServices(); + + const generateApiKey = useCallback(async () => { + try { + setIsLoading(true); + + const res = await sendPostLogstashApiKeys(); + if (res.error) { + throw res.error; + } + + setApiKey(res.data?.api_key); + } catch (err) { + notifications.toasts.addError(err, { + title: i18n.translate('xpack.fleet.settings.logstashInstructions.generateApiKeyError', { + defaultMessage: 'Impossible to generate an api key', + }), + }); + } finally { + setIsLoading(false); + } + }, [notifications.toasts]); + + return useMemo( + () => ({ + isLoading, + generateApiKey, + apiKey, + }), + [isLoading, generateApiKey, apiKey] + ); +} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/logstash_instructions/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/logstash_instructions/index.tsx new file mode 100644 index 0000000000000..2e7924711e55a --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/logstash_instructions/index.tsx @@ -0,0 +1,226 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, useMemo } from 'react'; + +import { + EuiCallOut, + EuiButton, + EuiSpacer, + EuiLink, + EuiCodeBlock, + EuiCopy, + EuiButtonIcon, +} from '@elastic/eui'; +import type { EuiCallOutProps } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; + +import { useStartServices } from '../../../../hooks'; + +import { getLogstashPipeline, LOGSTASH_CONFIG_PIPELINES } from './helpers'; +import { useLogstashApiKey } from './hooks'; + +export const LogstashInstructions = () => { + const { docLinks } = useStartServices(); + + return ( + + } + > + <> + + + + ), + }} + /> + + + + + ); +}; + +const CollapsibleCallout: React.FunctionComponent = ({ children, ...props }) => { + const [isOpen, setIsOpen] = useState(false); + + return ( + + + {isOpen ? ( + setIsOpen(false)}> + + + ) : ( + setIsOpen(true)} fill={true}> + + + )} + {isOpen && ( + <> + + {children} + + )} + + ); +}; + +const LogstashInstructionSteps = () => { + const { docLinks } = useStartServices(); + const logstashApiKey = useLogstashApiKey(); + + const steps = useMemo( + () => [ + { + children: ( + <> + + + {logstashApiKey.apiKey ? ( + +
API Key
+ {logstashApiKey.apiKey} + + {(copy) => ( +
+
+ +
+
+ )} +
+
+ ) : ( + + + + )} + + + ), + }, + { + children: ( + <> + + + + {LOGSTASH_CONFIG_PIPELINES} + + + ), + }, + { + children: ( + <> + + + + {getLogstashPipeline(logstashApiKey.apiKey)} + + + ), + }, + { + children: ( + <> + + + + ), + }} + /> + + + + + + + ), + }, + { + children: ( + <> + + + + ), + }, + ], + [logstashApiKey, docLinks] + ); + + return ( +
    + {steps.map((step, idx) => ( +
  1. {step.children}
  2. + ))} +
+ ); +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/hosts_input/index.stories.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/multi_row_input/index.stories.tsx similarity index 94% rename from x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/hosts_input/index.stories.tsx rename to x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/multi_row_input/index.stories.tsx index 9b4674f3ce778..4401cc3e1e492 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/hosts_input/index.stories.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/multi_row_input/index.stories.tsx @@ -9,7 +9,7 @@ import { useState } from '@storybook/addons'; import { addParameters } from '@storybook/react'; import React from 'react'; -import { HostsInput as Component } from '.'; +import { MultiRowInput as Component } from '.'; addParameters({ options: { @@ -19,7 +19,7 @@ addParameters({ export default { component: Component, - title: 'Sections/Fleet/Settings/HostInput', + title: 'Sections/Fleet/Settings/MultiRowInput', }; interface Args { diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/hosts_input/index.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/multi_row_input/index.test.tsx similarity index 94% rename from x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/hosts_input/index.test.tsx rename to x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/multi_row_input/index.test.tsx index 4d556cd2749c6..f3fcdfabc7722 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/hosts_input/index.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/multi_row_input/index.test.tsx @@ -10,7 +10,7 @@ import { fireEvent, act } from '@testing-library/react'; import { createFleetTestRendererMock } from '../../../../../../mock'; -import { HostsInput } from '.'; +import { MultiRowInput } from '.'; function renderInput( value = ['http://host1.com'], @@ -20,7 +20,7 @@ function renderInput( const renderer = createFleetTestRendererMock(); const utils = renderer.render( - { const { utils, mockOnChange } = renderInput(['http://host1.com', 'http://host2.com']); await act(async () => { - const deleteRowEl = await utils.container.querySelector('[aria-label="Delete host"]'); + const deleteRowEl = await utils.container.querySelector('[aria-label="Delete row"]'); if (!deleteRowEl) { - throw new Error('Delete host button not found'); + throw new Error('Delete row button not found'); } fireEvent.click(deleteRowEl); }); @@ -115,7 +115,7 @@ test('Should remove error when item deleted', async () => { mockOnChange.mockImplementation((newValue) => { utils.rerender( - { }); await act(async () => { - const deleteRowButtons = await utils.container.querySelectorAll('[aria-label="Delete host"]'); + const deleteRowButtons = await utils.container.querySelectorAll('[aria-label="Delete row"]'); if (deleteRowButtons.length !== 3) { - throw new Error('Delete host buttons not found'); + throw new Error('Delete row buttons not found'); } fireEvent.click(deleteRowButtons[1]); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/hosts_input/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/multi_row_input/index.tsx similarity index 59% rename from x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/hosts_input/index.tsx rename to x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/multi_row_input/index.tsx index 712bc72569776..1497d2a422ce4 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/hosts_input/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/multi_row_input/index.tsx @@ -23,13 +23,14 @@ import { EuiFormHelpText, euiDragDropReorder, EuiFormErrorText, + EuiTextArea, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import type { EuiTheme } from '../../../../../../../../../../src/plugins/kibana_react/common'; -export interface HostInputProps { +export interface MultiRowInputProps { id: string; value: string[]; onChange: (newValue: string[]) => void; @@ -38,17 +39,35 @@ export interface HostInputProps { errors?: Array<{ message: string; index?: number }>; isInvalid?: boolean; disabled?: boolean; + placeholder?: string; + multiline?: boolean; + sortable?: boolean; } interface SortableTextFieldProps { id: string; index: number; value: string; - onChange: (e: ChangeEvent) => void; + onChange: (e: ChangeEvent) => void; onDelete: (index: number) => void; errors?: string[]; autoFocus?: boolean; disabled?: boolean; + placeholder?: string; + multiline?: boolean; +} + +interface NonSortableTextFieldProps { + index: number; + value: string; + onChange: (e: ChangeEvent) => void; + onDelete: (index: number) => void; + errors?: string[]; + autoFocus?: boolean; + disabled?: boolean; + placeholder?: string; + multiline?: boolean; + deletable?: boolean; } const DraggableDiv = sytled.div` @@ -64,7 +83,18 @@ function displayErrors(errors?: string[]) { } const SortableTextField: FunctionComponent = React.memo( - ({ id, index, value, onChange, onDelete, autoFocus, errors, disabled }) => { + ({ + id, + index, + multiline, + value, + onChange, + onDelete, + placeholder, + autoFocus, + errors, + disabled, + }) => { const onDeleteHandler = useCallback(() => { onDelete(index); }, [onDelete, index]); @@ -106,6 +136,83 @@ const SortableTextField: FunctionComponent = React.memo(
+ {multiline ? ( + + ) : ( + + )} + {displayErrors(errors)} + + + + + + )} + + ); + } +); + +const NonSortableTextField: FunctionComponent = React.memo( + ({ + index, + deletable, + multiline, + value, + onChange, + onDelete, + placeholder, + autoFocus, + errors, + disabled, + }) => { + const onDeleteHandler = useCallback(() => { + onDelete(index); + }, [onDelete, index]); + + const isInvalid = (errors?.length ?? 0) > 0; + + return ( + <> + {index > 0 && } + + + + {multiline ? ( + + ) : ( = React.memo( autoFocus={autoFocus} isInvalid={isInvalid} disabled={disabled} - placeholder={i18n.translate('xpack.fleet.hostsInput.placeholder', { - defaultMessage: 'Specify host URL', - })} + placeholder={placeholder} /> - {displayErrors(errors)} - + )} + {displayErrors(errors)} + + {deletable && ( - - )} - + )} + + ); } ); -export const HostsInput: FunctionComponent = ({ +export const MultiRowInput: FunctionComponent = ({ id, value: valueFromProps, onChange, @@ -146,6 +253,9 @@ export const HostsInput: FunctionComponent = ({ isInvalid, errors, disabled, + placeholder, + multiline = false, + sortable = true, }) => { const [autoFocus, setAutoFocus] = useState(false); const value = useMemo(() => { @@ -156,7 +266,7 @@ export const HostsInput: FunctionComponent = ({ () => value.map((host, idx) => ({ value: host, - onChange: (e: ChangeEvent) => { + onChange: (e: ChangeEvent) => { const newValue = [...value]; newValue[idx] = e.target.value; @@ -215,17 +325,19 @@ export const HostsInput: FunctionComponent = ({ return errors && errors.filter((err) => err.index === undefined).map(({ message }) => message); }, [errors]); - const isSortable = rows.length > 1; + const isSortable = sortable && rows.length > 1; + return ( <> {helpText} {helpText && } - - - {rows.map((row, idx) => ( - - {isSortable ? ( + + {isSortable ? ( + + + {rows.map((row, idx) => ( + = ({ autoFocus={autoFocus} errors={indexedErrors[idx]} disabled={disabled} + placeholder={placeholder} /> - ) : ( - <> - - {displayErrors(indexedErrors[idx])} - - )} - - ))} - - + + ))} + + + ) : ( + rows.map((row, idx) => ( + 1} + /> + )) + )} {displayErrors(globalErrors)} = ({ iconType="plusInCircle" onClick={addRowHandler} > - + diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/settings_page/output_section.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/settings_page/output_section.tsx index 1da2bacf9068d..6f053316ac58b 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/settings_page/output_section.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/settings_page/output_section.tsx @@ -41,7 +41,11 @@ export const OutputSection: React.FunctionComponent = ({ - + ) => { + (e: React.ChangeEvent) => { const newValue = e.target.value; setValue(newValue); if (errors && validate && validate(newValue) === undefined) { @@ -155,6 +155,10 @@ export function useComboInput( isInvalid, disabled, }, + formRowProps: { + error: errors, + isInvalid, + }, value, clear: () => { setValue([]); diff --git a/x-pack/plugins/fleet/public/hooks/use_request/outputs.ts b/x-pack/plugins/fleet/public/hooks/use_request/outputs.ts index 8c56ee811e465..24b36df68a5fa 100644 --- a/x-pack/plugins/fleet/public/hooks/use_request/outputs.ts +++ b/x-pack/plugins/fleet/public/hooks/use_request/outputs.ts @@ -6,7 +6,12 @@ */ import { outputRoutesService } from '../../services'; -import type { PutOutputRequest, GetOutputsResponse, PostOutputRequest } from '../../types'; +import type { + PutOutputRequest, + GetOutputsResponse, + PostOutputRequest, + PostLogstashApiKeyResponse, +} from '../../types'; import { sendRequest, useRequest } from './use_request'; @@ -32,6 +37,13 @@ export function sendPutOutput(outputId: string, body: PutOutputRequest['body']) }); } +export function sendPostLogstashApiKeys() { + return sendRequest({ + method: 'post', + path: outputRoutesService.getCreateLogstashApiKeyPath(), + }); +} + export function sendPostOutput(body: PostOutputRequest['body']) { return sendRequest({ method: 'post', diff --git a/x-pack/plugins/fleet/public/types/index.ts b/x-pack/plugins/fleet/public/types/index.ts index ead44a798cfc7..0a5d39c4a1ce9 100644 --- a/x-pack/plugins/fleet/public/types/index.ts +++ b/x-pack/plugins/fleet/public/types/index.ts @@ -72,6 +72,7 @@ export type { GetOneEnrollmentAPIKeyResponse, PostEnrollmentAPIKeyRequest, PostEnrollmentAPIKeyResponse, + PostLogstashApiKeyResponse, GetOutputsResponse, PutOutputRequest, PutOutputResponse, diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 110a8da6bd171..54404ab4917fe 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -10289,7 +10289,6 @@ "xpack.fleet.homeIntegration.tutorialModule.noticeText.blogPostLink": "article de blog d'annonce", "xpack.fleet.homeIntegration.tutorialModule.noticeText.integrationLink": "disponible en tant qu'intégration Elastic Agent", "xpack.fleet.homeIntegration.tutorialModule.noticeText.notePrefix": "Remarque :", - "xpack.fleet.hostsInput.addRow": "Ajouter une ligne", "xpack.fleet.initializationErrorMessageTitle": "Initialisation de Fleet impossible", "xpack.fleet.integrations.customInputsLink": "entrées personnalisées", "xpack.fleet.integrations.discussForumLink": "forum de discussion", @@ -10406,7 +10405,6 @@ "xpack.fleet.serverError.enrollmentKeyDuplicate": "Une clé d'enregistrement nommée {providedKeyName} existe déjà pour la stratégie d'agent {agentPolicyId}", "xpack.fleet.serverError.returnedIncorrectKey": "La commande find enrollmentKeyById a renvoyé une clé erronée", "xpack.fleet.serverError.unableToCreateEnrollmentKey": "Impossible de créer une clé d'API d'enregistrement", - "xpack.fleet.settings.deleteHostButton": "Supprimer l'hôte", "xpack.fleet.settings.fleetServerHostsDifferentPathOrProtocolError": "Le protocole et le chemin doivent être identiques pour chaque URL", "xpack.fleet.settings.fleetServerHostsEmptyError": "Au moins une URL est obligatoire", "xpack.fleet.settings.fleetServerHostsError": "URL non valide", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index d0ec673fa45c5..c377c7d1d7b04 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -12225,8 +12225,6 @@ "xpack.fleet.homeIntegration.tutorialModule.noticeText.blogPostLink": "発表ブログ投稿", "xpack.fleet.homeIntegration.tutorialModule.noticeText.integrationLink": "Elasticエージェント統合として提供", "xpack.fleet.homeIntegration.tutorialModule.noticeText.notePrefix": "注", - "xpack.fleet.hostsInput.addRow": "行の追加", - "xpack.fleet.hostsInput.placeholder": "ホストURLを指定", "xpack.fleet.initializationErrorMessageTitle": "Fleet を初期化できません", "xpack.fleet.integration.settings.versionInfo.updatesAvailableBody": "バージョン{latestVersion}にアップグレードして最新の機能を入手", "xpack.fleet.integrations.confirmUpdateModal.body.agentCount": "{agentCount, plural, other {# 個のエージェント}}", @@ -12380,7 +12378,6 @@ "xpack.fleet.serverPlugin.privilegesTooltip": "Fleetアクセスには、すべてのSpacesが必要です。", "xpack.fleet.settings.confirmModal.cancelButtonText": "キャンセル", "xpack.fleet.settings.confirmModal.confirmButtonText": "保存してデプロイ", - "xpack.fleet.settings.deleteHostButton": "ホストの削除", "xpack.fleet.settings.deleteOutput.agentPolicyCount": "{agentPolicyCount, plural, other {# 件のエージェントポリシー}}", "xpack.fleet.settings.deleteOutput.agentsCount": "{agentCount, plural, other {# 個のエージェント}}", "xpack.fleet.settings.deleteOutput.confirmModalText": "{outputName}出力が削除されます。{policies}と{agents}が更新されます。このアクションは元に戻せません。続行していいですか?", @@ -12394,7 +12391,6 @@ "xpack.fleet.settings.editOutputFlyout.defaultMontoringOutputSwitchLabel": "この出力を{boldAgentMonitoring}のデフォルトにします。", "xpack.fleet.settings.editOutputFlyout.defaultOutputSwitchLabel": "この出力を{boldAgentIntegrations}のデフォルトにします。", "xpack.fleet.settings.editOutputFlyout.editTitle": "出力を編集", - "xpack.fleet.settings.editOutputFlyout.hostsInputLabel": "ホスト", "xpack.fleet.settings.editOutputFlyout.nameInputLabel": "名前", "xpack.fleet.settings.editOutputFlyout.nameInputPlaceholder": "名前を指定", "xpack.fleet.settings.editOutputFlyout.preconfiguredOutputCalloutDescription": "この出力に関連するほとんどのアクションは使用できません。詳細については、Kibana構成を参照してください。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 88c0a67ecf1b7..c40d6a11ee5ea 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -12248,8 +12248,6 @@ "xpack.fleet.homeIntegration.tutorialModule.noticeText.blogPostLink": "公告博客", "xpack.fleet.homeIntegration.tutorialModule.noticeText.integrationLink": "将作为 Elastic 代理集成来提供", "xpack.fleet.homeIntegration.tutorialModule.noticeText.notePrefix": "备注", - "xpack.fleet.hostsInput.addRow": "添加行", - "xpack.fleet.hostsInput.placeholder": "指定主机 URL", "xpack.fleet.initializationErrorMessageTitle": "无法初始化 Fleet", "xpack.fleet.integration.settings.versionInfo.updatesAvailableBody": "升级到版本 {latestVersion} 可获取最新功能", "xpack.fleet.integrations.confirmUpdateModal.body.agentCount": "{agentCount, plural, other {# 个代理}}", @@ -12403,7 +12401,6 @@ "xpack.fleet.serverPlugin.privilegesTooltip": "访问 Fleet 需要所有工作区。", "xpack.fleet.settings.confirmModal.cancelButtonText": "取消", "xpack.fleet.settings.confirmModal.confirmButtonText": "保存并部署", - "xpack.fleet.settings.deleteHostButton": "删除主机", "xpack.fleet.settings.deleteOutput.agentPolicyCount": "{agentPolicyCount, plural, other {# 个代理策略}}", "xpack.fleet.settings.deleteOutput.agentsCount": "{agentCount, plural, other {# 个代理}}", "xpack.fleet.settings.deleteOutput.confirmModalText": "此操作将删除 {outputName} 输出。它将更新 {policies} 和 {agents}。此操作无法撤消。是否确定要继续?", @@ -12417,7 +12414,6 @@ "xpack.fleet.settings.editOutputFlyout.defaultMontoringOutputSwitchLabel": "将此输出设为 {boldAgentMonitoring} 的默认值。", "xpack.fleet.settings.editOutputFlyout.defaultOutputSwitchLabel": "将此输出设为 {boldAgentIntegrations} 的默认值。", "xpack.fleet.settings.editOutputFlyout.editTitle": "编辑输出", - "xpack.fleet.settings.editOutputFlyout.hostsInputLabel": "主机", "xpack.fleet.settings.editOutputFlyout.nameInputLabel": "名称", "xpack.fleet.settings.editOutputFlyout.nameInputPlaceholder": "指定名称", "xpack.fleet.settings.editOutputFlyout.preconfiguredOutputCalloutDescription": "与此输出相关的操作多数不可用。请参阅 Kibana 配置了解详情。", From 2bf66ffe91e9101e358996475fd9b2c338fccfe3 Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Mon, 14 Mar 2022 12:23:17 -0400 Subject: [PATCH 18/44] [Dashboard][Controls] Hierarchical Chaining (#126649) * Hierarchical Chaining Implementation --- .../list_container_component.tsx | 8 +- .../control_group/control_group_migrations.ts | 32 +++ .../control_group_persistable_state.ts | 10 + .../embeddable/control_group_container.tsx | 261 ++++++++++++++---- .../options_list/options_list_embeddable.tsx | 86 +++--- .../control_group_container_factory.ts | 2 + .../embeddable/dashboard_control_group.ts | 63 +++++ src/plugins/dashboard/common/index.ts | 8 + .../embeddable/dashboard_container.test.tsx | 45 +-- .../dashboard_container_factory.tsx | 3 +- .../lib/dashboard_control_group.ts | 23 +- .../dashboard/public/dashboard_constants.ts | 7 - .../saved_dashboards/saved_dashboard.ts | 5 +- .../saved_objects/dashboard_migrations.ts | 19 +- .../public/lib/containers/container.ts | 58 +++- .../embeddable_child_panel.test.tsx | 5 +- .../public/lib/containers/i_container.ts | 11 + .../public/lib/embeddables/embeddable.tsx | 9 + .../public/lib/embeddables/i_embeddable.ts | 2 + .../embeddables/hello_world_container.tsx | 12 +- .../embeddable/public/tests/container.test.ts | 138 +++++++-- .../dashboard_controls_integration.ts | 121 +++++++- .../api_integration/apis/maps/migrations.js | 4 +- .../controls_migration_smoke_test.ts | 21 +- 24 files changed, 751 insertions(+), 202 deletions(-) create mode 100644 src/plugins/controls/common/control_group/control_group_migrations.ts create mode 100644 src/plugins/dashboard/common/embeddable/dashboard_control_group.ts diff --git a/examples/embeddable_examples/public/list_container/list_container_component.tsx b/examples/embeddable_examples/public/list_container/list_container_component.tsx index 031100c074095..d939cc2029f65 100644 --- a/examples/embeddable_examples/public/list_container/list_container_component.tsx +++ b/examples/embeddable_examples/public/list_container/list_container_component.tsx @@ -15,6 +15,7 @@ import { ContainerInput, ContainerOutput, EmbeddableStart, + EmbeddableChildPanel, } from '../../../../src/plugins/embeddable/public'; interface Props { @@ -31,7 +32,6 @@ function renderList( ) { let number = 0; const list = Object.values(panels).map((panel) => { - const child = embeddable.getChild(panel.explicitInput.id); number++; return ( @@ -42,7 +42,11 @@ function renderList( - + diff --git a/src/plugins/controls/common/control_group/control_group_migrations.ts b/src/plugins/controls/common/control_group/control_group_migrations.ts new file mode 100644 index 0000000000000..0060bda8b8cc0 --- /dev/null +++ b/src/plugins/controls/common/control_group/control_group_migrations.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ControlGroupInput, ControlsPanels } from '..'; + +export const makeControlOrdersZeroBased = (input: ControlGroupInput) => { + if ( + input.panels && + typeof input.panels === 'object' && + Object.keys(input.panels).length > 0 && + !Object.values(input.panels).find((panel) => (panel.order ?? 0) === 0) + ) { + // 0th element could not be found. Reorder all panels from 0; + const newPanels = Object.values(input.panels) + .sort((a, b) => (a.order > b.order ? 1 : -1)) + .map((panel, index) => { + panel.order = index; + return panel; + }) + .reduce((acc, currentPanel) => { + acc[currentPanel.explicitInput.id] = currentPanel; + return acc; + }, {} as ControlsPanels); + input.panels = newPanels; + } + return input; +}; diff --git a/src/plugins/controls/common/control_group/control_group_persistable_state.ts b/src/plugins/controls/common/control_group/control_group_persistable_state.ts index 0fd24bd234327..73f569bb7d247 100644 --- a/src/plugins/controls/common/control_group/control_group_persistable_state.ts +++ b/src/plugins/controls/common/control_group/control_group_persistable_state.ts @@ -13,6 +13,8 @@ import { } from '../../../embeddable/common/types'; import { ControlGroupInput, ControlPanelState } from './types'; import { SavedObjectReference } from '../../../../core/types'; +import { MigrateFunctionsObject } from '../../../kibana_utils/common'; +import { makeControlOrdersZeroBased } from './control_group_migrations'; type ControlGroupInputWithType = Partial & { type: string }; @@ -83,3 +85,11 @@ export const createControlGroupExtract = ( return { state: workingState as EmbeddableStateWithType, references }; }; }; + +export const migrations: MigrateFunctionsObject = { + '8.2.0': (state) => { + const controlInput = state as unknown as ControlGroupInput; + // for hierarchical chaining it is required that all control orders start at 0. + return makeControlOrdersZeroBased(controlInput); + }, +}; diff --git a/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx b/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx index 7393462caf029..734756b31aa2e 100644 --- a/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx +++ b/src/plugins/controls/public/control_group/embeddable/control_group_container.tsx @@ -11,14 +11,23 @@ import { uniqBy } from 'lodash'; import ReactDOM from 'react-dom'; import deepEqual from 'fast-deep-equal'; import { Filter, uniqFilters } from '@kbn/es-query'; -import { EMPTY, merge, pipe, Subscription } from 'rxjs'; -import { distinctUntilChanged, debounceTime, catchError, switchMap, map } from 'rxjs/operators'; +import { EMPTY, merge, pipe, Subject, Subscription } from 'rxjs'; import { EuiContextMenuPanel, EuiHorizontalRule } from '@elastic/eui'; +import { + distinctUntilChanged, + debounceTime, + catchError, + switchMap, + map, + skip, + mapTo, +} from 'rxjs/operators'; import { ControlGroupInput, ControlGroupOutput, ControlPanelState, + ControlsPanels, CONTROL_GROUP_TYPE, } from '../types'; import { @@ -29,44 +38,48 @@ import { } from '../../../../presentation_util/public'; import { pluginServices } from '../../services'; import { DataView } from '../../../../data_views/public'; +import { ControlGroupStrings } from '../control_group_strings'; +import { EditControlGroup } from '../editor/edit_control_group'; import { DEFAULT_CONTROL_WIDTH } from '../editor/editor_constants'; import { ControlGroup } from '../component/control_group_component'; import { controlGroupReducers } from '../state/control_group_reducers'; import { ControlEmbeddable, ControlInput, ControlOutput } from '../../types'; -import { Container, EmbeddableFactory } from '../../../../embeddable/public'; import { CreateControlButton, CreateControlButtonTypes } from '../editor/create_control'; -import { EditControlGroup } from '../editor/edit_control_group'; -import { ControlGroupStrings } from '../control_group_strings'; +import { Container, EmbeddableFactory, isErrorEmbeddable } from '../../../../embeddable/public'; const ControlGroupReduxWrapper = withSuspense< ReduxEmbeddableWrapperPropsWithChildren >(LazyReduxEmbeddableWrapper); +interface ChildEmbeddableOrderCache { + IdsToOrder: { [key: string]: number }; + idsInOrder: string[]; + lastChildId: string; +} + +const controlOrdersAreEqual = (panelsA: ControlsPanels, panelsB: ControlsPanels) => { + const ordersA = Object.values(panelsA).map((panel) => ({ + id: panel.explicitInput.id, + order: panel.order, + })); + const ordersB = Object.values(panelsB).map((panel) => ({ + id: panel.explicitInput.id, + order: panel.order, + })); + return deepEqual(ordersA, ordersB); +}; + export class ControlGroupContainer extends Container< ControlInput, ControlGroupInput, ControlGroupOutput > { public readonly type = CONTROL_GROUP_TYPE; + private subscriptions: Subscription = new Subscription(); private domNode?: HTMLElement; - - public untilReady = () => { - const panelsLoading = () => - Object.values(this.getOutput().embeddableLoaded).some((loaded) => !loaded); - if (panelsLoading()) { - return new Promise((resolve, reject) => { - const subscription = merge(this.getOutput$(), this.getInput$()).subscribe(() => { - if (this.destroyed) reject(); - if (!panelsLoading()) { - subscription.unsubscribe(); - resolve(); - } - }); - }); - } - return Promise.resolve(); - }; + private childOrderCache: ChildEmbeddableOrderCache; + private recalculateFilters$: Subject; /** * Returns a button that allows controls to be created externally using the embeddable @@ -141,51 +154,150 @@ export class ControlGroupContainer extends Container< initialInput, { embeddableLoaded: {} }, pluginServices.getServices().controls.getControlFactory, - parent + parent, + { + childIdInitializeOrder: Object.values(initialInput.panels) + .sort((a, b) => (a.order > b.order ? 1 : -1)) + .map((panel) => panel.explicitInput.id), + initializeSequentially: true, + } ); - // when all children are ready start recalculating filters when any child's output changes + this.recalculateFilters$ = new Subject(); + + // set up order cache so that it is aligned on input changes. + this.childOrderCache = this.getEmbeddableOrderCache(); + + // when all children are ready setup subscriptions this.untilReady().then(() => { - this.recalculateOutput(); - - const anyChildChangePipe = pipe( - map(() => this.getChildIds()), - distinctUntilChanged(deepEqual), - - // children may change, so make sure we subscribe/unsubscribe with switchMap - switchMap((newChildIds: string[]) => - merge( - ...newChildIds.map((childId) => - this.getChild(childId) - .getOutput$() + this.recalculateDataViews(); + this.recalculateFilters(); + this.setupSubscriptions(); + }); + } + + private setupSubscriptions = () => { + /** + * refresh control order cache and make all panels refreshInputFromParent whenever panel orders change + */ + this.subscriptions.add( + this.getInput$() + .pipe( + skip(1), + distinctUntilChanged((a, b) => controlOrdersAreEqual(a.panels, b.panels)) + ) + .subscribe(() => { + this.recalculateDataViews(); + this.recalculateFilters(); + this.childOrderCache = this.getEmbeddableOrderCache(); + this.childOrderCache.idsInOrder.forEach((id) => + this.getChild(id)?.refreshInputFromParent() + ); + }) + ); + + /** + * Create a pipe that outputs the child's ID, any time any child's output changes. + */ + const anyChildChangePipe = pipe( + map(() => this.getChildIds()), + distinctUntilChanged(deepEqual), + + // children may change, so make sure we subscribe/unsubscribe with switchMap + switchMap((newChildIds: string[]) => + merge( + ...newChildIds.map((childId) => + this.getChild(childId) + .getOutput$() + .pipe( // Embeddables often throw errors into their output streams. - .pipe(catchError(() => EMPTY)) - ) + catchError(() => EMPTY), + mapTo(childId) + ) ) ) - ); + ) + ); - this.subscriptions.add( - merge(this.getOutput$(), this.getOutput$().pipe(anyChildChangePipe)) - .pipe(debounceTime(10)) - .subscribe(this.recalculateOutput) - ); - }); - } + /** + * run OnChildOutputChanged when any child's output has changed + */ + this.subscriptions.add( + this.getOutput$() + .pipe(anyChildChangePipe) + .subscribe((childOutputChangedId) => { + this.recalculateDataViews(); + if (childOutputChangedId === this.childOrderCache.lastChildId) { + // the last control's output has updated, recalculate filters + this.recalculateFilters$.next(); + return; + } + + // when output changes on a child which isn't the last - make the next embeddable updateInputFromParent + const nextOrder = this.childOrderCache.IdsToOrder[childOutputChangedId] + 1; + if (nextOrder >= Object.keys(this.children).length) return; + setTimeout( + () => + this.getChild(this.childOrderCache.idsInOrder[nextOrder]).refreshInputFromParent(), + 1 // run on next tick + ); + }) + ); + + /** + * debounce output recalculation + */ + this.subscriptions.add( + this.recalculateFilters$.pipe(debounceTime(10)).subscribe(() => this.recalculateFilters()) + ); + }; + + private getPrecedingFilters = (id: string) => { + let filters: Filter[] = []; + const order = this.childOrderCache.IdsToOrder?.[id]; + if (!order || order === 0) return filters; + for (let i = 0; i < order; i++) { + const embeddable = this.getChild(this.childOrderCache.idsInOrder[i]); + if (!embeddable || isErrorEmbeddable(embeddable)) return filters; + filters = [...filters, ...(embeddable.getOutput().filters ?? [])]; + } + return filters; + }; + + private getEmbeddableOrderCache = (): ChildEmbeddableOrderCache => { + const panels = this.getInput().panels; + const IdsToOrder: { [key: string]: number } = {}; + const idsInOrder: string[] = []; + Object.values(panels) + .sort((a, b) => (a.order > b.order ? 1 : -1)) + .forEach((panel) => { + IdsToOrder[panel.explicitInput.id] = panel.order; + idsInOrder.push(panel.explicitInput.id); + }); + const lastChildId = idsInOrder[idsInOrder.length - 1]; + return { IdsToOrder, idsInOrder, lastChildId }; + }; public getPanelCount = () => { return Object.keys(this.getInput().panels).length; }; - private recalculateOutput = () => { + private recalculateFilters = () => { const allFilters: Filter[] = []; - const allDataViews: DataView[] = []; Object.values(this.children).map((child) => { const childOutput = child.getOutput() as ControlOutput; allFilters.push(...(childOutput?.filters ?? [])); + }); + this.updateOutput({ filters: uniqFilters(allFilters) }); + }; + + private recalculateDataViews = () => { + const allDataViews: DataView[] = []; + Object.values(this.children).map((child) => { + const childOutput = child.getOutput() as ControlOutput; allDataViews.push(...(childOutput.dataViews ?? [])); }); - this.updateOutput({ filters: uniqFilters(allFilters), dataViews: uniqBy(allDataViews, 'id') }); + this.updateOutput({ dataViews: uniqBy(allDataViews, 'id') }); }; protected createNewPanelState( @@ -193,12 +305,16 @@ export class ControlGroupContainer extends Container< partial: Partial = {} ): ControlPanelState { const panelState = super.createNewPanelState(factory, partial); - const highestOrder = Object.values(this.getInput().panels).reduce((highestSoFar, panel) => { - if (panel.order > highestSoFar) highestSoFar = panel.order; - return highestSoFar; - }, 0); + let nextOrder = 0; + if (Object.keys(this.getInput().panels).length > 0) { + nextOrder = + Object.values(this.getInput().panels).reduce((highestSoFar, panel) => { + if (panel.order > highestSoFar) highestSoFar = panel.order; + return highestSoFar; + }, 0) + 1; + } return { - order: highestOrder + 1, + order: nextOrder, width: this.getInput().defaultControlWidth ?? DEFAULT_CONTROL_WIDTH, ...panelState, } as ControlPanelState; @@ -206,19 +322,38 @@ export class ControlGroupContainer extends Container< protected getInheritedInput(id: string): ControlInput { const { filters, query, ignoreParentSettings, timeRange } = this.getInput(); + + const precedingFilters = this.getPrecedingFilters(id); + const allFilters = [ + ...(ignoreParentSettings?.ignoreFilters ? [] : filters ?? []), + ...precedingFilters, + ]; return { - filters: ignoreParentSettings?.ignoreFilters ? undefined : filters, + filters: allFilters, query: ignoreParentSettings?.ignoreQuery ? undefined : query, timeRange: ignoreParentSettings?.ignoreTimerange ? undefined : timeRange, id, }; } - public destroy() { - super.destroy(); - this.subscriptions.unsubscribe(); - if (this.domNode) ReactDOM.unmountComponentAtNode(this.domNode); - } + public untilReady = () => { + const panelsLoading = () => + Object.keys(this.getInput().panels).some( + (panelId) => !this.getOutput().embeddableLoaded[panelId] + ); + if (panelsLoading()) { + return new Promise((resolve, reject) => { + const subscription = merge(this.getOutput$(), this.getInput$()).subscribe(() => { + if (this.destroyed) reject(); + if (!panelsLoading()) { + subscription.unsubscribe(); + resolve(); + } + }); + }); + } + return Promise.resolve(); + }; public render(dom: HTMLElement) { if (this.domNode) { @@ -235,4 +370,10 @@ export class ControlGroupContainer extends Container< dom ); } + + public destroy() { + super.destroy(); + this.subscriptions.unsubscribe(); + if (this.domNode) ReactDOM.unmountComponentAtNode(this.domNode); + } } diff --git a/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx b/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx index 971fe98b52662..2575d5724535f 100644 --- a/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx +++ b/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx @@ -138,45 +138,36 @@ export class OptionsListEmbeddable extends Embeddable isEqual(a.validSelections, b.validSelections)), - skip(1) // skip the first input update because initial filters will be built by initialize. - ) - .subscribe(() => this.buildFilter()) - ); - /** - * when input selectedOptions changes, check all selectedOptions against the latest value of invalidSelections. + * when input selectedOptions changes, check all selectedOptions against the latest value of invalidSelections, and publish filter **/ this.subscriptions.add( this.getInput$() .pipe(distinctUntilChanged((a, b) => isEqual(a.selectedOptions, b.selectedOptions))) - .subscribe(({ selectedOptions: newSelectedOptions }) => { + .subscribe(async ({ selectedOptions: newSelectedOptions }) => { if (!newSelectedOptions || isEmpty(newSelectedOptions)) { this.updateComponentState({ validSelections: [], invalidSelections: [], }); - return; - } - const { invalidSelections } = this.componentStateSubject$.getValue(); - const newValidSelections: string[] = []; - const newInvalidSelections: string[] = []; - for (const selectedOption of newSelectedOptions) { - if (invalidSelections?.includes(selectedOption)) { - newInvalidSelections.push(selectedOption); - continue; + } else { + const { invalidSelections } = this.componentStateSubject$.getValue(); + const newValidSelections: string[] = []; + const newInvalidSelections: string[] = []; + for (const selectedOption of newSelectedOptions) { + if (invalidSelections?.includes(selectedOption)) { + newInvalidSelections.push(selectedOption); + continue; + } + newValidSelections.push(selectedOption); } - newValidSelections.push(selectedOption); + this.updateComponentState({ + validSelections: newValidSelections, + invalidSelections: newInvalidSelections, + }); } - this.updateComponentState({ - validSelections: newValidSelections, - invalidSelections: newInvalidSelections, - }); + const newFilters = await this.buildFilter(); + this.updateOutput({ filters: newFilters }); }) ); }; @@ -216,8 +207,9 @@ export class OptionsListEmbeddable extends Embeddable { - this.updateComponentState({ loading: true }); const { dataView, field } = await this.getCurrentDataViewAndField(); + this.updateComponentState({ loading: true }); + this.updateOutput({ loading: true, dataViews: [dataView] }); const { ignoreParentSettings, filters, query, selectedOptions, timeRange } = this.getInput(); if (this.abortController) this.abortController.abort(); @@ -244,30 +236,32 @@ export class OptionsListEmbeddable extends Embeddable { const { validSelections } = this.componentState; if (!validSelections || isEmpty(validSelections)) { - this.updateOutput({ filters: [] }); - return; + return []; } const { dataView, field } = await this.getCurrentDataViewAndField(); @@ -279,7 +273,7 @@ export class OptionsListEmbeddable extends Embeddable { diff --git a/src/plugins/controls/server/control_group/control_group_container_factory.ts b/src/plugins/controls/server/control_group/control_group_container_factory.ts index 39e1a9fbb12c9..179b7ebd55984 100644 --- a/src/plugins/controls/server/control_group/control_group_container_factory.ts +++ b/src/plugins/controls/server/control_group/control_group_container_factory.ts @@ -12,6 +12,7 @@ import { CONTROL_GROUP_TYPE } from '../../common'; import { createControlGroupExtract, createControlGroupInject, + migrations, } from '../../common/control_group/control_group_persistable_state'; export const controlGroupContainerPersistableStateServiceFactory = ( @@ -21,5 +22,6 @@ export const controlGroupContainerPersistableStateServiceFactory = ( id: CONTROL_GROUP_TYPE, extract: createControlGroupExtract(persistableStateService), inject: createControlGroupInject(persistableStateService), + migrations, }; }; diff --git a/src/plugins/dashboard/common/embeddable/dashboard_control_group.ts b/src/plugins/dashboard/common/embeddable/dashboard_control_group.ts new file mode 100644 index 0000000000000..95cb6c38ee9d7 --- /dev/null +++ b/src/plugins/dashboard/common/embeddable/dashboard_control_group.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { SerializableRecord } from '@kbn/utility-types'; +import { ControlGroupInput } from '../../../controls/common'; +import { ControlStyle } from '../../../controls/common/types'; +import { RawControlGroupAttributes } from '../types'; + +export const controlGroupInputToRawAttributes = ( + controlGroupInput: Omit +): Omit => { + return { + controlStyle: controlGroupInput.controlStyle, + panelsJSON: JSON.stringify(controlGroupInput.panels), + }; +}; + +export const getDefaultDashboardControlGroupInput = () => ({ + controlStyle: 'oneLine' as ControlGroupInput['controlStyle'], + panels: {}, +}); + +export const rawAttributesToControlGroupInput = ( + rawControlGroupAttributes: Omit +): Omit | undefined => { + const defaultControlGroupInput = getDefaultDashboardControlGroupInput(); + return { + controlStyle: rawControlGroupAttributes?.controlStyle ?? defaultControlGroupInput.controlStyle, + panels: + rawControlGroupAttributes?.panelsJSON && + typeof rawControlGroupAttributes?.panelsJSON === 'string' + ? JSON.parse(rawControlGroupAttributes?.panelsJSON) + : defaultControlGroupInput.panels, + }; +}; + +export const rawAttributesToSerializable = ( + rawControlGroupAttributes: Omit +): SerializableRecord => { + const defaultControlGroupInput = getDefaultDashboardControlGroupInput(); + return { + controlStyle: rawControlGroupAttributes?.controlStyle ?? defaultControlGroupInput.controlStyle, + panels: + rawControlGroupAttributes?.panelsJSON && + typeof rawControlGroupAttributes?.panelsJSON === 'string' + ? (JSON.parse(rawControlGroupAttributes?.panelsJSON) as SerializableRecord) + : defaultControlGroupInput.panels, + }; +}; + +export const serializableToRawAttributes = ( + controlGroupInput: SerializableRecord +): Omit => { + return { + controlStyle: controlGroupInput.controlStyle as ControlStyle, + panelsJSON: JSON.stringify(controlGroupInput.panels), + }; +}; diff --git a/src/plugins/dashboard/common/index.ts b/src/plugins/dashboard/common/index.ts index 73e01693977d9..e99fe82ffabda 100644 --- a/src/plugins/dashboard/common/index.ts +++ b/src/plugins/dashboard/common/index.ts @@ -28,3 +28,11 @@ export { migratePanelsTo730 } from './migrate_to_730_panels'; export const UI_SETTINGS = { ENABLE_LABS_UI: 'labs:dashboard:enable_ui', }; + +export { + controlGroupInputToRawAttributes, + getDefaultDashboardControlGroupInput, + rawAttributesToControlGroupInput, + rawAttributesToSerializable, + serializableToRawAttributes, +} from './embeddable/dashboard_control_group'; diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container.test.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container.test.tsx index 761db3ca47ff8..a26a4d4977a84 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container.test.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container.test.tsx @@ -134,26 +134,27 @@ test('DashboardContainer.replacePanel', async (done) => { const container = new DashboardContainer(initialInput, options); let counter = 0; - const subscriptionHandler = jest.fn(({ panels }) => { - counter++; - expect(panels[ID]).toBeDefined(); - // It should be called exactly 2 times and exit the second time - switch (counter) { - case 1: - return expect(panels[ID].type).toBe(CONTACT_CARD_EMBEDDABLE); - - case 2: { - expect(panels[ID].type).toBe(EMPTY_EMBEDDABLE); - subscription.unsubscribe(); - done(); + const subscription = container.getInput$().subscribe( + jest.fn(({ panels }) => { + counter++; + expect(panels[ID]).toBeDefined(); + // It should be called exactly 2 times and exit the second time + switch (counter) { + case 1: + return expect(panels[ID].type).toBe(CONTACT_CARD_EMBEDDABLE); + + case 2: { + expect(panels[ID].type).toBe(EMPTY_EMBEDDABLE); + subscription.unsubscribe(); + done(); + return; + } + + default: + throw Error('Called too many times!'); } - - default: - throw Error('Called too many times!'); - } - }); - - const subscription = container.getInput$().subscribe(subscriptionHandler); + }) + ); // replace the panel now container.replacePanel(container.getInput().panels[ID], { @@ -162,7 +163,7 @@ test('DashboardContainer.replacePanel', async (done) => { }); }); -test('Container view mode change propagates to existing children', async () => { +test('Container view mode change propagates to existing children', async (done) => { const initialInput = getSampleDashboardInput({ panels: { '123': getSampleDashboardPanel({ @@ -172,12 +173,12 @@ test('Container view mode change propagates to existing children', async () => { }, }); const container = new DashboardContainer(initialInput, options); - await nextTick(); - const embeddable = await container.getChild('123'); + const embeddable = await container.untilEmbeddableLoaded('123'); expect(embeddable.getInput().viewMode).toBe(ViewMode.VIEW); container.updateInput({ viewMode: ViewMode.EDIT }); expect(embeddable.getInput().viewMode).toBe(ViewMode.EDIT); + done(); }); test('Container view mode change propagates to new children', async () => { diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx index 0ec7ad21bce33..2595824e8b02e 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container_factory.tsx @@ -29,7 +29,8 @@ import { ControlGroupOutput, CONTROL_GROUP_TYPE, } from '../../../../controls/public'; -import { getDefaultDashboardControlGroupInput } from '../../dashboard_constants'; + +import { getDefaultDashboardControlGroupInput } from '../../../common/embeddable/dashboard_control_group'; export type DashboardContainerFactory = EmbeddableFactory< DashboardContainerInput, diff --git a/src/plugins/dashboard/public/application/lib/dashboard_control_group.ts b/src/plugins/dashboard/public/application/lib/dashboard_control_group.ts index 054c7e49dfc55..e421ec3477354 100644 --- a/src/plugins/dashboard/public/application/lib/dashboard_control_group.ts +++ b/src/plugins/dashboard/public/application/lib/dashboard_control_group.ts @@ -13,9 +13,13 @@ import { distinctUntilChanged, distinctUntilKeyChanged } from 'rxjs/operators'; import { DashboardContainer } from '..'; import { DashboardState } from '../../types'; -import { getDefaultDashboardControlGroupInput } from '../../dashboard_constants'; import { DashboardContainerInput, DashboardSavedObject } from '../..'; import { ControlGroupContainer, ControlGroupInput } from '../../../../controls/public'; +import { + controlGroupInputToRawAttributes, + getDefaultDashboardControlGroupInput, + rawAttributesToControlGroupInput, +} from '../../../common'; // only part of the control group input should be stored in dashboard state. The rest is passed down from the dashboard. export interface DashboardControlGroupInput { @@ -176,10 +180,9 @@ export const serializeControlGroupToDashboardSavedObject = ( return; } if (dashboardState.controlGroupInput) { - dashboardSavedObject.controlGroupInput = { - controlStyle: dashboardState.controlGroupInput.controlStyle, - panelsJSON: JSON.stringify(dashboardState.controlGroupInput.panels), - }; + dashboardSavedObject.controlGroupInput = controlGroupInputToRawAttributes( + dashboardState.controlGroupInput + ); } }; @@ -187,15 +190,7 @@ export const deserializeControlGroupFromDashboardSavedObject = ( dashboardSavedObject: DashboardSavedObject ): Omit | undefined => { if (!dashboardSavedObject.controlGroupInput) return; - - const defaultControlGroupInput = getDefaultDashboardControlGroupInput(); - return { - controlStyle: - dashboardSavedObject.controlGroupInput?.controlStyle ?? defaultControlGroupInput.controlStyle, - panels: dashboardSavedObject.controlGroupInput?.panelsJSON - ? JSON.parse(dashboardSavedObject.controlGroupInput?.panelsJSON) - : {}, - }; + return rawAttributesToControlGroupInput(dashboardSavedObject.controlGroupInput); }; export const combineDashboardFiltersWithControlGroupFilters = ( diff --git a/src/plugins/dashboard/public/dashboard_constants.ts b/src/plugins/dashboard/public/dashboard_constants.ts index 88fbc3b30392f..badc14ddaee66 100644 --- a/src/plugins/dashboard/public/dashboard_constants.ts +++ b/src/plugins/dashboard/public/dashboard_constants.ts @@ -6,8 +6,6 @@ * Side Public License, v 1. */ -import type { ControlStyle } from '../../controls/public'; - export const DASHBOARD_STATE_STORAGE_KEY = '_a'; export const GLOBAL_STATE_STORAGE_KEY = '_g'; @@ -25,11 +23,6 @@ export const DashboardConstants = { CHANGE_APPLY_DEBOUNCE: 50, }; -export const getDefaultDashboardControlGroupInput = () => ({ - controlStyle: 'oneLine' as ControlStyle, - panels: {}, -}); - export function createDashboardEditUrl(id?: string, editMode?: boolean) { if (!id) { return `${DashboardConstants.CREATE_NEW_DASHBOARD_URL}`; diff --git a/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts index 52ecb9549d54d..661a4dc8144fb 100644 --- a/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts +++ b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts @@ -17,8 +17,7 @@ import { extractReferences, injectReferences } from '../../common/saved_dashboar import { SavedObjectAttributes, SavedObjectReference } from '../../../../core/types'; import { DashboardOptions } from '../types'; - -import { ControlStyle } from '../../../controls/public'; +import { RawControlGroupAttributes } from '../application'; export interface DashboardSavedObject extends SavedObject { id?: string; @@ -39,7 +38,7 @@ export interface DashboardSavedObject extends SavedObject { outcome?: string; aliasId?: string; - controlGroupInput?: { controlStyle?: ControlStyle; panelsJSON?: string }; + controlGroupInput?: Omit; } const defaults = { diff --git a/src/plugins/dashboard/server/saved_objects/dashboard_migrations.ts b/src/plugins/dashboard/server/saved_objects/dashboard_migrations.ts index ed8f87ad9b51b..bd3051fc5a257 100644 --- a/src/plugins/dashboard/server/saved_objects/dashboard_migrations.ts +++ b/src/plugins/dashboard/server/saved_objects/dashboard_migrations.ts @@ -18,7 +18,12 @@ import { migrations730 } from './migrations_730'; import { SavedDashboardPanel } from '../../common/types'; import { EmbeddableSetup } from '../../../embeddable/server'; import { migrateMatchAllQuery } from './migrate_match_all_query'; -import { DashboardDoc700To720, DashboardDoc730ToLatest } from '../../common'; +import { + serializableToRawAttributes, + DashboardDoc700To720, + DashboardDoc730ToLatest, + rawAttributesToSerializable, +} from '../../common'; import { injectReferences, extractReferences } from '../../common/saved_dashboard_references'; import { convertPanelStateToSavedDashboardPanel, @@ -32,6 +37,7 @@ import { MigrateFunctionsObject, } from '../../../kibana_utils/common'; import { replaceIndexPatternReference } from './replace_index_pattern_reference'; +import { CONTROL_GROUP_TYPE } from '../../../controls/common'; function migrateIndexPattern(doc: DashboardDoc700To720) { const searchSourceJSON = get(doc, 'attributes.kibanaSavedObjectMeta.searchSourceJSON'); @@ -163,12 +169,23 @@ const migrateByValuePanels = (migrate: MigrateFunction, version: string): SavedObjectMigrationFn => (doc: any) => { const { attributes } = doc; + + if (attributes?.controlGroupInput) { + const controlGroupInput = rawAttributesToSerializable(attributes.controlGroupInput); + const migratedControlGroupInput = migrate({ + ...controlGroupInput, + type: CONTROL_GROUP_TYPE, + }); + attributes.controlGroupInput = serializableToRawAttributes(migratedControlGroupInput); + } + // Skip if panelsJSON is missing otherwise this will cause saved object import to fail when // importing objects without panelsJSON. At development time of this, there is no guarantee each saved // object has panelsJSON in all previous versions of kibana. if (typeof attributes?.panelsJSON !== 'string') { return doc; } + const panels = JSON.parse(attributes.panelsJSON) as SavedDashboardPanel[]; // Same here, prevent failing saved object import if ever panels aren't an array. if (!Array.isArray(panels)) { diff --git a/src/plugins/embeddable/public/lib/containers/container.ts b/src/plugins/embeddable/public/lib/containers/container.ts index a032126396d4f..39549cb4623c5 100644 --- a/src/plugins/embeddable/public/lib/containers/container.ts +++ b/src/plugins/embeddable/public/lib/containers/container.ts @@ -9,7 +9,8 @@ import uuid from 'uuid'; import { isEqual, xor } from 'lodash'; import { merge, Subscription } from 'rxjs'; -import { startWith, pairwise } from 'rxjs/operators'; +import { pairwise, take, delay } from 'rxjs/operators'; + import { Embeddable, EmbeddableInput, @@ -19,7 +20,13 @@ import { IEmbeddable, isErrorEmbeddable, } from '../embeddables'; -import { IContainer, ContainerInput, ContainerOutput, PanelState } from './i_container'; +import { + IContainer, + ContainerInput, + ContainerOutput, + PanelState, + EmbeddableContainerSettings, +} from './i_container'; import { PanelNotFoundError, EmbeddableFactoryNotFoundError } from '../errors'; import { EmbeddableStart } from '../../plugin'; import { isSavedObjectEmbeddableInput } from '../../../common/lib/saved_object_embeddable'; @@ -39,19 +46,29 @@ export abstract class Container< [key: string]: IEmbeddable | ErrorEmbeddable; } = {}; - private subscription: Subscription; + private subscription: Subscription | undefined; constructor( input: TContainerInput, output: TContainerOutput, protected readonly getFactory: EmbeddableStart['getEmbeddableFactory'], - parent?: Container + parent?: IContainer, + settings?: EmbeddableContainerSettings ) { super(input, output, parent); this.getFactory = getFactory; // Currently required for using in storybook due to https://github.com/storybookjs/storybook/issues/13834 + + // initialize all children on the first input change. Delayed so it is run after the constructor is finished. + this.getInput$() + .pipe(delay(0), take(1)) + .subscribe(() => { + this.initializeChildEmbeddables(input, settings); + }); + + // on all subsequent input changes, diff and update children on changes. this.subscription = this.getInput$() - // At each update event, get both the previous and current state - .pipe(startWith(input), pairwise()) + // At each update event, get both the previous and current state. + .pipe(pairwise()) .subscribe(([{ panels: prevPanels }, { panels: currentPanels }]) => { this.maybeUpdateChildren(currentPanels, prevPanels); }); @@ -166,7 +183,7 @@ export abstract class Container< public destroy() { super.destroy(); Object.values(this.children).forEach((child) => child.destroy()); - this.subscription.unsubscribe(); + this.subscription?.unsubscribe(); } public async untilEmbeddableLoaded( @@ -264,6 +281,33 @@ export abstract class Container< */ protected abstract getInheritedInput(id: string): TChildInput; + private async initializeChildEmbeddables( + initialInput: TContainerInput, + initializeSettings?: EmbeddableContainerSettings + ) { + let initializeOrder = Object.keys(initialInput.panels); + if (initializeSettings?.childIdInitializeOrder) { + const initializeOrderSet = new Set(); + for (const id of [...initializeSettings.childIdInitializeOrder, ...initializeOrder]) { + if (!initializeOrderSet.has(id) && Boolean(this.getInput().panels[id])) { + initializeOrderSet.add(id); + } + } + initializeOrder = Array.from(initializeOrderSet); + } + + for (const id of initializeOrder) { + if (initializeSettings?.initializeSequentially) { + const embeddable = await this.onPanelAdded(initialInput.panels[id]); + if (embeddable && !isErrorEmbeddable(embeddable)) { + await this.untilEmbeddableLoaded(id); + } + } else { + this.onPanelAdded(initialInput.panels[id]); + } + } + } + private async createAndSaveEmbeddable< TEmbeddableInput extends EmbeddableInput = EmbeddableInput, TEmbeddable extends IEmbeddable = IEmbeddable diff --git a/src/plugins/embeddable/public/lib/containers/embeddable_child_panel.test.tsx b/src/plugins/embeddable/public/lib/containers/embeddable_child_panel.test.tsx index 07867476508a5..e4dfc8ab58d82 100644 --- a/src/plugins/embeddable/public/lib/containers/embeddable_child_panel.test.tsx +++ b/src/plugins/embeddable/public/lib/containers/embeddable_child_panel.test.tsx @@ -7,7 +7,6 @@ */ import React from 'react'; -import { nextTick } from '@kbn/test-jest-helpers'; import { EmbeddableChildPanel } from './embeddable_child_panel'; import { CONTACT_CARD_EMBEDDABLE } from '../test_samples/embeddables/contact_card/contact_card_embeddable_factory'; import { SlowContactCardEmbeddableFactory } from '../test_samples/embeddables/contact_card/slow_contact_card_embeddable_factory'; @@ -60,7 +59,7 @@ test('EmbeddableChildPanel renders an embeddable when it is done loading', async /> ); - await nextTick(); + await new Promise((r) => setTimeout(r, 1)); component.update(); // Due to the way embeddables mount themselves on the dom node, they are not forced to be @@ -89,7 +88,7 @@ test(`EmbeddableChildPanel renders an error message if the factory doesn't exist ); - await nextTick(); + await new Promise((r) => setTimeout(r, 1)); component.update(); expect( diff --git a/src/plugins/embeddable/public/lib/containers/i_container.ts b/src/plugins/embeddable/public/lib/containers/i_container.ts index c4593cac4969a..f082000b38d4b 100644 --- a/src/plugins/embeddable/public/lib/containers/i_container.ts +++ b/src/plugins/embeddable/public/lib/containers/i_container.ts @@ -28,6 +28,17 @@ export interface ContainerInput extends EmbeddableInput }; } +export interface EmbeddableContainerSettings { + /** + * If true, the container will wait for each embeddable to load after creation before loading the next embeddable. + */ + initializeSequentially?: boolean; + /** + * Initialise children in the order specified. If an ID does not match it will be skipped and if a child is not included it will be initialized in the default order after the list of provided IDs. + */ + childIdInitializeOrder?: string[]; +} + export interface IContainer< Inherited extends {} = {}, I extends ContainerInput = ContainerInput, diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx index c8c0aea80e1e2..59f02107b0f23 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx @@ -89,6 +89,15 @@ export abstract class Embeddable< ); } + public refreshInputFromParent() { + if (!this.parent) return; + // Make sure this panel hasn't been removed immediately after it was added, but before it finished loading. + if (!this.parent.getInput().panels[this.id]) return; + + const newInput = this.parent.getInputForChild(this.id); + this.onResetInput(newInput); + } + public getIsContainer(): this is IContainer { return this.isContainer === true; } diff --git a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts index 0ee288cb4b8c6..a7a7372a3b554 100644 --- a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts +++ b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts @@ -189,4 +189,6 @@ export interface IEmbeddable< * Used to diff explicit embeddable input */ getExplicitInputIsEqual(lastInput: Partial): Promise; + + refreshInputFromParent(): void; } diff --git a/src/plugins/embeddable/public/lib/test_samples/embeddables/hello_world_container.tsx b/src/plugins/embeddable/public/lib/test_samples/embeddables/hello_world_container.tsx index 136ee5f4996b4..18dc9778bc3eb 100644 --- a/src/plugins/embeddable/public/lib/test_samples/embeddables/hello_world_container.tsx +++ b/src/plugins/embeddable/public/lib/test_samples/embeddables/hello_world_container.tsx @@ -12,6 +12,7 @@ import { I18nProvider } from '@kbn/i18n-react'; import { Container, ViewMode, ContainerInput } from '../..'; import { HelloWorldContainerComponent } from './hello_world_container_component'; import { EmbeddableStart } from '../../../plugin'; +import { EmbeddableContainerSettings } from '../../containers/i_container'; export const HELLO_WORLD_CONTAINER = 'HELLO_WORLD_CONTAINER'; @@ -40,9 +41,16 @@ export class HelloWorldContainer extends Container, - private readonly options: HelloWorldContainerOptions + private readonly options: HelloWorldContainerOptions, + initializeSettings?: EmbeddableContainerSettings ) { - super(input, { embeddableLoaded: {} }, options.getEmbeddableFactory || (() => undefined)); + super( + input, + { embeddableLoaded: {} }, + options.getEmbeddableFactory || (() => undefined), + undefined, + initializeSettings + ); } public getInheritedInput(id: string) { diff --git a/src/plugins/embeddable/public/tests/container.test.ts b/src/plugins/embeddable/public/tests/container.test.ts index f83316b11eb10..3e071594eea30 100644 --- a/src/plugins/embeddable/public/tests/container.test.ts +++ b/src/plugins/embeddable/public/tests/container.test.ts @@ -40,10 +40,12 @@ import { coreMock } from '../../../../core/public/mocks'; import { testPlugin } from './test_plugin'; import { of } from './helpers'; import { createEmbeddablePanelMock } from '../mocks'; +import { EmbeddableContainerSettings } from '../lib/containers/i_container'; async function creatHelloWorldContainerAndEmbeddable( containerInput: ContainerInput = { id: 'hello', panels: {} }, - embeddableInput = {} + embeddableInput = {}, + settings?: EmbeddableContainerSettings ) { const coreSetup = coreMock.createSetup(); const coreStart = coreMock.createStart(); @@ -69,10 +71,14 @@ async function creatHelloWorldContainerAndEmbeddable( application: coreStart.application, }); - const container = new HelloWorldContainer(containerInput, { - getEmbeddableFactory: start.getEmbeddableFactory, - panelComponent: testPanel, - }); + const container = new HelloWorldContainer( + containerInput, + { + getEmbeddableFactory: start.getEmbeddableFactory, + panelComponent: testPanel, + }, + settings + ); const embeddable = await container.addNewEmbeddable< ContactCardEmbeddableInput, @@ -87,23 +93,123 @@ async function creatHelloWorldContainerAndEmbeddable( return { container, embeddable, coreSetup, coreStart, setup, start, uiActions, testPanel }; } -test('Container initializes embeddables', async (done) => { - const { container } = await creatHelloWorldContainerAndEmbeddable({ - id: 'hello', - panels: { - '123': { - explicitInput: { id: '123' }, - type: CONTACT_CARD_EMBEDDABLE, - }, +describe('container initialization', () => { + const panels = { + '123': { + explicitInput: { id: '123' }, + type: CONTACT_CARD_EMBEDDABLE, }, - }); + '456': { + explicitInput: { id: '456' }, + type: CONTACT_CARD_EMBEDDABLE, + }, + '789': { + explicitInput: { id: '789' }, + type: CONTACT_CARD_EMBEDDABLE, + }, + }; - if (container.getOutput().embeddableLoaded['123']) { + const expectEmbeddableLoaded = (container: HelloWorldContainer, id: string) => { + expect(container.getOutput().embeddableLoaded['123']).toBe(true); const embeddable = container.getChild('123'); expect(embeddable).toBeDefined(); expect(embeddable.id).toBe('123'); + }; + + it('initializes embeddables', async (done) => { + const { container } = await creatHelloWorldContainerAndEmbeddable({ + id: 'hello', + panels, + }); + + expectEmbeddableLoaded(container, '123'); + expectEmbeddableLoaded(container, '456'); + expectEmbeddableLoaded(container, '789'); done(); - } + }); + + it('initializes embeddables in order', async (done) => { + const childIdInitializeOrder = ['456', '123', '789']; + const { container } = await creatHelloWorldContainerAndEmbeddable( + { + id: 'hello', + panels, + }, + {}, + { childIdInitializeOrder } + ); + + const onPanelAddedMock = jest.spyOn( + container as unknown as { onPanelAdded: () => {} }, + 'onPanelAdded' + ); + + await new Promise((r) => setTimeout(r, 1)); + for (const [index, orderedId] of childIdInitializeOrder.entries()) { + expect(onPanelAddedMock).toHaveBeenNthCalledWith(index + 1, { + explicitInput: { id: orderedId }, + type: 'CONTACT_CARD_EMBEDDABLE', + }); + } + done(); + }); + + it('initializes embeddables in order with partial order arg', async (done) => { + const childIdInitializeOrder = ['789', 'idontexist']; + const { container } = await creatHelloWorldContainerAndEmbeddable( + { + id: 'hello', + panels, + }, + {}, + { childIdInitializeOrder } + ); + const expectedInitializeOrder = ['789', '123', '456']; + + const onPanelAddedMock = jest.spyOn( + container as unknown as { onPanelAdded: () => {} }, + 'onPanelAdded' + ); + + await new Promise((r) => setTimeout(r, 1)); + for (const [index, orderedId] of expectedInitializeOrder.entries()) { + expect(onPanelAddedMock).toHaveBeenNthCalledWith(index + 1, { + explicitInput: { id: orderedId }, + type: 'CONTACT_CARD_EMBEDDABLE', + }); + } + done(); + }); + + it('initializes embeddables in order, awaiting each', async (done) => { + const childIdInitializeOrder = ['456', '123', '789']; + const { container } = await creatHelloWorldContainerAndEmbeddable( + { + id: 'hello', + panels, + }, + {}, + { childIdInitializeOrder, initializeSequentially: true } + ); + const onPanelAddedMock = jest.spyOn( + container as unknown as { onPanelAdded: () => {} }, + 'onPanelAdded' + ); + + const untilEmbeddableLoadedMock = jest.spyOn(container, 'untilEmbeddableLoaded'); + + await new Promise((r) => setTimeout(r, 10)); + + for (const [index, orderedId] of childIdInitializeOrder.entries()) { + await container.untilEmbeddableLoaded(orderedId); + expect(onPanelAddedMock).toHaveBeenNthCalledWith(index + 1, { + explicitInput: { id: orderedId }, + type: 'CONTACT_CARD_EMBEDDABLE', + }); + expect(untilEmbeddableLoadedMock).toHaveBeenCalledWith(orderedId); + } + done(); + }); }); test('Container.addNewEmbeddable', async () => { diff --git a/test/functional/apps/dashboard/dashboard_controls_integration.ts b/test/functional/apps/dashboard/dashboard_controls_integration.ts index 6f5c3722c10cb..a5feb4ca5e4e7 100644 --- a/test/functional/apps/dashboard/dashboard_controls_integration.ts +++ b/test/functional/apps/dashboard/dashboard_controls_integration.ts @@ -28,6 +28,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ]); describe('Dashboard controls integration', () => { + const clearAllControls = async () => { + const controlIds = await dashboardControls.getAllControlIds(); + for (const controlId of controlIds) { + await dashboardControls.removeExistingControl(controlId); + } + }; + before(async () => { await kibanaServer.savedObjects.cleanStandardList(); await kibanaServer.importExport.load( @@ -122,9 +129,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardControls.controlEditorSave(); // when creating a new filter, the ability to select a data view should be removed, because the dashboard now only has one data view - await testSubjects.click('addFilter'); - await testSubjects.missingOrFail('filterIndexPatternsSelect'); - await filterBar.ensureFieldEditorModalIsClosed(); + await retry.try(async () => { + await testSubjects.click('addFilter'); + const indexPatternSelectExists = await testSubjects.exists('filterIndexPatternsSelect'); + await filterBar.ensureFieldEditorModalIsClosed(); + expect(indexPatternSelectExists).to.be(false); + }); }); it('deletes an existing control', async () => { @@ -135,14 +145,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); after(async () => { - const controlIds = await dashboardControls.getAllControlIds(); - for (const controlId of controlIds) { - await dashboardControls.removeExistingControl(controlId); - } + await clearAllControls(); }); }); - describe('Interact with options list on dashboard', async () => { + describe('Interactions between options list and dashboard', async () => { let controlId: string; before(async () => { await dashboardAddPanel.addVisualization('Rendering-Test:-animal-sounds-pie'); @@ -290,7 +297,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); - describe('Options List validation', async () => { + describe('Options List dashboard validation', async () => { before(async () => { await dashboardControls.optionsListOpenPopover(controlId); await dashboardControls.optionsListPopoverSelectOption('meow'); @@ -367,6 +374,102 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await pieChart.getPieSliceCount()).to.be(1); }); }); + + after(async () => { + await filterBar.removeAllFilters(); + await clearAllControls(); + }); + }); + + describe('Control group hierarchical chaining', async () => { + let controlIds: string[]; + + const ensureAvailableOptionsEql = async (controlId: string, expectation: string[]) => { + await dashboardControls.optionsListOpenPopover(controlId); + await retry.try(async () => { + expect(await dashboardControls.optionsListPopoverGetAvailableOptions()).to.eql( + expectation + ); + }); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + }; + + before(async () => { + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'animal.keyword', + title: 'Animal', + }); + + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'name.keyword', + title: 'Animal Name', + }); + + await dashboardControls.createOptionsListControl({ + dataViewTitle: 'animals-*', + fieldName: 'sound.keyword', + title: 'Animal Sound', + }); + + controlIds = await dashboardControls.getAllControlIds(); + }); + + it('Shows all available options in first Options List control', async () => { + await dashboardControls.optionsListOpenPopover(controlIds[0]); + await retry.try(async () => { + expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(2); + }); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); + }); + + it('Selecting an option in the first Options List will filter the second and third controls', async () => { + await dashboardControls.optionsListOpenPopover(controlIds[0]); + await dashboardControls.optionsListPopoverSelectOption('cat'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); + + await ensureAvailableOptionsEql(controlIds[1], ['Tiger', 'sylvester']); + await ensureAvailableOptionsEql(controlIds[2], ['hiss', 'meow', 'growl', 'grr']); + }); + + it('Selecting an option in the second Options List will filter the third control', async () => { + await dashboardControls.optionsListOpenPopover(controlIds[1]); + await dashboardControls.optionsListPopoverSelectOption('sylvester'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[1]); + + await ensureAvailableOptionsEql(controlIds[2], ['meow', 'hiss']); + }); + + it('Can select an option in the third Options List', async () => { + await dashboardControls.optionsListOpenPopover(controlIds[2]); + await dashboardControls.optionsListPopoverSelectOption('meow'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[2]); + }); + + it('Selecting a conflicting option in the first control will validate the second and third controls', async () => { + await dashboardControls.optionsListOpenPopover(controlIds[0]); + await dashboardControls.optionsListPopoverClearSelections(); + await dashboardControls.optionsListPopoverSelectOption('dog'); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlIds[0]); + + await ensureAvailableOptionsEql(controlIds[1], [ + 'Fluffy', + 'Fee Fee', + 'Rover', + 'Ignored selection', + 'sylvester', + ]); + await ensureAvailableOptionsEql(controlIds[2], [ + 'ruff', + 'bark', + 'grrr', + 'bow ow ow', + 'grr', + 'Ignored selection', + 'meow', + ]); + }); }); }); } diff --git a/x-pack/test/api_integration/apis/maps/migrations.js b/x-pack/test/api_integration/apis/maps/migrations.js index 5ea24b5d25fc9..bb6081738eef2 100644 --- a/x-pack/test/api_integration/apis/maps/migrations.js +++ b/x-pack/test/api_integration/apis/maps/migrations.js @@ -7,6 +7,8 @@ import expect from '@kbn/expect'; +import semver from 'semver'; + export default function ({ getService }) { const supertest = getService('supertest'); @@ -77,7 +79,7 @@ export default function ({ getService }) { } expect(panels.length).to.be(1); expect(panels[0].type).to.be('map'); - expect(panels[0].version).to.be('8.2.0'); + expect(semver.gte(panels[0].version, '8.1.0')).to.be(true); }); }); }); diff --git a/x-pack/test/functional/apps/dashboard/migration_smoke_tests/controls_migration_smoke_test.ts b/x-pack/test/functional/apps/dashboard/migration_smoke_tests/controls_migration_smoke_test.ts index 72de77c2e2c2b..b87ee15910d23 100644 --- a/x-pack/test/functional/apps/dashboard/migration_smoke_tests/controls_migration_smoke_test.ts +++ b/x-pack/test/functional/apps/dashboard/migration_smoke_tests/controls_migration_smoke_test.ts @@ -72,14 +72,19 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(await dashboardControls.getAllControlTitles()).to.eql(['Speaker Name', 'Play Name']); const ids = await dashboardControls.getAllControlIds(); - for (const id of ids) { - await dashboardControls.optionsListOpenPopover(id); - await retry.try(async () => { - // Value counts should be 10, because there are more than 10 speakers and plays in the data set - expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(10); - }); - await dashboardControls.optionsListEnsurePopoverIsClosed(id); - } + + await dashboardControls.optionsListOpenPopover(ids[0]); + await retry.try(async () => { + expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(10); + }); + await dashboardControls.optionsListEnsurePopoverIsClosed(ids[0]); + + await dashboardControls.optionsListOpenPopover(ids[1]); + await retry.try(async () => { + // the second control should only have 5 available options because the previous control has HAMLET ROMEO JULIET and BRUTUS selected + expect(await dashboardControls.optionsListPopoverGetAvailableOptionsCount()).to.be(5); + }); + await dashboardControls.optionsListEnsurePopoverIsClosed(ids[1]); }); it('applies default selected options list options to control', async () => { From 65ee566b31c4bb1627a3f19c2543d1192ac2fa9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20G=C3=BCrkan=20YALAMAN?= Date: Mon, 14 Mar 2022 17:33:21 +0100 Subject: [PATCH 19/44] Fix automated curations history tab log views. (#127612) This fixes Automated Curations history tab by changing log source id with a specific filter for curations. EntSearchLogStream components doesn't have a default source id now since logs are sent to different indices, instead of one. For more granular access each component should define and pass their own source configuration. --- x-pack/plugins/enterprise_search/common/constants.ts | 2 +- .../components/automated_curations_history_panel.tsx | 2 ++ .../components/rejected_curations_history_panel.tsx | 2 ++ .../shared/log_stream/log_stream.test.tsx | 11 ++++++++--- .../applications/shared/log_stream/log_stream.tsx | 6 +++--- x-pack/plugins/enterprise_search/server/plugin.ts | 8 ++++---- 6 files changed, 20 insertions(+), 11 deletions(-) diff --git a/x-pack/plugins/enterprise_search/common/constants.ts b/x-pack/plugins/enterprise_search/common/constants.ts index 456a76d914f7d..c7c3fb2037465 100644 --- a/x-pack/plugins/enterprise_search/common/constants.ts +++ b/x-pack/plugins/enterprise_search/common/constants.ts @@ -88,5 +88,5 @@ export const READ_ONLY_MODE_HEADER = 'x-ent-search-read-only-mode'; export const ENTERPRISE_SEARCH_KIBANA_COOKIE = '_enterprise_search'; -export const LOGS_SOURCE_ID = 'ent-search-logs'; +export const ENTERPRISE_SEARCH_RELEVANCE_LOGS_SOURCE_ID = 'ent-search-logs'; export const ENTERPRISE_SEARCH_AUDIT_LOGS_SOURCE_ID = 'ent-search-audit-logs'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/automated_curations_history_panel.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/automated_curations_history_panel.tsx index 04f786b1ee1e1..6a82108b971c7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/automated_curations_history_panel.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations_history/components/automated_curations_history_panel.tsx @@ -11,6 +11,7 @@ import { useValues } from 'kea'; import { i18n } from '@kbn/i18n'; +import { ENTERPRISE_SEARCH_RELEVANCE_LOGS_SOURCE_ID } from '../../../../../../../../common/constants'; import { EntSearchLogStream } from '../../../../../../shared/log_stream'; import { DataPanel } from '../../../../data_panel'; import { EngineLogic } from '../../../../engine'; @@ -49,6 +50,7 @@ export const AutomatedCurationsHistoryPanel: React.FC = () => { hasBorder > { hasBorder > { const mockDateNow = jest.spyOn(global.Date, 'now').mockReturnValue(160000000); @@ -22,8 +24,8 @@ describe('EntSearchLogStream', () => { expect(wrapper.type()).toEqual(React.Suspense); }); - it('renders with the enterprise search log source ID', () => { - expect(wrapper.prop('sourceId')).toEqual('ent-search-logs'); + it('renders with the empty sourceId', () => { + expect(wrapper.prop('sourceId')).toBeUndefined(); }); it('renders with a default last-24-hours timestamp if no timestamp is passed', () => { @@ -46,7 +48,9 @@ describe('EntSearchLogStream', () => { }); it('allows passing a custom hoursAgo that modifies the default start timestamp', () => { - const wrapper = shallow(shallow().prop('children')); + const wrapper = shallow( + shallow().prop('children') + ); expect(wrapper.prop('startTimestamp')).toEqual(156400000); expect(wrapper.prop('endTimestamp')).toEqual(160000000); @@ -56,6 +60,7 @@ describe('EntSearchLogStream', () => { const wrapper = shallow( shallow( { + sourceId?: string; startTimestamp?: LogStreamProps['startTimestamp']; endTimestamp?: LogStreamProps['endTimestamp']; hoursAgo?: number; } export const EntSearchLogStream: React.FC = ({ + sourceId, startTimestamp, endTimestamp, hoursAgo = 24, @@ -40,7 +40,7 @@ export const EntSearchLogStream: React.FC = ({ return ( Date: Mon, 14 Mar 2022 12:44:14 -0400 Subject: [PATCH 20/44] [Security Solution][Endpoint] Tests and additional functionality for the Generic Artifact List page component (#126400) * Unit tests for the ArtifactListPage component * Fix bug: ensure flyout calls `onSuccess()` when update is done * Support for proving a form submit handler * Enhance http handler mock factory to provide the API options to the delay function * Change order of delay execution in http handler mock factory * use generic Delete, create, get one, get list, and update hooks for artifacts * add types to `useUrlParams` and delete custom `useUrlParams` from `ArtifatListPage` * Fix flyout call to check if data exists * Fix bug: drop `itemId` from url when edit item is not found. * Fix `doesDataExists` not updating when going from No data -to- user creates first item --- .../endpoint/http_handler_mock_factory.ts | 39 +- .../artifact_list_page.test.tsx | 913 ++++++++++++++++++ .../artifact_list_page/artifact_list_page.tsx | 66 +- .../components/artifact_delete_modal.tsx | 13 +- .../components/artifact_flyout.tsx | 114 ++- .../hooks/use_artifact_create_item.ts | 46 - .../hooks/use_artifact_get_item.ts | 28 - .../hooks/use_artifact_update_item.ts | 49 - .../hooks/use_is_flyout_opened.ts | 2 +- .../hooks/use_set_url_params.ts | 4 +- .../hooks/use_url_params.ts | 25 - ...em.ts => use_with_artifact_delete_item.ts} | 45 +- .../hooks/use_with_artifact_list_data.ts | 63 +- .../hooks/use_with_artifact_submit_data.ts | 3 +- .../components/artifact_list_page/index.ts | 5 +- .../artifact_list_page/translations.ts | 4 +- .../components/hooks/use_is_mounted.ts | 12 +- .../components/hooks/use_url_params.ts | 12 +- .../paginated_content/paginated_content.tsx | 30 +- .../search_exceptions/search_exceptions.tsx | 4 +- .../hooks/artifacts/use_list_artifact.tsx | 52 +- .../pages/mocks/trusted_apps_http_mocks.ts | 55 +- .../view/trusted_apps_page.test.tsx | 65 +- 23 files changed, 1276 insertions(+), 373 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_artifact_create_item.ts delete mode 100644 x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_artifact_get_item.ts delete mode 100644 x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_artifact_update_item.ts delete mode 100644 x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_url_params.ts rename x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/{use_artifact_delete_item.ts => use_with_artifact_delete_item.ts} (68%) diff --git a/x-pack/plugins/security_solution/public/common/mock/endpoint/http_handler_mock_factory.ts b/x-pack/plugins/security_solution/public/common/mock/endpoint/http_handler_mock_factory.ts index b0765f3abaf5e..9adc946bc397e 100644 --- a/x-pack/plugins/security_solution/public/common/mock/endpoint/http_handler_mock_factory.ts +++ b/x-pack/plugins/security_solution/public/common/mock/endpoint/http_handler_mock_factory.ts @@ -38,6 +38,8 @@ type SingleResponseProvider new Promise(r => setTimeout(r, 500)) * ) */ - mockDelay: jest.MockedFunction<() => Promise>; + mockDelay: jest.MockedFunction<(options: HttpFetchOptionsWithPath) => Promise>; }; /** @@ -99,9 +101,9 @@ interface RouteMock Promise; + delay?: (options: HttpFetchOptionsWithPath) => Promise; } export type ApiHandlerMockFactoryProps< @@ -134,14 +136,10 @@ export const httpHandlerMockFactory = void> = []; - const markApiCallAsHandled = async (delay?: RouteMock['delay']) => { + const markApiCallAsInFlight = () => { inflightApiCalls++; - - // If a delay was defined, then await that first - if (delay) { - await delay(); - } - + }; + const markApiCallAsHandled = async () => { // We always wait at least 1ms await new Promise((r) => setTimeout(r, 1)); @@ -200,10 +198,7 @@ export const httpHandlerMockFactory = { + let render: ( + props?: Partial + ) => ReturnType; + let renderResult: ReturnType; + let history: AppContextTestRender['history']; + let coreStart: AppContextTestRender['coreStart']; + let mockedApi: ReturnType; + let FormComponentMock: jest.Mock>; + + interface DeferredInterface { + promise: Promise; + resolve: (data: T) => void; + reject: (e: Error) => void; + } + + const getDeferred = function (): DeferredInterface { + let resolve: DeferredInterface['resolve']; + let reject: DeferredInterface['reject']; + + const promise = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + + // @ts-ignore + return { promise, resolve, reject }; + }; + + /** + * Returns the props object that the Form component was last called with + */ + const getLastFormComponentProps = (): ArtifactFormComponentProps => { + return FormComponentMock.mock.calls[FormComponentMock.mock.calls.length - 1][0]; + }; + + beforeEach(() => { + const mockedContext = createAppRootMockRenderer(); + + ({ history, coreStart } = mockedContext); + mockedApi = trustedAppsAllHttpMocks(coreStart.http); + + const apiClient = new TrustedAppsApiClient(coreStart.http); + const labels = { ...artifactListPageLabels }; + + FormComponentMock = jest.fn((({ mode, error, disabled }: ArtifactFormComponentProps) => { + return ( +
+
{`${mode} form`}
+
{`Is Disabled: ${disabled}`}
+ {error && ( + <> +
{error.message}
+
{JSON.stringify(error.body)}
+ + )} +
+ ); + }) as unknown as jest.Mock>); + + render = (props: Partial = {}) => { + return (renderResult = mockedContext.render( + + )); + }; + + // Ensure user privileges are reset + useUserPrivileges.mockReturnValue({ + ...useUserPrivileges(), + endpointPrivileges: getEndpointPrivilegesInitialStateMock(), + }); + }); + + it('should display a loader while determining which view to show', async () => { + // Mock a delay into the list results http call + const deferrable = getDeferred(); + mockedApi.responseProvider.trustedAppsList.mockDelay.mockReturnValue(deferrable.promise); + + const { getByTestId } = render(); + const loader = getByTestId('testPage-pageLoader'); + + expect(loader).not.toBeNull(); + + // release the API call + act(() => { + deferrable.resolve(); + }); + + await waitForElementToBeRemoved(loader); + }); + + describe('and NO data exists', () => { + let renderWithNoData: () => ReturnType; + let originalListApiResponseProvider: TrustedAppsGetListHttpMocksInterface['trustedAppsList']; + + beforeEach(() => { + originalListApiResponseProvider = + mockedApi.responseProvider.trustedAppsList.getMockImplementation()!; + + renderWithNoData = () => { + mockedApi.responseProvider.trustedAppsList.mockReturnValue({ + data: [], + page: 1, + per_page: 10, + total: 0, + }); + + render(); + + return renderResult; + }; + }); + + it('should display empty state', async () => { + renderWithNoData(); + + await waitFor(async () => { + expect(renderResult.getByTestId('testPage-emptyState')); + }); + }); + + it('should hide page headers', async () => { + renderWithNoData(); + + expect(renderResult.queryByTestId('header-page-title')).toBe(null); + }); + + it('should open create flyout when primary button is clicked', async () => { + renderWithNoData(); + const addButton = await renderResult.findByTestId('testPage-emptyState-addButton'); + + act(() => { + userEvent.click(addButton); + }); + + expect(renderResult.getByTestId('testPage-flyout')).toBeTruthy(); + expect(history.location.search).toMatch(/show=create/); + }); + + describe('and the first item is created', () => { + it('should show the list after creating first item and remove empty state', async () => { + renderWithNoData(); + const addButton = await renderResult.findByTestId('testPage-emptyState-addButton'); + + act(() => { + userEvent.click(addButton); + }); + + await waitFor(async () => { + expect(renderResult.getByTestId('testPage-flyout')); + }); + + // indicate form is valid + act(() => { + const lastProps = getLastFormComponentProps(); + lastProps.onChange({ item: { ...lastProps.item, name: 'some name' }, isValid: true }); + }); + + mockedApi.responseProvider.trustedAppsList.mockImplementation( + originalListApiResponseProvider + ); + + // Submit form + act(() => { + userEvent.click(renderResult.getByTestId('testPage-flyout-submitButton')); + }); + + // wait for the list to show up + await act(async () => { + await waitFor(() => { + expect(renderResult.getByTestId('testPage-list')).toBeTruthy(); + }); + }); + }); + }); + }); + + describe('and the flyout is opened', () => { + let renderAndWaitForFlyout: ( + props?: Partial + ) => Promise>; + + beforeEach(async () => { + history.push('somepage?show=create'); + + renderAndWaitForFlyout = async (...props) => { + render(...props); + + await waitFor(async () => { + expect(renderResult.getByTestId('testPage-flyout')); + }); + + return renderResult; + }; + }); + + it('should display `Cancel` button enabled', async () => { + await renderAndWaitForFlyout(); + + expect(renderResult.getByTestId('testPage-flyout-cancelButton')).toBeEnabled(); + }); + + it('should display `Submit` button as disabled', async () => { + await renderAndWaitForFlyout(); + + expect(renderResult.getByTestId('testPage-flyout-submitButton')).not.toBeEnabled(); + }); + + it.each([ + ['Cancel', 'testPage-flyout-cancelButton'], + ['Close', 'euiFlyoutCloseButton'], + ])('should close flyout when `%s` button is clicked', async (_, testId) => { + await renderAndWaitForFlyout(); + + act(() => { + userEvent.click(renderResult.getByTestId(testId)); + }); + + expect(renderResult.queryByTestId('testPage-flyout')).toBeNull(); + expect(history.location.search).toEqual(''); + }); + + it('should pass to the Form component the expected props', async () => { + await renderAndWaitForFlyout(); + + expect(FormComponentMock).toHaveBeenLastCalledWith( + { + disabled: false, + error: undefined, + item: { + comments: [], + description: '', + entries: [], + item_id: undefined, + list_id: 'endpoint_trusted_apps', + meta: expect.any(Object), + name: '', + namespace_type: 'agnostic', + os_types: ['windows'], + tags: ['policy:all'], + type: 'simple', + }, + mode: 'create', + onChange: expect.any(Function), + }, + expect.anything() + ); + }); + + describe('and form data is valid', () => { + beforeEach(async () => { + const _renderAndWaitForFlyout = renderAndWaitForFlyout; + + // Override renderAndWaitForFlyout to also set the form data as "valid" + renderAndWaitForFlyout = async (...props) => { + await _renderAndWaitForFlyout(...props); + + act(() => { + const lastProps = getLastFormComponentProps(); + lastProps.onChange({ item: { ...lastProps.item, name: 'some name' }, isValid: true }); + }); + + return renderResult; + }; + }); + + it('should enable the `Submit` button', async () => { + await renderAndWaitForFlyout(); + + expect(renderResult.getByTestId('testPage-flyout-submitButton')).toBeEnabled(); + }); + + describe('and user clicks submit', () => { + let releaseApiUpdateResponse: () => void; + let getByTestId: typeof renderResult['getByTestId']; + + beforeEach(async () => { + await renderAndWaitForFlyout(); + + getByTestId = renderResult.getByTestId; + + // Mock a delay into the create api http call + const deferrable = getDeferred(); + mockedApi.responseProvider.trustedAppCreate.mockDelay.mockReturnValue(deferrable.promise); + releaseApiUpdateResponse = deferrable.resolve; + + act(() => { + userEvent.click(renderResult.getByTestId('testPage-flyout-submitButton')); + }); + }); + + afterEach(() => { + if (releaseApiUpdateResponse) { + releaseApiUpdateResponse(); + } + }); + + it('should disable all buttons while an update is in flight', () => { + expect(getByTestId('testPage-flyout-cancelButton')).not.toBeEnabled(); + expect(getByTestId('testPage-flyout-submitButton')).not.toBeEnabled(); + }); + + it('should display loading indicator on Submit while an update is in flight', () => { + expect( + getByTestId('testPage-flyout-submitButton').querySelector('.euiLoadingSpinner') + ).toBeTruthy(); + }); + + it('should pass `disabled=true` to the Form component while an update is in flight', () => { + expect(getLastFormComponentProps().disabled).toBe(true); + }); + }); + + describe('and submit is successful', () => { + beforeEach(async () => { + await renderAndWaitForFlyout(); + + act(() => { + userEvent.click(renderResult.getByTestId('testPage-flyout-submitButton')); + }); + + await act(async () => { + await waitFor(() => { + expect(renderResult.queryByTestId('testPage-flyout')).toBeNull(); + }); + }); + }); + + it('should show a success toast', async () => { + expect(coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith( + '"some name" has been added.' + ); + }); + + it('should clear the URL params', () => { + expect(location.search).toBe(''); + }); + }); + + describe('and submit fails', () => { + beforeEach(async () => { + const _renderAndWaitForFlyout = renderAndWaitForFlyout; + + renderAndWaitForFlyout = async (...args) => { + mockedApi.responseProvider.trustedAppCreate.mockImplementation(() => { + throw new Error('oh oh. no good!'); + }); + + await _renderAndWaitForFlyout(...args); + + act(() => { + userEvent.click(renderResult.getByTestId('testPage-flyout-submitButton')); + }); + + await act(async () => { + await waitFor(() => + expect(mockedApi.responseProvider.trustedAppCreate).toHaveBeenCalled() + ); + }); + + return renderResult; + }; + }); + + // FIXME:PT investigate test failure + // (I don't understand why its failing... All assertions are successful -- HELP!) + it.skip('should re-enable `Cancel` and `Submit` buttons', async () => { + await renderAndWaitForFlyout(); + + expect(renderResult.getByTestId('testPage-flyout-cancelButton')).not.toBeEnabled(); + + expect(renderResult.getByTestId('testPage-flyout-submitButton')).not.toBeEnabled(); + }); + + // FIXME:PT investigate test failure + // (I don't understand why its failing... All assertions are successful -- HELP!) + it.skip('should pass error along to the Form component and reset disabled back to `false`', async () => { + await renderAndWaitForFlyout(); + const lastFormProps = getLastFormComponentProps(); + + expect(lastFormProps.error).toBeInstanceOf(Error); + expect(lastFormProps.disabled).toBe(false); + }); + }); + + describe('and a custom Submit handler is used', () => { + let handleSubmitCallback: jest.Mock; + let releaseSuccessSubmit: () => void; + let releaseFailureSubmit: () => void; + + beforeEach(async () => { + const deferred = getDeferred(); + releaseSuccessSubmit = () => act(() => deferred.resolve()); + releaseFailureSubmit = () => act(() => deferred.reject(new Error('oh oh. No good'))); + + handleSubmitCallback = jest.fn(async (item) => { + await deferred.promise; + + return new ExceptionsListItemGenerator().generateTrustedApp(item); + }); + + await renderAndWaitForFlyout({ onFormSubmit: handleSubmitCallback }); + + act(() => { + userEvent.click(renderResult.getByTestId('testPage-flyout-submitButton')); + }); + }); + + afterEach(() => { + if (releaseSuccessSubmit) { + releaseSuccessSubmit(); + } + }); + + it('should use custom submit handler when submit button is used', async () => { + expect(handleSubmitCallback).toHaveBeenCalled(); + + expect(renderResult.getByTestId('testPage-flyout-cancelButton')).not.toBeEnabled(); + + expect(renderResult.getByTestId('testPage-flyout-submitButton')).not.toBeEnabled(); + }); + + it('should catch and show error if one is encountered', async () => { + releaseFailureSubmit(); + await waitFor(() => { + expect(renderResult.getByTestId('formError')).toBeTruthy(); + }); + }); + + it('should show a success toast', async () => { + releaseSuccessSubmit(); + + await waitFor(() => { + expect(coreStart.notifications.toasts.addSuccess).toHaveBeenCalled(); + }); + + expect(coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith( + '"some name" has been added.' + ); + }); + + it('should clear the URL params', () => { + releaseSuccessSubmit(); + + expect(location.search).toBe(''); + }); + }); + }); + + describe('and in Edit mode', () => { + beforeEach(async () => { + history.push('somepage?show=edit&itemId=123'); + }); + + it('should show loader while initializing in edit mode', async () => { + const deferred = getDeferred(); + mockedApi.responseProvider.trustedApp.mockDelay.mockReturnValue(deferred.promise); + + const { getByTestId } = await renderAndWaitForFlyout(); + + // The loader should be shown and the flyout footer should not be shown + expect(getByTestId('testPage-flyout-loader')).toBeTruthy(); + expect(() => getByTestId('testPage-flyout-cancelButton')).toThrow(); + expect(() => getByTestId('testPage-flyout-submitButton')).toThrow(); + + // The Form should not yet have been rendered + expect(FormComponentMock).not.toHaveBeenCalled(); + + act(() => deferred.resolve()); + + // we should call the GET API with the id provided + await waitFor(() => { + expect(mockedApi.responseProvider.trustedApp).toHaveBeenLastCalledWith( + expect.objectContaining({ + path: expect.any(String), + query: expect.objectContaining({ + item_id: '123', + }), + }) + ); + }); + }); + + it('should provide Form component with the item for edit', async () => { + const { getByTestId } = await renderAndWaitForFlyout(); + + await act(async () => { + await waitFor(() => { + expect(getByTestId('formMock')).toBeTruthy(); + }); + }); + + expect(getLastFormComponentProps().item).toEqual({ + ...mockedApi.responseProvider.trustedApp({ + query: { item_id: '123' }, + } as unknown as HttpFetchOptionsWithPath), + created_at: expect.any(String), + }); + }); + + it('should show error toast and close flyout if item for edit does not exist', async () => { + mockedApi.responseProvider.trustedApp.mockImplementation(() => { + throw new Error('does not exist'); + }); + + await renderAndWaitForFlyout(); + + await act(async () => { + await waitFor(() => { + expect(mockedApi.responseProvider.trustedApp).toHaveBeenCalled(); + }); + }); + + expect(coreStart.notifications.toasts.addWarning).toHaveBeenCalledWith( + 'Failed to retrieve item for edit. Reason: does not exist' + ); + }); + + it('should not show the expired license callout', async () => { + const { queryByTestId, getByTestId } = await renderAndWaitForFlyout(); + + await act(async () => { + await waitFor(() => { + expect(getByTestId('formMock')).toBeTruthy(); + }); + }); + + expect(queryByTestId('testPage-flyout-expiredLicenseCallout')).not.toBeTruthy(); + }); + + it('should show expired license warning when unsupported features are being used (downgrade scenario)', async () => { + // make the API return a policy specific item + const _generateResponse = mockedApi.responseProvider.trustedApp.getMockImplementation()!; + mockedApi.responseProvider.trustedApp.mockImplementation((params) => { + return { + ..._generateResponse(params), + tags: [`${BY_POLICY_ARTIFACT_TAG_PREFIX}${123}`], + }; + }); + + useUserPrivileges.mockReturnValue({ + ...useUserPrivileges(), + endpointPrivileges: getEndpointPrivilegesInitialStateMock({ + canCreateArtifactsByPolicy: false, + }), + }); + + const { getByTestId } = await renderAndWaitForFlyout(); + + await act(async () => { + await waitFor(() => { + expect(getByTestId('formMock')).toBeTruthy(); + }); + }); + + expect(getByTestId('testPage-flyout-expiredLicenseCallout')).toBeTruthy(); + }); + }); + }); + + describe('and data exists', () => { + let renderWithListData: () => Promise>; + + const getFirstCard = async ({ + showActions = false, + }: Partial<{ showActions: boolean }> = {}): Promise => { + const cards = await renderResult.findAllByTestId('testPage-card'); + + if (cards.length === 0) { + throw new Error('No cards found!'); + } + + const card = cards[0]; + + if (showActions) { + await act(async () => { + userEvent.click(within(card).getByTestId('testPage-card-header-actions-button')); + + await waitFor(() => { + expect(renderResult.getByTestId('testPage-card-header-actions-contextMenuPanel')); + }); + }); + } + + return card; + }; + + beforeEach(async () => { + renderWithListData = async () => { + render(); + + await act(async () => { + await waitFor(() => { + expect(renderResult.getByTestId('testPage-list')).toBeTruthy(); + }); + }); + + return renderResult; + }; + }); + + it('should show list data loading indicator while list results are retrieved (and after list was checked to see if it has data)', async () => { + // add a delay to the list results, but not to the API call + // that is used to determine if the list contains data + mockedApi.responseProvider.trustedAppsList.mockDelay.mockImplementation(async (options) => { + const query = options.query as { page?: number; per_page?: number }; + if (query.page === 1 && query.per_page === 1) { + return; + } + + return new Promise((r) => setTimeout(r, 50)); + }); + + const { getByTestId } = await renderWithListData(); + + expect(getByTestId('testPage-list-loader')).toBeTruthy(); + }); + + it(`should show cards with results`, async () => { + const { findAllByTestId, getByTestId } = await renderWithListData(); + + await expect(findAllByTestId('testPage-card')).resolves.toHaveLength(10); + expect(getByTestId('testPage-showCount').textContent).toBe('Showing 20 artifacts'); + }); + + it('should show card actions', async () => { + const { getByTestId } = await renderWithListData(); + await getFirstCard({ showActions: true }); + + expect(getByTestId('testPage-card-cardEditAction')).toBeTruthy(); + expect(getByTestId('testPage-card-cardDeleteAction')).toBeTruthy(); + }); + + it('should persist pagination `page` changes to the URL', async () => { + const { getByTestId } = await renderWithListData(); + act(() => { + userEvent.click(getByTestId('pagination-button-1')); + }); + + await waitFor(() => { + expect(history.location.search).toMatch(/page=2/); + }); + }); + + it('should persist pagination `page size` changes to the URL', async () => { + const { getByTestId } = await renderWithListData(); + act(() => { + userEvent.click(getByTestId('tablePaginationPopoverButton')); + }); + await act(async () => { + await waitFor(() => { + expect(getByTestId('tablePagination-20-rows')); + }); + userEvent.click(getByTestId('tablePagination-20-rows')); + }); + + await waitFor(() => { + expect(history.location.search).toMatch(/pageSize=20/); + }); + }); + + describe('and interacting with card actions', () => { + const clickCardAction = async (action: 'edit' | 'delete') => { + await getFirstCard({ showActions: true }); + act(() => { + switch (action) { + case 'delete': + userEvent.click(renderResult.getByTestId('testPage-card-cardDeleteAction')); + break; + + case 'edit': + userEvent.click(renderResult.getByTestId('testPage-card-cardEditAction')); + break; + } + }); + }; + + it('should display the Edit flyout when edit action is clicked', async () => { + const { getByTestId } = await renderWithListData(); + await clickCardAction('edit'); + + expect(getByTestId('testPage-flyout')).toBeTruthy(); + }); + + it('should display the Delete modal when delete action is clicked', async () => { + const { getByTestId } = await renderWithListData(); + await clickCardAction('delete'); + + expect(getByTestId('testPage-deleteModal')).toBeTruthy(); + }); + + describe('and interacting with the deletion modal', () => { + let cancelButton: HTMLButtonElement; + let submitButton: HTMLButtonElement; + + beforeEach(async () => { + await renderWithListData(); + await clickCardAction('delete'); + + cancelButton = renderResult.getByTestId( + 'testPage-deleteModal-cancelButton' + ) as HTMLButtonElement; + submitButton = renderResult.getByTestId( + 'testPage-deleteModal-submitButton' + ) as HTMLButtonElement; + }); + + it('should show Cancel and Delete buttons enabled', async () => { + expect(cancelButton).toBeEnabled(); + expect(submitButton).toBeEnabled(); + }); + + it('should close modal if Cancel/Close buttons are clicked', async () => { + userEvent.click(cancelButton); + + expect(renderResult.queryByTestId('testPage-deleteModal')).toBeNull(); + }); + + it('should prevent modal from being closed while deletion is in flight', async () => { + const deferred = getDeferred(); + mockedApi.responseProvider.trustedAppDelete.mockDelay.mockReturnValue(deferred.promise); + + act(() => { + userEvent.click(submitButton); + }); + + await waitFor(() => { + expect(cancelButton).toBeEnabled(); + expect(submitButton).toBeEnabled(); + }); + + deferred.resolve(); // cleanup + }); + + it('should show success toast if deleted successfully', async () => { + act(() => { + userEvent.click(submitButton); + }); + + await act(async () => { + await waitFor(() => { + expect(mockedApi.responseProvider.trustedAppDelete).toHaveBeenCalled(); + }); + }); + + expect(coreStart.notifications.toasts.addSuccess).toHaveBeenCalledWith( + expect.stringMatching(/ has been removed$/) + ); + }); + + // FIXME:PT investigate test failure + // (I don't understand why its failing... All assertions are successful -- HELP!) + it.skip('should show error toast if deletion failed', async () => { + mockedApi.responseProvider.trustedAppDelete.mockImplementation(() => { + throw new Error('oh oh'); + }); + + act(() => { + userEvent.click(submitButton); + }); + + await act(async () => { + await waitFor(() => { + expect(mockedApi.responseProvider.trustedAppDelete).toHaveBeenCalled(); + }); + }); + + expect(coreStart.notifications.toasts.addDanger).toHaveBeenCalledWith( + expect.stringMatching(/^Unable to remove .*\. Reason: oh oh/) + ); + expect(renderResult.getByTestId('testPage-deleteModal')).toBeTruthy(); + expect(cancelButton).toBeEnabled(); + expect(submitButton).toBeEnabled(); + }); + }); + }); + + describe('and search bar is used', () => { + const clickSearchButton = () => { + act(() => { + fireEvent.click(renderResult.getByTestId('searchButton')); + }); + }; + + beforeEach(async () => { + await renderWithListData(); + }); + + it('should persist filter to the URL params', async () => { + act(() => { + userEvent.type(renderResult.getByTestId('searchField'), 'fooFooFoo'); + }); + clickSearchButton(); + + await waitFor(() => { + expect(history.location.search).toMatch(/fooFooFoo/); + }); + + await waitFor(() => { + expect(mockedApi.responseProvider.trustedAppsList).toHaveBeenLastCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ + filter: expect.stringMatching(/\*fooFooFoo\*/), + }), + }) + ); + }); + }); + + it('should persist policy filter to the URL params', async () => { + const policyId = mockedApi.responseProvider.endpointPackagePolicyList().items[0].id; + const firstPolicyTestId = `policiesSelector-popover-items-${policyId}`; + + await act(async () => { + await waitFor(() => { + expect(renderResult.getByTestId('policiesSelectorButton')).toBeTruthy(); + }); + }); + + act(() => { + userEvent.click(renderResult.getByTestId('policiesSelectorButton')); + }); + + await act(async () => { + await waitFor(() => { + expect(renderResult.getByTestId(firstPolicyTestId)).toBeTruthy(); + }); + userEvent.click(renderResult.getByTestId(firstPolicyTestId)); + }); + + await waitFor(() => { + expect(history.location.search).toMatch(new RegExp(`includedPolicies=${policyId}`)); + }); + }); + + it('should trigger a current page data fetch when Refresh button is clicked', async () => { + const currentApiCount = mockedApi.responseProvider.trustedAppsList.mock.calls.length; + + clickSearchButton(); + + await waitFor(() => { + expect(mockedApi.responseProvider.trustedAppsList).toHaveBeenCalledTimes( + currentApiCount + 1 + ); + }); + }); + + it('should show a no results found message if filter did not return any results', async () => { + let apiNoResultsDone = false; + mockedApi.responseProvider.trustedAppsList.mockImplementationOnce(() => { + apiNoResultsDone = true; + + return { + page: 1, + per_page: 10, + total: 0, + data: [], + }; + }); + + act(() => { + userEvent.type(renderResult.getByTestId('searchField'), 'fooFooFoo'); + }); + + clickSearchButton(); + + await act(async () => { + await waitFor(() => { + expect(apiNoResultsDone).toBe(true); + }); + }); + + await waitFor(() => { + // console.log(`\n\n${renderResult.getByTestId('testPage-list').outerHTML}\n\n\n`); + expect(renderResult.getByTestId('testPage-list-noResults')).toBeTruthy(); + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.tsx index 87673cf5c1e47..87e3b2bb00519 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.tsx @@ -20,19 +20,19 @@ import { ArtifactEntryCard } from '../artifact_entry_card'; import { ArtifactListPageLabels, artifactListPageLabels } from './translations'; import { useTestIdGenerator } from '../hooks/use_test_id_generator'; import { ManagementPageLoader } from '../management_page_loader'; -import { SearchExceptions } from '../search_exceptions'; +import { SearchExceptions, SearchExceptionsProps } from '../search_exceptions'; import { useArtifactCardPropsProvider, UseArtifactCardPropsProviderProps, } from './hooks/use_artifact_card_props_provider'; import { NoDataEmptyState } from './components/no_data_empty_state'; -import { ArtifactFlyoutProps, MaybeArtifactFlyout } from './components/artifact_flyout'; +import { ArtifactFlyoutProps, ArtifactFlyout } from './components/artifact_flyout'; import { useIsFlyoutOpened } from './hooks/use_is_flyout_opened'; import { useSetUrlParams } from './hooks/use_set_url_params'; import { useWithArtifactListData } from './hooks/use_with_artifact_list_data'; import { ExceptionsListApiClient } from '../../services/exceptions_list/exceptions_list_api_client'; import { ArtifactListPageUrlParams } from './types'; -import { useUrlParams } from './hooks/use_url_params'; +import { useUrlParams } from '../hooks/use_url_params'; import { ListPageRouteState, MaybeImmutable } from '../../../../common/endpoint/types'; import { DEFAULT_EXCEPTION_LIST_ITEM_SEARCHABLE_FIELDS } from '../../../../common/endpoint/service/artifacts/constants'; import { ArtifactDeleteModal } from './components/artifact_delete_modal'; @@ -42,6 +42,7 @@ import { useToasts } from '../../../common/lib/kibana'; import { useMemoizedRouteState } from '../../common/hooks'; import { BackToExternalAppSecondaryButton } from '../back_to_external_app_secondary_button'; import { BackToExternalAppButton } from '../back_to_external_app_button'; +import { useIsMounted } from '../hooks/use_is_mounted'; type ArtifactEntryCardType = typeof ArtifactEntryCard; @@ -56,6 +57,13 @@ export interface ArtifactListPageProps { ArtifactFormComponent: ArtifactFlyoutProps['FormComponent']; /** A list of labels for the given artifact page. Not all have to be defined, only those that should override the defaults */ labels: ArtifactListPageLabels; + /** + * Define a callback to handle the submission of the form data instead of the internal one in + * `ArtifactListPage` being used. + * @param item + * @param mode + */ + onFormSubmit?: Required['submitHandler']; /** A list of fields that will be used by the search functionality when a user enters a value in the searchbar */ searchableFields?: MaybeImmutable; flyoutSize?: EuiFlyoutSize; @@ -68,11 +76,14 @@ export const ArtifactListPage = memo( ArtifactFormComponent, searchableFields = DEFAULT_EXCEPTION_LIST_ITEM_SEARCHABLE_FIELDS, labels: _labels = {}, + onFormSubmit, + flyoutSize, 'data-test-subj': dataTestSubj, }) => { const { state: routeState } = useLocation(); const getTestId = useTestIdGenerator(dataTestSubj); const toasts = useToasts(); + const isMounted = useIsMounted(); const isFlyoutOpened = useIsFlyoutOpened(); const setUrlParams = useSetUrlParams(); const { @@ -171,31 +182,44 @@ export const ArtifactListPage = memo( [setUrlParams] ); - const handleOnSearch = useCallback( + const handleOnSearch = useCallback( (filterValue: string, selectedPolicies: string, doHardRefresh) => { + const didFilterChange = + filterValue !== (filter ?? '') || selectedPolicies !== (includedPolicies ?? ''); + setUrlParams({ // `undefined` will drop the param from the url filter: filterValue.trim() === '' ? undefined : filterValue, includedPolicies: selectedPolicies.trim() === '' ? undefined : selectedPolicies, }); - if (doHardRefresh) { + // We don't want to trigger a refresh of the list twice because the URL above was already + // updated, so if the user explicitly clicked the `Refresh` button and nothing has changed + // in the filter, then trigger a refresh (since the url update did not actually trigger one) + if (doHardRefresh && !didFilterChange) { refetchListData(); } }, - [refetchListData, setUrlParams] + [filter, includedPolicies, refetchListData, setUrlParams] ); const handleArtifactDeleteModalOnSuccess = useCallback(() => { - setSelectedItemForDelete(undefined); - refetchListData(); - }, [refetchListData]); + if (isMounted) { + setSelectedItemForDelete(undefined); + refetchListData(); + } + }, [isMounted, refetchListData]); const handleArtifactDeleteModalOnCancel = useCallback(() => { setSelectedItemForDelete(undefined); }, []); + const handleArtifactFlyoutOnClose = useCallback(() => { + setSelectedItemForEdit(undefined); + }, []); + const handleArtifactFlyoutOnSuccess = useCallback(() => { + setSelectedItemForEdit(undefined); refetchListData(); }, [refetchListData]); @@ -221,15 +245,19 @@ export const ArtifactListPage = memo( } > - {/* Flyout component is driven by URL params and may or may not be displayed based on those */} - + {isFlyoutOpened && ( + + )} {selectedItemForDelete && ( ( loading={isLoading} pagination={uiPagination} contentClassName="card-container" - data-test-subj={getTestId('cardContent')} + data-test-subj={getTestId('list')} /> )} diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_delete_modal.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_delete_modal.tsx index 4228d923a9ab3..6bc7abaafdd8f 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_delete_modal.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_delete_modal.tsx @@ -27,8 +27,8 @@ import { } from '../../../../../common/endpoint/service/artifacts'; import { ARTIFACT_DELETE_ACTION_LABELS, - useArtifactDeleteItem, -} from '../hooks/use_artifact_delete_item'; + useWithArtifactDeleteItem, +} from '../hooks/use_with_artifact_delete_item'; import { ExceptionsListApiClient } from '../../../services/exceptions_list/exceptions_list_api_client'; export const ARTIFACT_DELETE_LABELS = Object.freeze({ @@ -90,7 +90,11 @@ export const ArtifactDeleteModal = memo( ({ apiClient, item, onCancel, onSuccess, 'data-test-subj': dataTestSubj, labels }) => { const getTestId = useTestIdGenerator(dataTestSubj); - const { deleteArtifactItem, isLoading: isDeleting } = useArtifactDeleteItem(apiClient, labels); + const { deleteArtifactItem, isLoading: isDeleting } = useWithArtifactDeleteItem( + apiClient, + item, + labels + ); const onConfirm = useCallback(() => { deleteArtifactItem(item).then(() => onSuccess()); @@ -103,7 +107,7 @@ export const ArtifactDeleteModal = memo( }, [isDeleting, onCancel]); return ( - + {labels.deleteModalTitle(item.name)} @@ -139,6 +143,7 @@ export const ArtifactDeleteModal = memo( color="danger" onClick={onConfirm} isLoading={isDeleting} + isDisabled={isDeleting} data-test-subj={getTestId('submitButton')} > {labels.deleteModalSubmitButtonTitle} diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_flyout.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_flyout.tsx index 483695de73824..63759df8d42cd 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_flyout.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/components/artifact_flyout.tsx @@ -21,11 +21,11 @@ import { EuiTitle, } from '@elastic/eui'; import { EuiFlyoutSize } from '@elastic/eui/src/components/flyout/flyout'; -import { useUrlParams } from '../hooks/use_url_params'; +import { HttpFetchError } from 'kibana/public'; +import { useUrlParams } from '../../hooks/use_url_params'; import { useIsFlyoutOpened } from '../hooks/use_is_flyout_opened'; import { useTestIdGenerator } from '../../hooks/use_test_id_generator'; import { useSetUrlParams } from '../hooks/use_set_url_params'; -import { useArtifactGetItem } from '../hooks/use_artifact_get_item'; import { ArtifactFormComponentOnChangeCallbackProps, ArtifactFormComponentProps, @@ -37,6 +37,8 @@ import { useToasts } from '../../../../common/lib/kibana'; import { createExceptionListItemForCreate } from '../../../../../common/endpoint/service/artifacts/utils'; import { useWithArtifactSubmitData } from '../hooks/use_with_artifact_submit_data'; import { useIsArtifactAllowedPerPolicyUsage } from '../hooks/use_is_artifact_allowed_per_policy_usage'; +import { useIsMounted } from '../../hooks/use_is_mounted'; +import { useGetArtifact } from '../../../hooks/artifacts'; export const ARTIFACT_FLYOUT_LABELS = Object.freeze({ flyoutEditTitle: i18n.translate('xpack.securitySolution.artifactListPage.flyoutEditTitle', { @@ -98,7 +100,7 @@ export const ARTIFACT_FLYOUT_LABELS = Object.freeze({ defaultMessage: 'For more information, see our documentation.', }), - flyoutEditItemLoadFailure: (errorMessage: string) => + flyoutEditItemLoadFailure: (errorMessage: string): string => i18n.translate('xpack.securitySolution.artifactListPage.flyoutEditItemLoadFailure', { defaultMessage: 'Failed to retrieve item for edit. Reason: {errorMessage}', values: { errorMessage }, @@ -113,9 +115,9 @@ export const ARTIFACT_FLYOUT_LABELS = Object.freeze({ * values: { name }, * }) */ - flyoutCreateSubmitSuccess: ({ name }: ExceptionListItemSchema) => + flyoutCreateSubmitSuccess: ({ name }: ExceptionListItemSchema): string => i18n.translate('xpack.securitySolution.some_page.flyoutCreateSubmitSuccess', { - defaultMessage: '"{name}" has been added to your event filters.', + defaultMessage: '"{name}" has been added.', values: { name }, }), @@ -129,7 +131,7 @@ export const ARTIFACT_FLYOUT_LABELS = Object.freeze({ * values: { name }, * }) */ - flyoutEditSubmitSuccess: ({ name }: ExceptionListItemSchema) => + flyoutEditSubmitSuccess: ({ name }: ExceptionListItemSchema): string => i18n.translate('xpack.securitySolution.artifactListPage.flyoutEditSubmitSuccess', { defaultMessage: '"{name}" has been updated.', values: { name }, @@ -150,6 +152,11 @@ export interface ArtifactFlyoutProps { apiClient: ExceptionsListApiClient; FormComponent: React.ComponentType; onSuccess(): void; + onClose(): void; + submitHandler?: ( + item: ArtifactFormComponentOnChangeCallbackProps['item'], + mode: ArtifactFormComponentProps['mode'] + ) => Promise; /** * If the artifact data is provided and it matches the id in the URL, then it will not be * retrieved again via the API @@ -164,12 +171,14 @@ export interface ArtifactFlyoutProps { /** * Show the flyout based on URL params */ -export const MaybeArtifactFlyout = memo( +export const ArtifactFlyout = memo( ({ apiClient, item, FormComponent, onSuccess, + onClose, + submitHandler, labels: _labels = {}, 'data-test-subj': dataTestSubj, size = 'm', @@ -179,27 +188,45 @@ export const MaybeArtifactFlyout = memo( const isFlyoutOpened = useIsFlyoutOpened(); const setUrlParams = useSetUrlParams(); const { urlParams } = useUrlParams(); + const isMounted = useIsMounted(); const labels = useMemo(() => { return { ...ARTIFACT_FLYOUT_LABELS, ..._labels, }; }, [_labels]); + // TODO:PT Refactor internal/external state into the `useEithArtifactSucmitData()` hook + const [externalIsSubmittingData, setExternalIsSubmittingData] = useState(false); + const [externalSubmitHandlerError, setExternalSubmitHandlerError] = useState< + HttpFetchError | undefined + >(undefined); const isEditFlow = urlParams.show === 'edit'; const formMode: ArtifactFormComponentProps['mode'] = isEditFlow ? 'edit' : 'create'; const { - isLoading: isSubmittingData, + isLoading: internalIsSubmittingData, mutateAsync: submitData, - error: submitError, + error: internalSubmitError, } = useWithArtifactSubmitData(apiClient, formMode); + const isSubmittingData = useMemo(() => { + return submitHandler ? externalIsSubmittingData : internalIsSubmittingData; + }, [externalIsSubmittingData, internalIsSubmittingData, submitHandler]); + + const submitError = useMemo(() => { + return submitHandler ? externalSubmitHandlerError : internalSubmitError; + }, [externalSubmitHandlerError, internalSubmitError, submitHandler]); + const { isLoading: isLoadingItemForEdit, error, refetch: fetchItemForEdit, - } = useArtifactGetItem(apiClient, urlParams.itemId ?? '', false); + } = useGetArtifact(apiClient, urlParams.itemId ?? '', undefined, { + // We don't want to run this at soon as the component is rendered. `refetch` is called + // a little later if determined we're in `edit` mode + enabled: false, + }); const [formState, setFormState] = useState( createFormInitialState.bind(null, apiClient.listId, item) @@ -225,39 +252,69 @@ export const MaybeArtifactFlyout = memo( } // `undefined` will cause params to be dropped from url - setUrlParams({ id: undefined, show: undefined }, true); - }, [isSubmittingData, setUrlParams]); + setUrlParams({ itemId: undefined, show: undefined }, true); + + onClose(); + }, [isSubmittingData, onClose, setUrlParams]); const handleFormComponentOnChange: ArtifactFormComponentProps['onChange'] = useCallback( ({ item: updatedItem, isValid }) => { - setFormState({ - item: updatedItem, - isValid, - }); + if (isMounted) { + setFormState({ + item: updatedItem, + isValid, + }); + } }, - [] + [isMounted] ); - const handleSubmitClick = useCallback(() => { - submitData(formState.item).then((result) => { + const handleSuccess = useCallback( + (result: ExceptionListItemSchema) => { toasts.addSuccess( isEditFlow ? labels.flyoutEditSubmitSuccess(result) : labels.flyoutCreateSubmitSuccess(result) ); - // Close the flyout - // `undefined` will cause params to be dropped from url - setUrlParams({ id: undefined, show: undefined }, true); - }); - }, [formState.item, isEditFlow, labels, setUrlParams, submitData, toasts]); + if (isMounted) { + // Close the flyout + // `undefined` will cause params to be dropped from url + setUrlParams({ itemId: undefined, show: undefined }, true); + + onSuccess(); + } + }, + [isEditFlow, isMounted, labels, onSuccess, setUrlParams, toasts] + ); + + const handleSubmitClick = useCallback(() => { + if (submitHandler) { + setExternalIsSubmittingData(true); + + submitHandler(formState.item, formMode) + .then(handleSuccess) + .catch((submitHandlerError) => { + if (isMounted) { + setExternalSubmitHandlerError(submitHandlerError); + } + }) + .finally(() => { + if (isMounted) { + setExternalIsSubmittingData(false); + } + }); + } else { + submitData(formState.item).then(handleSuccess); + } + }, [formMode, formState.item, handleSuccess, isMounted, submitData, submitHandler]); // If we don't have the actual Artifact data yet for edit (in initialization phase - ex. came in with an // ID in the url that was not in the list), then retrieve it now useEffect(() => { if (isEditFlow && !hasItemDataForEdit && !error && isInitializing && !isLoadingItemForEdit) { fetchItemForEdit().then(({ data: editItemData }) => { - if (editItemData) { + if (editItemData && isMounted) { setFormState(createFormInitialState(apiClient.listId, editItemData)); } }); @@ -270,6 +327,7 @@ export const MaybeArtifactFlyout = memo( isInitializing, isLoadingItemForEdit, hasItemDataForEdit, + isMounted, ]); // If we got an error while trying ot retrieve the item for edit, then show a toast message @@ -278,7 +336,7 @@ export const MaybeArtifactFlyout = memo( toasts.addWarning(labels.flyoutEditItemLoadFailure(error?.body?.message || error.message)); // Blank out the url params for id and show (will close out the flyout) - setUrlParams({ id: undefined, show: undefined }); + setUrlParams({ itemId: undefined, show: undefined }); } }, [error, isEditFlow, labels, setUrlParams, toasts, urlParams.itemId]); @@ -306,7 +364,7 @@ export const MaybeArtifactFlyout = memo( )} - {isInitializing && } + {isInitializing && } {!isInitializing && ( ( ); } ); -MaybeArtifactFlyout.displayName = 'MaybeArtifactFlyout'; +ArtifactFlyout.displayName = 'ArtifactFlyout'; diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_artifact_create_item.ts b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_artifact_create_item.ts deleted file mode 100644 index 4252d66f2a510..0000000000000 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_artifact_create_item.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import { - CreateExceptionListItemSchema, - ExceptionListItemSchema, -} from '@kbn/securitysolution-io-ts-list-types'; -import { useMutation } from 'react-query'; -import { HttpFetchError } from 'kibana/public'; -import { ExceptionsListApiClient } from '../../../services/exceptions_list/exceptions_list_api_client'; - -// FIXME: delete entire file once PR# 125198 is merged. This entire file was copied from that pr - -export interface CallbackTypes { - onSuccess?: (updatedException: ExceptionListItemSchema) => void; - onError?: (error?: HttpFetchError) => void; - onSettled?: () => void; -} - -export function useCreateArtifact( - exceptionListApiClient: ExceptionsListApiClient, - callbacks: CallbackTypes = {} -) { - const { onSuccess = () => {}, onError = () => {}, onSettled = () => {} } = callbacks; - - return useMutation< - ExceptionListItemSchema, - HttpFetchError, - CreateExceptionListItemSchema, - () => void - >( - async (exception: CreateExceptionListItemSchema) => { - return exceptionListApiClient.create(exception); - }, - { - onSuccess, - onError, - onSettled: () => { - onSettled(); - }, - } - ); -} diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_artifact_get_item.ts b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_artifact_get_item.ts deleted file mode 100644 index 21b13aa285376..0000000000000 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_artifact_get_item.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { useQuery } from 'react-query'; -import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; -import { HttpFetchError } from 'kibana/public'; -import { ExceptionsListApiClient } from '../../../services/exceptions_list/exceptions_list_api_client'; - -export const useArtifactGetItem = ( - apiClient: ExceptionsListApiClient, - itemId: string, - enabled: boolean = true -) => { - return useQuery( - ['item', apiClient, itemId], - () => apiClient.get(itemId), - { - enabled, - refetchOnWindowFocus: false, - keepPreviousData: true, - retry: false, - } - ); -}; diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_artifact_update_item.ts b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_artifact_update_item.ts deleted file mode 100644 index a217da0159ed8..0000000000000 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_artifact_update_item.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import { - UpdateExceptionListItemSchema, - ExceptionListItemSchema, -} from '@kbn/securitysolution-io-ts-list-types'; -import { useQueryClient, useMutation } from 'react-query'; -import { HttpFetchError } from 'kibana/public'; -import { ExceptionsListApiClient } from '../../../services/exceptions_list/exceptions_list_api_client'; - -// FIXME: delete entire file once PR# 125198 is merged. This entire file was copied from that pr - -export interface CallbackTypes { - onSuccess?: (updatedException: ExceptionListItemSchema) => void; - onError?: (error?: HttpFetchError) => void; - onSettled?: () => void; -} - -export function useUpdateArtifact( - exceptionListApiClient: ExceptionsListApiClient, - callbacks: CallbackTypes = {} -) { - const queryClient = useQueryClient(); - const { onSuccess = () => {}, onError = () => {}, onSettled = () => {} } = callbacks; - - return useMutation< - ExceptionListItemSchema, - HttpFetchError, - UpdateExceptionListItemSchema, - () => void - >( - async (exception: UpdateExceptionListItemSchema) => { - return exceptionListApiClient.update(exception); - }, - { - onSuccess, - onError, - onSettled: () => { - queryClient.invalidateQueries(['list', exceptionListApiClient]); - queryClient.invalidateQueries(['get', exceptionListApiClient]); - onSettled(); - }, - } - ); -} diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_is_flyout_opened.ts b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_is_flyout_opened.ts index dc53a58924e83..d6e66dd972525 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_is_flyout_opened.ts +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_is_flyout_opened.ts @@ -6,7 +6,7 @@ */ import { useMemo } from 'react'; -import { useUrlParams } from './use_url_params'; +import { useUrlParams } from '../../hooks/use_url_params'; import { ArtifactListPageUrlParams } from '../types'; const SHOW_VALUES: readonly string[] = ['create', 'edit']; diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_set_url_params.ts b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_set_url_params.ts index 80ffdeb253946..aa157f2db0535 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_set_url_params.ts +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_set_url_params.ts @@ -8,9 +8,9 @@ import { useHistory, useLocation } from 'react-router-dom'; import { useCallback } from 'react'; import { pickBy } from 'lodash'; -import { useUrlParams } from './use_url_params'; +import { useUrlParams } from '../../hooks/use_url_params'; -// FIXME:PT delete/change once we get the common hook from @parkiino PR +// FIXME:PT Refactor into a more generic hooks for managing url params export const useSetUrlParams = (): (( /** Any param whose value is `undefined` will be removed from the URl when in append mode */ params: Record, diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_url_params.ts b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_url_params.ts deleted file mode 100644 index 7e1b8d16b3771..0000000000000 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_url_params.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { useMemo } from 'react'; -import { useLocation } from 'react-router-dom'; -import { parse, stringify } from 'query-string'; - -// FIXME:PT delete and use common hook once @parkiino merges -export function useUrlParams>(): { - urlParams: T; - toUrlParams: (params?: T) => string; -} { - const { search } = useLocation(); - return useMemo(() => { - const urlParams = parse(search) as unknown as T; - return { - urlParams, - toUrlParams: (params: T = urlParams) => stringify(params as unknown as object), - }; - }, [search]); -} diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_artifact_delete_item.ts b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_with_artifact_delete_item.ts similarity index 68% rename from x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_artifact_delete_item.ts rename to x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_with_artifact_delete_item.ts index feac0c2b0c599..df47472861a59 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_artifact_delete_item.ts +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_with_artifact_delete_item.ts @@ -6,12 +6,12 @@ */ import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; -import { useMutation, UseMutationResult } from 'react-query'; import { i18n } from '@kbn/i18n'; import { useMemo } from 'react'; import type { HttpFetchError } from 'kibana/public'; import { useToasts } from '../../../../common/lib/kibana'; import { ExceptionsListApiClient } from '../../../services/exceptions_list/exceptions_list_api_client'; +import { useDeleteArtifact } from '../../../hooks/artifacts'; export const ARTIFACT_DELETE_ACTION_LABELS = Object.freeze({ /** @@ -26,9 +26,9 @@ export const ARTIFACT_DELETE_ACTION_LABELS = Object.freeze({ * values: { itemName, errorMessage }, * }) */ - deleteActionFailure: (itemName: string, errorMessage: string) => + deleteActionFailure: (itemName: string, errorMessage: string): string => i18n.translate('xpack.securitySolution.artifactListPage.deleteActionFailure', { - defaultMessage: 'Unable to remove "{itemName}" . Reason: {errorMessage}', + defaultMessage: 'Unable to remove "{itemName}". Reason: {errorMessage}', values: { itemName, errorMessage }, }), @@ -41,49 +41,38 @@ export const ARTIFACT_DELETE_ACTION_LABELS = Object.freeze({ * values: { itemName }, * }) */ - deleteActionSuccess: (itemName: string) => + deleteActionSuccess: (itemName: string): string => i18n.translate('xpack.securitySolution.artifactListPage.deleteActionSuccess', { defaultMessage: '"{itemName}" has been removed', values: { itemName }, }), }); -type UseArtifactDeleteItemMutationResult = UseMutationResult< - ExceptionListItemSchema, - HttpFetchError, - ExceptionListItemSchema ->; +type UseArtifactDeleteItemMutationResult = ReturnType; export type UseArtifactDeleteItemInterface = UseArtifactDeleteItemMutationResult & { deleteArtifactItem: UseArtifactDeleteItemMutationResult['mutateAsync']; }; -export const useArtifactDeleteItem = ( +export const useWithArtifactDeleteItem = ( apiClient: ExceptionsListApiClient, + item: ExceptionListItemSchema, labels: typeof ARTIFACT_DELETE_ACTION_LABELS ): UseArtifactDeleteItemInterface => { const toasts = useToasts(); - - const mutation = useMutation( - async (item: ExceptionListItemSchema) => { - return apiClient.delete(item.item_id); + const deleteArtifact = useDeleteArtifact(apiClient, { + onError: (error: HttpFetchError) => { + toasts.addDanger(labels.deleteActionFailure(item.name, error.body?.message || error.message)); + }, + onSuccess: (response) => { + toasts.addSuccess(labels.deleteActionSuccess(response.name)); }, - { - onError: (error: HttpFetchError, item) => { - toasts.addDanger( - labels.deleteActionFailure(item.name, error.body?.message || error.message) - ); - }, - onSuccess: (response) => { - toasts.addSuccess(labels.deleteActionSuccess(response.name)); - }, - } - ); + }); return useMemo(() => { return { - ...mutation, - deleteArtifactItem: mutation.mutateAsync, + ...deleteArtifact, + deleteArtifactItem: deleteArtifact.mutateAsync, }; - }, [mutation]); + }, [deleteArtifact]); }; diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_with_artifact_list_data.ts b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_with_artifact_list_data.ts index 3eca6c60bc711..b1742c761ba49 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_with_artifact_list_data.ts +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_with_artifact_list_data.ts @@ -5,8 +5,6 @@ * 2.0. */ -import type { QueryObserverResult } from 'react-query'; -import type { FoundExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; import { useEffect, useMemo, useState } from 'react'; import { Pagination } from '@elastic/eui'; import { useQuery } from 'react-query'; @@ -16,16 +14,14 @@ import { MANAGEMENT_DEFAULT_PAGE_SIZE, MANAGEMENT_PAGE_SIZE_OPTIONS, } from '../../../common/constants'; -import { useUrlParams } from './use_url_params'; +import { useUrlParams } from '../../hooks/use_url_params'; import { ExceptionsListApiClient } from '../../../services/exceptions_list/exceptions_list_api_client'; import { ArtifactListPageUrlParams } from '../types'; import { MaybeImmutable } from '../../../../../common/endpoint/types'; import { useKueryFromExceptionsSearchFilter } from './use_kuery_from_exceptions_search_filter'; +import { useListArtifact } from '../../../hooks/artifacts'; -type WithArtifactListDataInterface = QueryObserverResult< - FoundExceptionListItemSchema, - ServerApiError -> & { +type WithArtifactListDataInterface = ReturnType & { /** * Set to true during initialization of the page until it can be determined if either data exists. * This should drive the showing of the overall page loading state if set to `true` @@ -50,16 +46,10 @@ export const useWithArtifactListData = ( const isMounted = useIsMounted(); const { - urlParams: { - page = 1, - pageSize = MANAGEMENT_DEFAULT_PAGE_SIZE, - sortOrder, - sortField, - filter, - includedPolicies, - }, + urlParams: { page = 1, pageSize = MANAGEMENT_DEFAULT_PAGE_SIZE, filter, includedPolicies }, } = useUrlParams(); + // Used to determine if the `does data exist` check should be done. const kuery = useKueryFromExceptionsSearchFilter(filter, searchableFields, includedPolicies); const { @@ -85,14 +75,15 @@ export const useWithArtifactListData = ( const [isPageInitializing, setIsPageInitializing] = useState(true); - const listDataRequest = useQuery( - ['list', apiClient, page, pageSize, sortField, sortField, kuery], - async () => apiClient.find({ page, perPage: pageSize, filter: kuery, sortField, sortOrder }), + const listDataRequest = useListArtifact( + apiClient, { - enabled: true, - keepPreviousData: true, - refetchOnWindowFocus: false, - } + page, + perPage: pageSize, + filter, + policies: includedPolicies ? includedPolicies.split(',') : [], + }, + searchableFields ); const { @@ -106,7 +97,7 @@ export const useWithArtifactListData = ( // This should only ever happen at most once; useEffect(() => { if (isMounted) { - if (isPageInitializing === true && !isLoadingDataExists) { + if (isPageInitializing && !isLoadingDataExists) { setIsPageInitializing(false); } } @@ -128,21 +119,30 @@ export const useWithArtifactListData = ( // Keep the `doesDataExist` updated if we detect that list data result total is zero. // Anytime: - // 1. the list data total is 0 - // 2. and page is 1 - // 3. and filter is empty - // 4. and doesDataExists is currently set to true - // check if data exists again + // 1. the list data total is 0 + // 2. and page is 1 + // 3. and filter is empty + // 4. and doesDataExists is `true` + // >> check if data exists again + // OR, Anytime: + // 1. `doesDataExists` is `false`, + // 2. and page is 1 + // 3. and filter is empty + // 4. the list data total is > 0 + // >> Check if data exists again (which should return true useEffect(() => { if ( isMounted && !isLoadingListData && + !isLoadingDataExists && !listDataError && - listData && - listData.total === 0 && String(page) === '1' && !kuery && - doesDataExist + // flow when there the last item on the list gets deleted, + // and list goes back to being empty + ((listData && listData.total === 0 && doesDataExist) || + // Flow when the list starts off empty and the first item is added + (listData && listData.total > 0 && !doesDataExist)) ) { checkIfDataExists(); } @@ -151,6 +151,7 @@ export const useWithArtifactListData = ( doesDataExist, filter, includedPolicies, + isLoadingDataExists, isLoadingListData, isMounted, kuery, diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_with_artifact_submit_data.ts b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_with_artifact_submit_data.ts index 59a2739c9d3af..89812e9b53ba5 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_with_artifact_submit_data.ts +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/hooks/use_with_artifact_submit_data.ts @@ -7,8 +7,7 @@ import { ExceptionsListApiClient } from '../../../services/exceptions_list/exceptions_list_api_client'; import { ArtifactFormComponentProps } from '../types'; -import { useUpdateArtifact } from './use_artifact_update_item'; -import { useCreateArtifact } from './use_artifact_create_item'; +import { useCreateArtifact, useUpdateArtifact } from '../../../hooks/artifacts'; export const useWithArtifactSubmitData = ( apiClient: ExceptionsListApiClient, diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/index.ts b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/index.ts index ba26a44259021..db5c03a48ff2a 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/index.ts +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/index.ts @@ -6,5 +6,8 @@ */ export { ArtifactListPage } from './artifact_list_page'; -export type { ArtifactListPageProps } from './artifact_list_page'; export * from './types'; +export { artifactListPageLabels } from './translations'; + +export type { ArtifactListPageProps } from './artifact_list_page'; +export type { ArtifactListPageLabels } from './translations'; diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/translations.ts b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/translations.ts index ba6acf8a359aa..c72b1a7af4105 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/translations.ts +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/translations.ts @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import { ARTIFACT_FLYOUT_LABELS } from './components/artifact_flyout'; import { ARTIFACT_DELETE_LABELS } from './components/artifact_delete_modal'; -import { ARTIFACT_DELETE_ACTION_LABELS } from './hooks/use_artifact_delete_item'; +import { ARTIFACT_DELETE_ACTION_LABELS } from './hooks/use_with_artifact_delete_item'; export const artifactListPageLabels = Object.freeze({ // ------------------------------ @@ -57,7 +57,7 @@ export const artifactListPageLabels = Object.freeze({ * values: { total }, * }) */ - getShowingCountLabel: (total: number) => { + getShowingCountLabel: (total: number): string => { return i18n.translate('xpack.securitySolution.artifactListPage.showingTotal', { defaultMessage: 'Showing {total, plural, one {# artifact} other {# artifacts}}', values: { total }, diff --git a/x-pack/plugins/security_solution/public/management/components/hooks/use_is_mounted.ts b/x-pack/plugins/security_solution/public/management/components/hooks/use_is_mounted.ts index c3ab4472cf429..0c5a79b2ca2fc 100644 --- a/x-pack/plugins/security_solution/public/management/components/hooks/use_is_mounted.ts +++ b/x-pack/plugins/security_solution/public/management/components/hooks/use_is_mounted.ts @@ -5,22 +5,22 @@ * 2.0. */ -import { useEffect, useRef } from 'react'; +import { useEffect, useState } from 'react'; /** - * Track when a comonent is mounted/unmounted. Good for use in async processing that may update + * Track when a component is mounted/unmounted. Good for use in async processing that may update * a component's internal state. */ export const useIsMounted = (): boolean => { - const isMounted = useRef(false); + const [isMounted, setIsMounted] = useState(false); useEffect(() => { - isMounted.current = true; + setIsMounted(true); return () => { - isMounted.current = false; + setIsMounted(false); }; }, []); - return isMounted.current; + return isMounted; }; diff --git a/x-pack/plugins/security_solution/public/management/components/hooks/use_url_params.ts b/x-pack/plugins/security_solution/public/management/components/hooks/use_url_params.ts index d5dc2a0005886..865b71781df63 100644 --- a/x-pack/plugins/security_solution/public/management/components/hooks/use_url_params.ts +++ b/x-pack/plugins/security_solution/public/management/components/hooks/use_url_params.ts @@ -6,7 +6,7 @@ */ import { useMemo } from 'react'; -import { parse, stringify, ParsedQuery } from 'query-string'; +import { parse, stringify } from 'query-string'; import { useLocation } from 'react-router-dom'; /** @@ -15,16 +15,16 @@ import { useLocation } from 'react-router-dom'; * `urlParams` that was parsed) for use in the url. * Object will be recreated every time `search` changes. */ -export function useUrlParams(): { - urlParams: ParsedQuery; - toUrlParams: (params: ParsedQuery) => string; +export function useUrlParams>(): { + urlParams: T; + toUrlParams: (params?: T) => string; } { const { search } = useLocation(); return useMemo(() => { - const urlParams = parse(search); + const urlParams = parse(search) as unknown as T; return { urlParams, - toUrlParams: (params = urlParams) => stringify(params), + toUrlParams: (params: T = urlParams) => stringify(params as unknown as object), }; }, [search]); } diff --git a/x-pack/plugins/security_solution/public/management/components/paginated_content/paginated_content.tsx b/x-pack/plugins/security_solution/public/management/components/paginated_content/paginated_content.tsx index c29eee5221043..33905265ef4da 100644 --- a/x-pack/plugins/security_solution/public/management/components/paginated_content/paginated_content.tsx +++ b/x-pack/plugins/security_solution/public/management/components/paginated_content/paginated_content.tsx @@ -93,18 +93,21 @@ const RootContainer = styled.div` } `; -const DefaultNoItemsFound = memo(() => { - return ( - - } - /> - ); -}); +const DefaultNoItemsFound = memo<{ 'data-test-subj'?: string }>( + ({ 'data-test-subj': dataTestSubj }) => { + return ( + + } + /> + ); + } +); DefaultNoItemsFound.displayName = 'DefaultNoItemsFound'; @@ -227,7 +230,8 @@ export const PaginatedContent = memo( return ; }); } - if (!loading) return noItemsMessage || ; + if (!loading) + return noItemsMessage || ; }, [ ItemComponent, error, diff --git a/x-pack/plugins/security_solution/public/management/components/search_exceptions/search_exceptions.tsx b/x-pack/plugins/security_solution/public/management/components/search_exceptions/search_exceptions.tsx index 7a7a28b4b0647..695b1f18ef317 100644 --- a/x-pack/plugins/security_solution/public/management/components/search_exceptions/search_exceptions.tsx +++ b/x-pack/plugins/security_solution/public/management/components/search_exceptions/search_exceptions.tsx @@ -102,8 +102,8 @@ export const SearchExceptions = memo( ) : null} {!hideRefreshButton ? ( - - + + {i18n.translate('xpack.securitySolution.management.search.button', { defaultMessage: 'Refresh', })} diff --git a/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_list_artifact.tsx b/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_list_artifact.tsx index 32aa0b26daa1d..1e0c4b3c55b8d 100644 --- a/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_list_artifact.tsx +++ b/x-pack/plugins/security_solution/public/management/hooks/artifacts/use_list_artifact.tsx @@ -7,49 +7,55 @@ import { FoundExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; import { HttpFetchError } from 'kibana/public'; import { QueryObserverResult, useQuery, UseQueryOptions } from 'react-query'; -import { DEFAULT_EXCEPTION_LIST_ITEM_SEARCHABLE_FIELDS } from '../../../../common/endpoint/service/artifacts/constants'; -import { MaybeImmutable } from '../../../../common/endpoint/types'; +import { useMemo } from 'react'; import { MANAGEMENT_DEFAULT_PAGE, MANAGEMENT_DEFAULT_PAGE_SIZE } from '../../common/constants'; import { parsePoliciesAndFilterToKql, parseQueryFilterToKQL } from '../../common/utils'; import { ExceptionsListApiClient } from '../../services/exceptions_list/exceptions_list_api_client'; +import { DEFAULT_EXCEPTION_LIST_ITEM_SEARCHABLE_FIELDS } from '../../../../common/endpoint/service/artifacts/constants'; +import { MaybeImmutable } from '../../../../common/endpoint/types'; const DEFAULT_OPTIONS = Object.freeze({}); export function useListArtifact( exceptionListApiClient: ExceptionsListApiClient, options: Partial<{ - filter?: string; - page?: number; - perPage?: number; - policies?: string[]; - excludedPolicies?: string[]; - }> = { - filter: '', - page: MANAGEMENT_DEFAULT_PAGE + 1, - perPage: MANAGEMENT_DEFAULT_PAGE_SIZE, - policies: [], - excludedPolicies: [], - }, + filter: string; + page: number; + perPage: number; + policies: string[]; + excludedPolicies: string[]; + }> = DEFAULT_OPTIONS, searchableFields: MaybeImmutable = DEFAULT_EXCEPTION_LIST_ITEM_SEARCHABLE_FIELDS, customQueryOptions: Partial< UseQueryOptions > = DEFAULT_OPTIONS, customQueryIds: string[] = [] ): QueryObserverResult { - const { filter, page, perPage, policies, excludedPolicies } = options; + const { + filter = '', + page = MANAGEMENT_DEFAULT_PAGE + 1, + perPage = MANAGEMENT_DEFAULT_PAGE_SIZE, + policies = [], + excludedPolicies = [], + } = options; + const filterKuery = useMemo(() => { + return parsePoliciesAndFilterToKql({ + kuery: parseQueryFilterToKQL(filter, searchableFields), + policies, + excludedPolicies, + }); + }, [filter, searchableFields, policies, excludedPolicies]); return useQuery( - [...customQueryIds, 'list', exceptionListApiClient, options], - () => { - return exceptionListApiClient.find({ - filter: parsePoliciesAndFilterToKql({ - policies, - excludedPolicies, - kuery: parseQueryFilterToKQL(filter, searchableFields), - }), + [...customQueryIds, 'list', exceptionListApiClient, filterKuery, page, perPage], + async () => { + const result = await exceptionListApiClient.find({ + filter: filterKuery, perPage, page, }); + + return result; }, { refetchIntervalInBackground: false, diff --git a/x-pack/plugins/security_solution/public/management/pages/mocks/trusted_apps_http_mocks.ts b/x-pack/plugins/security_solution/public/management/pages/mocks/trusted_apps_http_mocks.ts index c92dcc0bd7cc4..8e5f780bbab39 100644 --- a/x-pack/plugins/security_solution/public/management/pages/mocks/trusted_apps_http_mocks.ts +++ b/x-pack/plugins/security_solution/public/management/pages/mocks/trusted_apps_http_mocks.ts @@ -18,6 +18,7 @@ import { UpdateExceptionListItemSchema, ReadExceptionListItemSchema, CreateExceptionListItemSchema, + DeleteExceptionListItemSchema, ExceptionListSchema, } from '@kbn/securitysolution-io-ts-list-types'; import { @@ -68,15 +69,18 @@ export const trustedAppsGetListHttpMocks = }) ); - // FIXME: remove hard-coded IDs below adn get them from the new FleetPackagePolicyGenerator (#2262) + // If we have more than 2 items, then set policy ids on the per-policy trusted app + if (data.length > 2) { + // FIXME: remove hard-coded IDs below adn get them from the new FleetPackagePolicyGenerator (#2262) - // Change the 3rd entry (index 2) to be policy specific - data[2].tags = [ - // IDs below are those generated by the `fleetGetEndpointPackagePolicyListHttpMock()` mock, - // so if using in combination with that API mock, these should just "work" - `${BY_POLICY_ARTIFACT_TAG_PREFIX}ddf6570b-9175-4a6d-b288-61a09771c647`, - `${BY_POLICY_ARTIFACT_TAG_PREFIX}b8e616ae-44fc-4be7-846c-ce8fa5c082dd`, - ]; + // Change the 3rd entry (index 2) to be policy specific + data[2].tags = [ + // IDs below are those generated by the `fleetGetEndpointPackagePolicyListHttpMock()` mock, + // so if using in combination with that API mock, these should just "work" + `${BY_POLICY_ARTIFACT_TAG_PREFIX}ddf6570b-9175-4a6d-b288-61a09771c647`, + `${BY_POLICY_ARTIFACT_TAG_PREFIX}b8e616ae-44fc-4be7-846c-ce8fa5c082dd`, + ]; + } return { page: apiQueryParams.page ?? 1, @@ -125,7 +129,7 @@ export type TrustedAppsGetOneHttpMocksInterface = ResponseProvidersInterface<{ trustedApp: (options: HttpFetchOptionsWithPath) => ExceptionListItemSchema; }>; /** - * HTTP mock for retrieving list of Trusted Apps + * HTTP mock for retrieving one Trusted Apps */ export const trustedAppsGetOneHttpMocks = httpHandlerMockFactory([ @@ -149,11 +153,40 @@ export const trustedAppsGetOneHttpMocks = }, ]); +export type TrustedAppsDeleteOneHttpMocksInterface = ResponseProvidersInterface<{ + trustedAppDelete: (options: HttpFetchOptionsWithPath) => ExceptionListItemSchema; +}>; +/** + * HTTP mock for deleting one Trusted Apps + */ +export const trustedAppsDeleteOneHttpMocks = + httpHandlerMockFactory([ + { + id: 'trustedAppDelete', + path: EXCEPTION_LIST_ITEM_URL, + method: 'delete', + handler: ({ query }): ExceptionListItemSchema => { + const apiQueryParams = query as DeleteExceptionListItemSchema; + const exceptionItem = new ExceptionsListItemGenerator('seed').generate({ + os_types: ['windows'], + tags: [GLOBAL_ARTIFACT_TAG], + }); + + exceptionItem.item_id = apiQueryParams.item_id ?? exceptionItem.item_id; + exceptionItem.id = apiQueryParams.id ?? exceptionItem.id; + exceptionItem.namespace_type = + apiQueryParams.namespace_type ?? exceptionItem.namespace_type; + + return exceptionItem; + }, + }, + ]); + export type TrustedAppPostHttpMocksInterface = ResponseProvidersInterface<{ trustedAppCreate: (options: HttpFetchOptionsWithPath) => ExceptionListItemSchema; }>; /** - * HTTP mocks that support updating a single Trusted Apps + * HTTP mocks that support creating a single Trusted Apps */ export const trustedAppPostHttpMocks = httpHandlerMockFactory([ { @@ -201,6 +234,7 @@ export type TrustedAppsAllHttpMocksInterface = FleetGetEndpointPackagePolicyList TrustedAppsGetListHttpMocksInterface & TrustedAppsGetOneHttpMocksInterface & TrustedAppPutHttpMocksInterface & + TrustedAppsDeleteOneHttpMocksInterface & TrustedAppPostHttpMocksInterface & TrustedAppsPostCreateListHttpMockInterface; /** Use this HTTP mock when wanting to mock the API calls done by the Trusted Apps Http service */ @@ -210,6 +244,7 @@ export const trustedAppsAllHttpMocks = composeHttpHandlerMocks { const licenseServiceInstance = { @@ -46,11 +47,17 @@ describe('When on the Trusted Apps Page', () => { let coreStart: AppContextTestRender['coreStart']; let waitForAction: MiddlewareActionSpyHelper['waitForAction']; let render: () => ReturnType; + let renderResult: ReturnType; let mockedApis: ReturnType; + let getFakeTrustedApp = jest.fn(); const originalScrollTo = window.scrollTo; const act = reactTestingLibrary.act; - const getFakeTrustedApp = jest.fn(); + const waitForListUI = async (): Promise => { + await waitFor(() => { + expect(renderResult.getByTestId('trustedAppsListPageContent')).toBeTruthy(); + }); + }; beforeAll(() => { window.scrollTo = () => {}; @@ -62,7 +69,7 @@ describe('When on the Trusted Apps Page', () => { beforeEach(() => { mockedContext = createAppRootMockRenderer(); - getFakeTrustedApp.mockImplementation( + getFakeTrustedApp = jest.fn( (): TrustedApp => ({ id: '2d95bec3-b48f-4db7-9622-a2b061cc031d', version: 'abc123', @@ -90,7 +97,7 @@ describe('When on the Trusted Apps Page', () => { (licenseService.isPlatinumPlus as jest.Mock).mockReturnValue(true); waitForAction = mockedContext.middlewareSpy.waitForAction; mockedApis = trustedAppsAllHttpMocks(coreStart.http); - render = () => mockedContext.render(); + render = () => (renderResult = mockedContext.render()); reactTestingLibrary.act(() => { history.push('/administration/trusted_apps'); }); @@ -101,10 +108,11 @@ describe('When on the Trusted Apps Page', () => { describe('and there are trusted app entries', () => { const renderWithListData = async () => { - const renderResult = render(); + render(); await act(async () => { - await waitForAction('trustedAppsListResourceStateChanged'); + await waitForListUI(); }); + return renderResult; }; @@ -120,15 +128,13 @@ describe('When on the Trusted Apps Page', () => { }); it('should display the searchExceptions', async () => { - const renderResult = await renderWithListData(); + await renderWithListData(); expect(await renderResult.findByTestId('searchExceptions')).not.toBeNull(); }); describe('and the Grid view is being displayed', () => { - let renderResult: ReturnType; - const renderWithListDataAndClickOnEditCard = async () => { - renderResult = await renderWithListData(); + await renderWithListData(); await act(async () => { // The 3rd Trusted app to be rendered will be a policy specific one @@ -143,7 +149,7 @@ describe('When on the Trusted Apps Page', () => { const renderWithListDataAndClickAddButton = async (): Promise< ReturnType > => { - renderResult = await renderWithListData(); + await renderWithListData(); act(() => { const addButton = renderResult.getByTestId('trustedAppsListAddButton'); @@ -318,7 +324,7 @@ describe('When on the Trusted Apps Page', () => { } ); - renderResult = await renderWithListData(); + await renderWithListData(); await reactTestingLibrary.act(async () => { await apiResponseForEditTrustedApp; @@ -334,7 +340,7 @@ describe('When on the Trusted Apps Page', () => { }); it('should retrieve trusted app via API using url `id`', async () => { - renderResult = await renderAndWaitForGetApi(); + await renderAndWaitForGetApi(); expect(coreStart.http.get.mock.calls).toContainEqual([ EXCEPTION_LIST_ITEM_URL, @@ -389,7 +395,7 @@ describe('When on the Trusted Apps Page', () => { const renderAndClickAddButton = async (): Promise< ReturnType > => { - const renderResult = render(); + render(); await act(async () => { await Promise.all([ waitForAction('trustedAppsListResourceStateChanged'), @@ -457,7 +463,7 @@ describe('When on the Trusted Apps Page', () => { it('should have list of policies populated', async () => { const resetEnv = forceHTMLElementOffsetWidth(); - const renderResult = await renderAndClickAddButton(); + await renderAndClickAddButton(); act(() => { fireEvent.click(renderResult.getByTestId('perPolicy')); }); @@ -506,8 +512,7 @@ describe('When on the Trusted Apps Page', () => { }; it('should enable the Flyout Add button', async () => { - const renderResult = await renderAndClickAddButton(); - + await renderAndClickAddButton(); await fillInCreateForm(); const flyoutAddButton = renderResult.getByTestId( @@ -518,7 +523,6 @@ describe('When on the Trusted Apps Page', () => { }); describe('and the Flyout Add button is clicked', () => { - let renderResult: ReturnType; let releasePostCreateApi: () => void; beforeEach(async () => { @@ -530,7 +534,7 @@ describe('When on the Trusted Apps Page', () => { }) ); - renderResult = await renderAndClickAddButton(); + await renderAndClickAddButton(); await fillInCreateForm(); const userClickedSaveActionWatcher = waitForAction('trustedAppCreationDialogConfirmed'); @@ -668,7 +672,7 @@ describe('When on the Trusted Apps Page', () => { describe('and when the form data is not valid', () => { it('should not enable the Flyout Add button with an invalid hash', async () => { - const renderResult = await renderAndClickAddButton(); + await renderAndClickAddButton(); const { getByTestId } = renderResult; reactTestingLibrary.act(() => { @@ -726,12 +730,12 @@ describe('When on the Trusted Apps Page', () => { }); it('should show a loader until trusted apps existence can be confirmed', async () => { - const renderResult = render(); + render(); expect(await renderResult.findByTestId('trustedAppsListLoader')).not.toBeNull(); }); it('should show Empty Prompt if not entries exist', async () => { - const renderResult = render(); + render(); await act(async () => { await waitForAction('trustedAppsExistStateChanged'); }); @@ -739,7 +743,7 @@ describe('When on the Trusted Apps Page', () => { }); it('should hide empty prompt and show list after one trusted app is added', async () => { - const renderResult = render(); + render(); await act(async () => { await waitForAction('trustedAppsExistStateChanged'); }); @@ -781,7 +785,7 @@ describe('When on the Trusted Apps Page', () => { per_page: 1, }); - const renderResult = render(); + render(); await act(async () => { await waitForAction('trustedAppsExistStateChanged'); @@ -800,7 +804,7 @@ describe('When on the Trusted Apps Page', () => { }); it('should not display the searchExceptions', async () => { - const renderResult = render(); + render(); await act(async () => { await waitForAction('trustedAppsExistStateChanged'); }); @@ -809,14 +813,13 @@ describe('When on the Trusted Apps Page', () => { }); describe('and the search is dispatched', () => { - let renderResult: ReturnType; beforeEach(async () => { reactTestingLibrary.act(() => { history.push('/administration/trusted_apps?filter=test'); }); - renderResult = render(); + render(); await act(async () => { - await waitForAction('trustedAppsListResourceStateChanged'); + await waitForListUI(); }); }); @@ -833,11 +836,10 @@ describe('When on the Trusted Apps Page', () => { }); describe('and the back button is present', () => { - let renderResult: ReturnType; beforeEach(async () => { - renderResult = render(); + render(); await act(async () => { - await waitForAction('trustedAppsListResourceStateChanged'); + await waitForListUI(); }); reactTestingLibrary.act(() => { history.push('/administration/trusted_apps', { @@ -865,9 +867,8 @@ describe('When on the Trusted Apps Page', () => { }); describe('and the back button is not present', () => { - let renderResult: ReturnType; beforeEach(async () => { - renderResult = render(); + render(); await act(async () => { await waitForAction('trustedAppsListResourceStateChanged'); }); From faa3fc6b1c5627587c1078fe1f59d38d38753dc9 Mon Sep 17 00:00:00 2001 From: Bhavya RM Date: Mon, 14 Mar 2022 12:49:50 -0400 Subject: [PATCH 21/44] A11y tests for license management page (#127497) --- .../upload_license.test.tsx.snap | 10 +++++ .../sections/upload_license/upload_license.js | 5 ++- .../accessibility/apps/license_management.ts | 41 +++++++++++++++++++ x-pack/test/accessibility/config.ts | 1 + 4 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 x-pack/test/accessibility/apps/license_management.ts diff --git a/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap b/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap index db9647d03f8e2..18bc323b7685e 100644 --- a/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap +++ b/x-pack/plugins/license_management/__jest__/__snapshots__/upload_license.test.tsx.snap @@ -799,11 +799,13 @@ exports[`UploadLicense should display a modal when license requires acknowledgem className="euiFlexItem euiFlexItem--flexGrowZero" > - + { + before(async () => { + await PageObjects.common.navigateToApp('licenseManagement'); + }); + + it('License management page overview meets a11y requirements', async () => { + await a11y.testAppSnapshot(); + }); + + it('Update license panel meets a11y requirements', async () => { + await testSubjects.click('updateLicenseButton'); + await a11y.testAppSnapshot(); + }); + + it('Upload license error panel meets a11y requirements', async () => { + await testSubjects.click('uploadLicenseButton'); + await a11y.testAppSnapshot(); + }); + + it('Revert to basic license confirmation panel meets a11y requirements', async () => { + await testSubjects.click('cancelUploadButton'); + await testSubjects.click('revertToBasicButton'); + await a11y.testAppSnapshot(); + await testSubjects.click('confirmModalCancelButton'); + }); + }); +} diff --git a/x-pack/test/accessibility/config.ts b/x-pack/test/accessibility/config.ts index 933e8e97da397..e970f6d07b90a 100644 --- a/x-pack/test/accessibility/config.ts +++ b/x-pack/test/accessibility/config.ts @@ -40,6 +40,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { require.resolve('./apps/remote_clusters'), require.resolve('./apps/reporting'), require.resolve('./apps/enterprise_search'), + require.resolve('./apps/license_management'), ], pageObjects, From 865539befda028a0b9ccba02826eac0bf5ae74ed Mon Sep 17 00:00:00 2001 From: Nick Peihl Date: Mon, 14 Mar 2022 12:58:09 -0400 Subject: [PATCH 22/44] [Maps] Use Elastic Maps Service 8.1 (#126379) * Use Elastic Maps Service 8.1 * Update license override for ems-client --- package.json | 2 +- src/dev/license_checker/config.ts | 2 +- src/plugins/maps_ems/common/ems_defaults.ts | 2 +- yarn.lock | 8 ++++---- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 9c1a9df9f4446..b756d9cb05f1b 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ "@elastic/charts": "43.1.1", "@elastic/datemath": "link:bazel-bin/packages/elastic-datemath", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@8.2.0-canary.1", - "@elastic/ems-client": "8.0.0", + "@elastic/ems-client": "8.1.0", "@elastic/eui": "48.1.1", "@elastic/filesaver": "1.1.2", "@elastic/node-crypto": "1.2.1", diff --git a/src/dev/license_checker/config.ts b/src/dev/license_checker/config.ts index 38cc61387e92c..d8e9f9cc7e114 100644 --- a/src/dev/license_checker/config.ts +++ b/src/dev/license_checker/config.ts @@ -76,7 +76,7 @@ export const DEV_ONLY_LICENSE_ALLOWED = ['MPL-2.0']; export const LICENSE_OVERRIDES = { 'jsts@1.6.2': ['Eclipse Distribution License - v 1.0'], // cf. https://github.com/bjornharrtell/jsts '@mapbox/jsonlint-lines-primitives@2.0.2': ['MIT'], // license in readme https://github.com/tmcw/jsonlint - '@elastic/ems-client@8.0.0': ['Elastic License 2.0'], + '@elastic/ems-client@8.1.0': ['Elastic License 2.0'], '@elastic/eui@48.1.1': ['SSPL-1.0 OR Elastic License 2.0'], 'language-subtag-registry@0.3.21': ['CC-BY-4.0'], // retired ODC‑By license https://github.com/mattcg/language-subtag-registry }; diff --git a/src/plugins/maps_ems/common/ems_defaults.ts b/src/plugins/maps_ems/common/ems_defaults.ts index 6d2d97ded0fc2..7b964c10ab063 100644 --- a/src/plugins/maps_ems/common/ems_defaults.ts +++ b/src/plugins/maps_ems/common/ems_defaults.ts @@ -8,7 +8,7 @@ export const DEFAULT_EMS_FILE_API_URL = 'https://vector.maps.elastic.co'; export const DEFAULT_EMS_TILE_API_URL = 'https://tiles.maps.elastic.co'; -export const DEFAULT_EMS_LANDING_PAGE_URL = 'https://maps.elastic.co/v8.0'; +export const DEFAULT_EMS_LANDING_PAGE_URL = 'https://maps.elastic.co/v8.1'; export const DEFAULT_EMS_FONT_LIBRARY_URL = 'https://tiles.maps.elastic.co/fonts/{fontstack}/{range}.pbf'; diff --git a/yarn.lock b/yarn.lock index 1b2c7cdcf2f4d..c707e0217ac36 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1482,10 +1482,10 @@ "@elastic/transport" "^8.0.2" tslib "^2.3.0" -"@elastic/ems-client@8.0.0": - version "8.0.0" - resolved "https://registry.yarnpkg.com/@elastic/ems-client/-/ems-client-8.0.0.tgz#94f682298f39f19d14a1eca927a22508029671e1" - integrity sha512-0nIEu+PHkWmTZUI27J/6BCPyY7bsmNTbDRn9EHPyciWq487G7TWoocoZog/mj1DoP2bo/ZxA8dpTKf6bJpy2Rg== +"@elastic/ems-client@8.1.0": + version "8.1.0" + resolved "https://registry.yarnpkg.com/@elastic/ems-client/-/ems-client-8.1.0.tgz#e33ec651314a9ceb9a8a7f3e4c6e205c39f20efb" + integrity sha512-u7Y8EakPk07nqRYqRxYTTLOIMb8Y+u7UM+2BGaw10jYVxdQ85sA4oi37GJJPJVn7jk/x9R7yTQ6Mpc3FbPGoRg== dependencies: "@types/geojson" "^7946.0.7" "@types/lru-cache" "^5.1.0" From 9d8f95f39048002fb216c52e7fc5ae5c657eac9b Mon Sep 17 00:00:00 2001 From: Rachel Shen Date: Mon, 14 Mar 2022 11:26:15 -0600 Subject: [PATCH 23/44] [shared-ux] Migrate redirect app links component (#124197) --- .../src/api_docs/tests/snapshots/plugin_a.mdx | 2 +- .../api_docs/tests/snapshots/plugin_a_foo.mdx | 2 +- .../src/api_docs/tests/snapshots/plugin_b.mdx | 2 +- .../shared_ux/public/components/index.ts | 2 + .../redirect_app_links/click_handler.test.ts | 211 ++++++++++++++++ .../redirect_app_links/click_handler.ts | 49 ++++ .../components/redirect_app_links/index.ts | 19 ++ .../redirect_app_links/redirect_app_links.mdx | 12 + .../redirect_app_links.stories.tsx | 43 ++++ .../redirect_app_links.test.tsx | 237 ++++++++++++++++++ .../redirect_app_links/redirect_app_links.tsx | 65 +++++ .../public/components/utility/utils.test.ts | 41 +++ .../public/components/utility/utils.ts | 37 +++ 13 files changed, 719 insertions(+), 3 deletions(-) create mode 100644 src/plugins/shared_ux/public/components/redirect_app_links/click_handler.test.ts create mode 100644 src/plugins/shared_ux/public/components/redirect_app_links/click_handler.ts create mode 100644 src/plugins/shared_ux/public/components/redirect_app_links/index.ts create mode 100644 src/plugins/shared_ux/public/components/redirect_app_links/redirect_app_links.mdx create mode 100644 src/plugins/shared_ux/public/components/redirect_app_links/redirect_app_links.stories.tsx create mode 100644 src/plugins/shared_ux/public/components/redirect_app_links/redirect_app_links.test.tsx create mode 100644 src/plugins/shared_ux/public/components/redirect_app_links/redirect_app_links.tsx create mode 100644 src/plugins/shared_ux/public/components/utility/utils.test.ts create mode 100644 src/plugins/shared_ux/public/components/utility/utils.ts diff --git a/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a.mdx b/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a.mdx index 9d3b2f5d3cf99..ab80f1f02d0ac 100644 --- a/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a.mdx +++ b/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a.mdx @@ -4,7 +4,7 @@ slug: /kibana-dev-docs/api/pluginA title: "pluginA" image: https://source.unsplash.com/400x175/?github summary: API docs for the pluginA plugin -date: 2020-11-16 +date: 2022-02-14 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'pluginA'] warning: This document is auto-generated and is meant to be viewed inside our experimental, new docs system. Reach out in #docs-engineering for more info. --- diff --git a/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a_foo.mdx b/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a_foo.mdx index 6d7f42982b89b..e9873f8223017 100644 --- a/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a_foo.mdx +++ b/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_a_foo.mdx @@ -4,7 +4,7 @@ slug: /kibana-dev-docs/api/pluginA-foo title: "pluginA.foo" image: https://source.unsplash.com/400x175/?github summary: API docs for the pluginA.foo plugin -date: 2020-11-16 +date: 2022-02-14 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'pluginA.foo'] warning: This document is auto-generated and is meant to be viewed inside our experimental, new docs system. Reach out in #docs-engineering for more info. --- diff --git a/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_b.mdx b/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_b.mdx index c86fbed82c23c..1671cd7a529d3 100644 --- a/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_b.mdx +++ b/packages/kbn-docs-utils/src/api_docs/tests/snapshots/plugin_b.mdx @@ -4,7 +4,7 @@ slug: /kibana-dev-docs/api/pluginB title: "pluginB" image: https://source.unsplash.com/400x175/?github summary: API docs for the pluginB plugin -date: 2020-11-16 +date: 2022-02-14 tags: ['contributor', 'dev', 'apidocs', 'kibana', 'pluginB'] warning: This document is auto-generated and is meant to be viewed inside our experimental, new docs system. Reach out in #docs-engineering for more info. --- diff --git a/src/plugins/shared_ux/public/components/index.ts b/src/plugins/shared_ux/public/components/index.ts index c2f835b97ebde..82648193e7a92 100644 --- a/src/plugins/shared_ux/public/components/index.ts +++ b/src/plugins/shared_ux/public/components/index.ts @@ -25,6 +25,8 @@ export const LazySolutionToolbarButton = React.lazy(() => })) ); +export const RedirectAppLinks = React.lazy(() => import('./redirect_app_links')); + /** * A `ExitFullScreenButton` component that is wrapped by the `withSuspense` HOC. This component can * be used directly by consumers and will load the `LazyExitFullScreenButton` component lazily with diff --git a/src/plugins/shared_ux/public/components/redirect_app_links/click_handler.test.ts b/src/plugins/shared_ux/public/components/redirect_app_links/click_handler.test.ts new file mode 100644 index 0000000000000..1569203c394df --- /dev/null +++ b/src/plugins/shared_ux/public/components/redirect_app_links/click_handler.test.ts @@ -0,0 +1,211 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { MouseEvent } from 'react'; +import { ApplicationStart } from 'src/core/public'; +import { createNavigateToUrlClickHandler } from './click_handler'; + +const createLink = ({ + href = '/base-path/app/targetApp', + target = '', +}: { href?: string; target?: string } = {}): HTMLAnchorElement => { + const el = document.createElement('a'); + if (href) { + el.href = href; + } + el.target = target; + return el; +}; + +const createEvent = ({ + target = createLink(), + button = 0, + defaultPrevented = false, + modifierKey = false, +}: { + target?: HTMLElement; + button?: number; + defaultPrevented?: boolean; + modifierKey?: boolean; +}): MouseEvent => { + return { + target, + button, + defaultPrevented, + ctrlKey: modifierKey, + preventDefault: jest.fn(), + } as unknown as MouseEvent; +}; + +describe('createNavigateToUrlClickHandler', () => { + let container: HTMLElement; + let navigateToUrl: jest.MockedFunction; + + const createHandler = () => + createNavigateToUrlClickHandler({ + container, + navigateToUrl, + }); + + beforeEach(() => { + container = document.createElement('div'); + navigateToUrl = jest.fn(); + }); + + it('calls `navigateToUrl` with the link url', () => { + const handler = createHandler(); + + const event = createEvent({ + target: createLink({ href: '/base-path/app/targetApp' }), + }); + handler(event); + + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(navigateToUrl).toHaveBeenCalledWith('http://localhost/base-path/app/targetApp'); + }); + + it('is triggered if a non-link target has a parent link', () => { + const handler = createHandler(); + + const link = createLink(); + const target = document.createElement('span'); + link.appendChild(target); + + const event = createEvent({ target }); + handler(event); + + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(navigateToUrl).toHaveBeenCalledWith('http://localhost/base-path/app/targetApp'); + }); + + it('is not triggered if a non-link target has no parent link', () => { + const handler = createHandler(); + + const parent = document.createElement('div'); + const target = document.createElement('span'); + parent.appendChild(target); + + const event = createEvent({ target }); + handler(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(navigateToUrl).not.toHaveBeenCalled(); + }); + + it('is not triggered when the link has no href', () => { + const handler = createHandler(); + + const event = createEvent({ + target: createLink({ href: '' }), + }); + handler(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(navigateToUrl).not.toHaveBeenCalled(); + }); + + it('is only triggered when the link does not have an external target', () => { + const handler = createHandler(); + + let event = createEvent({ + target: createLink({ target: '_blank' }), + }); + handler(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(navigateToUrl).not.toHaveBeenCalled(); + + event = createEvent({ + target: createLink({ target: 'some-target' }), + }); + handler(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(navigateToUrl).not.toHaveBeenCalled(); + + event = createEvent({ + target: createLink({ target: '_self' }), + }); + handler(event); + + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(navigateToUrl).toHaveBeenCalledTimes(1); + + event = createEvent({ + target: createLink({ target: '' }), + }); + handler(event); + + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(navigateToUrl).toHaveBeenCalledTimes(2); + }); + + it('is only triggered from left clicks', () => { + const handler = createHandler(); + + let event = createEvent({ + button: 1, + }); + handler(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(navigateToUrl).not.toHaveBeenCalled(); + + event = createEvent({ + button: 12, + }); + handler(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(navigateToUrl).not.toHaveBeenCalled(); + + event = createEvent({ + button: 0, + }); + handler(event); + + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(navigateToUrl).toHaveBeenCalledTimes(1); + }); + + it('is not triggered if the event default is prevented', () => { + const handler = createHandler(); + + let event = createEvent({ + defaultPrevented: true, + }); + handler(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(navigateToUrl).not.toHaveBeenCalled(); + + event = createEvent({ + defaultPrevented: false, + }); + handler(event); + + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(navigateToUrl).toHaveBeenCalledTimes(1); + }); + + it('is not triggered if any modifier key is pressed', () => { + const handler = createHandler(); + + let event = createEvent({ modifierKey: true }); + handler(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(navigateToUrl).not.toHaveBeenCalled(); + + event = createEvent({ modifierKey: false }); + handler(event); + + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(navigateToUrl).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/plugins/shared_ux/public/components/redirect_app_links/click_handler.ts b/src/plugins/shared_ux/public/components/redirect_app_links/click_handler.ts new file mode 100644 index 0000000000000..89b057ffd0eaa --- /dev/null +++ b/src/plugins/shared_ux/public/components/redirect_app_links/click_handler.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { ApplicationStart } from 'src/core/public'; +import { getClosestLink, hasActiveModifierKey } from '../utility/utils'; + +interface CreateCrossAppClickHandlerOptions { + navigateToUrl: ApplicationStart['navigateToUrl']; + container?: HTMLElement; +} + +export const createNavigateToUrlClickHandler = ({ + container, + navigateToUrl, +}: CreateCrossAppClickHandlerOptions): React.MouseEventHandler => { + return (e) => { + if (!container) { + return; + } + // see https://github.com/DefinitelyTyped/DefinitelyTyped/pull/12239 + const target = e.target as HTMLElement; + + const link = getClosestLink(target, container); + if (!link) { + return; + } + + const isNotEmptyHref = link.href; + const hasNoTarget = link.target === '' || link.target === '_self'; + const isLeftClickOnly = e.button === 0; + + if ( + isNotEmptyHref && + hasNoTarget && + isLeftClickOnly && + !e.defaultPrevented && + !hasActiveModifierKey(e) + ) { + e.preventDefault(); + navigateToUrl(link.href); + } + }; +}; diff --git a/src/plugins/shared_ux/public/components/redirect_app_links/index.ts b/src/plugins/shared_ux/public/components/redirect_app_links/index.ts new file mode 100644 index 0000000000000..e5f05f2c70741 --- /dev/null +++ b/src/plugins/shared_ux/public/components/redirect_app_links/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +/* eslint-disable import/no-default-export */ + +import { RedirectAppLinks } from './redirect_app_links'; +export type { RedirectAppLinksProps } from './redirect_app_links'; + +export { RedirectAppLinks } from './redirect_app_links'; + +/** + * Exporting the RedirectAppLinks component as a default export so it can be + * loaded by React.lazy. + */ +export default RedirectAppLinks; diff --git a/src/plugins/shared_ux/public/components/redirect_app_links/redirect_app_links.mdx b/src/plugins/shared_ux/public/components/redirect_app_links/redirect_app_links.mdx new file mode 100644 index 0000000000000..0023182940ae9 --- /dev/null +++ b/src/plugins/shared_ux/public/components/redirect_app_links/redirect_app_links.mdx @@ -0,0 +1,12 @@ +--- +id: sharedUX/Components/AppLink +slug: /shared-ux/components/redirect-app-link +title: Redirect App Link +summary: The component for redirect links. +tags: ['shared-ux', 'component'] +date: 2022-02-01 +--- + +> This documentation is in progress. + +**This component has been refactored.** Instead of requiring the entire `application`, it instead takes just `navigateToUrl` and `currentAppId$`. This makes the component more lightweight. diff --git a/src/plugins/shared_ux/public/components/redirect_app_links/redirect_app_links.stories.tsx b/src/plugins/shared_ux/public/components/redirect_app_links/redirect_app_links.stories.tsx new file mode 100644 index 0000000000000..0ca0e2a8d9978 --- /dev/null +++ b/src/plugins/shared_ux/public/components/redirect_app_links/redirect_app_links.stories.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { EuiButton } from '@elastic/eui'; +import React from 'react'; +import { BehaviorSubject } from 'rxjs'; + +import { action } from '@storybook/addon-actions'; +import { RedirectAppLinks } from './redirect_app_links'; +import mdx from './redirect_app_links.mdx'; + +export default { + title: 'Redirect App Links', + description: 'app links component that takes in an application id and navigation url.', + parameters: { + docs: { + page: mdx, + }, + }, +}; + +export const Component = () => { + return ( + Promise.resolve()} + currentAppId$={new BehaviorSubject('test')} + > + + Test link + + + ); +}; diff --git a/src/plugins/shared_ux/public/components/redirect_app_links/redirect_app_links.test.tsx b/src/plugins/shared_ux/public/components/redirect_app_links/redirect_app_links.test.tsx new file mode 100644 index 0000000000000..d2cf5aa664cc6 --- /dev/null +++ b/src/plugins/shared_ux/public/components/redirect_app_links/redirect_app_links.test.tsx @@ -0,0 +1,237 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { MouseEvent } from 'react'; +import { mount } from 'enzyme'; +import { applicationServiceMock } from '../../../../../core/public/mocks'; +import { RedirectAppLinks } from './redirect_app_links'; +import { BehaviorSubject } from 'rxjs'; + +/* eslint-disable jsx-a11y/click-events-have-key-events */ + +describe('RedirectAppLinks', () => { + let application: ReturnType; + + beforeEach(() => { + application = applicationServiceMock.createStartContract(); + application.currentAppId$ = new BehaviorSubject('currentApp'); + }); + + it('intercept click events on children link elements', () => { + let event: MouseEvent; + const component = mount( + + ); + + component.find('a').simulate('click', { button: 0, defaultPrevented: false }); + expect(application.navigateToUrl).toHaveBeenCalledTimes(1); + expect(event!.defaultPrevented).toBe(true); + }); + + it('intercept click events on children inside link elements', async () => { + let event: MouseEvent; + + const component = mount( +
{ + event = e; + }} + > + + + +
+ ); + + component.find('span').simulate('click', { button: 0, defaultPrevented: false }); + + expect(application.navigateToUrl).toHaveBeenCalledTimes(1); + expect(event!.defaultPrevented).toBe(true); + }); + + it('does not intercept click events when the target is not inside a link', () => { + let event: MouseEvent; + + const component = mount( +
{ + event = e; + }} + > + + + content + + +
+ ); + + component.find('span').simulate('click', { button: 0, defaultPrevented: false }); + + expect(application.navigateToApp).not.toHaveBeenCalled(); + expect(event!.defaultPrevented).toBe(false); + }); + + it('does not intercept click events when the link is a parent of the container', () => { + let event: MouseEvent; + + const component = mount( +
{ + event = e; + }} + > + + + content + + +
+ ); + + component.find('span').simulate('click', { button: 0, defaultPrevented: false }); + + expect(application.navigateToApp).not.toHaveBeenCalled(); + expect(event!.defaultPrevented).toBe(false); + }); + + it('does not intercept click events when the link has an external target', () => { + let event: MouseEvent; + + const component = mount( +
{ + event = e; + }} + > + + + content + + +
+ ); + + component.find('a').simulate('click', { button: 0, defaultPrevented: false }); + + expect(application.navigateToApp).not.toHaveBeenCalled(); + expect(event!.defaultPrevented).toBe(false); + }); + + it('does not intercept click events when the event is already defaultPrevented', () => { + let event: MouseEvent; + + const component = mount( +
{ + event = e; + }} + > + + + e.preventDefault()}>content + + +
+ ); + + component.find('span').simulate('click', { button: 0, defaultPrevented: false }); + + expect(application.navigateToApp).not.toHaveBeenCalled(); + expect(event!.defaultPrevented).toBe(true); + }); + + it('does not intercept click events when the event propagation is stopped', () => { + let event: MouseEvent; + + const component = mount( +
{ + event = e; + }} + > + + e.stopPropagation()}> + content + + +
+ ); + + component.find('a').simulate('click', { button: 0, defaultPrevented: false }); + + expect(application.navigateToApp).not.toHaveBeenCalled(); + expect(event!).toBe(undefined); + }); + + it('does not intercept click events when the event is not triggered from the left button', () => { + let event: MouseEvent; + + const component = mount( +
{ + event = e; + }} + > + +
+ content +
+
+
+ ); + + component.find('a').simulate('click', { button: 1, defaultPrevented: false }); + + expect(application.navigateToApp).not.toHaveBeenCalled(); + expect(event!.defaultPrevented).toBe(false); + }); + + it('does not intercept click events when the event has a modifier key enabled', () => { + let event: MouseEvent; + + const component = mount( +
{ + event = e; + }} + > + +
+ content +
+
+
+ ); + + component.find('a').simulate('click', { button: 0, ctrlKey: true, defaultPrevented: false }); + + expect(application.navigateToApp).not.toHaveBeenCalled(); + expect(event!.defaultPrevented).toBe(false); + }); +}); diff --git a/src/plugins/shared_ux/public/components/redirect_app_links/redirect_app_links.tsx b/src/plugins/shared_ux/public/components/redirect_app_links/redirect_app_links.tsx new file mode 100644 index 0000000000000..6354914684fb6 --- /dev/null +++ b/src/plugins/shared_ux/public/components/redirect_app_links/redirect_app_links.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { FunctionComponent, useRef, useMemo } from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { ApplicationStart } from 'src/core/public'; +import { createNavigateToUrlClickHandler } from './click_handler'; + +type Props = React.HTMLAttributes & + Pick; + +export interface RedirectAppLinksProps extends Props { + className?: string; + 'data-test-subj'?: string; +} + +/** + * Utility component that will intercept click events on children anchor (``) elements to call + * `application.navigateToUrl` with the link's href. This will trigger SPA friendly navigation + * when the link points to a valid Kibana app. + * + * @example + * ```tsx + * url} currentAppId$={observableAppId}> + * Go to another-app + * + * ``` + * + * @remarks + * It is recommended to use the component at the highest possible level of the component tree that would + * require to handle the links. A good practice is to consider it as a context provider and to use it + * at the root level of an application or of the page that require the feature. + */ +export const RedirectAppLinks: FunctionComponent = ({ + navigateToUrl, + currentAppId$, + children, + ...otherProps +}) => { + const currentAppId = useObservable(currentAppId$, undefined); + const containerRef = useRef(null); + + const clickHandler = useMemo( + () => + containerRef.current && currentAppId + ? createNavigateToUrlClickHandler({ + container: containerRef.current, + navigateToUrl, + }) + : undefined, + [currentAppId, navigateToUrl] + ); + + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events +
+ {children} +
+ ); +}; diff --git a/src/plugins/shared_ux/public/components/utility/utils.test.ts b/src/plugins/shared_ux/public/components/utility/utils.test.ts new file mode 100644 index 0000000000000..2c04038d253a7 --- /dev/null +++ b/src/plugins/shared_ux/public/components/utility/utils.test.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getClosestLink } from './utils'; + +const createBranch = (...tags: string[]): HTMLElement[] => { + const elements: HTMLElement[] = []; + let parent: HTMLElement | undefined; + for (const tag of tags) { + const element = document.createElement(tag); + elements.push(element); + if (parent) { + parent.appendChild(element); + } + parent = element; + } + + return elements; +}; + +describe('getClosestLink', () => { + it(`returns the element itself if it's a link`, () => { + const [target] = createBranch('A'); + expect(getClosestLink(target)).toBe(target); + }); + + it('returns the closest parent that is a link', () => { + const [, , link, , target] = createBranch('A', 'DIV', 'A', 'DIV', 'SPAN'); + expect(getClosestLink(target)).toBe(link); + }); + + it('returns undefined if the closest link is further than the container', () => { + const [, container, target] = createBranch('A', 'DIV', 'SPAN'); + expect(getClosestLink(target, container)).toBe(undefined); + }); +}); diff --git a/src/plugins/shared_ux/public/components/utility/utils.ts b/src/plugins/shared_ux/public/components/utility/utils.ts new file mode 100644 index 0000000000000..0ac501d160815 --- /dev/null +++ b/src/plugins/shared_ux/public/components/utility/utils.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; + +/** + * Returns true if any modifier key is active on the event, false otherwise. + */ +export const hasActiveModifierKey = (event: React.MouseEvent): boolean => { + return event.metaKey || event.altKey || event.ctrlKey || event.shiftKey; +}; + +/** + * Returns the closest anchor (``) element in the element parents (self included) up to the given container (excluded), or undefined if none is found. + */ +export const getClosestLink = ( + element: HTMLElement | null | undefined, + container?: HTMLElement +): HTMLAnchorElement | undefined => { + let current = element; + do { + if (current?.tagName.toLowerCase() === 'a') { + return current as HTMLAnchorElement; + } + const parent = current?.parentElement; + if (!parent || parent === document.body || parent === container) { + break; + } + current = parent; + } while (parent || parent !== document.body || parent !== container); + return undefined; +}; From 499deb3661642a249e9d4b649cd1f7a1891c89e5 Mon Sep 17 00:00:00 2001 From: Michael Dokolin Date: Mon, 14 Mar 2022 18:27:48 +0100 Subject: [PATCH 24/44] [Expressions] Add support of argument values validators (#126728) * Add argument options parameter validation * Update chart expressions to remove custom arguments validation --- .../__snapshots__/gauge_function.test.ts.snap | 8 --- .../gauge_function.test.ts | 19 ++---- .../expression_functions/gauge_function.ts | 26 --------- .../expression_functions/heatmap_legend.ts | 10 ---- .../metric_vis_function.ts | 16 +---- .../common/expression_functions/i18n.ts | 12 ---- .../mosaic_vis_function.ts | 4 -- .../expression_functions/pie_vis_function.ts | 4 -- .../treemap_vis_function.ts | 4 -- .../waffle_vis_function.ts | 4 -- .../expression_functions/tagcloud_function.ts | 14 ----- src/plugins/charts/common/index.ts | 2 - src/plugins/charts/common/utils.ts | 20 ------- .../common/execution/execution.test.ts | 58 +++++++++++++++++++ .../expressions/common/execution/execution.ts | 15 ++++- 15 files changed, 77 insertions(+), 139 deletions(-) delete mode 100644 src/plugins/charts/common/utils.ts diff --git a/src/plugins/chart_expressions/expression_gauge/common/expression_functions/__snapshots__/gauge_function.test.ts.snap b/src/plugins/chart_expressions/expression_gauge/common/expression_functions/__snapshots__/gauge_function.test.ts.snap index 2eca3361d097d..c640ed8884d98 100644 --- a/src/plugins/chart_expressions/expression_gauge/common/expression_functions/__snapshots__/gauge_function.test.ts.snap +++ b/src/plugins/chart_expressions/expression_gauge/common/expression_functions/__snapshots__/gauge_function.test.ts.snap @@ -712,11 +712,3 @@ Object { exports[`interpreter/functions#gauge throws error if centralMajor or centralMajorMode are provided for the horizontalBullet shape 1`] = `"Fields \\"centralMajor\\" and \\"centralMajorMode\\" are not supported by the shape \\"horizontalBullet\\""`; exports[`interpreter/functions#gauge throws error if centralMajor or centralMajorMode are provided for the vertical shape 1`] = `"Fields \\"centralMajor\\" and \\"centralMajorMode\\" are not supported by the shape \\"verticalBullet\\""`; - -exports[`interpreter/functions#gauge throws error on wrong colorMode type 1`] = `"Invalid color mode is specified. Supported color modes: palette, none"`; - -exports[`interpreter/functions#gauge throws error on wrong labelMajorMode type 1`] = `"Invalid label major mode is specified. Supported label major modes: auto, custom, none"`; - -exports[`interpreter/functions#gauge throws error on wrong shape type 1`] = `"Invalid shape is specified. Supported shapes: horizontalBullet, verticalBullet, arc, circle"`; - -exports[`interpreter/functions#gauge throws error on wrong ticksPosition type 1`] = `"Invalid ticks position is specified. Supported ticks positions: hidden, auto, bands"`; diff --git a/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.test.ts b/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.test.ts index 54c7fed1a9b9b..40d95d5f44af2 100644 --- a/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.test.ts +++ b/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.test.ts @@ -36,28 +36,19 @@ describe('interpreter/functions#gauge', () => { min: 'col-1-2', metric: 'col-0-1', }; - const checkArg = ( - arg: keyof GaugeArguments, - options: Record, - invalidValue: string - ) => { + const checkArg = (arg: keyof GaugeArguments, options: Record) => { Object.values(options).forEach((option) => { it(`returns an object with the correct structure for the ${option} ${arg}`, () => { const actual = fn(context, { ...args, [arg]: option }, undefined); expect(actual).toMatchSnapshot(); }); }); - - it(`throws error on wrong ${arg} type`, () => { - const actual = () => fn(context, { ...args, [arg]: invalidValue as any }, undefined); - expect(actual).toThrowErrorMatchingSnapshot(); - }); }; - checkArg('shape', GaugeShapes, 'invalid_shape'); - checkArg('colorMode', GaugeColorModes, 'invalid_color_mode'); - checkArg('ticksPosition', GaugeTicksPositions, 'invalid_ticks_position'); - checkArg('labelMajorMode', GaugeLabelMajorModes, 'invalid_label_major_mode'); + checkArg('shape', GaugeShapes); + checkArg('colorMode', GaugeColorModes); + checkArg('ticksPosition', GaugeTicksPositions); + checkArg('labelMajorMode', GaugeLabelMajorModes); it(`returns an object with the correct structure for the circle if centralMajor and centralMajorMode are passed`, () => { const actual = fn( diff --git a/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.ts b/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.ts index e0bf574315410..89d32940808c4 100644 --- a/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.ts +++ b/src/plugins/chart_expressions/expression_gauge/common/expression_functions/gauge_function.ts @@ -8,7 +8,6 @@ import { i18n } from '@kbn/i18n'; import { prepareLogTable, validateAccessor } from '../../../../visualizations/common/utils'; -import { validateOptions } from '../../../../charts/common'; import { GaugeExpressionFunctionDefinition } from '../types'; import { EXPRESSION_GAUGE_NAME, @@ -21,26 +20,6 @@ import { import { isRoundShape } from '../utils'; export const errors = { - invalidShapeError: () => - i18n.translate('expressionGauge.functions.gauge.errors.invalidShapeError', { - defaultMessage: `Invalid shape is specified. Supported shapes: {shapes}`, - values: { shapes: Object.values(GaugeShapes).join(', ') }, - }), - invalidColorModeError: () => - i18n.translate('expressionGauge.functions.gauge.errors.invalidColorModeError', { - defaultMessage: `Invalid color mode is specified. Supported color modes: {colorModes}`, - values: { colorModes: Object.values(GaugeColorModes).join(', ') }, - }), - invalidTicksPositionError: () => - i18n.translate('expressionGauge.functions.gauge.errors.invalidTicksPositionError', { - defaultMessage: `Invalid ticks position is specified. Supported ticks positions: {ticksPositions}`, - values: { ticksPositions: Object.values(GaugeTicksPositions).join(', ') }, - }), - invalidLabelMajorModeError: () => - i18n.translate('expressionGauge.functions.gauge.errors.invalidLabelMajorModeError', { - defaultMessage: `Invalid label major mode is specified. Supported label major modes: {labelMajorModes}`, - values: { labelMajorModes: Object.values(GaugeLabelMajorModes).join(', ') }, - }), centralMajorNotSupportedForShapeError: (shape: string) => i18n.translate('expressionGauge.functions.gauge.errors.centralMajorNotSupportedForShapeError', { defaultMessage: @@ -185,11 +164,6 @@ export const gaugeFunction = (): GaugeExpressionFunctionDefinition => ({ }, fn(data, args, handlers) { - validateOptions(args.shape, GaugeShapes, errors.invalidShapeError); - validateOptions(args.colorMode, GaugeColorModes, errors.invalidColorModeError); - validateOptions(args.ticksPosition, GaugeTicksPositions, errors.invalidTicksPositionError); - validateOptions(args.labelMajorMode, GaugeLabelMajorModes, errors.invalidLabelMajorModeError); - validateAccessor(args.metric, data.columns); validateAccessor(args.min, data.columns); validateAccessor(args.max, data.columns); diff --git a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_legend.ts b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_legend.ts index b5a8d8aa74a2e..29d5d2a0ca8c0 100644 --- a/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_legend.ts +++ b/src/plugins/chart_expressions/expression_heatmap/common/expression_functions/heatmap_legend.ts @@ -8,18 +8,9 @@ import { Position } from '@elastic/charts'; import { i18n } from '@kbn/i18n'; import type { ExpressionFunctionDefinition } from '../../../../expressions/common'; -import { validateOptions } from '../../../../charts/common'; import { EXPRESSION_HEATMAP_LEGEND_NAME } from '../constants'; import { HeatmapLegendConfig, HeatmapLegendConfigResult } from '../types'; -export const errors = { - invalidPositionError: () => - i18n.translate('expressionHeatmap.functions.heatmap.errors.invalidPositionError', { - defaultMessage: `Invalid position is specified. Supported positions: {positions}`, - values: { positions: Object.values(Position).join(', ') }, - }), -}; - export const heatmapLegendConfig: ExpressionFunctionDefinition< typeof EXPRESSION_HEATMAP_LEGEND_NAME, null, @@ -67,7 +58,6 @@ export const heatmapLegendConfig: ExpressionFunctionDefinition< }, }, fn(input, args) { - validateOptions(args.position, Position, errors.invalidPositionError); return { type: EXPRESSION_HEATMAP_LEGEND_NAME, ...args, diff --git a/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts b/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts index 3f76de3011acf..811ffb7ad3d91 100644 --- a/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts +++ b/src/plugins/chart_expressions/expression_metric/common/expression_functions/metric_vis_function.ts @@ -14,22 +14,11 @@ import { Dimension, validateAccessor, } from '../../../../visualizations/common/utils'; -import { ColorMode, validateOptions } from '../../../../charts/common'; +import { ColorMode } from '../../../../charts/common'; import { MetricVisExpressionFunctionDefinition } from '../types'; import { EXPRESSION_METRIC_NAME, LabelPosition } from '../constants'; const errors = { - invalidColorModeError: () => - i18n.translate('expressionMetricVis.function.errors.invalidColorModeError', { - defaultMessage: 'Invalid color mode is specified. Supported color modes: {colorModes}', - values: { colorModes: Object.values(ColorMode).join(', ') }, - }), - invalidLabelPositionError: () => - i18n.translate('expressionMetricVis.function.errors.invalidLabelPositionError', { - defaultMessage: - 'Invalid label position is specified. Supported label positions: {labelPosition}', - values: { labelPosition: Object.values(LabelPosition).join(', ') }, - }), severalMetricsAndColorFullBackgroundSpecifiedError: () => i18n.translate( 'expressionMetricVis.function.errors.severalMetricsAndColorFullBackgroundSpecified', @@ -151,9 +140,6 @@ export const metricVisFunction = (): MetricVisExpressionFunctionDefinition => ({ } } - validateOptions(args.colorMode, ColorMode, errors.invalidColorModeError); - validateOptions(args.labelPosition, LabelPosition, errors.invalidLabelPositionError); - args.metric.forEach((metric) => validateAccessor(metric, input.columns)); validateAccessor(args.bucket, input.columns); diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/i18n.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/i18n.ts index 7800b81ec91ad..250d0f1033ffe 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/i18n.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/i18n.ts @@ -7,8 +7,6 @@ */ import { i18n } from '@kbn/i18n'; -import { Position } from '@elastic/charts'; -import { LegendDisplay } from '../types/expression_renderers'; export const strings = { getPieVisFunctionName: () => @@ -133,14 +131,4 @@ export const errors = { defaultMessage: 'A split row and column are specified. Expression is supporting only one of them at once.', }), - invalidLegendDisplayError: () => - i18n.translate('expressionPartitionVis.reusable.function.errors.invalidLegendDisplayError', { - defaultMessage: `Invalid legend display mode is specified. Supported ticks legend display modes: {legendDisplayModes}`, - values: { legendDisplayModes: Object.values(LegendDisplay).join(', ') }, - }), - invalidLegendPositionError: () => - i18n.translate('expressionPartitionVis.reusable.function.errors.invalidLegendPositionError', { - defaultMessage: `Invalid legend position is specified. Supported ticks legend positions: {positions}`, - values: { positions: Object.values(Position).join(', ') }, - }), }; diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.ts index a0058f38b0f8c..609fa3a433cde 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/mosaic_vis_function.ts @@ -9,7 +9,6 @@ import { Position } from '@elastic/charts'; import { LegendDisplay, PartitionVisParams } from '../types/expression_renderers'; import { prepareLogTable, validateAccessor } from '../../../../visualizations/common/utils'; -import { validateOptions } from '../../../../charts/common'; import { ChartTypes, MosaicVisExpressionFunctionDefinition } from '../types'; import { PARTITION_LABELS_FUNCTION, @@ -117,9 +116,6 @@ export const mosaicVisFunction = (): MosaicVisExpressionFunctionDefinition => ({ args.splitRow.forEach((splitRow) => validateAccessor(splitRow, context.columns)); } - validateOptions(args.legendDisplay, LegendDisplay, errors.invalidLegendDisplayError); - validateOptions(args.legendPosition, Position, errors.invalidLegendPositionError); - const visConfig: PartitionVisParams = { ...args, ariaLabel: diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.ts index f0dc14d9bf4c7..46d564f155411 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/pie_vis_function.ts @@ -9,7 +9,6 @@ import { Position } from '@elastic/charts'; import { EmptySizeRatios, LegendDisplay, PartitionVisParams } from '../types/expression_renderers'; import { prepareLogTable, validateAccessor } from '../../../../visualizations/common/utils'; -import { validateOptions } from '../../../../charts/common'; import { ChartTypes, PieVisExpressionFunctionDefinition } from '../types'; import { PARTITION_LABELS_FUNCTION, @@ -137,9 +136,6 @@ export const pieVisFunction = (): PieVisExpressionFunctionDefinition => ({ args.splitRow.forEach((splitRow) => validateAccessor(splitRow, context.columns)); } - validateOptions(args.legendDisplay, LegendDisplay, errors.invalidLegendDisplayError); - validateOptions(args.legendPosition, Position, errors.invalidLegendPositionError); - const visConfig: PartitionVisParams = { ...args, ariaLabel: diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.ts index 1a9a8ac79f631..4d3faa79c7a3e 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/treemap_vis_function.ts @@ -9,7 +9,6 @@ import { Position } from '@elastic/charts'; import { LegendDisplay, PartitionVisParams } from '../types/expression_renderers'; import { prepareLogTable, validateAccessor } from '../../../../visualizations/common/utils'; -import { validateOptions } from '../../../../charts/common'; import { ChartTypes, TreemapVisExpressionFunctionDefinition } from '../types'; import { PARTITION_LABELS_FUNCTION, @@ -117,9 +116,6 @@ export const treemapVisFunction = (): TreemapVisExpressionFunctionDefinition => args.splitRow.forEach((splitRow) => validateAccessor(splitRow, context.columns)); } - validateOptions(args.legendDisplay, LegendDisplay, errors.invalidLegendDisplayError); - validateOptions(args.legendPosition, Position, errors.invalidLegendPositionError); - const visConfig: PartitionVisParams = { ...args, ariaLabel: diff --git a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.ts b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.ts index a434da73607e6..303a39d1de436 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.ts +++ b/src/plugins/chart_expressions/expression_partition_vis/common/expression_functions/waffle_vis_function.ts @@ -9,7 +9,6 @@ import { Position } from '@elastic/charts'; import { LegendDisplay, PartitionVisParams } from '../types/expression_renderers'; import { prepareLogTable, validateAccessor } from '../../../../visualizations/common/utils'; -import { validateOptions } from '../../../../charts/common'; import { ChartTypes, WaffleVisExpressionFunctionDefinition } from '../types'; import { PARTITION_LABELS_FUNCTION, @@ -111,9 +110,6 @@ export const waffleVisFunction = (): WaffleVisExpressionFunctionDefinition => ({ args.splitRow.forEach((splitRow) => validateAccessor(splitRow, context.columns)); } - validateOptions(args.legendDisplay, LegendDisplay, errors.invalidLegendDisplayError); - validateOptions(args.legendPosition, Position, errors.invalidLegendPositionError); - const buckets = args.bucket ? [args.bucket] : []; const visConfig: PartitionVisParams = { ...args, diff --git a/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.ts b/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.ts index 5fb991a3ba262..0b9bbc36e5c5d 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.ts +++ b/src/plugins/chart_expressions/expression_tagcloud/common/expression_functions/tagcloud_function.ts @@ -12,7 +12,6 @@ import { Dimension, validateAccessor, } from '../../../../visualizations/common/utils'; -import { validateOptions } from '../../../../charts/common'; import { TagCloudRendererParams } from '../types'; import { ExpressionTagcloudFunction } from '../types'; import { EXPRESSION_NAME, ScaleOptions, Orientation } from '../constants'; @@ -79,16 +78,6 @@ export const errors = { }, }) ), - invalidScaleOptionError: () => - i18n.translate('expressionTagcloud.functions.tagcloud.invalidScaleOptionError', { - defaultMessage: `Invalid scale option is specified. Supported scale options: {scaleOptions}`, - values: { scaleOptions: Object.values(ScaleOptions).join(', ') }, - }), - invalidOrientationError: () => - i18n.translate('expressionTagcloud.functions.tagcloud.invalidOrientationError', { - defaultMessage: `Invalid orientation of words is specified. Supported scale orientation: {orientation}`, - values: { orientation: Object.values(Orientation).join(', ') }, - }), }; export const tagcloudFunction: ExpressionTagcloudFunction = () => { @@ -151,9 +140,6 @@ export const tagcloudFunction: ExpressionTagcloudFunction = () => { validateAccessor(args.metric, input.columns); validateAccessor(args.bucket, input.columns); - validateOptions(args.scale, ScaleOptions, errors.invalidScaleOptionError); - validateOptions(args.orientation, Orientation, errors.invalidOrientationError); - const visParams: TagCloudRendererParams = { scale: args.scale, orientation: args.orientation, diff --git a/src/plugins/charts/common/index.ts b/src/plugins/charts/common/index.ts index 35f12884d29cd..2b8f252f892a5 100644 --- a/src/plugins/charts/common/index.ts +++ b/src/plugins/charts/common/index.ts @@ -35,5 +35,3 @@ export { } from './static'; export type { ColorSchemaParams, Labels, Style, PaletteContinuity } from './types'; - -export { validateOptions } from './utils'; diff --git a/src/plugins/charts/common/utils.ts b/src/plugins/charts/common/utils.ts deleted file mode 100644 index 393110e26994b..0000000000000 --- a/src/plugins/charts/common/utils.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -export const validateOptions = ( - value: string, - availableOptions: Record | Array, - getErrorMessage: () => string -) => { - const options = Array.isArray(availableOptions) - ? availableOptions - : Object.values(availableOptions); - if (!options.includes(value)) { - throw new Error(getErrorMessage()); - } -}; diff --git a/src/plugins/expressions/common/execution/execution.test.ts b/src/plugins/expressions/common/execution/execution.test.ts index 6dab9f7c683ed..90b2d590bcf69 100644 --- a/src/plugins/expressions/common/execution/execution.test.ts +++ b/src/plugins/expressions/common/execution/execution.test.ts @@ -731,6 +731,64 @@ describe('Execution', () => { }); }); + describe('when arguments are not valid', () => { + let executor: ReturnType; + + beforeEach(() => { + const validateArg: ExpressionFunctionDefinition< + 'validateArg', + unknown, + { arg: unknown }, + unknown + > = { + name: 'validateArg', + args: { + arg: { + help: '', + multi: true, + options: ['valid'], + }, + }, + help: '', + fn: () => 'something', + }; + executor = createUnitTestExecutor(); + executor.registerFunction(validateArg); + }); + + it('errors when argument is invalid', async () => { + const { result } = await executor.run('validateArg arg="invalid"', null).toPromise(); + + expect(result).toMatchObject({ + type: 'error', + error: { + message: + "[validateArg] > Value 'invalid' is not among the allowed options for argument 'arg': 'valid'", + }, + }); + }); + + it('errors when at least one value is invalid', async () => { + const { result } = await executor + .run('validateArg arg="valid" arg="invalid"', null) + .toPromise(); + + expect(result).toMatchObject({ + type: 'error', + error: { + message: + "[validateArg] > Value 'invalid' is not among the allowed options for argument 'arg': 'valid'", + }, + }); + }); + + it('does not error when argument is valid', async () => { + const { result } = await executor.run('validateArg arg="valid"', null).toPromise(); + + expect(result).toBe('something'); + }); + }); + describe('debug mode', () => { test('can execute expression in debug mode', async () => { const execution = createExecution('add val=1 | add val=2 | add val=3', {}, true); diff --git a/src/plugins/expressions/common/execution/execution.ts b/src/plugins/expressions/common/execution/execution.ts index 6bd3d5584ff78..7cc7fa771d8c8 100644 --- a/src/plugins/expressions/common/execution/execution.ts +++ b/src/plugins/expressions/common/execution/execution.ts @@ -38,7 +38,7 @@ import { } from '../ast'; import { ExecutionContext, DefaultInspectorAdapters } from './types'; import { getType, Datatable } from '../expression_types'; -import { ExpressionFunction } from '../expression_functions'; +import type { ExpressionFunction, ExpressionFunctionParameter } from '../expression_functions'; import { getByAlias } from '../util/get_by_alias'; import { ExecutionContract } from './execution_contract'; import { ExpressionExecutionParams } from '../service'; @@ -442,6 +442,16 @@ export class Execution< throw new Error(`Can not cast '${fromTypeName}' to any of '${toTypeNames.join(', ')}'`); } + validate(value: Type, argDef: ExpressionFunctionParameter): void { + if (argDef.options?.length && !argDef.options.includes(value)) { + throw new Error( + `Value '${value}' is not among the allowed options for argument '${ + argDef.name + }': '${argDef.options.join("', '")}'` + ); + } + } + // Processes the multi-valued AST argument values into arguments that can be passed to the function resolveArgs( fnDef: Fn, @@ -498,7 +508,8 @@ export class Execution< } return this.cast(output, argDefs[argName].types); - }) + }), + tap((value) => this.validate(value, argDefs[argName])) ) ) ); From feb641681a98d49fcf18eacc888fbf81ad812f86 Mon Sep 17 00:00:00 2001 From: Giorgos Bamparopoulos Date: Mon, 14 Mar 2022 17:41:40 +0000 Subject: [PATCH 25/44] [APM] Fix time comparison types when relative time range is selected (#126567) * Remove exactStart and exactEnd * Add tests for comparison handling * Remove useComparison hook --- .../backend_detail_dependencies_table.tsx | 15 +- .../backend_error_rate_chart.tsx | 23 +- .../backend_latency_chart.tsx | 23 +- .../backend_throughput_chart.tsx | 23 +- .../index.tsx | 14 +- .../app/error_group_overview/index.tsx | 1 - .../app/service_inventory/index.tsx | 17 +- .../index.tsx | 16 +- ...ice_overview_instances_chart_and_table.tsx | 22 +- .../index.tsx | 10 +- .../service_overview_throughput_chart.tsx | 7 +- .../maybe_view_trace_link.tsx | 6 +- .../waterfall/flyout_top_level_properties.tsx | 6 +- .../routing/service_detail/index.tsx | 2 + .../failed_transaction_rate_chart/index.tsx | 8 +- .../shared/charts/latency_chart/index.tsx | 12 +- .../index.tsx | 4 +- .../shared/time_comparison/comparison.test.ts | 477 ++++++++++++++++++ .../time_comparison/get_comparison_options.ts | 126 +++++ .../time_comparison/get_comparison_types.ts | 45 -- .../get_time_range_comparison.test.ts | 98 ---- .../get_time_range_comparison.ts | 19 +- .../shared/time_comparison/index.test.tsx | 355 +++++-------- .../shared/time_comparison/index.tsx | 115 +---- .../shared/transactions_table/index.tsx | 18 +- .../url_params_context/helpers.test.ts | 99 +--- .../context/url_params_context/helpers.ts | 36 +- .../url_params_context/resolve_url_params.ts | 5 +- .../context/url_params_context/types.ts | 2 - .../url_params_context/url_params_context.tsx | 7 +- .../apm/public/hooks/use_comparison.ts | 44 -- .../use_error_group_distribution_fetcher.tsx | 1 - .../apm/public/hooks/use_time_range.ts | 10 +- .../use_transaction_latency_chart_fetcher.ts | 10 +- 34 files changed, 897 insertions(+), 779 deletions(-) create mode 100644 x-pack/plugins/apm/public/components/shared/time_comparison/comparison.test.ts create mode 100644 x-pack/plugins/apm/public/components/shared/time_comparison/get_comparison_options.ts delete mode 100644 x-pack/plugins/apm/public/components/shared/time_comparison/get_comparison_types.ts delete mode 100644 x-pack/plugins/apm/public/hooks/use_comparison.ts diff --git a/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_detail_dependencies_table.tsx b/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_detail_dependencies_table.tsx index 2fb9d91becf66..ee71e4d8c8674 100644 --- a/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_detail_dependencies_table.tsx +++ b/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_detail_dependencies_table.tsx @@ -9,7 +9,6 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { getNodeName, NodeType } from '../../../../common/connections'; import { useApmParams } from '../../../hooks/use_apm_params'; -import { useLegacyUrlParams } from '../../../context/url_params_context/use_url_params'; import { useFetcher } from '../../../hooks/use_fetcher'; import { getTimeRangeComparison } from '../../shared/time_comparison/get_time_range_comparison'; import { DependenciesTable } from '../../shared/dependencies_table'; @@ -18,11 +17,15 @@ import { useTimeRange } from '../../../hooks/use_time_range'; export function BackendDetailDependenciesTable() { const { - urlParams: { comparisonEnabled, comparisonType }, - } = useLegacyUrlParams(); - - const { - query: { backendName, rangeFrom, rangeTo, kuery, environment }, + query: { + backendName, + rangeFrom, + rangeTo, + kuery, + environment, + comparisonEnabled, + comparisonType, + }, } = useApmParams('/backends/overview'); const { start, end } = useTimeRange({ rangeFrom, rangeTo }); diff --git a/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_error_rate_chart.tsx b/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_error_rate_chart.tsx index 9fb53ab15d374..5ecb41829f06c 100644 --- a/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_error_rate_chart.tsx +++ b/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_error_rate_chart.tsx @@ -7,7 +7,6 @@ import React, { useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { asPercent } from '../../../../common/utils/formatters'; -import { useComparison } from '../../../hooks/use_comparison'; import { useFetcher } from '../../../hooks/use_fetcher'; import { useTimeRange } from '../../../hooks/use_time_range'; import { Coordinate, TimeSeries } from '../../../../typings/timeseries'; @@ -17,6 +16,10 @@ import { ChartType, getTimeSeriesColor, } from '../../shared/charts/helper/get_timeseries_color'; +import { + getComparisonChartTheme, + getTimeRangeComparison, +} from '../../shared/time_comparison/get_time_range_comparison'; function yLabelFormat(y?: number | null) { return asPercent(y || 0, 1); @@ -28,12 +31,26 @@ export function BackendFailedTransactionRateChart({ height: number; }) { const { - query: { backendName, kuery, environment, rangeFrom, rangeTo }, + query: { + backendName, + kuery, + environment, + rangeFrom, + rangeTo, + comparisonEnabled, + comparisonType, + }, } = useApmParams('/backends/overview'); const { start, end } = useTimeRange({ rangeFrom, rangeTo }); - const { offset, comparisonChartTheme } = useComparison(); + const comparisonChartTheme = getComparisonChartTheme(); + const { offset } = getTimeRangeComparison({ + start, + end, + comparisonType, + comparisonEnabled, + }); const { data, status } = useFetcher( (callApmApi) => { diff --git a/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_latency_chart.tsx b/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_latency_chart.tsx index 9d95b58fe24de..8289ac01b7b27 100644 --- a/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_latency_chart.tsx +++ b/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_latency_chart.tsx @@ -7,7 +7,6 @@ import React, { useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { getDurationFormatter } from '../../../../common/utils/formatters'; -import { useComparison } from '../../../hooks/use_comparison'; import { useFetcher } from '../../../hooks/use_fetcher'; import { useTimeRange } from '../../../hooks/use_time_range'; import { Coordinate, TimeSeries } from '../../../../typings/timeseries'; @@ -21,15 +20,33 @@ import { ChartType, getTimeSeriesColor, } from '../../shared/charts/helper/get_timeseries_color'; +import { + getComparisonChartTheme, + getTimeRangeComparison, +} from '../../shared/time_comparison/get_time_range_comparison'; export function BackendLatencyChart({ height }: { height: number }) { const { - query: { backendName, rangeFrom, rangeTo, kuery, environment }, + query: { + backendName, + rangeFrom, + rangeTo, + kuery, + environment, + comparisonEnabled, + comparisonType, + }, } = useApmParams('/backends/overview'); const { start, end } = useTimeRange({ rangeFrom, rangeTo }); - const { offset, comparisonChartTheme } = useComparison(); + const comparisonChartTheme = getComparisonChartTheme(); + const { offset } = getTimeRangeComparison({ + start, + end, + comparisonType, + comparisonEnabled, + }); const { data, status } = useFetcher( (callApmApi) => { diff --git a/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_throughput_chart.tsx b/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_throughput_chart.tsx index c293561f780b1..c8a37146d60a4 100644 --- a/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_throughput_chart.tsx +++ b/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_throughput_chart.tsx @@ -7,7 +7,6 @@ import React, { useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { asTransactionRate } from '../../../../common/utils/formatters'; -import { useComparison } from '../../../hooks/use_comparison'; import { useFetcher } from '../../../hooks/use_fetcher'; import { useTimeRange } from '../../../hooks/use_time_range'; import { Coordinate, TimeSeries } from '../../../../typings/timeseries'; @@ -17,15 +16,33 @@ import { ChartType, getTimeSeriesColor, } from '../../shared/charts/helper/get_timeseries_color'; +import { + getComparisonChartTheme, + getTimeRangeComparison, +} from '../../shared/time_comparison/get_time_range_comparison'; export function BackendThroughputChart({ height }: { height: number }) { const { - query: { backendName, rangeFrom, rangeTo, kuery, environment }, + query: { + backendName, + rangeFrom, + rangeTo, + kuery, + environment, + comparisonEnabled, + comparisonType, + }, } = useApmParams('/backends/overview'); const { start, end } = useTimeRange({ rangeFrom, rangeTo }); - const { offset, comparisonChartTheme } = useComparison(); + const comparisonChartTheme = getComparisonChartTheme(); + const { offset } = getTimeRangeComparison({ + start, + end, + comparisonType, + comparisonEnabled, + }); const { data, status } = useFetcher( (callApmApi) => { diff --git a/x-pack/plugins/apm/public/components/app/backend_inventory/backend_inventory_dependencies_table/index.tsx b/x-pack/plugins/apm/public/components/app/backend_inventory/backend_inventory_dependencies_table/index.tsx index 71970e00f6d26..fa7cf4a3ba242 100644 --- a/x-pack/plugins/apm/public/components/app/backend_inventory/backend_inventory_dependencies_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/backend_inventory/backend_inventory_dependencies_table/index.tsx @@ -10,7 +10,6 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { useUiTracker } from '../../../../../../observability/public'; import { getNodeName, NodeType } from '../../../../../common/connections'; -import { useLegacyUrlParams } from '../../../../context/url_params_context/use_url_params'; import { useApmParams } from '../../../../hooks/use_apm_params'; import { useFetcher } from '../../../../hooks/use_fetcher'; import { useTimeRange } from '../../../../hooks/use_time_range'; @@ -20,11 +19,14 @@ import { getTimeRangeComparison } from '../../../shared/time_comparison/get_time export function BackendInventoryDependenciesTable() { const { - urlParams: { comparisonEnabled, comparisonType }, - } = useLegacyUrlParams(); - - const { - query: { rangeFrom, rangeTo, environment, kuery }, + query: { + rangeFrom, + rangeTo, + environment, + kuery, + comparisonEnabled, + comparisonType, + }, } = useApmParams('/backends'); const { start, end } = useTimeRange({ rangeFrom, rangeTo }); diff --git a/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx b/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx index 46b963d13e510..7d90ee6824de9 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/error_group_overview/index.tsx @@ -68,7 +68,6 @@ export function ErrorGroupOverview() { comparisonType, comparisonEnabled, }); - const { errorDistributionData, status } = useErrorGroupDistributionFetcher({ serviceName, groupId: undefined, diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx index 024e83a3c9883..c26ae5a273b4e 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/index.tsx @@ -10,16 +10,15 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import uuid from 'uuid'; import { useAnomalyDetectionJobsContext } from '../../../context/anomaly_detection_jobs/use_anomaly_detection_jobs_context'; -import { useLegacyUrlParams } from '../../../context/url_params_context/use_url_params'; import { useLocalStorage } from '../../../hooks/use_local_storage'; import { useApmParams } from '../../../hooks/use_apm_params'; import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; import { useTimeRange } from '../../../hooks/use_time_range'; import { SearchBar } from '../../shared/search_bar'; -import { getTimeRangeComparison } from '../../shared/time_comparison/get_time_range_comparison'; import { ServiceList } from './service_list'; import { MLCallout, shouldDisplayMlCallout } from '../../shared/ml_callout'; import { joinByKey } from '../../../../common/utils/join_by_key'; +import { getTimeRangeComparison } from '../../shared/time_comparison/get_time_range_comparison'; const initialData = { requestId: '', @@ -30,11 +29,15 @@ const initialData = { function useServicesFetcher() { const { - urlParams: { comparisonEnabled, comparisonType }, - } = useLegacyUrlParams(); - - const { - query: { rangeFrom, rangeTo, environment, kuery, serviceGroup }, + query: { + rangeFrom, + rangeTo, + environment, + kuery, + serviceGroup, + comparisonEnabled, + comparisonType, + }, } = useApmParams('/services'); const { start, end } = useTimeRange({ rangeFrom, rangeTo }); diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx index 20f31fa9b272e..88498f4186457 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_dependencies_table/index.tsx @@ -11,7 +11,6 @@ import React, { ReactNode } from 'react'; import { useUiTracker } from '../../../../../../observability/public'; import { getNodeName, NodeType } from '../../../../../common/connections'; import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; -import { useLegacyUrlParams } from '../../../../context/url_params_context/use_url_params'; import { useApmParams } from '../../../../hooks/use_apm_params'; import { useFetcher } from '../../../../hooks/use_fetcher'; import { useTimeRange } from '../../../../hooks/use_time_range'; @@ -34,11 +33,16 @@ export function ServiceOverviewDependenciesTable({ hidePerPageOptions = false, }: ServiceOverviewDependenciesTableProps) { const { - urlParams: { comparisonEnabled, comparisonType, latencyAggregationType }, - } = useLegacyUrlParams(); - - const { - query: { environment, kuery, rangeFrom, rangeTo, serviceGroup }, + query: { + environment, + kuery, + rangeFrom, + rangeTo, + serviceGroup, + comparisonEnabled, + comparisonType, + latencyAggregationType, + }, } = useApmParams('/services/{serviceName}/*'); const { start, end } = useTimeRange({ rangeFrom, rangeTo }); diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx index 5e0aa95340e81..dfea13eaaf476 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_chart_and_table.tsx @@ -10,7 +10,6 @@ import { orderBy } from 'lodash'; import React, { useState } from 'react'; import uuid from 'uuid'; import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; -import { useLegacyUrlParams } from '../../../context/url_params_context/use_url_params'; import { useApmParams } from '../../../hooks/use_apm_params'; import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; import { useTimeRange } from '../../../hooks/use_time_range'; @@ -21,6 +20,7 @@ import { ServiceOverviewInstancesTable, TableOptions, } from './service_overview_instances_table'; +import { LatencyAggregationType } from '../../../../common/latency_aggregation_types'; interface ServiceOverviewInstancesChartAndTableProps { chartHeight: number; @@ -73,13 +73,17 @@ export function ServiceOverviewInstancesChartAndTable({ const { direction, field } = sort; const { - query: { environment, kuery, rangeFrom, rangeTo }, + query: { + environment, + kuery, + rangeFrom, + rangeTo, + comparisonEnabled, + comparisonType, + latencyAggregationType, + }, } = useApmParams('/services/{serviceName}/overview'); - const { - urlParams: { latencyAggregationType, comparisonType, comparisonEnabled }, - } = useLegacyUrlParams(); - const { start, end } = useTimeRange({ rangeFrom, rangeTo }); const { comparisonStart, comparisonEnd } = getTimeRangeComparison({ @@ -108,7 +112,8 @@ export function ServiceOverviewInstancesChartAndTable({ query: { environment, kuery, - latencyAggregationType, + latencyAggregationType: + latencyAggregationType as LatencyAggregationType, start, end, transactionType, @@ -190,7 +195,8 @@ export function ServiceOverviewInstancesChartAndTable({ query: { environment, kuery, - latencyAggregationType, + latencyAggregationType: + latencyAggregationType as LatencyAggregationType, start, end, numBuckets: 20, diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/index.tsx index c41ad329ea863..03f036e44b4c1 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/index.tsx @@ -14,7 +14,6 @@ import { import { i18n } from '@kbn/i18n'; import React, { ReactNode, useEffect, useState } from 'react'; import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; -import { useLegacyUrlParams } from '../../../../context/url_params_context/use_url_params'; import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; import { APIReturnType } from '../../../../services/rest/create_call_apm_api'; import { @@ -27,6 +26,7 @@ import { getColumns } from './get_columns'; import { InstanceDetails } from './intance_details'; import { useApmParams } from '../../../../hooks/use_apm_params'; import { useBreakpoints } from '../../../../hooks/use_breakpoints'; +import { LatencyAggregationType } from '../../../../../common/latency_aggregation_types'; type ServiceInstanceMainStatistics = APIReturnType<'GET /internal/apm/services/{serviceName}/service_overview_instances/main_statistics'>; @@ -71,13 +71,9 @@ export function ServiceOverviewInstancesTable({ const { agentName } = useApmServiceContext(); const { - query: { kuery }, + query: { kuery, latencyAggregationType, comparisonEnabled }, } = useApmParams('/services/{serviceName}'); - const { - urlParams: { latencyAggregationType, comparisonEnabled }, - } = useLegacyUrlParams(); - const [itemIdToOpenActionMenuRowMap, setItemIdToOpenActionMenuRowMap] = useState>({}); @@ -127,7 +123,7 @@ export function ServiceOverviewInstancesTable({ agentName, serviceName, kuery, - latencyAggregationType, + latencyAggregationType: latencyAggregationType as LatencyAggregationType, detailedStatsData, comparisonEnabled, toggleRowDetails, diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx index dbbb925fe634b..a0a8f7babe640 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_throughput_chart.tsx @@ -18,7 +18,6 @@ import { ApmMlDetectorType } from '../../../../common/anomaly_detection/apm_ml_d import { asExactTransactionRate } from '../../../../common/utils/formatters'; import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; import { useEnvironmentsContext } from '../../../context/environments_context/use_environments_context'; -import { useLegacyUrlParams } from '../../../context/url_params_context/use_url_params'; import { useApmParams } from '../../../hooks/use_apm_params'; import { useFetcher } from '../../../hooks/use_fetcher'; import { usePreferredServiceAnomalyTimeseries } from '../../../hooks/use_preferred_service_anomaly_timeseries'; @@ -48,11 +47,7 @@ export function ServiceOverviewThroughputChart({ transactionName?: string; }) { const { - urlParams: { comparisonEnabled, comparisonType }, - } = useLegacyUrlParams(); - - const { - query: { rangeFrom, rangeTo }, + query: { rangeFrom, rangeTo, comparisonEnabled, comparisonType }, } = useApmParams('/services/{serviceName}'); const { environment } = useEnvironmentsContext(); diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/maybe_view_trace_link.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/maybe_view_trace_link.tsx index dfc89f78e4b3b..855f5c037fdd1 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/maybe_view_trace_link.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/maybe_view_trace_link.tsx @@ -9,11 +9,11 @@ import { EuiButton, EuiFlexItem, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; import { getNextEnvironmentUrlParam } from '../../../../../common/environment_filter_values'; -import { useLegacyUrlParams } from '../../../../context/url_params_context/use_url_params'; import { Transaction as ITransaction } from '../../../../../typings/es_schemas/ui/transaction'; import { TransactionDetailLink } from '../../../shared/links/apm/transaction_detail_link'; import { IWaterfall } from './waterfall_container/waterfall/waterfall_helpers/waterfall_helpers'; import { Environment } from '../../../../../common/environment_rt'; +import { useApmParams } from '../../../../hooks/use_apm_params'; export function MaybeViewTraceLink({ transaction, @@ -25,8 +25,8 @@ export function MaybeViewTraceLink({ environment: Environment; }) { const { - urlParams: { latencyAggregationType, comparisonEnabled, comparisonType }, - } = useLegacyUrlParams(); + query: { latencyAggregationType, comparisonEnabled, comparisonType }, + } = useApmParams('/services/{serviceName}/transactions/view'); const viewFullTraceButtonLabel = i18n.translate( 'xpack.apm.transactionDetails.viewFullTraceButtonLabel', diff --git a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/flyout_top_level_properties.tsx b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/flyout_top_level_properties.tsx index 20e278000266a..ead54b3e9d6d9 100644 --- a/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/flyout_top_level_properties.tsx +++ b/x-pack/plugins/apm/public/components/app/transaction_details/waterfall_with_summary/waterfall_container/waterfall/flyout_top_level_properties.tsx @@ -13,7 +13,6 @@ import { } from '../../../../../../../common/elasticsearch_fieldnames'; import { getNextEnvironmentUrlParam } from '../../../../../../../common/environment_filter_values'; import { Transaction } from '../../../../../../../typings/es_schemas/ui/transaction'; -import { useLegacyUrlParams } from '../../../../../../context/url_params_context/use_url_params'; import { useApmParams } from '../../../../../../hooks/use_apm_params'; import { TransactionDetailLink } from '../../../../../shared/links/apm/transaction_detail_link'; import { ServiceLink } from '../../../../../shared/service_link'; @@ -24,11 +23,10 @@ interface Props { } export function FlyoutTopLevelProperties({ transaction }: Props) { - const { - urlParams: { latencyAggregationType, comparisonEnabled, comparisonType }, - } = useLegacyUrlParams(); const { query } = useApmParams('/services/{serviceName}/transactions/view'); + const { latencyAggregationType, comparisonEnabled, comparisonType } = query; + if (!transaction) { return null; } diff --git a/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx b/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx index 79b46f98a520b..a4c2b84d57b35 100644 --- a/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx +++ b/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx @@ -28,6 +28,7 @@ import { ServiceProfiling } from '../../app/service_profiling'; import { ServiceDependencies } from '../../app/service_dependencies'; import { ServiceLogs } from '../../app/service_logs'; import { InfraOverview } from '../../app/infra_overview'; +import { LatencyAggregationType } from '../../../../common/latency_aggregation_types'; function page({ title, @@ -94,6 +95,7 @@ export const serviceDetail = { kuery: '', environment: ENVIRONMENT_ALL.value, serviceGroup: '', + latencyAggregationType: LatencyAggregationType.avg, }, }, children: { diff --git a/x-pack/plugins/apm/public/components/shared/charts/failed_transaction_rate_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/failed_transaction_rate_chart/index.tsx index 2efbae85d91c2..a968bf3186086 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/failed_transaction_rate_chart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/failed_transaction_rate_chart/index.tsx @@ -57,11 +57,11 @@ export function FailedTransactionRateChart({ kuery, }: Props) { const { - urlParams: { transactionName, comparisonEnabled, comparisonType }, + urlParams: { transactionName }, } = useLegacyUrlParams(); const { - query: { rangeFrom, rangeTo }, + query: { rangeFrom, rangeTo, comparisonEnabled, comparisonType }, } = useApmParams('/services/{serviceName}'); const { start, end } = useTimeRange({ rangeFrom, rangeTo }); @@ -74,7 +74,7 @@ export function FailedTransactionRateChart({ const { serviceName, transactionType, alerts } = useApmServiceContext(); - const comparisonChartThem = getComparisonChartTheme(); + const comparisonChartTheme = getComparisonChartTheme(); const { comparisonStart, comparisonEnd } = getTimeRangeComparison({ start, end, @@ -165,7 +165,7 @@ export function FailedTransactionRateChart({ timeseries={timeseries} yLabelFormat={yLabelFormat} yDomain={{ min: 0, max: 1 }} - customTheme={comparisonChartThem} + customTheme={comparisonChartTheme} anomalyTimeseries={preferredAnomalyTimeseries} alerts={alerts.filter( (alert) => diff --git a/x-pack/plugins/apm/public/components/shared/charts/latency_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/latency_chart/index.tsx index 6991a7aa7e200..880879119be98 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/latency_chart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/latency_chart/index.tsx @@ -15,7 +15,6 @@ import { useApmServiceContext } from '../../../../context/apm_service/use_apm_se import { LatencyAggregationType } from '../../../../../common/latency_aggregation_types'; import { getDurationFormatter } from '../../../../../common/utils/formatters'; import { useLicenseContext } from '../../../../context/license/use_license_context'; -import { useLegacyUrlParams } from '../../../../context/url_params_context/use_url_params'; import { useTransactionLatencyChartsFetcher } from '../../../../hooks/use_transaction_latency_chart_fetcher'; import { TimeseriesChart } from '../../../shared/charts/timeseries_chart'; import { @@ -28,6 +27,7 @@ import { getComparisonChartTheme } from '../../time_comparison/get_time_range_co import { useEnvironmentsContext } from '../../../../context/environments_context/use_environments_context'; import { ApmMlDetectorType } from '../../../../../common/anomaly_detection/apm_ml_detectors'; import { usePreferredServiceAnomalyTimeseries } from '../../../../hooks/use_preferred_service_anomaly_timeseries'; +import { useAnyOfApmParams } from '../../../../hooks/use_apm_params'; interface Props { height?: number; @@ -48,10 +48,16 @@ export function LatencyChart({ height, kuery }: Props) { const history = useHistory(); const comparisonChartTheme = getComparisonChartTheme(); - const { urlParams } = useLegacyUrlParams(); - const { latencyAggregationType, comparisonEnabled } = urlParams; const license = useLicenseContext(); + const { + query: { comparisonEnabled, latencyAggregationType }, + } = useAnyOfApmParams( + '/services/{serviceName}/overview', + '/services/{serviceName}/transactions', + '/services/{serviceName}/transactions/view' + ); + const { environment } = useEnvironmentsContext(); const { latencyChartsData, latencyChartsStatus } = diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_coldstart_rate_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_coldstart_rate_chart/index.tsx index 2b99562b67172..b6558bea79d3e 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_coldstart_rate_chart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_coldstart_rate_chart/index.tsx @@ -74,7 +74,7 @@ export function TransactionColdstartRateChart({ const { start, end } = useTimeRange({ rangeFrom, rangeTo }); const { serviceName, transactionType } = useApmServiceContext(); - const comparisonChartThem = getComparisonChartTheme(); + const comparisonChartTheme = getComparisonChartTheme(); const { comparisonStart, comparisonEnd } = getTimeRangeComparison({ start, end, @@ -177,7 +177,7 @@ export function TransactionColdstartRateChart({ timeseries={timeseries} yLabelFormat={yLabelFormat} yDomain={{ min: 0, max: 1 }} - customTheme={comparisonChartThem} + customTheme={comparisonChartTheme} /> ); diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/comparison.test.ts b/x-pack/plugins/apm/public/components/shared/time_comparison/comparison.test.ts new file mode 100644 index 0000000000000..30da5078f5dec --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/comparison.test.ts @@ -0,0 +1,477 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TimeRangeComparisonEnum } from '../../../../common/runtime_types/comparison_type_rt'; +import { getTimeRangeComparison } from './get_time_range_comparison'; +import { getDateRange } from '../../../context/url_params_context/helpers'; +import { getComparisonOptions } from './get_comparison_options'; +import moment from 'moment'; + +function getExpectedTimesAndComparisons({ + rangeFrom, + rangeTo, +}: { + rangeFrom: string; + rangeTo: string; +}) { + const { start, end } = getDateRange({ rangeFrom, rangeTo }); + const comparisonOptions = getComparisonOptions({ start, end }); + + const comparisons = comparisonOptions.map(({ value, text }) => { + const { comparisonStart, comparisonEnd, offset } = getTimeRangeComparison({ + comparisonEnabled: true, + comparisonType: value, + start, + end, + }); + + return { + value, + text, + comparisonStart, + comparisonEnd, + offset, + }; + }); + + return { + start, + end, + comparisons, + }; +} + +describe('Comparison test suite', () => { + let dateNowSpy: jest.SpyInstance; + + beforeAll(() => { + const mockDateNow = '2022-01-14T18:30:15.500Z'; + dateNowSpy = jest + .spyOn(Date, 'now') + .mockReturnValue(new Date(mockDateNow).getTime()); + }); + + afterAll(() => { + dateNowSpy.mockRestore(); + }); + + describe('When the time difference is less than 25 hours', () => { + let expectation: ReturnType; + + beforeAll(() => { + expectation = getExpectedTimesAndComparisons({ + rangeFrom: '2022-01-15T18:00:00.000Z', + rangeTo: '2022-01-16T18:30:00.000Z', + }); + }); + + it('should return the correct start and end date', () => { + expect(expectation.start).toBe('2022-01-15T18:00:00.000Z'); + expect(expectation.end).toBe('2022-01-16T18:30:00.000Z'); + }); + + it('should return comparison by day and week', () => { + expect(expectation.comparisons).toEqual([ + { + value: TimeRangeComparisonEnum.DayBefore, + text: 'Day before', + comparisonStart: '2022-01-14T18:00:00.000Z', + comparisonEnd: '2022-01-15T18:30:00.000Z', + offset: '1d', + }, + { + value: TimeRangeComparisonEnum.WeekBefore, + text: 'Week before', + comparisonStart: '2022-01-08T18:00:00.000Z', + comparisonEnd: '2022-01-09T18:30:00.000Z', + offset: '1w', + }, + ]); + }); + }); + + describe('When the time difference is more than 25 hours', () => { + let expectation: ReturnType; + + beforeAll(() => { + expectation = getExpectedTimesAndComparisons({ + rangeFrom: '2022-01-15T18:00:00.000Z', + rangeTo: '2022-01-16T19:00:00.000Z', + }); + }); + + it('should return the correct start and end date', () => { + expect(expectation.start).toBe('2022-01-15T18:00:00.000Z'); + expect(expectation.end).toBe('2022-01-16T19:00:00.000Z'); + }); + + it('should only return comparison by week', () => { + expect(expectation.comparisons).toEqual([ + { + value: TimeRangeComparisonEnum.WeekBefore, + text: 'Week before', + comparisonStart: '2022-01-08T18:00:00.000Z', + comparisonEnd: '2022-01-09T19:00:00.000Z', + offset: '1w', + }, + ]); + }); + }); + + describe('When the time difference is more than 25 hours and less than 8 days', () => { + let expectation: ReturnType; + + beforeAll(() => { + expectation = getExpectedTimesAndComparisons({ + rangeFrom: '2022-01-15T18:00:00.000Z', + rangeTo: '2022-01-22T21:00:00.000Z', + }); + }); + + it('should return the correct start and end date', () => { + expect(expectation.start).toBe('2022-01-15T18:00:00.000Z'); + expect(expectation.end).toBe('2022-01-22T21:00:00.000Z'); + }); + + it('should only return comparison by week', () => { + expect(expectation.comparisons).toEqual([ + { + value: TimeRangeComparisonEnum.WeekBefore, + text: 'Week before', + comparisonStart: '2022-01-08T18:00:00.000Z', + comparisonEnd: '2022-01-15T21:00:00.000Z', + offset: '1w', + }, + ]); + }); + }); + + describe('When the time difference is 8 days', () => { + let expectation: ReturnType; + + beforeAll(() => { + expectation = getExpectedTimesAndComparisons({ + rangeFrom: '2022-01-15T18:00:00.000Z', + rangeTo: '2022-01-23T18:00:00.000Z', + }); + }); + + it('should return the correct start and end date', () => { + expect(expectation.start).toBe('2022-01-15T18:00:00.000Z'); + expect(expectation.end).toBe('2022-01-23T18:00:00.000Z'); + }); + + it('should only return comparison by period and format text as DD/MM HH:mm when range years are the same', () => { + expect(expectation.comparisons).toEqual([ + { + value: TimeRangeComparisonEnum.PeriodBefore, + text: '07/01 18:00 - 15/01 18:00', + comparisonStart: '2022-01-07T18:00:00.000Z', + comparisonEnd: '2022-01-15T18:00:00.000Z', + offset: '691200000ms', + }, + ]); + }); + + it('should have the same offset for start / end and comparisonStart / comparisonEnd', () => { + const { start, end, comparisons } = expectation; + const diffInMs = moment(end).diff(moment(start)); + expect(`${diffInMs}ms`).toBe(comparisons[0].offset); + }); + }); + + describe('When the time difference is more than 8 days', () => { + let expectation: ReturnType; + + beforeAll(() => { + expectation = getExpectedTimesAndComparisons({ + rangeFrom: '2022-01-15T18:00:00.000Z||/d', + rangeTo: '2022-01-23T18:00:00.000Z', + }); + }); + + it('should return the correct start and end date', () => { + expect(expectation.start).toBe('2022-01-15T00:00:00.000Z'); + expect(expectation.end).toBe('2022-01-23T18:00:00.000Z'); + }); + + it('should only return comparison by period and format text as DD/MM HH:mm when range years are the same', () => { + expect(expectation.comparisons).toEqual([ + { + value: TimeRangeComparisonEnum.PeriodBefore, + text: '06/01 06:00 - 15/01 00:00', + comparisonStart: '2022-01-06T06:00:00.000Z', + comparisonEnd: '2022-01-15T00:00:00.000Z', + offset: '756000000ms', + }, + ]); + }); + + it('should have the same offset for start / end and comparisonStart / comparisonEnd', () => { + const { start, end, comparisons } = expectation; + const diffInMs = moment(end).diff(moment(start)); + expect(`${diffInMs}ms`).toBe(comparisons[0].offset); + }); + }); + + describe('When "Today" is selected', () => { + let expectation: ReturnType; + + beforeAll(() => { + expectation = getExpectedTimesAndComparisons({ + rangeFrom: 'now/d', + rangeTo: 'now/d', + }); + }); + + it('should return the correct start and end date', () => { + expect(expectation.start).toBe('2022-01-14T00:00:00.000Z'); + expect(expectation.end).toBe('2022-01-14T23:59:59.999Z'); + }); + + it('should return comparison by day and week', () => { + expect(expectation.comparisons).toEqual([ + { + value: TimeRangeComparisonEnum.DayBefore, + text: 'Day before', + comparisonStart: '2022-01-13T00:00:00.000Z', + comparisonEnd: '2022-01-13T23:59:59.999Z', + offset: '1d', + }, + { + value: TimeRangeComparisonEnum.WeekBefore, + text: 'Week before', + comparisonStart: '2022-01-07T00:00:00.000Z', + comparisonEnd: '2022-01-07T23:59:59.999Z', + offset: '1w', + }, + ]); + }); + }); + + describe('When "This week" is selected', () => { + let expectation: ReturnType; + + beforeAll(() => { + expectation = getExpectedTimesAndComparisons({ + rangeFrom: 'now/w', + rangeTo: 'now/w', + }); + }); + + it('should return the correct start and end date', () => { + expect(expectation.start).toBe('2022-01-09T00:00:00.000Z'); + expect(expectation.end).toBe('2022-01-15T23:59:59.999Z'); + }); + + it('should only return comparison by week', () => { + expect(expectation.comparisons).toEqual([ + { + value: TimeRangeComparisonEnum.WeekBefore, + text: 'Week before', + comparisonStart: '2022-01-02T00:00:00.000Z', + comparisonEnd: '2022-01-08T23:59:59.999Z', + offset: '1w', + }, + ]); + }); + }); + + describe('When "Last 24 hours" is selected with no rounding', () => { + let expectation: ReturnType; + + beforeAll(() => { + expectation = getExpectedTimesAndComparisons({ + rangeFrom: 'now-24h', + rangeTo: 'now', + }); + }); + + it('should return the correct start and end date', () => { + expect(expectation.start).toBe('2022-01-13T18:30:15.500Z'); + expect(expectation.end).toBe('2022-01-14T18:30:15.500Z'); + }); + + it('should return comparison by day and week', () => { + expect(expectation.comparisons).toEqual([ + { + value: TimeRangeComparisonEnum.DayBefore, + text: 'Day before', + comparisonStart: '2022-01-12T18:30:15.500Z', + comparisonEnd: '2022-01-13T18:30:15.500Z', + offset: '1d', + }, + { + value: TimeRangeComparisonEnum.WeekBefore, + text: 'Week before', + comparisonStart: '2022-01-06T18:30:15.500Z', + comparisonEnd: '2022-01-07T18:30:15.500Z', + offset: '1w', + }, + ]); + }); + }); + + describe('When "Last 24 hours" is selected with rounding', () => { + let expectation: ReturnType; + + beforeAll(() => { + expectation = getExpectedTimesAndComparisons({ + rangeFrom: 'now-24h/h', + rangeTo: 'now', + }); + }); + + it('should return the correct start and end date', () => { + expect(expectation.start).toBe('2022-01-13T18:00:00.000Z'); + expect(expectation.end).toBe('2022-01-14T18:30:15.500Z'); + }); + + it('should return comparison by day and week', () => { + expect(expectation.comparisons).toEqual([ + { + value: TimeRangeComparisonEnum.DayBefore, + text: 'Day before', + comparisonStart: '2022-01-12T18:00:00.000Z', + comparisonEnd: '2022-01-13T18:30:15.500Z', + offset: '1d', + }, + { + value: TimeRangeComparisonEnum.WeekBefore, + text: 'Week before', + comparisonStart: '2022-01-06T18:00:00.000Z', + comparisonEnd: '2022-01-07T18:30:15.500Z', + offset: '1w', + }, + ]); + }); + }); + + describe('When "Last 7 days" is selected with no rounding', () => { + let expectation: ReturnType; + + beforeAll(() => { + expectation = getExpectedTimesAndComparisons({ + rangeFrom: 'now-7d', + rangeTo: 'now', + }); + }); + + it('should return the correct start and end date', () => { + expect(expectation.start).toBe('2022-01-07T18:30:15.500Z'); + expect(expectation.end).toBe('2022-01-14T18:30:15.500Z'); + }); + + it('should only return comparison by week', () => { + expect(expectation.comparisons).toEqual([ + { + value: TimeRangeComparisonEnum.WeekBefore, + text: 'Week before', + comparisonStart: '2021-12-31T18:30:15.500Z', + comparisonEnd: '2022-01-07T18:30:15.500Z', + offset: '1w', + }, + ]); + }); + }); + + describe('When "Last 7 days" is selected with rounding', () => { + let expectation: ReturnType; + + beforeAll(() => { + expectation = getExpectedTimesAndComparisons({ + rangeFrom: 'now-7d/d', + rangeTo: 'now', + }); + }); + + it('should return the correct start and end date', () => { + expect(expectation.start).toBe('2022-01-07T00:00:00.000Z'); + expect(expectation.end).toBe('2022-01-14T18:30:15.500Z'); + }); + + it('should only return comparison by week', () => { + expect(expectation.comparisons).toEqual([ + { + value: TimeRangeComparisonEnum.WeekBefore, + text: 'Week before', + comparisonStart: '2021-12-31T00:00:00.000Z', + comparisonEnd: '2022-01-07T18:30:15.500Z', + offset: '1w', + }, + ]); + }); + }); + + describe('When "Last 30 days" is selected with no rounding', () => { + let expectation: ReturnType; + + beforeAll(() => { + expectation = getExpectedTimesAndComparisons({ + rangeFrom: 'now-30d', + rangeTo: 'now', + }); + }); + + it('should return the correct start and end date', () => { + expect(expectation.start).toBe('2021-12-15T18:30:15.500Z'); + expect(expectation.end).toBe('2022-01-14T18:30:15.500Z'); + }); + + it('should only return comparison by period and format text as DD/MM/YY HH:mm when range years are different', () => { + expect(expectation.comparisons).toEqual([ + { + value: TimeRangeComparisonEnum.PeriodBefore, + text: '15/11/21 18:30 - 15/12/21 18:30', + comparisonStart: '2021-11-15T18:30:15.500Z', + comparisonEnd: '2021-12-15T18:30:15.500Z', + offset: '2592000000ms', + }, + ]); + }); + + it('should have the same offset for start / end and comparisonStart / comparisonEnd', () => { + const { start, end, comparisons } = expectation; + const diffInMs = moment(end).diff(moment(start)); + expect(`${diffInMs}ms`).toBe(comparisons[0].offset); + }); + }); + + describe('When "Last 30 days" is selected with rounding', () => { + let expectation: ReturnType; + + beforeAll(() => { + expectation = getExpectedTimesAndComparisons({ + rangeFrom: 'now-30d/d', + rangeTo: 'now', + }); + }); + + it('should return the correct start and end date', () => { + expect(expectation.start).toBe('2021-12-15T00:00:00.000Z'); + expect(expectation.end).toBe('2022-01-14T18:30:15.500Z'); + }); + + it('should only return comparison by period and format text as DD/MM/YY HH:mm when range years are different', () => { + expect(expectation.comparisons).toEqual([ + { + value: TimeRangeComparisonEnum.PeriodBefore, + text: '14/11/21 05:29 - 15/12/21 00:00', + comparisonStart: '2021-11-14T05:29:44.500Z', + comparisonEnd: '2021-12-15T00:00:00.000Z', + offset: '2658615500ms', + }, + ]); + }); + + it('should have the same offset for start / end and comparisonStart / comparisonEnd', () => { + const { start, end, comparisons } = expectation; + const diffInMs = moment(end).diff(moment(start)); + expect(`${diffInMs}ms`).toBe(comparisons[0].offset); + }); + }); +}); diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/get_comparison_options.ts b/x-pack/plugins/apm/public/components/shared/time_comparison/get_comparison_options.ts new file mode 100644 index 0000000000000..9400f668a18f0 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/get_comparison_options.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import moment from 'moment'; +import { i18n } from '@kbn/i18n'; +import { TimeRangeComparisonEnum } from '../../../../common/runtime_types/comparison_type_rt'; +import { getTimeRangeComparison } from './get_time_range_comparison'; + +const eightDaysInHours = moment.duration(8, 'd').asHours(); + +function getDateFormat({ + previousPeriodStart, + currentPeriodEnd, +}: { + previousPeriodStart?: string; + currentPeriodEnd?: string; +}) { + const momentPreviousPeriodStart = moment(previousPeriodStart); + const momentCurrentPeriodEnd = moment(currentPeriodEnd); + const isDifferentYears = + momentPreviousPeriodStart.get('year') !== + momentCurrentPeriodEnd.get('year'); + return isDifferentYears ? 'DD/MM/YY HH:mm' : 'DD/MM HH:mm'; +} + +function formatDate({ + dateFormat, + previousPeriodStart, + previousPeriodEnd, +}: { + dateFormat: string; + previousPeriodStart?: string; + previousPeriodEnd?: string; +}) { + const momentStart = moment(previousPeriodStart); + const momentEnd = moment(previousPeriodEnd); + return `${momentStart.format(dateFormat)} - ${momentEnd.format(dateFormat)}`; +} + +function getSelectOptions({ + comparisonTypes, + start, + end, +}: { + comparisonTypes: TimeRangeComparisonEnum[]; + start?: string; + end?: string; +}) { + return comparisonTypes.map((value) => { + switch (value) { + case TimeRangeComparisonEnum.DayBefore: { + return { + value, + text: i18n.translate('xpack.apm.timeComparison.select.dayBefore', { + defaultMessage: 'Day before', + }), + }; + } + case TimeRangeComparisonEnum.WeekBefore: { + return { + value, + text: i18n.translate('xpack.apm.timeComparison.select.weekBefore', { + defaultMessage: 'Week before', + }), + }; + } + case TimeRangeComparisonEnum.PeriodBefore: { + const { comparisonStart, comparisonEnd } = getTimeRangeComparison({ + comparisonType: TimeRangeComparisonEnum.PeriodBefore, + start, + end, + comparisonEnabled: true, + }); + + const dateFormat = getDateFormat({ + previousPeriodStart: comparisonStart, + currentPeriodEnd: end, + }); + + return { + value, + text: formatDate({ + dateFormat, + previousPeriodStart: comparisonStart, + previousPeriodEnd: comparisonEnd, + }), + }; + } + } + }); +} + +export function getComparisonOptions({ + start, + end, +}: { + start?: string; + end?: string; +}) { + const momentStart = moment(start); + const momentEnd = moment(end); + const hourDiff = momentEnd.diff(momentStart, 'h', true); + + let comparisonTypes: TimeRangeComparisonEnum[]; + + if (hourDiff < 25) { + // Less than 25 hours. This is because relative times may be rounded when + // asking for a day, which can result in a duration > 24h. (e.g. rangeFrom: 'now-24h/h, rangeTo: 'now') + comparisonTypes = [ + TimeRangeComparisonEnum.DayBefore, + TimeRangeComparisonEnum.WeekBefore, + ]; + } else if (hourDiff < eightDaysInHours) { + // Less than 8 days. This is because relative times may be rounded when + // asking for a week, which can result in a duration > 7d. (e.g. rangeFrom: 'now-7d/d, rangeTo: 'now') + comparisonTypes = [TimeRangeComparisonEnum.WeekBefore]; + } else { + comparisonTypes = [TimeRangeComparisonEnum.PeriodBefore]; + } + + return getSelectOptions({ comparisonTypes, start, end }); +} diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/get_comparison_types.ts b/x-pack/plugins/apm/public/components/shared/time_comparison/get_comparison_types.ts deleted file mode 100644 index 97754cd91fd3e..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/time_comparison/get_comparison_types.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import moment from 'moment'; -import { TimeRangeComparisonEnum } from '../../../../common/runtime_types/comparison_type_rt'; -import { getDateDifference } from '../../../../common/utils/formatters'; - -export function getComparisonTypes({ - start, - end, -}: { - start?: string; - end?: string; -}) { - const momentStart = moment(start).startOf('second'); - const momentEnd = moment(end).startOf('second'); - - const dateDiff = getDateDifference({ - start: momentStart, - end: momentEnd, - precise: true, - unitOfTime: 'days', - }); - - // Less than or equals to one day - if (dateDiff <= 1) { - return [ - TimeRangeComparisonEnum.DayBefore, - TimeRangeComparisonEnum.WeekBefore, - ]; - } - - // Less than or equals to one week - if (dateDiff <= 7) { - return [TimeRangeComparisonEnum.WeekBefore]; - } - // } - - // above one week or when rangeTo is not "now" - return [TimeRangeComparisonEnum.PeriodBefore]; -} diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.test.ts b/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.test.ts index 7e67d76c2ada2..c6619ad6d35f3 100644 --- a/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.test.ts +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.test.ts @@ -41,102 +41,4 @@ describe('getTimeRangeComparison', () => { expect(result).toEqual({}); }); }); - - describe('Time range is between 0 - 24 hours', () => { - describe('when day before is selected', () => { - it('returns the correct time range - 15 min', () => { - const start = '2021-01-28T14:45:00.000Z'; - const end = '2021-01-28T15:00:00.000Z'; - const result = getTimeRangeComparison({ - comparisonType: TimeRangeComparisonEnum.DayBefore, - comparisonEnabled: true, - start, - end, - }); - expect(result.comparisonStart).toEqual('2021-01-27T14:45:00.000Z'); - expect(result.comparisonEnd).toEqual('2021-01-27T15:00:00.000Z'); - expect(result.offset).toEqual('1d'); - }); - }); - describe('when a week before is selected', () => { - it('returns the correct time range - 15 min', () => { - const start = '2021-01-28T14:45:00.000Z'; - const end = '2021-01-28T15:00:00.000Z'; - const result = getTimeRangeComparison({ - comparisonType: TimeRangeComparisonEnum.WeekBefore, - comparisonEnabled: true, - start, - end, - }); - expect(result.comparisonStart).toEqual('2021-01-21T14:45:00.000Z'); - expect(result.comparisonEnd).toEqual('2021-01-21T15:00:00.000Z'); - expect(result.offset).toEqual('1w'); - }); - }); - describe('when previous period is selected', () => { - it('returns the correct time range - 15 min', () => { - const start = '2021-02-09T14:40:01.087Z'; - const end = '2021-02-09T14:56:00.000Z'; - const result = getTimeRangeComparison({ - start, - end, - comparisonType: TimeRangeComparisonEnum.PeriodBefore, - comparisonEnabled: true, - }); - expect(result).toEqual({ - comparisonStart: '2021-02-09T14:24:02.174Z', - comparisonEnd: '2021-02-09T14:40:01.087Z', - offset: '958913ms', - }); - }); - }); - }); - - describe('Time range is between 24 hours - 1 week', () => { - describe('when a week before is selected', () => { - it('returns the correct time range - 2 days', () => { - const start = '2021-01-26T15:00:00.000Z'; - const end = '2021-01-28T15:00:00.000Z'; - const result = getTimeRangeComparison({ - comparisonType: TimeRangeComparisonEnum.WeekBefore, - comparisonEnabled: true, - start, - end, - }); - expect(result.comparisonStart).toEqual('2021-01-19T15:00:00.000Z'); - expect(result.comparisonEnd).toEqual('2021-01-21T15:00:00.000Z'); - expect(result.offset).toEqual('1w'); - }); - }); - }); - - describe('Time range is greater than 7 days', () => { - it('uses the date difference to calculate the time range - 8 days', () => { - const start = '2021-01-10T15:00:00.000Z'; - const end = '2021-01-18T15:00:00.000Z'; - const result = getTimeRangeComparison({ - comparisonType: TimeRangeComparisonEnum.PeriodBefore, - comparisonEnabled: true, - start, - end, - }); - expect(result.comparisonStart).toEqual('2021-01-02T15:00:00.000Z'); - expect(result.comparisonEnd).toEqual('2021-01-10T15:00:00.000Z'); - expect(result.offset).toEqual('691200000ms'); - }); - - it('uses the date difference to calculate the time range - 30 days', () => { - const start = '2021-01-01T15:00:00.000Z'; - const end = '2021-01-31T15:00:00.000Z'; - const result = getTimeRangeComparison({ - comparisonType: TimeRangeComparisonEnum.PeriodBefore, - comparisonEnabled: true, - start, - end, - }); - expect(result.comparisonStart).toEqual('2020-12-02T15:00:00.000Z'); - expect(result.comparisonEnd).toEqual('2021-01-01T15:00:00.000Z'); - expect(result.offset).toEqual('2592000000ms'); - }); - }); }); diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.ts b/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.ts index 92611d88aa0cc..6a1f7b1978ca0 100644 --- a/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.ts +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.ts @@ -11,7 +11,6 @@ import { TimeRangeComparisonType, TimeRangeComparisonEnum, } from '../../../../common/runtime_types/comparison_type_rt'; -import { getDateDifference } from '../../../../common/utils/formatters'; export function getComparisonChartTheme(): PartialTheme { return { @@ -48,13 +47,9 @@ export function getTimeRangeComparison({ if (!comparisonEnabled || !comparisonType || !start || !end) { return {}; } - const startMoment = moment(start); const endMoment = moment(end); - const startEpoch = startMoment.valueOf(); - const endEpoch = endMoment.valueOf(); - let diff: number; let offset: string; @@ -63,29 +58,21 @@ export function getTimeRangeComparison({ diff = oneDayInMilliseconds; offset = '1d'; break; - case TimeRangeComparisonEnum.WeekBefore: diff = oneWeekInMilliseconds; offset = '1w'; break; - case TimeRangeComparisonEnum.PeriodBefore: - diff = getDateDifference({ - start: startMoment, - end: endMoment, - unitOfTime: 'milliseconds', - precise: true, - }); + diff = endMoment.diff(startMoment); offset = `${diff}ms`; break; - default: throw new Error('Unknown comparisonType'); } return { - comparisonStart: new Date(startEpoch - diff).toISOString(), - comparisonEnd: new Date(endEpoch - diff).toISOString(), + comparisonStart: startMoment.subtract(diff, 'ms').toISOString(), + comparisonEnd: endMoment.subtract(diff, 'ms').toISOString(), offset, }; } diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/index.test.tsx b/x-pack/plugins/apm/public/components/shared/time_comparison/index.test.tsx index d811fbb5d0357..83d2962316aa2 100644 --- a/x-pack/plugins/apm/public/components/shared/time_comparison/index.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/index.test.tsx @@ -13,10 +13,9 @@ import { expectTextsInDocument, expectTextsNotInDocument, } from '../../../utils/test_helpers'; -import { getSelectOptions, TimeComparison } from './'; +import { TimeComparison } from './'; import * as urlHelpers from '../../shared/links/url_helpers'; import moment from 'moment'; -import { getComparisonTypes } from './get_comparison_types'; import { MockApmPluginContextWrapper } from '../../../context/apm_plugin/mock_apm_plugin_context'; import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; import { @@ -26,14 +25,14 @@ import { import { MockUrlParamsContextProvider } from '../../../context/url_params_context/mock_url_params_context_provider'; function getWrapper({ - exactStart, - exactEnd, + rangeFrom, + rangeTo, comparisonType, comparisonEnabled, environment = ENVIRONMENT_ALL.value, }: { - exactStart: string; - exactEnd: string; + rangeFrom: string; + rangeTo: string; comparisonType?: TimeRangeComparisonType; comparisonEnabled?: boolean; environment?: string; @@ -42,7 +41,7 @@ function getWrapper({ return ( { +describe('TimeComparison component', () => { beforeAll(() => { moment.tz.setDefault('Europe/Amsterdam'); }); afterAll(() => moment.tz.setDefault('')); - describe('getComparisonTypes', () => { - it('shows week and day before when 15 minutes is selected', () => { - expect( - getComparisonTypes({ - start: '2021-06-04T16:17:02.335Z', - end: '2021-06-04T16:32:02.335Z', - }) - ).toEqual([ - TimeRangeComparisonEnum.DayBefore.valueOf(), - TimeRangeComparisonEnum.WeekBefore.valueOf(), - ]); - }); - - it('shows week and day before when Today is selected', () => { - expect( - getComparisonTypes({ - start: '2021-06-04T04:00:00.000Z', - end: '2021-06-05T03:59:59.999Z', - }) - ).toEqual([ - TimeRangeComparisonEnum.DayBefore.valueOf(), - TimeRangeComparisonEnum.WeekBefore.valueOf(), - ]); - }); - - it('shows week and day before when 24 hours is selected', () => { - expect( - getComparisonTypes({ - start: '2021-06-03T16:31:35.748Z', - end: '2021-06-04T16:31:35.748Z', - }) - ).toEqual([ - TimeRangeComparisonEnum.DayBefore.valueOf(), - TimeRangeComparisonEnum.WeekBefore.valueOf(), - ]); - }); + const spy = jest.spyOn(urlHelpers, 'replace'); + beforeEach(() => { + jest.resetAllMocks(); + }); - it('shows week and day before when 24 hours is selected but milliseconds are different', () => { - expect( - getComparisonTypes({ - start: '2021-10-15T00:52:59.554Z', - end: '2021-10-14T00:52:59.553Z', - }) - ).toEqual([ - TimeRangeComparisonEnum.DayBefore.valueOf(), - TimeRangeComparisonEnum.WeekBefore.valueOf(), - ]); + describe('Time range is between 0 - 25 hours', () => { + it('sets default values', () => { + const Wrapper = getWrapper({ + rangeFrom: '2021-06-04T16:17:02.335Z', + rangeTo: '2021-06-04T16:32:02.335Z', + }); + render(, { wrapper: Wrapper }); + expect(spy).toHaveBeenCalledWith(expect.anything(), { + query: { + comparisonEnabled: 'true', + comparisonType: TimeRangeComparisonEnum.DayBefore, + }, + }); }); - it('shows week before when 25 hours is selected', () => { + it('selects day before and enables comparison', () => { + const Wrapper = getWrapper({ + rangeFrom: '2021-06-04T16:17:02.335Z', + rangeTo: '2021-06-04T16:32:02.335Z', + comparisonEnabled: true, + comparisonType: TimeRangeComparisonEnum.DayBefore, + }); + const component = render(, { wrapper: Wrapper }); + expectTextsInDocument(component, ['Day before', 'Week before']); expect( - getComparisonTypes({ - start: '2021-06-02T12:32:00.000Z', - end: '2021-06-03T13:32:09.079Z', - }) - ).toEqual([TimeRangeComparisonEnum.WeekBefore.valueOf()]); + (component.getByTestId('comparisonSelect') as HTMLSelectElement) + .selectedIndex + ).toEqual(0); }); - it('shows week before when 7 days is selected', () => { - expect( - getComparisonTypes({ - start: '2021-05-28T16:32:17.520Z', - end: '2021-06-04T16:32:17.520Z', - }) - ).toEqual([TimeRangeComparisonEnum.WeekBefore.valueOf()]); - }); - it('shows period before when 8 days is selected', () => { + it('enables day before option when date difference is equal to 24 hours', () => { + const Wrapper = getWrapper({ + rangeFrom: '2021-06-03T16:31:35.748Z', + rangeTo: '2021-06-04T16:31:35.748Z', + comparisonEnabled: true, + comparisonType: TimeRangeComparisonEnum.DayBefore, + }); + const component = render(, { wrapper: Wrapper }); + expectTextsInDocument(component, ['Day before', 'Week before']); expect( - getComparisonTypes({ - start: '2021-05-27T16:32:46.747Z', - end: '2021-06-04T16:32:46.747Z', - }) - ).toEqual([TimeRangeComparisonEnum.PeriodBefore.valueOf()]); + (component.getByTestId('comparisonSelect') as HTMLSelectElement) + .selectedIndex + ).toEqual(0); }); }); - describe('getSelectOptions', () => { - it('returns formatted text based on comparison type', () => { - expect( - getSelectOptions({ - comparisonTypes: [ - TimeRangeComparisonEnum.DayBefore, - TimeRangeComparisonEnum.WeekBefore, - TimeRangeComparisonEnum.PeriodBefore, - ], - start: '2021-05-27T16:32:46.747Z', - end: '2021-06-04T16:32:46.747Z', - }) - ).toEqual([ - { - value: TimeRangeComparisonEnum.DayBefore.valueOf(), - text: 'Day before', - }, - { - value: TimeRangeComparisonEnum.WeekBefore.valueOf(), - text: 'Week before', - }, - { - value: TimeRangeComparisonEnum.PeriodBefore.valueOf(), - text: '19/05 18:32 - 27/05 18:32', - }, - ]); + describe('Time range is between 25 hours - 8 days', () => { + it("doesn't show day before option when date difference is greater than 25 hours", () => { + const Wrapper = getWrapper({ + rangeFrom: '2021-06-02T12:32:00.000Z', + rangeTo: '2021-06-03T13:32:09.079Z', + comparisonEnabled: true, + comparisonType: TimeRangeComparisonEnum.WeekBefore, + }); + const component = render(, { + wrapper: Wrapper, + }); + expectTextsNotInDocument(component, ['Day before']); + expectTextsInDocument(component, ['Week before']); }); - it('formats period before as DD/MM/YY HH:mm when range years are different', () => { - expect( - getSelectOptions({ - comparisonTypes: [TimeRangeComparisonEnum.PeriodBefore], - start: '2020-05-27T16:32:46.747Z', - end: '2021-06-04T16:32:46.747Z', - }) - ).toEqual([ - { - value: TimeRangeComparisonEnum.PeriodBefore.valueOf(), - text: '20/05/19 18:32 - 27/05/20 18:32', + it('sets default values', () => { + const Wrapper = getWrapper({ + rangeFrom: '2021-06-02T12:32:00.000Z', + rangeTo: '2021-06-03T13:32:09.079Z', + }); + render(, { + wrapper: Wrapper, + }); + expect(spy).toHaveBeenCalledWith(expect.anything(), { + query: { + comparisonEnabled: 'true', + comparisonType: TimeRangeComparisonEnum.WeekBefore, }, - ]); + }); }); - }); - describe('TimeComparison component', () => { - const spy = jest.spyOn(urlHelpers, 'replace'); - beforeEach(() => { - jest.resetAllMocks(); - }); - describe('Time range is between 0 - 24 hours', () => { - it('sets default values', () => { - const Wrapper = getWrapper({ - exactStart: '2021-06-04T16:17:02.335Z', - exactEnd: '2021-06-04T16:32:02.335Z', - }); - render(, { wrapper: Wrapper }); - expect(spy).toHaveBeenCalledWith(expect.anything(), { - query: { - comparisonEnabled: 'true', - comparisonType: TimeRangeComparisonEnum.DayBefore, - }, - }); + it('selects week before and enables comparison', () => { + const Wrapper = getWrapper({ + rangeFrom: '2021-06-02T12:32:00.000Z', + rangeTo: '2021-06-03T13:32:09.079Z', + comparisonEnabled: true, + comparisonType: TimeRangeComparisonEnum.WeekBefore, }); - it('selects day before and enables comparison', () => { - const Wrapper = getWrapper({ - exactStart: '2021-06-04T16:17:02.335Z', - exactEnd: '2021-06-04T16:32:02.335Z', - comparisonEnabled: true, - comparisonType: TimeRangeComparisonEnum.DayBefore, - }); - const component = render(, { wrapper: Wrapper }); - expectTextsInDocument(component, ['Day before', 'Week before']); - expect( - (component.getByTestId('comparisonSelect') as HTMLSelectElement) - .selectedIndex - ).toEqual(0); - }); - - it('enables day before option when date difference is equal to 24 hours', () => { - const Wrapper = getWrapper({ - exactStart: '2021-06-03T16:31:35.748Z', - exactEnd: '2021-06-04T16:31:35.748Z', - comparisonEnabled: true, - comparisonType: TimeRangeComparisonEnum.DayBefore, - }); - const component = render(, { wrapper: Wrapper }); - expectTextsInDocument(component, ['Day before', 'Week before']); - expect( - (component.getByTestId('comparisonSelect') as HTMLSelectElement) - .selectedIndex - ).toEqual(0); + const component = render(, { + wrapper: Wrapper, }); + expectTextsNotInDocument(component, ['Day before']); + expectTextsInDocument(component, ['Week before']); + expect( + (component.getByTestId('comparisonSelect') as HTMLSelectElement) + .selectedIndex + ).toEqual(0); }); + }); - describe('Time range is between 24 hours - 1 week', () => { - it("doesn't show day before option when date difference is greater than 24 hours", () => { - const Wrapper = getWrapper({ - exactStart: '2021-06-02T12:32:00.000Z', - exactEnd: '2021-06-03T13:32:09.079Z', - comparisonEnabled: true, - comparisonType: TimeRangeComparisonEnum.WeekBefore, - }); - const component = render(, { - wrapper: Wrapper, - }); - expectTextsNotInDocument(component, ['Day before']); - expectTextsInDocument(component, ['Week before']); + describe('Time range is greater than 8 days', () => { + it('Shows absolute times without year when within the same year', () => { + const Wrapper = getWrapper({ + rangeFrom: '2021-05-27T16:32:46.747Z', + rangeTo: '2021-06-04T16:32:46.747Z', + comparisonEnabled: true, + comparisonType: TimeRangeComparisonEnum.PeriodBefore, }); - it('sets default values', () => { - const Wrapper = getWrapper({ - exactStart: '2021-06-02T12:32:00.000Z', - exactEnd: '2021-06-03T13:32:09.079Z', - }); - render(, { - wrapper: Wrapper, - }); - expect(spy).toHaveBeenCalledWith(expect.anything(), { - query: { - comparisonEnabled: 'true', - comparisonType: TimeRangeComparisonEnum.WeekBefore, - }, - }); - }); - it('selects week before and enables comparison', () => { - const Wrapper = getWrapper({ - exactStart: '2021-06-02T12:32:00.000Z', - exactEnd: '2021-06-03T13:32:09.079Z', - comparisonEnabled: true, - comparisonType: TimeRangeComparisonEnum.WeekBefore, - }); - const component = render(, { - wrapper: Wrapper, - }); - expectTextsNotInDocument(component, ['Day before']); - expectTextsInDocument(component, ['Week before']); - expect( - (component.getByTestId('comparisonSelect') as HTMLSelectElement) - .selectedIndex - ).toEqual(0); + const component = render(, { + wrapper: Wrapper, }); + expect(spy).not.toHaveBeenCalled(); + expectTextsInDocument(component, ['19/05 18:32 - 27/05 18:32']); + expect( + (component.getByTestId('comparisonSelect') as HTMLSelectElement) + .selectedIndex + ).toEqual(0); }); - describe('Time range is greater than 7 days', () => { - it('Shows absolute times without year when within the same year', () => { - const Wrapper = getWrapper({ - exactStart: '2021-05-27T16:32:46.747Z', - exactEnd: '2021-06-04T16:32:46.747Z', - comparisonEnabled: true, - comparisonType: TimeRangeComparisonEnum.PeriodBefore, - }); - const component = render(, { - wrapper: Wrapper, - }); - expect(spy).not.toHaveBeenCalled(); - expectTextsInDocument(component, ['19/05 18:32 - 27/05 18:32']); - expect( - (component.getByTestId('comparisonSelect') as HTMLSelectElement) - .selectedIndex - ).toEqual(0); + it('Shows absolute times with year when on different year', () => { + const Wrapper = getWrapper({ + rangeFrom: '2020-05-27T16:32:46.747Z', + rangeTo: '2021-06-04T16:32:46.747Z', + comparisonEnabled: true, + comparisonType: TimeRangeComparisonEnum.PeriodBefore, }); - - it('Shows absolute times with year when on different year', () => { - const Wrapper = getWrapper({ - exactStart: '2020-05-27T16:32:46.747Z', - exactEnd: '2021-06-04T16:32:46.747Z', - comparisonEnabled: true, - comparisonType: TimeRangeComparisonEnum.PeriodBefore, - }); - const component = render(, { - wrapper: Wrapper, - }); - expect(spy).not.toHaveBeenCalled(); - expectTextsInDocument(component, ['20/05/19 18:32 - 27/05/20 18:32']); - expect( - (component.getByTestId('comparisonSelect') as HTMLSelectElement) - .selectedIndex - ).toEqual(0); + const component = render(, { + wrapper: Wrapper, }); + expect(spy).not.toHaveBeenCalled(); + expectTextsInDocument(component, ['20/05/19 18:32 - 27/05/20 18:32']); + expect( + (component.getByTestId('comparisonSelect') as HTMLSelectElement) + .selectedIndex + ).toEqual(0); }); }); }); diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx b/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx index e61ffbbbc5bab..cb0bc870354c4 100644 --- a/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx @@ -7,12 +7,10 @@ import { EuiCheckbox, EuiSelect } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import moment from 'moment'; import React from 'react'; import { useHistory } from 'react-router-dom'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; import { useUiTracker } from '../../../../../observability/public'; -import { TimeRangeComparisonEnum } from '../../../../common/runtime_types/comparison_type_rt'; import { useLegacyUrlParams } from '../../../context/url_params_context/use_url_params'; import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context'; import { useAnyOfApmParams } from '../../../hooks/use_apm_params'; @@ -20,8 +18,7 @@ import { useBreakpoints } from '../../../hooks/use_breakpoints'; import { useTimeRange } from '../../../hooks/use_time_range'; import * as urlHelpers from '../../shared/links/url_helpers'; import { getComparisonEnabled } from './get_comparison_enabled'; -import { getComparisonTypes } from './get_comparison_types'; -import { getTimeRangeComparison } from './get_time_range_comparison'; +import { getComparisonOptions } from './get_comparison_options'; const PrependContainer = euiStyled.div` display: flex; @@ -32,88 +29,6 @@ const PrependContainer = euiStyled.div` padding: 0 ${({ theme }) => theme.eui.paddingSizes.m}; `; -function getDateFormat({ - previousPeriodStart, - currentPeriodEnd, -}: { - previousPeriodStart?: string; - currentPeriodEnd?: string; -}) { - const momentPreviousPeriodStart = moment(previousPeriodStart); - const momentCurrentPeriodEnd = moment(currentPeriodEnd); - const isDifferentYears = - momentPreviousPeriodStart.get('year') !== - momentCurrentPeriodEnd.get('year'); - return isDifferentYears ? 'DD/MM/YY HH:mm' : 'DD/MM HH:mm'; -} - -function formatDate({ - dateFormat, - previousPeriodStart, - previousPeriodEnd, -}: { - dateFormat: string; - previousPeriodStart?: string; - previousPeriodEnd?: string; -}) { - const momentStart = moment(previousPeriodStart); - const momentEnd = moment(previousPeriodEnd); - return `${momentStart.format(dateFormat)} - ${momentEnd.format(dateFormat)}`; -} - -export function getSelectOptions({ - comparisonTypes, - start, - end, -}: { - comparisonTypes: TimeRangeComparisonEnum[]; - start?: string; - end?: string; -}) { - return comparisonTypes.map((value) => { - switch (value) { - case TimeRangeComparisonEnum.DayBefore: { - return { - value, - text: i18n.translate('xpack.apm.timeComparison.select.dayBefore', { - defaultMessage: 'Day before', - }), - }; - } - case TimeRangeComparisonEnum.WeekBefore: { - return { - value, - text: i18n.translate('xpack.apm.timeComparison.select.weekBefore', { - defaultMessage: 'Week before', - }), - }; - } - case TimeRangeComparisonEnum.PeriodBefore: { - const { comparisonStart, comparisonEnd } = getTimeRangeComparison({ - comparisonType: TimeRangeComparisonEnum.PeriodBefore, - start, - end, - comparisonEnabled: true, - }); - - const dateFormat = getDateFormat({ - previousPeriodStart: comparisonStart, - currentPeriodEnd: end, - }); - - return { - value, - text: formatDate({ - dateFormat, - previousPeriodStart: comparisonStart, - previousPeriodEnd: comparisonEnd, - }), - }; - } - } - }); -} - export function TimeComparison() { const { core } = useApmPluginContext(); const trackApmEvent = useUiTracker({ app: 'apm' }); @@ -123,19 +38,13 @@ export function TimeComparison() { query: { rangeFrom, rangeTo }, } = useAnyOfApmParams('/services', '/backends/*', '/services/{serviceName}'); - const { exactStart, exactEnd } = useTimeRange({ - rangeFrom, - rangeTo, - }); + const { start, end } = useTimeRange({ rangeFrom, rangeTo }); const { urlParams: { comparisonEnabled, comparisonType }, } = useLegacyUrlParams(); - const comparisonTypes = getComparisonTypes({ - start: exactStart, - end: exactEnd, - }); + const comparisonOptions = getComparisonOptions({ start, end }); // Sets default values if (comparisonEnabled === undefined || comparisonType === undefined) { @@ -148,26 +57,22 @@ export function TimeComparison() { }) === false ? 'false' : 'true', - comparisonType: comparisonType ? comparisonType : comparisonTypes[0], + comparisonType: comparisonType + ? comparisonType + : comparisonOptions[0].value, }, }); return null; } - const selectOptions = getSelectOptions({ - comparisonTypes, - start: exactStart, - end: exactEnd, - }); - - const isSelectedComparisonTypeAvailable = selectOptions.some( + const isSelectedComparisonTypeAvailable = comparisonOptions.some( ({ value }) => value === comparisonType ); // Replaces type when current one is no longer available in the select options - if (selectOptions.length !== 0 && !isSelectedComparisonTypeAvailable) { + if (comparisonOptions.length !== 0 && !isSelectedComparisonTypeAvailable) { urlHelpers.replace(history, { - query: { comparisonType: selectOptions[0].value }, + query: { comparisonType: comparisonOptions[0].value }, }); return null; } @@ -177,7 +82,7 @@ export function TimeComparison() { fullWidth={isSmall} data-test-subj="comparisonSelect" disabled={!comparisonEnabled} - options={selectOptions} + options={comparisonOptions} value={comparisonType} prepend={ diff --git a/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx b/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx index 4c1063173d929..bf6e2b70d390e 100644 --- a/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx @@ -15,7 +15,6 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { EuiCode } from '@elastic/eui'; import { APIReturnType } from '../../../services/rest/create_call_apm_api'; import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context'; -import { useLegacyUrlParams } from '../../../context/url_params_context/use_url_params'; import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; import { TransactionOverviewLink } from '../links/apm/transaction_overview_link'; import { getTimeRangeComparison } from '../time_comparison/get_time_range_comparison'; @@ -24,6 +23,8 @@ import { getColumns } from './get_columns'; import { ElasticDocsLink } from '../links/elastic_docs_link'; import { useBreakpoints } from '../../../hooks/use_breakpoints'; import { ManagedTable } from '../managed_table'; +import { useAnyOfApmParams } from '../../../hooks/use_apm_params'; +import { LatencyAggregationType } from '../../../../common/latency_aggregation_types'; type ApiResponse = APIReturnType<'GET /internal/apm/services/{serviceName}/transactions/groups/main_statistics'>; @@ -97,8 +98,11 @@ export function TransactionsTable({ const { transactionType, serviceName } = useApmServiceContext(); const { - urlParams: { latencyAggregationType, comparisonType, comparisonEnabled }, - } = useLegacyUrlParams(); + query: { comparisonEnabled, comparisonType, latencyAggregationType }, + } = useAnyOfApmParams( + '/services/{serviceName}/transactions', + '/services/{serviceName}/overview' + ); const { comparisonStart, comparisonEnd } = getTimeRangeComparison({ start, @@ -123,7 +127,8 @@ export function TransactionsTable({ start, end, transactionType, - latencyAggregationType, + latencyAggregationType: + latencyAggregationType as LatencyAggregationType, }, }, } @@ -198,7 +203,8 @@ export function TransactionsTable({ end, numBuckets: 20, transactionType, - latencyAggregationType, + latencyAggregationType: + latencyAggregationType as LatencyAggregationType, transactionNames: JSON.stringify( transactionGroups.map(({ name }) => name).sort() ), @@ -218,7 +224,7 @@ export function TransactionsTable({ const columns = getColumns({ serviceName, - latencyAggregationType, + latencyAggregationType: latencyAggregationType as LatencyAggregationType, transactionGroupDetailedStatistics, comparisonEnabled, shouldShowSparkPlots, diff --git a/x-pack/plugins/apm/public/context/url_params_context/helpers.test.ts b/x-pack/plugins/apm/public/context/url_params_context/helpers.test.ts index 784b10b3f3ee1..9ab0948fd75aa 100644 --- a/x-pack/plugins/apm/public/context/url_params_context/helpers.test.ts +++ b/x-pack/plugins/apm/public/context/url_params_context/helpers.test.ts @@ -14,58 +14,6 @@ describe('url_params_context helpers', () => { jest.restoreAllMocks(); }); describe('getDateRange', () => { - describe('with non-rounded dates', () => { - describe('one minute', () => { - it('rounds the start value to minute', () => { - expect( - helpers.getDateRange({ - state: {}, - rangeFrom: '2021-01-28T05:47:52.134Z', - rangeTo: '2021-01-28T05:48:55.304Z', - }) - ).toEqual({ - start: '2021-01-28T05:47:00.000Z', - end: '2021-01-28T05:48:55.304Z', - exactStart: '2021-01-28T05:47:52.134Z', - exactEnd: '2021-01-28T05:48:55.304Z', - }); - }); - }); - describe('one day', () => { - it('rounds the start value to minute', () => { - expect( - helpers.getDateRange({ - state: {}, - rangeFrom: '2021-01-27T05:46:07.377Z', - rangeTo: '2021-01-28T05:46:13.367Z', - }) - ).toEqual({ - start: '2021-01-27T05:46:00.000Z', - end: '2021-01-28T05:46:13.367Z', - exactStart: '2021-01-27T05:46:07.377Z', - exactEnd: '2021-01-28T05:46:13.367Z', - }); - }); - }); - - describe('one year', () => { - it('rounds the start value to minute', () => { - expect( - helpers.getDateRange({ - state: {}, - rangeFrom: '2020-01-28T05:52:36.290Z', - rangeTo: '2021-01-28T05:52:39.741Z', - }) - ).toEqual({ - start: '2020-01-28T05:52:00.000Z', - end: '2021-01-28T05:52:39.741Z', - exactStart: '2020-01-28T05:52:36.290Z', - exactEnd: '2021-01-28T05:52:39.741Z', - }); - }); - }); - }); - describe('when rangeFrom and rangeTo are not changed', () => { it('returns the previous state', () => { expect( @@ -75,8 +23,6 @@ describe('url_params_context helpers', () => { rangeTo: 'now', start: '1970-01-01T00:00:00.000Z', end: '1971-01-01T00:00:00.000Z', - exactStart: '1970-01-01T00:00:00.000Z', - exactEnd: '1971-01-01T00:00:00.000Z', }, rangeFrom: 'now-1m', rangeTo: 'now', @@ -84,8 +30,6 @@ describe('url_params_context helpers', () => { ).toEqual({ start: '1970-01-01T00:00:00.000Z', end: '1971-01-01T00:00:00.000Z', - exactStart: '1970-01-01T00:00:00.000Z', - exactEnd: '1971-01-01T00:00:00.000Z', }); }); }); @@ -107,8 +51,6 @@ describe('url_params_context helpers', () => { ).toEqual({ start: '1972-01-01T00:00:00.000Z', end: '1973-01-01T00:00:00.000Z', - exactStart: undefined, - exactEnd: undefined, }); }); }); @@ -119,32 +61,27 @@ describe('url_params_context helpers', () => { jest .spyOn(datemath, 'parse') .mockReturnValueOnce(undefined) - .mockReturnValueOnce(endDate) - .mockReturnValueOnce(undefined) .mockReturnValueOnce(endDate); + expect( helpers.getDateRange({ state: { start: '1972-01-01T00:00:00.000Z', end: '1973-01-01T00:00:00.000Z', - exactStart: '1972-01-01T00:00:00.000Z', - exactEnd: '1973-01-01T00:00:00.000Z', }, rangeFrom: 'nope', rangeTo: 'now', }) ).toEqual({ start: '1972-01-01T00:00:00.000Z', - exactStart: '1972-01-01T00:00:00.000Z', end: '1973-01-01T00:00:00.000Z', - exactEnd: '1973-01-01T00:00:00.000Z', }); }); }); describe('when rangeFrom or rangeTo have changed', () => { it('returns new state', () => { - jest.spyOn(datemath, 'parse').mockReturnValue(moment(0).utc()); + jest.spyOn(Date, 'now').mockReturnValue(moment(0).unix()); expect( helpers.getDateRange({ @@ -158,40 +95,10 @@ describe('url_params_context helpers', () => { rangeTo: 'now', }) ).toEqual({ - start: '1970-01-01T00:00:00.000Z', + start: '1969-12-31T23:58:00.000Z', end: '1970-01-01T00:00:00.000Z', - exactStart: '1970-01-01T00:00:00.000Z', - exactEnd: '1970-01-01T00:00:00.000Z', }); }); }); }); - - describe('getExactDate', () => { - it('returns date when it is not not relative', () => { - expect(helpers.getExactDate('2021-01-28T05:47:52.134Z')).toEqual( - new Date('2021-01-28T05:47:52.134Z') - ); - }); - - ['s', 'm', 'h', 'd', 'w'].map((roundingOption) => - it(`removes /${roundingOption} rounding option from relative time`, () => { - const spy = jest.spyOn(datemath, 'parse'); - helpers.getExactDate(`now/${roundingOption}`); - expect(spy).toHaveBeenCalledWith('now', {}); - }) - ); - - it('removes rounding option but keeps subtracting time', () => { - const spy = jest.spyOn(datemath, 'parse'); - helpers.getExactDate('now-24h/h'); - expect(spy).toHaveBeenCalledWith('now-24h', {}); - }); - - it('removes rounding option but keeps adding time', () => { - const spy = jest.spyOn(datemath, 'parse'); - helpers.getExactDate('now+15m/h'); - expect(spy).toHaveBeenCalledWith('now+15m', {}); - }); - }); }); diff --git a/x-pack/plugins/apm/public/context/url_params_context/helpers.ts b/x-pack/plugins/apm/public/context/url_params_context/helpers.ts index ee6ac43c1aeab..6856c0e7d2506 100644 --- a/x-pack/plugins/apm/public/context/url_params_context/helpers.ts +++ b/x-pack/plugins/apm/public/context/url_params_context/helpers.ts @@ -6,8 +6,7 @@ */ import datemath from '@elastic/datemath'; -import { compact, pickBy } from 'lodash'; -import moment from 'moment'; +import { pickBy } from 'lodash'; import { UrlParams } from './types'; function getParsedDate(rawDate?: string, options = {}) { @@ -19,25 +18,12 @@ function getParsedDate(rawDate?: string, options = {}) { } } -export function getExactDate(rawDate: string) { - const isRelativeDate = rawDate.startsWith('now'); - if (isRelativeDate) { - // remove rounding from relative dates "Today" (now/d) and "This week" (now/w) - const rawDateWithouRounding = rawDate.replace(/\/([smhdw])$/, ''); - return getParsedDate(rawDateWithouRounding); - } - return getParsedDate(rawDate); -} - export function getDateRange({ state = {}, rangeFrom, rangeTo, }: { - state?: Pick< - UrlParams, - 'rangeFrom' | 'rangeTo' | 'start' | 'end' | 'exactStart' | 'exactEnd' - >; + state?: Pick; rangeFrom?: string; rangeTo?: string; }) { @@ -46,35 +32,23 @@ export function getDateRange({ return { start: state.start, end: state.end, - exactStart: state.exactStart, - exactEnd: state.exactEnd, }; } const start = getParsedDate(rangeFrom); const end = getParsedDate(rangeTo, { roundUp: true }); - const exactStart = rangeFrom ? getExactDate(rangeFrom) : undefined; - const exactEnd = rangeTo ? getExactDate(rangeTo) : undefined; - // `getParsedDate` will return undefined for invalid or empty dates. We return // the previous state if either date is undefined. if (!start || !end) { return { start: state.start, end: state.end, - exactStart: state.exactStart, - exactEnd: state.exactEnd, }; } - // rounds down start to minute - const roundedStart = moment(start).startOf('minute'); - return { - start: roundedStart.toISOString(), + start: start.toISOString(), end: end.toISOString(), - exactStart: exactStart?.toISOString(), - exactEnd: exactEnd?.toISOString(), }; } @@ -95,10 +69,6 @@ export function toBoolean(value?: string) { return value === 'true'; } -export function getPathAsArray(pathname: string = '') { - return compact(pathname.split('/')); -} - export function removeUndefinedProps(obj: T): Partial { return pickBy(obj, (value) => value !== undefined); } diff --git a/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts b/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts index eb231741ad77e..5bb3a46c3aea4 100644 --- a/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts +++ b/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts @@ -19,10 +19,7 @@ import { } from './helpers'; import { UrlParams } from './types'; -type TimeUrlParams = Pick< - UrlParams, - 'start' | 'end' | 'rangeFrom' | 'rangeTo' | 'exactStart' | 'exactEnd' ->; +type TimeUrlParams = Pick; export function resolveUrlParams(location: Location, state: TimeUrlParams) { const query = toQuery(location.search); diff --git a/x-pack/plugins/apm/public/context/url_params_context/types.ts b/x-pack/plugins/apm/public/context/url_params_context/types.ts index aaad2fac2da22..6c0f10f78e7c8 100644 --- a/x-pack/plugins/apm/public/context/url_params_context/types.ts +++ b/x-pack/plugins/apm/public/context/url_params_context/types.ts @@ -16,8 +16,6 @@ export interface UrlParams { environment?: string; rangeFrom?: string; rangeTo?: string; - exactStart?: string; - exactEnd?: string; refreshInterval?: number; refreshPaused?: boolean; sortDirection?: string; diff --git a/x-pack/plugins/apm/public/context/url_params_context/url_params_context.tsx b/x-pack/plugins/apm/public/context/url_params_context/url_params_context.tsx index a128db6c2cd7a..80a99d1a4274b 100644 --- a/x-pack/plugins/apm/public/context/url_params_context/url_params_context.tsx +++ b/x-pack/plugins/apm/public/context/url_params_context/url_params_context.tsx @@ -34,8 +34,7 @@ const UrlParamsProvider: React.ComponentClass<{}> = withRouter( ({ location, children }) => { const refUrlParams = useRef(resolveUrlParams(location, {})); - const { start, end, rangeFrom, rangeTo, exactStart, exactEnd } = - refUrlParams.current; + const { start, end, rangeFrom, rangeTo } = refUrlParams.current; // Counter to force an update in useFetcher when the refresh button is clicked. const [rangeId, setRangeId] = useState(0); @@ -47,10 +46,8 @@ const UrlParamsProvider: React.ComponentClass<{}> = withRouter( end, rangeFrom, rangeTo, - exactStart, - exactEnd, }), - [location, start, end, rangeFrom, rangeTo, exactStart, exactEnd] + [location, start, end, rangeFrom, rangeTo] ); refUrlParams.current = urlParams; diff --git a/x-pack/plugins/apm/public/hooks/use_comparison.ts b/x-pack/plugins/apm/public/hooks/use_comparison.ts deleted file mode 100644 index 93d0e31969c50..0000000000000 --- a/x-pack/plugins/apm/public/hooks/use_comparison.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - getComparisonChartTheme, - getTimeRangeComparison, -} from '../components/shared/time_comparison/get_time_range_comparison'; -import { useLegacyUrlParams } from '../context/url_params_context/use_url_params'; -import { useApmParams } from './use_apm_params'; -import { useTimeRange } from './use_time_range'; - -export function useComparison() { - const comparisonChartTheme = getComparisonChartTheme(); - const { query } = useApmParams('/*'); - - if (!('rangeFrom' in query && 'rangeTo' in query)) { - throw new Error('rangeFrom or rangeTo not defined in query'); - } - - const { start, end } = useTimeRange({ - rangeFrom: query.rangeFrom, - rangeTo: query.rangeTo, - }); - - const { - urlParams: { comparisonType, comparisonEnabled }, - } = useLegacyUrlParams(); - - const { offset } = getTimeRangeComparison({ - start, - end, - comparisonType, - comparisonEnabled, - }); - - return { - offset, - comparisonChartTheme, - }; -} diff --git a/x-pack/plugins/apm/public/hooks/use_error_group_distribution_fetcher.tsx b/x-pack/plugins/apm/public/hooks/use_error_group_distribution_fetcher.tsx index d843067b48e91..62e2ab92c4fcc 100644 --- a/x-pack/plugins/apm/public/hooks/use_error_group_distribution_fetcher.tsx +++ b/x-pack/plugins/apm/public/hooks/use_error_group_distribution_fetcher.tsx @@ -31,7 +31,6 @@ export function useErrorGroupDistributionFetcher({ comparisonType, comparisonEnabled, }); - const { data, status } = useFetcher( (callApmApi) => { if (start && end) { diff --git a/x-pack/plugins/apm/public/hooks/use_time_range.ts b/x-pack/plugins/apm/public/hooks/use_time_range.ts index 79ca70130a442..26333062dfa3c 100644 --- a/x-pack/plugins/apm/public/hooks/use_time_range.ts +++ b/x-pack/plugins/apm/public/hooks/use_time_range.ts @@ -12,14 +12,12 @@ import { getDateRange } from '../context/url_params_context/helpers'; interface TimeRange { start: string; end: string; - exactStart: string; - exactEnd: string; refreshTimeRange: () => void; timeRangeId: number; } type PartialTimeRange = Pick & - Pick, 'start' | 'end' | 'exactStart' | 'exactEnd'>; + Pick, 'start' | 'end'>; export function useTimeRange(range: { rangeFrom?: string; @@ -43,7 +41,7 @@ export function useTimeRange({ }): TimeRange | PartialTimeRange { const { incrementTimeRangeId, timeRangeId } = useTimeRangeId(); - const { start, end, exactStart, exactEnd } = useMemo(() => { + const { start, end } = useMemo(() => { return getDateRange({ state: {}, rangeFrom, @@ -52,15 +50,13 @@ export function useTimeRange({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [rangeFrom, rangeTo, timeRangeId]); - if ((!start || !end || !exactStart || !exactEnd) && !optional) { + if ((!start || !end) && !optional) { throw new Error('start and/or end were unexpectedly not set'); } return { start, end, - exactStart, - exactEnd, refreshTimeRange: incrementTimeRangeId, timeRangeId, }; diff --git a/x-pack/plugins/apm/public/hooks/use_transaction_latency_chart_fetcher.ts b/x-pack/plugins/apm/public/hooks/use_transaction_latency_chart_fetcher.ts index 33bb9095665d8..8dbc1f3a47505 100644 --- a/x-pack/plugins/apm/public/hooks/use_transaction_latency_chart_fetcher.ts +++ b/x-pack/plugins/apm/public/hooks/use_transaction_latency_chart_fetcher.ts @@ -23,16 +23,11 @@ export function useTransactionLatencyChartsFetcher({ }) { const { transactionType, serviceName } = useApmServiceContext(); const { - urlParams: { - transactionName, - latencyAggregationType, - comparisonType, - comparisonEnabled, - }, + urlParams: { transactionName, latencyAggregationType }, } = useLegacyUrlParams(); const { - query: { rangeFrom, rangeTo }, + query: { rangeFrom, rangeTo, comparisonType, comparisonEnabled }, } = useApmParams('/services/{serviceName}'); const { start, end } = useTimeRange({ rangeFrom, rangeTo }); @@ -43,7 +38,6 @@ export function useTransactionLatencyChartsFetcher({ comparisonType, comparisonEnabled, }); - const { data, error, status } = useFetcher( (callApmApi) => { if ( From 719ccb6d87ab4e2cf92ce77636c12bd6b61b428b Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Mon, 14 Mar 2022 10:50:40 -0700 Subject: [PATCH 26/44] [Reporting] Document steps to grant users reporting access under Basic (#127513) * [Reporting] Document steps to grant users reporting access under Basic license * Apply suggestions from code review Co-authored-by: Kaarina Tungseth * corrections to api calls Co-authored-by: Kaarina Tungseth Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- docs/setup/configuring-reporting.asciidoc | 107 ++++++++++++++---- ...kibana-privileges-with-reporting-basic.png | Bin 0 -> 118851 bytes .../reporting-troubleshooting.asciidoc | 1 + 3 files changed, 83 insertions(+), 25 deletions(-) create mode 100644 docs/user/reporting/images/kibana-privileges-with-reporting-basic.png diff --git a/docs/setup/configuring-reporting.asciidoc b/docs/setup/configuring-reporting.asciidoc index bd800d6032309..6bdf6e5b27a64 100644 --- a/docs/setup/configuring-reporting.asciidoc +++ b/docs/setup/configuring-reporting.asciidoc @@ -6,7 +6,16 @@ Configure reporting ++++ -To enable users to manually and automatically generate reports, install the reporting packages, grant users access to the {report-features}, and secure the reporting endpoints. +For security, you grant users access to the {report-features} and secure the reporting endpoints +with TLS/SSL encryption. Additionally, you can install graphical packages into the operating system +to enable the {kib} server to have screenshotting capabilities. + +* <> +* <> +* <> +* <> +* <> +* <> [float] [[install-reporting-packages]] @@ -32,7 +41,7 @@ If you are using Ubuntu/Debian systems, install the following packages: * `libfontconfig1` * `libnss3` -If the system is missing dependencies, *Reporting* fails in a non-deterministic way. {kib} runs a self-test at server startup, and +If the system is missing dependencies, a screenshot report job may fail in a non-deterministic way. {kib} runs a self-test at server startup, and if it encounters errors, logs them in the Console. The error message does not include information about why Chromium failed to run. The most common error message is `Error: connect ECONNREFUSED`, which indicates that {kib} could not connect to the Chromium process. @@ -53,7 +62,7 @@ xpack.reporting.roles.enabled: false + NOTE: If you use the default settings, you can still create a custom role that grants reporting privileges. The default role is `reporting_user`. This behavior is being deprecated and does not allow application-level access controls for {report-features}, and does not allow API keys or authentication tokens to authorize report generation. Refer to <> for information and caveats about the deprecated access control features. -. Create the reporting role. +. Create the reporting role. .. Open the main menu, then click *Stack Management*. @@ -77,14 +86,13 @@ For more information, refer to {ref}/security-privileges.html[Security privilege .. Click *Customize*, then click *Analytics*. -.. Next each application listed, click *All* or click *Read*. You will need to enable the *Customize sub-feature -privileges* checkbox to grant reporting privileges if you select *Read*. +.. For each application, select *All*, or to customize the privileges, select *Read* and *Customize sub-feature privileges*. + -If you’ve followed the example above, you should end up on a screen defining your customized privileges that looks like this: +NOTE: If you have a Basic license, sub-feature privileges are unavailable. For details, check out <>. [role="screenshot"] -image::user/reporting/images/kibana-privileges-with-reporting.png["Kibana privileges with Reporting options"] +image::user/reporting/images/kibana-privileges-with-reporting.png["Kibana privileges with Reporting options, Gold or higher license"] + -NOTE: If *Reporting* options for application features are not available, contact your administrator, or <>. +NOTE: If the *Reporting* options for application features are unavailable, and the cluster license is higher than Basic, contact your administrator, or <>. .. Click *Add {kib} privilege*. @@ -94,7 +102,7 @@ NOTE: If *Reporting* options for application features are not available, contact .. Open the main menu, then click *Stack Management*. -.. Click *Users*, then click the user you want to assign the reporting role to. +.. Click *Users*, then click the user you want to assign the reporting role to. .. From the *Roles* dropdown, select *custom_reporting_user*. @@ -105,29 +113,43 @@ Granting the privilege to generate reports also grants the user the privilege to [float] [[reporting-roles-user-api]] ==== Grant access with the role API -With <> enabled in Reporting, you can also use the {ref}/security-api-put-role.html[role API] to grant access to the {report-features}. Grant custom reporting roles to users in combination with other roles that grant read access to the data in {es}, and at least read access in the applications where users can generate reports. +With <> enabled in Reporting, you can also use the {ref}/security-api-put-role.html[role API] to grant access to the {report-features}, using *All* privileges, or sub-feature privileges. -[source, sh] +NOTE: If you have a Basic license, sub-feature privileges are unavailable. For details, check out the API command to grant *All* privileges in <>. + +Grant users custom Reporting roles, other roles that grant read access to the data in {es}, and at least read access in the applications where users can generate reports. + +[source, json] --------------------------------------------------------------- -POST /_security/role/custom_reporting_user +PUT localhost:5601/api/security/role/custom_reporting_user { - metadata: {}, - elasticsearch: { cluster: [], indices: [], run_as: [] }, - kibana: [ + "elasticsearch": { "cluster": [], "indices": [], "run_as": [] }, + "kibana": [ { - base: [], - feature: { - dashboard: [ - 'generate_report', <1> - 'download_csv_report' <2> + "base": [], + "feature": { + "dashboard": [ + "minimal_read", + "generate_report", <1> + "download_csv_report" <2> + ], + "discover": [ + "minimal_read", + "generate_report" <3> + ], + "canvas": [ + "minimal_read", + "generate_report" <4> ], - discover: ['generate_report'], <3> - canvas: ['generate_report'], <4> - visualize: ['generate_report'], <5> + "visualize": [ + "minimal_read", + "generate_report" <5> + ] }, - spaces: ['*'], + "spaces": [ "*" ] } - ] + ], + "metadata": {} // optional } --------------------------------------------------------------- // CONSOLE @@ -139,6 +161,41 @@ POST /_security/role/custom_reporting_user <5> Grants access to generate PNG and PDF reports in *Visualize Library*. [float] +[[grant-user-access-basic]] +=== Grant users access with a Basic license + +With a Basic license, you can grant users access with custom roles to {report-features} with <>. However, with a Basic license, sub-feature privileges are unavailable. <>, then select *All* privileges for the applications where users can create reports. + +[role="screenshot"] +image::user/reporting/images/kibana-privileges-with-reporting-basic.png["Kibana privileges with Reporting options, Basic license"] + +With a Basic license, sub-feature application privileges are unavailable, but you can use the {ref}/security-api-put-role.html[role API] to grant access to CSV {report-features}: + +[source, sh] +--------------------------------------------------------------- +PUT localhost:5601/api/security/role/custom_reporting_user +{ + "elasticsearch": { "cluster": [], "indices": [], "run_as": [] }, + "kibana": [ + { + "base": [], + "feature": { + "dashboard": [ "all" ], <1> + "discover": [ "all" ], <2> + }, + "spaces": [ "*" ] + } + ], + "metadata": {} // optional +} +--------------------------------------------------------------- +// CONSOLE + +<1> Grants access to generate CSV reports from saved searches in *Discover*. +<2> Grants access to download CSV reports from saved search panels in *Dashboard*. + +[float] +[[grant-user-access-external-provider]] ==== Grant access using an external provider If you are using an external identity provider, such as LDAP or Active Directory, you can assign roles to individual users or groups of users. Role mappings are configured in {ref}/mapping-roles.html[`config/role_mapping.yml`]. diff --git a/docs/user/reporting/images/kibana-privileges-with-reporting-basic.png b/docs/user/reporting/images/kibana-privileges-with-reporting-basic.png new file mode 100644 index 0000000000000000000000000000000000000000..6d2c3ba645b54e2a58e4e5d74ecc2849832b4fb2 GIT binary patch literal 118851 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πRG!J^`OKaaUP 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

2-l@(XX3UCN9#h`iS4%VW-zqODcY;{D?!+c@ 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 ++++ From 9739c5fa159cc50c4962ee90b33bcc982579637d Mon Sep 17 00:00:00 2001 From: Matthew Kime Date: Mon, 14 Mar 2022 13:11:17 -0500 Subject: [PATCH 27/44] [data view mgmt] only use resolve api for getting index list (#127085) * only use resolve api for getting index list Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../data_view_editor_flyout_content.tsx | 6 +- .../empty_prompts/empty_prompts.tsx | 1 - .../public/lib/get_indices.test.ts | 100 ++---------------- .../public/lib/get_indices.ts | 98 +---------------- 4 files changed, 13 insertions(+), 192 deletions(-) diff --git a/src/plugins/data_view_editor/public/components/data_view_editor_flyout_content.tsx b/src/plugins/data_view_editor/public/components/data_view_editor_flyout_content.tsx index 0c5381e99b8fa..deab9b8cf824f 100644 --- a/src/plugins/data_view_editor/public/components/data_view_editor_flyout_content.tsx +++ b/src/plugins/data_view_editor/public/components/data_view_editor_flyout_content.tsx @@ -143,14 +143,13 @@ const IndexPatternEditorFlyoutContentComponent = ({ isRollupIndex: () => false, pattern: '*', showAllIndices: allowHidden, - searchClient, }).then((dataSources) => { setAllSources(dataSources); const matchedSet = getMatchedIndices(dataSources, [], [], allowHidden); setMatchedIndices(matchedSet); setIsLoadingSources(false); }); - }, [http, allowHidden, searchClient]); + }, [http, allowHidden]); // loading list of index patterns useEffect(() => { @@ -407,7 +406,6 @@ const loadMatchedIndices = memoizeOne( isRollupIndex, pattern: query, showAllIndices: allowHidden, - searchClient, }); indexRequests.push(exactMatchedQuery); // provide default value when not making a request for the partialMatchQuery @@ -418,14 +416,12 @@ const loadMatchedIndices = memoizeOne( isRollupIndex, pattern: query, showAllIndices: allowHidden, - searchClient, }); const partialMatchQuery = getIndices({ http, isRollupIndex, pattern: `${query}*`, showAllIndices: allowHidden, - searchClient, }); indexRequests.push(exactMatchQuery); diff --git a/src/plugins/data_view_editor/public/components/empty_prompts/empty_prompts.tsx b/src/plugins/data_view_editor/public/components/empty_prompts/empty_prompts.tsx index e5f4e6cec057e..f34ec90e414b2 100644 --- a/src/plugins/data_view_editor/public/components/empty_prompts/empty_prompts.tsx +++ b/src/plugins/data_view_editor/public/components/empty_prompts/empty_prompts.tsx @@ -66,7 +66,6 @@ export const EmptyPrompts: FC = ({ allSources, onCancel, children, loadSo isRollupIndex: () => false, pattern: '*:*', showAllIndices: false, - searchClient, }).then((dataSources) => { setRemoteClustersExist(!!dataSources.filter(removeAliases).length); }); diff --git a/src/plugins/data_view_editor/public/lib/get_indices.test.ts b/src/plugins/data_view_editor/public/lib/get_indices.test.ts index d65cd27e090bb..bc4e6b0896065 100644 --- a/src/plugins/data_view_editor/public/lib/get_indices.test.ts +++ b/src/plugins/data_view_editor/public/lib/get_indices.test.ts @@ -6,15 +6,9 @@ * Side Public License, v 1. */ -import { - getIndices, - getIndicesViaSearch, - responseToItemArray, - dedupeMatchedItems, -} from './get_indices'; +import { getIndices, responseToItemArray } from './get_indices'; import { httpServiceMock } from '../../../../core/public/mocks'; -import { ResolveIndexResponseItemIndexAttrs, MatchedItem } from '../types'; -import { Observable } from 'rxjs'; +import { ResolveIndexResponseItemIndexAttrs } from '../types'; export const successfulResolveResponse = { indices: [ @@ -38,41 +32,8 @@ export const successfulResolveResponse = { ], }; -const successfulSearchResponse = { - isPartial: false, - isRunning: false, - rawResponse: { - aggregations: { - indices: { - buckets: [{ key: 'kibana_sample_data_ecommerce' }, { key: '.kibana_1' }], - }, - }, - }, -}; - -const partialSearchResponse = { - isPartial: true, - isRunning: true, - rawResponse: { - hits: { - total: 2, - hits: [], - }, - }, -}; - -const errorSearchResponse = { - isPartial: true, - isRunning: false, -}; - const isRollupIndex = () => false; const getTags = () => []; -const searchClient = () => - new Observable((observer) => { - observer.next(successfulSearchResponse); - observer.complete(); - }) as any; const http = httpServiceMock.createStartContract(); http.get.mockResolvedValue(successfulResolveResponse); @@ -83,7 +44,6 @@ describe('getIndices', () => { const result = await getIndices({ http, pattern: 'kibana', - searchClient: uncalledSearchClient, isRollupIndex, }); expect(http.get).toHaveBeenCalled(); @@ -95,42 +55,23 @@ describe('getIndices', () => { it('should make two calls in cross cluser case', async () => { http.get.mockResolvedValue(successfulResolveResponse); - const result = await getIndices({ http, pattern: '*:kibana', searchClient, isRollupIndex }); + const result = await getIndices({ http, pattern: '*:kibana', isRollupIndex }); expect(http.get).toHaveBeenCalled(); - expect(result.length).toBe(4); + expect(result.length).toBe(3); expect(result[0].name).toBe('f-alias'); expect(result[1].name).toBe('foo'); - expect(result[2].name).toBe('kibana_sample_data_ecommerce'); - expect(result[3].name).toBe('remoteCluster1:bar-01'); + expect(result[2].name).toBe('remoteCluster1:bar-01'); }); it('should ignore ccs query-all', async () => { - expect((await getIndices({ http, pattern: '*:', searchClient, isRollupIndex })).length).toBe(0); + expect((await getIndices({ http, pattern: '*:', isRollupIndex })).length).toBe(0); }); it('should ignore a single comma', async () => { - expect((await getIndices({ http, pattern: ',', searchClient, isRollupIndex })).length).toBe(0); - expect((await getIndices({ http, pattern: ',*', searchClient, isRollupIndex })).length).toBe(0); - expect( - (await getIndices({ http, pattern: ',foobar', searchClient, isRollupIndex })).length - ).toBe(0); - }); - - it('should work with partial responses', async () => { - const searchClientPartialResponse = () => - new Observable((observer) => { - observer.next(partialSearchResponse); - observer.next(successfulSearchResponse); - observer.complete(); - }) as any; - const result = await getIndices({ - http, - pattern: '*:kibana', - searchClient: searchClientPartialResponse, - isRollupIndex, - }); - expect(result.length).toBe(4); + expect((await getIndices({ http, pattern: ',', isRollupIndex })).length).toBe(0); + expect((await getIndices({ http, pattern: ',*', isRollupIndex })).length).toBe(0); + expect((await getIndices({ http, pattern: ',foobar', isRollupIndex })).length).toBe(0); }); it('response object to item array', () => { @@ -162,33 +103,12 @@ describe('getIndices', () => { expect(responseToItemArray({}, getTags)).toEqual([]); }); - it('matched items are deduped', () => { - const setA = [{ name: 'a' }, { name: 'b' }] as MatchedItem[]; - const setB = [{ name: 'b' }, { name: 'c' }] as MatchedItem[]; - expect(dedupeMatchedItems(setA, setB)).toHaveLength(3); - }); - describe('errors', () => { it('should handle thrown errors gracefully', async () => { http.get.mockImplementationOnce(() => { throw new Error('Test error'); }); - const result = await getIndices({ http, pattern: 'kibana', searchClient, isRollupIndex }); - expect(result.length).toBe(0); - }); - - it('getIndicesViaSearch should handle error responses gracefully', async () => { - const searchClientErrorResponse = () => - new Observable((observer) => { - observer.next(errorSearchResponse); - observer.complete(); - }) as any; - const result = await getIndicesViaSearch({ - pattern: '*:kibana', - searchClient: searchClientErrorResponse, - showAllIndices: false, - isRollupIndex, - }); + const result = await getIndices({ http, pattern: 'kibana', isRollupIndex }); expect(result.length).toBe(0); }); }); diff --git a/src/plugins/data_view_editor/public/lib/get_indices.ts b/src/plugins/data_view_editor/public/lib/get_indices.ts index de93e2c177937..2bf97dd31e45e 100644 --- a/src/plugins/data_view_editor/public/lib/get_indices.ts +++ b/src/plugins/data_view_editor/public/lib/get_indices.ts @@ -8,18 +8,11 @@ import { sortBy } from 'lodash'; import { HttpStart } from 'kibana/public'; -import { map, filter } from 'rxjs/operators'; import { i18n } from '@kbn/i18n'; import { Tag, INDEX_PATTERN_TYPE } from '../types'; import { MatchedItem, ResolveIndexResponse, ResolveIndexResponseItemIndexAttrs } from '../types'; -import { MAX_SEARCH_SIZE } from '../constants'; -import { - DataPublicPluginStart, - IEsSearchResponse, - isErrorResponse, - isCompleteResponse, -} from '../../../data/public'; +import { IEsSearchResponse } from '../../../data/public'; const aliasLabel = i18n.translate('indexPatternEditor.aliasLabel', { defaultMessage: 'Alias' }); const dataStreamLabel = i18n.translate('indexPatternEditor.dataStreamLabel', { @@ -78,42 +71,6 @@ export const searchResponseToArray = } }; -export const getIndicesViaSearch = async ({ - pattern, - searchClient, - showAllIndices, - isRollupIndex, -}: { - pattern: string; - searchClient: DataPublicPluginStart['search']['search']; - showAllIndices: boolean; - isRollupIndex: (indexName: string) => boolean; -}): Promise => - searchClient({ - params: { - ignoreUnavailable: true, - expand_wildcards: showAllIndices ? 'all' : 'open', - index: pattern, - body: { - size: 0, // no hits - aggs: { - indices: { - terms: { - field: '_index', - size: MAX_SEARCH_SIZE, - }, - }, - }, - }, - }, - }) - .pipe( - filter((resp) => isCompleteResponse(resp) || isErrorResponse(resp)), - map(searchResponseToArray(getIndexTags(isRollupIndex), showAllIndices)) - ) - .toPromise() - .catch(() => []); - export const getIndicesViaResolve = async ({ http, pattern, @@ -137,48 +94,18 @@ export const getIndicesViaResolve = async ({ } }); -/** - * Takes two MatchedItem[]s and returns a merged set, with the second set prrioritized over the first based on name - * - * @param matchedA - * @param matchedB - */ - -export const dedupeMatchedItems = (matchedA: MatchedItem[], matchedB: MatchedItem[]) => { - const mergedMatchedItems = matchedA.reduce((col, item) => { - col[item.name] = item; - return col; - }, {} as Record); - - matchedB.reduce((col, item) => { - col[item.name] = item; - return col; - }, mergedMatchedItems); - - return Object.values(mergedMatchedItems).sort((a, b) => { - if (a.name > b.name) return 1; - if (b.name > a.name) return -1; - - return 0; - }); -}; - export async function getIndices({ http, pattern: rawPattern = '', showAllIndices = false, - searchClient, isRollupIndex, }: { http: HttpStart; pattern: string; showAllIndices?: boolean; - searchClient: DataPublicPluginStart['search']['search']; isRollupIndex: (indexName: string) => boolean; }): Promise { const pattern = rawPattern.trim(); - const isCCS = pattern.indexOf(':') !== -1; - const requests: Array> = []; // Searching for `*:` fails for CCS environments. The search request // is worthless anyways as the we should only send a request @@ -198,33 +125,12 @@ export async function getIndices({ return []; } - const promiseResolve = getIndicesViaResolve({ + return getIndicesViaResolve({ http, pattern, showAllIndices, isRollupIndex, }).catch(() => []); - requests.push(promiseResolve); - - if (isCCS) { - // CCS supports ±1 major version. We won't be able to expect resolve endpoint to exist until v9 - const promiseSearch = getIndicesViaSearch({ - pattern, - searchClient, - showAllIndices, - isRollupIndex, - }).catch(() => []); - requests.push(promiseSearch); - } - - const responses = await Promise.all(requests); - - if (responses.length === 2) { - const [resolveResponse, searchResponse] = responses; - return dedupeMatchedItems(searchResponse, resolveResponse); - } else { - return responses[0]; - } } export const responseToItemArray = ( From b0ae7f004b21f81722c7291732a002857f2c72ff Mon Sep 17 00:00:00 2001 From: Matthew Kime Date: Mon, 14 Mar 2022 13:12:08 -0500 Subject: [PATCH 28/44] [data view mgmt] Spaces list can expand and contract (#127101) * data view spaces list is limited and can expand and contract AND it has a proper click target Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../index_pattern_table/spaces_list.tsx | 16 ++++++-------- .../spaces/public/space_avatar/types.ts | 15 +++++++++++++ .../space_list/space_list_internal.test.tsx | 13 +++++++++-- .../public/space_list/space_list_internal.tsx | 22 ++++++++++++++++--- .../plugins/spaces/public/space_list/types.ts | 4 ++++ .../apps/data_views/spaces/index.ts | 3 ++- 6 files changed, 58 insertions(+), 15 deletions(-) diff --git a/src/plugins/data_view_management/public/components/index_pattern_table/spaces_list.tsx b/src/plugins/data_view_management/public/components/index_pattern_table/spaces_list.tsx index c17e174ef1dda..be7bdb1b31fdb 100644 --- a/src/plugins/data_view_management/public/components/index_pattern_table/spaces_list.tsx +++ b/src/plugins/data_view_management/public/components/index_pattern_table/spaces_list.tsx @@ -8,7 +8,6 @@ import React, { FC, useState } from 'react'; -import { EuiButtonEmpty } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import type { SpacesPluginStart, @@ -33,7 +32,6 @@ export const SpacesList: FC = ({ spacesApi, spaceIds, id, title, refresh function onClose() { setShowFlyout(false); - refresh(); } const LazySpaceList = spacesApi.ui.components.getSpaceList; @@ -47,18 +45,18 @@ export const SpacesList: FC = ({ spacesApi, spaceIds, id, title, refresh title, noun, }, + onUpdate: refresh, onClose, }; return ( <> - setShowFlyout(true)} - style={{ height: 'auto' }} - data-test-subj="manageSpacesButton" - > - - + setShowFlyout(true)} + /> {showFlyout && } ); diff --git a/x-pack/plugins/spaces/public/space_avatar/types.ts b/x-pack/plugins/spaces/public/space_avatar/types.ts index 365c71eeeea73..15a7a4dab839c 100644 --- a/x-pack/plugins/spaces/public/space_avatar/types.ts +++ b/x-pack/plugins/spaces/public/space_avatar/types.ts @@ -33,4 +33,19 @@ export interface SpaceAvatarProps { * Default value is false. */ isDisabled?: boolean; + + /** + * Callback to be invoked when the avatar is clicked. + */ + onClick?: (event: React.MouseEvent) => void; + + /** + * Callback to be invoked when the avatar is clicked via keyboard. + */ + onKeyPress?: (event: React.KeyboardEvent) => void; + + /** + * Style props for the avatar. + */ + style?: React.CSSProperties; } diff --git a/x-pack/plugins/spaces/public/space_list/space_list_internal.test.tsx b/x-pack/plugins/spaces/public/space_list/space_list_internal.test.tsx index cc365e943a510..de407c2d51c2a 100644 --- a/x-pack/plugins/spaces/public/space_list/space_list_internal.test.tsx +++ b/x-pack/plugins/spaces/public/space_list/space_list_internal.test.tsx @@ -80,6 +80,9 @@ describe('SpaceListInternal', () => { function getButton(wrapper: ReactWrapper) { return wrapper.find('EuiButtonEmpty'); } + async function getListClickTarget(wrapper: ReactWrapper) { + return (await wrapper.find('[data-test-subj="space-avatar-alpha"]')).first(); + } describe('using default properties', () => { describe('with only the active space', () => { @@ -235,15 +238,18 @@ describe('SpaceListInternal', () => { const { spaces, namespaces } = getSpaceData(8); it('with displayLimit=0, shows badges without button', async () => { - const props = { namespaces: [...namespaces, '?'], displayLimit: 0 }; + const props = { namespaces: [...namespaces, '?'], displayLimit: 0, listOnClick: jest.fn() }; const wrapper = await createSpaceList({ spaces, props }); expect(getListText(wrapper)).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', '+1']); expect(getButton(wrapper)).toHaveLength(0); + + (await getListClickTarget(wrapper)).simulate('click'); + expect(props.listOnClick).toHaveBeenCalledTimes(1); }); it('with displayLimit=1, shows badges with button', async () => { - const props = { namespaces: [...namespaces, '?'], displayLimit: 1 }; + const props = { namespaces: [...namespaces, '?'], displayLimit: 1, listOnClick: jest.fn() }; const wrapper = await createSpaceList({ spaces, props }); expect(getListText(wrapper)).toEqual(['A']); @@ -257,6 +263,9 @@ describe('SpaceListInternal', () => { const badgeText = getListText(wrapper); expect(badgeText).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', '+1']); expect(button.text()).toEqual('show less'); + + (await getListClickTarget(wrapper)).simulate('click'); + expect(props.listOnClick).toHaveBeenCalledTimes(1); }); it('with displayLimit=7, shows badges with button', async () => { diff --git a/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx b/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx index 50f24c8df2f35..17403fe7134eb 100644 --- a/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx +++ b/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx @@ -43,6 +43,7 @@ export const SpaceListInternal = ({ namespaces, displayLimit = DEFAULT_DISPLAY_LIMIT, behaviorContext, + listOnClick = () => {}, }: SpaceListProps) => { const { spacesDataPromise } = useSpaces(); @@ -103,14 +104,22 @@ export const SpaceListInternal = ({ if (displayLimit && authorizedSpaceTargets.length > displayLimit) { button = isExpanded ? ( - setIsExpanded(false)}> + setIsExpanded(false)} + style={{ alignSelf: 'center' }} + > ) : ( - setIsExpanded(true)}> + setIsExpanded(true)} + style={{ alignSelf: 'center' }} + > - + ); })} diff --git a/x-pack/plugins/spaces/public/space_list/types.ts b/x-pack/plugins/spaces/public/space_list/types.ts index 2e7e813a48a2f..a167b51155036 100644 --- a/x-pack/plugins/spaces/public/space_list/types.ts +++ b/x-pack/plugins/spaces/public/space_list/types.ts @@ -28,4 +28,8 @@ export interface SpaceListProps { * the active space. */ behaviorContext?: 'within-space' | 'outside-space'; + /** + * Click handler for spaces list, specifically excluding expand and contract buttons. + */ + listOnClick?: () => void; } diff --git a/x-pack/test/functional/apps/data_views/spaces/index.ts b/x-pack/test/functional/apps/data_views/spaces/index.ts index 0d5bccd156b0e..ac891070e8e0f 100644 --- a/x-pack/test/functional/apps/data_views/spaces/index.ts +++ b/x-pack/test/functional/apps/data_views/spaces/index.ts @@ -42,7 +42,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.settings.clickKibanaIndexPatterns(); // click manage spaces on first entry - await (await testSubjects.findAll('manageSpacesButton', 10000))[0].click(); + // first avatar is in header, so we want the second one + await (await testSubjects.findAll('space-avatar-default', 1000))[1].click(); // select custom space await testSubjects.click('sts-space-selector-row-custom_space'); From 53d745625e1fd2e34b1b2663de8edfffc13ddb92 Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Mon, 14 Mar 2022 13:21:18 -0500 Subject: [PATCH 29/44] [build] Include x-pack example plugins when using example-plugins flag (#126931) * Revert "Revert "[build] Include x-pack example plugins when using example-plugins flag (#120697)" (#125729)" This reverts commit 7b4c34e41819294399421c1d047241cc1e9e5ee2. * skip alerting_example * link to issue --- .../tasks/build_kibana_example_plugins.ts | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/dev/build/tasks/build_kibana_example_plugins.ts b/src/dev/build/tasks/build_kibana_example_plugins.ts index 7eb696ffdd3b2..0208ba2ed61b6 100644 --- a/src/dev/build/tasks/build_kibana_example_plugins.ts +++ b/src/dev/build/tasks/build_kibana_example_plugins.ts @@ -13,17 +13,26 @@ import { exec, mkdirp, copyAll, Task } from '../lib'; export const BuildKibanaExamplePlugins: Task = { description: 'Building distributable versions of Kibana example plugins', - async run(config, log, build) { - const examplesDir = Path.resolve(REPO_ROOT, 'examples'); + async run(config, log) { const args = [ - '../../scripts/plugin_helpers', + Path.resolve(REPO_ROOT, 'scripts/plugin_helpers'), 'build', `--kibana-version=${config.getBuildVersion()}`, ]; - const folders = Fs.readdirSync(examplesDir, { withFileTypes: true }) - .filter((f) => f.isDirectory()) - .map((f) => Path.resolve(REPO_ROOT, 'examples', f.name)); + const getExampleFolders = (dir: string) => { + return Fs.readdirSync(dir, { withFileTypes: true }) + .filter((f) => f.isDirectory()) + .map((f) => Path.resolve(dir, f.name)); + }; + + // https://github.com/elastic/kibana/issues/127338 + const skipExamples = ['alerting_example']; + + const folders = [ + ...getExampleFolders(Path.resolve(REPO_ROOT, 'examples')), + ...getExampleFolders(Path.resolve(REPO_ROOT, 'x-pack/examples')), + ].filter((p) => !skipExamples.includes(Path.basename(p))); for (const examplePlugin of folders) { try { @@ -40,8 +49,8 @@ export const BuildKibanaExamplePlugins: Task = { const pluginsDir = config.resolveFromTarget('example_plugins'); await mkdirp(pluginsDir); - await copyAll(examplesDir, pluginsDir, { - select: ['*/build/*.zip'], + await copyAll(REPO_ROOT, pluginsDir, { + select: ['examples/*/build/*.zip', 'x-pack/examples/*/build/*.zip'], }); }, }; From 956052591002fbe826a8879104da689dbbb106f7 Mon Sep 17 00:00:00 2001 From: Jordan <51442161+JordanSh@users.noreply.github.com> Date: Mon, 14 Mar 2022 20:46:10 +0200 Subject: [PATCH 30/44] set max width for dashboard page, some text changes for risks table (#127601) --- .../pages/compliance_dashboard/compliance_dashboard.tsx | 1 + .../dashboard_sections/benchmarks_section.tsx | 2 +- .../public/pages/compliance_dashboard/translations.ts | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_dashboard.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_dashboard.tsx index a21ac4877f1c6..07b5294a8d4ae 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_dashboard.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/compliance_dashboard.tsx @@ -37,6 +37,7 @@ export const ComplianceDashboard = () => { pageHeader={{ pageTitle: TEXT.CLOUD_POSTURE, }} + restrictWidth={1600} > diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/benchmarks_section.tsx b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/benchmarks_section.tsx index 18e137ff3e678..0fc8f0d3d0bef 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/benchmarks_section.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/dashboard_sections/benchmarks_section.tsx @@ -87,7 +87,7 @@ export const BenchmarksSection = () => { - {moment(cluster.meta.lastUpdate).fromNow()} + {` ${moment(cluster.meta.lastUpdate).fromNow()}`} diff --git a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/translations.ts b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/translations.ts index 0d62625f98254..87193ef67fa3a 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/translations.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/compliance_dashboard/translations.ts @@ -15,8 +15,8 @@ export const CLOUD_POSTURE_SCORE = i18n.translate('xpack.csp.cloud_posture_score defaultMessage: 'Cloud Posture Score', }); -export const RISKS = i18n.translate('xpack.csp.risks', { - defaultMessage: 'Risks', +export const RISKS = i18n.translate('xpack.csp.complianceDashboard.failedFindingsChartLabel', { + defaultMessage: 'Failed Findings', }); export const OPEN_CASES = i18n.translate('xpack.csp.open_cases', { From 7145dc0d8e83eea35feb5c91141b25dbd03c6ab0 Mon Sep 17 00:00:00 2001 From: Jack Date: Mon, 14 Mar 2022 15:07:46 -0400 Subject: [PATCH 31/44] Update session view plugin process tree alerts (#126997) * Add UI for alerts row * Add selectedAlert in session view * Allow jumpToEvent to be used by alert documents * Change behavior of selecting alert row will update selectedProcess * PR comment fix and polish process tree node button styles * Handle very long rule name in alert row * Fix default selection highlight color * Address PR comments * Address PR comments and add alert count to alert button with tests * Add 99+ to alerts button and more tests * Fix scroll to jumpToEvent not working on load, and add scrollToAlert * Change hardcoded root esculation to user escalation * Fix mock process data causing failed jest test runs --- .../plugins/session_view/common/constants.ts | 1 + .../constants/session_view_process.mock.ts | 146 ++++++++++++++---- .../public/components/process_tree/hooks.ts | 4 +- .../public/components/process_tree/index.tsx | 15 +- .../public/components/process_tree/styles.ts | 2 +- .../components/process_tree_alert/helpers.ts | 15 ++ .../process_tree_alert/index.test.tsx | 62 ++++++++ .../components/process_tree_alert/index.tsx | 68 ++++++++ .../components/process_tree_alert/styles.ts | 91 +++++++++++ .../process_tree_alerts/index.test.tsx | 35 +++-- .../components/process_tree_alerts/index.tsx | 132 ++++++++-------- .../components/process_tree_alerts/styles.ts | 12 +- .../components/process_tree_node/buttons.tsx | 27 +++- .../process_tree_node/index.test.tsx | 35 ++++- .../components/process_tree_node/index.tsx | 51 +++++- .../components/process_tree_node/styles.ts | 19 ++- .../process_tree_node/use_button_styles.ts | 51 ++++-- .../public/components/session_view/hooks.ts | 11 +- .../public/components/session_view/index.tsx | 10 +- .../session_view/public/methods/index.tsx | 9 +- x-pack/plugins/session_view/public/plugin.ts | 4 +- x-pack/plugins/session_view/public/types.ts | 10 ++ 22 files changed, 630 insertions(+), 180 deletions(-) create mode 100644 x-pack/plugins/session_view/public/components/process_tree_alert/helpers.ts create mode 100644 x-pack/plugins/session_view/public/components/process_tree_alert/index.test.tsx create mode 100644 x-pack/plugins/session_view/public/components/process_tree_alert/index.tsx create mode 100644 x-pack/plugins/session_view/public/components/process_tree_alert/styles.ts diff --git a/x-pack/plugins/session_view/common/constants.ts b/x-pack/plugins/session_view/common/constants.ts index 5baf690dc44a5..4d0d475859d84 100644 --- a/x-pack/plugins/session_view/common/constants.ts +++ b/x-pack/plugins/session_view/common/constants.ts @@ -25,3 +25,4 @@ export const ENTRY_SESSION_ENTITY_ID_PROPERTY = 'process.entry_leader.entity_id' // search functionality will instead use a separate ES backend search to avoid this. // 3. Fewer round trips to the backend! export const PROCESS_EVENTS_PER_PAGE = 1000; +export const MOUSE_EVENT_PLACEHOLDER = { stopPropagation: () => undefined } as React.MouseEvent; diff --git a/x-pack/plugins/session_view/common/mocks/constants/session_view_process.mock.ts b/x-pack/plugins/session_view/common/mocks/constants/session_view_process.mock.ts index b7b0bbb91b5ec..a1bbed450e2b7 100644 --- a/x-pack/plugins/session_view/common/mocks/constants/session_view_process.mock.ts +++ b/x-pack/plugins/session_view/common/mocks/constants/session_view_process.mock.ts @@ -19,7 +19,7 @@ export const mockEvents: ProcessEvent[] = [ { '@timestamp': '2021-11-23T15:25:04.210Z', user: { - name: '', + name: 'vagrant', id: '1000', }, process: { @@ -39,7 +39,7 @@ export const mockEvents: ProcessEvent[] = [ parent: { pid: 2442, user: { - name: '', + name: 'vagrant', id: '1000', }, executable: '/usr/bin/bash', @@ -63,7 +63,7 @@ export const mockEvents: ProcessEvent[] = [ session_leader: { pid: 2442, user: { - name: '', + name: 'vagrant', id: '1000', }, executable: '/usr/bin/bash', @@ -87,7 +87,7 @@ export const mockEvents: ProcessEvent[] = [ entry_leader: { pid: 2442, user: { - name: '', + name: 'vagrant', id: '1000', }, executable: '/usr/bin/bash', @@ -111,7 +111,7 @@ export const mockEvents: ProcessEvent[] = [ group_leader: { pid: 2442, user: { - name: '', + name: 'vagrant', id: '1000', }, executable: '/usr/bin/bash', @@ -163,7 +163,7 @@ export const mockEvents: ProcessEvent[] = [ { '@timestamp': '2021-11-23T15:25:04.218Z', user: { - name: '', + name: 'vagrant', id: '1000', }, process: { @@ -183,7 +183,7 @@ export const mockEvents: ProcessEvent[] = [ parent: { pid: 2442, user: { - name: '', + name: 'vagrant', id: '1000', }, executable: '/usr/bin/bash', @@ -207,7 +207,7 @@ export const mockEvents: ProcessEvent[] = [ session_leader: { pid: 2442, user: { - name: '', + name: 'vagrant', id: '1000', }, executable: '/usr/bin/bash', @@ -231,7 +231,7 @@ export const mockEvents: ProcessEvent[] = [ entry_leader: { pid: 2442, user: { - name: '', + name: 'vagrant', id: '1000', }, executable: '/usr/bin/bash', @@ -282,7 +282,7 @@ export const mockEvents: ProcessEvent[] = [ { '@timestamp': '2021-11-23T15:25:05.202Z', user: { - name: '', + name: 'vagrant', id: '1000', }, process: { @@ -303,7 +303,7 @@ export const mockEvents: ProcessEvent[] = [ parent: { pid: 2442, user: { - name: '', + name: 'vagrant', id: '1000', }, executable: '/usr/bin/bash', @@ -326,7 +326,7 @@ export const mockEvents: ProcessEvent[] = [ session_leader: { pid: 2442, user: { - name: '', + name: 'vagrant', id: '1000', }, executable: '/usr/bin/bash', @@ -350,7 +350,7 @@ export const mockEvents: ProcessEvent[] = [ entry_leader: { pid: 2442, user: { - name: '', + name: 'vagrant', id: '1000', }, executable: '/usr/bin/bash', @@ -456,7 +456,7 @@ export const mockAlerts: ProcessEvent[] = [ parent: { pid: 2442, user: { - name: '', + name: 'vagrant', id: '1000', }, executable: '/usr/bin/bash', @@ -480,7 +480,7 @@ export const mockAlerts: ProcessEvent[] = [ session_leader: { pid: 2442, user: { - name: '', + name: 'vagrant', id: '1000', }, executable: '/usr/bin/bash', @@ -504,7 +504,7 @@ export const mockAlerts: ProcessEvent[] = [ entry_leader: { pid: 2442, user: { - name: '', + name: 'vagrant', id: '1000', }, executable: '/usr/bin/bash', @@ -528,7 +528,7 @@ export const mockAlerts: ProcessEvent[] = [ group_leader: { pid: 2442, user: { - name: '', + name: 'vagrant', id: '1000', }, executable: '/usr/bin/bash', @@ -624,7 +624,7 @@ export const mockAlerts: ProcessEvent[] = [ parent: { pid: 2442, user: { - name: '', + name: 'vagrant', id: '1000', }, executable: '/usr/bin/bash', @@ -648,7 +648,7 @@ export const mockAlerts: ProcessEvent[] = [ session_leader: { pid: 2442, user: { - name: '', + name: 'vagrant', id: '1000', }, executable: '/usr/bin/bash', @@ -672,7 +672,7 @@ export const mockAlerts: ProcessEvent[] = [ entry_leader: { pid: 2442, user: { - name: '', + name: 'vagrant', id: '1000', }, executable: '/usr/bin/bash', @@ -696,7 +696,7 @@ export const mockAlerts: ProcessEvent[] = [ group_leader: { pid: 2442, user: { - name: '', + name: 'vagrant', id: '1000', }, executable: '/usr/bin/bash', @@ -881,7 +881,7 @@ export const processMock: Process = { }, }, user: { - id: '1', + id: '1000', name: 'vagrant', }, process: { @@ -895,10 +895,102 @@ export const processMock: Process = { working_directory: '/home/vagrant', start: '2021-11-23T15:25:04.210Z', pid: 1, - parent: {} as ProcessFields, - session_leader: {} as ProcessFields, - entry_leader: {} as ProcessFields, - group_leader: {} as ProcessFields, + parent: { + pid: 2442, + user: { + name: 'vagrant', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:26:34.859Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + } as ProcessFields, + session_leader: { + pid: 2442, + user: { + name: 'vagrant', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:26:34.859Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + } as ProcessFields, + entry_leader: { + pid: 2442, + user: { + name: 'vagrant', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:26:34.859Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + } as ProcessFields, + group_leader: { + pid: 2442, + user: { + name: 'vagrant', + id: '1000', + }, + executable: '/usr/bin/bash', + command_line: 'bash', + interactive: true, + entity_id: '3d0192c6-7c54-5ee6-a110-3539a7cf42bc', + name: '', + args: [], + args_count: 0, + working_directory: '/home/vagrant', + start: '2021-11-23T15:26:34.859Z', + tty: { + descriptor: 0, + type: 'char_device', + char_device: { + major: 8, + minor: 1, + }, + }, + } as ProcessFields, }, } as ProcessEvent), isUserEntered: () => false, @@ -916,7 +1008,7 @@ export const sessionViewAlertProcessMock: Process = { ...processMock, events: [...mockEvents, ...mockAlerts], hasAlerts: () => true, - getAlerts: () => mockEvents, + getAlerts: () => mockAlerts, hasExec: () => true, isUserEntered: () => true, }; diff --git a/x-pack/plugins/session_view/public/components/process_tree/hooks.ts b/x-pack/plugins/session_view/public/components/process_tree/hooks.ts index a8c6ffe8e75d3..e4f934ea21da6 100644 --- a/x-pack/plugins/session_view/public/components/process_tree/hooks.ts +++ b/x-pack/plugins/session_view/public/components/process_tree/hooks.ts @@ -68,8 +68,8 @@ export class ProcessImpl implements Process { const { group_leader: groupLeader, session_leader: sessionLeader } = child.getDetails().process; - // search matches will never be filtered out - if (child.searchMatched) { + // search matches or processes with alerts will never be filtered out + if (child.searchMatched || child.hasAlerts()) { return true; } diff --git a/x-pack/plugins/session_view/public/components/process_tree/index.tsx b/x-pack/plugins/session_view/public/components/process_tree/index.tsx index 6b3061a0d77bb..171836b510815 100644 --- a/x-pack/plugins/session_view/public/components/process_tree/index.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree/index.tsx @@ -130,21 +130,18 @@ export const ProcessTree = ({ useEffect(() => { // after 2 pages are loaded (due to bi-directional jump to), auto select the process // for the jumpToEvent - if (jumpToEvent && data.length === 2) { + if (!selectedProcess && jumpToEvent) { const process = processMap[jumpToEvent.process.entity_id]; if (process) { onProcessSelected(process); + selectProcess(process); } - } - }, [jumpToEvent, processMap, onProcessSelected, data]); - - // auto selects the session leader process if no selection is made yet - useEffect(() => { - if (!selectedProcess) { + } else if (!selectedProcess) { + // auto selects the session leader process if no selection is made yet onProcessSelected(sessionLeader); } - }, [sessionLeader, onProcessSelected, selectedProcess]); + }, [jumpToEvent, processMap, onProcessSelected, selectProcess, selectedProcess, sessionLeader]); return (

)}
{ const { euiTheme } = useEuiTheme(); const cached = useMemo(() => { - const defaultSelectionColor = euiTheme.colors.accent; + const defaultSelectionColor = euiTheme.colors.primary; const scroller: CSSObject = { position: 'relative', diff --git a/x-pack/plugins/session_view/public/components/process_tree_alert/helpers.ts b/x-pack/plugins/session_view/public/components/process_tree_alert/helpers.ts new file mode 100644 index 0000000000000..29a85aa777f0f --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree_alert/helpers.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +const STATUS_TO_COLOR_MAP: Record = { + open: 'primary', + acknowledged: 'warning', + closed: 'default', +}; + +export const getBadgeColorFromAlertStatus = (status: string | undefined) => + STATUS_TO_COLOR_MAP[status || 'closed']; diff --git a/x-pack/plugins/session_view/public/components/process_tree_alert/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree_alert/index.test.tsx new file mode 100644 index 0000000000000..635ac09682eae --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree_alert/index.test.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { mockAlerts } from '../../../common/mocks/constants/session_view_process.mock'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { ProcessTreeAlert } from './index'; + +const mockAlert = mockAlerts[0]; +const TEST_ID = `sessionView:sessionViewAlertDetail-${mockAlert.kibana?.alert.uuid}`; +const ALERT_RULE_NAME = mockAlert.kibana?.alert.rule.name; +const ALERT_STATUS = mockAlert.kibana?.alert.workflow_status; + +describe('ProcessTreeAlerts component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + }); + + describe('When ProcessTreeAlert is mounted', () => { + it('should render alert row correctly', async () => { + renderResult = mockedContext.render( + + ); + + expect(renderResult.queryByTestId(TEST_ID)).toBeTruthy(); + expect(renderResult.queryByText(ALERT_RULE_NAME!)).toBeTruthy(); + expect(renderResult.queryByText(ALERT_STATUS!)).toBeTruthy(); + }); + + it('should execute onClick callback', async () => { + const mockFn = jest.fn(); + renderResult = mockedContext.render( + + ); + + const alertRow = renderResult.queryByTestId(TEST_ID); + expect(alertRow).toBeTruthy(); + alertRow?.click(); + expect(mockFn).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/process_tree_alert/index.tsx b/x-pack/plugins/session_view/public/components/process_tree_alert/index.tsx new file mode 100644 index 0000000000000..d0d4c84252513 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree_alert/index.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect } from 'react'; +import { EuiBadge, EuiIcon, EuiText, EuiButtonIcon } from '@elastic/eui'; +import { ProcessEvent, ProcessEventAlert } from '../../../common/types/process_tree'; +import { getBadgeColorFromAlertStatus } from './helpers'; +import { useStyles } from './styles'; + +interface ProcessTreeAlertDeps { + alert: ProcessEvent; + isInvestigated: boolean; + isSelected: boolean; + onClick: (alert: ProcessEventAlert | null) => void; + selectAlert: (alertUuid: string) => void; +} + +export const ProcessTreeAlert = ({ + alert, + isInvestigated, + isSelected, + onClick, + selectAlert, +}: ProcessTreeAlertDeps) => { + const styles = useStyles({ isInvestigated, isSelected }); + + const { uuid, rule, workflow_status: status } = alert.kibana?.alert || {}; + + useEffect(() => { + if (isInvestigated && isSelected && uuid) { + selectAlert(uuid); + } + }, [isInvestigated, isSelected, uuid, selectAlert]); + + if (!(alert.kibana && rule)) { + return null; + } + + const { name } = rule; + + const handleClick = () => { + onClick(alert.kibana?.alert ?? null); + }; + + return ( + + + + + {name} + + + {status} + + + ); +}; diff --git a/x-pack/plugins/session_view/public/components/process_tree_alert/styles.ts b/x-pack/plugins/session_view/public/components/process_tree_alert/styles.ts new file mode 100644 index 0000000000000..bcd47edf56db8 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/process_tree_alert/styles.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { useEuiTheme, transparentize } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; + +interface StylesDeps { + isInvestigated: boolean; + isSelected: boolean; +} + +export const useStyles = ({ isInvestigated, isSelected }: StylesDeps) => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const { size, colors, font } = euiTheme; + + const getHighlightColors = () => { + let bgColor = 'none'; + let hoverBgColor = `${transparentize(colors.primary, 0.04)}`; + + if (isInvestigated && isSelected) { + bgColor = `${transparentize(colors.danger, 0.08)}`; + hoverBgColor = `${transparentize(colors.danger, 0.12)}`; + } else if (isInvestigated) { + bgColor = `${transparentize(colors.danger, 0.04)}`; + hoverBgColor = `${transparentize(colors.danger, 0.12)}`; + } else if (isSelected) { + bgColor = `${transparentize(colors.primary, 0.08)}`; + hoverBgColor = `${transparentize(colors.primary, 0.12)}`; + } + + return { bgColor, hoverBgColor }; + }; + + const { bgColor, hoverBgColor } = getHighlightColors(); + + const alert: CSSObject = { + fontFamily: font.family, + display: 'flex', + alignItems: 'center', + minHeight: '20px', + padding: `${size.xs} ${size.base}`, + boxSizing: 'content-box', + cursor: 'pointer', + '&:not(:last-child)': { + marginBottom: size.s, + }, + background: bgColor, + '&:hover': { + background: hoverBgColor, + }, + }; + + const alertRowItem: CSSObject = { + '&:first-of-type': { + marginRight: size.m, + }, + '&:not(:first-of-type)': { + marginRight: size.s, + }, + }; + + const alertRuleName: CSSObject = { + ...alertRowItem, + maxWidth: '70%', + }; + + const alertStatus: CSSObject = { + ...alertRowItem, + textTransform: 'capitalize', + '&, span': { + cursor: 'pointer !important', + }, + }; + + return { + alert, + alertRowItem, + alertRuleName, + alertStatus, + }; + }, [euiTheme, isInvestigated, isSelected]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/process_tree_alerts/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree_alerts/index.test.tsx index 618b36578d7da..c4dbaf817cff2 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_alerts/index.test.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_alerts/index.test.tsx @@ -21,34 +21,45 @@ describe('ProcessTreeAlerts component', () => { describe('When ProcessTreeAlerts is mounted', () => { it('should return null if no alerts', async () => { - renderResult = mockedContext.render(); + renderResult = mockedContext.render( + + ); expect(renderResult.queryByTestId('sessionView:sessionViewAlertDetails')).toBeNull(); }); it('should return an array of alert details', async () => { - renderResult = mockedContext.render(); + renderResult = mockedContext.render( + + ); expect(renderResult.queryByTestId('sessionView:sessionViewAlertDetails')).toBeTruthy(); mockAlerts.forEach((alert) => { if (!alert.kibana) { return; } - const { uuid, rule, original_event: event, workflow_status: status } = alert.kibana.alert; - const { name, query, severity } = rule; + const { uuid } = alert.kibana.alert; expect( renderResult.queryByTestId(`sessionView:sessionViewAlertDetail-${uuid}`) ).toBeTruthy(); - expect( - renderResult.queryByTestId(`sessionView:sessionViewAlertDetailViewRule-${uuid}`) - ).toBeTruthy(); - expect(renderResult.queryAllByText(new RegExp(event.action, 'i')).length).toBeTruthy(); - expect(renderResult.queryAllByText(new RegExp(status, 'i')).length).toBeTruthy(); - expect(renderResult.queryAllByText(new RegExp(name, 'i')).length).toBeTruthy(); - expect(renderResult.queryAllByText(new RegExp(query, 'i')).length).toBeTruthy(); - expect(renderResult.queryAllByText(new RegExp(severity, 'i')).length).toBeTruthy(); }); }); + + it('should execute onAlertSelected when clicking on an alert', async () => { + const mockFn = jest.fn(); + renderResult = mockedContext.render( + + ); + + expect(renderResult.queryByTestId('sessionView:sessionViewAlertDetails')).toBeTruthy(); + + const testAlertRow = renderResult.queryByTestId( + `sessionView:sessionViewAlertDetail-${mockAlerts[0].kibana?.alert.uuid}` + ); + expect(testAlertRow).toBeTruthy(); + testAlertRow?.click(); + expect(mockFn).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/x-pack/plugins/session_view/public/components/process_tree_alerts/index.tsx b/x-pack/plugins/session_view/public/components/process_tree_alerts/index.tsx index 5312c09867b96..dcca29dcf4f84 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_alerts/index.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_alerts/index.tsx @@ -5,91 +5,87 @@ * 2.0. */ -import React from 'react'; -import { EuiButton, EuiText, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; +import React, { useState, useEffect, useRef, MouseEvent, useCallback } from 'react'; import { useStyles } from './styles'; -import { ProcessEvent } from '../../../common/types/process_tree'; -import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; -import { CoreStart } from '../../../../../../src/core/public'; +import { ProcessEvent, ProcessEventAlert } from '../../../common/types/process_tree'; +import { ProcessTreeAlert } from '../process_tree_alert'; +import { MOUSE_EVENT_PLACEHOLDER } from '../../../common/constants'; interface ProcessTreeAlertsDeps { alerts: ProcessEvent[]; + jumpToAlertID?: string; + isProcessSelected?: boolean; + onAlertSelected: (e: MouseEvent) => void; } -const getRuleUrl = (alert: ProcessEvent, http: CoreStart['http']) => { - return http.basePath.prepend(`/app/security/rules/id/${alert.kibana?.alert.rule.uuid}`); -}; +export function ProcessTreeAlerts({ + alerts, + jumpToAlertID, + isProcessSelected = false, + onAlertSelected, +}: ProcessTreeAlertsDeps) { + const [selectedAlert, setSelectedAlert] = useState(null); + const styles = useStyles(); -const ProcessTreeAlert = ({ alert }: { alert: ProcessEvent }) => { - const { http } = useKibana().services; + useEffect(() => { + const jumpToAlert = alerts.find((alert) => alert.kibana?.alert.uuid === jumpToAlertID); + if (jumpToAlertID && jumpToAlert) { + setSelectedAlert(jumpToAlert.kibana?.alert!); + } + }, [jumpToAlertID, alerts]); - if (!alert.kibana) { - return null; - } + const scrollerRef = useRef(null); - const { uuid, rule, original_event: event, workflow_status: status } = alert.kibana.alert; - const { name, query, severity } = rule; + const selectAlert = useCallback((alertUuid: string) => { + if (!scrollerRef?.current) { + return; + } - return ( - - - -
- -
- {name} -
- -
- {query} -
- -
- -
- {severity} -
- -
- {status} -
- -
- -
- {event.action} - -
- - - -
-
-
-
- ); -}; + const alertEl = scrollerRef.current.querySelector(`[data-id="${alertUuid}"]`); -export function ProcessTreeAlerts({ alerts }: ProcessTreeAlertsDeps) { - const styles = useStyles(); + if (alertEl) { + const cTop = scrollerRef.current.scrollTop; + const cBottom = cTop + scrollerRef.current.clientHeight; + + const eTop = alertEl.offsetTop; + const eBottom = eTop + alertEl.clientHeight; + const isVisible = eTop >= cTop && eBottom <= cBottom; + + if (!isVisible) { + alertEl.scrollIntoView({ block: 'nearest' }); + } + } + }, []); if (alerts.length === 0) { return null; } + const handleAlertClick = (alert: ProcessEventAlert | null) => { + onAlertSelected(MOUSE_EVENT_PLACEHOLDER); + setSelectedAlert(alert); + }; + return ( -
- {alerts.map((alert: ProcessEvent) => ( - - ))} +
+ {alerts.map((alert: ProcessEvent, idx: number) => { + const alertUuid = alert.kibana?.alert.uuid || null; + + return ( + + ); + })}
); } diff --git a/x-pack/plugins/session_view/public/components/process_tree_alerts/styles.ts b/x-pack/plugins/session_view/public/components/process_tree_alerts/styles.ts index d601891591305..ddd4a04d2f991 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_alerts/styles.ts +++ b/x-pack/plugins/session_view/public/components/process_tree_alerts/styles.ts @@ -19,21 +19,15 @@ export const useStyles = () => { marginTop: size.s, marginRight: size.s, color: colors.text, - padding: size.m, + padding: `${size.s} 0`, borderStyle: 'solid', borderColor: colors.lightShade, borderWidth: border.width.thin, borderRadius: border.radius.medium, maxWidth: 800, + maxHeight: 378, + overflowY: 'auto', backgroundColor: 'white', - '&>div': { - borderTop: border.thin, - marginTop: size.m, - paddingTop: size.m, - '&:first-child': { - borderTop: 'none', - }, - }, }; return { diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/buttons.tsx b/x-pack/plugins/session_view/public/components/process_tree_node/buttons.tsx index 16cb946174691..3cbd64caf9353 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_node/buttons.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_node/buttons.tsx @@ -17,7 +17,7 @@ export const ChildrenProcessesButton = ({ onToggle: () => void; isExpanded: boolean; }) => { - const { button, buttonArrow, getExpandedIcon } = useButtonStyles(); + const { button, buttonArrow, expandedIcon } = useButtonStyles({ isExpanded }); return ( - + ); }; @@ -45,7 +45,9 @@ export const SessionLeaderButton = ({ }) => { const groupLeaderCount = process.getChildren(false).length; const sameGroupCount = childCount - groupLeaderCount; - const { button, buttonArrow, getExpandedIcon } = useButtonStyles(); + const { button, buttonArrow, expandedIcon } = useButtonStyles({ + isExpanded: !showGroupLeadersOnly, + }); if (sameGroupCount > 0) { return ( @@ -74,7 +76,7 @@ export const SessionLeaderButton = ({ count: sameGroupCount, }} /> - + ); @@ -85,11 +87,15 @@ export const SessionLeaderButton = ({ export const AlertButton = ({ isExpanded, onToggle, + alertsCount, }: { isExpanded: boolean; onToggle: () => void; + alertsCount: number; }) => { - const { alertButton, buttonArrow, getExpandedIcon } = useButtonStyles(); + const { alertButton, alertsCountNumber, buttonArrow, expandedIcon } = useButtonStyles({ + isExpanded, + }); return ( - - + {alertsCount > 1 ? ( + + ) : ( + + )} + {alertsCount > 1 && ( + ({alertsCount > 99 ? '99+' : alertsCount}) + )} + ); }; diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx b/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx index 2a3bf94086021..6b85ca1d208e1 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_node/index.test.tsx @@ -133,12 +133,45 @@ describe('ProcessTreeNode component', () => { windowGetSelectionSpy.mockRestore(); }); describe('Alerts', () => { - it('renders Alert button when process has alerts', async () => { + it('renders Alert button when process has one alert', async () => { + const processMockWithOneAlert = { + ...sessionViewAlertProcessMock, + events: sessionViewAlertProcessMock.events.slice( + 0, + sessionViewAlertProcessMock.events.length - 1 + ), + getAlerts: () => [sessionViewAlertProcessMock.getAlerts()[0]], + }; + renderResult = mockedContext.render(); + + expect(renderResult.queryByTestId('processTreeNodeAlertButton')).toBeTruthy(); + expect(renderResult.queryByTestId('processTreeNodeAlertButton')?.textContent).toBe('Alert'); + }); + it('renders Alerts button when process has more than one alerts', async () => { renderResult = mockedContext.render( ); expect(renderResult.queryByTestId('processTreeNodeAlertButton')).toBeTruthy(); + expect(renderResult.queryByTestId('processTreeNodeAlertButton')?.textContent).toBe( + `Alerts(${sessionViewAlertProcessMock.getAlerts().length})` + ); + }); + it('renders Alerts button with 99+ when process has more than 99 alerts', async () => { + const processMockWithOneAlert = { + ...sessionViewAlertProcessMock, + getAlerts: () => + Array.from( + new Array(100), + (item) => (item = sessionViewAlertProcessMock.getAlerts()[0]) + ), + }; + renderResult = mockedContext.render(); + + expect(renderResult.queryByTestId('processTreeNodeAlertButton')).toBeTruthy(); + expect(renderResult.queryByTestId('processTreeNodeAlertButton')?.textContent).toBe( + 'Alerts(99+)' + ); }); it('toggle Alert Details button when Alert button is clicked', async () => { renderResult = mockedContext.render( diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx b/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx index 9db83f58f7738..f73cc706fd398 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx +++ b/x-pack/plugins/session_view/public/components/process_tree_node/index.tsx @@ -18,6 +18,7 @@ import React, { useEffect, MouseEvent, useCallback, + useMemo, } from 'react'; import { EuiButton, EuiIcon } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -31,6 +32,8 @@ interface ProcessDeps { isSessionLeader?: boolean; depth?: number; onProcessSelected?: (process: Process) => void; + jumpToAlertID?: string; + selectedProcessId?: string; } /** @@ -41,6 +44,8 @@ export function ProcessTreeNode({ isSessionLeader = false, depth = 0, onProcessSelected, + jumpToAlertID, + selectedProcessId, }: ProcessDeps) { const textRef = useRef(null); @@ -54,8 +59,24 @@ export function ProcessTreeNode({ }, [isSessionLeader, process.autoExpand]); const alerts = process.getAlerts(); - const styles = useStyles({ depth, hasAlerts: !!alerts.length }); - const buttonStyles = useButtonStyles(); + const hasAlerts = useMemo(() => !!alerts.length, [alerts]); + const hasInvestigatedAlert = useMemo( + () => + !!( + hasAlerts && + alerts.find((alert) => jumpToAlertID && jumpToAlertID === alert.kibana?.alert.uuid) + ), + [hasAlerts, alerts, jumpToAlertID] + ); + const styles = useStyles({ depth, hasAlerts, hasInvestigatedAlert }); + const buttonStyles = useButtonStyles({}); + + // Automatically expand alerts list when investigating an alert + useEffect(() => { + if (hasInvestigatedAlert) { + setAlertsExpanded(true); + } + }, [hasInvestigatedAlert]); useLayoutEffect(() => { if (searchMatched !== null && textRef.current) { @@ -100,7 +121,7 @@ export function ProcessTreeNode({ const processDetails = process.getDetails(); - if (!processDetails) { + if (!processDetails?.process) { return null; } @@ -120,7 +141,7 @@ export function ProcessTreeNode({ const shouldRenderChildren = childrenExpanded && children && children.length > 0; const childrenTreeDepth = depth + 1; - const showRootEscalation = user.name === 'root' && user.id !== parent.user.id; + const showUserEscalation = user.id !== parent.user.id; const interactiveSession = !!tty; const sessionIcon = interactiveSession ? 'consoleApp' : 'compute'; const hasExec = process.hasExec(); @@ -172,27 +193,39 @@ export function ProcessTreeNode({ )} - {showRootEscalation && ( + {showUserEscalation && ( + {user.name} )} {!isSessionLeader && childCount > 0 && ( )} {alerts.length > 0 && ( - + )}
- {alertsExpanded && } + {alertsExpanded && ( + + )} {shouldRenderChildren && (
@@ -203,6 +236,8 @@ export function ProcessTreeNode({ process={child} depth={childrenTreeDepth} onProcessSelected={onProcessSelected} + jumpToAlertID={jumpToAlertID} + selectedProcessId={selectedProcessId} /> ); })} diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/styles.ts b/x-pack/plugins/session_view/public/components/process_tree_node/styles.ts index 07092d6de28ea..8c077b6dfbbd7 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_node/styles.ts +++ b/x-pack/plugins/session_view/public/components/process_tree_node/styles.ts @@ -12,15 +12,16 @@ import { CSSObject } from '@emotion/react'; interface StylesDeps { depth: number; hasAlerts: boolean; + hasInvestigatedAlert: boolean; } -export const useStyles = ({ depth, hasAlerts }: StylesDeps) => { +export const useStyles = ({ depth, hasAlerts, hasInvestigatedAlert }: StylesDeps) => { const { euiTheme } = useEuiTheme(); const cached = useMemo(() => { const { colors, border, size } = euiTheme; - const TREE_INDENT = euiTheme.base * 2; + const TREE_INDENT = `calc(${size.l} + ${size.xxs})`; const darkText: CSSObject = { color: colors.text, @@ -49,10 +50,12 @@ export const useStyles = ({ depth, hasAlerts }: StylesDeps) => { const hoverColor = transparentize(colors.primary, 0.04); let borderColor = 'transparent'; - // TODO: alerts highlight colors if (hasAlerts) { + borderColor = colors.danger; + } + + if (hasInvestigatedAlert) { bgColor = transparentize(colors.danger, 0.04); - borderColor = transparentize(colors.danger, 0.48); } return { bgColor, borderColor, hoverColor }; @@ -65,7 +68,7 @@ export const useStyles = ({ depth, hasAlerts }: StylesDeps) => { cursor: 'pointer', position: 'relative', margin: `${size.s} 0px`, - '&:not(:first-child)': { + '&:not(:first-of-type)': { marginTop: size.s, }, '&:hover:before': { @@ -76,10 +79,10 @@ export const useStyles = ({ depth, hasAlerts }: StylesDeps) => { height: '100%', pointerEvents: 'none', content: `''`, - marginLeft: `-${depth * TREE_INDENT}px`, + marginLeft: `calc(-${depth} * ${TREE_INDENT})`, borderLeft: `${size.xs} solid ${borderColor}`, backgroundColor: bgColor, - width: `calc(100% + ${depth * TREE_INDENT}px)`, + width: `calc(100% + ${depth} * ${TREE_INDENT})`, }, }; @@ -112,7 +115,7 @@ export const useStyles = ({ depth, hasAlerts }: StylesDeps) => { workingDir, alertDetails, }; - }, [depth, euiTheme, hasAlerts]); + }, [depth, euiTheme, hasAlerts, hasInvestigatedAlert]); return cached; }; diff --git a/x-pack/plugins/session_view/public/components/process_tree_node/use_button_styles.ts b/x-pack/plugins/session_view/public/components/process_tree_node/use_button_styles.ts index d208fa8f079af..529a0ce5819f9 100644 --- a/x-pack/plugins/session_view/public/components/process_tree_node/use_button_styles.ts +++ b/x-pack/plugins/session_view/public/components/process_tree_node/use_button_styles.ts @@ -6,25 +6,28 @@ */ import { useMemo } from 'react'; -import { useEuiTheme, transparentize } from '@elastic/eui'; +import { useEuiTheme, transparentize, shade } from '@elastic/eui'; import { euiLightVars as theme } from '@kbn/ui-theme'; import { CSSObject } from '@emotion/react'; -export const useButtonStyles = () => { +interface ButtonStylesDeps { + isExpanded?: boolean; +} + +export const useButtonStyles = ({ isExpanded }: ButtonStylesDeps) => { const { euiTheme } = useEuiTheme(); const cached = useMemo(() => { - const { colors, border, font, size } = euiTheme; + const { colors, border, size } = euiTheme; const button: CSSObject = { background: transparentize(theme.euiColorVis6, 0.04), border: `${border.width.thin} solid ${transparentize(theme.euiColorVis6, 0.48)}`, lineHeight: '18px', height: '20px', - fontSize: '11px', - fontFamily: font.familyCode, + fontSize: size.m, borderRadius: border.radius.medium, - color: colors.text, + color: shade(theme.euiColorVis6, 0.25), marginLeft: size.s, minWidth: 0, }; @@ -35,28 +38,52 @@ export const useButtonStyles = () => { const alertButton: CSSObject = { ...button, + color: colors.dangerText, background: transparentize(colors.dangerText, 0.04), border: `${border.width.thin} solid ${transparentize(colors.dangerText, 0.48)}`, }; + const alertsCountNumber: CSSObject = { + paddingLeft: size.xs, + }; + + if (isExpanded) { + button.color = colors.ghost; + button.background = theme.euiColorVis6; + button['&:hover, &:focus'] = { + backgroundColor: `${theme.euiColorVis6} !important`, + }; + + alertButton.color = colors.ghost; + alertButton.background = colors.dangerText; + alertButton['&:hover, &:focus'] = { + backgroundColor: `${colors.dangerText} !important`, + }; + } + const userChangedButton: CSSObject = { ...button, - background: transparentize(theme.euiColorVis1, 0.04), - border: `${border.width.thin} solid ${transparentize(theme.euiColorVis1, 0.48)}`, + color: theme.euiColorVis3, + background: transparentize(theme.euiColorVis3, 0.04), + border: `${border.width.thin} solid ${transparentize(theme.euiColorVis3, 0.48)}`, }; - const getExpandedIcon = (expanded: boolean) => { - return expanded ? 'arrowUp' : 'arrowDown'; + const userChangedButtonUsername: CSSObject = { + textTransform: 'capitalize', }; + const expandedIcon = isExpanded ? 'arrowUp' : 'arrowDown'; + return { buttonArrow, button, alertButton, + alertsCountNumber, userChangedButton, - getExpandedIcon, + userChangedButtonUsername, + expandedIcon, }; - }, [euiTheme]); + }, [euiTheme, isExpanded]); return cached; }; diff --git a/x-pack/plugins/session_view/public/components/session_view/hooks.ts b/x-pack/plugins/session_view/public/components/session_view/hooks.ts index b93e5b43ddf88..17574cfd28074 100644 --- a/x-pack/plugins/session_view/public/components/session_view/hooks.ts +++ b/x-pack/plugins/session_view/public/components/session_view/hooks.ts @@ -17,8 +17,8 @@ export const useFetchSessionViewProcessEvents = ( jumpToEvent: ProcessEvent | undefined ) => { const { http } = useKibana().services; - - const jumpToCursor = jumpToEvent && jumpToEvent['@timestamp']; + const [isJumpToFirstPage, setIsJumpToFirstPage] = useState(false); + const jumpToCursor = jumpToEvent && jumpToEvent.process.start; const query = useInfiniteQuery( 'sessionViewProcessEvents', @@ -66,10 +66,11 @@ export const useFetchSessionViewProcessEvents = ( ); useEffect(() => { - if (jumpToEvent && query.data?.pages.length === 1) { - query.fetchPreviousPage(); + if (jumpToEvent && query.data?.pages.length === 1 && !isJumpToFirstPage) { + query.fetchPreviousPage({ cancelRefetch: true }); + setIsJumpToFirstPage(true); } - }, [jumpToEvent, query]); + }, [jumpToEvent, query, isJumpToFirstPage]); return query; }; diff --git a/x-pack/plugins/session_view/public/components/session_view/index.tsx b/x-pack/plugins/session_view/public/components/session_view/index.tsx index 7a82edc94ff1b..9535a604167cc 100644 --- a/x-pack/plugins/session_view/public/components/session_view/index.tsx +++ b/x-pack/plugins/session_view/public/components/session_view/index.tsx @@ -15,19 +15,13 @@ import { import { FormattedMessage } from '@kbn/i18n-react'; import { SectionLoading } from '../../shared_imports'; import { ProcessTree } from '../process_tree'; -import { Process, ProcessEvent } from '../../../common/types/process_tree'; +import { Process } from '../../../common/types/process_tree'; +import { SessionViewDeps } from '../../types'; import { SessionViewDetailPanel } from '../session_view_detail_panel'; import { SessionViewSearchBar } from '../session_view_search_bar'; import { useStyles } from './styles'; import { useFetchSessionViewProcessEvents } from './hooks'; -interface SessionViewDeps { - // the root node of the process tree to render. e.g process.entry.entity_id or process.session_leader.entity_id - sessionEntityId: string; - height?: number; - jumpToEvent?: ProcessEvent; -} - /** * The main wrapper component for the session view. */ diff --git a/x-pack/plugins/session_view/public/methods/index.tsx b/x-pack/plugins/session_view/public/methods/index.tsx index 560bb302ebabf..1eecdcbb3e50e 100644 --- a/x-pack/plugins/session_view/public/methods/index.tsx +++ b/x-pack/plugins/session_view/public/methods/index.tsx @@ -8,17 +8,22 @@ import React, { lazy, Suspense } from 'react'; import { EuiLoadingSpinner } from '@elastic/eui'; import { QueryClient, QueryClientProvider } from 'react-query'; +import { SessionViewDeps } from '../types'; // Initializing react-query const queryClient = new QueryClient(); const SessionViewLazy = lazy(() => import('../components/session_view')); -export const getSessionViewLazy = (sessionEntityId: string) => { +export const getSessionViewLazy = ({ sessionEntityId, height, jumpToEvent }: SessionViewDeps) => { return ( }> - + ); diff --git a/x-pack/plugins/session_view/public/plugin.ts b/x-pack/plugins/session_view/public/plugin.ts index d25c95b00b2c6..d51d1bc0e6c67 100644 --- a/x-pack/plugins/session_view/public/plugin.ts +++ b/x-pack/plugins/session_view/public/plugin.ts @@ -6,7 +6,7 @@ */ import { CoreSetup, CoreStart, Plugin } from '../../../../src/core/public'; -import { SessionViewServices } from './types'; +import { SessionViewServices, SessionViewDeps } from './types'; import { getSessionViewLazy } from './methods'; export class SessionViewPlugin implements Plugin { @@ -14,7 +14,7 @@ export class SessionViewPlugin implements Plugin { public start(core: CoreStart) { return { - getSessionView: (sessionEntityId: string) => getSessionViewLazy(sessionEntityId), + getSessionView: (sessionViewDeps: SessionViewDeps) => getSessionViewLazy(sessionViewDeps), }; } diff --git a/x-pack/plugins/session_view/public/types.ts b/x-pack/plugins/session_view/public/types.ts index 2349b8423eb36..d84623af7c0ed 100644 --- a/x-pack/plugins/session_view/public/types.ts +++ b/x-pack/plugins/session_view/public/types.ts @@ -7,11 +7,21 @@ import { ReactNode } from 'react'; import { CoreStart } from '../../../../src/core/public'; import { TimelinesUIStart } from '../../timelines/public'; +import { ProcessEvent } from '../common/types/process_tree'; export type SessionViewServices = CoreStart & { timelines: TimelinesUIStart; }; +export interface SessionViewDeps { + // the root node of the process tree to render. e.g process.entry.entity_id or process.session_leader.entity_id + sessionEntityId: string; + height?: number; + // if provided, the session view will jump to and select the provided event if it belongs to the session leader + // session view will fetch a page worth of events starting from jumpToEvent as well as a page backwards. + jumpToEvent?: ProcessEvent; +} + export interface EuiTabProps { id: string; name: string; From 9ca4abae4c14c39edeab78de2c5f8e25e5dc5fa0 Mon Sep 17 00:00:00 2001 From: Rashmi Kulkarni Date: Mon, 14 Mar 2022 12:27:41 -0700 Subject: [PATCH 32/44] adding a retry loop (#127528) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/test/accessibility/apps/reporting.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/x-pack/test/accessibility/apps/reporting.ts b/x-pack/test/accessibility/apps/reporting.ts index 24cb1b0ced86e..c6a6571cc0ff6 100644 --- a/x-pack/test/accessibility/apps/reporting.ts +++ b/x-pack/test/accessibility/apps/reporting.ts @@ -75,7 +75,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await retry.waitForWithTimeout('A reporting list item', 5000, () => { return testSubjects.exists('reportingListItemObjectTitle'); }); - await a11y.testAppSnapshot(); + await retry.try(async () => { + await a11y.testAppSnapshot(); + }); }); }); } From 43244dcc1ed53a2ebe60f5dcfb1a15563ec7a859 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 14 Mar 2022 15:43:29 -0400 Subject: [PATCH 33/44] Update dependency elastic-apm-node to ^3.30.0 (#127552) Co-authored-by: Renovate Bot Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 44 +++++++++++++++++++++++++++----------------- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index b756d9cb05f1b..98c371b8d8de5 100644 --- a/package.json +++ b/package.json @@ -233,7 +233,7 @@ "deep-freeze-strict": "^1.1.1", "deepmerge": "^4.2.2", "del": "^5.1.0", - "elastic-apm-node": "^3.29.0", + "elastic-apm-node": "^3.30.0", "execa": "^4.0.2", "exit-hook": "^2.2.0", "expiry-js": "0.1.7", diff --git a/yarn.lock b/yarn.lock index c707e0217ac36..d45eacf02df93 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7519,6 +7519,15 @@ agentkeepalive@^4.1.3: depd "^1.1.2" humanize-ms "^1.2.1" +agentkeepalive@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-4.2.1.tgz#a7975cbb9f83b367f06c90cc51ff28fe7d499717" + integrity sha512-Zn4cw2NEqd+9fiSVWMscnjyQ1a8Yfoc5oBajLeo5w+YBHgDUcEBY2hS4YpTz6iN5f/2zQiktcuM6tS8x1p9dpA== + dependencies: + debug "^4.1.0" + depd "^1.1.2" + humanize-ms "^1.2.1" + aggregate-error@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.0.1.tgz#db2fe7246e536f40d9b5442a39e117d7dd6a24e0" @@ -12744,11 +12753,12 @@ ejs@^3.1.6: dependencies: jake "^10.6.1" -elastic-apm-http-client@^10.4.0: - version "10.4.0" - resolved "https://registry.yarnpkg.com/elastic-apm-http-client/-/elastic-apm-http-client-10.4.0.tgz#e466aaf04ef8beda22d9922fbf3543158f1ffb8a" - integrity sha512-mH3Cn61ICbj/rxhILAQ0NHQNmzD+kLBGyXklC0wSqIaiJs56yhCNgtXQZ3ajYxzrYW9Ox1QI4NVg3Gezg7nTCg== +elastic-apm-http-client@11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/elastic-apm-http-client/-/elastic-apm-http-client-11.0.0.tgz#a38a85eae078e3f7f09edda86db6d6419a8ecfea" + integrity sha512-HB6+O0C4GGj9k5bd6yL3QK5prGKh+Rf8Tc5iW0T7FCdh2HliICfGmB6wmdQ2XkClblLtISh7tKYgVr9YgdXl3Q== dependencies: + agentkeepalive "^4.2.1" breadth-filter "^2.0.0" container-info "^1.0.1" end-of-stream "^1.4.4" @@ -12759,10 +12769,10 @@ elastic-apm-http-client@^10.4.0: semver "^6.3.0" stream-chopper "^3.0.1" -elastic-apm-node@^3.29.0: - version "3.29.0" - resolved "https://registry.yarnpkg.com/elastic-apm-node/-/elastic-apm-node-3.29.0.tgz#3e828405adb9e91ed66bb30780268cc30703f46a" - integrity sha512-tPZKoeIJus8mCYXbIcr+jtsU56EQmmUJ+FvcCopp1zB9mCBLrsqdnJ1oXApLmwMAdWn3IpClO1DZi4gmuRNrEA== +elastic-apm-node@^3.30.0: + version "3.30.0" + resolved "https://registry.yarnpkg.com/elastic-apm-node/-/elastic-apm-node-3.30.0.tgz#4df7110324535089f66f7a3a96bf37d2fe47f38b" + integrity sha512-KumRBDGIE+MGgJfteAi9BDqeGxpAYpbovWjNdB5x8T3/zpnQRJkDMSblliEsMwD6uKf2+Nkxzmyq9UZdh5MbGQ== dependencies: "@elastic/ecs-pino-format" "^1.2.0" after-all-results "^2.0.0" @@ -12771,7 +12781,7 @@ elastic-apm-node@^3.29.0: basic-auth "^2.0.1" cookie "^0.4.0" core-util-is "^1.0.2" - elastic-apm-http-client "^10.4.0" + elastic-apm-http-client "11.0.0" end-of-stream "^1.4.4" error-callsites "^2.0.4" error-stack-parser "^2.0.6" @@ -12779,7 +12789,6 @@ elastic-apm-node@^3.29.0: fast-safe-stringify "^2.0.7" http-headers "^3.0.2" is-native "^1.0.1" - load-source-map "^2.0.0" lru-cache "^6.0.0" measured-reporting "^1.51.1" monitor-event-loop-delay "^1.0.0" @@ -12793,6 +12802,7 @@ elastic-apm-node@^3.29.0: semver "^6.3.0" set-cookie-serde "^1.0.0" shallow-clone-shim "^2.0.0" + source-map "^0.8.0-beta.0" sql-summary "^1.0.1" traceparent "^1.0.0" traverse "^0.6.6" @@ -18954,13 +18964,6 @@ load-json-file@^6.2.0: strip-bom "^4.0.0" type-fest "^0.6.0" -load-source-map@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/load-source-map/-/load-source-map-2.0.0.tgz#48f1c7002d7d9e20dd119da6e566104ec46a5683" - integrity sha512-QNZzJ2wMrTmCdeobMuMNEXHN1QGk8HG6louEkzD/zwQ7EU2RarrzlhQ4GnUYEFzLhK+Jq7IGyF/qy+XYBSO7AQ== - dependencies: - source-map "^0.7.3" - loader-runner@^2.4.0: version "2.4.0" resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.4.0.tgz#ed47066bfe534d7e84c4c7b9998c2a75607d9357" @@ -26178,6 +26181,13 @@ source-map@^0.7.2, source-map@^0.7.3, source-map@~0.7.2: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ== +source-map@^0.8.0-beta.0: + version "0.8.0-beta.0" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.8.0-beta.0.tgz#d4c1bb42c3f7ee925f005927ba10709e0d1d1f11" + integrity sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA== + dependencies: + whatwg-url "^7.0.0" + sourcemap-codec@^1.4.1: version "1.4.6" resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.6.tgz#e30a74f0402bad09807640d39e971090a08ce1e9" From 8e3815b2b7b8bc3b29df81a0d471de9685a16ccc Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Mon, 14 Mar 2022 14:44:28 -0500 Subject: [PATCH 34/44] skip tests failing es promotion. #127654 --- .../migrations/actions/integration_tests/actions.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/core/server/saved_objects/migrations/actions/integration_tests/actions.test.ts b/src/core/server/saved_objects/migrations/actions/integration_tests/actions.test.ts index bac8f491534f0..3f7a2e9c822aa 100644 --- a/src/core/server/saved_objects/migrations/actions/integration_tests/actions.test.ts +++ b/src/core/server/saved_objects/migrations/actions/integration_tests/actions.test.ts @@ -895,7 +895,9 @@ describe('migration actions', () => { } `); }); - it('resolves left wait_for_task_completion_timeout when the task does not finish within the timeout', async () => { + + // Failing ES Promotion: https://github.com/elastic/kibana/issues/127654 + it.skip('resolves left wait_for_task_completion_timeout when the task does not finish within the timeout', async () => { await waitForIndexStatusYellow({ client, index: '.kibana_1', @@ -1259,7 +1261,9 @@ describe('migration actions', () => { await expect(task()).rejects.toThrow('index_not_found_exception'); }); - it('resolves left wait_for_task_completion_timeout when the task does not complete within the timeout', async () => { + + // Failing ES Promotion: https://github.com/elastic/kibana/issues/127654 + it.skip('resolves left wait_for_task_completion_timeout when the task does not complete within the timeout', async () => { const res = (await pickupUpdatedMappings( client, '.kibana_1' From faaffdc96e1913eb741b2f61482d630941536343 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Mon, 14 Mar 2022 16:15:26 -0400 Subject: [PATCH 35/44] [Fleet] Improve Fleet output UI (#127644) --- .../components/outputs_table/index.tsx | 11 ++++++++-- .../settings/hooks/use_confirm_modal.tsx | 20 ++++++++++--------- .../settings/hooks/use_delete_output.tsx | 6 ++++++ 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/outputs_table/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/outputs_table/index.tsx index 0ccb5ed4ab7b2..331c7dc192ecb 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/outputs_table/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/outputs_table/index.tsx @@ -7,7 +7,7 @@ import React, { useMemo } from 'react'; import styled from 'styled-components'; -import { EuiBasicTable, EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui'; +import { EuiBasicTable, EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiIconTip } from '@elastic/eui'; import type { EuiBasicTableColumn } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -59,7 +59,14 @@ export const OutputsTable: React.FunctionComponent = ({ {output.is_preconfigured && ( - + )} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/hooks/use_confirm_modal.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/hooks/use_confirm_modal.tsx index 542c9a1e1d154..aa284ed64f2c9 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/hooks/use_confirm_modal.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/hooks/use_confirm_modal.tsx @@ -13,13 +13,14 @@ import React, { useCallback, useContext, useState } from 'react'; interface ModalState { title?: React.ReactNode; description?: React.ReactNode; - buttonColor?: EuiConfirmModalProps['buttonColor']; + options?: ModalOptions; onConfirm: () => void; onCancel: () => void; } interface ModalOptions { buttonColor?: EuiConfirmModalProps['buttonColor']; + confirmButtonText?: string; } const ModalContext = React.createContext resolve(true), onCancel: () => resolve(false), - buttonColor: options?.buttonColor, + options, }); }); }, @@ -67,7 +68,7 @@ export const ConfirmModalProvider: React.FunctionComponent = ({ children }) => { onConfirm: () => {}, }); - const showModal = useCallback(({ title, description, onConfirm, onCancel }) => { + const showModal = useCallback(({ title, description, onConfirm, onCancel, options }) => { setIsVisible(true); setModal({ title, @@ -80,6 +81,7 @@ export const ConfirmModalProvider: React.FunctionComponent = ({ children }) => { setIsVisible(false); onCancel(); }, + options, }); }, []); @@ -89,18 +91,18 @@ export const ConfirmModalProvider: React.FunctionComponent = ({ children }) => { {modal.description} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/hooks/use_delete_output.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/hooks/use_delete_output.tsx index bab9d0936a275..ee03ed040b416 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/hooks/use_delete_output.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/hooks/use_delete_output.tsx @@ -81,6 +81,12 @@ export function useDeleteOutput(onSuccess: () => void) { />, { buttonColor: 'danger', + confirmButtonText: i18n.translate( + 'xpack.fleet.settings.deleteOutputs.confirmButtonLabel', + { + defaultMessage: 'Delete and deploy', + } + ), } ); From 54106269738e94e50a5cd1ecebb192401b514aa8 Mon Sep 17 00:00:00 2001 From: Chris Cowan Date: Mon, 14 Mar 2022 14:45:56 -0600 Subject: [PATCH 36/44] [Infrastructure UI] Use bucket selector to evaluate Inventory Threshold Rule with Elasticsearch (#125034) * [Metrics UI] Use bucket selector to evaluate Inventory Threshold Rule with Elasticsearch * updating tests * Remove unused dependencies * Adding fallback for empty triggers * Lowering the composite size to a safe value * Reverting composite size change for Metric Threshold * Adding some checks to ensure the bucket_script is available * fixing typo * Fix reason bug for bits. The formatter assumes it's listed in bytes. * Fixing bug where results might not exist in each result set * Converting boolean[] to boolean * removing unused deps Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../evaluate_condition.ts | 63 ++------- .../inventory_metric_threshold_executor.ts | 17 ++- .../lib/convert_metric_value.ts | 21 +++ .../lib/create_bucket_selector.ts | 69 ++++++++++ .../lib/create_condition_script.ts | 24 ++++ .../lib/create_request.ts | 6 +- .../lib/get_data.ts | 16 ++- x-pack/plugins/infra/server/plugin.ts | 2 +- .../metrics_ui/inventory_threshold_alert.ts | 122 +++++++----------- 9 files changed, 200 insertions(+), 140 deletions(-) create mode 100644 x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/convert_metric_value.ts create mode 100644 x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/create_bucket_selector.ts create mode 100644 x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/create_condition_script.ts diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts index 6425aa1018825..d05560de7f4c3 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts @@ -8,19 +8,19 @@ import { ElasticsearchClient } from 'kibana/server'; import { mapValues } from 'lodash'; import moment from 'moment'; -import { Comparator, InventoryMetricConditions } from '../../../../common/alerting/metrics'; +import { InventoryMetricConditions } from '../../../../common/alerting/metrics'; import { InfraTimerangeInput } from '../../../../common/http_api'; -import { InventoryItemType, SnapshotMetricType } from '../../../../common/inventory_models/types'; +import { InventoryItemType } from '../../../../common/inventory_models/types'; import { LogQueryFields } from '../../../services/log_queries/get_log_query_fields'; import { InfraSource } from '../../sources'; import { calcualteFromBasedOnMetric } from './lib/calculate_from_based_on_metric'; import { getData } from './lib/get_data'; type ConditionResult = InventoryMetricConditions & { - shouldFire: boolean[]; - shouldWarn: boolean[]; + shouldFire: boolean; + shouldWarn: boolean; currentValue: number; - isNoData: boolean[]; + isNoData: boolean; isError: boolean; }; @@ -45,8 +45,7 @@ export const evaluateCondition = async ({ lookbackSize?: number; startTime?: number; }): Promise> => { - const { comparator, warningComparator, metric, customMetric } = condition; - let { threshold, warningThreshold } = condition; + const { metric, customMetric } = condition; const to = startTime ? moment(startTime) : moment(); @@ -69,61 +68,21 @@ export const evaluateCondition = async ({ source, logQueryFields, compositeSize, + condition, filterQuery, customMetric ); - threshold = threshold.map((n) => convertMetricValue(metric, n)); - warningThreshold = warningThreshold?.map((n) => convertMetricValue(metric, n)); - - const valueEvaluator = (value?: DataValue, t?: number[], c?: Comparator) => { - if (value === undefined || value === null || !t || !c) return [false]; - const comparisonFunction = comparatorMap[c]; - return [comparisonFunction(value as number, t)]; - }; - const result = mapValues(currentValues, (value) => { return { ...condition, - shouldFire: valueEvaluator(value, threshold, comparator), - shouldWarn: valueEvaluator(value, warningThreshold, warningComparator), - isNoData: [value === null], + shouldFire: value.trigger, + shouldWarn: value.warn, + isNoData: value === null, isError: value === undefined, - currentValue: getCurrentValue(value), + currentValue: value.value, }; }) as unknown; // Typescript doesn't seem to know what `throw` is doing return result as Record; }; - -const getCurrentValue: (value: number | null) => number = (value) => { - if (value !== null) return Number(value); - return NaN; -}; - -type DataValue = number | null; - -const comparatorMap = { - [Comparator.BETWEEN]: (value: number, [a, b]: number[]) => - value >= Math.min(a, b) && value <= Math.max(a, b), - // `threshold` is always an array of numbers in case the BETWEEN comparator is - // used; all other compartors will just destructure the first value in the array - [Comparator.GT]: (a: number, [b]: number[]) => a > b, - [Comparator.LT]: (a: number, [b]: number[]) => a < b, - [Comparator.OUTSIDE_RANGE]: (value: number, [a, b]: number[]) => value < a || value > b, - [Comparator.GT_OR_EQ]: (a: number, [b]: number[]) => a >= b, - [Comparator.LT_OR_EQ]: (a: number, [b]: number[]) => a <= b, -}; - -// Some metrics in the UI are in a different unit that what we store in ES. -const convertMetricValue = (metric: SnapshotMetricType, value: number) => { - if (converters[metric]) { - return converters[metric](value); - } else { - return value; - } -}; -const converters: Record number> = { - cpu: (n) => Number(n) / 100, - memory: (n) => Number(n) / 100, -}; diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts index df79091612254..c2d808d7d94b7 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; import { ALERT_REASON, ALERT_RULE_PARAMETERS } from '@kbn/rule-data-utils'; -import { first, get, last } from 'lodash'; +import { first, get } from 'lodash'; import moment from 'moment'; import { ActionGroup, @@ -123,16 +123,12 @@ export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) = const inventoryItems = Object.keys(first(results)!); for (const group of inventoryItems) { // AND logic; all criteria must be across the threshold - const shouldAlertFire = results.every((result) => { - // Grab the result of the most recent bucket - return last(result[group].shouldFire); - }); - const shouldAlertWarn = results.every((result) => last(result[group].shouldWarn)); - + const shouldAlertFire = results.every((result) => result[group]?.shouldFire); + const shouldAlertWarn = results.every((result) => result[group]?.shouldWarn); // AND logic; because we need to evaluate all criteria, if one of them reports no data then the // whole alert is in a No Data/Error state - const isNoData = results.some((result) => last(result[group].isNoData)); - const isError = results.some((result) => result[group].isError); + const isNoData = results.some((result) => result[group]?.isNoData); + const isError = results.some((result) => result[group]?.isError); const nextState = isError ? AlertStates.ERROR @@ -222,6 +218,9 @@ const formatThreshold = (metric: SnapshotMetricType, value: number) => { if (metricFormatter.formatter === 'percent') { v = Number(v) / 100; } + if (metricFormatter.formatter === 'bits') { + v = Number(v) / 8; + } return formatter(v); }) : value; diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/convert_metric_value.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/convert_metric_value.ts new file mode 100644 index 0000000000000..c502d9ca7d7b2 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/convert_metric_value.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SnapshotMetricType } from '../../../../../common/inventory_models/types'; + +// Some metrics in the UI are in a different unit that what we store in ES. +export const convertMetricValue = (metric: SnapshotMetricType, value: number) => { + if (converters[metric]) { + return converters[metric](value); + } else { + return value; + } +}; +const converters: Record number> = { + cpu: (n) => Number(n) / 100, + memory: (n) => Number(n) / 100, +}; diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/create_bucket_selector.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/create_bucket_selector.ts new file mode 100644 index 0000000000000..1538b89f5e205 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/create_bucket_selector.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Comparator, InventoryMetricConditions } from '../../../../../common/alerting/metrics'; +import { SnapshotCustomMetricInput } from '../../../../../common/http_api'; +import { SnapshotMetricType } from '../../../../../common/inventory_models/types'; +import { createConditionScript } from './create_condition_script'; + +const EMPTY_SHOULD_WARN = { + bucket_script: { + buckets_path: {}, + script: '0', + }, +}; + +export const createBucketSelector = ( + metric: SnapshotMetricType, + condition: InventoryMetricConditions, + customMetric?: SnapshotCustomMetricInput +) => { + const metricId = customMetric && customMetric.field ? customMetric.id : metric; + const hasWarn = condition.warningThreshold != null && condition.warningComparator != null; + const hasTrigger = condition.threshold != null && condition.comparator != null; + + const shouldWarn = hasWarn + ? { + bucket_script: { + buckets_path: { + value: metricId, + }, + script: createConditionScript( + condition.warningThreshold as number[], + condition.warningComparator as Comparator, + metric + ), + }, + } + : EMPTY_SHOULD_WARN; + + const shouldTrigger = hasTrigger + ? { + bucket_script: { + buckets_path: { + value: metricId, + }, + script: createConditionScript(condition.threshold, condition.comparator, metric), + }, + } + : EMPTY_SHOULD_WARN; + + return { + selectedBucket: { + bucket_selector: { + buckets_path: { + shouldWarn: 'shouldWarn', + shouldTrigger: 'shouldTrigger', + }, + script: + '(params.shouldWarn != null && params.shouldWarn > 0) || (params.shouldTrigger != null && params.shouldTrigger > 0)', + }, + }, + shouldWarn, + shouldTrigger, + }; +}; diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/create_condition_script.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/create_condition_script.ts new file mode 100644 index 0000000000000..782c65d8a0beb --- /dev/null +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/create_condition_script.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { Comparator } from '../../../../../common/alerting/metrics'; +import { SnapshotMetricType } from '../../../../../common/inventory_models/types'; +import { convertMetricValue } from './convert_metric_value'; + +export const createConditionScript = ( + conditionThresholds: number[], + comparator: Comparator, + metric: SnapshotMetricType +) => { + const threshold = conditionThresholds.map((n) => convertMetricValue(metric, n)); + if (comparator === Comparator.BETWEEN && threshold.length === 2) { + return `params.value > ${threshold[0]} && params.value < ${threshold[1]} ? 1 : 0`; + } + if (comparator === Comparator.OUTSIDE_RANGE && threshold.length === 2) { + return `params.value < ${threshold[0]} && params.value > ${threshold[1]} ? 1 : 0`; + } + return `params.value ${comparator} ${threshold[0]} ? 1 : 0`; +}; diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/create_request.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/create_request.ts index 2472a01f40095..91f298f2bf082 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/create_request.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/create_request.ts @@ -13,6 +13,8 @@ import { } from '../../../../../common/inventory_models/types'; import { parseFilterQuery } from '../../../../utils/serialized_query'; import { createMetricAggregations } from './create_metric_aggregations'; +import { InventoryMetricConditions } from '../../../../../common/alerting/metrics'; +import { createBucketSelector } from './create_bucket_selector'; export const createRequest = ( index: string, @@ -21,6 +23,7 @@ export const createRequest = ( timerange: InfraTimerangeInput, compositeSize: number, afterKey: { node: string } | undefined, + condition: InventoryMetricConditions, filterQuery?: string, customMetric?: SnapshotCustomMetricInput ) => { @@ -50,6 +53,7 @@ export const createRequest = ( composite.after = afterKey; } const metricAggregations = createMetricAggregations(timerange, nodeType, metric, customMetric); + const bucketSelector = createBucketSelector(metric, condition, customMetric); const request: ESSearchRequest = { allow_no_indices: true, @@ -61,7 +65,7 @@ export const createRequest = ( aggs: { nodes: { composite, - aggs: metricAggregations, + aggs: { ...metricAggregations, ...bucketSelector }, }, }, }, diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/get_data.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/get_data.ts index 9f8bd5674dcef..9fcdecc881f4d 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/get_data.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/lib/get_data.ts @@ -6,6 +6,7 @@ */ import { ElasticsearchClient } from 'kibana/server'; +import { InventoryMetricConditions } from '../../../../../common/alerting/metrics'; import { InfraTimerangeInput, SnapshotCustomMetricInput } from '../../../../../common/http_api'; import { InventoryItemType, @@ -18,11 +19,13 @@ import { createRequest } from './create_request'; interface BucketKey { node: string; } -type Response = Record; +type Response = Record; type Metric = Record; interface Bucket { key: BucketKey; doc_count: number; + shouldWarn: { value: number }; + shouldTrigger: { value: number }; } type NodeBucket = Bucket & Metric; interface ResponseAggregations { @@ -40,6 +43,7 @@ export const getData = async ( source: InfraSource, logQueryFields: LogQueryFields | undefined, compositeSize: number, + condition: InventoryMetricConditions, filterQuery?: string, customMetric?: SnapshotCustomMetricInput, afterKey?: BucketKey, @@ -50,9 +54,13 @@ export const getData = async ( const nextAfterKey = nodes.after_key; for (const bucket of nodes.buckets) { const metricId = customMetric && customMetric.field ? customMetric.id : metric; - previous[bucket.key.node] = bucket?.[metricId]?.value ?? null; + previous[bucket.key.node] = { + value: bucket?.[metricId]?.value ?? null, + warn: bucket?.shouldWarn.value > 0 ?? false, + trigger: bucket?.shouldTrigger.value > 0 ?? false, + }; } - if (nextAfterKey && nodes.buckets.length === compositeSize) { + if (nextAfterKey) { return getData( esClient, nodeType, @@ -61,6 +69,7 @@ export const getData = async ( source, logQueryFields, compositeSize, + condition, filterQuery, customMetric, nextAfterKey, @@ -81,6 +90,7 @@ export const getData = async ( timerange, compositeSize, afterKey, + condition, filterQuery, customMetric ); diff --git a/x-pack/plugins/infra/server/plugin.ts b/x-pack/plugins/infra/server/plugin.ts index 10c2cd3a9b32c..7e44d7af2d61f 100644 --- a/x-pack/plugins/infra/server/plugin.ts +++ b/x-pack/plugins/infra/server/plugin.ts @@ -46,7 +46,7 @@ export const config: PluginConfigDescriptor = { schema: schema.object({ alerting: schema.object({ inventory_threshold: schema.object({ - group_by_page_size: schema.number({ defaultValue: 10_000 }), + group_by_page_size: schema.number({ defaultValue: 5_000 }), }), metric_threshold: schema.object({ group_by_page_size: schema.number({ defaultValue: 10_000 }), diff --git a/x-pack/test/api_integration/apis/metrics_ui/inventory_threshold_alert.ts b/x-pack/test/api_integration/apis/metrics_ui/inventory_threshold_alert.ts index 91a446a4e5958..ebeae3c249111 100644 --- a/x-pack/test/api_integration/apis/metrics_ui/inventory_threshold_alert.ts +++ b/x-pack/test/api_integration/apis/metrics_ui/inventory_threshold_alert.ts @@ -96,25 +96,12 @@ export default function ({ getService }: FtrProviderContext) { sourceId: 'default', threshold: [100], comparator: '>', - shouldFire: [true], - shouldWarn: [false], - isNoData: [false], + shouldFire: true, + shouldWarn: false, + isNoData: false, isError: false, currentValue: 1.109, }, - 'host-1': { - metric: 'cpu', - timeSize: 1, - timeUnit: 'm', - sourceId: 'default', - threshold: [100], - comparator: '>', - shouldFire: [false], - shouldWarn: [false], - isNoData: [false], - isError: false, - currentValue: 0.7703333333333333, - }, }); }); it('should work FOR LAST 5 minute', async () => { @@ -132,25 +119,12 @@ export default function ({ getService }: FtrProviderContext) { sourceId: 'default', threshold: [100], comparator: '>', - shouldFire: [true], - shouldWarn: [false], - isNoData: [false], + shouldFire: true, + shouldWarn: false, + isNoData: false, isError: false, currentValue: 1.0376666666666665, }, - 'host-1': { - metric: 'cpu', - timeSize: 5, - timeUnit: 'm', - sourceId: 'default', - threshold: [100], - comparator: '>', - shouldFire: [false], - shouldWarn: [false], - isNoData: [false], - isError: false, - currentValue: 0.9192, - }, }); }); }); @@ -176,9 +150,9 @@ export default function ({ getService }: FtrProviderContext) { sourceId: 'default', threshold: [1], comparator: '>', - shouldFire: [true], - shouldWarn: [false], - isNoData: [false], + shouldFire: true, + shouldWarn: false, + isNoData: false, isError: false, currentValue: 1666.6666666666667, }, @@ -189,9 +163,9 @@ export default function ({ getService }: FtrProviderContext) { sourceId: 'default', threshold: [1], comparator: '>', - shouldFire: [true], - shouldWarn: [false], - isNoData: [false], + shouldFire: true, + shouldWarn: false, + isNoData: false, isError: false, currentValue: 2000, }, @@ -217,9 +191,9 @@ export default function ({ getService }: FtrProviderContext) { sourceId: 'default', threshold: [1], comparator: '>', - shouldFire: [true], - shouldWarn: [false], - isNoData: [false], + shouldFire: true, + shouldWarn: false, + isNoData: false, isError: false, currentValue: 2266.6666666666665, }, @@ -230,9 +204,9 @@ export default function ({ getService }: FtrProviderContext) { sourceId: 'default', threshold: [1], comparator: '>', - shouldFire: [true], - shouldWarn: [false], - isNoData: [false], + shouldFire: true, + shouldWarn: false, + isNoData: false, isError: false, currentValue: 2266.6666666666665, }, @@ -250,7 +224,7 @@ export default function ({ getService }: FtrProviderContext) { condition: { ...baseCondition, metric: 'logRate', - threshold: [1], + threshold: [0.1], }, esClient, }); @@ -260,11 +234,11 @@ export default function ({ getService }: FtrProviderContext) { timeSize: 1, timeUnit: 'm', sourceId: 'default', - threshold: [1], + threshold: [0.1], comparator: '>', - shouldFire: [false], - shouldWarn: [false], - isNoData: [false], + shouldFire: true, + shouldWarn: false, + isNoData: false, isError: false, currentValue: 0.3, }, @@ -273,11 +247,11 @@ export default function ({ getService }: FtrProviderContext) { timeSize: 1, timeUnit: 'm', sourceId: 'default', - threshold: [1], + threshold: [0.1], comparator: '>', - shouldFire: [false], - shouldWarn: [false], - isNoData: [false], + shouldFire: true, + shouldWarn: false, + isNoData: false, isError: false, currentValue: 0.3, }, @@ -290,7 +264,7 @@ export default function ({ getService }: FtrProviderContext) { condition: { ...baseCondition, metric: 'logRate' as SnapshotMetricType, - threshold: [1], + threshold: [0.1], timeSize: 5, }, esClient, @@ -302,11 +276,11 @@ export default function ({ getService }: FtrProviderContext) { timeSize: 5, timeUnit: 'm', sourceId: 'default', - threshold: [1], + threshold: [0.1], comparator: '>', - shouldFire: [false], - shouldWarn: [false], - isNoData: [false], + shouldFire: true, + shouldWarn: false, + isNoData: false, isError: false, currentValue: 0.3, }, @@ -315,11 +289,11 @@ export default function ({ getService }: FtrProviderContext) { timeSize: 5, timeUnit: 'm', sourceId: 'default', - threshold: [1], + threshold: [0.1], comparator: '>', - shouldFire: [false], - shouldWarn: [false], - isNoData: [false], + shouldFire: true, + shouldWarn: false, + isNoData: false, isError: false, currentValue: 0.3, }, @@ -350,9 +324,9 @@ export default function ({ getService }: FtrProviderContext) { sourceId: 'default', threshold: [1], comparator: '>', - shouldFire: [true], - shouldWarn: [false], - isNoData: [false], + shouldFire: true, + shouldWarn: false, + isNoData: false, isError: false, currentValue: 43332.833333333336, }, @@ -363,9 +337,9 @@ export default function ({ getService }: FtrProviderContext) { sourceId: 'default', threshold: [1], comparator: '>', - shouldFire: [true], - shouldWarn: [false], - isNoData: [false], + shouldFire: true, + shouldWarn: false, + isNoData: false, isError: false, currentValue: 42783.833333333336, }, @@ -393,9 +367,9 @@ export default function ({ getService }: FtrProviderContext) { sourceId: 'default', threshold: [1], comparator: '>', - shouldFire: [true], - shouldWarn: [false], - isNoData: [false], + shouldFire: true, + shouldWarn: false, + isNoData: false, isError: false, currentValue: 50197.666666666664, }, @@ -406,9 +380,9 @@ export default function ({ getService }: FtrProviderContext) { sourceId: 'default', threshold: [1], comparator: '>', - shouldFire: [true], - shouldWarn: [false], - isNoData: [false], + shouldFire: true, + shouldWarn: false, + isNoData: false, isError: false, currentValue: 50622.066666666666, }, From f071726b9e7bd20ccb9fc0e1d36de59d8d92b4fd Mon Sep 17 00:00:00 2001 From: Mat Schaffer Date: Tue, 15 Mar 2022 06:05:21 +0900 Subject: [PATCH 37/44] [Stack Monitoring] Diagnostic query docs (#127572) --- .../monitoring/dev_docs/how_to/cloud_setup.md | 3 +- .../dev_docs/runbook/cpu_metrics.md | 21 ++++ .../dev_docs/runbook/diagnostic_queries.md | 96 +++++++++++++++++++ x-pack/plugins/monitoring/readme.md | 4 +- 4 files changed, 121 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/monitoring/dev_docs/how_to/cloud_setup.md b/x-pack/plugins/monitoring/dev_docs/how_to/cloud_setup.md index 9ae4e0bd5bfa3..dc4cd57b8a17b 100644 --- a/x-pack/plugins/monitoring/dev_docs/how_to/cloud_setup.md +++ b/x-pack/plugins/monitoring/dev_docs/how_to/cloud_setup.md @@ -38,6 +38,7 @@ elasticsearch.hosts: ${ELASTICSEARCH_ENDPOINT} elasticsearch.username: kibana_dev elasticsearch.password: ${ELASTIC_PASSWORD} elasticsearch.ignoreVersionMismatch: true +monitoring.ui.container.elasticsearch.enabled: true YAML ``` @@ -47,4 +48,4 @@ And start kibana with that config: yarn start --config config/kibana.cloud.yml ``` -Note that your local kibana will run data migrations and probably render the cloud created kibana unusable after your local kibana starts up. \ No newline at end of file +Note that your local kibana will run data migrations and probably render the cloud created kibana unusable after your local kibana starts up. diff --git a/x-pack/plugins/monitoring/dev_docs/runbook/cpu_metrics.md b/x-pack/plugins/monitoring/dev_docs/runbook/cpu_metrics.md index e69de29bb2d1d..7f8ee8de1c1e5 100644 --- a/x-pack/plugins/monitoring/dev_docs/runbook/cpu_metrics.md +++ b/x-pack/plugins/monitoring/dev_docs/runbook/cpu_metrics.md @@ -0,0 +1,21 @@ +CPU Utilization is a metric that seems like a simple question: How hard are my CPUs working? + +But the way CPU resources get managed can get interesting. Especially when [cgroups](https://www.kernel.org/doc/Documentation/cgroup-v1/cgroups.txt) and [CFS](https://www.kernel.org/doc/html/latest/scheduler/sched-design-CFS.html) are used. + +When trying to debug why a CPU metric doesn't look the way you expect it to in a Stack Monitoring graph, this information may be helpful. + +At the time of writing, the code path to get from a system level CPU metric to a utilization percentage looks like this: + +1. `node_cpu_metric` set to `node_cgroup_quota_as_cpu_utilization` when cgroup is enabled: [node_detail.js](/x-pack/plugins/monitoring/server/routes/api/v1/elasticsearch/node_detail.js#L61-65) +1. `node_cgroup_quota_as_cpu_utilization` defined as a `QuotaMetric` against `cpu.cfs_quota_micros`: [metrics.ts](/x-pack/plugins/monitoring/server/lib/metrics/elasticsearch/metrics.ts#L798-801) +1. `QuotaMetric` tries to produce a ratio of usage to quota, but returns null when quota isn't a positive number: [quota_metric.ts](/x-pack/plugins/monitoring/server/lib/metrics/classes/quota_metric.ts#L79-80) + +So it's important to be aware of the `monitoring.ui.container.elasticsearch.enabled` setting, which defaults to `true` on cloud.elastic.co. + +Some values of `cfs_quota_micros` could produce unexpected results. For example, if cgroups enabled but no quota is set, you'll get an "N/A" in the stack monitoring UI since elasticsearch can't directly see how much of the CPU it's using. + +You can confirm a point-in-time value of `cfs_quota_micros` for Elasticsearch by using the [node stats API](https://www.elastic.co/guide/en/elasticsearch/reference/master/cluster-nodes-stats.html). + +The CPU available on Elastic Cloud is based on the memory size of the instance, and smaller instance sizes get an additional boost via direct adjustments to the `cfs_quota_us` cgroup setting. + +For self-hosted deployments, the cgroup configuration will likely need to be checked via `docker inspect`. diff --git a/x-pack/plugins/monitoring/dev_docs/runbook/diagnostic_queries.md b/x-pack/plugins/monitoring/dev_docs/runbook/diagnostic_queries.md index e69de29bb2d1d..668f0f455f9d2 100644 --- a/x-pack/plugins/monitoring/dev_docs/runbook/diagnostic_queries.md +++ b/x-pack/plugins/monitoring/dev_docs/runbook/diagnostic_queries.md @@ -0,0 +1,96 @@ +If the stack monitoring UI isn't showing data for any cluster, it may first be useful to survey the available data using a query like this: + +```Kibana Dev Tools +POST .monitoring-*/_search +{ + "size": 0, + "query": { + "range": { + "timestamp": { + "gte": "now-1h", + "lte": "now" + } + } + }, + "aggs": { + "clusters": { + "terms": { + "field": "cluster_uuid", + "size": 1000 + }, + "aggs": { + "indices": { + "terms": { + "field": "_index", + "size": 1000 + }, + "aggs": { + "documentTypes": { + "terms": { + "field": "type", + "size": 1000 + } + } + } + } + } + } + } +} +``` + +This will show what document types are available in each index for each cluster UUID in the last hour. + +The main cluster list requires ES cluster stats to be available. You can use this query to check for the presence of cluster stats for a given `CLUSTER_UUID` (note the replacement required in the query). + +```Kibana Dev Tools +POST .monitoring-*,*:.monitoring-*,metrics-*,*:metrics-*/_search +{ + "size": 10, + "query": { + "bool": { + "filter": [ + { + "bool": { + "should": [ + { + "term": { + "type": "cluster_stats" + } + }, + { + "term": { + "metricset.name": "cluster_stats" + } + } + ] + } + }, + { + "term": { + "cluster_uuid": "" + } + }, + { + "range": { + "timestamp": { + "format": "epoch_millis", + "gte": "now-7d", + "lte": "now" + } + } + } + ] + } + }, + "collapse": { + "field": "cluster_uuid" + }, + "sort": { + "timestamp": { + "order": "desc", + "unmapped_type": "long" + } + } +} +``` diff --git a/x-pack/plugins/monitoring/readme.md b/x-pack/plugins/monitoring/readme.md index 16cbe5ea5023e..1aee5f55023c8 100644 --- a/x-pack/plugins/monitoring/readme.md +++ b/x-pack/plugins/monitoring/readme.md @@ -18,5 +18,5 @@ This plugin provides the Stack Monitoring kibana application. - [APM tracing](dev_docs/how_to/apm_tracing.md) (WIP) ## Troubleshooting -- [Diagnostic queries](dev_docs/runbook/diagnostic_queries.md) (WIP) -- [CPU metrics](dev_docs/runbook/cpu_metrics.md) (WIP) \ No newline at end of file +- [Diagnostic queries](dev_docs/runbook/diagnostic_queries.md) +- [CPU metrics](dev_docs/runbook/cpu_metrics.md) From 0bf745ed2e9a07b44b444e86026d444f04c10904 Mon Sep 17 00:00:00 2001 From: Spencer Date: Mon, 14 Mar 2022 16:46:47 -0600 Subject: [PATCH 38/44] [type-summarizer] build types with sourcemaps for @kbn/mapbox-gl (#127529) --- packages/kbn-type-summarizer/src/bazel_cli.ts | 70 ++++++++++--------- .../src/lib/bazel_cli_config.ts | 55 +++++++++------ .../lib/export_collector/collector_results.ts | 54 ++++++-------- .../lib/export_collector/exports_collector.ts | 31 ++++---- .../lib/export_collector/imported_symbol.ts | 68 ++++++++++++++++++ .../lib/export_collector/imported_symbols.ts | 21 ------ .../src/lib/export_collector/index.ts | 1 + .../src/lib/export_collector/result_value.ts | 3 +- .../src/lib/export_info.ts | 44 +++++++++++- .../kbn-type-summarizer/src/lib/printer.ts | 67 ++++++++++++++---- .../integration_tests/import_boundary.test.ts | 45 ++++++++++-- ...ummarizer_output.js => type_summarizer.js} | 4 +- 12 files changed, 317 insertions(+), 146 deletions(-) create mode 100644 packages/kbn-type-summarizer/src/lib/export_collector/imported_symbol.ts delete mode 100644 packages/kbn-type-summarizer/src/lib/export_collector/imported_symbols.ts rename scripts/{build_type_summarizer_output.js => type_summarizer.js} (76%) diff --git a/packages/kbn-type-summarizer/src/bazel_cli.ts b/packages/kbn-type-summarizer/src/bazel_cli.ts index af6b13ebfc09c..2a6b8eb245d4c 100644 --- a/packages/kbn-type-summarizer/src/bazel_cli.ts +++ b/packages/kbn-type-summarizer/src/bazel_cli.ts @@ -10,7 +10,7 @@ import Fsp from 'fs/promises'; import Path from 'path'; import { run } from './lib/run'; -import { parseBazelCliConfig } from './lib/bazel_cli_config'; +import { parseBazelCliConfigs } from './lib/bazel_cli_config'; import { summarizePackage } from './summarize_package'; import { runApiExtractor } from './run_api_extractor'; @@ -29,41 +29,43 @@ run( log.debug('cwd:', process.cwd()); log.debug('argv', process.argv); - const config = parseBazelCliConfig(argv); - await Fsp.mkdir(config.outputDir, { recursive: true }); + for (const config of parseBazelCliConfigs(argv)) { + await Fsp.rm(config.outputDir, { recursive: true }); + await Fsp.mkdir(config.outputDir, { recursive: true }); - // generate pkg json output - await Fsp.writeFile( - Path.resolve(config.outputDir, 'package.json'), - JSON.stringify( - { - name: `@types/${config.packageName.replaceAll('@', '').replaceAll('/', '__')}`, - description: 'Generated by @kbn/type-summarizer', - types: './index.d.ts', - private: true, - license: 'MIT', - version: '1.1.0', - }, - null, - 2 - ) - ); - - if (config.use === 'type-summarizer') { - await summarizePackage(log, { - dtsDir: Path.dirname(config.inputPath), - inputPaths: [config.inputPath], - outputDir: config.outputDir, - tsconfigPath: config.tsconfigPath, - repoRelativePackageDir: config.repoRelativePackageDir, - }); - log.success('type summary created for', config.repoRelativePackageDir); - } else { - await runApiExtractor( - config.tsconfigPath, - config.inputPath, - Path.resolve(config.outputDir, 'index.d.ts') + // generate pkg json output + await Fsp.writeFile( + Path.resolve(config.outputDir, 'package.json'), + JSON.stringify( + { + name: `@types/${config.packageName.replaceAll('@', '').replaceAll('/', '__')}`, + description: 'Generated by @kbn/type-summarizer', + types: './index.d.ts', + private: true, + license: 'MIT', + version: '1.1.0', + }, + null, + 2 + ) ); + + if (config.type === 'type-summarizer') { + await summarizePackage(log, { + dtsDir: Path.dirname(config.inputPath), + inputPaths: [config.inputPath], + outputDir: config.outputDir, + tsconfigPath: config.tsconfigPath, + repoRelativePackageDir: config.repoRelativePackageDir, + }); + log.success('type summary created for', config.repoRelativePackageDir); + } else { + await runApiExtractor( + config.tsconfigPath, + config.inputPath, + Path.resolve(config.outputDir, 'index.d.ts') + ); + } } }, { diff --git a/packages/kbn-type-summarizer/src/lib/bazel_cli_config.ts b/packages/kbn-type-summarizer/src/lib/bazel_cli_config.ts index da82aef8f7d79..28d7063df6d54 100644 --- a/packages/kbn-type-summarizer/src/lib/bazel_cli_config.ts +++ b/packages/kbn-type-summarizer/src/lib/bazel_cli_config.ts @@ -12,7 +12,17 @@ import { CliError } from './cli_error'; import { parseCliFlags } from './cli_flags'; import * as Path from './path'; -const TYPE_SUMMARIZER_PACKAGES = ['@kbn/type-summarizer', '@kbn/crypto', '@kbn/generate']; +const TYPE_SUMMARIZER_PACKAGES = [ + '@kbn/type-summarizer', + '@kbn/crypto', + '@kbn/generate', + '@kbn/mapbox-gl', +]; + +type TypeSummarizerType = 'api-extractor' | 'type-summarizer'; +function isTypeSummarizerType(v: string): v is TypeSummarizerType { + return v === 'api-extractor' || v === 'type-summarizer'; +} const isString = (i: any): i is string => typeof i === 'string' && i.length > 0; @@ -22,7 +32,7 @@ interface BazelCliConfig { tsconfigPath: string; inputPath: string; repoRelativePackageDir: string; - use: 'api-extractor' | 'type-summarizer'; + type: TypeSummarizerType; } function isKibanaRepo(dir: string) { @@ -55,11 +65,11 @@ function findRepoRoot() { } } -export function parseBazelCliFlags(argv: string[]): BazelCliConfig { +export function parseBazelCliFlags(argv: string[]): BazelCliConfig[] { const { rawFlags, unknownFlags } = parseCliFlags(argv, { string: ['use'], default: { - use: 'api-extractor', + use: 'api-extractor,type-summarizer', }, }); @@ -79,8 +89,11 @@ export function parseBazelCliFlags(argv: string[]): BazelCliConfig { throw new CliError(`extra positional arguments`, { showHelp: true }); } - const use = rawFlags.use; - if (use !== 'api-extractor' && use !== 'type-summarizer') { + const use = String(rawFlags.use || '') + .split(',') + .map((t) => t.trim()) + .filter(Boolean); + if (!use.every(isTypeSummarizerType)) { throw new CliError(`invalid --use flag, expected "api-extractor" or "type-summarizer"`); } @@ -90,14 +103,14 @@ export function parseBazelCliFlags(argv: string[]): BazelCliConfig { ).name; const repoRelativePackageDir = Path.relative(repoRoot, packageDir); - return { - use, + return use.map((type) => ({ + type, packageName, tsconfigPath: Path.join(repoRoot, repoRelativePackageDir, 'tsconfig.json'), inputPath: Path.join(repoRoot, 'node_modules', packageName, 'target_types/index.d.ts'), repoRelativePackageDir, - outputDir: Path.join(repoRoot, 'data/type-summarizer-output', use), - }; + outputDir: Path.join(repoRoot, 'data/type-summarizer-output', type), + })); } function parseJsonFromCli(json: string) { @@ -125,7 +138,7 @@ function parseJsonFromCli(json: string) { } } -export function parseBazelCliJson(json: string): BazelCliConfig { +export function parseBazelCliJson(json: string): BazelCliConfig[] { const config = parseJsonFromCli(json); if (typeof config !== 'object' || config === null) { throw new CliError('config JSON must be an object'); @@ -168,17 +181,19 @@ export function parseBazelCliJson(json: string): BazelCliConfig { throw new CliError(`buildFilePath [${buildFilePath}] must be a relative path`); } - return { - packageName, - outputDir: Path.resolve(outputDir), - tsconfigPath: Path.resolve(tsconfigPath), - inputPath: Path.resolve(inputPath), - repoRelativePackageDir: Path.dirname(buildFilePath), - use: TYPE_SUMMARIZER_PACKAGES.includes(packageName) ? 'type-summarizer' : 'api-extractor', - }; + return [ + { + packageName, + outputDir: Path.resolve(outputDir), + tsconfigPath: Path.resolve(tsconfigPath), + inputPath: Path.resolve(inputPath), + repoRelativePackageDir: Path.dirname(buildFilePath), + type: TYPE_SUMMARIZER_PACKAGES.includes(packageName) ? 'type-summarizer' : 'api-extractor', + }, + ]; } -export function parseBazelCliConfig(argv: string[]) { +export function parseBazelCliConfigs(argv: string[]): BazelCliConfig[] { if (typeof argv[0] === 'string' && argv[0].startsWith('{')) { return parseBazelCliJson(argv[0]); } diff --git a/packages/kbn-type-summarizer/src/lib/export_collector/collector_results.ts b/packages/kbn-type-summarizer/src/lib/export_collector/collector_results.ts index f8f4e131f8386..6b1f98a10f69a 100644 --- a/packages/kbn-type-summarizer/src/lib/export_collector/collector_results.ts +++ b/packages/kbn-type-summarizer/src/lib/export_collector/collector_results.ts @@ -7,58 +7,46 @@ */ import * as ts from 'typescript'; -import { ValueNode, ExportFromDeclaration } from '../ts_nodes'; +import { ValueNode, DecSymbol } from '../ts_nodes'; import { ResultValue } from './result_value'; -import { ImportedSymbols } from './imported_symbols'; +import { ImportedSymbol } from './imported_symbol'; import { Reference, ReferenceKey } from './reference'; import { SourceMapper } from '../source_mapper'; +import { ExportInfo } from '../export_info'; -export type CollectorResult = Reference | ImportedSymbols | ResultValue; +export type CollectorResult = + | Reference + | { type: 'imports'; imports: ImportedSymbol[] } + | ResultValue; export class CollectorResults { - imports: ImportedSymbols[] = []; - importsByPath = new Map(); + importedSymbols = new Set(); nodes: ResultValue[] = []; nodesByAst = new Map(); constructor(private readonly sourceMapper: SourceMapper) {} - addNode(exported: boolean, node: ValueNode) { + addNode(exportInfo: ExportInfo | undefined, node: ValueNode) { const existing = this.nodesByAst.get(node); if (existing) { - existing.exported = existing.exported || exported; + existing.exportInfo ||= exportInfo; return; } - const result = new ResultValue(exported, node); + const result = new ResultValue(exportInfo, node); this.nodesByAst.set(node, result); this.nodes.push(result); } - ensureExported(node: ValueNode) { - this.addNode(true, node); - } - - addImport( - exported: boolean, - node: ts.ImportDeclaration | ExportFromDeclaration, - symbol: ts.Symbol + addImportFromNodeModules( + exportInfo: ExportInfo | undefined, + symbol: DecSymbol, + moduleId: string ) { - const literal = node.moduleSpecifier; - if (!ts.isStringLiteral(literal)) { - throw new Error('import statement with non string literal module identifier'); - } - - const existing = this.importsByPath.get(literal.text); - if (existing) { - existing.symbols.push(symbol); - return; - } - - const result = new ImportedSymbols(exported, node, [symbol]); - this.importsByPath.set(literal.text, result); - this.imports.push(result); + const imp = ImportedSymbol.fromSymbol(symbol, moduleId); + imp.exportInfo ||= exportInfo; + this.importedSymbols.add(imp); } private getReferencesFromNodes() { @@ -88,6 +76,10 @@ export class CollectorResults { } getAll(): CollectorResult[] { - return [...this.getReferencesFromNodes(), ...this.imports, ...this.nodes]; + return [ + ...this.getReferencesFromNodes(), + { type: 'imports', imports: Array.from(this.importedSymbols) }, + ...this.nodes, + ]; } } diff --git a/packages/kbn-type-summarizer/src/lib/export_collector/exports_collector.ts b/packages/kbn-type-summarizer/src/lib/export_collector/exports_collector.ts index 3f46ceda70e1f..b6e7a8382bc0a 100644 --- a/packages/kbn-type-summarizer/src/lib/export_collector/exports_collector.ts +++ b/packages/kbn-type-summarizer/src/lib/export_collector/exports_collector.ts @@ -26,9 +26,9 @@ import { SourceMapper } from '../source_mapper'; import { isNodeModule } from '../is_node_module'; interface ResolvedNmImport { - type: 'import'; - node: ts.ImportDeclaration | ExportFromDeclaration; - targetPath: string; + type: 'import_from_node_modules'; + symbol: DecSymbol; + moduleId: string; } interface ResolvedSymbol { type: 'symbol'; @@ -93,15 +93,14 @@ export class ExportCollector { private getImportFromNodeModules(symbol: DecSymbol): undefined | ResolvedNmImport { const parentImport = this.getParentImport(symbol); - if (parentImport) { - // this symbol is within an import statement, is it an import from a node_module? + if (parentImport && ts.isStringLiteral(parentImport.moduleSpecifier)) { + // this symbol is within an import statement, is it an import from a node_module? first + // we resolve the import alias to it's source symbol, which we will show us the file that + // the import resolves to const aliased = this.resolveAliasSymbolStep(symbol); - - // symbol is in an import or export-from statement, make sure we want to traverse to that file const targetPaths = [ ...new Set(aliased.declarations.map((d) => this.sourceMapper.getSourceFile(d).fileName)), ]; - if (targetPaths.length > 1) { throw new Error('importing a symbol from multiple locations is unsupported at this time'); } @@ -109,9 +108,9 @@ export class ExportCollector { const targetPath = targetPaths[0]; if (isNodeModule(this.dtsDir, targetPath)) { return { - type: 'import', - node: parentImport, - targetPath, + type: 'import_from_node_modules', + symbol, + moduleId: parentImport.moduleSpecifier.text, }; } } @@ -148,8 +147,8 @@ export class ExportCollector { this.traversedSymbols.add(symbol); const source = this.resolveAliasSymbol(symbol); - if (source.type === 'import') { - results.addImport(!!exportInfo, source.node, symbol); + if (source.type === 'import_from_node_modules') { + results.addImportFromNodeModules(exportInfo, source.symbol, source.moduleId); return; } @@ -157,7 +156,7 @@ export class ExportCollector { if (seen) { for (const node of symbol.declarations) { assertExportedValueNode(node); - results.ensureExported(node); + results.addNode(exportInfo, node); } return; } @@ -185,7 +184,7 @@ export class ExportCollector { } if (isExportedValueNode(node)) { - results.addNode(!!exportInfo, node); + results.addNode(exportInfo, node); } } } @@ -201,7 +200,7 @@ export class ExportCollector { for (const symbol of this.typeChecker.getExportsOfModule(moduleSymbol)) { assertDecSymbol(symbol); - this.collectResults(results, new ExportInfo(`${symbol.escapedName}`), symbol); + this.collectResults(results, ExportInfo.fromSymbol(symbol), symbol); } return results; diff --git a/packages/kbn-type-summarizer/src/lib/export_collector/imported_symbol.ts b/packages/kbn-type-summarizer/src/lib/export_collector/imported_symbol.ts new file mode 100644 index 0000000000000..04ddd7730df0c --- /dev/null +++ b/packages/kbn-type-summarizer/src/lib/export_collector/imported_symbol.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import * as ts from 'typescript'; +import { DecSymbol, findKind } from '../ts_nodes'; +import { ExportInfo } from '../export_info'; + +const cache = new WeakMap(); + +export class ImportedSymbol { + static fromSymbol(symbol: DecSymbol, moduleId: string) { + const cached = cache.get(symbol); + if (cached) { + return cached; + } + + if (symbol.declarations.length !== 1) { + throw new Error('expected import symbol to have exactly one declaration'); + } + + const dec = symbol.declarations[0]; + if ( + !ts.isImportClause(dec) && + !ts.isExportSpecifier(dec) && + !ts.isImportSpecifier(dec) && + !ts.isNamespaceImport(dec) + ) { + const kind = findKind(dec); + throw new Error( + `expected import declaration to be an ImportClause, ImportSpecifier, or NamespaceImport, got ${kind}` + ); + } + + if (!dec.name) { + throw new Error(`expected ${findKind(dec)} to have a name defined`); + } + + const imp = ts.isImportClause(dec) + ? new ImportedSymbol(symbol, 'default', dec.name.text, dec.isTypeOnly, moduleId) + : ts.isNamespaceImport(dec) + ? new ImportedSymbol(symbol, '*', dec.name.text, dec.parent.isTypeOnly, moduleId) + : new ImportedSymbol( + symbol, + dec.name.text, + dec.propertyName?.text, + dec.isTypeOnly || dec.parent.parent.isTypeOnly, + moduleId + ); + + cache.set(symbol, imp); + return imp; + } + + public exportInfo: ExportInfo | undefined; + + constructor( + public readonly symbol: DecSymbol, + public readonly remoteName: string, + public readonly localName: string | undefined, + public readonly isTypeOnly: boolean, + public readonly moduleId: string + ) {} +} diff --git a/packages/kbn-type-summarizer/src/lib/export_collector/imported_symbols.ts b/packages/kbn-type-summarizer/src/lib/export_collector/imported_symbols.ts deleted file mode 100644 index 1c9fa800baaab..0000000000000 --- a/packages/kbn-type-summarizer/src/lib/export_collector/imported_symbols.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import * as ts from 'typescript'; -import { ExportFromDeclaration } from '../ts_nodes'; - -export class ImportedSymbols { - type = 'import' as const; - - constructor( - public readonly exported: boolean, - public readonly importNode: ts.ImportDeclaration | ExportFromDeclaration, - // TODO: I'm going to need to keep track of local names for these... unless that's embedded in the symbols - public readonly symbols: ts.Symbol[] - ) {} -} diff --git a/packages/kbn-type-summarizer/src/lib/export_collector/index.ts b/packages/kbn-type-summarizer/src/lib/export_collector/index.ts index 87f6630d2fcfa..a0447445ac20f 100644 --- a/packages/kbn-type-summarizer/src/lib/export_collector/index.ts +++ b/packages/kbn-type-summarizer/src/lib/export_collector/index.ts @@ -8,3 +8,4 @@ export * from './exports_collector'; export * from './collector_results'; +export * from './imported_symbol'; diff --git a/packages/kbn-type-summarizer/src/lib/export_collector/result_value.ts b/packages/kbn-type-summarizer/src/lib/export_collector/result_value.ts index 91249eea68e14..839f5a6f99f82 100644 --- a/packages/kbn-type-summarizer/src/lib/export_collector/result_value.ts +++ b/packages/kbn-type-summarizer/src/lib/export_collector/result_value.ts @@ -7,9 +7,10 @@ */ import { ValueNode } from '../ts_nodes'; +import { ExportInfo } from '../export_info'; export class ResultValue { type = 'value' as const; - constructor(public exported: boolean, public readonly node: ValueNode) {} + constructor(public exportInfo: ExportInfo | undefined, public readonly node: ValueNode) {} } diff --git a/packages/kbn-type-summarizer/src/lib/export_info.ts b/packages/kbn-type-summarizer/src/lib/export_info.ts index 3dee04121d322..121f9345abde0 100644 --- a/packages/kbn-type-summarizer/src/lib/export_info.ts +++ b/packages/kbn-type-summarizer/src/lib/export_info.ts @@ -6,6 +6,48 @@ * Side Public License, v 1. */ +import * as ts from 'typescript'; +import { DecSymbol } from './ts_nodes'; + +type ExportType = 'export' | 'export type'; + export class ExportInfo { - constructor(public readonly name: string) {} + static fromSymbol(symbol: DecSymbol) { + const exportInfo = symbol.declarations.reduce((acc: ExportInfo | undefined, dec) => { + const next = ExportInfo.fromNode(dec, symbol); + if (!acc) { + return next; + } + + if (next.name !== acc.name || next.type !== acc.type) { + throw new Error('unable to handle export symbol with different types of declarations'); + } + + return acc; + }, undefined); + + if (!exportInfo) { + throw new Error('unable to get candidates'); + } + + return exportInfo; + } + + static fromNode(node: ts.Node, symbol: DecSymbol) { + let type: ExportType = 'export'; + + if (ts.isExportSpecifier(node)) { + if (node.isTypeOnly || node.parent.parent.isTypeOnly) { + type = 'export type'; + } + } + + return new ExportInfo(node.getText(), type, symbol); + } + + constructor( + public readonly name: string, + public readonly type: ExportType, + public readonly symbol: DecSymbol + ) {} } diff --git a/packages/kbn-type-summarizer/src/lib/printer.ts b/packages/kbn-type-summarizer/src/lib/printer.ts index 8ecc4356ea4a2..15a7a03363c02 100644 --- a/packages/kbn-type-summarizer/src/lib/printer.ts +++ b/packages/kbn-type-summarizer/src/lib/printer.ts @@ -12,7 +12,8 @@ import { SourceNode, CodeWithSourceMap } from 'source-map'; import * as Path from './path'; import { findKind } from './ts_nodes'; import { SourceMapper } from './source_mapper'; -import { CollectorResult } from './export_collector'; +import { ExportInfo } from './export_info'; +import { CollectorResult, ImportedSymbol } from './export_collector'; type SourceNodes = Array; const COMMENT_TRIM = /^(\s+)(\/\*|\*|\/\/)/; @@ -44,14 +45,11 @@ export class Printer { return `/// \n`; } - if (r.type === 'import') { - // TODO: handle default imports, imports with alternate names, etc - return `import { ${r.symbols - .map((s) => s.escapedName) - .join(', ')} } from ${r.importNode.moduleSpecifier.getText()};\n`; + if (r.type === 'imports') { + return this.printImports(r.imports); } - return this.toSourceNodes(r.node, r.exported); + return this.toSourceNodes(r.node, r.exportInfo); }) ); @@ -70,6 +68,45 @@ export class Printer { return output; } + private printImports(imports: ImportedSymbol[]) { + const importLines: string[] = []; + const exportLines: string[] = []; + + for (const imp of imports) { + const from = ` from '${imp.moduleId}';`; + + if (imp.remoteName === 'default') { + importLines.push( + [imp.isTypeOnly ? `import type ` : 'import ', imp.localName, from].join('') + ); + } else if (imp.remoteName === '*') { + importLines.push( + [imp.isTypeOnly ? `import type * as ` : 'import * as ', imp.localName, from].join('') + ); + } else { + importLines.push( + [ + imp.isTypeOnly ? 'import type { ' : 'import { ', + imp.localName ? `${imp.remoteName} as ${imp.localName}` : imp.remoteName, + ' }', + from, + ].join('') + ); + } + + if (imp.exportInfo) { + exportLines.push(`${imp.exportInfo.type} { ${imp.localName || imp.remoteName} };`); + } + } + + const lines = [ + ...importLines, + ...(importLines.length && exportLines.length ? [''] : []), + ...exportLines, + ]; + return lines.length ? lines.join('\n') + '\n\n' : ''; + } + private getDeclarationKeyword(node: ts.Declaration) { if (node.kind === ts.SyntaxKind.FunctionDeclaration) { return 'function'; @@ -92,11 +129,11 @@ export class Printer { } } - private printModifiers(exported: boolean, node: ts.Declaration) { + private printModifiers(exportInfo: ExportInfo | undefined, node: ts.Declaration) { const flags = ts.getCombinedModifierFlags(node); const modifiers: string[] = []; - if (exported) { - modifiers.push('export'); + if (exportInfo) { + modifiers.push(exportInfo.type); } if (flags & ts.ModifierFlags.Default) { modifiers.push('default'); @@ -248,7 +285,7 @@ export class Printer { return `<${typeParams.map((p) => this.printNode(p)).join(', ')}>`; } - private toSourceNodes(node: ts.Node, exported = false): SourceNodes { + private toSourceNodes(node: ts.Node, exportInfo?: ExportInfo): SourceNodes { switch (node.kind) { case ts.SyntaxKind.LiteralType: case ts.SyntaxKind.StringLiteral: @@ -267,7 +304,7 @@ export class Printer { return [ this.getLeadingComments(node), - this.printModifiers(exported, node), + this.printModifiers(exportInfo, node), this.getMappedSourceNode(node.name), this.printTypeParameters(node), `(${node.parameters.map((p) => p.getFullText()).join(', ')})`, @@ -294,7 +331,7 @@ export class Printer { if (ts.isVariableDeclaration(node)) { return [ ...this.getLeadingComments(node), - this.printModifiers(exported, node), + this.printModifiers(exportInfo, node), this.getMappedSourceNode(node.name), ...(node.type ? [': ', this.printNode(node.type)] : []), ';\n', @@ -310,7 +347,7 @@ export class Printer { if (ts.isTypeAliasDeclaration(node)) { return [ ...this.getLeadingComments(node), - this.printModifiers(exported, node), + this.printModifiers(exportInfo, node), this.getMappedSourceNode(node.name), this.printTypeParameters(node), ' = ', @@ -321,7 +358,7 @@ export class Printer { if (ts.isClassDeclaration(node)) { return [ ...this.getLeadingComments(node), - this.printModifiers(exported, node), + this.printModifiers(exportInfo, node), node.name ? this.getMappedSourceNode(node.name) : [], this.printTypeParameters(node), ' {\n', diff --git a/packages/kbn-type-summarizer/src/tests/integration_tests/import_boundary.test.ts b/packages/kbn-type-summarizer/src/tests/integration_tests/import_boundary.test.ts index f1e3279bb57b0..60f7d9489b00b 100644 --- a/packages/kbn-type-summarizer/src/tests/integration_tests/import_boundary.test.ts +++ b/packages/kbn-type-summarizer/src/tests/integration_tests/import_boundary.test.ts @@ -25,7 +25,7 @@ const nodeModules = { `, }; -it('output type links to named import from node modules', async () => { +it('output links to named import from node modules', async () => { const output = await run( ` import { Foo } from 'foo' @@ -36,13 +36,14 @@ it('output type links to named import from node modules', async () => { expect(output.code).toMatchInlineSnapshot(` "import { Foo } from 'foo'; + export type ValidName = string | Foo //# sourceMappingURL=index.d.ts.map" `); expect(output.map).toMatchInlineSnapshot(` Object { "file": "index.d.ts", - "mappings": ";YACY,S", + "mappings": ";;YACY,S", "names": Array [], "sourceRoot": "../../../src", "sources": Array [ @@ -57,7 +58,40 @@ it('output type links to named import from node modules', async () => { `); }); -it('output type links to default import from node modules', async () => { +it('output links to type exports from node modules', async () => { + const output = await run( + ` + export type { Foo } from 'foo' + `, + { + otherFiles: nodeModules, + } + ); + + expect(output.code).toMatchInlineSnapshot(` + "import type { Foo } from 'foo'; + + export type { Foo }; + + //# sourceMappingURL=index.d.ts.map" + `); + expect(output.map).toMatchInlineSnapshot(` + Object { + "file": "index.d.ts", + "mappings": "", + "names": Array [], + "sourceRoot": "../../../src", + "sources": Array [], + "version": 3, + } + `); + expect(output.logs).toMatchInlineSnapshot(` + "debug loaded sourcemaps for [ 'packages/kbn-type-summarizer/__tmp__/dist_dts/index.d.ts' ] + " + `); +}); + +it('output links to default import from node modules', async () => { const output = await run( ` import Bar from 'bar' @@ -67,14 +101,15 @@ it('output type links to default import from node modules', async () => { ); expect(output.code).toMatchInlineSnapshot(` - "import { Bar } from 'bar'; + "import Bar from 'bar'; + export type ValidName = string | Bar //# sourceMappingURL=index.d.ts.map" `); expect(output.map).toMatchInlineSnapshot(` Object { "file": "index.d.ts", - "mappings": ";YACY,S", + "mappings": ";;YACY,S", "names": Array [], "sourceRoot": "../../../src", "sources": Array [ diff --git a/scripts/build_type_summarizer_output.js b/scripts/type_summarizer.js similarity index 76% rename from scripts/build_type_summarizer_output.js rename to scripts/type_summarizer.js index 619c8db5d2d05..09d3aa69138e9 100644 --- a/scripts/build_type_summarizer_output.js +++ b/scripts/type_summarizer.js @@ -6,6 +6,6 @@ * Side Public License, v 1. */ -require('../src/setup_node_env/ensure_node_preserve_symlinks'); +require('../src/setup_node_env'); require('source-map-support/register'); -require('@kbn/type-summarizer/target_node/bazel_cli'); +require('../packages/kbn-type-summarizer/src/bazel_cli'); From 595b6948a1492a04fff14c8d6b2f8b50308681c0 Mon Sep 17 00:00:00 2001 From: Davey Holler Date: Mon, 14 Mar 2022 17:04:17 -0700 Subject: [PATCH 39/44] Adding a beta badge to the ES index card. (#127674) * Adding a beta badge to the ES index card. * i18n --- .../document_creation_buttons.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.tsx index edc91804961e1..03f4c62a959cc 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_buttons.tsx @@ -69,7 +69,7 @@ export const DocumentCreationButtons: React.FC = ({

{i18n.translate('xpack.enterpriseSearch.appSearch.documentCreation.helperText', { defaultMessage: - 'There are four ways to send documents to your engine for indexing. You can paste or upload a JSON file, POST to the documents API endpoint, connect to an existing Elasticsearch index, or use the Elastic Web Crawler to automatically index documents from a URL.', + 'There are four ways to send documents to your engine for indexing. You can paste or upload a JSON file, POST to the documents API endpoint, connect to an existing Elasticsearch index (beta), or use the Elastic Web Crawler to automatically index documents from a URL.', })}

); @@ -187,6 +187,19 @@ export const DocumentCreationButtons: React.FC = ({ Date: Mon, 14 Mar 2022 18:56:35 -0600 Subject: [PATCH 40/44] Define codeowner for @kbn/type-summarizer --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b8563e3e44e8b..1ee6089df8c5f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -219,6 +219,7 @@ /packages/kbn-optimizer/ @elastic/kibana-operations /packages/kbn-pm/ @elastic/kibana-operations /packages/kbn-test/ @elastic/kibana-operations +/packages/kbn-type-summarizer/ @elastic/kibana-operations /packages/kbn-ui-shared-deps-npm/ @elastic/kibana-operations /packages/kbn-ui-shared-deps-src/ @elastic/kibana-operations /packages/kbn-bazel-packages/ @elastic/kibana-operations From 9871111cf687dca5a05e2be59316c2cb2233678c Mon Sep 17 00:00:00 2001 From: Spencer Date: Mon, 14 Mar 2022 20:34:28 -0600 Subject: [PATCH 41/44] [bazel] validate packages/BUILD.bazel in CI (#127241) --- .buildkite/scripts/steps/checks.sh | 1 + .../scripts/steps/checks/bazel_packages.sh | 8 ++ packages/BUILD.bazel | 3 +- packages/kbn-bazel-packages/BUILD.bazel | 3 + .../src/bazel_package.test.ts | 18 +++- .../kbn-bazel-packages/src/bazel_package.ts | 68 ++++++++++++++- ...generate_packages_build_bazel_file.test.ts | 85 ------------------- .../src/generate_packages_build_bazel_file.ts | 49 ----------- packages/kbn-bazel-packages/src/index.ts | 1 - packages/kbn-generate/src/cli.ts | 3 +- .../src/commands/package_command.ts | 21 +++-- .../packages_build_manifest_command.ts | 54 ++++++++++++ .../kbn-generate/src/lib/validate_file.ts | 34 ++++++++ .../templates/package/jest.config.js.ejs | 2 +- .../templates/packages_BUILD.bazel.ejs | 37 ++++++++ src/dev/precommit_hook/casing_check_config.js | 8 +- 16 files changed, 236 insertions(+), 159 deletions(-) create mode 100755 .buildkite/scripts/steps/checks/bazel_packages.sh delete mode 100644 packages/kbn-bazel-packages/src/generate_packages_build_bazel_file.test.ts delete mode 100644 packages/kbn-bazel-packages/src/generate_packages_build_bazel_file.ts create mode 100644 packages/kbn-generate/src/commands/packages_build_manifest_command.ts create mode 100644 packages/kbn-generate/src/lib/validate_file.ts create mode 100644 packages/kbn-generate/templates/packages_BUILD.bazel.ejs diff --git a/.buildkite/scripts/steps/checks.sh b/.buildkite/scripts/steps/checks.sh index 9e335fc3cdea3..bde9e82a4d1e6 100755 --- a/.buildkite/scripts/steps/checks.sh +++ b/.buildkite/scripts/steps/checks.sh @@ -6,6 +6,7 @@ export DISABLE_BOOTSTRAP_VALIDATION=false .buildkite/scripts/bootstrap.sh .buildkite/scripts/steps/checks/commit/commit.sh +.buildkite/scripts/steps/checks/bazel_packages.sh .buildkite/scripts/steps/checks/telemetry.sh .buildkite/scripts/steps/checks/ts_projects.sh .buildkite/scripts/steps/checks/jest_configs.sh diff --git a/.buildkite/scripts/steps/checks/bazel_packages.sh b/.buildkite/scripts/steps/checks/bazel_packages.sh new file mode 100755 index 0000000000000..85268bdcb0f06 --- /dev/null +++ b/.buildkite/scripts/steps/checks/bazel_packages.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source .buildkite/scripts/common/util.sh + +echo --- Check Bazel Packages Manifest +node scripts/generate packages_build_manifest --validate diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 66361060f1ee6..f01042cecfae9 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -1,6 +1,7 @@ ################ ################ -## This file is automatically generated, to create a new package use `node scripts/generate package --help` +## This file is automatically generated, to create a new package use `node scripts/generate package --help` or run +## `node scripts/generate packages_build_manifest` to regenerate it from the current state of the repo ################ ################ diff --git a/packages/kbn-bazel-packages/BUILD.bazel b/packages/kbn-bazel-packages/BUILD.bazel index 08ffbe24ba2aa..9adfe80060889 100644 --- a/packages/kbn-bazel-packages/BUILD.bazel +++ b/packages/kbn-bazel-packages/BUILD.bazel @@ -40,6 +40,7 @@ RUNTIME_DEPS = [ "//packages/kbn-utils", "//packages/kbn-std", "@npm//globby", + "@npm//normalize-path", ] # In this array place dependencies necessary to build the types, which will include the @@ -55,7 +56,9 @@ RUNTIME_DEPS = [ TYPES_DEPS = [ "//packages/kbn-utils:npm_module_types", "//packages/kbn-std:npm_module_types", + "@npm//@types/normalize-path", "@npm//globby", + "@npm//normalize-path", ] jsts_transpiler( diff --git a/packages/kbn-bazel-packages/src/bazel_package.test.ts b/packages/kbn-bazel-packages/src/bazel_package.test.ts index 884cf0e646ba0..70d540e43f06a 100644 --- a/packages/kbn-bazel-packages/src/bazel_package.test.ts +++ b/packages/kbn-bazel-packages/src/bazel_package.test.ts @@ -15,24 +15,34 @@ const OWN_BAZEL_BUILD_FILE = Fs.readFileSync(Path.resolve(__dirname, '../BUILD.b describe('hasBuildRule()', () => { it('returns true if there is a rule with the name "build"', () => { - const pkg = new BazelPackage('foo', {}, OWN_BAZEL_BUILD_FILE); + const pkg = new BazelPackage('foo', { name: 'foo' }, OWN_BAZEL_BUILD_FILE); expect(pkg.hasBuildRule()).toBe(true); }); it('returns false if there is no rule with name "build"', () => { - const pkg = new BazelPackage('foo', {}, ``); + const pkg = new BazelPackage('foo', { name: 'foo' }, ``); + expect(pkg.hasBuildRule()).toBe(false); + }); + + it('returns false if there is no BUILD.bazel file', () => { + const pkg = new BazelPackage('foo', { name: 'foo' }); expect(pkg.hasBuildRule()).toBe(false); }); }); describe('hasBuildTypesRule()', () => { it('returns true if there is a rule with the name "build_types"', () => { - const pkg = new BazelPackage('foo', {}, OWN_BAZEL_BUILD_FILE); + const pkg = new BazelPackage('foo', { name: 'foo' }, OWN_BAZEL_BUILD_FILE); expect(pkg.hasBuildTypesRule()).toBe(true); }); it('returns false if there is no rule with name "build_types"', () => { - const pkg = new BazelPackage('foo', {}, ``); + const pkg = new BazelPackage('foo', { name: 'foo' }, ``); + expect(pkg.hasBuildTypesRule()).toBe(false); + }); + + it('returns false if there is no BUILD.bazel file', () => { + const pkg = new BazelPackage('foo', { name: 'foo' }); expect(pkg.hasBuildTypesRule()).toBe(false); }); }); diff --git a/packages/kbn-bazel-packages/src/bazel_package.ts b/packages/kbn-bazel-packages/src/bazel_package.ts index 28170cb68a5d2..35950c9896faf 100644 --- a/packages/kbn-bazel-packages/src/bazel_package.ts +++ b/packages/kbn-bazel-packages/src/bazel_package.ts @@ -6,14 +6,49 @@ * Side Public License, v 1. */ +import { inspect } from 'util'; import Path from 'path'; import Fsp from 'fs/promises'; + +import normalizePath from 'normalize-path'; import { REPO_ROOT } from '@kbn/utils'; const BUILD_RULE_NAME = /(^|\s)name\s*=\s*"build"/; const BUILD_TYPES_RULE_NAME = /(^|\s)name\s*=\s*"build_types"/; +/** + * Simple parsed representation of a package.json file, validated + * by `assertParsedPackageJson()` and extensible as needed in the future + */ +export interface ParsedPackageJson { + /** + * The name of the package, usually `@kbn/`+something + */ + name: string; + /** + * All other fields in the package.json are typed as unknown as all we need at this time is "name" + */ + [key: string]: unknown; +} + +function isObj(v: unknown): v is Record { + return !!(typeof v === 'object' && v); +} + +function assertParsedPackageJson(v: unknown): asserts v is ParsedPackageJson { + if (!isObj(v) || typeof v.name !== 'string') { + throw new Error('Expected parsed package.json to be an object with at least a "name" property'); + } +} + +/** + * Representation of a Bazel Package in the Kibana repository + */ export class BazelPackage { + /** + * Create a BazelPackage object from a package directory. Reads some files from the package and returns + * a Promise for a BazelPackage instance + */ static async fromDir(dir: string) { let pkg; try { @@ -22,6 +57,8 @@ export class BazelPackage { throw new Error(`unable to parse package.json in [${dir}]: ${error.message}`); } + assertParsedPackageJson(pkg); + let buildBazelContent; if (pkg.name !== '@kbn/pm') { try { @@ -31,20 +68,43 @@ export class BazelPackage { } } - return new BazelPackage(Path.relative(REPO_ROOT, dir), pkg, buildBazelContent); + return new BazelPackage(normalizePath(Path.relative(REPO_ROOT, dir)), pkg, buildBazelContent); } constructor( - public readonly repoRelativeDir: string, - public readonly pkg: any, - public readonly buildBazelContent?: string + /** + * Relative path from the root of the repository to the package directory + */ + public readonly normalizedRepoRelativeDir: string, + /** + * Parsed package.json file from the package + */ + public readonly pkg: ParsedPackageJson, + /** + * Content of the BUILD.bazel file + */ + private readonly buildBazelContent?: string ) {} + /** + * Returns true if the package includes a `:build` bazel rule + */ hasBuildRule() { return !!(this.buildBazelContent && BUILD_RULE_NAME.test(this.buildBazelContent)); } + /** + * Returns true if the package includes a `:build_types` bazel rule + */ hasBuildTypesRule() { return !!(this.buildBazelContent && BUILD_TYPES_RULE_NAME.test(this.buildBazelContent)); } + + /** + * Custom inspect handler so that logging variables in scripts/generate doesn't + * print all the BUILD.bazel files + */ + [inspect.custom]() { + return `BazelPackage<${this.normalizedRepoRelativeDir}>`; + } } diff --git a/packages/kbn-bazel-packages/src/generate_packages_build_bazel_file.test.ts b/packages/kbn-bazel-packages/src/generate_packages_build_bazel_file.test.ts deleted file mode 100644 index 2d9fd2ed48adc..0000000000000 --- a/packages/kbn-bazel-packages/src/generate_packages_build_bazel_file.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { generatePackagesBuildBazelFile } from './generate_packages_build_bazel_file'; - -import { BazelPackage } from './bazel_package'; - -it('produces a valid BUILD.bazel file', () => { - const packages = [ - new BazelPackage( - 'foo', - {}, - ` - rule( - name = "build" - ) - rule( - name = "build_types" - ) - ` - ), - new BazelPackage( - 'bar', - {}, - ` - rule( - name= "build_types" - ) - ` - ), - new BazelPackage( - 'bar', - {}, - ` - rule( - name ="build" - ) - ` - ), - new BazelPackage('bar', {}), - ]; - - expect(generatePackagesBuildBazelFile(packages)).toMatchInlineSnapshot(` - "################ - ################ - ## This file is automatically generated, to create a new package use \`node scripts/generate package --help\` - ################ - ################ - - # It will build all declared code packages - filegroup( - name = \\"build_pkg_code\\", - srcs = [ - \\"//foo:build\\", - \\"//bar:build\\", - ], - ) - - # It will build all declared package types - filegroup( - name = \\"build_pkg_types\\", - srcs = [ - \\"//foo:build_types\\", - \\"//bar:build_types\\", - ], - ) - - # Grouping target to call all underlying packages build - # targets so we can build them all at once - # It will auto build all declared code packages and types packages - filegroup( - name = \\"build\\", - srcs = [ - \\":build_pkg_code\\", - \\":build_pkg_types\\" - ], - ) - " - `); -}); diff --git a/packages/kbn-bazel-packages/src/generate_packages_build_bazel_file.ts b/packages/kbn-bazel-packages/src/generate_packages_build_bazel_file.ts deleted file mode 100644 index d1dd3561ed39d..0000000000000 --- a/packages/kbn-bazel-packages/src/generate_packages_build_bazel_file.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import { BazelPackage } from './bazel_package'; - -export function generatePackagesBuildBazelFile(packages: BazelPackage[]) { - return `################ -################ -## This file is automatically generated, to create a new package use \`node scripts/generate package --help\` -################ -################ - -# It will build all declared code packages -filegroup( - name = "build_pkg_code", - srcs = [ -${packages - .flatMap((p) => (p.hasBuildRule() ? ` "//${p.repoRelativeDir}:build",` : [])) - .join('\n')} - ], -) - -# It will build all declared package types -filegroup( - name = "build_pkg_types", - srcs = [ -${packages - .flatMap((p) => (p.hasBuildTypesRule() ? ` "//${p.repoRelativeDir}:build_types",` : [])) - .join('\n')} - ], -) - -# Grouping target to call all underlying packages build -# targets so we can build them all at once -# It will auto build all declared code packages and types packages -filegroup( - name = "build", - srcs = [ - ":build_pkg_code", - ":build_pkg_types" - ], -) -`; -} diff --git a/packages/kbn-bazel-packages/src/index.ts b/packages/kbn-bazel-packages/src/index.ts index 7e73fcd0a63ee..c200882df8ab9 100644 --- a/packages/kbn-bazel-packages/src/index.ts +++ b/packages/kbn-bazel-packages/src/index.ts @@ -8,4 +8,3 @@ export * from './discover_packages'; export type { BazelPackage } from './bazel_package'; -export * from './generate_packages_build_bazel_file'; diff --git a/packages/kbn-generate/src/cli.ts b/packages/kbn-generate/src/cli.ts index 9ade4f68366f5..0b52f5bb4da72 100644 --- a/packages/kbn-generate/src/cli.ts +++ b/packages/kbn-generate/src/cli.ts @@ -12,6 +12,7 @@ import { Render } from './lib/render'; import { ContextExtensions } from './generate_command'; import { PackageCommand } from './commands/package_command'; +import { PackagesBuildManifestCommand } from './commands/packages_build_manifest_command'; /** * Runs the generate CLI. Called by `node scripts/generate` and not intended for use outside of that script @@ -26,6 +27,6 @@ export function runGenerateCli() { }; }, }, - [PackageCommand] + [PackageCommand, PackagesBuildManifestCommand] ).execute(); } diff --git a/packages/kbn-generate/src/commands/package_command.ts b/packages/kbn-generate/src/commands/package_command.ts index 284b3b96a0308..6ce14571e64bb 100644 --- a/packages/kbn-generate/src/commands/package_command.ts +++ b/packages/kbn-generate/src/commands/package_command.ts @@ -13,10 +13,10 @@ import normalizePath from 'normalize-path'; import globby from 'globby'; import { REPO_ROOT } from '@kbn/utils'; -import { discoverBazelPackages, generatePackagesBuildBazelFile } from '@kbn/bazel-packages'; +import { discoverBazelPackages } from '@kbn/bazel-packages'; import { createFailError, createFlagError, isFailError, sortPackageJson } from '@kbn/dev-utils'; -import { ROOT_PKG_DIR, PKG_TEMPLATE_DIR } from '../paths'; +import { TEMPLATE_DIR, ROOT_PKG_DIR, PKG_TEMPLATE_DIR } from '../paths'; import type { GenerateCommand } from '../generate_command'; export const PackageCommand: GenerateCommand = { @@ -49,7 +49,7 @@ export const PackageCommand: GenerateCommand = { const containingDir = flags.dir ? Path.resolve(`${flags.dir}`) : ROOT_PKG_DIR; const packageDir = Path.resolve(containingDir, name.slice(1).replace('/', '-')); - const repoRelativeDir = normalizePath(Path.relative(REPO_ROOT, packageDir)); + const normalizedRepoRelativeDir = normalizePath(Path.relative(REPO_ROOT, packageDir)); try { await Fsp.readdir(packageDir); @@ -107,8 +107,8 @@ export const PackageCommand: GenerateCommand = { name, web, dev, - directoryName: Path.basename(repoRelativeDir), - repoRelativeDir, + directoryName: Path.basename(normalizedRepoRelativeDir), + normalizedRepoRelativeDir, }, }); } @@ -122,17 +122,20 @@ export const PackageCommand: GenerateCommand = { ? [packageJson.devDependencies, packageJson.dependencies] : [packageJson.dependencies, packageJson.devDependencies]; - addDeps[name] = `link:bazel-bin/${repoRelativeDir}`; - addDeps[typePkgName] = `link:bazel-bin/${repoRelativeDir}/npm_module_types`; + addDeps[name] = `link:bazel-bin/${normalizedRepoRelativeDir}`; + addDeps[typePkgName] = `link:bazel-bin/${normalizedRepoRelativeDir}/npm_module_types`; delete removeDeps[name]; delete removeDeps[typePkgName]; await Fsp.writeFile(packageJsonPath, sortPackageJson(JSON.stringify(packageJson))); log.info('Updated package.json file'); - await Fsp.writeFile( + await render.toFile( + Path.resolve(TEMPLATE_DIR, 'packages_BUILD.bazel.ejs'), Path.resolve(REPO_ROOT, 'packages/BUILD.bazel'), - generatePackagesBuildBazelFile(await discoverBazelPackages()) + { + packages: await discoverBazelPackages(), + } ); log.info('Updated packages/BUILD.bazel'); diff --git a/packages/kbn-generate/src/commands/packages_build_manifest_command.ts b/packages/kbn-generate/src/commands/packages_build_manifest_command.ts new file mode 100644 index 0000000000000..9103d56d42a1d --- /dev/null +++ b/packages/kbn-generate/src/commands/packages_build_manifest_command.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Path from 'path'; +import Fsp from 'fs/promises'; + +import { REPO_ROOT } from '@kbn/utils'; +import { discoverBazelPackages } from '@kbn/bazel-packages'; + +import { TEMPLATE_DIR } from '../paths'; +import { GenerateCommand } from '../generate_command'; +import { validateFile } from '../lib/validate_file'; + +const USAGE = `node scripts/generate packages_build_manifest`; + +export const PackagesBuildManifestCommand: GenerateCommand = { + name: 'packages_build_manifest', + usage: USAGE, + description: 'Generate the packages/BUILD.bazel file', + flags: { + boolean: ['validate'], + help: ` + --validate Rather than writing the generated output to disk, validate that the content on disk is in sync with the + `, + }, + async run({ log, render, flags }) { + const validate = !!flags.validate; + + const packages = await discoverBazelPackages(); + const dest = Path.resolve(REPO_ROOT, 'packages/BUILD.bazel'); + const relDest = Path.relative(process.cwd(), dest); + + const content = await render.toString( + Path.join(TEMPLATE_DIR, 'packages_BUILD.bazel.ejs'), + dest, + { + packages, + } + ); + + if (validate) { + await validateFile(log, USAGE, dest, content); + return; + } + + await Fsp.writeFile(dest, content); + log.success('Wrote', relDest); + }, +}; diff --git a/packages/kbn-generate/src/lib/validate_file.ts b/packages/kbn-generate/src/lib/validate_file.ts new file mode 100644 index 0000000000000..d4f3640a45471 --- /dev/null +++ b/packages/kbn-generate/src/lib/validate_file.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import Fsp from 'fs/promises'; +import Path from 'path'; + +import { ToolingLog, createFailError, diffStrings } from '@kbn/dev-utils'; + +export async function validateFile(log: ToolingLog, usage: string, path: string, expected: string) { + const relPath = Path.relative(process.cwd(), path); + + let current; + try { + current = await Fsp.readFile(path, 'utf8'); + } catch (error) { + if (error && error.code === 'ENOENT') { + throw createFailError(`${relPath} is missing, please run "${usage}" and commit the result`); + } + + throw error; + } + + if (current !== expected) { + log.error(`${relPath} is outdated:\n${diffStrings(expected, current)}`); + throw createFailError(`${relPath} is outdated, please run "${usage}" and commit the result`); + } + + log.success(`${relPath} is valid`); +} diff --git a/packages/kbn-generate/templates/package/jest.config.js.ejs b/packages/kbn-generate/templates/package/jest.config.js.ejs index 1846d6a8f96f5..6a65cc6b304c5 100644 --- a/packages/kbn-generate/templates/package/jest.config.js.ejs +++ b/packages/kbn-generate/templates/package/jest.config.js.ejs @@ -1,5 +1,5 @@ module.exports = { preset: <%- js(pkg.web ? '@kbn/test' : '@kbn/test/jest_node') %>, rootDir: '../..', - roots: [<%- js(`/${pkg.repoRelativeDir}`) %>], + roots: [<%- js(`/${pkg.normalizedRepoRelativeDir}`) %>], }; diff --git a/packages/kbn-generate/templates/packages_BUILD.bazel.ejs b/packages/kbn-generate/templates/packages_BUILD.bazel.ejs new file mode 100644 index 0000000000000..43dd306d3cbb7 --- /dev/null +++ b/packages/kbn-generate/templates/packages_BUILD.bazel.ejs @@ -0,0 +1,37 @@ +################ +################ +## This file is automatically generated, to create a new package use `node scripts/generate package --help` or run +## `node scripts/generate packages_build_manifest` to regenerate it from the current state of the repo +################ +################ + +# It will build all declared code packages +filegroup( + name = "build_pkg_code", + srcs = [ +<% for (const p of packages.filter(p => p.hasBuildRule())) { _%> + "//<%- p.normalizedRepoRelativeDir %>:build", +<% } _%> + ], +) + +# It will build all declared package types +filegroup( + name = "build_pkg_types", + srcs = [ +<% for (const p of packages.filter(p => p.hasBuildTypesRule())) { _%> + "//<%- p.normalizedRepoRelativeDir %>:build_types", +<% } _%> + ], +) + +# Grouping target to call all underlying packages build +# targets so we can build them all at once +# It will auto build all declared code packages and types packages +filegroup( + name = "build", + srcs = [ + ":build_pkg_code", + ":build_pkg_types" + ], +) diff --git a/src/dev/precommit_hook/casing_check_config.js b/src/dev/precommit_hook/casing_check_config.js index 86448be7c3e53..eb65e0b752174 100644 --- a/src/dev/precommit_hook/casing_check_config.js +++ b/src/dev/precommit_hook/casing_check_config.js @@ -67,6 +67,9 @@ export const IGNORE_FILE_GLOBS = [ // Buildkite '.buildkite/**/*', + + // generator templates use weird filenames based on the requirements for the files they're generating + 'packages/kbn-generate/templates/**/*', ]; /** @@ -107,10 +110,7 @@ export const IGNORE_DIRECTORY_GLOBS = [ * * @type {Array} */ -export const REMOVE_EXTENSION = [ - 'packages/kbn-plugin-generator/template/**/*.ejs', - 'packages/kbn-generate/templates/**/*.ejs', -]; +export const REMOVE_EXTENSION = ['packages/kbn-plugin-generator/template/**/*.ejs']; /** * DO NOT ADD FILES TO THIS LIST!! From c060b9f6f76c4971da67c9101e7466db6b727df6 Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Mon, 14 Mar 2022 21:48:29 -0600 Subject: [PATCH 42/44] [Security Solutions] Increases timeout and unskips detection tests (#126294) ## Summary Increases the timeouts from 2 minutes to now 5 minutes and unskips the detection tests. If any of the tests fail consistently then I will skip just those individual tests instead of the whole suit. See https://github.com/elastic/kibana/issues/125851 and https://github.com/elastic/elasticsearch/issues/84256 This could cause issues with: https://github.com/elastic/kibana/issues/125319 If so, then we will have to deal with the cloud based tests in a different way but in reality we need the extra time as some test cases do take a while to run on CI. This also: * Removes skips around code that have been in there for a while and adds documentation to the parts that are left over. ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../basic/tests/open_close_signals.ts | 2 +- .../basic/tests/query_signals.ts | 2 ++ .../security_and_spaces/tests/create_exceptions.ts | 3 +-- .../security_and_spaces/tests/create_index.ts | 1 + .../security_and_spaces/tests/create_ml.ts | 3 +-- .../security_and_spaces/tests/create_rules.ts | 6 ++---- .../security_and_spaces/tests/create_threat_matching.ts | 3 +-- .../tests/finalize_signals_migrations.ts | 1 + .../security_and_spaces/tests/generating_signals.ts | 5 ++--- .../security_and_spaces/tests/index.ts | 3 +-- .../security_and_spaces/tests/open_close_signals.ts | 8 +++++--- x-pack/test/detection_engine_api_integration/utils.ts | 3 ++- 12 files changed, 20 insertions(+), 20 deletions(-) diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/open_close_signals.ts b/x-pack/test/detection_engine_api_integration/basic/tests/open_close_signals.ts index fc0072de05f5e..f58dee11c48da 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/open_close_signals.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/open_close_signals.ts @@ -34,7 +34,7 @@ export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); const log = getService('log'); - describe.skip('open_close_signals', () => { + describe('open_close_signals', () => { describe('tests with auditbeat data', () => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/auditbeat/hosts'); diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/query_signals.ts b/x-pack/test/detection_engine_api_integration/basic/tests/query_signals.ts index 3ef0a12cf70dc..48d13e8b2afd6 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/query_signals.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/query_signals.ts @@ -39,6 +39,7 @@ export default ({ getService }: FtrProviderContext) => { }); }); + // This fails and should be investigated or removed if it no longer applies it.skip('should not give errors when querying and the signals index does exist and is empty', async () => { await createSignalsIndex(supertest, log); const { body } = await supertest @@ -160,6 +161,7 @@ export default ({ getService }: FtrProviderContext) => { }); }); + // This fails and should be investigated or removed if it no longer applies it.skip('should not give errors when querying and the signals index does exist and is empty', async () => { await createSignalsIndex(supertest, log); const { body } = await supertest diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_exceptions.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_exceptions.ts index 818ba3b366e40..0dfc753be402c 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_exceptions.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_exceptions.ts @@ -63,8 +63,7 @@ export default ({ getService }: FtrProviderContext) => { const log = getService('log'); const es = getService('es'); - // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/125851 - describe.skip('create_rules_with_exceptions', () => { + describe('create_rules_with_exceptions', () => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/auditbeat/hosts'); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_index.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_index.ts index 17d6b3957637a..6d867877e0dc3 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_index.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_index.ts @@ -38,6 +38,7 @@ export default ({ getService }: FtrProviderContext) => { await esArchiver.unload('x-pack/test/functional/es_archives/signals/index_alias_clash'); }); + // This fails and should be investigated or removed if it no longer applies it.skip('should report that signals index does not exist', async () => { const { body } = await supertest.get(DETECTION_ENGINE_INDEX_URL).send().expect(404); expect(body).to.eql({ message: 'index for this space does not exist', status_code: 404 }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_ml.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_ml.ts index e2ce3922f2d3f..343db03c2ae27 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_ml.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_ml.ts @@ -92,8 +92,7 @@ export default ({ getService }: FtrProviderContext) => { return body; } - // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/125851 - describe.skip('Generating signals from ml anomalies', () => { + describe('Generating signals from ml anomalies', () => { before(async () => { // Order is critical here: auditbeat data must be loaded before attempting to start the ML job, // as the job looks for certain indices on start diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts index 1075a63742777..82899ff0de8f7 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts @@ -44,8 +44,7 @@ export default ({ getService }: FtrProviderContext) => { const supertestWithoutAuth = getService('supertestWithoutAuth'); const log = getService('log'); - // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/125851 - describe.skip('create_rules', () => { + describe('create_rules', () => { describe('creating rules', () => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/auditbeat/hosts'); @@ -106,8 +105,7 @@ export default ({ getService }: FtrProviderContext) => { await waitForRuleSuccessOrStatus(supertest, log, body.id); }); - // TODO: does the below test work? - it.skip('should create a single rule with a rule_id and an index pattern that does not match anything available and partial failure for the rule', async () => { + it('should create a single rule with a rule_id and an index pattern that does not match anything available and partial failure for the rule', async () => { const simpleRule = getRuleForSignalTesting(['does-not-exist-*']); const { body } = await supertest .post(DETECTION_ENGINE_RULES_URL) diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts index b5b232f70ec89..346998e7af261 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts @@ -69,8 +69,7 @@ export default ({ getService }: FtrProviderContext) => { /** * Specific api integration tests for threat matching rule type */ - // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/125851 - describe.skip('create_threat_matching', () => { + describe('create_threat_matching', () => { describe('creating threat match rule', () => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/auditbeat/hosts'); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/finalize_signals_migrations.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/finalize_signals_migrations.ts index df06647c2a8b5..8733c645112d1 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/finalize_signals_migrations.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/finalize_signals_migrations.ts @@ -183,6 +183,7 @@ export default ({ getService }: FtrProviderContext): void => { expect(statusAfter.map((s) => s.is_outdated)).to.eql([false, false]); }); + // This fails and should be investigated or removed if it no longer applies it.skip('deletes the underlying migration task', async () => { await waitFor( async () => { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts index 761792e29ea1d..11c5af219de4c 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts @@ -71,8 +71,7 @@ export default ({ getService }: FtrProviderContext) => { const es = getService('es'); const log = getService('log'); - // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/125851 - describe.skip('Generating signals from source indexes', () => { + describe('Generating signals from source indexes', () => { beforeEach(async () => { await deleteSignalsIndex(supertest, log); await createSignalsIndex(supertest, log); @@ -1160,7 +1159,7 @@ export default ({ getService }: FtrProviderContext) => { }); }); - describe.skip('Signal deduplication', async () => { + describe('Signal deduplication', async () => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/auditbeat/hosts'); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts index d234c70de5240..a9bda19638041 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts @@ -9,8 +9,7 @@ import { FtrProviderContext } from '../../common/ftr_provider_context'; // eslint-disable-next-line import/no-default-export export default ({ loadTestFile }: FtrProviderContext): void => { - // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/125851 - describe.skip('detection engine api security and spaces enabled', function () { + describe('detection engine api security and spaces enabled', function () { describe('', function () { this.tags('ciGroup11'); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/open_close_signals.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/open_close_signals.ts index f9a5a3283bd2f..6138995bdc02f 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/open_close_signals.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/open_close_signals.ts @@ -39,7 +39,7 @@ export default ({ getService }: FtrProviderContext) => { const log = getService('log'); describe('open_close_signals', () => { - describe.skip('validation checks', () => { + describe('validation checks', () => { it('should not give errors when querying and the signals index does not exist yet', async () => { const { body } = await supertest .post(DETECTION_ENGINE_SIGNALS_STATUS_URL) @@ -165,7 +165,8 @@ export default ({ getService }: FtrProviderContext) => { expect(everySignalClosed).to.eql(true); }); - it('should be able to close signals with t1 analyst user', async () => { + // This fails and should be investigated or removed if it no longer applies + it.skip('should be able to close signals with t1 analyst user', async () => { const rule = getRuleForSignalTesting(['auditbeat-*']); const { id } = await createRule(supertest, log, rule); await waitForRuleSuccessOrStatus(supertest, log, id); @@ -200,7 +201,8 @@ export default ({ getService }: FtrProviderContext) => { await deleteUserAndRole(getService, ROLES.t1_analyst); }); - it('should be able to close signals with soc_manager user', async () => { + // This fails and should be investigated or removed if it no longer applies + it.skip('should be able to close signals with soc_manager user', async () => { const rule = getRuleForSignalTesting(['auditbeat-*']); const { id } = await createRule(supertest, log, rule); await waitForRuleSuccessOrStatus(supertest, log, id); diff --git a/x-pack/test/detection_engine_api_integration/utils.ts b/x-pack/test/detection_engine_api_integration/utils.ts index 496faa3c5e421..545bd20fc777b 100644 --- a/x-pack/test/detection_engine_api_integration/utils.ts +++ b/x-pack/test/detection_engine_api_integration/utils.ts @@ -457,6 +457,7 @@ export const getSimpleRulePreviewOutput = ( ) => ({ logs, previewId, + isAborted: false, }); export const resolveSimpleRuleOutput = ( @@ -924,7 +925,7 @@ export const waitFor = async ( functionToTest: () => Promise, functionName: string, log: ToolingLog, - maxTimeout: number = 100000, + maxTimeout: number = 400000, timeoutWait: number = 250 ): Promise => { let found = false; From 553374816224ac901327c9dae5e75b8ffa70b79e Mon Sep 17 00:00:00 2001 From: Dmitry Tomashevich <39378793+Dmitriynj@users.noreply.github.com> Date: Tue, 15 Mar 2022 12:10:18 +0500 Subject: [PATCH 43/44] [Discover] Fix "encoded URL params in context page" cloud test permissions (#126470) * [Discover] fix cloud test for encoded param in context * [Discover] improve test description wording * [Discover] apply suggestion Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../apps/discover/_context_encoded_url_param.ts | 12 +++++++----- test/functional/config.js | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/test/functional/apps/discover/_context_encoded_url_param.ts b/test/functional/apps/discover/_context_encoded_url_param.ts index 83ac63afd915f..fdbee7a637f46 100644 --- a/test/functional/apps/discover/_context_encoded_url_param.ts +++ b/test/functional/apps/discover/_context_encoded_url_param.ts @@ -12,30 +12,32 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const dataGrid = getService('dataGrid'); const kibanaServer = getService('kibanaServer'); + const security = getService('security'); const PageObjects = getPageObjects(['common', 'discover', 'timePicker', 'settings', 'header']); const testSubjects = getService('testSubjects'); const es = getService('es'); - describe('context encoded id param', () => { + describe('encoded URL params in context page', () => { before(async function () { + await security.testUser.setRoles(['kibana_admin', 'context_encoded_param']); await PageObjects.common.navigateToApp('settings'); await es.transport.request({ - path: '/includes-plus-symbol-doc-id/_doc/1+1=2', + path: '/context-encoded-param/_doc/1+1=2', method: 'PUT', body: { username: 'Dmitry', '@timestamp': '2015-09-21T09:30:23', }, }); - await PageObjects.settings.createIndexPattern('includes-plus-symbol-doc-id'); + await PageObjects.settings.createIndexPattern('context-encoded-param'); await kibanaServer.uiSettings.update({ 'doc_table:legacy': false }); await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); await PageObjects.common.navigateToApp('discover'); }); - it('should navigate to context page correctly', async () => { - await PageObjects.discover.selectIndexPattern('includes-plus-symbol-doc-id'); + it('should navigate correctly', async () => { + await PageObjects.discover.selectIndexPattern('context-encoded-param'); await PageObjects.header.waitUntilLoadingHasFinished(); // navigate to the context view diff --git a/test/functional/config.js b/test/functional/config.js index 389f432641acf..bfd2da7518fb7 100644 --- a/test/functional/config.js +++ b/test/functional/config.js @@ -216,6 +216,21 @@ export default async function ({ readConfigFile }) { kibana: [], }, + context_encoded_param: { + elasticsearch: { + cluster: [], + indices: [ + { + names: ['context-encoded-param'], + privileges: ['read', 'view_index_metadata', 'manage', 'create_index', 'index'], + field_security: { grant: ['*'], except: [] }, + }, + ], + run_as: [], + }, + kibana: [], + }, + kibana_sample_read: { elasticsearch: { cluster: [], From f4bd49b5927de5f15882f63064338e92b722abad Mon Sep 17 00:00:00 2001 From: Esteban Beltran Date: Tue, 15 Mar 2022 08:55:38 +0100 Subject: [PATCH 44/44] [Cases] Only enable save if changes were made to the connector form (#127455) --- .../components/edit_connector/index.test.tsx | 115 ++++++++++++++++-- .../components/edit_connector/index.tsx | 24 +++- 2 files changed, 123 insertions(+), 16 deletions(-) diff --git a/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx b/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx index bcc700c543f7e..0512501bf5e11 100644 --- a/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx +++ b/x-pack/plugins/cases/public/components/edit_connector/index.test.tsx @@ -14,6 +14,8 @@ import { TestProviders } from '../../common/mock'; import { connectorsMock } from '../../containers/configure/mock'; import { basicCase, basicPush, caseUserActions } from '../../containers/mock'; import { useKibana } from '../../common/lib/kibana'; +import { CaseConnector } from '../../containers/configure/types'; +import userEvent from '@testing-library/user-event'; jest.mock('../../common/lib/kibana'); const useKibanaMock = useKibana as jest.Mocked; @@ -29,18 +31,20 @@ const caseServices = { hasDataToPush: true, }, }; -const defaultProps: EditConnectorProps = { - caseData: basicCase, - caseServices, - connectorName: connectorsMock[0].name, - connectors: connectorsMock, - hasDataToPush: true, - isLoading: false, - isValidConnector: true, - onSubmit, - updateCase, - userActions: caseUserActions, - userCanCrud: true, +const getDefaultProps = (): EditConnectorProps => { + return { + caseData: basicCase, + caseServices, + connectorName: connectorsMock[0].name, + connectors: connectorsMock, + hasDataToPush: true, + isLoading: false, + isValidConnector: true, + onSubmit, + updateCase, + userActions: caseUserActions, + userCanCrud: true, + }; }; describe('EditConnector ', () => { @@ -53,6 +57,7 @@ describe('EditConnector ', () => { }); it('Renders servicenow connector from case initially', async () => { + const defaultProps = getDefaultProps(); const serviceNowProps = { ...defaultProps, caseData: { @@ -71,6 +76,7 @@ describe('EditConnector ', () => { }); it('Renders no connector, and then edit', async () => { + const defaultProps = getDefaultProps(); const wrapper = mount( @@ -92,6 +98,7 @@ describe('EditConnector ', () => { }); it('Edit external service on submit', async () => { + const defaultProps = getDefaultProps(); const wrapper = mount( @@ -111,6 +118,7 @@ describe('EditConnector ', () => { }); it('Revert to initial external service on error', async () => { + const defaultProps = getDefaultProps(); onSubmit.mockImplementation((connector, onSuccess, onError) => { onError(new Error('An error has occurred')); }); @@ -155,11 +163,15 @@ describe('EditConnector ', () => { }); it('Resets selector on cancel', async () => { + const defaultProps = getDefaultProps(); const props = { ...defaultProps, caseData: { ...defaultProps.caseData, - connector: { ...defaultProps.caseData.connector, id: 'servicenow-1' }, + connector: { + ...defaultProps.caseData.connector, + id: 'servicenow-1', + }, }, }; @@ -185,6 +197,7 @@ describe('EditConnector ', () => { }); it('Renders loading spinner', async () => { + const defaultProps = getDefaultProps(); const props = { ...defaultProps, isLoading: true }; const wrapper = mount( @@ -197,6 +210,7 @@ describe('EditConnector ', () => { }); it('does not allow the connector to be edited when the user does not have write permissions', async () => { + const defaultProps = getDefaultProps(); const props = { ...defaultProps, userCanCrud: false }; const wrapper = mount( @@ -211,6 +225,7 @@ describe('EditConnector ', () => { }); it('displays the permissions error message when one is provided', async () => { + const defaultProps = getDefaultProps(); const props = { ...defaultProps, permissionsError: 'error message' }; const wrapper = mount( @@ -232,6 +247,7 @@ describe('EditConnector ', () => { }); it('displays the callout message when none is selected', async () => { + const defaultProps = getDefaultProps(); const props = { ...defaultProps, connectors: [] }; const wrapper = mount( @@ -247,4 +263,77 @@ describe('EditConnector ', () => { expect(wrapper.find(`[data-test-subj="push-callouts"]`).exists()).toEqual(true); }); }); + + it('disables the save button until changes are done ', async () => { + const defaultProps = getDefaultProps(); + const serviceNowProps = { + ...defaultProps, + caseData: { + ...defaultProps.caseData, + connector: { + ...defaultProps.caseData.connector, + id: 'servicenow-1', + fields: { + urgency: null, + severity: null, + impact: null, + category: null, + subcategory: null, + }, + } as CaseConnector, + }, + }; + const result = render( + + + + ); + + // the save button should be disabled + userEvent.click(result.getByTestId('connector-edit-button')); + expect(result.getByTestId('edit-connectors-submit')).toBeDisabled(); + + // simulate changing the connector + userEvent.click(result.getByTestId('dropdown-connectors')); + userEvent.click(result.getAllByTestId('dropdown-connector-no-connector')[0]); + expect(result.getByTestId('edit-connectors-submit')).toBeEnabled(); + + // this strange assertion is required because of existing race conditions inside the EditConnector component + await waitFor(() => { + expect(true).toBeTruthy(); + }); + }); + + it('disables the save button when no connector is the default', async () => { + const defaultProps = getDefaultProps(); + const noneConnector = { + ...defaultProps, + caseData: { + ...defaultProps.caseData, + connector: { + id: 'none', + fields: null, + } as CaseConnector, + }, + }; + const result = render( + + + + ); + + // the save button should be disabled + userEvent.click(result.getByTestId('connector-edit-button')); + expect(result.getByTestId('edit-connectors-submit')).toBeDisabled(); + + // simulate changing the connector + userEvent.click(result.getByTestId('dropdown-connectors')); + userEvent.click(result.getAllByTestId('dropdown-connector-resilient-2')[0]); + expect(result.getByTestId('edit-connectors-submit')).toBeEnabled(); + + // this strange assertion is required because of existing race conditions inside the EditConnector component + await waitFor(() => { + expect(true).toBeTruthy(); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/edit_connector/index.tsx b/x-pack/plugins/cases/public/components/edit_connector/index.tsx index 5ab27e3e32009..7db20170c7857 100644 --- a/x-pack/plugins/cases/public/components/edit_connector/index.tsx +++ b/x-pack/plugins/cases/public/components/edit_connector/index.tsx @@ -4,8 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import React, { useCallback, useEffect, useReducer } from 'react'; +import React, { useCallback, useEffect, useReducer, useState } from 'react'; import deepEqual from 'fast-deep-equal'; import { EuiText, @@ -22,7 +21,7 @@ import { isEmpty, noop } from 'lodash/fp'; import { FieldConfig, Form, UseField, useForm } from '../../common/shared_imports'; import { Case } from '../../../common/ui/types'; -import { ActionConnector, ConnectorTypeFields } from '../../../common/api'; +import { ActionConnector, ConnectorTypeFields, NONE_CONNECTOR_ID } from '../../../common/api'; import { ConnectorSelector } from '../connector_selector/form'; import { ConnectorFieldsForm } from '../connectors/fields_form'; import { CaseUserActions } from '../../containers/types'; @@ -133,6 +132,9 @@ export const EditConnector = React.memo( schema, }); + // by default save if disabled + const [enableSave, setEnableSave] = useState(false); + const { setFieldValue, submit } = form; const [{ currentConnector, fields, editConnector }, dispatch] = useReducer( @@ -140,6 +142,21 @@ export const EditConnector = React.memo( { ...initialState, fields: caseFields } ); + // only enable the save button if changes were made to the previous selected + // connector or its fields + useEffect(() => { + // null and none are equivalent to `no connector`. + // This makes sure we don't enable the button when the "no connector" option is selected + // by default. e.g. when a case is created without a selector + const isNoConnectorDeafultValue = + currentConnector === null && selectedConnector === NONE_CONNECTOR_ID; + const enable = + (!isNoConnectorDeafultValue && currentConnector?.id !== selectedConnector) || + !deepEqual(fields, caseFields); + + setEnableSave(enable); + }, [caseFields, currentConnector, fields, selectedConnector]); + useEffect(() => { // Initialize the current connector with the connector information attached to the case if we can find that // connector in the retrieved connectors from the API call @@ -330,6 +347,7 @@ export const EditConnector = React.memo(