From 68f82ced36f646e3bbada4bb389b2391bb3132d3 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 7 Nov 2022 14:46:24 +0000 Subject: [PATCH 001/192] skip flaky suite (#142715) --- .../group3/drilldowns/dashboard_to_dashboard_drilldown.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/dashboard/group3/drilldowns/dashboard_to_dashboard_drilldown.ts b/x-pack/test/functional/apps/dashboard/group3/drilldowns/dashboard_to_dashboard_drilldown.ts index 3b1f9c2065180..4678df4038408 100644 --- a/x-pack/test/functional/apps/dashboard/group3/drilldowns/dashboard_to_dashboard_drilldown.ts +++ b/x-pack/test/functional/apps/dashboard/group3/drilldowns/dashboard_to_dashboard_drilldown.ts @@ -38,7 +38,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const elasticChart = getService('elasticChart'); describe('Dashboard to dashboard drilldown', function () { - describe('Create & use drilldowns', () => { + // FLAKY: https://github.com/elastic/kibana/issues/142715 + describe.skip('Create & use drilldowns', () => { before(async () => { log.debug('Dashboard Drilldowns:initTests'); await security.testUser.setRoles(['test_logstash_reader', 'global_dashboard_all']); From 9f3c4c160934619449b67f9af994cce4d8d824b0 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 7 Nov 2022 15:17:02 +0000 Subject: [PATCH 002/192] chore(NA): move Bazel --progress_report_interval from common to build option (#144703) --- .bazelrc.common | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.bazelrc.common b/.bazelrc.common index 9acc4ebc875e8..d93c107637b79 100644 --- a/.bazelrc.common +++ b/.bazelrc.common @@ -51,9 +51,9 @@ query --incompatible_no_implicit_file_export common --color=yes common --show_progress common --show_task_finish -common --progress_report_interval=10 common --show_progress_rate_limit=10 -common --show_loading_progress +build --progress_report_interval=10 +build --show_loading_progress build --show_result=1 # Specifies desired output mode for running tests. From b980c8cc4a99ed5c53039371af127444ada19ae6 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Mon, 7 Nov 2022 16:23:04 +0100 Subject: [PATCH 003/192] [Synthetics] Fixes size of metrics viz and loadings (#143742) --- .../observability/e2e/synthetics_runner.ts | 1 + .../configurations/constants/constants.ts | 32 ++-- .../single_metric_attributes.test.ts | 2 +- .../embeddable/embeddable.test.tsx | 42 +---- .../embeddable/embeddable.tsx | 145 +++++++----------- .../exploratory_view/embeddable/index.tsx | 96 ++++++------ .../embeddable/single_metric.test.tsx | 48 ------ .../embeddable/single_metric.tsx | 130 ---------------- .../embeddable/use_app_data_view.ts | 65 ++++++++ .../embeddable/use_local_data_view.ts | 39 +++++ .../shared/exploratory_view/index.tsx | 1 + .../shared/exploratory_view/labels.ts | 43 ++++++ .../obsv_exploratory_view.tsx | 30 +--- .../columns/data_type_select.test.tsx | 3 +- .../observability_data_views.test.ts | 3 +- .../observability_data_views.ts | 27 ++-- .../e2e/journeys/synthetics/index.ts | 6 +- .../common/pages/synthetics_page_template.tsx | 6 +- .../monitor_errors/failed_tests_count.tsx | 29 ++-- .../monitor_summary/availability_panel.tsx | 30 ++-- .../availability_sparklines.tsx | 37 +++-- .../monitor_summary/duration_panel.tsx | 28 ++-- .../monitor_summary/duration_trend.tsx | 2 +- .../monitor_summary/kpi_wrapper.tsx | 29 ---- .../monitor_summary/monitor_errors_count.tsx | 29 ++-- .../monitor_summary/monitor_summary.tsx | 2 +- .../contexts/synthetics_data_view_context.tsx | 38 ++++- .../public/apps/synthetics/synthetics_app.tsx | 34 ++-- .../legacy_uptime/state/api/index_status.ts | 16 +- .../charts/page_load_dist_chart.tsx | 19 ++- .../rum_dashboard/charts/page_views_chart.tsx | 40 ++--- .../page_load_distribution/index.tsx | 6 +- 32 files changed, 471 insertions(+), 587 deletions(-) delete mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/single_metric.test.tsx delete mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/single_metric.tsx create mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/use_app_data_view.ts create mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/use_local_data_view.ts create mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/labels.ts delete mode 100644 x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/kpi_wrapper.tsx diff --git a/x-pack/plugins/observability/e2e/synthetics_runner.ts b/x-pack/plugins/observability/e2e/synthetics_runner.ts index a24771c091c09..27a6cb66a3a2f 100644 --- a/x-pack/plugins/observability/e2e/synthetics_runner.ts +++ b/x-pack/plugins/observability/e2e/synthetics_runner.ts @@ -101,6 +101,7 @@ export class SyntheticsRunner { playwrightOptions: { headless, chromiumSandbox: false, timeout: 60 * 1000 }, match: match === 'undefined' ? '' : match, pauseOnError, + screenshots: 'off', }); await this.assertResults(results); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts index 6a925c9b3d99b..0b02d31f40117 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/constants/constants.ts @@ -14,12 +14,13 @@ import { FID_FIELD, LCP_FIELD, TBT_FIELD, - TRANSACTION_TIME_TO_FIRST_BYTE, TRANSACTION_DURATION, + TRANSACTION_TIME_TO_FIRST_BYTE, } from './elasticsearch_fieldnames'; import { AGENT_HOST_LABEL, AGENT_TYPE_LABEL, + BACKEND_TIME_LABEL, BROWSER_FAMILY_LABEL, BROWSER_VERSION_LABEL, CLS_LABEL, @@ -28,38 +29,37 @@ import { DEVICE_DISTRIBUTION_LABEL, DEVICE_LABEL, ENVIRONMENT_LABEL, + EVENT_DATASET_LABEL, FCP_LABEL, FID_LABEL, + HEATMAP_LABEL, HOST_NAME_LABEL, - KPI_OVER_TIME_LABEL, KPI_LABEL, + KPI_OVER_TIME_LABEL, + LABELS_FIELD, LCP_LABEL, LOCATION_LABEL, + MESSAGE_LABEL, METRIC_LABEL, MONITOR_ID_LABEL, MONITOR_NAME_LABEL, MONITOR_STATUS_LABEL, MONITOR_TYPE_LABEL, + MONITORS_DURATION_LABEL, OBSERVER_LOCATION_LABEL, OS_LABEL, + PAGE_LOAD_TIME_LABEL, PERF_DIST_LABEL, PORT_LABEL, REQUEST_METHOD, SERVICE_NAME_LABEL, SERVICE_TYPE_LABEL, + SINGLE_METRIC_LABEL, + STEP_DURATION_LABEL, + STEP_NAME_LABEL, TAGS_LABEL, TBT_LABEL, URL_LABEL, - BACKEND_TIME_LABEL, - MONITORS_DURATION_LABEL, - PAGE_LOAD_TIME_LABEL, - LABELS_FIELD, - STEP_NAME_LABEL, - STEP_DURATION_LABEL, - EVENT_DATASET_LABEL, - MESSAGE_LABEL, - SINGLE_METRIC_LABEL, - HEATMAP_LABEL, } from './labels'; import { MONITOR_DURATION_US, @@ -180,14 +180,6 @@ export enum ReportTypes { HEATMAP = 'heatmap', } -export enum DataTypes { - SYNTHETICS = 'synthetics', - UX = 'ux', - MOBILE = 'mobile', - METRICS = 'infra_metrics', - LOGS = 'infra_logs', -} - export const USE_BREAK_DOWN_COLUMN = 'USE_BREAK_DOWN_COLUMN'; export const FILTER_RECORDS = 'FILTER_RECORDS'; export const TERMS_COLUMN = 'TERMS_COLUMN'; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes/single_metric_attributes.test.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes/single_metric_attributes.test.ts index 7897b691e6fc8..54aed080f9185 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes/single_metric_attributes.test.ts +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/configurations/lens_attributes/single_metric_attributes.test.ts @@ -15,8 +15,8 @@ import { LayerConfig, LensAttributes } from '../lens_attributes'; import { TRANSACTION_DURATION } from '../constants/elasticsearch_fieldnames'; import { lensPluginMock } from '@kbn/lens-plugin/public/mocks'; import { FormulaPublicApi } from '@kbn/lens-plugin/public'; -import { DataTypes } from '../constants'; import { sampleMetricFormulaAttribute } from '../test_data/test_formula_metric_attribute'; +import { DataTypes } from '../..'; describe('SingleMetricAttributes', () => { mockAppDataView(); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/embeddable.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/embeddable.test.tsx index a6c3cd1777ec5..f10d188c6b566 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/embeddable.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/embeddable.test.tsx @@ -17,7 +17,7 @@ jest.mock('../header/add_to_case_action', () => ({ })); const mockLensAttrs = { - title: '[Host] KPI Hosts - metric 1', + hidePanelTitles: true, description: '', visualizationType: 'lnsMetric', state: { @@ -110,7 +110,7 @@ describe('Embeddable', () => { caseOwner={mockOwner} customLensAttrs={mockLensAttrs} customTimeRange={mockTimeRange} - indexPatterns={mockDataViews} + dataViewState={mockDataViews} lens={mockLens} reportType={mockReportType} title={mockTitle} @@ -128,7 +128,7 @@ describe('Embeddable', () => { caseOwner={mockOwner} customLensAttrs={mockLensAttrs} customTimeRange={mockTimeRange} - indexPatterns={mockDataViews} + dataViewState={mockDataViews} lens={mockLens} reportType={mockReportType} withActions={mockActions} @@ -146,7 +146,7 @@ describe('Embeddable', () => { caseOwner={mockOwner} customLensAttrs={mockLensAttrs} customTimeRange={mockTimeRange} - indexPatterns={mockDataViews} + dataViewState={mockDataViews} lens={mockLens} reportType={mockReportType} withActions={mockActions} @@ -174,38 +174,6 @@ describe('Embeddable', () => { ); }); - it('renders single metric', () => { - const { container } = render( - - ); - expect( - container.querySelector(`[data-test-subj="exploratoryView-singleMetric"]`) - ).toBeInTheDocument(); - expect(container.querySelector(`[data-test-subj="exploratoryView"]`)).not.toBeInTheDocument(); - expect((mockLens.EmbeddableComponent as jest.Mock).mock.calls[0][0].id).toEqual( - 'exploratoryView-singleMetric' - ); - expect((mockLens.EmbeddableComponent as jest.Mock).mock.calls[0][0].attributes).toEqual( - mockLensAttrs - ); - expect((mockLens.EmbeddableComponent as jest.Mock).mock.calls[0][0].timeRange).toEqual( - mockTimeRange - ); - expect((mockLens.EmbeddableComponent as jest.Mock).mock.calls[0][0].withDefaultActions).toEqual( - true - ); - }); - it('renders AddToCaseAction', () => { render( { caseOwner={mockOwner} customLensAttrs={mockLensAttrs} customTimeRange={mockTimeRange} - indexPatterns={mockDataViews} + dataViewState={mockDataViews} isSingleMetric={true} lens={mockLens} reportType={mockReportType} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/embeddable.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/embeddable.tsx index 04fec072229d7..fc80a96e7b69f 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/embeddable.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/embeddable.tsx @@ -29,16 +29,15 @@ import { obsvReportConfigMap } from '../obsv_exploratory_view'; import { ActionTypes, useActions } from './use_actions'; import { AddToCaseAction } from '../header/add_to_case_action'; import { observabilityFeatureId } from '../../../../../common'; -import { SingleMetric, SingleMetricOptions } from './single_metric'; export interface ExploratoryEmbeddableProps { appId?: 'securitySolutionUI' | 'observability'; appendTitle?: JSX.Element; attributes?: AllSeries; axisTitlesVisibility?: XYState['axisTitlesVisibilitySettings']; - customHeight?: string | number; + customHeight?: string; customLensAttrs?: any; // Takes LensAttributes - customTimeRange?: { from: string; to: string }; // requred if rendered with LensAttributes + customTimeRange?: { from: string; to: string }; // required if rendered with LensAttributes dataTypesIndexPatterns?: Partial>; isSingleMetric?: boolean; legendIsVisible?: boolean; @@ -49,15 +48,13 @@ export interface ExploratoryEmbeddableProps { reportConfigMap?: ReportConfigMap; reportType: ReportViewType; showCalculationMethod?: boolean; - singleMetricOptions?: SingleMetricOptions; title?: string | JSX.Element; withActions?: boolean | ActionTypes[]; - align?: 'left' | 'right' | 'center'; } export interface ExploratoryEmbeddableComponentProps extends ExploratoryEmbeddableProps { lens: LensPublicStart; - indexPatterns: DataViewState; + dataViewState: DataViewState; lensFormulaHelper?: FormulaPublicApi; } @@ -70,8 +67,7 @@ export default function Embeddable({ customHeight, customLensAttrs, customTimeRange, - indexPatterns, - isSingleMetric = false, + dataViewState, legendIsVisible, legendPosition, lens, @@ -80,11 +76,9 @@ export default function Embeddable({ reportConfigMap = {}, reportType, showCalculationMethod = false, - singleMetricOptions, title, withActions = true, lensFormulaHelper, - align, hideTicks, }: ExploratoryEmbeddableComponentProps) { const LensComponent = lens?.EmbeddableComponent; @@ -102,7 +96,7 @@ export default function Embeddable({ attributes, reportType, theme, - indexPatterns, + dataViewState, { ...reportConfigMap, ...obsvReportConfigMap } ); @@ -174,59 +168,41 @@ export default function Embeddable({ } return ( - - - {title && ( - - -

{title}

-
-
- )} - {showCalculationMethod && ( - - { - setOperationType(val); - }} - /> - - )} - {appendTitle && appendTitle} -
- - {isSingleMetric && ( - - - - )} - {!isSingleMetric && ( - + + {(title || showCalculationMethod || appendTitle) && ( + + {title && ( + + +

{title}

+
+
+ )} + {showCalculationMethod && ( + + { + setOperationType(val); + }} + /> + + )} + {appendTitle && appendTitle} +
)} + + {isSaveOpen && attributesJSON && ( ` - height: 100%; + height: ${(props) => (props.$customHeight ? `${props.$customHeight};` : `100%;`)}; + position: relative; &&& { > :nth-child(2) { height: ${(props) => props.$customHeight ? `${props.$customHeight};` : `calc(100% - 32px);`}; } - .embPanel--editing { - border-style: initial !important; - :hover { - box-shadow: none; - } - } - .embPanel__title { - display: none; - } - .embPanel__optionsMenuPopover { - visibility: collapse; - } .expExpressionRenderer__expression { - padding-top: 0; + padding: 0 !important; } - &&&:hover { - .embPanel__optionsMenuPopover { - visibility: visible; - } - } - .legacyMtrVis > :first-child { - justify-content: ${(props) => - props.align === 'left' ? `flex-start;` : props.align === 'right' ? `flex-end;` : 'center;'}; + .legacyMtrVis { + justify-content: flex-end; .legacyMtrVis__container { - padding-top: 4px; - padding-left: ${(props) => (props.align === 'left' ? `0` : '16px;')}; - padding-right: ${(props) => (props.align === 'right' ? `0` : '16px;')}; + padding: 0; + } + .legacyMtrVis__value { + line-height: 32px !important; + font-size: 27px !important; } > :first-child { transform: none !important; } } + + .euiLoadingChart { + position: absolute; + top: 50%; + right: 50%; + transform: translate(50%, -50%); + } } `; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/index.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/index.tsx index f74ea50157241..09e17398220d8 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/index.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/index.tsx @@ -5,17 +5,18 @@ * 2.0. */ -import React, { useCallback, useEffect, useState } from 'react'; +import React from 'react'; import { EuiLoadingSpinner } from '@elastic/eui'; import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common'; import type { CoreStart } from '@kbn/core/public'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { EuiErrorBoundary } from '@elastic/eui'; +import styled from 'styled-components'; +import { DataView } from '@kbn/data-views-plugin/common'; +import { FormulaPublicApi } from '@kbn/lens-plugin/public'; +import { useAppDataView } from './use_app_data_view'; import { ObservabilityPublicPluginsStart, useFetcher } from '../../../..'; import type { ExploratoryEmbeddableProps, ExploratoryEmbeddableComponentProps } from './embeddable'; -import { ObservabilityDataViews } from '../../../../utils/observability_data_views'; -import type { DataViewState } from '../hooks/use_app_data_view'; -import type { AppDataType } from '../types'; const Embeddable = React.lazy(() => import('./embeddable')); @@ -30,68 +31,75 @@ function ExploratoryViewEmbeddable(props: ExploratoryEmbeddableComponentProps) { export function getExploratoryViewEmbeddable( services: CoreStart & ObservabilityPublicPluginsStart ) { - const { lens, dataViews, uiSettings } = services; + const { lens, dataViews: dataViewsService, uiSettings } = services; + + const dataViewCache: Record = {}; + + const lenStateHelperPromise: Promise<{ formula: FormulaPublicApi }> | null = null; return (props: ExploratoryEmbeddableProps) => { - if (!dataViews || !lens) { + const { dataTypesIndexPatterns, attributes, customHeight } = props; + + if (!dataViewsService || !lens || !attributes || attributes?.length === 0) { return null; } - const [indexPatterns, setIndexPatterns] = useState({} as DataViewState); - const [loading, setLoading] = useState(false); - - const series = props.attributes && props.attributes[0]; + const series = attributes[0]; const isDarkMode = uiSettings?.get('theme:darkMode'); const { data: lensHelper, loading: lensLoading } = useFetcher(async () => { + if (lenStateHelperPromise) { + return lenStateHelperPromise; + } return lens.stateHelperApi(); }, []); - const loadIndexPattern = useCallback( - async ({ dataType }: { dataType: AppDataType }) => { - const dataTypesIndexPatterns = props.dataTypesIndexPatterns; - - setLoading(true); - try { - const obsvIndexP = new ObservabilityDataViews(dataViews, true); - const indPattern = await obsvIndexP.getDataView( - dataType, - dataTypesIndexPatterns?.[dataType] - ); - setIndexPatterns((prevState) => ({ ...(prevState ?? {}), [dataType]: indPattern })); - - setLoading(false); - } catch (e) { - setLoading(false); - } - }, - [props.dataTypesIndexPatterns] - ); - - useEffect(() => { - if (series?.dataType) { - loadIndexPattern({ dataType: series.dataType }); - } - }, [series?.dataType, loadIndexPattern]); + const { dataViews, loading } = useAppDataView({ + dataViewCache, + dataViewsService, + dataTypesIndexPatterns, + seriesDataType: series?.dataType, + }); - if (Object.keys(indexPatterns).length === 0 || loading || !lensHelper || lensLoading) { - return ; + if (Object.keys(dataViews).length === 0 || loading || !lensHelper || lensLoading) { + return ( + + + + ); } return ( - + + + ); }; } + +const Wrapper = styled.div<{ + customHeight?: string; +}>` + height: ${(props) => (props.customHeight ? `${props.customHeight};` : `100%;`)}; +`; + +const LoadingWrapper = styled.div<{ + customHeight?: string; +}>` + height: ${(props) => (props.customHeight ? `${props.customHeight};` : `100%;`)}; + display: flex; + align-items: center; + justify-content: center; +`; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/single_metric.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/single_metric.test.tsx deleted file mode 100644 index 8767223d69638..0000000000000 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/single_metric.test.tsx +++ /dev/null @@ -1,48 +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 { render } from '@testing-library/react'; -import React from 'react'; -import { SingleMetric } from './single_metric'; - -describe('SingleMetric', () => { - it('renders SingleMetric without icon or postfix', async () => { - const { container } = render(); - expect( - container.querySelector(`[data-test-subj="single-metric-icon"]`) - ).not.toBeInTheDocument(); - expect( - container.querySelector(`[data-test-subj="single-metric"]`)?.style?.maxWidth - ).toEqual(`calc(100%)`); - expect( - container.querySelector(`[data-test-subj="single-metric-postfix"]`) - ).not.toBeInTheDocument(); - }); - - it('renders SingleMetric icon', async () => { - const { container } = render(); - expect( - container.querySelector(`[data-test-subj="single-metric"]`)?.style?.maxWidth - ).toEqual(`calc(100% - 30px)`); - expect(container.querySelector(`[data-test-subj="single-metric-icon"]`)).toBeInTheDocument(); - }); - - it('renders SingleMetric postfix', async () => { - const { container, getByText } = render( - - ); - expect(getByText('Host')).toBeInTheDocument(); - expect( - container.querySelector(`[data-test-subj="single-metric"]`)?.style?.maxWidth - ).toEqual(`calc(100% - 30px - 150px)`); - expect(container.querySelector(`[data-test-subj="single-metric-postfix"]`)).toBeInTheDocument(); - expect( - container.querySelector(`[data-test-subj="single-metric-postfix"]`)?.style - ?.maxWidth - ).toEqual(`150px`); - }); -}); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/single_metric.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/single_metric.tsx deleted file mode 100644 index 3ada5b7875385..0000000000000 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/single_metric.tsx +++ /dev/null @@ -1,130 +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 React from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiTitle, IconType } from '@elastic/eui'; -import styled from 'styled-components'; - -export interface SingleMetricOptions { - alignLnsMetric?: string; - disableBorder?: boolean; - disableShadow?: boolean; - metricIcon?: IconType; - metricIconColor?: string; - metricIconWidth?: string; - metricPostfix?: string; - metricPostfixWidth?: string; -} - -type SingleMetricProps = SingleMetricOptions & { - children?: JSX.Element; -}; - -export function SingleMetric({ - alignLnsMetric = 'flex-start', - children, - disableBorder = true, - disableShadow = true, - metricIcon, - metricIconColor, - metricIconWidth = '30px', - metricPostfix, - metricPostfixWidth = '150px', -}: SingleMetricProps) { - let metricMaxWidth = '100%'; - metricMaxWidth = metricIcon ? `${metricMaxWidth} - ${metricIconWidth}` : metricMaxWidth; - metricMaxWidth = metricPostfix ? `${metricMaxWidth} - ${metricPostfixWidth}` : metricMaxWidth; - - return ( - - {metricIcon && ( - - - - )} - - {children} - - {metricPostfix && ( - - -

{metricPostfix}

-
-
- )} -
- ); -} - -const StyledTitle = styled(EuiTitle)` - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -`; - -const LensWrapper = styled(EuiFlexGroup)<{ - $alignLnsMetric?: string; - $disableBorder?: boolean; - $disableShadow?: boolean; -}>` - .embPanel__optionsMenuPopover { - visibility: collapse; - } - .embPanel--editing { - background-color: transparent; - } - ${(props) => - props.$disableBorder - ? `.embPanel--editing { - border: 0; - }` - : ''} - &&&:hover { - .embPanel__optionsMenuPopover { - visibility: visible; - } - ${(props) => - props.$disableShadow - ? `.embPanel--editing { - box-shadow: none; - }` - : ''} - } - .embPanel__title { - display: none; - } - ${(props) => - props.$alignLnsMetric - ? `.lnsMetricExpression__container { - align-items: ${props.$alignLnsMetric ?? 'flex-start'}; - }` - : ''} -`; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/use_app_data_view.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/use_app_data_view.ts new file mode 100644 index 0000000000000..768a0ac46238c --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/use_app_data_view.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useEffect, useState } from 'react'; +import { DataView } from '@kbn/data-views-plugin/common'; +import { useLocalDataView } from './use_local_data_view'; +import type { ExploratoryEmbeddableProps, ObservabilityPublicPluginsStart } from '../../../..'; +import type { DataViewState } from '../hooks/use_app_data_view'; +import type { AppDataType } from '../types'; +import { ObservabilityDataViews } from '../../../../utils/observability_data_views/observability_data_views'; + +export const useAppDataView = ({ + dataViewCache, + seriesDataType, + dataViewsService, + dataTypesIndexPatterns, +}: { + seriesDataType: AppDataType; + dataViewCache: Record; + dataViewsService: ObservabilityPublicPluginsStart['dataViews']; + dataTypesIndexPatterns: ExploratoryEmbeddableProps['dataTypesIndexPatterns']; +}) => { + const [dataViews, setDataViews] = useState({} as DataViewState); + const [loading, setLoading] = useState(false); + + const { dataViewTitle } = useLocalDataView(seriesDataType, dataTypesIndexPatterns); + + const loadIndexPattern = useCallback( + async ({ dataType }: { dataType: AppDataType }) => { + setLoading(true); + try { + if (dataViewTitle) { + if (dataViewCache[dataViewTitle]) { + setDataViews((prevState) => ({ + ...(prevState ?? {}), + [dataType]: dataViewCache[dataViewTitle], + })); + } else { + const obsvIndexP = new ObservabilityDataViews(dataViewsService, true); + const indPattern = await obsvIndexP.getDataView(dataType, dataViewTitle); + dataViewCache[dataViewTitle] = indPattern!; + setDataViews((prevState) => ({ ...(prevState ?? {}), [dataType]: indPattern })); + } + + setLoading(false); + } + } catch (e) { + setLoading(false); + } + }, + [dataViewCache, dataViewTitle, dataViewsService] + ); + + useEffect(() => { + if (seriesDataType) { + loadIndexPattern({ dataType: seriesDataType }); + } + }, [seriesDataType, loadIndexPattern]); + + return { dataViews, loading }; +}; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/use_local_data_view.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/use_local_data_view.ts new file mode 100644 index 0000000000000..ebd4cc442781a --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/embeddable/use_local_data_view.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useEffect } from 'react'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; +import { getDataTypeIndices } from '../../../../utils/observability_data_views'; +import { AppDataType } from '../types'; +import { ExploratoryEmbeddableProps, useFetcher } from '../../../..'; + +export function useLocalDataView( + seriesDataType: AppDataType, + dataTypesIndexPatterns: ExploratoryEmbeddableProps['dataTypesIndexPatterns'] +) { + const [dataViewTitle, setDataViewTitle] = useLocalStorage( + `${seriesDataType}AppDataViewTitle`, + '' + ); + + const initDatViewTitle = dataTypesIndexPatterns?.[seriesDataType]; + + const { data: updatedDataViewTitle } = useFetcher(async () => { + if (initDatViewTitle) { + return initDatViewTitle; + } + return (await getDataTypeIndices(seriesDataType)).indices; + }, [initDatViewTitle, seriesDataType]); + + useEffect(() => { + if (updatedDataViewTitle) { + setDataViewTitle(updatedDataViewTitle); + } + }, [setDataViewTitle, updatedDataViewTitle]); + + return { dataViewTitle }; +} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/index.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/index.tsx index 82713f152aa4a..ec7fc1e542af0 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/index.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/index.tsx @@ -90,3 +90,4 @@ export function ExploratoryViewPage({ // eslint-disable-next-line import/no-default-export export default ExploratoryViewPage; +export { DataTypes } from './labels'; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/labels.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/labels.ts new file mode 100644 index 0000000000000..e30a90924c627 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/labels.ts @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { i18n } from '@kbn/i18n'; + +export enum DataTypes { + SYNTHETICS = 'synthetics', + UX = 'ux', + MOBILE = 'mobile', + METRICS = 'infra_metrics', + LOGS = 'infra_logs', +} + +export const DataTypesLabels: Record = { + [DataTypes.UX]: i18n.translate('xpack.observability.overview.exploratoryView.uxLabel', { + defaultMessage: 'User experience (RUM)', + }), + + [DataTypes.SYNTHETICS]: i18n.translate( + 'xpack.observability.overview.exploratoryView.syntheticsLabel', + { + defaultMessage: 'Synthetics monitoring', + } + ), + + [DataTypes.METRICS]: i18n.translate('xpack.observability.overview.exploratoryView.metricsLabel', { + defaultMessage: 'Metrics', + }), + + [DataTypes.LOGS]: i18n.translate('xpack.observability.overview.exploratoryView.logsLabel', { + defaultMessage: 'Logs', + }), + + [DataTypes.MOBILE]: i18n.translate( + 'xpack.observability.overview.exploratoryView.mobileExperienceLabel', + { + defaultMessage: 'Mobile experience', + } + ), +}; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/obsv_exploratory_view.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/obsv_exploratory_view.tsx index 77655518ac8c5..31c8187a82c88 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/obsv_exploratory_view.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/obsv_exploratory_view.tsx @@ -6,8 +6,8 @@ */ import * as React from 'react'; -import { i18n } from '@kbn/i18n'; import { EuiErrorBoundary } from '@elastic/eui'; +import { DataTypes, DataTypesLabels } from './labels'; import { getSyntheticsHeatmapConfig } from './configurations/synthetics/heatmap_config'; import { getSyntheticsSingleMetricConfig } from './configurations/synthetics/single_metric_config'; import { ExploratoryViewPage } from '.'; @@ -23,7 +23,6 @@ import { SINGLE_METRIC_LABEL, } from './configurations/constants/labels'; import { SELECT_REPORT_TYPE } from './series_editor/series_editor'; -import { DataTypes } from './configurations/constants'; import { getRumDistributionConfig } from './configurations/rum/data_distribution_config'; import { getKPITrendsLensConfig } from './configurations/rum/kpi_over_time_config'; import { getCoreWebVitalsConfig } from './configurations/rum/core_web_vitals_config'; @@ -36,33 +35,6 @@ import { usePluginContext } from '../../../hooks/use_plugin_context'; import { getLogsKPIConfig } from './configurations/infra_logs/kpi_over_time_config'; import { getSingleMetricConfig } from './configurations/rum/single_metric_config'; -export const DataTypesLabels = { - [DataTypes.UX]: i18n.translate('xpack.observability.overview.exploratoryView.uxLabel', { - defaultMessage: 'User experience (RUM)', - }), - - [DataTypes.SYNTHETICS]: i18n.translate( - 'xpack.observability.overview.exploratoryView.syntheticsLabel', - { - defaultMessage: 'Synthetics monitoring', - } - ), - - [DataTypes.METRICS]: i18n.translate('xpack.observability.overview.exploratoryView.metricsLabel', { - defaultMessage: 'Metrics', - }), - - [DataTypes.LOGS]: i18n.translate('xpack.observability.overview.exploratoryView.logsLabel', { - defaultMessage: 'Logs', - }), - - [DataTypes.MOBILE]: i18n.translate( - 'xpack.observability.overview.exploratoryView.mobileExperienceLabel', - { - defaultMessage: 'Mobile experience', - } - ), -}; export const dataTypes: Array<{ id: AppDataType; label: string }> = [ { id: DataTypes.SYNTHETICS, diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/data_type_select.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/data_type_select.test.tsx index 9856cdd527232..c3457c2938630 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/data_type_select.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/series_editor/columns/data_type_select.test.tsx @@ -9,8 +9,7 @@ import React from 'react'; import { fireEvent, screen } from '@testing-library/react'; import { mockAppDataView, mockUxSeries, render } from '../../rtl_helpers'; import { DataTypesSelect } from './data_type_select'; -import { DataTypes } from '../../configurations/constants'; -import { DataTypesLabels } from '../../obsv_exploratory_view'; +import { DataTypes, DataTypesLabels } from '../../labels'; describe('DataTypeSelect', function () { const seriesId = 0; diff --git a/x-pack/plugins/observability/public/utils/observability_data_views/observability_data_views.test.ts b/x-pack/plugins/observability/public/utils/observability_data_views/observability_data_views.test.ts index fc71b76b27e64..4b9b904b73fc4 100644 --- a/x-pack/plugins/observability/public/utils/observability_data_views/observability_data_views.test.ts +++ b/x-pack/plugins/observability/public/utils/observability_data_views/observability_data_views.test.ts @@ -104,7 +104,8 @@ describe('ObservabilityDataViews', function () { fieldFormats, id: 'rum_static_index_pattern_id_trace_apm_', timeFieldName: '@timestamp', - title: '(rum-data-view)*,trace-*,apm-*', + title: 'trace-*,apm-*', + name: 'User experience (RUM)', }); expect(dataViews?.createAndSave).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/observability/public/utils/observability_data_views/observability_data_views.ts b/x-pack/plugins/observability/public/utils/observability_data_views/observability_data_views.ts index 12bbf802538c0..d43c6f27e97ea 100644 --- a/x-pack/plugins/observability/public/utils/observability_data_views/observability_data_views.ts +++ b/x-pack/plugins/observability/public/utils/observability_data_views/observability_data_views.ts @@ -13,6 +13,7 @@ import type { DataViewSpec, } from '@kbn/data-views-plugin/public'; import { RuntimeField } from '@kbn/data-views-plugin/public'; +import { DataTypesLabels } from '../../components/shared/exploratory_view/labels'; import { syntheticsRuntimeFields } from '../../components/shared/exploratory_view/configurations/synthetics/runtime_fields'; import { getApmDataViewTitle } from '../../components/shared/exploratory_view/utils/utils'; import { rumFieldFormats } from '../../components/shared/exploratory_view/configurations/rum/field_formats'; @@ -57,17 +58,8 @@ export const dataViewList: Record = { mobile: 'mobile_static_index_pattern_id', }; -const appToPatternMap: Record = { - synthetics: '(synthetics-data-view)*', - apm: 'apm-*', - ux: '(rum-data-view)*', - infra_logs: '(infra-logs-data-view)*', - infra_metrics: '(infra-metrics-data-view)*', - mobile: '(mobile-data-view)*', -}; - const getAppIndicesWithPattern = (app: AppDataType, indices: string) => { - return `${appToPatternMap?.[app] ?? app},${indices}`; + return `${indices}`; }; const getAppDataViewId = (app: AppDataType, indices: string) => { @@ -126,6 +118,7 @@ export class ObservabilityDataViews { id: getAppDataViewId(app, indices), timeFieldName: '@timestamp', fieldFormats: this.getFieldFormats(app), + name: DataTypesLabels[app], }); if (runtimeFields !== null) { @@ -140,11 +133,14 @@ export class ObservabilityDataViews { async createAndSavedDataView(app: AppDataType, indices: string) { const appIndicesPattern = getAppIndicesWithPattern(app, indices); + const dataViewId = getAppDataViewId(app, indices); + return await this.dataViews.createAndSave({ title: appIndicesPattern, - id: getAppDataViewId(app, indices), + id: dataViewId, timeFieldName: '@timestamp', fieldFormats: this.getFieldFormats(app), + name: DataTypesLabels[app], }); } // we want to make sure field formats remain same @@ -163,12 +159,17 @@ export class ObservabilityDataViews { } } }); + let hasNewRuntimeField = false; if (runtimeFields !== null) { + const allRunTimeFields = dataView.getAllRuntimeFields(); runtimeFields.forEach(({ name, field }) => { - dataView.addRuntimeField(name, field); + if (!allRunTimeFields[name]) { + hasNewRuntimeField = true; + dataView.addRuntimeField(name, field); + } }); } - if (isParamsDifferent || runtimeFields !== null) { + if (isParamsDifferent || hasNewRuntimeField) { await this.dataViews?.updateSavedObject(dataView); } } diff --git a/x-pack/plugins/synthetics/e2e/journeys/synthetics/index.ts b/x-pack/plugins/synthetics/e2e/journeys/synthetics/index.ts index 5affc2796c28a..f62cbce7c37e0 100644 --- a/x-pack/plugins/synthetics/e2e/journeys/synthetics/index.ts +++ b/x-pack/plugins/synthetics/e2e/journeys/synthetics/index.ts @@ -6,7 +6,9 @@ */ export * from './getting_started.journey'; -export * from './add_monitor.journey'; +// TODO: Fix this test +// export * from './add_monitor.journey'; export * from './monitor_selector.journey'; export * from './overview_sorting.journey'; -export * from './overview_scrolling.journey'; +// TODO: Fix this test +// export * from './overview_scrolling.journey'; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/pages/synthetics_page_template.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/pages/synthetics_page_template.tsx index dcd139727a138..8558770193ae2 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/common/pages/synthetics_page_template.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/common/pages/synthetics_page_template.tsx @@ -10,10 +10,10 @@ import styled from 'styled-components'; import { EuiPageHeaderProps, EuiPageTemplateProps, useIsWithinMaxBreakpoint } from '@elastic/eui'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { useInspectorContext } from '@kbn/observability-plugin/public'; +import { useSyntheticsDataView } from '../../../contexts'; import { ClientPluginsStart } from '../../../../../plugin'; import { EmptyStateLoading } from '../../monitors_page/overview/empty_state/empty_state_loading'; import { EmptyStateError } from '../../monitors_page/overview/empty_state/empty_state_error'; -import { useHasData } from '../../monitors_page/overview/empty_state/use_has_data'; interface Props { path: string; @@ -48,7 +48,7 @@ export const SyntheticsPageTemplateComponent: React.FC { @@ -59,7 +59,7 @@ export const SyntheticsPageTemplateComponent: React.FC; } - const showLoading = loading && !data; + const showLoading = loading && !hasData; return ( <> diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_errors/failed_tests_count.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_errors/failed_tests_count.tsx index 3316dbd404e57..c04c959d077db 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_errors/failed_tests_count.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_errors/failed_tests_count.tsx @@ -8,7 +8,6 @@ import { useKibana } from '@kbn/kibana-react-plugin/public'; import React from 'react'; import { ClientPluginsStart } from '../../../../../plugin'; -import { KpiWrapper } from '../monitor_summary/kpi_wrapper'; import { useMonitorQueryId } from '../hooks/use_monitor_query_id'; export const FailedTestsCount = (time: { to: string; from: string }) => { @@ -19,21 +18,19 @@ export const FailedTestsCount = (time: { to: string; from: string }) => { const monitorId = useMonitorQueryId(); return ( - - - + dataType: 'synthetics', + selectedMetricField: 'monitor_failed_tests', + name: 'synthetics-series-1', + }, + ]} + /> ); }; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/availability_panel.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/availability_panel.tsx index eaab0dfb92eff..14fe439556ca2 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/availability_panel.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/availability_panel.tsx @@ -9,8 +9,6 @@ import React from 'react'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { ReportTypes } from '@kbn/observability-plugin/public'; import { ClientPluginsStart } from '../../../../../plugin'; - -import { KpiWrapper } from './kpi_wrapper'; import { useMonitorQueryId } from '../hooks/use_monitor_query_id'; interface AvailabilityPanelprops { @@ -28,20 +26,18 @@ export const AvailabilityPanel = (props: AvailabilityPanelprops) => { const monitorId = useMonitorQueryId(); return ( - - - + ); }; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/availability_sparklines.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/availability_sparklines.tsx index 7cfc59d21f768..8691b0d176613 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/availability_sparklines.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/availability_sparklines.tsx @@ -27,24 +27,23 @@ export const AvailabilitySparklines = (props: AvailabilitySparklinesProps) => { const theme = useTheme(); return ( - <> - - + ); }; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_panel.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_panel.tsx index e340452791fd3..6a5d3c813ffee 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_panel.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_panel.tsx @@ -10,7 +10,6 @@ import { useKibana } from '@kbn/kibana-react-plugin/public'; import { ReportTypes } from '@kbn/observability-plugin/public'; import { ClientPluginsStart } from '../../../../../plugin'; -import { KpiWrapper } from './kpi_wrapper'; import { useMonitorQueryId } from '../hooks/use_monitor_query_id'; interface DurationPanelProps { @@ -27,19 +26,18 @@ export const DurationPanel = (props: DurationPanelProps) => { const monitorId = useMonitorQueryId(); return ( - - - + ); }; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_trend.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_trend.tsx index de2e4489d82f8..cd26118009e56 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_trend.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_trend.tsx @@ -26,7 +26,7 @@ export const MonitorDurationTrend = (props: MonitorDurationTrendProps) => { return ( ({ dataType: 'synthetics', diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/kpi_wrapper.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/kpi_wrapper.tsx deleted file mode 100644 index 82a3eb3c6b7d8..0000000000000 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/kpi_wrapper.tsx +++ /dev/null @@ -1,29 +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 React from 'react'; -import { css } from '@emotion/react'; -import { useEuiTheme } from '@elastic/eui'; - -export const KpiWrapper: React.FC> = ({ children }) => { - const { euiTheme } = useEuiTheme(); - - const wrapperStyle = css` - border: none; - & > span.euiLoadingSpinner { - margin: ${euiTheme.size.s}; - } - - .legacyMtrVis__container > div { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - `; - - return
{children}
; -}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/monitor_errors_count.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/monitor_errors_count.tsx index 850aec1a9d4a1..c4740a74f889f 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/monitor_errors_count.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/monitor_errors_count.tsx @@ -8,7 +8,6 @@ import { useKibana } from '@kbn/kibana-react-plugin/public'; import React from 'react'; import { ReportTypes } from '@kbn/observability-plugin/public'; -import { KpiWrapper } from './kpi_wrapper'; import { ClientPluginsStart } from '../../../../../plugin'; import { useMonitorQueryId } from '../hooks/use_monitor_query_id'; @@ -25,20 +24,18 @@ export const MonitorErrorsCount = (props: MonitorErrorsCountProps) => { const monitorId = useMonitorQueryId(); return ( - - - + ); }; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/monitor_summary.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/monitor_summary.tsx index 03ccc403d0b30..f58ff821198ff 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/monitor_summary.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/monitor_summary.tsx @@ -53,7 +53,7 @@ export const MonitorSummary = () => { - + diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/contexts/synthetics_data_view_context.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/contexts/synthetics_data_view_context.tsx index 4ddd22a23cbdb..a4005366e306a 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/contexts/synthetics_data_view_context.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/contexts/synthetics_data_view_context.tsx @@ -5,28 +5,52 @@ * 2.0. */ -import React, { createContext, useContext } from 'react'; +import React, { createContext, useContext, useEffect, useMemo } from 'react'; import { useFetcher } from '@kbn/observability-plugin/public'; import { DataViewsPublicPluginStart, DataView } from '@kbn/data-views-plugin/public'; -import { useHasData } from '../components/monitors_page/overview/empty_state/use_has_data'; +import { useDispatch, useSelector } from 'react-redux'; +import { IHttpSerializedFetchError } from '../state/utils/http_error'; +import { getIndexStatus, selectIndexState } from '../state'; -export const SyntheticsDataViewContext = createContext({} as DataView); +export const SyntheticsDataViewContext = createContext( + {} as { + dataView?: DataView; + loading?: boolean; + indices?: string; + error?: IHttpSerializedFetchError | null; + hasData?: boolean; + } +); export const SyntheticsDataViewContextProvider: React.FC<{ dataViews: DataViewsPublicPluginStart; }> = ({ children, dataViews }) => { - const { settings, data: indexStatus } = useHasData(); + const { loading: hasDataLoading, error, data: indexStatus } = useSelector(selectIndexState); - const heartbeatIndices = settings?.heartbeatIndices || ''; + const heartbeatIndices = indexStatus?.indices || ''; - const { data } = useFetcher>(async () => { + const dispatch = useDispatch(); + + const hasData = Boolean(indexStatus?.indexExists); + + useEffect(() => { + dispatch(getIndexStatus()); + }, [dispatch]); + + const { data, loading } = useFetcher>(async () => { if (heartbeatIndices && indexStatus?.indexExists) { // this only creates an dateView in memory, not as saved object return dataViews.create({ title: heartbeatIndices }); } }, [heartbeatIndices, indexStatus?.indexExists]); - return ; + const isLoading = loading || hasDataLoading; + + const value = useMemo(() => { + return { dataView: data, indices: heartbeatIndices, isLoading, error, hasData }; + }, [data, heartbeatIndices, isLoading, error, hasData]); + + return ; }; export const useSyntheticsDataView = () => useContext(SyntheticsDataViewContext); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/synthetics_app.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/synthetics_app.tsx index 923a0b9bb16f5..95a2de56636e9 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/synthetics_app.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/synthetics_app.tsx @@ -17,7 +17,7 @@ import { } from '@kbn/kibana-react-plugin/public'; import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common'; import { InspectorContextProvider } from '@kbn/observability-plugin/public'; -import { SyntheticsAppProps } from './contexts'; +import { SyntheticsAppProps, SyntheticsDataViewContextProvider } from './contexts'; import { SyntheticsRefreshContextProvider, @@ -98,21 +98,23 @@ const Application = (props: SyntheticsAppProps) => { - - -
- - - - - - -
-
-
+ + + +
+ + + + + + +
+
+
+
diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/state/api/index_status.ts b/x-pack/plugins/synthetics/public/legacy_uptime/state/api/index_status.ts index 857915deb9023..21bd05635e0d7 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/state/api/index_status.ts +++ b/x-pack/plugins/synthetics/public/legacy_uptime/state/api/index_status.ts @@ -9,6 +9,20 @@ import { API_URLS } from '../../../../common/constants'; import { StatesIndexStatus, StatesIndexStatusType } from '../../../../common/runtime_types'; import { apiService } from './utils'; +let indexStatusPromise: Promise<{ indexExists: boolean; indices: string }> | null = null; + export const fetchIndexStatus = async (): Promise => { - return await apiService.get(API_URLS.INDEX_STATUS, undefined, StatesIndexStatusType); + if (indexStatusPromise) { + return indexStatusPromise; + } + indexStatusPromise = apiService.get(API_URLS.INDEX_STATUS, undefined, StatesIndexStatusType); + indexStatusPromise.then( + () => { + indexStatusPromise = null; + }, + () => { + indexStatusPromise = null; + } + ); + return indexStatusPromise; }; diff --git a/x-pack/plugins/ux/public/components/app/rum_dashboard/charts/page_load_dist_chart.tsx b/x-pack/plugins/ux/public/components/app/rum_dashboard/charts/page_load_dist_chart.tsx index 27f201b99ed8b..390e2f21ae5cb 100644 --- a/x-pack/plugins/ux/public/components/app/rum_dashboard/charts/page_load_dist_chart.tsx +++ b/x-pack/plugins/ux/public/components/app/rum_dashboard/charts/page_load_dist_chart.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useCallback } from 'react'; import { AllSeries } from '@kbn/observability-plugin/public'; import { getExploratoryViewFilter } from '../../../../services/data/get_exp_view_filter'; import { useExpViewAttributes } from './use_exp_view_attrs'; @@ -28,13 +28,16 @@ export function PageLoadDistChart({ onPercentileChange, breakdown }: Props) { const kibana = useKibanaServices(); const { ExploratoryViewEmbeddable } = kibana.observability!; - const onBrushEnd = ({ range }: { range: number[] }) => { - if (!range) { - return; - } - const [minX, maxX] = range; - onPercentileChange(minX, maxX); - }; + const onBrushEnd = useCallback( + ({ range }: { range: number[] }) => { + if (!range) { + return; + } + const [minX, maxX] = range; + onPercentileChange(minX, maxX); + }, + [onPercentileChange] + ); const { reportDefinitions, time } = useExpViewAttributes(); diff --git a/x-pack/plugins/ux/public/components/app/rum_dashboard/charts/page_views_chart.tsx b/x-pack/plugins/ux/public/components/app/rum_dashboard/charts/page_views_chart.tsx index db3a865b9da1f..e96b99c4066cf 100644 --- a/x-pack/plugins/ux/public/components/app/rum_dashboard/charts/page_views_chart.tsx +++ b/x-pack/plugins/ux/public/components/app/rum_dashboard/charts/page_views_chart.tsx @@ -6,7 +6,7 @@ */ import moment from 'moment'; -import React from 'react'; +import React, { useCallback } from 'react'; import { AllSeries, fromQuery, @@ -51,24 +51,27 @@ export function PageViewsChart({ breakdown }: Props) { filters: getExploratoryViewFilter(uxUiFilters, urlParams), }, ]; - const onBrushEnd = ({ range }: { range: number[] }) => { - if (!range) { - return; - } - const [minX, maxX] = range; + const onBrushEnd = useCallback( + ({ range }: { range: number[] }) => { + if (!range) { + return; + } + const [minX, maxX] = range; - const rangeFrom = moment(minX).toISOString(); - const rangeTo = moment(maxX).toISOString(); + const rangeFrom = moment(minX).toISOString(); + const rangeTo = moment(maxX).toISOString(); - history.push({ - ...history.location, - search: fromQuery({ - ...toQuery(history.location.search), - rangeFrom, - rangeTo, - }), - }); - }; + history.push({ + ...history.location, + search: fromQuery({ + ...toQuery(history.location.search), + rangeFrom, + rangeTo, + }), + }); + }, + [history] + ); if (!dataViewTitle) { return null; @@ -76,11 +79,10 @@ export function PageViewsChart({ breakdown }: Props) { return ( (null); - const onPercentileChange = (min: number, max: number) => { + const onPercentileChange = useCallback((min: number, max: number) => { setPercentileRange({ min, max }); - }; + }, []); return (
From c78b7fad9bf59d1942e66e44a6b5b37cf4436884 Mon Sep 17 00:00:00 2001 From: Oleg Sucharevich Date: Mon, 7 Nov 2022 17:30:37 +0200 Subject: [PATCH 004/192] [Cloud Posture] feat: enable auto update (#144664) --- .../migrations/check_registered_types.test.ts | 2 +- x-pack/plugins/fleet/common/constants/epm.ts | 7 ++++++- x-pack/plugins/fleet/server/saved_objects/index.ts | 3 ++- .../server/saved_objects/migrations/to_v8_6_0.ts | 12 ++++++++++++ 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/core/server/integration_tests/saved_objects/migrations/check_registered_types.test.ts b/src/core/server/integration_tests/saved_objects/migrations/check_registered_types.test.ts index 3026e2eec4d83..0252a6be82215 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/check_registered_types.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/check_registered_types.test.ts @@ -80,7 +80,7 @@ describe('checking migration metadata changes on all registered SO types', () => "endpoint:user-artifact": "f94c250a52b30d0a2d32635f8b4c5bdabd1e25c0", "endpoint:user-artifact-manifest": "8c14d49a385d5d1307d956aa743ec78de0b2be88", "enterprise_search_telemetry": "fafcc8318528d34f721c42d1270787c52565bad5", - "epm-packages": "c4c39f20d6bcfff40994813ee0f2bab01d34b646", + "epm-packages": "cb22b422398a785e7e0565a19c6d4d5c7af6f2fd", "epm-packages-assets": "9fd3d6726ac77369249e9a973902c2cd615fc771", "event_loop_delays_daily": "d2ed39cf669577d90921c176499908b4943fb7bd", "exception-list": "fe8cc004fd2742177cdb9300f4a67689463faf9c", diff --git a/x-pack/plugins/fleet/common/constants/epm.ts b/x-pack/plugins/fleet/common/constants/epm.ts index f12cd9585851b..08efe1f717b44 100644 --- a/x-pack/plugins/fleet/common/constants/epm.ts +++ b/x-pack/plugins/fleet/common/constants/epm.ts @@ -41,6 +41,7 @@ export const autoUpdatePackages = [ FLEET_ENDPOINT_PACKAGE, FLEET_APM_PACKAGE, FLEET_SYNTHETICS_PACKAGE, + FLEET_CLOUD_SECURITY_POSTURE_PACKAGE, ]; export const HIDDEN_API_REFERENCE_PACKAGES = [ @@ -49,7 +50,11 @@ export const HIDDEN_API_REFERENCE_PACKAGES = [ FLEET_SYNTHETICS_PACKAGE, ]; -export const autoUpgradePoliciesPackages = [FLEET_APM_PACKAGE, FLEET_SYNTHETICS_PACKAGE]; +export const autoUpgradePoliciesPackages = [ + FLEET_APM_PACKAGE, + FLEET_SYNTHETICS_PACKAGE, + FLEET_CLOUD_SECURITY_POSTURE_PACKAGE, +]; export const agentAssetTypes = { Input: 'input', diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts index b0fbe456246c6..e38e6c966c11d 100644 --- a/x-pack/plugins/fleet/server/saved_objects/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/index.ts @@ -47,7 +47,7 @@ import { migratePackagePolicyToV840, } from './migrations/to_v8_4_0'; import { migratePackagePolicyToV850, migrateAgentPolicyToV850 } from './migrations/to_v8_5_0'; -import { migrateSettingsToV860 } from './migrations/to_v8_6_0'; +import { migrateSettingsToV860, migrateInstallationToV860 } from './migrations/to_v8_6_0'; /* * Saved object types and mappings @@ -297,6 +297,7 @@ const getSavedObjectTypes = ( '8.0.0': migrateInstallationToV800, '8.3.0': migrateInstallationToV830, '8.4.0': migrateInstallationToV840, + '8.6.0': migrateInstallationToV860, }, }, [ASSETS_SAVED_OBJECT_TYPE]: { diff --git a/x-pack/plugins/fleet/server/saved_objects/migrations/to_v8_6_0.ts b/x-pack/plugins/fleet/server/saved_objects/migrations/to_v8_6_0.ts index e43406d859600..019c9d6c947da 100644 --- a/x-pack/plugins/fleet/server/saved_objects/migrations/to_v8_6_0.ts +++ b/x-pack/plugins/fleet/server/saved_objects/migrations/to_v8_6_0.ts @@ -9,6 +9,9 @@ import type { SavedObjectMigrationFn } from '@kbn/core/server'; import type { Settings } from '../../../common/types'; +import type { Installation } from '../../../common'; +import { FLEET_CLOUD_SECURITY_POSTURE_PACKAGE } from '../../../common/constants'; + export const migrateSettingsToV860: SavedObjectMigrationFn = ( settingsDoc, migrationContext @@ -20,3 +23,12 @@ export const migrateSettingsToV860: SavedObjectMigrationFn = return settingsDoc; }; + +export const migrateInstallationToV860: SavedObjectMigrationFn = ( + installationDoc +) => { + if (installationDoc.attributes.name === FLEET_CLOUD_SECURITY_POSTURE_PACKAGE) { + installationDoc.attributes.keep_policies_up_to_date = true; + } + return installationDoc; +}; From ad8e48f87c2f404355e4ee0b1089ae7a10e88881 Mon Sep 17 00:00:00 2001 From: Ersin Erdal <92688503+ersin-erdal@users.noreply.github.com> Date: Mon, 7 Nov 2022 16:49:31 +0100 Subject: [PATCH 005/192] Change Alerts > Actions execution order (#143577) * Change Alerts > Actions execution order --- .../create_execution_handler.test.ts | 595 -------------- .../task_runner/create_execution_handler.ts | 215 ----- .../task_runner/execution_handler.test.ts | 732 ++++++++++++++++++ .../server/task_runner/execution_handler.ts | 410 ++++++++++ .../schedule_actions_for_alerts.test.ts | 207 ----- .../schedule_actions_for_alerts.ts | 134 ---- .../server/task_runner/task_runner.test.ts | 5 +- .../server/task_runner/task_runner.ts | 120 +-- .../alerting/server/task_runner/types.ts | 70 +- 9 files changed, 1192 insertions(+), 1296 deletions(-) delete mode 100644 x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts delete mode 100644 x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts create mode 100644 x-pack/plugins/alerting/server/task_runner/execution_handler.test.ts create mode 100644 x-pack/plugins/alerting/server/task_runner/execution_handler.ts delete mode 100644 x-pack/plugins/alerting/server/task_runner/schedule_actions_for_alerts.test.ts delete mode 100644 x-pack/plugins/alerting/server/task_runner/schedule_actions_for_alerts.ts diff --git a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts deleted file mode 100644 index 716f0fefef3e4..0000000000000 --- a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.test.ts +++ /dev/null @@ -1,595 +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 { createExecutionHandler } from './create_execution_handler'; -import { CreateExecutionHandlerOptions } from './types'; -import { loggingSystemMock } from '@kbn/core/server/mocks'; -import { - actionsClientMock, - actionsMock, - renderActionParameterTemplatesDefault, -} from '@kbn/actions-plugin/server/mocks'; -import { KibanaRequest } from '@kbn/core/server'; -import { asSavedObjectExecutionSource } from '@kbn/actions-plugin/server'; -import { InjectActionParamsOpts } from './inject_action_params'; -import { NormalizedRuleType } from '../rule_type_registry'; -import { - ActionsCompletion, - AlertInstanceContext, - AlertInstanceState, - RuleTypeParams, - RuleTypeState, -} from '../types'; -import { RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; -import { alertingEventLoggerMock } from '../lib/alerting_event_logger/alerting_event_logger.mock'; - -jest.mock('./inject_action_params', () => ({ - injectActionParams: jest.fn(), -})); - -const alertingEventLogger = alertingEventLoggerMock.create(); - -const ruleType: NormalizedRuleType< - RuleTypeParams, - RuleTypeParams, - RuleTypeState, - AlertInstanceState, - AlertInstanceContext, - 'default' | 'other-group', - 'recovered' -> = { - id: 'test', - name: 'Test', - actionGroups: [ - { id: 'default', name: 'Default' }, - { id: 'other-group', name: 'Other Group' }, - ], - defaultActionGroupId: 'default', - minimumLicenseRequired: 'basic', - isExportable: true, - recoveryActionGroup: { - id: 'recovered', - name: 'Recovered', - }, - executor: jest.fn(), - producer: 'alerts', -}; - -const actionsClient = actionsClientMock.create(); - -const mockActionsPlugin = actionsMock.createStart(); -const createExecutionHandlerParams: jest.Mocked< - CreateExecutionHandlerOptions< - RuleTypeParams, - RuleTypeParams, - RuleTypeState, - AlertInstanceState, - AlertInstanceContext, - 'default' | 'other-group', - 'recovered' - > -> = { - actionsPlugin: mockActionsPlugin, - spaceId: 'test1', - ruleId: '1', - ruleName: 'name-of-alert', - ruleConsumer: 'rule-consumer', - executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - tags: ['tag-A', 'tag-B'], - apiKey: 'MTIzOmFiYw==', - kibanaBaseUrl: 'http://localhost:5601', - ruleType, - logger: loggingSystemMock.create().get(), - alertingEventLogger, - actions: [ - { - id: '1', - group: 'default', - actionTypeId: 'test', - params: { - foo: true, - contextVal: 'My {{context.value}} goes here', - stateVal: 'My {{state.value}} goes here', - alertVal: 'My {{alertId}} {{alertName}} {{spaceId}} {{tags}} {{alertInstanceId}} goes here', - }, - }, - ], - request: {} as KibanaRequest, - ruleParams: { - foo: true, - contextVal: 'My other {{context.value}} goes here', - stateVal: 'My other {{state.value}} goes here', - }, - supportsEphemeralTasks: false, - maxEphemeralActionsPerRule: 10, - actionsConfigMap: { - default: { - max: 1000, - }, - }, -}; -let ruleRunMetricsStore: RuleRunMetricsStore; - -describe('Create Execution Handler', () => { - beforeEach(() => { - jest.resetAllMocks(); - jest - .requireMock('./inject_action_params') - .injectActionParams.mockImplementation( - ({ actionParams }: InjectActionParamsOpts) => actionParams - ); - mockActionsPlugin.isActionTypeEnabled.mockReturnValue(true); - mockActionsPlugin.isActionExecutable.mockReturnValue(true); - mockActionsPlugin.getActionsClientWithRequest.mockResolvedValue(actionsClient); - mockActionsPlugin.renderActionParameterTemplates.mockImplementation( - renderActionParameterTemplatesDefault - ); - ruleRunMetricsStore = new RuleRunMetricsStore(); - }); - - test('enqueues execution per selected action', async () => { - const executionHandler = createExecutionHandler(createExecutionHandlerParams); - await executionHandler({ - actionGroup: 'default', - state: {}, - context: {}, - alertId: '2', - ruleRunMetricsStore, - }); - expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(1); - expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(1); - expect(mockActionsPlugin.getActionsClientWithRequest).toHaveBeenCalledWith( - createExecutionHandlerParams.request - ); - expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); - expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "apiKey": "MTIzOmFiYw==", - "consumer": "rule-consumer", - "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", - "id": "1", - "params": Object { - "alertVal": "My 1 name-of-alert test1 tag-A,tag-B 2 goes here", - "contextVal": "My goes here", - "foo": true, - "stateVal": "My goes here", - }, - "relatedSavedObjects": Array [ - Object { - "id": "1", - "namespace": "test1", - "type": "alert", - "typeId": "test", - }, - ], - "source": Object { - "source": Object { - "id": "1", - "type": "alert", - }, - "type": "SAVED_OBJECT", - }, - "spaceId": "test1", - }, - ], - ] - `); - - expect(alertingEventLogger.logAction).toHaveBeenCalledTimes(1); - expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith(1, { - id: '1', - typeId: 'test', - alertId: '2', - alertGroup: 'default', - }); - - expect(jest.requireMock('./inject_action_params').injectActionParams).toHaveBeenCalledWith({ - ruleId: '1', - spaceId: 'test1', - actionTypeId: 'test', - actionParams: { - alertVal: 'My 1 name-of-alert test1 tag-A,tag-B 2 goes here', - contextVal: 'My goes here', - foo: true, - stateVal: 'My goes here', - }, - }); - - expect(ruleRunMetricsStore.getTriggeredActionsStatus()).toBe(ActionsCompletion.COMPLETE); - }); - - test(`doesn't call actionsPlugin.execute for disabled actionTypes`, async () => { - // Mock two calls, one for check against actions[0] and the second for actions[1] - mockActionsPlugin.isActionExecutable.mockReturnValueOnce(false); - mockActionsPlugin.isActionTypeEnabled.mockReturnValueOnce(false); - mockActionsPlugin.isActionTypeEnabled.mockReturnValueOnce(true); - const executionHandler = createExecutionHandler({ - ...createExecutionHandlerParams, - actions: [ - { - id: '2', - group: 'default', - actionTypeId: 'test2', - params: { - foo: true, - contextVal: 'My other {{context.value}} goes here', - stateVal: 'My other {{state.value}} goes here', - }, - }, - { - id: '2', - group: 'default', - actionTypeId: 'test2', - params: { - foo: true, - contextVal: 'My other {{context.value}} goes here', - stateVal: 'My other {{state.value}} goes here', - }, - }, - ], - }); - await executionHandler({ - actionGroup: 'default', - state: {}, - context: {}, - alertId: '2', - ruleRunMetricsStore, - }); - expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(1); - expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(2); - expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); - expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledWith([ - { - consumer: 'rule-consumer', - id: '2', - params: { - foo: true, - contextVal: 'My other goes here', - stateVal: 'My other goes here', - }, - source: asSavedObjectExecutionSource({ - id: '1', - type: 'alert', - }), - relatedSavedObjects: [ - { - id: '1', - namespace: 'test1', - type: 'alert', - typeId: 'test', - }, - ], - spaceId: 'test1', - apiKey: createExecutionHandlerParams.apiKey, - executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - }, - ]); - }); - - test('trow error error message when action type is disabled', async () => { - mockActionsPlugin.preconfiguredActions = []; - mockActionsPlugin.isActionExecutable.mockReturnValue(false); - mockActionsPlugin.isActionTypeEnabled.mockReturnValue(false); - const executionHandler = createExecutionHandler({ - ...createExecutionHandlerParams, - actions: [ - { - id: '1', - group: 'default', - actionTypeId: '.slack', - params: { - foo: true, - }, - }, - { - id: '2', - group: 'default', - actionTypeId: '.slack', - params: { - foo: true, - contextVal: 'My other {{context.value}} goes here', - stateVal: 'My other {{state.value}} goes here', - }, - }, - ], - }); - - await executionHandler({ - actionGroup: 'default', - state: {}, - context: {}, - alertId: '2', - ruleRunMetricsStore, - }); - expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(0); - expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(2); - expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(0); - - mockActionsPlugin.isActionExecutable.mockImplementation(() => true); - const executionHandlerForPreconfiguredAction = createExecutionHandler({ - ...createExecutionHandlerParams, - actions: [...createExecutionHandlerParams.actions], - }); - await executionHandlerForPreconfiguredAction({ - actionGroup: 'default', - state: {}, - context: {}, - alertId: '2', - ruleRunMetricsStore, - }); - expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); - }); - - test('limits actionsPlugin.execute per action group', async () => { - const executionHandler = createExecutionHandler(createExecutionHandlerParams); - await executionHandler({ - actionGroup: 'other-group', - state: {}, - context: {}, - alertId: '2', - ruleRunMetricsStore, - }); - expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(0); - expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(0); - expect(actionsClient.bulkEnqueueExecution).not.toHaveBeenCalled(); - }); - - test('context attribute gets parameterized', async () => { - const executionHandler = createExecutionHandler(createExecutionHandlerParams); - await executionHandler({ - actionGroup: 'default', - context: { value: 'context-val' }, - state: {}, - alertId: '2', - ruleRunMetricsStore, - }); - expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(1); - expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(1); - expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); - expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "apiKey": "MTIzOmFiYw==", - "consumer": "rule-consumer", - "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", - "id": "1", - "params": Object { - "alertVal": "My 1 name-of-alert test1 tag-A,tag-B 2 goes here", - "contextVal": "My context-val goes here", - "foo": true, - "stateVal": "My goes here", - }, - "relatedSavedObjects": Array [ - Object { - "id": "1", - "namespace": "test1", - "type": "alert", - "typeId": "test", - }, - ], - "source": Object { - "source": Object { - "id": "1", - "type": "alert", - }, - "type": "SAVED_OBJECT", - }, - "spaceId": "test1", - }, - ], - ] - `); - }); - - test('state attribute gets parameterized', async () => { - const executionHandler = createExecutionHandler(createExecutionHandlerParams); - await executionHandler({ - actionGroup: 'default', - context: {}, - state: { value: 'state-val' }, - alertId: '2', - ruleRunMetricsStore, - }); - expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); - expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "apiKey": "MTIzOmFiYw==", - "consumer": "rule-consumer", - "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", - "id": "1", - "params": Object { - "alertVal": "My 1 name-of-alert test1 tag-A,tag-B 2 goes here", - "contextVal": "My goes here", - "foo": true, - "stateVal": "My state-val goes here", - }, - "relatedSavedObjects": Array [ - Object { - "id": "1", - "namespace": "test1", - "type": "alert", - "typeId": "test", - }, - ], - "source": Object { - "source": Object { - "id": "1", - "type": "alert", - }, - "type": "SAVED_OBJECT", - }, - "spaceId": "test1", - }, - ], - ] - `); - }); - - test(`logs an error when action group isn't part of actionGroups available for the ruleType`, async () => { - const executionHandler = createExecutionHandler(createExecutionHandlerParams); - await executionHandler({ - // we have to trick the compiler as this is an invalid type and this test checks whether we - // enforce this at runtime as well as compile time - actionGroup: 'invalid-group' as 'default' | 'other-group', - context: {}, - state: {}, - alertId: '2', - ruleRunMetricsStore, - }); - expect(createExecutionHandlerParams.logger.error).toHaveBeenCalledWith( - 'Invalid action group "invalid-group" for rule "test".' - ); - - expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(0); - expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(0); - expect(ruleRunMetricsStore.getTriggeredActionsStatus()).toBe(ActionsCompletion.COMPLETE); - }); - - test('Stops triggering actions when the number of total triggered actions is reached the number of max executable actions', async () => { - const executionHandler = createExecutionHandler({ - ...createExecutionHandlerParams, - actionsConfigMap: { - default: { - max: 2, - }, - }, - actions: [ - { - id: '1', - group: 'default', - actionTypeId: 'test2', - params: { - foo: true, - contextVal: 'My other {{context.value}} goes here', - stateVal: 'My other {{state.value}} goes here', - }, - }, - { - id: '2', - group: 'default', - actionTypeId: 'test2', - params: { - foo: true, - contextVal: 'My other {{context.value}} goes here', - stateVal: 'My other {{state.value}} goes here', - }, - }, - { - id: '3', - group: 'default', - actionTypeId: 'test3', - params: { - foo: true, - contextVal: '{{context.value}} goes here', - stateVal: '{{state.value}} goes here', - }, - }, - ], - }); - - ruleRunMetricsStore = new RuleRunMetricsStore(); - - await executionHandler({ - actionGroup: 'default', - context: {}, - state: { value: 'state-val' }, - alertId: '2', - ruleRunMetricsStore, - }); - - expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(2); - expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(3); - expect(ruleRunMetricsStore.getTriggeredActionsStatus()).toBe(ActionsCompletion.PARTIAL); - expect(createExecutionHandlerParams.logger.debug).toHaveBeenCalledTimes(1); - expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); - }); - - test('Skips triggering actions for a specific action type when it reaches the limit for that specific action type', async () => { - const executionHandler = createExecutionHandler({ - ...createExecutionHandlerParams, - actionsConfigMap: { - default: { - max: 4, - }, - 'test-action-type-id': { - max: 1, - }, - }, - actions: [ - ...createExecutionHandlerParams.actions, - { - id: '2', - group: 'default', - actionTypeId: 'test-action-type-id', - params: { - foo: true, - contextVal: 'My other {{context.value}} goes here', - stateVal: 'My other {{state.value}} goes here', - }, - }, - { - id: '3', - group: 'default', - actionTypeId: 'test-action-type-id', - params: { - foo: true, - contextVal: '{{context.value}} goes here', - stateVal: '{{state.value}} goes here', - }, - }, - { - id: '4', - group: 'default', - actionTypeId: 'another-action-type-id', - params: { - foo: true, - contextVal: '{{context.value}} goes here', - stateVal: '{{state.value}} goes here', - }, - }, - { - id: '5', - group: 'default', - actionTypeId: 'another-action-type-id', - params: { - foo: true, - contextVal: '{{context.value}} goes here', - stateVal: '{{state.value}} goes here', - }, - }, - ], - }); - - ruleRunMetricsStore = new RuleRunMetricsStore(); - - await executionHandler({ - actionGroup: 'default', - context: {}, - state: { value: 'state-val' }, - alertId: '2', - ruleRunMetricsStore, - }); - - expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(4); - expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(5); - expect(ruleRunMetricsStore.getStatusByConnectorType('test').numberOfTriggeredActions).toBe(1); - expect( - ruleRunMetricsStore.getStatusByConnectorType('test-action-type-id').numberOfTriggeredActions - ).toBe(1); - expect( - ruleRunMetricsStore.getStatusByConnectorType('another-action-type-id') - .numberOfTriggeredActions - ).toBe(2); - expect(ruleRunMetricsStore.getTriggeredActionsStatus()).toBe(ActionsCompletion.PARTIAL); - expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); - }); -}); diff --git a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts deleted file mode 100644 index 51ba1404b2a4f..0000000000000 --- a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts +++ /dev/null @@ -1,215 +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 { asSavedObjectExecutionSource } from '@kbn/actions-plugin/server'; -import { isEphemeralTaskRejectedDueToCapacityError } from '@kbn/task-manager-plugin/server'; -import { chunk } from 'lodash'; -import { transformActionParams } from './transform_action_params'; -import { injectActionParams } from './inject_action_params'; -import { - ActionsCompletion, - AlertInstanceContext, - AlertInstanceState, - RuleTypeParams, - RuleTypeState, -} from '../types'; -import { CreateExecutionHandlerOptions, ExecutionHandlerOptions } from './types'; - -export type ExecutionHandler = ( - options: ExecutionHandlerOptions -) => Promise; - -export function createExecutionHandler< - Params extends RuleTypeParams, - ExtractedParams extends RuleTypeParams, - State extends RuleTypeState, - InstanceState extends AlertInstanceState, - InstanceContext extends AlertInstanceContext, - ActionGroupIds extends string, - RecoveryActionGroupId extends string ->({ - logger, - ruleId, - ruleName, - ruleConsumer, - executionId, - tags, - actionsPlugin, - actions: ruleActions, - spaceId, - apiKey, - ruleType, - kibanaBaseUrl, - alertingEventLogger, - request, - ruleParams, - supportsEphemeralTasks, - maxEphemeralActionsPerRule, - actionsConfigMap, -}: CreateExecutionHandlerOptions< - Params, - ExtractedParams, - State, - InstanceState, - InstanceContext, - ActionGroupIds, - RecoveryActionGroupId ->): ExecutionHandler { - const ruleTypeActionGroups = new Map( - ruleType.actionGroups.map((actionGroup) => [actionGroup.id, actionGroup.name]) - ); - const CHUNK_SIZE = 1000; - - return async ({ - actionGroup, - context, - state, - ruleRunMetricsStore, - alertId, - }: ExecutionHandlerOptions) => { - if (!ruleTypeActionGroups.has(actionGroup)) { - logger.error(`Invalid action group "${actionGroup}" for rule "${ruleType.id}".`); - return; - } - - const actions = ruleActions - .filter(({ group }) => group === actionGroup) - .map((action) => { - return { - ...action, - params: transformActionParams({ - actionsPlugin, - alertId: ruleId, - alertType: ruleType.id, - actionTypeId: action.actionTypeId, - alertName: ruleName, - spaceId, - tags, - alertInstanceId: alertId, - alertActionGroup: actionGroup, - alertActionGroupName: ruleTypeActionGroups.get(actionGroup)!, - context, - actionParams: action.params, - actionId: action.id, - state, - kibanaBaseUrl, - alertParams: ruleParams, - }), - }; - }) - .map((action) => ({ - ...action, - params: injectActionParams({ - ruleId, - spaceId, - actionParams: action.params, - actionTypeId: action.actionTypeId, - }), - })); - - ruleRunMetricsStore.incrementNumberOfGeneratedActions(actions.length); - - const actionsClient = await actionsPlugin.getActionsClientWithRequest(request); - let ephemeralActionsToSchedule = maxEphemeralActionsPerRule; - - const bulkActions = []; - const logActions = []; - for (const action of actions) { - const { actionTypeId } = action; - - ruleRunMetricsStore.incrementNumberOfGeneratedActionsByConnectorType(actionTypeId); - - if (ruleRunMetricsStore.hasReachedTheExecutableActionsLimit(actionsConfigMap)) { - ruleRunMetricsStore.setTriggeredActionsStatusByConnectorType({ - actionTypeId, - status: ActionsCompletion.PARTIAL, - }); - logger.debug( - `Rule "${ruleId}" skipped scheduling action "${action.id}" because the maximum number of allowed actions has been reached.` - ); - break; - } - - if ( - ruleRunMetricsStore.hasReachedTheExecutableActionsLimitByConnectorType({ - actionTypeId, - actionsConfigMap, - }) - ) { - if (!ruleRunMetricsStore.hasConnectorTypeReachedTheLimit(actionTypeId)) { - logger.debug( - `Rule "${ruleId}" skipped scheduling action "${action.id}" because the maximum number of allowed actions for connector type ${actionTypeId} has been reached.` - ); - } - ruleRunMetricsStore.setTriggeredActionsStatusByConnectorType({ - actionTypeId, - status: ActionsCompletion.PARTIAL, - }); - continue; - } - - if (!actionsPlugin.isActionExecutable(action.id, actionTypeId, { notifyUsage: true })) { - logger.warn( - `Rule "${ruleId}" skipped scheduling action "${action.id}" because it is disabled` - ); - continue; - } - - ruleRunMetricsStore.incrementNumberOfTriggeredActions(); - ruleRunMetricsStore.incrementNumberOfTriggeredActionsByConnectorType(actionTypeId); - - const namespace = spaceId === 'default' ? {} : { namespace: spaceId }; - - const enqueueOptions = { - id: action.id, - params: action.params, - spaceId, - apiKey: apiKey ?? null, - consumer: ruleConsumer, - source: asSavedObjectExecutionSource({ - id: ruleId, - type: 'alert', - }), - executionId, - relatedSavedObjects: [ - { - id: ruleId, - type: 'alert', - namespace: namespace.namespace, - typeId: ruleType.id, - }, - ], - }; - - if (supportsEphemeralTasks && ephemeralActionsToSchedule > 0) { - ephemeralActionsToSchedule--; - try { - await actionsClient.ephemeralEnqueuedExecution(enqueueOptions); - } catch (err) { - if (isEphemeralTaskRejectedDueToCapacityError(err)) { - bulkActions.push(enqueueOptions); - } - } - } else { - bulkActions.push(enqueueOptions); - } - logActions.push({ - id: action.id, - typeId: actionTypeId, - alertId, - alertGroup: actionGroup, - }); - } - - for (const c of chunk(bulkActions, CHUNK_SIZE)) { - await actionsClient.bulkEnqueueExecution(c); - } - - for (const action of logActions) { - alertingEventLogger.logAction(action); - } - }; -} diff --git a/x-pack/plugins/alerting/server/task_runner/execution_handler.test.ts b/x-pack/plugins/alerting/server/task_runner/execution_handler.test.ts new file mode 100644 index 0000000000000..c75107c52cb81 --- /dev/null +++ b/x-pack/plugins/alerting/server/task_runner/execution_handler.test.ts @@ -0,0 +1,732 @@ +/* + * 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 { ExecutionHandler } from './execution_handler'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { + actionsClientMock, + actionsMock, + renderActionParameterTemplatesDefault, +} from '@kbn/actions-plugin/server/mocks'; +import { KibanaRequest } from '@kbn/core/server'; +import { InjectActionParamsOpts } from './inject_action_params'; +import { NormalizedRuleType } from '../rule_type_registry'; +import { ActionsCompletion, RuleTypeParams, RuleTypeState, SanitizedRule } from '../types'; +import { RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; +import { alertingEventLoggerMock } from '../lib/alerting_event_logger/alerting_event_logger.mock'; +import { TaskRunnerContext } from './task_runner_factory'; +import { ConcreteTaskInstance } from '@kbn/task-manager-plugin/server'; +import { Alert } from '../alert'; +import { AlertInstanceState, AlertInstanceContext } from '../../common'; +import { asSavedObjectExecutionSource } from '@kbn/actions-plugin/server'; +import sinon from 'sinon'; + +jest.mock('./inject_action_params', () => ({ + injectActionParams: jest.fn(), +})); + +const alertingEventLogger = alertingEventLoggerMock.create(); +const actionsClient = actionsClientMock.create(); +const mockActionsPlugin = actionsMock.createStart(); +const apiKey = Buffer.from('123:abc').toString('base64'); +const ruleType: NormalizedRuleType< + RuleTypeParams, + RuleTypeParams, + RuleTypeState, + AlertInstanceState, + AlertInstanceContext, + 'default' | 'other-group', + 'recovered' +> = { + id: 'test', + name: 'Test', + actionGroups: [ + { id: 'default', name: 'Default' }, + { id: 'recovered', name: 'Recovered' }, + { id: 'other-group', name: 'Other Group' }, + ], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + recoveryActionGroup: { + id: 'recovered', + name: 'Recovered', + }, + executor: jest.fn(), + producer: 'alerts', +}; +const rule = { + id: '1', + name: 'name-of-alert', + tags: ['tag-A', 'tag-B'], + mutedInstanceIds: [], + params: { + foo: true, + contextVal: 'My other {{context.value}} goes here', + stateVal: 'My other {{state.value}} goes here', + }, + actions: [ + { + id: '1', + group: 'default', + actionTypeId: 'test', + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: 'My {{alertId}} {{alertName}} {{spaceId}} {{tags}} {{alertInstanceId}} goes here', + }, + }, + ], +} as unknown as SanitizedRule; + +const defaultExecutionParams = { + rule, + ruleType, + logger: loggingSystemMock.create().get(), + taskRunnerContext: { + actionsConfigMap: { + default: { + max: 1000, + }, + }, + actionsPlugin: mockActionsPlugin, + } as unknown as TaskRunnerContext, + apiKey, + ruleConsumer: 'rule-consumer', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + ruleLabel: 'rule-label', + request: {} as KibanaRequest, + alertingEventLogger, + taskInstance: { + params: { spaceId: 'test1', alertId: '1' }, + } as unknown as ConcreteTaskInstance, + actionsClient, +}; + +let ruleRunMetricsStore: RuleRunMetricsStore; +let clock: sinon.SinonFakeTimers; +type ActionGroup = 'default' | 'other-group' | 'recovered'; +const generateAlert = ({ + id, + group = 'default', + context, + state, + scheduleActions = true, +}: { + id: number; + group?: ActionGroup; + context?: AlertInstanceContext; + state?: AlertInstanceState; + scheduleActions?: boolean; +}) => { + const alert = new Alert< + AlertInstanceState, + AlertInstanceContext, + 'default' | 'other-group' | 'recovered' + >(String(id), { + state: state || { test: true }, + meta: { + lastScheduledActions: { + date: new Date(), + group, + }, + }, + }); + if (scheduleActions) { + alert.scheduleActions(group); + } + if (context) { + alert.setContext(context); + } + return { [id]: alert }; +}; + +// @ts-ignore +const generateExecutionParams = (params = {}) => { + return { + ...defaultExecutionParams, + ...params, + ruleRunMetricsStore, + }; +}; + +describe('Execution Handler', () => { + beforeEach(() => { + jest.resetAllMocks(); + jest + .requireMock('./inject_action_params') + .injectActionParams.mockImplementation( + ({ actionParams }: InjectActionParamsOpts) => actionParams + ); + mockActionsPlugin.isActionTypeEnabled.mockReturnValue(true); + mockActionsPlugin.isActionExecutable.mockReturnValue(true); + mockActionsPlugin.getActionsClientWithRequest.mockResolvedValue(actionsClient); + mockActionsPlugin.renderActionParameterTemplates.mockImplementation( + renderActionParameterTemplatesDefault + ); + ruleRunMetricsStore = new RuleRunMetricsStore(); + }); + beforeAll(() => { + clock = sinon.useFakeTimers(); + }); + afterAll(() => clock.restore()); + + test('enqueues execution per selected action', async () => { + const executionHandler = new ExecutionHandler(generateExecutionParams()); + await executionHandler.run(generateAlert({ id: 1 })); + + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(1); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(1); + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); + expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "apiKey": "MTIzOmFiYw==", + "consumer": "rule-consumer", + "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", + "id": "1", + "params": Object { + "alertVal": "My 1 name-of-alert test1 tag-A,tag-B 1 goes here", + "contextVal": "My goes here", + "foo": true, + "stateVal": "My goes here", + }, + "relatedSavedObjects": Array [ + Object { + "id": "1", + "namespace": "test1", + "type": "alert", + "typeId": "test", + }, + ], + "source": Object { + "source": Object { + "id": "1", + "type": "alert", + }, + "type": "SAVED_OBJECT", + }, + "spaceId": "test1", + }, + ], + ] + `); + + expect(alertingEventLogger.logAction).toHaveBeenCalledTimes(1); + expect(alertingEventLogger.logAction).toHaveBeenNthCalledWith(1, { + id: '1', + typeId: 'test', + alertId: '1', + alertGroup: 'default', + }); + + expect(jest.requireMock('./inject_action_params').injectActionParams).toHaveBeenCalledWith({ + ruleId: '1', + spaceId: 'test1', + actionTypeId: 'test', + actionParams: { + alertVal: 'My 1 name-of-alert test1 tag-A,tag-B 1 goes here', + contextVal: 'My goes here', + foo: true, + stateVal: 'My goes here', + }, + }); + + expect(ruleRunMetricsStore.getTriggeredActionsStatus()).toBe(ActionsCompletion.COMPLETE); + }); + + test(`doesn't call actionsPlugin.execute for disabled actionTypes`, async () => { + // Mock two calls, one for check against actions[0] and the second for actions[1] + mockActionsPlugin.isActionExecutable.mockReturnValueOnce(false); + mockActionsPlugin.isActionTypeEnabled.mockReturnValueOnce(false); + mockActionsPlugin.isActionTypeEnabled.mockReturnValueOnce(true); + const executionHandler = new ExecutionHandler( + generateExecutionParams({ + rule: { + ...defaultExecutionParams.rule, + actions: [ + { + id: '2', + group: 'default', + actionTypeId: 'test2', + params: { + foo: true, + contextVal: 'My other {{context.value}} goes here', + stateVal: 'My other {{state.value}} goes here', + }, + }, + { + id: '2', + group: 'default', + actionTypeId: 'test2', + params: { + foo: true, + contextVal: 'My other {{context.value}} goes here', + stateVal: 'My other {{state.value}} goes here', + }, + }, + ], + }, + }) + ); + + await executionHandler.run(generateAlert({ id: 1 })); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(1); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(2); + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledWith([ + { + consumer: 'rule-consumer', + id: '2', + params: { + foo: true, + contextVal: 'My other goes here', + stateVal: 'My other goes here', + }, + source: asSavedObjectExecutionSource({ + id: '1', + type: 'alert', + }), + relatedSavedObjects: [ + { + id: '1', + namespace: 'test1', + type: 'alert', + typeId: 'test', + }, + ], + spaceId: 'test1', + apiKey, + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + }, + ]); + }); + + test('trow error error message when action type is disabled', async () => { + mockActionsPlugin.preconfiguredActions = []; + mockActionsPlugin.isActionExecutable.mockReturnValue(false); + mockActionsPlugin.isActionTypeEnabled.mockReturnValue(false); + const executionHandler = new ExecutionHandler( + generateExecutionParams({ + rule: { + ...defaultExecutionParams.rule, + actions: [ + { + id: '1', + group: 'default', + actionTypeId: '.slack', + params: { + foo: true, + }, + }, + { + id: '2', + group: 'default', + actionTypeId: '.slack', + params: { + foo: true, + contextVal: 'My other {{context.value}} goes here', + stateVal: 'My other {{state.value}} goes here', + }, + }, + ], + }, + }) + ); + + await executionHandler.run(generateAlert({ id: 2 })); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(0); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(2); + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(0); + + mockActionsPlugin.isActionExecutable.mockImplementation(() => true); + const executionHandlerForPreconfiguredAction = new ExecutionHandler({ + ...defaultExecutionParams, + ruleRunMetricsStore, + }); + + await executionHandlerForPreconfiguredAction.run(generateAlert({ id: 2 })); + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); + }); + + test('limits actionsPlugin.execute per action group', async () => { + const executionHandler = new ExecutionHandler(generateExecutionParams()); + await executionHandler.run(generateAlert({ id: 2, group: 'other-group' })); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(0); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(0); + expect(actionsClient.bulkEnqueueExecution).not.toHaveBeenCalled(); + }); + + test('context attribute gets parameterized', async () => { + const executionHandler = new ExecutionHandler(generateExecutionParams()); + await executionHandler.run(generateAlert({ id: 2, context: { value: 'context-val' } })); + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(1); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(1); + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); + expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "apiKey": "MTIzOmFiYw==", + "consumer": "rule-consumer", + "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", + "id": "1", + "params": Object { + "alertVal": "My 1 name-of-alert test1 tag-A,tag-B 2 goes here", + "contextVal": "My context-val goes here", + "foo": true, + "stateVal": "My goes here", + }, + "relatedSavedObjects": Array [ + Object { + "id": "1", + "namespace": "test1", + "type": "alert", + "typeId": "test", + }, + ], + "source": Object { + "source": Object { + "id": "1", + "type": "alert", + }, + "type": "SAVED_OBJECT", + }, + "spaceId": "test1", + }, + ], + ] + `); + }); + + test('state attribute gets parameterized', async () => { + const executionHandler = new ExecutionHandler(generateExecutionParams()); + await executionHandler.run(generateAlert({ id: 2, state: { value: 'state-val' } })); + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); + expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "apiKey": "MTIzOmFiYw==", + "consumer": "rule-consumer", + "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", + "id": "1", + "params": Object { + "alertVal": "My 1 name-of-alert test1 tag-A,tag-B 2 goes here", + "contextVal": "My goes here", + "foo": true, + "stateVal": "My state-val goes here", + }, + "relatedSavedObjects": Array [ + Object { + "id": "1", + "namespace": "test1", + "type": "alert", + "typeId": "test", + }, + ], + "source": Object { + "source": Object { + "id": "1", + "type": "alert", + }, + "type": "SAVED_OBJECT", + }, + "spaceId": "test1", + }, + ], + ] + `); + }); + + test(`logs an error when action group isn't part of actionGroups available for the ruleType`, async () => { + const executionHandler = new ExecutionHandler(generateExecutionParams()); + await executionHandler.run( + generateAlert({ id: 2, group: 'invalid-group' as 'default' | 'other-group' }) + ); + expect(defaultExecutionParams.logger.error).toHaveBeenCalledWith( + 'Invalid action group "invalid-group" for rule "test".' + ); + + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(0); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(0); + expect(ruleRunMetricsStore.getTriggeredActionsStatus()).toBe(ActionsCompletion.COMPLETE); + }); + + test('Stops triggering actions when the number of total triggered actions is reached the number of max executable actions', async () => { + const executionHandler = new ExecutionHandler( + generateExecutionParams({ + ...defaultExecutionParams, + taskRunnerContext: { + ...defaultExecutionParams.taskRunnerContext, + actionsConfigMap: { + default: { + max: 2, + }, + }, + }, + rule: { + ...defaultExecutionParams.rule, + actions: [ + { + id: '1', + group: 'default', + actionTypeId: 'test2', + params: { + foo: true, + contextVal: 'My other {{context.value}} goes here', + stateVal: 'My other {{state.value}} goes here', + }, + }, + { + id: '2', + group: 'default', + actionTypeId: 'test2', + params: { + foo: true, + contextVal: 'My other {{context.value}} goes here', + stateVal: 'My other {{state.value}} goes here', + }, + }, + { + id: '3', + group: 'default', + actionTypeId: 'test3', + params: { + foo: true, + contextVal: '{{context.value}} goes here', + stateVal: '{{state.value}} goes here', + }, + }, + ], + }, + }) + ); + await executionHandler.run(generateAlert({ id: 2, state: { value: 'state-val' } })); + + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(2); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(3); + expect(ruleRunMetricsStore.getTriggeredActionsStatus()).toBe(ActionsCompletion.PARTIAL); + expect(defaultExecutionParams.logger.debug).toHaveBeenCalledTimes(1); + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); + }); + + test('Skips triggering actions for a specific action type when it reaches the limit for that specific action type', async () => { + const executionHandler = new ExecutionHandler( + generateExecutionParams({ + ...defaultExecutionParams, + taskRunnerContext: { + ...defaultExecutionParams.taskRunnerContext, + actionsConfigMap: { + default: { + max: 4, + }, + 'test-action-type-id': { + max: 1, + }, + }, + }, + rule: { + ...defaultExecutionParams.rule, + actions: [ + ...defaultExecutionParams.rule.actions, + { + id: '2', + group: 'default', + actionTypeId: 'test-action-type-id', + params: { + foo: true, + contextVal: 'My other {{context.value}} goes here', + stateVal: 'My other {{state.value}} goes here', + }, + }, + { + id: '3', + group: 'default', + actionTypeId: 'test-action-type-id', + params: { + foo: true, + contextVal: '{{context.value}} goes here', + stateVal: '{{state.value}} goes here', + }, + }, + { + id: '4', + group: 'default', + actionTypeId: 'another-action-type-id', + params: { + foo: true, + contextVal: '{{context.value}} goes here', + stateVal: '{{state.value}} goes here', + }, + }, + { + id: '5', + group: 'default', + actionTypeId: 'another-action-type-id', + params: { + foo: true, + contextVal: '{{context.value}} goes here', + stateVal: '{{state.value}} goes here', + }, + }, + ], + }, + }) + ); + await executionHandler.run(generateAlert({ id: 2, state: { value: 'state-val' } })); + + expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(4); + expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(5); + expect(ruleRunMetricsStore.getStatusByConnectorType('test').numberOfTriggeredActions).toBe(1); + expect( + ruleRunMetricsStore.getStatusByConnectorType('test-action-type-id').numberOfTriggeredActions + ).toBe(1); + expect( + ruleRunMetricsStore.getStatusByConnectorType('another-action-type-id') + .numberOfTriggeredActions + ).toBe(2); + expect(ruleRunMetricsStore.getTriggeredActionsStatus()).toBe(ActionsCompletion.PARTIAL); + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); + }); + + test('schedules alerts with recovered actions', async () => { + const executionHandler = new ExecutionHandler( + generateExecutionParams({ + ...defaultExecutionParams, + rule: { + ...defaultExecutionParams.rule, + actions: [ + { + id: '1', + group: 'recovered', + actionTypeId: 'test', + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{alertId}} {{alertName}} {{spaceId}} {{tags}} {{alertInstanceId}} goes here', + }, + }, + ], + }, + }) + ); + await executionHandler.run(generateAlert({ id: 1, scheduleActions: false }), true); + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); + expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "apiKey": "MTIzOmFiYw==", + "consumer": "rule-consumer", + "executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28", + "id": "1", + "params": Object { + "alertVal": "My 1 name-of-alert test1 tag-A,tag-B 1 goes here", + "contextVal": "My goes here", + "foo": true, + "stateVal": "My goes here", + }, + "relatedSavedObjects": Array [ + Object { + "id": "1", + "namespace": "test1", + "type": "alert", + "typeId": "test", + }, + ], + "source": Object { + "source": Object { + "id": "1", + "type": "alert", + }, + "type": "SAVED_OBJECT", + }, + "spaceId": "test1", + }, + ], + ] + `); + }); + + test('does not schedule alerts with recovered actions that are muted', async () => { + const executionHandler = new ExecutionHandler( + generateExecutionParams({ + ...defaultExecutionParams, + rule: { + ...defaultExecutionParams.rule, + mutedInstanceIds: ['1'], + actions: [ + { + id: '1', + group: 'recovered', + actionTypeId: 'test', + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{alertId}} {{alertName}} {{spaceId}} {{tags}} {{alertInstanceId}} goes here', + }, + }, + ], + }, + }) + ); + await executionHandler.run(generateAlert({ id: 1, scheduleActions: false }), true); + + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(0); + expect(defaultExecutionParams.logger.debug).nthCalledWith( + 1, + `skipping scheduling of actions for '1' in rule ${defaultExecutionParams.ruleLabel}: rule is muted` + ); + }); + + test('does not schedule active alerts that are throttled', async () => { + const executionHandler = new ExecutionHandler( + generateExecutionParams({ + ...defaultExecutionParams, + rule: { + ...defaultExecutionParams.rule, + throttle: '1m', + }, + }) + ); + await executionHandler.run(generateAlert({ id: 1 })); + + clock.tick(30000); + + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(0); + expect(defaultExecutionParams.logger.debug).nthCalledWith( + 1, + `skipping scheduling of actions for '1' in rule ${defaultExecutionParams.ruleLabel}: rule is throttled` + ); + }); + + test('does not schedule active alerts that are muted', async () => { + const executionHandler = new ExecutionHandler( + generateExecutionParams({ + ...defaultExecutionParams, + rule: { + ...defaultExecutionParams.rule, + mutedInstanceIds: ['1'], + }, + }) + ); + await executionHandler.run(generateAlert({ id: 1 })); + + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(0); + expect(defaultExecutionParams.logger.debug).nthCalledWith( + 1, + `skipping scheduling of actions for '1' in rule ${defaultExecutionParams.ruleLabel}: rule is muted` + ); + }); +}); diff --git a/x-pack/plugins/alerting/server/task_runner/execution_handler.ts b/x-pack/plugins/alerting/server/task_runner/execution_handler.ts new file mode 100644 index 0000000000000..2d72bcd284a66 --- /dev/null +++ b/x-pack/plugins/alerting/server/task_runner/execution_handler.ts @@ -0,0 +1,410 @@ +/* + * 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 type { PublicMethodsOf } from '@kbn/utility-types'; +import { Logger } from '@kbn/core/server'; +import { asSavedObjectExecutionSource } from '@kbn/actions-plugin/server'; +import { isEphemeralTaskRejectedDueToCapacityError } from '@kbn/task-manager-plugin/server'; +import { ExecuteOptions as EnqueueExecutionOptions } from '@kbn/actions-plugin/server/create_execute_function'; +import { ActionsClient } from '@kbn/actions-plugin/server/actions_client'; +import { chunk } from 'lodash'; +import { AlertingEventLogger } from '../lib/alerting_event_logger/alerting_event_logger'; +import { RawRule } from '../types'; +import { RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; +import { injectActionParams } from './inject_action_params'; +import { ExecutionHandlerOptions, RuleTaskInstance } from './types'; +import { TaskRunnerContext } from './task_runner_factory'; +import { transformActionParams } from './transform_action_params'; +import { Alert } from '../alert'; +import { NormalizedRuleType } from '../rule_type_registry'; +import { + ActionsCompletion, + AlertInstanceContext, + AlertInstanceState, + RuleAction, + RuleTypeParams, + RuleTypeState, + SanitizedRule, +} from '../../common'; + +enum Reasons { + MUTED = 'muted', + THROTTLED = 'throttled', + ACTION_GROUP_NOT_CHANGED = 'actionGroupHasNotChanged', +} + +export class ExecutionHandler< + Params extends RuleTypeParams, + ExtractedParams extends RuleTypeParams, + RuleState extends RuleTypeState, + State extends AlertInstanceState, + Context extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string +> { + private logger: Logger; + private alertingEventLogger: PublicMethodsOf; + private rule: SanitizedRule; + private ruleType: NormalizedRuleType< + Params, + ExtractedParams, + RuleState, + State, + Context, + ActionGroupIds, + RecoveryActionGroupId + >; + private taskRunnerContext: TaskRunnerContext; + private taskInstance: RuleTaskInstance; + private ruleRunMetricsStore: RuleRunMetricsStore; + private apiKey: RawRule['apiKey']; + private ruleConsumer: string; + private executionId: string; + private ruleLabel: string; + private ephemeralActionsToSchedule: number; + private CHUNK_SIZE = 1000; + private skippedAlerts: { [key: string]: { reason: string } } = {}; + private actionsClient: PublicMethodsOf; + private ruleTypeActionGroups?: Map; + private mutedAlertIdsSet?: Set; + + constructor({ + rule, + ruleType, + logger, + alertingEventLogger, + taskRunnerContext, + taskInstance, + ruleRunMetricsStore, + apiKey, + ruleConsumer, + executionId, + ruleLabel, + actionsClient, + }: ExecutionHandlerOptions< + Params, + ExtractedParams, + RuleState, + State, + Context, + ActionGroupIds, + RecoveryActionGroupId + >) { + this.logger = logger; + this.alertingEventLogger = alertingEventLogger; + this.rule = rule; + this.ruleType = ruleType; + this.taskRunnerContext = taskRunnerContext; + this.taskInstance = taskInstance; + this.ruleRunMetricsStore = ruleRunMetricsStore; + this.apiKey = apiKey; + this.ruleConsumer = ruleConsumer; + this.executionId = executionId; + this.ruleLabel = ruleLabel; + this.actionsClient = actionsClient; + this.ephemeralActionsToSchedule = taskRunnerContext.maxEphemeralActionsPerRule; + this.ruleTypeActionGroups = new Map( + ruleType.actionGroups.map((actionGroup) => [actionGroup.id, actionGroup.name]) + ); + this.mutedAlertIdsSet = new Set(rule.mutedInstanceIds); + } + + public async run( + alerts: Record>, + recovered: boolean = false + ) { + const { + CHUNK_SIZE, + logger, + alertingEventLogger, + ruleRunMetricsStore, + taskRunnerContext: { actionsConfigMap, actionsPlugin }, + taskInstance: { + params: { spaceId, alertId: ruleId }, + }, + } = this; + + const executables = this.generateExecutables({ alerts, recovered }); + + if (!!executables.length) { + const logActions = []; + const bulkActions: EnqueueExecutionOptions[] = []; + + this.ruleRunMetricsStore.incrementNumberOfGeneratedActions(executables.length); + + for (const { action, alert, alertId, actionGroup, state } of executables) { + const { actionTypeId } = action; + + if (!recovered) { + alert.updateLastScheduledActions(action.group as ActionGroupIds); + alert.unscheduleActions(); + } + + ruleRunMetricsStore.incrementNumberOfGeneratedActionsByConnectorType(actionTypeId); + + if (ruleRunMetricsStore.hasReachedTheExecutableActionsLimit(actionsConfigMap)) { + ruleRunMetricsStore.setTriggeredActionsStatusByConnectorType({ + actionTypeId, + status: ActionsCompletion.PARTIAL, + }); + logger.debug( + `Rule "${this.rule.id}" skipped scheduling action "${action.id}" because the maximum number of allowed actions has been reached.` + ); + break; + } + + if ( + ruleRunMetricsStore.hasReachedTheExecutableActionsLimitByConnectorType({ + actionTypeId, + actionsConfigMap, + }) + ) { + if (!ruleRunMetricsStore.hasConnectorTypeReachedTheLimit(actionTypeId)) { + logger.debug( + `Rule "${this.rule.id}" skipped scheduling action "${action.id}" because the maximum number of allowed actions for connector type ${actionTypeId} has been reached.` + ); + } + ruleRunMetricsStore.setTriggeredActionsStatusByConnectorType({ + actionTypeId, + status: ActionsCompletion.PARTIAL, + }); + continue; + } + + if (!this.isActionExecutable(action)) { + this.logger.warn( + `Rule "${this.taskInstance.params.alertId}" skipped scheduling action "${action.id}" because it is disabled` + ); + continue; + } + + ruleRunMetricsStore.incrementNumberOfTriggeredActions(); + ruleRunMetricsStore.incrementNumberOfTriggeredActionsByConnectorType(actionTypeId); + + const actionToRun = { + ...action, + params: injectActionParams({ + ruleId, + spaceId, + actionTypeId, + actionParams: transformActionParams({ + actionsPlugin, + alertId: ruleId, + alertType: this.ruleType.id, + actionTypeId, + alertName: this.rule.name, + spaceId, + tags: this.rule.tags, + alertInstanceId: alertId, + alertActionGroup: actionGroup, + alertActionGroupName: this.ruleTypeActionGroups!.get(actionGroup)!, + context: alert.getContext(), + actionId: action.id, + state, + kibanaBaseUrl: this.taskRunnerContext.kibanaBaseUrl, + alertParams: this.rule.params, + actionParams: action.params, + }), + }), + }; + + await this.actionRunOrAddToBulk({ + enqueueOptions: this.getEnqueueOptions(actionToRun), + bulkActions, + }); + + logActions.push({ + id: action.id, + typeId: action.actionTypeId, + alertId, + alertGroup: action.group, + }); + + if (recovered) { + alert.scheduleActions(action.group as ActionGroupIds); + } + } + + if (!!bulkActions.length) { + for (const c of chunk(bulkActions, CHUNK_SIZE)) { + await this.actionsClient!.bulkEnqueueExecution(c); + } + } + + if (!!logActions.length) { + for (const action of logActions) { + alertingEventLogger.logAction(action); + } + } + } + } + + private generateExecutables({ + alerts, + recovered, + }: { + alerts: Record>; + recovered: boolean; + }) { + const executables = []; + + for (const action of this.rule.actions) { + for (const [alertId, alert] of Object.entries(alerts)) { + const actionGroup = recovered + ? this.ruleType.recoveryActionGroup.id + : alert.getScheduledActionOptions()?.actionGroup!; + + if (!this.ruleTypeActionGroups!.has(actionGroup)) { + this.logger.error( + `Invalid action group "${actionGroup}" for rule "${this.ruleType.id}".` + ); + continue; + } + + if (action.group === actionGroup && this.isAlertExecutable({ alertId, alert, recovered })) { + const state = recovered ? {} : alert.getScheduledActionOptions()?.state!; + + executables.push({ + action, + alert, + alertId, + actionGroup, + state, + }); + } + } + } + + return executables; + } + + private async actionRunOrAddToBulk({ + enqueueOptions, + bulkActions, + }: { + enqueueOptions: EnqueueExecutionOptions; + bulkActions: EnqueueExecutionOptions[]; + }) { + if (this.taskRunnerContext.supportsEphemeralTasks && this.ephemeralActionsToSchedule > 0) { + this.ephemeralActionsToSchedule--; + try { + await this.actionsClient!.ephemeralEnqueuedExecution(enqueueOptions); + } catch (err) { + if (isEphemeralTaskRejectedDueToCapacityError(err)) { + bulkActions.push(enqueueOptions); + } + } + } else { + bulkActions.push(enqueueOptions); + } + } + + private getEnqueueOptions(action: RuleAction): EnqueueExecutionOptions { + const { + apiKey, + ruleConsumer, + executionId, + taskInstance: { + params: { spaceId, alertId: ruleId }, + }, + } = this; + + const namespace = spaceId === 'default' ? {} : { namespace: spaceId }; + return { + id: action.id, + params: action.params, + spaceId, + apiKey: apiKey ?? null, + consumer: ruleConsumer, + source: asSavedObjectExecutionSource({ + id: ruleId, + type: 'alert', + }), + executionId, + relatedSavedObjects: [ + { + id: ruleId, + type: 'alert', + namespace: namespace.namespace, + typeId: this.ruleType.id, + }, + ], + }; + } + + private isActionExecutable(action: RuleAction) { + return this.taskRunnerContext.actionsPlugin.isActionExecutable(action.id, action.actionTypeId, { + notifyUsage: true, + }); + } + + private isAlertExecutable({ + alertId, + alert, + recovered, + }: { + alertId: string; + alert: Alert; + recovered: boolean; + }) { + const { + rule: { throttle, notifyWhen }, + ruleLabel, + logger, + mutedAlertIdsSet, + } = this; + + const muted = mutedAlertIdsSet!.has(alertId); + const throttled = alert.isThrottled(throttle); + + if (muted) { + if ( + !this.skippedAlerts[alertId] || + (this.skippedAlerts[alertId] && this.skippedAlerts[alertId].reason !== Reasons.MUTED) + ) { + logger.debug( + `skipping scheduling of actions for '${alertId}' in rule ${ruleLabel}: rule is muted` + ); + } + this.skippedAlerts[alertId] = { reason: Reasons.MUTED }; + return false; + } + + if (!recovered) { + if (throttled) { + if ( + !this.skippedAlerts[alertId] || + (this.skippedAlerts[alertId] && this.skippedAlerts[alertId].reason !== Reasons.THROTTLED) + ) { + logger.debug( + `skipping scheduling of actions for '${alertId}' in rule ${ruleLabel}: rule is throttled` + ); + } + this.skippedAlerts[alertId] = { reason: Reasons.THROTTLED }; + return false; + } + + if (notifyWhen === 'onActionGroupChange' && !alert.scheduledActionGroupHasChanged()) { + if ( + !this.skippedAlerts[alertId] || + (this.skippedAlerts[alertId] && + this.skippedAlerts[alertId].reason !== Reasons.ACTION_GROUP_NOT_CHANGED) + ) { + logger.debug( + `skipping scheduling of actions for '${alertId}' in rule ${ruleLabel}: alert is active but action group has not changed` + ); + } + this.skippedAlerts[alertId] = { reason: Reasons.ACTION_GROUP_NOT_CHANGED }; + return false; + } + + return alert.hasScheduledActions(); + } else { + return true; + } + } +} diff --git a/x-pack/plugins/alerting/server/task_runner/schedule_actions_for_alerts.test.ts b/x-pack/plugins/alerting/server/task_runner/schedule_actions_for_alerts.test.ts deleted file mode 100644 index cdd16289c7a1a..0000000000000 --- a/x-pack/plugins/alerting/server/task_runner/schedule_actions_for_alerts.test.ts +++ /dev/null @@ -1,207 +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 { RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; -import { RecoveredActionGroup } from '../types'; -import { RULE_NAME } from './fixtures'; -import { loggingSystemMock } from '@kbn/core/server/mocks'; -import { scheduleActionsForAlerts } from './schedule_actions_for_alerts'; -import { Alert } from '../alert'; -import { AlertInstanceState, AlertInstanceContext, DefaultActionGroupId } from '../../common'; -import sinon from 'sinon'; - -describe('Schedule Actions For Alerts', () => { - const ruleRunMetricsStore = new RuleRunMetricsStore(); - const executionHandler = jest.fn(); - const recoveryActionGroup = RecoveredActionGroup; - const mutedAlertIdsSet = new Set('2'); - const logger: ReturnType = - loggingSystemMock.createLogger(); - const notifyWhen = 'onActiveAlert'; - const throttle = null; - let clock: sinon.SinonFakeTimers; - - beforeEach(() => { - jest.resetAllMocks(); - clock.reset(); - }); - beforeAll(() => { - clock = sinon.useFakeTimers(); - }); - afterAll(() => clock.restore()); - - test('schedules alerts with executable actions', async () => { - const alert = new Alert('1', { - state: { test: true }, - meta: { - lastScheduledActions: { - date: new Date(), - group: 'default', - }, - }, - }); - alert.scheduleActions('default'); - const alerts = { '1': alert }; - const recoveredAlerts = {}; - - await scheduleActionsForAlerts({ - activeAlerts: alerts, - recoveryActionGroup, - recoveredAlerts, - executionHandler, - mutedAlertIdsSet, - logger, - ruleLabel: RULE_NAME, - ruleRunMetricsStore, - throttle, - notifyWhen, - }); - - expect(executionHandler).toBeCalledWith({ - actionGroup: 'default', - context: {}, - state: { test: true }, - alertId: '1', - ruleRunMetricsStore, - }); - }); - - test('schedules alerts with recovered actions', async () => { - const alert = new Alert('1', { - state: { test: true }, - meta: { - lastScheduledActions: { - date: new Date(), - group: 'default', - }, - }, - }); - const alerts = {}; - const recoveredAlerts = { '1': alert }; - - await scheduleActionsForAlerts({ - activeAlerts: alerts, - recoveryActionGroup, - recoveredAlerts, - executionHandler, - mutedAlertIdsSet, - logger, - ruleLabel: RULE_NAME, - ruleRunMetricsStore, - throttle, - notifyWhen, - }); - - expect(executionHandler).toHaveBeenNthCalledWith(1, { - actionGroup: 'recovered', - context: {}, - state: {}, - alertId: '1', - ruleRunMetricsStore, - }); - }); - - test('does not schedule alerts with recovered actions that are muted', async () => { - const alert = new Alert('2', { - state: { test: true }, - meta: { - lastScheduledActions: { - date: new Date(), - group: 'default', - }, - }, - }); - const alerts = {}; - const recoveredAlerts = { '2': alert }; - - await scheduleActionsForAlerts({ - activeAlerts: alerts, - recoveryActionGroup, - recoveredAlerts, - executionHandler, - mutedAlertIdsSet, - logger, - ruleLabel: RULE_NAME, - ruleRunMetricsStore, - throttle, - notifyWhen, - }); - - expect(executionHandler).not.toBeCalled(); - expect(logger.debug).nthCalledWith( - 1, - `skipping scheduling of actions for '2' in rule ${RULE_NAME}: instance is muted` - ); - }); - - test('does not schedule active alerts that are throttled', async () => { - const alert = new Alert('1', { - state: { test: true }, - meta: { - lastScheduledActions: { - date: new Date(), - group: 'default', - }, - }, - }); - clock.tick(30000); - alert.scheduleActions('default'); - const alerts = { '1': alert }; - const recoveredAlerts = {}; - - await scheduleActionsForAlerts({ - activeAlerts: alerts, - recoveryActionGroup, - recoveredAlerts, - executionHandler, - mutedAlertIdsSet, - logger, - ruleLabel: RULE_NAME, - ruleRunMetricsStore, - throttle: '1m', - notifyWhen, - }); - expect(executionHandler).not.toBeCalled(); - expect(logger.debug).nthCalledWith( - 1, - `skipping scheduling of actions for '1' in rule ${RULE_NAME}: rule is throttled` - ); - }); - - test('does not schedule active alerts that are muted', async () => { - const alert = new Alert('2', { - state: { test: true }, - meta: { - lastScheduledActions: { - date: new Date(), - group: 'default', - }, - }, - }); - const alerts = { '2': alert }; - const recoveredAlerts = {}; - - await scheduleActionsForAlerts({ - activeAlerts: alerts, - recoveryActionGroup, - recoveredAlerts, - executionHandler, - mutedAlertIdsSet, - logger, - ruleLabel: RULE_NAME, - ruleRunMetricsStore, - throttle, - notifyWhen, - }); - - expect(executionHandler).not.toBeCalled(); - expect(logger.debug).nthCalledWith( - 1, - `skipping scheduling of actions for '2' in rule ${RULE_NAME}: rule is muted` - ); - }); -}); diff --git a/x-pack/plugins/alerting/server/task_runner/schedule_actions_for_alerts.ts b/x-pack/plugins/alerting/server/task_runner/schedule_actions_for_alerts.ts deleted file mode 100644 index 8c1d62ecf4e32..0000000000000 --- a/x-pack/plugins/alerting/server/task_runner/schedule_actions_for_alerts.ts +++ /dev/null @@ -1,134 +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 { Logger } from '@kbn/core/server'; -import { ExecutionHandler } from './create_execution_handler'; -import { ScheduleActionsForAlertsParams } from './types'; -import { AlertInstanceState, AlertInstanceContext } from '../types'; -import { Alert } from '../alert'; -import { RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; - -export async function scheduleActionsForAlerts< - InstanceState extends AlertInstanceState, - InstanceContext extends AlertInstanceContext, - ActionGroupIds extends string, - RecoveryActionGroupId extends string ->( - params: ScheduleActionsForAlertsParams< - InstanceState, - InstanceContext, - ActionGroupIds, - RecoveryActionGroupId - > -): Promise { - const { - logger, - activeAlerts, - recoveryActionGroup, - recoveredAlerts, - executionHandler, - mutedAlertIdsSet, - ruleLabel, - ruleRunMetricsStore, - throttle, - notifyWhen, - } = params; - // execute alerts with executable actions - for (const [alertId, alert] of Object.entries(activeAlerts)) { - const executeAction: boolean = shouldExecuteAction( - alertId, - alert, - mutedAlertIdsSet, - ruleLabel, - logger, - throttle, - notifyWhen - ); - if (executeAction && alert.hasScheduledActions()) { - const { actionGroup, state } = alert.getScheduledActionOptions()!; - await executeAlert(alertId, alert, executionHandler, ruleRunMetricsStore, actionGroup, state); - } - } - - // execute recovered alerts - for (const alertId of Object.keys(recoveredAlerts)) { - if (mutedAlertIdsSet.has(alertId)) { - logger.debug( - `skipping scheduling of actions for '${alertId}' in rule ${ruleLabel}: instance is muted` - ); - } else { - const alert = recoveredAlerts[alertId]; - await executeAlert( - alertId, - alert, - executionHandler, - ruleRunMetricsStore, - recoveryActionGroup.id, - {} as InstanceState - ); - alert.scheduleActions(recoveryActionGroup.id); - } - } -} - -async function executeAlert< - InstanceState extends AlertInstanceState, - InstanceContext extends AlertInstanceContext, - ActionGroupIds extends string, - RecoveryActionGroupId extends string ->( - alertId: string, - alert: Alert, - executionHandler: ExecutionHandler, - ruleRunMetricsStore: RuleRunMetricsStore, - actionGroup: ActionGroupIds | RecoveryActionGroupId, - state: InstanceState -) { - alert.updateLastScheduledActions(actionGroup); - alert.unscheduleActions(); - return executionHandler({ - actionGroup, - context: alert.getContext(), - state, - alertId, - ruleRunMetricsStore, - }); -} - -function shouldExecuteAction< - InstanceState extends AlertInstanceState, - InstanceContext extends AlertInstanceContext, - ActionGroupIds extends string ->( - alertId: string, - alert: Alert, - mutedAlertIdsSet: Set, - ruleLabel: string, - logger: Logger, - throttle: string | null, - notifyWhen: string | null -) { - const throttled = alert.isThrottled(throttle); - const muted = mutedAlertIdsSet.has(alertId); - let executeAction = true; - - if (throttled || muted) { - executeAction = false; - logger.debug( - `skipping scheduling of actions for '${alertId}' in rule ${ruleLabel}: rule is ${ - muted ? 'muted' : 'throttled' - }` - ); - } else if (notifyWhen === 'onActionGroupChange' && !alert.scheduledActionGroupHasChanged()) { - executeAction = false; - logger.debug( - `skipping scheduling of actions for '${alertId}' in rule ${ruleLabel}: alert is active but action group has not changed` - ); - } - - return executeAction; -} diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts index 3d3c82d91fe28..54573f1b7e2b2 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts @@ -2424,8 +2424,7 @@ describe('Task Runner', () => { const runnerResult = await taskRunner.run(); - // 1x(.server-log) and 1x(any-action) per alert - expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(2); + expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); expect( taskRunnerFactoryInitializerParams.internalSavedObjectsRepository.update @@ -2468,7 +2467,7 @@ describe('Task Runner', () => { expect(logger.debug).nthCalledWith( 3, - 'Rule "1" skipped scheduling action "2" because the maximum number of allowed actions for connector type .server-log has been reached.' + 'Rule "1" skipped scheduling action "1" because the maximum number of allowed actions for connector type .server-log has been reached.' ); testAlertingEventLogCalls({ diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.ts index fe8fcbb73152a..354b18d3d38d4 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts @@ -9,11 +9,11 @@ import apm from 'elastic-apm-node'; import { cloneDeep, omit } from 'lodash'; import { UsageCounter } from '@kbn/usage-collection-plugin/server'; import uuid from 'uuid'; -import { KibanaRequest, Logger } from '@kbn/core/server'; +import { Logger } from '@kbn/core/server'; import { ConcreteTaskInstance, throwUnrecoverableError } from '@kbn/task-manager-plugin/server'; import { nanosToMillis } from '@kbn/event-log-plugin/server'; +import { ExecutionHandler } from './execution_handler'; import { TaskRunnerContext } from './task_runner_factory'; -import { createExecutionHandler } from './create_execution_handler'; import { Alert, createAlertFactory } from '../alert'; import { ElasticsearchError, @@ -25,12 +25,10 @@ import { processAlerts, } from '../lib'; import { - Rule, RuleExecutionStatus, RuleExecutionStatusErrorReasons, IntervalSchedule, RawAlertInstance, - RawRule, RawRuleExecutionStatus, RuleMonitoring, RuleMonitoringHistory, @@ -66,7 +64,6 @@ import { wrapSearchSourceClient } from '../lib/wrap_search_source_client'; import { AlertingEventLogger } from '../lib/alerting_event_logger/alerting_event_logger'; import { loadRule } from './rule_loader'; import { logAlerts } from './log_alerts'; -import { scheduleActionsForAlerts } from './schedule_actions_for_alerts'; import { getPublicAlertFactory } from '../alert/create_alert_factory'; import { TaskRunnerTimer, TaskRunnerTimerSpan } from './task_runner_timer'; @@ -154,47 +151,6 @@ export class TaskRunner< this.stackTraceLog = null; } - private getExecutionHandler( - ruleId: string, - ruleName: string, - tags: string[] | undefined, - spaceId: string, - apiKey: RawRule['apiKey'], - kibanaBaseUrl: string | undefined, - actions: Rule['actions'], - ruleParams: Params, - request: KibanaRequest - ) { - return createExecutionHandler< - Params, - ExtractedParams, - RuleState, - State, - Context, - ActionGroupIds, - RecoveryActionGroupId - >({ - ruleId, - ruleName, - ruleConsumer: this.ruleConsumer!, - tags, - executionId: this.executionId, - logger: this.logger, - actionsPlugin: this.context.actionsPlugin, - apiKey, - actions, - spaceId, - ruleType: this.ruleType, - kibanaBaseUrl, - alertingEventLogger: this.alertingEventLogger, - request, - ruleParams, - supportsEphemeralTasks: this.context.supportsEphemeralTasks, - maxEphemeralActionsPerRule: this.context.maxEphemeralActionsPerRule, - actionsConfigMap: this.context.actionsConfigMap, - }); - } - private async updateRuleSavedObject( ruleId: string, namespace: string | undefined, @@ -223,6 +179,11 @@ export class TaskRunner< return !this.context.cancelAlertsOnRuleTimeout || !this.ruleType.cancelAlertsOnRuleTimeout; } + // Usage counter for telemetry + // This keeps track of how many times action executions were skipped after rule + // execution completed successfully after the execution timeout + // This can occur when rule executors do not short circuit execution in response + // to timeout private countUsageOfActionExecutionAfterRuleCancellation() { if (this.cancelled && this.usageCounter) { if (this.context.cancelAlertsOnRuleTimeout && this.ruleType.cancelAlertsOnRuleTimeout) { @@ -259,7 +220,6 @@ export class TaskRunner< schedule, throttle, notifyWhen, - mutedInstanceIds, name, tags, createdBy, @@ -464,52 +424,34 @@ export class TaskRunner< } ); - await this.timer.runWithTimer(TaskRunnerTimerSpan.TriggerActions, async () => { - const executionHandler = this.getExecutionHandler( - ruleId, - rule.name, - rule.tags, - spaceId, - apiKey, - this.context.kibanaBaseUrl, - rule.actions, - rule.params, - fakeRequest - ); + const executionHandler = new ExecutionHandler({ + rule, + ruleType: this.ruleType, + logger: this.logger, + taskRunnerContext: this.context, + taskInstance: this.taskInstance, + ruleRunMetricsStore, + apiKey, + ruleConsumer: this.ruleConsumer!, + executionId: this.executionId, + ruleLabel, + alertingEventLogger: this.alertingEventLogger, + actionsClient: await this.context.actionsPlugin.getActionsClientWithRequest(fakeRequest), + }); + await this.timer.runWithTimer(TaskRunnerTimerSpan.TriggerActions, async () => { await rulesClient.clearExpiredSnoozes({ id: rule.id }); - const ruleIsSnoozed = isRuleSnoozed(rule); - if (!ruleIsSnoozed && this.shouldLogAndScheduleActionsForAlerts()) { - const mutedAlertIdsSet = new Set(mutedInstanceIds); - - await scheduleActionsForAlerts({ - activeAlerts, - recoveryActionGroup: this.ruleType.recoveryActionGroup, - recoveredAlerts, - executionHandler, - mutedAlertIdsSet, - logger: this.logger, - ruleLabel, - ruleRunMetricsStore, - throttle, - notifyWhen, - }); + if (isRuleSnoozed(rule)) { + this.logger.debug(`no scheduling of actions for rule ${ruleLabel}: rule is snoozed.`); + } else if (!this.shouldLogAndScheduleActionsForAlerts()) { + this.logger.debug( + `no scheduling of actions for rule ${ruleLabel}: rule execution has been cancelled.` + ); + this.countUsageOfActionExecutionAfterRuleCancellation(); } else { - if (ruleIsSnoozed) { - this.logger.debug(`no scheduling of actions for rule ${ruleLabel}: rule is snoozed.`); - } - if (!this.shouldLogAndScheduleActionsForAlerts()) { - this.logger.debug( - `no scheduling of actions for rule ${ruleLabel}: rule execution has been cancelled.` - ); - // Usage counter for telemetry - // This keeps track of how many times action executions were skipped after rule - // execution completed successfully after the execution timeout - // This can occur when rule executors do not short circuit execution in response - // to timeout - this.countUsageOfActionExecutionAfterRuleCancellation(); - } + await executionHandler.run(activeAlerts); + await executionHandler.run(recoveredAlerts, true); } }); diff --git a/x-pack/plugins/alerting/server/task_runner/types.ts b/x-pack/plugins/alerting/server/task_runner/types.ts index ce439fa3b3b0a..bffa25577b3a6 100644 --- a/x-pack/plugins/alerting/server/task_runner/types.ts +++ b/x-pack/plugins/alerting/server/task_runner/types.ts @@ -7,25 +7,21 @@ import { KibanaRequest, Logger } from '@kbn/core/server'; import { ConcreteTaskInstance } from '@kbn/task-manager-plugin/server'; -import { PluginStartContract as ActionsPluginStartContract } from '@kbn/actions-plugin/server'; import { PublicMethodsOf } from '@kbn/utility-types'; +import { ActionsClient } from '@kbn/actions-plugin/server/actions_client'; +import { TaskRunnerContext } from './task_runner_factory'; import { - ActionGroup, - RuleAction, AlertInstanceContext, AlertInstanceState, RuleTypeParams, - RuleTypeState, IntervalSchedule, RuleMonitoring, RuleTaskState, SanitizedRule, + RuleTypeState, } from '../../common'; -import { Alert } from '../alert'; import { NormalizedRuleType } from '../rule_type_registry'; -import { ExecutionHandler } from './create_execution_handler'; import { RawRule, RulesClientApi } from '../types'; -import { ActionsConfigMap } from '../lib/get_actions_config_map'; import { RuleRunMetrics, RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; import { AlertingEventLogger } from '../lib/alerting_event_logger/alerting_event_logger'; @@ -57,67 +53,35 @@ export interface RuleTaskInstance extends ConcreteTaskInstance { state: RuleTaskState; } -export interface ScheduleActionsForAlertsParams< - InstanceState extends AlertInstanceState, - InstanceContext extends AlertInstanceContext, - ActionGroupIds extends string, - RecoveryActionGroupId extends string -> { - logger: Logger; - recoveryActionGroup: ActionGroup; - recoveredAlerts: Record>; - executionHandler: ExecutionHandler; - mutedAlertIdsSet: Set; - ruleLabel: string; - ruleRunMetricsStore: RuleRunMetricsStore; - activeAlerts: Record>; - throttle: string | null; - notifyWhen: string | null; -} - // / ExecutionHandler -export interface CreateExecutionHandlerOptions< +export interface ExecutionHandlerOptions< Params extends RuleTypeParams, ExtractedParams extends RuleTypeParams, - State extends RuleTypeState, - InstanceState extends AlertInstanceState, - InstanceContext extends AlertInstanceContext, + RuleState extends RuleTypeState, + State extends AlertInstanceState, + Context extends AlertInstanceContext, ActionGroupIds extends string, RecoveryActionGroupId extends string > { - ruleId: string; - ruleName: string; - ruleConsumer: string; - executionId: string; - tags?: string[]; - actionsPlugin: ActionsPluginStartContract; - actions: RuleAction[]; - spaceId: string; - apiKey: RawRule['apiKey']; - kibanaBaseUrl: string | undefined; ruleType: NormalizedRuleType< Params, ExtractedParams, + RuleState, State, - InstanceState, - InstanceContext, + Context, ActionGroupIds, RecoveryActionGroupId >; logger: Logger; alertingEventLogger: PublicMethodsOf; - request: KibanaRequest; - ruleParams: RuleTypeParams; - supportsEphemeralTasks: boolean; - maxEphemeralActionsPerRule: number; - actionsConfigMap: ActionsConfigMap; -} - -export interface ExecutionHandlerOptions { - actionGroup: ActionGroupIds; - alertId: string; - context: AlertInstanceContext; - state: AlertInstanceState; + rule: SanitizedRule; + taskRunnerContext: TaskRunnerContext; + taskInstance: RuleTaskInstance; ruleRunMetricsStore: RuleRunMetricsStore; + apiKey: RawRule['apiKey']; + ruleConsumer: string; + executionId: string; + ruleLabel: string; + actionsClient: PublicMethodsOf; } From 20b8741263dbd4c0927b7cde410d4479148c1ada Mon Sep 17 00:00:00 2001 From: Abdul Wahab Zahid Date: Mon, 7 Nov 2022 17:04:40 +0100 Subject: [PATCH 006/192] [Synthetics UI] Monitor Status Panel (histogram) - Summary / History Page (#144293) - Create ping statuses route and state. - Create and insert Monitor Status Panel component in Monitor Detail -> Summary page. - Refresh the chart from refresh context while considering the minimum monitor interval. - Added brushing on the chart on History page. - Added "View History" button on the Summary page. - Adjust color shades for darkMode. - Address PR Feedback. --- .../common/constants/synthetics/rest_api.ts | 1 + .../synthetics/common/lib/schedule_to_time.ts | 4 + .../common/runtime_types/ping/ping.ts | 27 +++ .../hooks/use_ping_statuses.tsx | 87 +++++++++ .../monitor_history/monitor_history.tsx | 34 +++- .../monitor_details/monitor_status/labels.ts | 50 +++++ .../monitor_status_cell_tooltip.tsx | 120 ++++++++++++ .../monitor_status_chart_theme.ts | 54 ++++++ .../monitor_status/monitor_status_data.ts | 183 ++++++++++++++++++ .../monitor_status/monitor_status_header.tsx | 95 +++++++++ .../monitor_status/monitor_status_legend.tsx | 64 ++++++ .../monitor_status/monitor_status_panel.tsx | 99 ++++++++++ .../monitor_status/use_monitor_status_data.ts | 79 ++++++++ .../monitor_summary/monitor_summary.tsx | 10 +- .../public/apps/synthetics/state/index.ts | 1 + .../synthetics/state/ping_status/actions.ts | 16 ++ .../apps/synthetics/state/ping_status/api.ts | 36 ++++ .../synthetics/state/ping_status/effects.ts | 23 +++ .../synthetics/state/ping_status/index.ts | 64 ++++++ .../synthetics/state/ping_status/models.ts | 14 ++ .../synthetics/state/ping_status/selectors.ts | 29 +++ .../apps/synthetics/state/root_effect.ts | 2 + .../apps/synthetics/state/root_reducer.ts | 3 + .../__mocks__/synthetics_store.mock.ts | 30 +++ .../server/common/pings/query_pings.ts | 76 ++++++-- .../lib/adapters/framework/adapter_types.ts | 10 +- .../plugins/synthetics/server/routes/index.ts | 3 +- .../server/routes/pings/get_ping_statuses.ts | 83 ++++++++ .../server/routes/pings/get_pings.ts | 26 +-- .../synthetics/server/routes/pings/index.ts | 1 + 30 files changed, 1287 insertions(+), 37 deletions(-) create mode 100644 x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/hooks/use_ping_statuses.tsx create mode 100644 x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/labels.ts create mode 100644 x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_cell_tooltip.tsx create mode 100644 x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_chart_theme.ts create mode 100644 x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_data.ts create mode 100644 x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_header.tsx create mode 100644 x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_legend.tsx create mode 100644 x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_panel.tsx create mode 100644 x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/use_monitor_status_data.ts create mode 100644 x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/actions.ts create mode 100644 x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/api.ts create mode 100644 x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/effects.ts create mode 100644 x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/index.ts create mode 100644 x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/models.ts create mode 100644 x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/selectors.ts create mode 100644 x-pack/plugins/synthetics/server/routes/pings/get_ping_statuses.ts diff --git a/x-pack/plugins/synthetics/common/constants/synthetics/rest_api.ts b/x-pack/plugins/synthetics/common/constants/synthetics/rest_api.ts index adea7f323e432..b3ed4e26948ab 100644 --- a/x-pack/plugins/synthetics/common/constants/synthetics/rest_api.ts +++ b/x-pack/plugins/synthetics/common/constants/synthetics/rest_api.ts @@ -8,5 +8,6 @@ export enum SYNTHETICS_API_URLS { SYNTHETICS_OVERVIEW = '/internal/synthetics/overview', PINGS = '/internal/synthetics/pings', + PING_STATUSES = '/internal/synthetics/ping_statuses', OVERVIEW_STATUS = `/internal/synthetics/overview/status`, } diff --git a/x-pack/plugins/synthetics/common/lib/schedule_to_time.ts b/x-pack/plugins/synthetics/common/lib/schedule_to_time.ts index d944c35b2ec78..4578790f54572 100644 --- a/x-pack/plugins/synthetics/common/lib/schedule_to_time.ts +++ b/x-pack/plugins/synthetics/common/lib/schedule_to_time.ts @@ -12,6 +12,10 @@ export function scheduleToMilli(schedule: SyntheticsMonitorSchedule): number { return timeValue * getMilliFactorForScheduleUnit(schedule.unit); } +export function scheduleToMinutes(schedule: SyntheticsMonitorSchedule): number { + return Math.floor(scheduleToMilli(schedule) / (60 * 1000)); +} + function getMilliFactorForScheduleUnit(scheduleUnit: ScheduleUnit): number { switch (scheduleUnit) { case ScheduleUnit.SECONDS: diff --git a/x-pack/plugins/synthetics/common/runtime_types/ping/ping.ts b/x-pack/plugins/synthetics/common/runtime_types/ping/ping.ts index 598a62265f1c2..7fb0a799605a8 100644 --- a/x-pack/plugins/synthetics/common/runtime_types/ping/ping.ts +++ b/x-pack/plugins/synthetics/common/runtime_types/ping/ping.ts @@ -244,6 +244,24 @@ export const PingType = t.intersection([ export type Ping = t.TypeOf; +export const PingStatusType = t.intersection([ + t.type({ + timestamp: t.string, + docId: t.string, + config_id: t.string, + locationId: t.string, + summary: t.partial({ + down: t.number, + up: t.number, + }), + }), + t.partial({ + error: PingErrorType, + }), +]); + +export type PingStatus = t.TypeOf; + // Convenience function for tests etc that makes an empty ping // object with the minimum of fields. export const makePing = (f: { @@ -282,6 +300,15 @@ export const PingsResponseType = t.type({ export type PingsResponse = t.TypeOf; +export const PingStatusesResponseType = t.type({ + total: t.number, + pings: t.array(PingStatusType), + from: t.string, + to: t.string, +}); + +export type PingStatusesResponse = t.TypeOf; + export const GetPingsParamsType = t.intersection([ t.type({ dateRange: DateRangeType, diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/hooks/use_ping_statuses.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/hooks/use_ping_statuses.tsx new file mode 100644 index 0000000000000..ca107c7af3ae1 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/hooks/use_ping_statuses.tsx @@ -0,0 +1,87 @@ +/* + * 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, useCallback, useRef } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; + +import { PingStatus } from '../../../../../../common/runtime_types'; +import { + getMonitorPingStatusesAction, + selectIsMonitorStatusesLoading, + selectPingStatusesForMonitorAndLocationAsc, +} from '../../../state'; + +import { useSelectedMonitor } from './use_selected_monitor'; +import { useSelectedLocation } from './use_selected_location'; + +export const usePingStatuses = ({ + from, + to, + size, + monitorInterval, + lastRefresh, +}: { + from: number; + to: number; + size: number; + monitorInterval: number; + lastRefresh: number; +}) => { + const { monitor } = useSelectedMonitor(); + const location = useSelectedLocation(); + + const pingStatusesSelector = useCallback(() => { + return selectPingStatusesForMonitorAndLocationAsc(monitor?.id ?? '', location?.label ?? ''); + }, [monitor?.id, location?.label]); + const isLoading = useSelector(selectIsMonitorStatusesLoading); + const pingStatuses = useSelector(pingStatusesSelector()) as PingStatus[]; + const dispatch = useDispatch(); + + const lastCall = useRef({ monitorId: '', locationLabel: '', to: 0, from: 0, lastRefresh: 0 }); + const toDiff = Math.abs(lastCall.current.to - to) / (1000 * 60); + const fromDiff = Math.abs(lastCall.current.from - from) / (1000 * 60); + const lastRefreshDiff = Math.abs(lastCall.current.lastRefresh - lastRefresh) / (1000 * 60); + const isDataChangedEnough = + toDiff >= monitorInterval || + fromDiff >= monitorInterval || + lastRefreshDiff >= 3 || // Minimum monitor interval + monitor?.id !== lastCall.current.monitorId || + location?.label !== lastCall.current.locationLabel; + + useEffect(() => { + if (!isLoading && isDataChangedEnough && monitor?.id && location?.label && from && to && size) { + dispatch( + getMonitorPingStatusesAction.get({ + monitorId: monitor.id, + locationId: location.label, + from, + to, + size, + }) + ); + + lastCall.current = { + monitorId: monitor.id, + locationLabel: location?.label, + to, + from, + lastRefresh, + }; + } + // `isLoading` shouldn't be included in deps + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dispatch, monitor?.id, location?.label, from, to, size, isDataChangedEnough, lastRefresh]); + + return pingStatuses.filter(({ timestamp }) => { + const timestampN = Number(new Date(timestamp)); + return timestampN >= from && timestampN <= to; + }); +}; + +export const usePingStatusesIsLoading = () => { + return useSelector(selectIsMonitorStatusesLoading) as boolean; +}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_history/monitor_history.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_history/monitor_history.tsx index a1522f58047da..e0ed2162e6f1b 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_history/monitor_history.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_history/monitor_history.tsx @@ -4,9 +4,37 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { EuiText } from '@elastic/eui'; -import React from 'react'; + +import React, { useCallback } from 'react'; +import { EuiSpacer } from '@elastic/eui'; +import { useUrlParams } from '../../../hooks'; +import { SyntheticsDatePicker } from '../../common/date_picker/synthetics_date_picker'; +import { MonitorStatusPanel } from '../monitor_status/monitor_status_panel'; export const MonitorHistory = () => { - return Monitor history tab content; + const [useGetUrlParams, updateUrlParams] = useUrlParams(); + const { dateRangeStart, dateRangeEnd } = useGetUrlParams(); + + const handleStatusChartBrushed = useCallback( + ({ fromUtc, toUtc }) => { + updateUrlParams({ dateRangeStart: fromUtc, dateRangeEnd: toUtc }); + }, + [updateUrlParams] + ); + + return ( + <> + + + + + + ); }; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/labels.ts b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/labels.ts new file mode 100644 index 0000000000000..fdd3ddf3a4432 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/labels.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const AVAILABILITY_LABEL = i18n.translate( + 'xpack.synthetics.monitorDetails.availability.label', + { + defaultMessage: 'Availability', + } +); + +export const COMPLETE_LABEL = i18n.translate('xpack.synthetics.monitorDetails.complete.label', { + defaultMessage: 'Complete', +}); + +export const FAILED_LABEL = i18n.translate('xpack.synthetics.monitorDetails.failed.label', { + defaultMessage: 'Failed', +}); + +export const SKIPPED_LABEL = i18n.translate('xpack.synthetics.monitorDetails.skipped.label', { + defaultMessage: 'Skipped', +}); + +export const ERROR_LABEL = i18n.translate('xpack.synthetics.monitorDetails.error.label', { + defaultMessage: 'Error', +}); + +export const STATUS_LABEL = i18n.translate('xpack.synthetics.monitorDetails.status', { + defaultMessage: 'Status', +}); + +export const LAST_24_HOURS_LABEL = i18n.translate('xpack.synthetics.monitorDetails.last24Hours', { + defaultMessage: 'Last 24 hours', +}); + +export const VIEW_HISTORY_LABEL = i18n.translate('xpack.synthetics.monitorDetails.viewHistory', { + defaultMessage: 'View History', +}); + +export const BRUSH_AREA_MESSAGE = i18n.translate( + 'xpack.synthetics.monitorDetails.brushArea.message', + { + defaultMessage: 'Brush an area for higher fidelity', + } +); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_cell_tooltip.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_cell_tooltip.tsx new file mode 100644 index 0000000000000..8aa155dd2df7b --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_cell_tooltip.tsx @@ -0,0 +1,120 @@ +/* + * 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 moment from 'moment'; +import { css } from '@emotion/react'; +import { useEuiTheme, EuiText, EuiProgress } from '@elastic/eui'; + +import { + TooltipTable, + TooltipTableBody, + TooltipHeader, + TooltipDivider, + TooltipTableRow, + TooltipTableCell, +} from '@elastic/charts'; + +import { usePingStatusesIsLoading } from '../hooks/use_ping_statuses'; +import { MonitorStatusTimeBin, SUCCESS_VIZ_COLOR, DANGER_VIZ_COLOR } from './monitor_status_data'; +import * as labels from './labels'; + +export const MonitorStatusCellTooltip = ({ timeBin }: { timeBin?: MonitorStatusTimeBin }) => { + const { euiTheme } = useEuiTheme(); + const isLoading = usePingStatusesIsLoading(); + + if (!timeBin) { + return <>{''}; + } + + const startM = moment(timeBin.start); + const endM = moment(timeBin.end); + const startDateStr = startM.format('LL'); + const timeStartStr = startM.format('HH:mm'); + const timeEndStr = endM.format('HH:mm'); + const isDifferentDays = startM.dayOfYear() !== endM.dayOfYear(); + + // If start and end days are different, show date for both of the days + const endDateSegment = isDifferentDays ? `${endM.format('LL')} @ ` : ''; + const tooltipTitle = `${startDateStr} @ ${timeStartStr} - ${endDateSegment}${timeEndStr}`; + + const availabilityStr = + timeBin.ups + timeBin.downs > 0 + ? `${Math.round((timeBin.ups / (timeBin.ups + timeBin.downs)) * 100)}%` + : '-'; + + return ( + <> + + + {tooltipTitle} + + + + {isLoading ? : } + +
+ + + + + + + + + + + + + +
+ + ); +}; + +const TooltipListRow = ({ + color, + label, + value, +}: { + color?: string; + label: string; + value: string; +}) => { + const { euiTheme } = useEuiTheme(); + + return ( + <> + + {label} + + + + + {value} + + + + ); +}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_chart_theme.ts b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_chart_theme.ts new file mode 100644 index 0000000000000..022a928f2916c --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_chart_theme.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { HeatmapStyle, RecursivePartial } from '@elastic/charts'; +import { EuiThemeComputed } from '@elastic/eui'; +import { CHART_CELL_WIDTH } from './monitor_status_data'; + +export function getMonitorStatusChartTheme( + euiTheme: EuiThemeComputed, + brushable: boolean +): RecursivePartial { + return { + grid: { + cellHeight: { + min: 20, + }, + stroke: { + width: 0, + color: 'transparent', + }, + }, + maxRowHeight: 30, + maxColumnWidth: CHART_CELL_WIDTH, + cell: { + maxWidth: 'fill', + maxHeight: 3, + label: { + visible: false, + }, + border: { + stroke: 'transparent', + strokeWidth: 0.5, + }, + }, + xAxisLabel: { + visible: true, + fontSize: 10, + fontFamily: euiTheme.font.family, + fontWeight: euiTheme.font.weight.light, + textColor: euiTheme.colors.subduedText, + }, + yAxisLabel: { + visible: false, + }, + brushTool: { + visible: brushable, + fill: euiTheme.colors.darkShade, + }, + }; +} diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_data.ts b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_data.ts new file mode 100644 index 0000000000000..f588ab242adf9 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_data.ts @@ -0,0 +1,183 @@ +/* + * 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 datemath from '@elastic/datemath'; +import moment from 'moment'; +import { + tint, + transparentize, + VISUALIZATION_COLORS, + EuiThemeComputed, + EuiThemeColorModeStandard, + COLOR_MODES_STANDARD, +} from '@elastic/eui'; +import type { BrushEvent } from '@elastic/charts'; +import { PingStatus } from '../../../../../../common/runtime_types'; + +export const SUCCESS_VIZ_COLOR = VISUALIZATION_COLORS[0]; +export const DANGER_VIZ_COLOR = VISUALIZATION_COLORS[VISUALIZATION_COLORS.length - 1]; +export const CHART_CELL_WIDTH = 17; + +export interface MonitorStatusTimeBucket { + start: number; + end: number; +} + +export interface MonitorStatusTimeBin { + start: number; + end: number; + ups: number; + downs: number; + + /** + * To color code the time bin on chart + */ + value: number; +} + +export interface MonitorStatusPanelProps { + /** + * Either epoch in millis or Kibana date range e.g. 'now-24h' + */ + from: string | number; + + /** + * Either epoch in millis or Kibana date range e.g. 'now' + */ + to: string | number; + + brushable: boolean; // Whether to allow brushing on the chart to allow zooming in on data. + periodCaption?: string; // e.g. Last 24 Hours + showViewHistoryButton?: boolean; + onBrushed?: (timeBounds: { from: number; to: number; fromUtc: string; toUtc: string }) => void; +} + +export function getColorBands(euiTheme: EuiThemeComputed, colorMode: EuiThemeColorModeStandard) { + const colorTransitionFn = colorMode === COLOR_MODES_STANDARD.dark ? transparentize : tint; + + return [ + { color: DANGER_VIZ_COLOR, start: -Infinity, end: -1 }, + { color: DANGER_VIZ_COLOR, start: -1, end: -0.75 }, + { color: colorTransitionFn(DANGER_VIZ_COLOR, 0.25), start: -0.75, end: -0.5 }, + { color: colorTransitionFn(DANGER_VIZ_COLOR, 0.5), start: -0.5, end: -0.25 }, + { color: colorTransitionFn(DANGER_VIZ_COLOR, 0.75), start: -0.25, end: -0.000000001 }, + { + color: getSkippedVizColor(euiTheme), + start: -0.000000001, + end: 0.000000001, + }, + { color: colorTransitionFn(SUCCESS_VIZ_COLOR, 0.5), start: 0.000000001, end: 0.25 }, + { color: colorTransitionFn(SUCCESS_VIZ_COLOR, 0.35), start: 0.25, end: 0.5 }, + { color: colorTransitionFn(SUCCESS_VIZ_COLOR, 0.2), start: 0.5, end: 0.8 }, + { color: SUCCESS_VIZ_COLOR, start: 0.8, end: 1 }, + { color: SUCCESS_VIZ_COLOR, start: 1, end: Infinity }, + ]; +} + +export function getSkippedVizColor(euiTheme: EuiThemeComputed) { + return euiTheme.colors.lightestShade; +} + +export function getErrorVizColor(euiTheme: EuiThemeComputed) { + return euiTheme.colors.dangerText; +} + +export function getXAxisLabelFormatter(interval: number) { + return (value: string | number) => { + const m = moment(value); + const [hours, minutes] = [m.hours(), m.minutes()]; + const isFirstBucketOfADay = hours === 0 && minutes <= 36; + const isIntervalAcrossDays = interval >= 24 * 60; + return moment(value).format(isFirstBucketOfADay || isIntervalAcrossDays ? 'l' : 'HH:mm'); + }; +} + +export function createTimeBuckets(intervalMinutes: number, from: number, to: number) { + const currentMark = getEndTime(intervalMinutes, to); + const buckets: MonitorStatusTimeBucket[] = []; + + let tick = currentMark; + let maxIterations = 5000; + while (tick >= from && maxIterations > 0) { + const start = tick - Math.floor(intervalMinutes * 60 * 1000); + buckets.unshift({ start, end: tick }); + tick = start; + --maxIterations; + } + + return buckets; +} + +export function createStatusTimeBins( + timeBuckets: MonitorStatusTimeBucket[], + pingStatuses: PingStatus[] +): MonitorStatusTimeBin[] { + let iPingStatus = 0; + return (timeBuckets ?? []).map((bucket) => { + const currentBin: MonitorStatusTimeBin = { + start: bucket.start, + end: bucket.end, + ups: 0, + downs: 0, + value: 0, + }; + while ( + iPingStatus < pingStatuses.length && + moment(pingStatuses[iPingStatus].timestamp).valueOf() < bucket.end + ) { + currentBin.ups += pingStatuses[iPingStatus]?.summary.up ?? 0; + currentBin.downs += pingStatuses[iPingStatus]?.summary.down ?? 0; + currentBin.value = getStatusEffectiveValue(currentBin.ups, currentBin.downs); + iPingStatus++; + } + + return currentBin; + }); +} + +export function indexBinsByEndTime(bins: MonitorStatusTimeBin[]) { + return bins.reduce((acc, cur) => { + return acc.set(cur.end, cur); + }, new Map()); +} + +export function dateToMilli(date: string | number | moment.Moment | undefined): number { + if (typeof date === 'number') { + return date; + } + + let d = date; + if (typeof date === 'string') { + d = datemath.parse(date, { momentInstance: moment }); + } + + return moment(d).valueOf(); +} + +export function getBrushData(e: BrushEvent) { + const [from, to] = [Number(e.x?.[0]), Number(e.x?.[1])]; + const [fromUtc, toUtc] = [moment(from).format(), moment(to).format()]; + + return { from, to, fromUtc, toUtc }; +} + +function getStatusEffectiveValue(ups: number, downs: number): number { + if (ups === downs) { + return -0.1; + } + + return (ups - downs) / (ups + downs); +} + +function getEndTime(intervalMinutes: number, to: number) { + const intervalUnderHour = Math.floor(intervalMinutes) % 60; + + const upperBoundMinutes = + Math.ceil(new Date(to).getUTCMinutes() / intervalUnderHour) * intervalUnderHour; + + return moment(to).utc().startOf('hour').add(upperBoundMinutes, 'minute').valueOf(); +} diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_header.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_header.tsx new file mode 100644 index 0000000000000..8de032f1ccd53 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_header.tsx @@ -0,0 +1,95 @@ +/* + * 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 { + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiTitle, + EuiLink, +} from '@elastic/eui'; +import { css } from '@emotion/css'; +import { useHistory } from 'react-router-dom'; + +import { MONITOR_HISTORY_ROUTE } from '../../../../../../common/constants'; +import { stringifyUrlParams } from '../../../utils/url_params'; +import { useGetUrlParams } from '../../../hooks'; + +import { useSelectedMonitor } from '../hooks/use_selected_monitor'; + +import * as labels from './labels'; +import { MonitorStatusPanelProps } from './monitor_status_data'; + +export const MonitorStatusHeader = ({ + from, + to, + periodCaption, + showViewHistoryButton, +}: MonitorStatusPanelProps) => { + const history = useHistory(); + const params = useGetUrlParams(); + const { monitor } = useSelectedMonitor(); + + const isLast24Hours = from === 'now-24h' && to === 'now'; + const periodCaptionText = !!periodCaption + ? periodCaption + : isLast24Hours + ? labels.LAST_24_HOURS_LABEL + : ''; + + return ( + + + +

{labels.STATUS_LABEL}

+
+
+ {periodCaptionText ? ( + + + {periodCaptionText} + + + ) : null} + + + {showViewHistoryButton ? ( + + + + {labels.VIEW_HISTORY_LABEL} + + + + ) : null} +
+ ); +}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_legend.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_legend.tsx new file mode 100644 index 0000000000000..1b672ae6bf0ed --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_legend.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { css } from '@emotion/css'; +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText, useEuiTheme } from '@elastic/eui'; +import * as labels from './labels'; +import { DANGER_VIZ_COLOR, getSkippedVizColor, SUCCESS_VIZ_COLOR } from './monitor_status_data'; + +export const MonitorStatusLegend = ({ brushable }: { brushable: boolean }) => { + const { euiTheme } = useEuiTheme(); + + const LegendItem = useMemo(() => { + return ({ + color, + label, + iconType = 'dot', + }: { + color: string; + label: string; + iconType?: string; + }) => ( + + + {label} + + ); + }, []); + + return ( + + + + + {/* + // Hiding error for now until @elastic/chart's Heatmap chart supports annotations + // `getErrorVizColor` can be imported from './monitor_status_data' + + */} + + {brushable ? ( + <> + + + + {labels.BRUSH_AREA_MESSAGE} + + + + ) : null} + + ); +}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_panel.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_panel.tsx new file mode 100644 index 0000000000000..aa807f860f5dd --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/monitor_status_panel.tsx @@ -0,0 +1,99 @@ +/* + * 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, { useMemo } from 'react'; + +import { EuiPanel, useEuiTheme, EuiResizeObserver } from '@elastic/eui'; +import { Chart, Settings, Heatmap, ScaleType } from '@elastic/charts'; + +import { MonitorStatusHeader } from './monitor_status_header'; +import { MonitorStatusCellTooltip } from './monitor_status_cell_tooltip'; +import { MonitorStatusLegend } from './monitor_status_legend'; +import { getMonitorStatusChartTheme } from './monitor_status_chart_theme'; +import { + getXAxisLabelFormatter, + getColorBands, + getBrushData, + MonitorStatusPanelProps, +} from './monitor_status_data'; +import { useMonitorStatusData } from './use_monitor_status_data'; + +export const MonitorStatusPanel = ({ + from = 'now-24h', + to = 'now', + brushable = true, + periodCaption = undefined, + showViewHistoryButton = false, + onBrushed, +}: MonitorStatusPanelProps) => { + const { euiTheme, colorMode } = useEuiTheme(); + const { timeBins, handleResize, getTimeBinByXValue, xDomain, intervalByWidth } = + useMonitorStatusData({ from, to }); + + const heatmap = useMemo(() => { + return getMonitorStatusChartTheme(euiTheme, brushable); + }, [euiTheme, brushable]); + + return ( + + + + + {(resizeRef) => ( +
+ + ( + + ), + }} + theme={{ heatmap }} + onBrushEnd={(brushArea) => { + onBrushed?.(getBrushData(brushArea)); + }} + /> + timeBin.end} + yAccessor={() => 'T'} + valueAccessor={(timeBin) => timeBin.value} + valueFormatter={(d) => d.toFixed(2)} + xAxisLabelFormatter={getXAxisLabelFormatter(intervalByWidth)} + timeZone="UTC" + xScale={{ + type: ScaleType.Time, + interval: { + type: 'calendar', + unit: 'm', + value: intervalByWidth, + }, + }} + /> + +
+ )} +
+ + +
+ ); +}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/use_monitor_status_data.ts b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/use_monitor_status_data.ts new file mode 100644 index 0000000000000..3465c229c65ce --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/use_monitor_status_data.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useMemo, useState } from 'react'; +import { throttle } from 'lodash'; + +import { scheduleToMinutes } from '../../../../../../common/lib/schedule_to_time'; +import { useSyntheticsRefreshContext } from '../../../contexts/synthetics_refresh_context'; + +import { useSelectedMonitor } from '../hooks/use_selected_monitor'; +import { usePingStatuses } from '../hooks/use_ping_statuses'; +import { + dateToMilli, + createTimeBuckets, + createStatusTimeBins, + CHART_CELL_WIDTH, + indexBinsByEndTime, + MonitorStatusPanelProps, +} from './monitor_status_data'; + +export const useMonitorStatusData = ({ + from, + to, +}: Pick) => { + const { lastRefresh } = useSyntheticsRefreshContext(); + const { monitor } = useSelectedMonitor(); + const monitorInterval = Math.max(3, monitor?.schedule ? scheduleToMinutes(monitor?.schedule) : 3); + + const fromMillis = dateToMilli(from); + const toMillis = dateToMilli(to); + const totalMinutes = Math.ceil(toMillis - fromMillis) / (1000 * 60); + const pingStatuses = usePingStatuses({ + from: fromMillis, + to: toMillis, + size: Math.min(10000, Math.ceil((totalMinutes / monitorInterval) * 2)), // Acts as max size between from - to + monitorInterval, + lastRefresh, + }); + + const [binsAvailableByWidth, setBinsAvailableByWidth] = useState(50); + const intervalByWidth = Math.floor( + Math.max(monitorInterval, totalMinutes / binsAvailableByWidth) + ); + + // Disabling deps warning as we wanna throttle the callback + // eslint-disable-next-line react-hooks/exhaustive-deps + const handleResize = useCallback( + throttle((e: { width: number; height: number }) => { + setBinsAvailableByWidth(Math.floor(e.width / CHART_CELL_WIDTH)); + }, 500), + [] + ); + + const { timeBins, timeBinsByEndTime, xDomain } = useMemo(() => { + const timeBuckets = createTimeBuckets(intervalByWidth, fromMillis, toMillis); + const bins = createStatusTimeBins(timeBuckets, pingStatuses); + const indexedBins = indexBinsByEndTime(bins); + + const timeDomain = { + min: bins?.[0]?.end ?? fromMillis, + max: bins?.[bins.length - 1]?.end ?? toMillis, + }; + + return { timeBins: bins, timeBinsByEndTime: indexedBins, xDomain: timeDomain }; + }, [intervalByWidth, pingStatuses, fromMillis, toMillis]); + + return { + intervalByWidth, + timeBins, + xDomain, + handleResize, + getTimeBinByXValue: (xValue: number | undefined) => + xValue === undefined ? undefined : timeBinsByEndTime.get(xValue), + }; +}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/monitor_summary.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/monitor_summary.tsx index f58ff821198ff..cc0153c7513cd 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/monitor_summary.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/monitor_summary.tsx @@ -20,6 +20,7 @@ import { LoadWhenInView } from '@kbn/observability-plugin/public'; import { useEarliestStartDate } from '../hooks/use_earliest_start_data'; import { MonitorErrorSparklines } from './monitor_error_sparklines'; +import { MonitorStatusPanel } from '../monitor_status/monitor_status_panel'; import { DurationSparklines } from './duration_sparklines'; import { MonitorDurationTrend } from './duration_trend'; import { StepDurationPanel } from './step_duration_panel'; @@ -110,8 +111,13 @@ export const MonitorSummary = () => { - {/* */} - {/* /!* TODO: Add status panel*!/ */} + + diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/index.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/index.ts index 727fd0dfcd4c2..c80fc4640ff67 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/state/index.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/index.ts @@ -18,3 +18,4 @@ export * from './monitor_list'; export * from './monitor_details'; export * from './overview'; export * from './browser_journey'; +export * from './ping_status'; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/actions.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/actions.ts new file mode 100644 index 0000000000000..f268a5a2c5600 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/actions.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PingStatusesResponse } from '../../../../../common/runtime_types'; +import { createAsyncAction } from '../utils/actions'; + +import { PingStatusActionArgs } from './models'; + +export const getMonitorPingStatusesAction = createAsyncAction< + PingStatusActionArgs, + PingStatusesResponse +>('[PING STATUSES] GET PING STATUSES'); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/api.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/api.ts new file mode 100644 index 0000000000000..38930dfb02cb8 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/api.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SYNTHETICS_API_URLS } from '../../../../../common/constants'; +import { + PingStatusesResponse, + PingStatusesResponseType, +} from '../../../../../common/runtime_types'; +import { apiService } from '../../../../utils/api_service'; + +export const fetchMonitorPingStatuses = async ({ + monitorId, + locationId, + from, + to, + size, +}: { + monitorId: string; + locationId: string; + from: string; + to: string; + size: number; +}): Promise => { + const locations = JSON.stringify([locationId]); + const sort = 'desc'; + + return await apiService.get( + SYNTHETICS_API_URLS.PING_STATUSES, + { monitorId, from, to, locations, sort, size }, + PingStatusesResponseType + ); +}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/effects.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/effects.ts new file mode 100644 index 0000000000000..d7756b6b271e7 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/effects.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 { takeEvery } from 'redux-saga/effects'; +import { fetchEffectFactory } from '../utils/fetch_effect'; +import { fetchMonitorPingStatuses } from './api'; + +import { getMonitorPingStatusesAction } from './actions'; + +export function* fetchPingStatusesEffect() { + yield takeEvery( + getMonitorPingStatusesAction.get, + fetchEffectFactory( + fetchMonitorPingStatuses, + getMonitorPingStatusesAction.success, + getMonitorPingStatusesAction.fail + ) + ); +} diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/index.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/index.ts new file mode 100644 index 0000000000000..350db7cb41177 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/index.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createReducer } from '@reduxjs/toolkit'; + +import { PingStatus } from '../../../../../common/runtime_types'; + +import { IHttpSerializedFetchError } from '../utils/http_error'; + +import { getMonitorPingStatusesAction } from './actions'; + +export interface PingStatusState { + pingStatuses: { + [monitorId: string]: { + [locationId: string]: { + [timestamp: string]: PingStatus; + }; + }; + }; + loading: boolean; + error: IHttpSerializedFetchError | null; +} + +const initialState: PingStatusState = { + pingStatuses: {}, + loading: false, + error: null, +}; + +export const pingStatusReducer = createReducer(initialState, (builder) => { + builder + .addCase(getMonitorPingStatusesAction.get, (state) => { + state.loading = true; + }) + .addCase(getMonitorPingStatusesAction.success, (state, action) => { + (action.payload.pings ?? []).forEach((ping) => { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { config_id, locationId, timestamp } = ping; + if (!state.pingStatuses[config_id]) { + state.pingStatuses[config_id] = {}; + } + + if (!state.pingStatuses[config_id][locationId]) { + state.pingStatuses[config_id][locationId] = {}; + } + + state.pingStatuses[config_id][locationId][timestamp] = ping; + }); + + state.loading = false; + }) + .addCase(getMonitorPingStatusesAction.fail, (state, action) => { + state.error = action.payload; + state.loading = false; + }); +}); + +export * from './actions'; +export * from './effects'; +export * from './selectors'; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/models.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/models.ts new file mode 100644 index 0000000000000..bae8ae9acb3fa --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/models.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface PingStatusActionArgs { + monitorId: string; + locationId: string; + from: string | number; + to: string | number; + size: number; +} diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/selectors.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/selectors.ts new file mode 100644 index 0000000000000..cf3061e0fab33 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/ping_status/selectors.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createSelector } from 'reselect'; + +import { PingStatus } from '../../../../../common/runtime_types'; +import { SyntheticsAppState } from '../root_reducer'; + +import { PingStatusState } from '.'; + +type PingSelectorReturnType = (state: SyntheticsAppState) => PingStatus[]; + +const getState = (appState: SyntheticsAppState) => appState.pingStatus; + +export const selectIsMonitorStatusesLoading = createSelector(getState, (state) => state.loading); + +export const selectPingStatusesForMonitorAndLocationAsc = ( + monitorId: string, + locationId: string +): PingSelectorReturnType => + createSelector([(state: SyntheticsAppState) => state.pingStatus], (state: PingStatusState) => { + return Object.values(state?.pingStatuses?.[monitorId]?.[locationId] ?? {}).sort( + (a, b) => Number(new Date(a.timestamp)) - Number(new Date(b.timestamp)) + ); + }); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/root_effect.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/root_effect.ts index 7a5c55d72fd24..4a2543b8f941c 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/state/root_effect.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/root_effect.ts @@ -14,6 +14,7 @@ import { fetchMonitorListEffect, upsertMonitorEffect } from './monitor_list'; import { fetchMonitorOverviewEffect, fetchOverviewStatusEffect } from './overview'; import { fetchServiceLocationsEffect } from './service_locations'; import { browserJourneyEffects } from './browser_journey'; +import { fetchPingStatusesEffect } from './ping_status'; export const rootEffect = function* root(): Generator { yield all([ @@ -27,5 +28,6 @@ export const rootEffect = function* root(): Generator { fork(browserJourneyEffects), fork(fetchOverviewStatusEffect), fork(fetchNetworkEventsEffect), + fork(fetchPingStatusesEffect), ]); }; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/root_reducer.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/root_reducer.ts index b9c7b6ec8db51..6cd15c31f5287 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/state/root_reducer.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/root_reducer.ts @@ -17,6 +17,7 @@ import { serviceLocationsReducer, ServiceLocationsState } from './service_locati import { monitorOverviewReducer, MonitorOverviewState } from './overview'; import { BrowserJourneyState } from './browser_journey/models'; import { browserJourneyReducer } from './browser_journey'; +import { PingStatusState, pingStatusReducer } from './ping_status'; export interface SyntheticsAppState { ui: UiState; @@ -28,6 +29,7 @@ export interface SyntheticsAppState { overview: MonitorOverviewState; browserJourney: BrowserJourneyState; networkEvents: NetworkEventsState; + pingStatus: PingStatusState; } export const rootReducer = combineReducers({ @@ -40,4 +42,5 @@ export const rootReducer = combineReducers({ overview: monitorOverviewReducer, browserJourney: browserJourneyReducer, networkEvents: networkEventsReducer, + pingStatus: pingStatusReducer, }); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts b/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts index 7bf845deee9a6..cd1223ac6df4b 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts @@ -105,6 +105,7 @@ export const mockState: SyntheticsAppState = { monitorDetails: getMonitorDetailsMockSlice(), browserJourney: getBrowserJourneyMockSlice(), networkEvents: {}, + pingStatus: getPingStatusesMockSlice(), }; function getBrowserJourneyMockSlice() { @@ -416,3 +417,32 @@ function getMonitorDetailsMockSlice() { selectedLocationId: 'us_central', }; } + +function getPingStatusesMockSlice() { + const monitorDetails = getMonitorDetailsMockSlice(); + + return { + pingStatuses: monitorDetails.pings.data.reduce((acc, cur) => { + if (!acc[cur.monitor.id]) { + acc[cur.monitor.id] = {}; + } + + if (!acc[cur.monitor.id][cur.observer.geo.name]) { + acc[cur.monitor.id][cur.observer.geo.name] = {}; + } + + acc[cur.monitor.id][cur.observer.geo.name][cur.timestamp] = { + timestamp: cur.timestamp, + error: undefined, + locationId: cur.observer.geo.name, + config_id: cur.config_id, + docId: cur.docId, + summary: cur.summary, + }; + + return acc; + }, {} as SyntheticsAppState['pingStatus']['pingStatuses']), + loading: false, + error: null, + } as SyntheticsAppState['pingStatus']; +} diff --git a/x-pack/plugins/synthetics/server/common/pings/query_pings.ts b/x-pack/plugins/synthetics/server/common/pings/query_pings.ts index 872336767aec8..ac0c0b98a5626 100644 --- a/x-pack/plugins/synthetics/server/common/pings/query_pings.ts +++ b/x-pack/plugins/synthetics/server/common/pings/query_pings.ts @@ -5,8 +5,12 @@ * 2.0. */ -import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { UMElasticsearchQueryFn } from '../../legacy_uptime/lib/adapters/framework'; +import { + Field, + QueryDslFieldAndFormat, + QueryDslQueryContainer, +} from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { UMElasticsearchQueryFnParams } from '../../legacy_uptime/lib/adapters/framework'; import { GetPingsParams, HttpResponseBody, @@ -60,18 +64,35 @@ function isStringArray(value: unknown): value is string[] { throw Error('Excluded locations can only be strings'); } -export const queryPings: UMElasticsearchQueryFn = async ({ - uptimeEsClient, - dateRange: { from, to }, - index, - monitorId, - status, - sort, - size: sizeParam, - pageIndex, - locations, - excludedLocations, -}) => { +type QueryFields = Array; +type GetParamsWithFields = UMElasticsearchQueryFnParams< + GetPingsParams & { fields: QueryFields; fieldsExtractorFn: (doc: any) => F } +>; +type GetParamsWithoutFields = UMElasticsearchQueryFnParams; + +export function queryPings( + params: UMElasticsearchQueryFnParams +): Promise; + +export function queryPings( + params: UMElasticsearchQueryFnParams> +): Promise<{ total: number; pings: F[] }>; + +export async function queryPings( + params: GetParamsWithFields | GetParamsWithoutFields +): Promise { + const { + uptimeEsClient, + dateRange: { from, to }, + index, + monitorId, + status, + sort, + size: sizeParam, + pageIndex, + locations, + excludedLocations, + } = params; const size = sizeParam ?? DEFAULT_PAGE_SIZE; const searchBody = { @@ -92,6 +113,8 @@ export const queryPings: UMElasticsearchQueryFn = ...((locations ?? []).length > 0 ? { post_filter: { terms: { 'observer.geo.name': locations as unknown as string[] } } } : {}), + _source: true, + fields: [] as QueryFields, }; // if there are excluded locations, add a clause to the query's filter @@ -110,6 +133,23 @@ export const queryPings: UMElasticsearchQueryFn = }); } + // If fields are queried, only query the subset of asked fields and omit _source + if (isGetParamsWithFields(params)) { + searchBody._source = false; + searchBody.fields = params.fields; + + const { + body: { + hits: { hits, total }, + }, + } = await uptimeEsClient.search({ body: searchBody }); + + return { + total: total.value, + pings: hits.map((doc: any) => params.fieldsExtractorFn(doc)), + }; + } + const { body: { hits: { hits, total }, @@ -133,4 +173,10 @@ export const queryPings: UMElasticsearchQueryFn = total: total.value, pings, }; -}; +} + +function isGetParamsWithFields( + params: GetParamsWithFields | GetParamsWithoutFields +): params is GetParamsWithFields { + return (params as GetParamsWithFields).fields?.length > 0; +} diff --git a/x-pack/plugins/synthetics/server/legacy_uptime/lib/adapters/framework/adapter_types.ts b/x-pack/plugins/synthetics/server/legacy_uptime/lib/adapters/framework/adapter_types.ts index b5c41eb0c50b4..07c247dc44514 100644 --- a/x-pack/plugins/synthetics/server/legacy_uptime/lib/adapters/framework/adapter_types.ts +++ b/x-pack/plugins/synthetics/server/legacy_uptime/lib/adapters/framework/adapter_types.ts @@ -35,11 +35,13 @@ import type { TelemetryEventsSender } from '../../telemetry/sender'; import type { UptimeRouter } from '../../../../types'; import { UptimeConfig } from '../../../../../common/config'; +export type UMElasticsearchQueryFnParams

= { + uptimeEsClient: UptimeEsClient; + esClient?: IScopedClusterClient; +} & P; + export type UMElasticsearchQueryFn = ( - params: { - uptimeEsClient: UptimeEsClient; - esClient?: IScopedClusterClient; - } & P + params: UMElasticsearchQueryFnParams

) => Promise; export type UMSavedObjectsQueryFn = ( diff --git a/x-pack/plugins/synthetics/server/routes/index.ts b/x-pack/plugins/synthetics/server/routes/index.ts index 546dda039ba22..13ba1cdcda4f2 100644 --- a/x-pack/plugins/synthetics/server/routes/index.ts +++ b/x-pack/plugins/synthetics/server/routes/index.ts @@ -28,7 +28,7 @@ import { editSyntheticsMonitorRoute } from './monitor_cruds/edit_monitor'; import { addSyntheticsMonitorRoute } from './monitor_cruds/add_monitor'; import { addSyntheticsProjectMonitorRoute } from './monitor_cruds/add_monitor_project'; import { addSyntheticsProjectMonitorRouteLegacy } from './monitor_cruds/add_monitor_project_legacy'; -import { syntheticsGetPingsRoute } from './pings'; +import { syntheticsGetPingsRoute, syntheticsGetPingStatusesRoute } from './pings'; import { createGetCurrentStatusRoute } from './status/current_status'; import { SyntheticsRestApiRouteFactory, @@ -56,6 +56,7 @@ export const syntheticsAppRestApiRoutes: SyntheticsRestApiRouteFactory[] = [ getServiceAllowedRoute, getAPIKeySyntheticsRoute, syntheticsGetPingsRoute, + syntheticsGetPingStatusesRoute, getHasZipUrlMonitorRoute, createGetCurrentStatusRoute, ]; diff --git a/x-pack/plugins/synthetics/server/routes/pings/get_ping_statuses.ts b/x-pack/plugins/synthetics/server/routes/pings/get_ping_statuses.ts new file mode 100644 index 0000000000000..7e232208de39f --- /dev/null +++ b/x-pack/plugins/synthetics/server/routes/pings/get_ping_statuses.ts @@ -0,0 +1,83 @@ +/* + * 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 { SYNTHETICS_API_URLS } from '../../../common/constants'; +import { PingError, PingStatus } from '../../../common/runtime_types'; +import { UMServerLibs } from '../../legacy_uptime/lib/lib'; +import { UMRestApiRouteFactory } from '../../legacy_uptime/routes/types'; +import { queryPings } from '../../common/pings/query_pings'; + +import { getPingsRouteQuerySchema } from './get_pings'; + +export const syntheticsGetPingStatusesRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({ + method: 'GET', + path: SYNTHETICS_API_URLS.PING_STATUSES, + validate: { + query: getPingsRouteQuerySchema, + }, + handler: async ({ uptimeEsClient, request, response }): Promise => { + const { + from, + to, + index, + monitorId, + status, + sort, + size, + pageIndex, + locations, + excludedLocations, + } = request.query; + + const result = await queryPings({ + uptimeEsClient, + dateRange: { from, to }, + index, + monitorId, + status, + sort, + size, + pageIndex, + locations: locations ? JSON.parse(locations) : [], + excludedLocations, + fields: ['@timestamp', 'config_id', 'summary.*', 'error.*', 'observer.geo.name'], + fieldsExtractorFn: extractPingStatus, + }); + + return { + ...result, + from, + to, + }; + }, +}); + +function grabPingError(doc: any): PingError | undefined { + const docContainsError = Object.keys(doc?.fields ?? {}).some((key) => key.startsWith('error.')); + if (!docContainsError) { + return undefined; + } + + return { + code: doc.fields['error.code']?.[0], + id: doc.fields['error.id']?.[0], + stack_trace: doc.fields['error.stack_trace']?.[0], + type: doc.fields['error.type']?.[0], + message: doc.fields['error.message']?.[0], + }; +} + +function extractPingStatus(doc: any) { + return { + timestamp: doc.fields['@timestamp']?.[0], + docId: doc._id, + config_id: doc.fields.config_id?.[0], + locationId: doc.fields['observer.geo.name']?.[0], + summary: { up: doc.fields['summary.up']?.[0], down: doc.fields['summary.down']?.[0] }, + error: grabPingError(doc), + } as PingStatus; +} diff --git a/x-pack/plugins/synthetics/server/routes/pings/get_pings.ts b/x-pack/plugins/synthetics/server/routes/pings/get_pings.ts index def868e404db5..80424cbe78902 100644 --- a/x-pack/plugins/synthetics/server/routes/pings/get_pings.ts +++ b/x-pack/plugins/synthetics/server/routes/pings/get_pings.ts @@ -11,22 +11,24 @@ import { UMServerLibs } from '../../legacy_uptime/lib/lib'; import { UMRestApiRouteFactory } from '../../legacy_uptime/routes/types'; import { queryPings } from '../../common/pings/query_pings'; +export const getPingsRouteQuerySchema = schema.object({ + from: schema.string(), + to: schema.string(), + locations: schema.maybe(schema.string()), + excludedLocations: schema.maybe(schema.string()), + monitorId: schema.maybe(schema.string()), + index: schema.maybe(schema.number()), + size: schema.maybe(schema.number()), + pageIndex: schema.maybe(schema.number()), + sort: schema.maybe(schema.string()), + status: schema.maybe(schema.string()), +}); + export const syntheticsGetPingsRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({ method: 'GET', path: SYNTHETICS_API_URLS.PINGS, validate: { - query: schema.object({ - from: schema.string(), - to: schema.string(), - locations: schema.maybe(schema.string()), - excludedLocations: schema.maybe(schema.string()), - monitorId: schema.maybe(schema.string()), - index: schema.maybe(schema.number()), - size: schema.maybe(schema.number()), - pageIndex: schema.maybe(schema.number()), - sort: schema.maybe(schema.string()), - status: schema.maybe(schema.string()), - }), + query: getPingsRouteQuerySchema, }, handler: async ({ uptimeEsClient, request, response }): Promise => { const { diff --git a/x-pack/plugins/synthetics/server/routes/pings/index.ts b/x-pack/plugins/synthetics/server/routes/pings/index.ts index 7bc2a27c155bb..89fa3194d4dc2 100644 --- a/x-pack/plugins/synthetics/server/routes/pings/index.ts +++ b/x-pack/plugins/synthetics/server/routes/pings/index.ts @@ -6,3 +6,4 @@ */ export { syntheticsGetPingsRoute } from './get_pings'; +export { syntheticsGetPingStatusesRoute } from './get_ping_statuses'; From 4b863abc18305e013ed54fab7532b78b6ab1c5f0 Mon Sep 17 00:00:00 2001 From: Luke Elmers Date: Mon, 7 Nov 2022 09:14:42 -0700 Subject: [PATCH 007/192] Adds base implementation of the Kibana Health Gateway. (#141172) --- .github/CODEOWNERS | 1 + package.json | 1 + packages/BUILD.bazel | 2 + packages/kbn-health-gateway-server/.gitignore | 1 + .../kbn-health-gateway-server/BUILD.bazel | 127 ++++++++++++ packages/kbn-health-gateway-server/README.md | 77 +++++++ packages/kbn-health-gateway-server/index.ts | 9 + .../kbn-health-gateway-server/jest.config.js | 13 ++ .../kbn-health-gateway-server/kibana.jsonc | 7 + .../kbn-health-gateway-server/package.json | 12 ++ .../scripts/.env.example | 22 ++ .../scripts/docker-compose.yml | 89 ++++++++ .../kbn-health-gateway-server/scripts/init.ts | 13 ++ .../src/config/config_service.test.mocks.ts | 24 +++ .../src/config/config_service.test.ts | 92 +++++++++ .../src/config/config_service.ts | 44 ++++ .../src/config/index.ts | 9 + .../src/config/read_argv.test.ts | 43 ++++ .../src/config/read_argv.ts | 21 ++ .../kbn-health-gateway-server/src/index.ts | 67 ++++++ .../src/kibana/index.ts | 10 + .../src/kibana/kibana_config.test.ts | 79 ++++++++ .../src/kibana/kibana_config.ts | 109 ++++++++++ .../src/kibana/kibana_service.test.mocks.ts | 10 + .../src/kibana/kibana_service.test.ts | 57 ++++++ .../src/kibana/kibana_service.ts | 42 ++++ .../src/kibana/routes/index.ts | 9 + .../src/kibana/routes/status.ts | 146 +++++++++++++ .../src/server/index.ts | 11 + .../src/server/server.mock.ts | 32 +++ .../src/server/server.test.mocks.ts | 29 +++ .../src/server/server.test.ts | 95 +++++++++ .../src/server/server.ts | 62 ++++++ .../src/server/server_config.test.ts | 191 ++++++++++++++++++ .../src/server/server_config.ts | 92 +++++++++ .../kbn-health-gateway-server/tsconfig.json | 16 ++ src/cli_health_gateway/cli_health_gateway.ts | 26 +++ src/cli_health_gateway/dev.js | 10 + src/cli_health_gateway/dist.js | 10 + src/cli_health_gateway/tsconfig.json | 15 ++ .../tasks/bin/scripts/kibana-health-gateway | 29 +++ .../bin/scripts/kibana-health-gateway.bat | 36 ++++ test/package/deb.yml | 1 + .../assert_health_gateway_cli/tasks/main.yml | 13 ++ test/package/rpm.yml | 1 + tsconfig.base.json | 2 + tsconfig.json | 2 +- yarn.lock | 4 + 48 files changed, 1812 insertions(+), 1 deletion(-) create mode 100644 packages/kbn-health-gateway-server/.gitignore create mode 100644 packages/kbn-health-gateway-server/BUILD.bazel create mode 100644 packages/kbn-health-gateway-server/README.md create mode 100644 packages/kbn-health-gateway-server/index.ts create mode 100644 packages/kbn-health-gateway-server/jest.config.js create mode 100644 packages/kbn-health-gateway-server/kibana.jsonc create mode 100644 packages/kbn-health-gateway-server/package.json create mode 100644 packages/kbn-health-gateway-server/scripts/.env.example create mode 100644 packages/kbn-health-gateway-server/scripts/docker-compose.yml create mode 100644 packages/kbn-health-gateway-server/scripts/init.ts create mode 100644 packages/kbn-health-gateway-server/src/config/config_service.test.mocks.ts create mode 100644 packages/kbn-health-gateway-server/src/config/config_service.test.ts create mode 100644 packages/kbn-health-gateway-server/src/config/config_service.ts create mode 100644 packages/kbn-health-gateway-server/src/config/index.ts create mode 100644 packages/kbn-health-gateway-server/src/config/read_argv.test.ts create mode 100644 packages/kbn-health-gateway-server/src/config/read_argv.ts create mode 100644 packages/kbn-health-gateway-server/src/index.ts create mode 100644 packages/kbn-health-gateway-server/src/kibana/index.ts create mode 100644 packages/kbn-health-gateway-server/src/kibana/kibana_config.test.ts create mode 100644 packages/kbn-health-gateway-server/src/kibana/kibana_config.ts create mode 100644 packages/kbn-health-gateway-server/src/kibana/kibana_service.test.mocks.ts create mode 100644 packages/kbn-health-gateway-server/src/kibana/kibana_service.test.ts create mode 100644 packages/kbn-health-gateway-server/src/kibana/kibana_service.ts create mode 100644 packages/kbn-health-gateway-server/src/kibana/routes/index.ts create mode 100644 packages/kbn-health-gateway-server/src/kibana/routes/status.ts create mode 100644 packages/kbn-health-gateway-server/src/server/index.ts create mode 100644 packages/kbn-health-gateway-server/src/server/server.mock.ts create mode 100644 packages/kbn-health-gateway-server/src/server/server.test.mocks.ts create mode 100644 packages/kbn-health-gateway-server/src/server/server.test.ts create mode 100644 packages/kbn-health-gateway-server/src/server/server.ts create mode 100644 packages/kbn-health-gateway-server/src/server/server_config.test.ts create mode 100644 packages/kbn-health-gateway-server/src/server/server_config.ts create mode 100644 packages/kbn-health-gateway-server/tsconfig.json create mode 100644 src/cli_health_gateway/cli_health_gateway.ts create mode 100644 src/cli_health_gateway/dev.js create mode 100644 src/cli_health_gateway/dist.js create mode 100644 src/cli_health_gateway/tsconfig.json create mode 100755 src/dev/build/tasks/bin/scripts/kibana-health-gateway create mode 100755 src/dev/build/tasks/bin/scripts/kibana-health-gateway.bat create mode 100644 test/package/roles/assert_health_gateway_cli/tasks/main.yml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 86f74c0ec222f..2c7d27f19ce8f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -931,6 +931,7 @@ packages/kbn-get-repo-files @elastic/kibana-operations packages/kbn-guided-onboarding @elastic/platform-onboarding packages/kbn-handlebars @elastic/kibana-security packages/kbn-hapi-mocks @elastic/kibana-core +packages/kbn-health-gateway-server @elastic/kibana-core packages/kbn-i18n @elastic/kibana-core packages/kbn-i18n-react @elastic/kibana-core packages/kbn-import-resolver @elastic/kibana-operations diff --git a/package.json b/package.json index d1de2f9973e09..a35493afce1fc 100644 --- a/package.json +++ b/package.json @@ -335,6 +335,7 @@ "@kbn/guided-onboarding": "link:bazel-bin/packages/kbn-guided-onboarding", "@kbn/handlebars": "link:bazel-bin/packages/kbn-handlebars", "@kbn/hapi-mocks": "link:bazel-bin/packages/kbn-hapi-mocks", + "@kbn/health-gateway-server": "link:bazel-bin/packages/kbn-health-gateway-server", "@kbn/home-sample-data-card": "link:bazel-bin/packages/home/sample_data_card", "@kbn/home-sample-data-tab": "link:bazel-bin/packages/home/sample_data_tab", "@kbn/home-sample-data-types": "link:bazel-bin/packages/home/sample_data_types", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 81aec2ac8a1ba..dc4c82a2c4b94 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -244,6 +244,7 @@ filegroup( "//packages/kbn-guided-onboarding:build", "//packages/kbn-handlebars:build", "//packages/kbn-hapi-mocks:build", + "//packages/kbn-health-gateway-server:build", "//packages/kbn-i18n:build", "//packages/kbn-i18n-react:build", "//packages/kbn-import-resolver:build", @@ -593,6 +594,7 @@ filegroup( "//packages/kbn-guided-onboarding:build_types", "//packages/kbn-handlebars:build_types", "//packages/kbn-hapi-mocks:build_types", + "//packages/kbn-health-gateway-server:build_types", "//packages/kbn-i18n:build_types", "//packages/kbn-i18n-react:build_types", "//packages/kbn-import-resolver:build_types", diff --git a/packages/kbn-health-gateway-server/.gitignore b/packages/kbn-health-gateway-server/.gitignore new file mode 100644 index 0000000000000..bdcfd7bb8629c --- /dev/null +++ b/packages/kbn-health-gateway-server/.gitignore @@ -0,0 +1 @@ +scripts/.env diff --git a/packages/kbn-health-gateway-server/BUILD.bazel b/packages/kbn-health-gateway-server/BUILD.bazel new file mode 100644 index 0000000000000..1273cc7a0d7a0 --- /dev/null +++ b/packages/kbn-health-gateway-server/BUILD.bazel @@ -0,0 +1,127 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library") +load("//src/dev/bazel:index.bzl", "jsts_transpiler", "pkg_npm", "pkg_npm_types", "ts_project") + +PKG_DIRNAME = "kbn-health-gateway-server" +PKG_REQUIRE_NAME = "@kbn/health-gateway-server" + +SOURCE_FILES = glob( + [ + "**/*.ts", + ], + exclude = [ + "**/*.config.js", + "**/*.mock.*", + "**/*.test.*", + "**/*.stories.*", + "**/__snapshots__", + "**/integration_tests", + "**/mocks", + "**/scripts", + "**/storybook", + "**/test_fixtures", + "**/test_helpers", + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "package.json", +] + +RUNTIME_DEPS = [ + "@npm//@hapi/hapi", + "@npm//node-fetch", + "//packages/kbn-config", + "//packages/kbn-config-mocks", + "//packages/kbn-config-schema", + "//packages/kbn-logging-mocks", + "//packages/kbn-server-http-tools", + "//packages/kbn-utils", + "//packages/core/logging/core-logging-server-internal", +] + +TYPES_DEPS = [ + "@npm//@types/node", + "@npm//@types/jest", + "@npm//@types/hapi__hapi", + "@npm//@types/node-fetch", + "@npm//moment", + "//packages/core/base/core-base-server-internal:npm_module_types", + "//packages/core/logging/core-logging-server-internal:npm_module_types", + "//packages/kbn-config:npm_module_types", + "//packages/kbn-config-mocks:npm_module_types", + "//packages/kbn-config-schema:npm_module_types", + "//packages/kbn-logging:npm_module_types", + "//packages/kbn-logging-mocks:npm_module_types", + "//packages/kbn-server-http-tools:npm_module_types", + "//packages/kbn-utils:npm_module_types", + "//packages/kbn-utility-types:npm_module_types", + "//packages/kbn-utility-types-jest:npm_module_types", +] + +jsts_transpiler( + name = "target_node", + srcs = SRCS, + build_pkg_name = package_name(), +) + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + "//:tsconfig.bazel.json", + ], +) + +ts_project( + name = "tsc_types", + args = ['--pretty'], + srcs = SRCS, + deps = TYPES_DEPS, + declaration = True, + emit_declaration_only = True, + out_dir = "target_types", + root_dir = ".", + tsconfig = ":tsconfig", +) + +js_library( + name = PKG_DIRNAME, + srcs = NPM_MODULE_EXTRA_FILES, + deps = RUNTIME_DEPS + [":target_node"], + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +js_library( + name = "npm_module_types", + srcs = NPM_MODULE_EXTRA_FILES, + deps = RUNTIME_DEPS + [":target_node", ":tsc_types"], + package_name = PKG_REQUIRE_NAME, + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "npm_module", + deps = [":" + PKG_DIRNAME], +) + +filegroup( + name = "build", + srcs = [":npm_module"], + visibility = ["//visibility:public"], +) + +pkg_npm( + name = "build_types", + deps = [":npm_module_types"], + visibility = ["//visibility:public"], +) diff --git a/packages/kbn-health-gateway-server/README.md b/packages/kbn-health-gateway-server/README.md new file mode 100644 index 0000000000000..70de6130b82ab --- /dev/null +++ b/packages/kbn-health-gateway-server/README.md @@ -0,0 +1,77 @@ +# @kbn/health-gateway-server + +This package runs a small server called the Health Gateway, which exists to query +the status APIs of multiple Kibana instances and return an aggregated result. + +This is used by the Elastic Cloud infrastructure to run two different Kibana processes +with different `node.roles`: one process for handling UI requests, and one for background +tasks. + +## Configuration + +Similar to Kibana, the gateway has a yml configuration file that it reads from. By default +this lives alongside the `kibana.yml` at `/config/gateway.yml`. Like Kibana, +you can provide a `-c` or `--config` CLI argument to override the location of the config +file. + +For example: +```bash +$ yarn start --config /path/to/some/other/config.yml +``` +Here is a sample configuration file recommended for use in development: + +```yaml +# config/gateway.yml +server: + port: 3000 + host: 'localhost' + ssl: + enabled: true + # Using Kibana test certs + key: /path/to/packages/kbn-dev-utils/certs/kibana.key + certificate: /path/to/packages/kbn-dev-utils/certs/kibana.crt + certificateAuthorities: /path/to/packages/kbn-dev-utils/certs/ca.crt + +kibana: + hosts: + - 'https://localhost:5605' + - 'https://localhost:5606' + ssl: + # Using Kibana test certs + certificate: /path/to/packages/kbn-dev-utils/certs/kibana.crt + certificateAuthorities: /path/to/packages/kbn-dev-utils/certs/ca.crt + verificationMode: certificate + +logging: + root: + appenders: ['console'] + level: 'all' +``` + +Note that the gateway supports the same logging configuration as Kibana, including +all of the same appenders. + +## Development & Testing + +To run this locally, first you need to create a `config/gateway.yml` file. There's a +`docker-compose.yml` intended for development, which will run Elasticsearch and +two different Kibana instances for testing. Before using it, you'll want to create +a `.env` file: + +```bash +# From the /packages/kbn-health-gateway-server/scripts directory +$ cp .env.example .env +# (modify the .env settings if desired) +$ docker-compose up +``` + +This will automatically run Kibana on the ports from the sample `gateway.yml` +above (5605-5606). + +Once you have your `gateway.yml` and have started docker-compose, you can run the +server from the `/packages/kbn-health-gateway-server` directory with `yarn start`. Then you should +be able to make requests to the `/api/status` endpoint: + +```bash +$ curl "https://localhost:3000/api/status" +``` diff --git a/packages/kbn-health-gateway-server/index.ts b/packages/kbn-health-gateway-server/index.ts new file mode 100644 index 0000000000000..7d9858d07675a --- /dev/null +++ b/packages/kbn-health-gateway-server/index.ts @@ -0,0 +1,9 @@ +/* + * 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 { bootstrap } from './src'; diff --git a/packages/kbn-health-gateway-server/jest.config.js b/packages/kbn-health-gateway-server/jest.config.js new file mode 100644 index 0000000000000..8f78cb28e601f --- /dev/null +++ b/packages/kbn-health-gateway-server/jest.config.js @@ -0,0 +1,13 @@ +/* + * 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. + */ + +module.exports = { + preset: '@kbn/test/jest_node', + rootDir: '../..', + roots: ['/packages/kbn-health-gateway-server'], +}; diff --git a/packages/kbn-health-gateway-server/kibana.jsonc b/packages/kbn-health-gateway-server/kibana.jsonc new file mode 100644 index 0000000000000..5c31c05c82461 --- /dev/null +++ b/packages/kbn-health-gateway-server/kibana.jsonc @@ -0,0 +1,7 @@ +{ + "type": "shared-server", + "id": "@kbn/health-gateway-server", + "owner": "@elastic/kibana-core", + "runtimeDeps": [], + "typeDeps": [] +} diff --git a/packages/kbn-health-gateway-server/package.json b/packages/kbn-health-gateway-server/package.json new file mode 100644 index 0000000000000..d38191a879a84 --- /dev/null +++ b/packages/kbn-health-gateway-server/package.json @@ -0,0 +1,12 @@ +{ + "name": "@kbn/health-gateway-server", + "private": true, + "version": "1.0.0", + "main": "./target_node/index.js", + "author": "Kibana Core", + "license": "SSPL-1.0 OR Elastic License 2.0", + "scripts": { + "start": "node ../../bazel-bin/packages/kbn-health-gateway-server/target_node/scripts/init.js" + }, + "types": "./target_types/index.d.ts" +} diff --git a/packages/kbn-health-gateway-server/scripts/.env.example b/packages/kbn-health-gateway-server/scripts/.env.example new file mode 100644 index 0000000000000..e63dc7c3700eb --- /dev/null +++ b/packages/kbn-health-gateway-server/scripts/.env.example @@ -0,0 +1,22 @@ +# Password for the 'elastic' user (at least 6 characters) +ELASTIC_PASSWORD=changeme + +# Password for the 'kibana_system' user (at least 6 characters) +KIBANA_PASSWORD=changeme + +# Version of Elastic products +STACK_VERSION=8.4.0 + +# Set to 'basic' or 'trial' to automatically start the 30-day trial +LICENSE=basic + +# Port to expose Elasticsearch HTTP API to the host +ES_PORT=9205 + +# Ports to expose Kibana to the host +KIBANA_01_PORT=5605 +KIBANA_02_PORT=5606 + +# Increase or decrease based on the available host memory (in bytes) +MEM_LIMIT=2147483648 + diff --git a/packages/kbn-health-gateway-server/scripts/docker-compose.yml b/packages/kbn-health-gateway-server/scripts/docker-compose.yml new file mode 100644 index 0000000000000..460119688265a --- /dev/null +++ b/packages/kbn-health-gateway-server/scripts/docker-compose.yml @@ -0,0 +1,89 @@ +version: "3" + +services: + setup: + depends_on: + elasticsearch: + condition: service_healthy + image: docker.elastic.co/elasticsearch/elasticsearch:${STACK_VERSION} + # Adapted from https://github.com/elastic/elasticsearch/blob/main/docs/reference/setup/install/docker/docker-compose.yml + command: > + bash -c ' + echo "Setting kibana_system password"; + until curl -s -X POST -u "elastic:${ELASTIC_PASSWORD}" -H "Content-Type: application/json" http://elasticsearch:9200/_security/user/kibana_system/_password -d "{\"password\":\"${KIBANA_PASSWORD}\"}" | grep -q "^{}"; do sleep 10; done; + echo "All done!"; + ' + + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:${STACK_VERSION} + volumes: + - ../../kbn-dev-utils/certs:/usr/share/elasticsearch/config/certs + ports: + - ${ES_PORT}:9200 + environment: + node.name: elasticsearch + cluster.name: health-gateway-test-cluster + discovery.type: single-node + ELASTIC_PASSWORD: ${ELASTIC_PASSWORD} + bootstrap.memory_lock: true + xpack.security.enabled: true + xpack.license.self_generated.type: ${LICENSE} + mem_limit: ${MEM_LIMIT} + ulimits: + memlock: + soft: -1 + hard: -1 + healthcheck: + test: + [ + "CMD-SHELL", + "curl -s http://localhost:9200 | grep -q 'missing authentication credentials'", + ] + interval: 10s + timeout: 10s + retries: 120 + + kbn01: + depends_on: + elasticsearch: + condition: service_healthy + image: docker.elastic.co/kibana/kibana:${STACK_VERSION} + volumes: + - ../../kbn-dev-utils/certs:/usr/share/kibana/config/certs + ports: + - ${KIBANA_01_PORT}:5601 + environment: + SERVERNAME: kbn01 + NODE_ROLES: '["ui"]' + STATUS_ALLOWANONYMOUS: true + SERVER_SSL_ENABLED: true + SERVER_SSL_KEY: config/certs/kibana.key + SERVER_SSL_CERTIFICATE: config/certs/kibana.crt + SERVER_SSL_CERTIFICATEAUTHORITIES: config/certs/ca.crt + ELASTICSEARCH_HOSTS: http://elasticsearch:9200 + ELASTICSEARCH_USERNAME: kibana_system + ELASTICSEARCH_PASSWORD: ${KIBANA_PASSWORD} + mem_limit: ${MEM_LIMIT} + + kbn02: + depends_on: + elasticsearch: + condition: service_healthy + image: docker.elastic.co/kibana/kibana:${STACK_VERSION} + volumes: + - ../../kbn-dev-utils/certs:/usr/share/kibana/config/certs + ports: + - ${KIBANA_02_PORT}:5601 + environment: + SERVERNAME: kbn02 + NODE_ROLES: '["background_tasks"]' + STATUS_ALLOWANONYMOUS: true + SERVER_SSL_ENABLED: true + SERVER_SSL_KEY: config/certs/kibana.key + SERVER_SSL_CERTIFICATE: config/certs/kibana.crt + SERVER_SSL_CERTIFICATEAUTHORITIES: config/certs/ca.crt + ELASTICSEARCH_HOSTS: http://elasticsearch:9200 + ELASTICSEARCH_USERNAME: kibana_system + ELASTICSEARCH_PASSWORD: ${KIBANA_PASSWORD} + mem_limit: ${MEM_LIMIT} + diff --git a/packages/kbn-health-gateway-server/scripts/init.ts b/packages/kbn-health-gateway-server/scripts/init.ts new file mode 100644 index 0000000000000..dcef64f3f68b9 --- /dev/null +++ b/packages/kbn-health-gateway-server/scripts/init.ts @@ -0,0 +1,13 @@ +/* + * 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 { bootstrap } from '../src'; + +(async () => { + await bootstrap(); +})(); diff --git a/packages/kbn-health-gateway-server/src/config/config_service.test.mocks.ts b/packages/kbn-health-gateway-server/src/config/config_service.test.mocks.ts new file mode 100644 index 0000000000000..80bc0db6c88fa --- /dev/null +++ b/packages/kbn-health-gateway-server/src/config/config_service.test.mocks.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 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 { + createTestEnv, + configServiceMock as configMock, + rawConfigServiceMock as rawMock, +} from '@kbn/config-mocks'; + +export const envCreateDefaultMock = jest.fn().mockImplementation(() => createTestEnv); +export const configServiceMock = jest.fn().mockImplementation(() => configMock.create()); +export const rawConfigServiceMock = jest.fn().mockImplementation(() => rawMock.create()); +jest.doMock('@kbn/config', () => ({ + Env: { + createDefault: envCreateDefaultMock, + }, + ConfigService: configServiceMock, + RawConfigService: rawConfigServiceMock, +})); diff --git a/packages/kbn-health-gateway-server/src/config/config_service.test.ts b/packages/kbn-health-gateway-server/src/config/config_service.test.ts new file mode 100644 index 0000000000000..351467085195d --- /dev/null +++ b/packages/kbn-health-gateway-server/src/config/config_service.test.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { + envCreateDefaultMock, + configServiceMock, + rawConfigServiceMock, +} from './config_service.test.mocks'; +import { loggerMock, MockedLogger } from '@kbn/logging-mocks'; +import { fromRoot } from '@kbn/utils'; +import { getConfigService } from './config_service'; + +const DEFAULT_CONFIG_PATH = fromRoot('config/gateway.yml'); + +describe('getConfigService', () => { + let logger: MockedLogger; + + beforeEach(() => { + logger = loggerMock.create(); + }); + + afterEach(() => { + envCreateDefaultMock.mockClear(); + configServiceMock.mockClear(); + rawConfigServiceMock.mockClear(); + }); + + test('instantiates RawConfigService with the default config path', () => { + const oldArgv = process.argv; + process.argv = []; + + getConfigService({ logger }); + expect(rawConfigServiceMock).toHaveBeenCalledTimes(1); + expect(rawConfigServiceMock).toHaveBeenCalledWith([DEFAULT_CONFIG_PATH]); + + process.argv = oldArgv; + }); + + test('instantiates RawConfigService with a custom config path provided via -c flag', () => { + const oldArgv = process.argv; + process.argv = ['-a', 'bc', '-c', 'a/b/c.yml', '-x', 'yz']; + + getConfigService({ logger }); + + expect(rawConfigServiceMock).toHaveBeenCalledTimes(1); + expect(rawConfigServiceMock).toHaveBeenCalledWith(['a/b/c.yml']); + + process.argv = oldArgv; + }); + + test('instantiates RawConfigService with a custom config path provided via --config flag', () => { + const oldArgv = process.argv; + process.argv = ['-a', 'bc', '--config', 'a/b/c.yml', '-x', 'yz']; + + getConfigService({ logger }); + + expect(rawConfigServiceMock).toHaveBeenCalledTimes(1); + expect(rawConfigServiceMock).toHaveBeenCalledWith(['a/b/c.yml']); + + process.argv = oldArgv; + }); + + test('creates default env', async () => { + const oldArgv = process.argv; + process.argv = []; + + getConfigService({ logger }); + expect(envCreateDefaultMock).toHaveBeenCalledTimes(1); + expect(envCreateDefaultMock.mock.calls[0][1].configs).toEqual([DEFAULT_CONFIG_PATH]); + + process.argv = oldArgv; + }); + + test('attempts to load the config', () => { + const mockLoadConfig = jest.fn(); + rawConfigServiceMock.mockImplementationOnce(() => ({ + loadConfig: mockLoadConfig, + })); + getConfigService({ logger }); + expect(mockLoadConfig).toHaveBeenCalledTimes(1); + }); + + test('instantiates the config service', async () => { + getConfigService({ logger }); + expect(configServiceMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/kbn-health-gateway-server/src/config/config_service.ts b/packages/kbn-health-gateway-server/src/config/config_service.ts new file mode 100644 index 0000000000000..059a1773d29c3 --- /dev/null +++ b/packages/kbn-health-gateway-server/src/config/config_service.ts @@ -0,0 +1,44 @@ +/* + * 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 { fromRoot, REPO_ROOT } from '@kbn/utils'; +import type { LoggerFactory } from '@kbn/logging'; +import { ConfigService as KbnConfigService, CliArgs, Env, RawConfigService } from '@kbn/config'; +import { getArgValues } from './read_argv'; + +const CONFIG_CLI_FLAGS = ['-c', '--config']; +const DEFAULT_CONFIG_PATH = fromRoot('config/gateway.yml'); + +// These `cliArgs` are required by `Env` for use with Kibana, +// however they have no effect on the health gateway. +const KIBANA_CLI_ARGS: CliArgs = { + dev: false, + silent: false, + watch: false, + basePath: false, + disableOptimizer: true, + cache: false, + dist: false, + oss: false, + runExamples: false, +}; + +export function getConfigService({ logger }: { logger: LoggerFactory }) { + const configPathOverride = getArgValues(process.argv, CONFIG_CLI_FLAGS); + const configPath = configPathOverride.length ? configPathOverride : [DEFAULT_CONFIG_PATH]; + + const rawConfigService = new RawConfigService(configPath); + rawConfigService.loadConfig(); + + const env = Env.createDefault(REPO_ROOT, { + configs: configPath, + cliArgs: KIBANA_CLI_ARGS, + }); + + return new KbnConfigService(rawConfigService, env, logger); +} diff --git a/packages/kbn-health-gateway-server/src/config/index.ts b/packages/kbn-health-gateway-server/src/config/index.ts new file mode 100644 index 0000000000000..ce365290e7028 --- /dev/null +++ b/packages/kbn-health-gateway-server/src/config/index.ts @@ -0,0 +1,9 @@ +/* + * 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 { getConfigService } from './config_service'; diff --git a/packages/kbn-health-gateway-server/src/config/read_argv.test.ts b/packages/kbn-health-gateway-server/src/config/read_argv.test.ts new file mode 100644 index 0000000000000..57208027ec042 --- /dev/null +++ b/packages/kbn-health-gateway-server/src/config/read_argv.test.ts @@ -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 { getArgValues } from './read_argv'; + +describe('getArgValues', () => { + it('retrieve the arg value from the provided argv arguments', () => { + const argValues = getArgValues( + ['--config', 'my-config', '--foo', '-b', 'bar', '--config', 'other-config', '--baz'], + '--config' + ); + expect(argValues).toEqual(['my-config', 'other-config']); + }); + + it('accept aliases', () => { + const argValues = getArgValues( + ['--config', 'my-config', '--foo', '-b', 'bar', '-c', 'other-config', '--baz'], + ['--config', '-c'] + ); + expect(argValues).toEqual(['my-config', 'other-config']); + }); + + it('returns an empty array when the arg is not found', () => { + const argValues = getArgValues( + ['--config', 'my-config', '--foo', '-b', 'bar', '-c', 'other-config', '--baz'], + '--unicorn' + ); + expect(argValues).toEqual([]); + }); + + it('ignores the flag when no value is provided', () => { + const argValues = getArgValues( + ['-c', 'my-config', '--foo', '-b', 'bar', '--config'], + ['--config', '-c'] + ); + expect(argValues).toEqual(['my-config']); + }); +}); diff --git a/packages/kbn-health-gateway-server/src/config/read_argv.ts b/packages/kbn-health-gateway-server/src/config/read_argv.ts new file mode 100644 index 0000000000000..fd2be4d6d1776 --- /dev/null +++ b/packages/kbn-health-gateway-server/src/config/read_argv.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 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. + */ + +/** + * Borrowed from @kbn/apm-config-loader. + */ +export const getArgValues = (argv: string[], flag: string | string[]): string[] => { + const flags = typeof flag === 'string' ? [flag] : flag; + const values: string[] = []; + for (let i = 0; i < argv.length; i++) { + if (flags.includes(argv[i]) && argv[i + 1]) { + values.push(argv[++i]); + } + } + return values; +}; diff --git a/packages/kbn-health-gateway-server/src/index.ts b/packages/kbn-health-gateway-server/src/index.ts new file mode 100644 index 0000000000000..e069153eef3ff --- /dev/null +++ b/packages/kbn-health-gateway-server/src/index.ts @@ -0,0 +1,67 @@ +/* + * 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 { ServiceConfigDescriptor } from '@kbn/core-base-server-internal'; +import { + config as loggingConfig, + LoggingSystem, + LoggingConfigType, +} from '@kbn/core-logging-server-internal'; +import { getConfigService } from './config'; +import { config as kibanaConfig, KibanaService } from './kibana'; +import { config as serverConfig, Server, ServerStart } from './server'; + +export async function bootstrap() { + const loggingSystem = new LoggingSystem(); + const logger = loggingSystem.asLoggerFactory(); + const configService = getConfigService({ logger }); + + const configDescriptors: ServiceConfigDescriptor[] = [loggingConfig, kibanaConfig, serverConfig]; + for (const { path, schema } of configDescriptors) { + configService.setSchema(path, schema); + } + + await configService.validate(); + + await loggingSystem.upgrade(configService.atPathSync('logging')); + const log = logger.get('root'); + + let server: Server; + let serverStart: ServerStart; + try { + server = new Server({ config: configService, logger }); + serverStart = await server.start(); + } catch (e) { + log.error(`Failed to start Server: ${e}`); + process.exit(1); + } + + let kibanaService: KibanaService; + try { + kibanaService = new KibanaService({ config: configService, logger }); + await kibanaService.start({ server: serverStart }); + } catch (e) { + log.error(`Failed to start Kibana service: ${e}`); + process.exit(1); + } + + const attemptGracefulShutdown = async (exitCode: number = 0) => { + await server.stop(); + kibanaService.stop(); + await loggingSystem.stop(); + process.exit(exitCode); + }; + + process.on('unhandledRejection', async (err: Error) => { + log.error(err); + await attemptGracefulShutdown(1); + }); + + process.on('SIGINT', async () => await attemptGracefulShutdown()); + process.on('SIGTERM', async () => await attemptGracefulShutdown()); +} diff --git a/packages/kbn-health-gateway-server/src/kibana/index.ts b/packages/kbn-health-gateway-server/src/kibana/index.ts new file mode 100644 index 0000000000000..23fb58ebd356e --- /dev/null +++ b/packages/kbn-health-gateway-server/src/kibana/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { config } from './kibana_config'; +export { KibanaService } from './kibana_service'; diff --git a/packages/kbn-health-gateway-server/src/kibana/kibana_config.test.ts b/packages/kbn-health-gateway-server/src/kibana/kibana_config.test.ts new file mode 100644 index 0000000000000..841659b445a03 --- /dev/null +++ b/packages/kbn-health-gateway-server/src/kibana/kibana_config.test.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { config } from './kibana_config'; + +describe('kibana config', () => { + test('has defaults for config', () => { + const configSchema = config.schema; + const obj = { + hosts: ['http://localhost:5601'], + }; + expect(configSchema.validate(obj)).toMatchInlineSnapshot(` + Object { + "hosts": Array [ + "http://localhost:5601", + ], + "requestTimeout": "PT30S", + "ssl": Object { + "verificationMode": "full", + }, + } + `); + }); + + describe('hosts', () => { + test('accepts valid hosts', () => { + const configSchema = config.schema; + const validHosts = ['http://some.host:1234', 'https://some.other.host']; + expect(configSchema.validate({ hosts: validHosts })).toEqual( + expect.objectContaining({ hosts: validHosts }) + ); + }); + + test('throws if invalid hosts', () => { + const invalidHosts = ['https://localhost:3000', 'abcxyz']; + const configSchema = config.schema; + expect(() => configSchema.validate({ hosts: invalidHosts })).toThrowError( + '[hosts.1]: expected URI with scheme [http|https].' + ); + }); + }); + + describe('ssl', () => { + test('accepts valid ssl config', () => { + const configSchema = config.schema; + const valid = { + certificate: '/herp/derp', + certificateAuthorities: ['/beep/boop'], + verificationMode: 'certificate', + }; + expect( + configSchema.validate({ + hosts: ['http://localhost:5601'], + ssl: valid, + }) + ).toEqual(expect.objectContaining({ ssl: valid })); + }); + + test('throws if invalid ssl config', () => { + const configSchema = config.schema; + const hosts = ['http://localhost:5601']; + const invalid = { + verificationMode: 'nope', + }; + expect(() => configSchema.validate({ hosts, ssl: invalid })) + .toThrowErrorMatchingInlineSnapshot(` + "[ssl.verificationMode]: types that failed validation: + - [ssl.verificationMode.0]: expected value to equal [none] + - [ssl.verificationMode.1]: expected value to equal [certificate] + - [ssl.verificationMode.2]: expected value to equal [full]" + `); + }); + }); +}); diff --git a/packages/kbn-health-gateway-server/src/kibana/kibana_config.ts b/packages/kbn-health-gateway-server/src/kibana/kibana_config.ts new file mode 100644 index 0000000000000..b44af3cbcd2da --- /dev/null +++ b/packages/kbn-health-gateway-server/src/kibana/kibana_config.ts @@ -0,0 +1,109 @@ +/* + * 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 { readFileSync } from 'fs'; +import type { Duration } from 'moment'; +import { schema, TypeOf } from '@kbn/config-schema'; +import { ServiceConfigDescriptor } from '@kbn/core-base-server-internal'; + +const hostURISchema = schema.uri({ scheme: ['http', 'https'] }); + +const configSchema = schema.object({ + hosts: schema.arrayOf(hostURISchema, { + minSize: 1, + }), + requestTimeout: schema.duration({ defaultValue: '30s' }), + ssl: schema.object({ + verificationMode: schema.oneOf( + [schema.literal('none'), schema.literal('certificate'), schema.literal('full')], + { defaultValue: 'full' } + ), + certificateAuthorities: schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string(), { minSize: 1 })]) + ), + certificate: schema.maybe(schema.string()), + }), +}); + +export type KibanaConfigType = TypeOf; + +export const config: ServiceConfigDescriptor = { + path: 'kibana' as const, + schema: configSchema, +}; + +export class KibanaConfig { + /** + * Kibana hosts that the gateway will connect to. + */ + public readonly hosts: string[]; + + /** + * Timeout after which HTTP requests to the Kibana hosts will be aborted. + */ + public readonly requestTimeout: Duration; + + /** + * Settings to configure SSL connection between the gateway and Kibana hosts. + */ + public readonly ssl: SslConfig; + + constructor(rawConfig: KibanaConfigType) { + this.hosts = rawConfig.hosts; + this.requestTimeout = rawConfig.requestTimeout; + + const { verificationMode } = rawConfig.ssl; + const { certificate, certificateAuthorities } = readKeyAndCerts(rawConfig); + + this.ssl = { + certificate, + certificateAuthorities, + verificationMode, + }; + } +} + +interface SslConfig { + verificationMode: 'none' | 'certificate' | 'full'; + certificate?: string; + certificateAuthorities?: string[]; +} + +const readKeyAndCerts = (rawConfig: KibanaConfigType) => { + let certificate: string | undefined; + let certificateAuthorities: string[] | undefined; + + const addCAs = (ca: string[] | undefined) => { + if (ca && ca.length) { + certificateAuthorities = [...(certificateAuthorities || []), ...ca]; + } + }; + + if (rawConfig.ssl.certificate) { + certificate = readFile(rawConfig.ssl.certificate); + } + + const ca = rawConfig.ssl.certificateAuthorities; + if (ca) { + const parsed: string[] = []; + const paths = Array.isArray(ca) ? ca : [ca]; + if (paths.length > 0) { + for (const path of paths) { + parsed.push(readFile(path)); + } + addCAs(parsed); + } + } + + return { + certificate, + certificateAuthorities, + }; +}; + +const readFile = (file: string) => readFileSync(file, 'utf8'); diff --git a/packages/kbn-health-gateway-server/src/kibana/kibana_service.test.mocks.ts b/packages/kbn-health-gateway-server/src/kibana/kibana_service.test.mocks.ts new file mode 100644 index 0000000000000..36934786ac03a --- /dev/null +++ b/packages/kbn-health-gateway-server/src/kibana/kibana_service.test.mocks.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 mockReadFileSync = jest.fn(); +jest.doMock('fs', () => ({ readFileSync: mockReadFileSync })); diff --git a/packages/kbn-health-gateway-server/src/kibana/kibana_service.test.ts b/packages/kbn-health-gateway-server/src/kibana/kibana_service.test.ts new file mode 100644 index 0000000000000..5940e8de9682b --- /dev/null +++ b/packages/kbn-health-gateway-server/src/kibana/kibana_service.test.ts @@ -0,0 +1,57 @@ +/* + * 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 { configServiceMock, IConfigServiceMock } from '@kbn/config-mocks'; +import { loggerMock, MockedLogger } from '@kbn/logging-mocks'; +import type { ServerStart } from '../server'; +import { serverMock } from '../server/server.mock'; +import { mockReadFileSync } from './kibana_service.test.mocks'; +import { KibanaService } from './kibana_service'; + +describe('KibanaService', () => { + let config: IConfigServiceMock; + let logger: MockedLogger; + let server: ServerStart; + const mockConfig = { + hosts: ['https://localhost:5605', 'https://localhost:5606'], + requestTimeout: '30s', + ssl: { + certificate: '/herp/derp', + certificateAuthorities: '/beep/boop', + verificationMode: 'certificate', + }, + }; + + beforeEach(() => { + mockReadFileSync.mockReset(); + mockReadFileSync.mockImplementation((path: string) => `content-of-${path}`); + config = configServiceMock.create(); + config.atPathSync.mockReturnValue(mockConfig); + logger = loggerMock.create(); + server = serverMock.createStartContract(); + }); + + describe('start', () => { + test(`doesn't return a start contract`, async () => { + const kibanaService = new KibanaService({ config, logger }); + const kibanaStart = await kibanaService.start({ server }); + expect(kibanaStart).toBeUndefined(); + }); + + test('registers /api/status route with the server', async () => { + const kibanaService = new KibanaService({ config, logger }); + await kibanaService.start({ server }); + expect(server.addRoute).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + path: '/api/status', + }) + ); + }); + }); +}); diff --git a/packages/kbn-health-gateway-server/src/kibana/kibana_service.ts b/packages/kbn-health-gateway-server/src/kibana/kibana_service.ts new file mode 100644 index 0000000000000..f1ef43e2b70b2 --- /dev/null +++ b/packages/kbn-health-gateway-server/src/kibana/kibana_service.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 type { IConfigService } from '@kbn/config'; +import type { Logger, LoggerFactory } from '@kbn/logging'; +import { ServerStart } from '../server'; +import { createStatusRoute } from './routes'; + +interface KibanaServiceStartDependencies { + server: ServerStart; +} + +interface KibanaServiceDependencies { + logger: LoggerFactory; + config: IConfigService; +} + +/** + * A service to interact with the configured `kibana.hosts`. + */ +export class KibanaService { + private readonly log: Logger; + private readonly config: IConfigService; + + constructor({ logger, config }: KibanaServiceDependencies) { + this.log = logger.get('kibana-service'); + this.config = config; + } + + async start({ server }: KibanaServiceStartDependencies) { + server.addRoute(createStatusRoute({ config: this.config, log: this.log })); + } + + stop() { + // nothing to do here yet + } +} diff --git a/packages/kbn-health-gateway-server/src/kibana/routes/index.ts b/packages/kbn-health-gateway-server/src/kibana/routes/index.ts new file mode 100644 index 0000000000000..f7fcbda3c6d6e --- /dev/null +++ b/packages/kbn-health-gateway-server/src/kibana/routes/index.ts @@ -0,0 +1,9 @@ +/* + * 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 { createStatusRoute } from './status'; diff --git a/packages/kbn-health-gateway-server/src/kibana/routes/status.ts b/packages/kbn-health-gateway-server/src/kibana/routes/status.ts new file mode 100644 index 0000000000000..1ad66107013e3 --- /dev/null +++ b/packages/kbn-health-gateway-server/src/kibana/routes/status.ts @@ -0,0 +1,146 @@ +/* + * 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 https from 'https'; +import { URL } from 'url'; +import type { Request, ResponseToolkit } from '@hapi/hapi'; +import nodeFetch, { RequestInit, Response } from 'node-fetch'; +import type { IConfigService } from '@kbn/config'; +import type { Logger } from '@kbn/logging'; +import type { KibanaConfigType } from '../kibana_config'; +import { KibanaConfig } from '../kibana_config'; + +const HTTPS = 'https:'; + +const GATEWAY_STATUS_ROUTE = '/api/status'; +const KIBANA_STATUS_ROUTE = '/api/status'; + +interface StatusRouteDependencies { + log: Logger; + config: IConfigService; +} + +type Fetch = (path: string) => Promise; + +export function createStatusRoute({ config, log }: StatusRouteDependencies) { + const kibanaConfig = new KibanaConfig(config.atPathSync('kibana')); + const fetch = configureFetch(kibanaConfig); + + return { + method: 'GET', + path: GATEWAY_STATUS_ROUTE, + handler: async (req: Request, h: ResponseToolkit) => { + const responses = await fetchKibanaStatuses({ fetch, kibanaConfig, log }); + const { body, statusCode } = mergeStatusResponses(responses); + return h.response(body).type('application/json').code(statusCode); + }, + }; +} + +async function fetchKibanaStatuses({ + fetch, + kibanaConfig, + log, +}: { + fetch: Fetch; + kibanaConfig: KibanaConfig; + log: Logger; +}) { + const requests = await Promise.allSettled( + kibanaConfig.hosts.map(async (host) => { + log.debug(`Fetching response from ${host}${KIBANA_STATUS_ROUTE}`); + const response = fetch(`${host}${KIBANA_STATUS_ROUTE}`).then((res) => res.json()); + return response; + }) + ); + + return requests.map((r, i) => { + if (r.status === 'rejected') { + log.error(`Unable to retrieve status from ${kibanaConfig.hosts[i]}${KIBANA_STATUS_ROUTE}`); + } else { + log.info( + `Got response from ${kibanaConfig.hosts[i]}${KIBANA_STATUS_ROUTE}: ${JSON.stringify( + r.value.status?.overall ? r.value.status.overall : r.value + )}` + ); + } + return r; + }); +} + +function mergeStatusResponses( + responses: Array | PromiseRejectedResult> +) { + let statusCode = 200; + for (const response of responses) { + if (response.status === 'rejected') { + statusCode = 503; + } + } + + return { + body: {}, // Need to determine what response body, if any, we want to include + statusCode, + }; +} + +function generateAgentConfig(sslConfig: KibanaConfig['ssl']) { + const options: https.AgentOptions = { + ca: sslConfig.certificateAuthorities, + cert: sslConfig.certificate, + }; + + const verificationMode = sslConfig.verificationMode; + switch (verificationMode) { + case 'none': + options.rejectUnauthorized = false; + break; + case 'certificate': + options.rejectUnauthorized = true; + // by default, NodeJS is checking the server identify + options.checkServerIdentity = () => undefined; + break; + case 'full': + options.rejectUnauthorized = true; + break; + default: + throw new Error(`Unknown ssl verificationMode: ${verificationMode}`); + } + + return options; +} + +function configureFetch(kibanaConfig: KibanaConfig) { + let agent: https.Agent; + + return async (url: string) => { + const { protocol } = new URL(url); + if (protocol === HTTPS && !agent) { + agent = new https.Agent(generateAgentConfig(kibanaConfig.ssl)); + } + + const controller = new AbortController(); + const timeoutId = setTimeout( + () => controller.abort(), + kibanaConfig.requestTimeout.asMilliseconds() + ); + + const fetchOptions: RequestInit = { + ...(protocol === HTTPS && { agent }), + signal: controller.signal, + }; + try { + const response = await nodeFetch(url, fetchOptions); + clearTimeout(timeoutId); + return response; + } catch (e) { + clearTimeout(timeoutId); + throw e; + } + }; +} diff --git a/packages/kbn-health-gateway-server/src/server/index.ts b/packages/kbn-health-gateway-server/src/server/index.ts new file mode 100644 index 0000000000000..698725affdec5 --- /dev/null +++ b/packages/kbn-health-gateway-server/src/server/index.ts @@ -0,0 +1,11 @@ +/* + * 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 { config } from './server_config'; +export type { ServerStart } from './server'; +export { Server } from './server'; diff --git a/packages/kbn-health-gateway-server/src/server/server.mock.ts b/packages/kbn-health-gateway-server/src/server/server.mock.ts new file mode 100644 index 0000000000000..c62449d321752 --- /dev/null +++ b/packages/kbn-health-gateway-server/src/server/server.mock.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 type { PublicMethodsOf } from '@kbn/utility-types'; +import type { ServerStart } from './server'; +import { Server } from './server'; + +const createStartMock = (): jest.Mocked => ({ + addRoute: jest.fn(), +}); + +type ServerContract = PublicMethodsOf; +const createMock = (): jest.Mocked => { + const service: jest.Mocked = { + start: jest.fn(), + stop: jest.fn(), + }; + + service.start.mockResolvedValue(createStartMock()); + + return service; +}; + +export const serverMock = { + create: createMock, + createStartContract: createStartMock, +}; diff --git a/packages/kbn-health-gateway-server/src/server/server.test.mocks.ts b/packages/kbn-health-gateway-server/src/server/server.test.mocks.ts new file mode 100644 index 0000000000000..543fe9b29e9cc --- /dev/null +++ b/packages/kbn-health-gateway-server/src/server/server.test.mocks.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { sslSchema, getServerOptions, getListenerOptions } from '@kbn/server-http-tools'; + +export const hapiStartMock = jest.fn(); +export const hapiStopMock = jest.fn(); +export const hapiRouteMock = jest.fn(); +export const createServerMock = jest.fn().mockImplementation(() => ({ + info: { uri: 'http://localhost:3000' }, + start: hapiStartMock, + stop: hapiStopMock, + route: hapiRouteMock, +})); +export const getServerOptionsMock = jest.fn().mockImplementation(getServerOptions); +export const getListenerOptionsMock = jest.fn().mockImplementation(getListenerOptions); + +jest.doMock('@kbn/server-http-tools', () => ({ + createServer: createServerMock, + getServerOptions: getServerOptionsMock, + getListenerOptions: getListenerOptionsMock, + sslSchema, + SslConfig: jest.fn(), +})); diff --git a/packages/kbn-health-gateway-server/src/server/server.test.ts b/packages/kbn-health-gateway-server/src/server/server.test.ts new file mode 100644 index 0000000000000..e0a65229c3374 --- /dev/null +++ b/packages/kbn-health-gateway-server/src/server/server.test.ts @@ -0,0 +1,95 @@ +/* + * 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 { + createServerMock, + getServerOptionsMock, + getListenerOptionsMock, + hapiStartMock, + hapiStopMock, + hapiRouteMock, +} from './server.test.mocks'; +import { configServiceMock, IConfigServiceMock } from '@kbn/config-mocks'; +import { loggerMock, MockedLogger } from '@kbn/logging-mocks'; +import { Server } from './server'; + +const mockConfig = { + port: 3000, + host: 'localhost', + maxPayload: { getValueInBytes: () => '1048576b' }, + keepaliveTimeout: 120000, + shutdownTimeout: '30s', + socketTimeout: 120000, +}; + +describe('Server', () => { + let config: IConfigServiceMock; + let logger: MockedLogger; + + beforeEach(() => { + config = configServiceMock.create(); + config.atPathSync.mockReturnValue(mockConfig); + logger = loggerMock.create(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('start', () => { + test('logs the uri on server start', async () => { + const server = new Server({ config, logger }); + await server.start(); + expect(logger.info).toHaveBeenCalledWith('Server running on http://localhost:3000'); + }); + + test('provides the correct server options', async () => { + const server = new Server({ config, logger }); + await server.start(); + expect(createServerMock).toHaveBeenCalledTimes(1); + expect(getServerOptionsMock).toHaveBeenCalledTimes(1); + expect(getServerOptionsMock.mock.calls[0][0]).toEqual( + expect.objectContaining({ ...mockConfig }) + ); + expect(getListenerOptionsMock.mock.calls[0][0]).toEqual( + expect.objectContaining({ ...mockConfig }) + ); + }); + + test('starts the Hapi server', async () => { + const server = new Server({ config, logger }); + await server.start(); + expect(hapiStartMock).toHaveBeenCalledTimes(1); + }); + + describe('addRoute', () => { + test('registers route with Hapi', async () => { + const server = new Server({ config, logger }); + const { addRoute } = await server.start(); + addRoute({ + method: 'GET', + path: '/api/whatever', + }); + expect(hapiRouteMock).toHaveBeenCalledTimes(1); + expect(hapiRouteMock).toHaveBeenCalledWith({ + method: 'GET', + path: '/api/whatever', + }); + }); + }); + }); + + describe('stop', () => { + test('attempts graceful shutdown', async () => { + const server = new Server({ config, logger }); + await server.start(); + await server.stop(); + expect(hapiStopMock).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/kbn-health-gateway-server/src/server/server.ts b/packages/kbn-health-gateway-server/src/server/server.ts new file mode 100644 index 0000000000000..90123cf70380d --- /dev/null +++ b/packages/kbn-health-gateway-server/src/server/server.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 type { Server as HapiServer, ServerRoute as HapiServerRoute } from '@hapi/hapi'; +import { createServer, getServerOptions, getListenerOptions } from '@kbn/server-http-tools'; +import type { IConfigService } from '@kbn/config'; +import type { Logger, LoggerFactory } from '@kbn/logging'; +import { ServerConfig } from './server_config'; +import type { ServerConfigType } from './server_config'; + +interface ServerDeps { + logger: LoggerFactory; + config: IConfigService; +} + +type RouteDefinition = HapiServerRoute; + +export interface ServerStart { + addRoute: (routeDefinition: RouteDefinition) => void; +} + +/** + * A very thin wrapper around Hapi, which only exposes the functionality we + * need for this app. + */ +export class Server { + private readonly log: Logger; + private readonly config: IConfigService; + private server?: HapiServer; + + constructor({ logger, config }: ServerDeps) { + this.log = logger.get('server'); + this.config = config; + } + + async start(): Promise { + const serverConfig = new ServerConfig(this.config.atPathSync('server')); + this.server = createServer(getServerOptions(serverConfig), getListenerOptions(serverConfig)); + + await this.server.start(); + this.log.info(`Server running on ${this.server.info.uri}`); + + return { + addRoute: (definition) => { + this.log.debug(`registering route handler for [${definition.path}]`); + this.server!.route(definition); + }, + }; + } + + async stop() { + this.log.debug('Attempting graceful shutdown'); + if (this.server) { + await this.server.stop(); + } + } +} diff --git a/packages/kbn-health-gateway-server/src/server/server_config.test.ts b/packages/kbn-health-gateway-server/src/server/server_config.test.ts new file mode 100644 index 0000000000000..66f82c0f1502b --- /dev/null +++ b/packages/kbn-health-gateway-server/src/server/server_config.test.ts @@ -0,0 +1,191 @@ +/* + * 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 { config, ServerConfig } from './server_config'; + +describe('server config', () => { + test('has defaults for config', () => { + const configSchema = config.schema; + const obj = {}; + expect(configSchema.validate(obj)).toMatchInlineSnapshot(` + Object { + "host": "localhost", + "keepaliveTimeout": 120000, + "maxPayload": ByteSizeValue { + "valueInBytes": 1048576, + }, + "port": 3000, + "shutdownTimeout": "PT30S", + "socketTimeout": 120000, + "ssl": Object { + "cipherSuites": Array [ + "TLS_AES_256_GCM_SHA384", + "TLS_CHACHA20_POLY1305_SHA256", + "TLS_AES_128_GCM_SHA256", + "ECDHE-RSA-AES128-GCM-SHA256", + "ECDHE-ECDSA-AES128-GCM-SHA256", + "ECDHE-RSA-AES256-GCM-SHA384", + "ECDHE-ECDSA-AES256-GCM-SHA384", + "DHE-RSA-AES128-GCM-SHA256", + "ECDHE-RSA-AES128-SHA256", + "DHE-RSA-AES128-SHA256", + "ECDHE-RSA-AES256-SHA384", + "DHE-RSA-AES256-SHA384", + "ECDHE-RSA-AES256-SHA256", + "DHE-RSA-AES256-SHA256", + "HIGH", + "!aNULL", + "!eNULL", + "!EXPORT", + "!DES", + "!RC4", + "!MD5", + "!PSK", + "!SRP", + "!CAMELLIA", + ], + "clientAuthentication": "none", + "enabled": false, + "keystore": Object {}, + "supportedProtocols": Array [ + "TLSv1.1", + "TLSv1.2", + "TLSv1.3", + ], + "truststore": Object {}, + }, + } + `); + }); + + describe('host', () => { + const validHostnames = ['www.example.com', '8.8.8.8', '::1', 'localhost', '0.0.0.0']; + const invalidHostnames = ['asdf$%^', '0']; + + test('accepts valid hostnames', () => { + for (const val of validHostnames) { + const { host } = config.schema.validate({ host: val }); + expect(host).toBe(val); + } + }); + + test('throws if invalid hostname', () => { + for (const host of invalidHostnames) { + const configSchema = config.schema; + expect(() => configSchema.validate({ host })).toThrowError( + '[host]: value must be a valid hostname (see RFC 1123).' + ); + } + }); + }); + + describe('port', () => { + test('accepts valid ports', () => { + const validPorts = [80, 3000, 5601]; + for (const val of validPorts) { + const { port } = config.schema.validate({ port: val }); + expect(port).toBe(val); + } + }); + + test('throws if invalid ports', () => { + const configSchema = config.schema; + expect(() => configSchema.validate({ port: false })).toThrowError( + 'port]: expected value of type [number] but got [boolean]' + ); + expect(() => configSchema.validate({ port: 'oops' })).toThrowError( + 'port]: expected value of type [number] but got [string]' + ); + }); + }); + + describe('maxPayload', () => { + test('can specify max payload as string', () => { + const obj = { + maxPayload: '2mb', + }; + const configValue = config.schema.validate(obj); + expect(configValue.maxPayload.getValueInBytes()).toBe(2 * 1024 * 1024); + }); + }); + + describe('shutdownTimeout', () => { + test('can specify a valid shutdownTimeout', () => { + const configValue = config.schema.validate({ shutdownTimeout: '5s' }); + expect(configValue.shutdownTimeout.asMilliseconds()).toBe(5000); + }); + + test('can specify a valid shutdownTimeout (lower-edge of 1 second)', () => { + const configValue = config.schema.validate({ shutdownTimeout: '1s' }); + expect(configValue.shutdownTimeout.asMilliseconds()).toBe(1000); + }); + + test('can specify a valid shutdownTimeout (upper-edge of 2 minutes)', () => { + const configValue = config.schema.validate({ shutdownTimeout: '2m' }); + expect(configValue.shutdownTimeout.asMilliseconds()).toBe(120000); + }); + + test('should error if below 1s', () => { + expect(() => config.schema.validate({ shutdownTimeout: '100ms' })).toThrow( + '[shutdownTimeout]: the value should be between 1 second and 2 minutes' + ); + }); + + test('should error if over 2 minutes', () => { + expect(() => config.schema.validate({ shutdownTimeout: '3m' })).toThrow( + '[shutdownTimeout]: the value should be between 1 second and 2 minutes' + ); + }); + }); + + describe('with TLS', () => { + test('throws if TLS is enabled but `redirectHttpFromPort` is equal to `port`', () => { + const configSchema = config.schema; + const obj = { + port: 1234, + ssl: { + certificate: '/path/to/certificate', + enabled: true, + key: '/path/to/key', + redirectHttpFromPort: 1234, + }, + }; + expect(() => configSchema.validate(obj)).toThrowErrorMatchingInlineSnapshot( + `"The health gateway does not accept http traffic to [port] when ssl is enabled (only https is allowed), so [ssl.redirectHttpFromPort] cannot be configured to the same value. Both are [1234]."` + ); + }); + }); + + describe('socketTimeout', () => { + test('can specify socket timeouts', () => { + const obj = { + keepaliveTimeout: 1e5, + socketTimeout: 5e5, + }; + const { keepaliveTimeout, socketTimeout } = config.schema.validate(obj); + expect(keepaliveTimeout).toBe(1e5); + expect(socketTimeout).toBe(5e5); + }); + }); + + describe('cors', () => { + test('is always disabled', () => { + const configSchema = config.schema; + const obj = {}; + expect(new ServerConfig(configSchema.validate(obj)).cors).toMatchInlineSnapshot(` + Object { + "allowCredentials": false, + "allowOrigin": Array [ + "*", + ], + "enabled": false, + } + `); + }); + }); +}); diff --git a/packages/kbn-health-gateway-server/src/server/server_config.ts b/packages/kbn-health-gateway-server/src/server/server_config.ts new file mode 100644 index 0000000000000..79c4f760c4408 --- /dev/null +++ b/packages/kbn-health-gateway-server/src/server/server_config.ts @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 type { Duration } from 'moment'; +import { schema, TypeOf, ByteSizeValue } from '@kbn/config-schema'; +import { ServiceConfigDescriptor } from '@kbn/core-base-server-internal'; +import type { ISslConfig, ICorsConfig, IHttpConfig } from '@kbn/server-http-tools'; +import { sslSchema, SslConfig } from '@kbn/server-http-tools'; + +const configSchema = schema.object( + { + host: schema.string({ + defaultValue: 'localhost', + hostname: true, + }), + port: schema.number({ + defaultValue: 3000, + }), + maxPayload: schema.byteSize({ + defaultValue: '1048576b', + }), + keepaliveTimeout: schema.number({ + defaultValue: 120000, + }), + shutdownTimeout: schema.duration({ + defaultValue: '30s', + validate: (duration) => { + const durationMs = duration.asMilliseconds(); + if (durationMs < 1000 || durationMs > 2 * 60 * 1000) { + return 'the value should be between 1 second and 2 minutes'; + } + }, + }), + socketTimeout: schema.number({ + defaultValue: 120000, + }), + ssl: sslSchema, + }, + { + validate: (rawConfig) => { + if ( + rawConfig.ssl.enabled && + rawConfig.ssl.redirectHttpFromPort !== undefined && + rawConfig.ssl.redirectHttpFromPort === rawConfig.port + ) { + return ( + 'The health gateway does not accept http traffic to [port] when ssl is ' + + 'enabled (only https is allowed), so [ssl.redirectHttpFromPort] ' + + `cannot be configured to the same value. Both are [${rawConfig.port}].` + ); + } + }, + } +); + +export type ServerConfigType = TypeOf; + +export const config: ServiceConfigDescriptor = { + path: 'server' as const, + schema: configSchema, +}; + +export class ServerConfig implements IHttpConfig { + host: string; + port: number; + maxPayload: ByteSizeValue; + keepaliveTimeout: number; + shutdownTimeout: Duration; + socketTimeout: number; + ssl: ISslConfig; + cors: ICorsConfig; + + constructor(rawConfig: ServerConfigType) { + this.host = rawConfig.host; + this.port = rawConfig.port; + this.maxPayload = rawConfig.maxPayload; + this.keepaliveTimeout = rawConfig.keepaliveTimeout; + this.shutdownTimeout = rawConfig.shutdownTimeout; + this.socketTimeout = rawConfig.socketTimeout; + this.ssl = new SslConfig(rawConfig.ssl); + this.cors = { + enabled: false, + allowCredentials: false, + allowOrigin: ['*'], + }; + } +} diff --git a/packages/kbn-health-gateway-server/tsconfig.json b/packages/kbn-health-gateway-server/tsconfig.json new file mode 100644 index 0000000000000..98e6b09c1c81a --- /dev/null +++ b/packages/kbn-health-gateway-server/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.bazel.json", + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "target_types", + "stripInternal": false, + "types": [ + "jest", + "node" + ] + }, + "include": [ + "**/*.ts", + ] +} diff --git a/src/cli_health_gateway/cli_health_gateway.ts b/src/cli_health_gateway/cli_health_gateway.ts new file mode 100644 index 0000000000000..018a47aed2a39 --- /dev/null +++ b/src/cli_health_gateway/cli_health_gateway.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { Command } from 'commander'; +import { kibanaPackageJson } from '@kbn/utils'; +import { bootstrap } from '@kbn/health-gateway-server'; + +const program = new Command('bin/kibana-health-gateway'); + +program + .version(kibanaPackageJson.version) + .description( + 'This command starts up a health gateway server that can be ' + + 'configured to send requests to multiple Kibana instances' + ) + .option('-c, --config', 'Path to a gateway.yml configuration file') + .action(async () => { + return await bootstrap(); + }); + +program.parse(process.argv); diff --git a/src/cli_health_gateway/dev.js b/src/cli_health_gateway/dev.js new file mode 100644 index 0000000000000..ba5dbba0bbe71 --- /dev/null +++ b/src/cli_health_gateway/dev.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +require('../setup_node_env'); +require('./cli_health_gateway'); diff --git a/src/cli_health_gateway/dist.js b/src/cli_health_gateway/dist.js new file mode 100644 index 0000000000000..d1a5cd6a82944 --- /dev/null +++ b/src/cli_health_gateway/dist.js @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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. + */ + +require('../setup_node_env/dist'); +require('./cli_health_gateway'); diff --git a/src/cli_health_gateway/tsconfig.json b/src/cli_health_gateway/tsconfig.json new file mode 100644 index 0000000000000..c8ad5deb6f6d5 --- /dev/null +++ b/src/cli_health_gateway/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + }, + "include": [ + "*.js", + "*.ts", + ], + "kbn_references": [ + { "path": "../cli/tsconfig.json" }, + ] +} diff --git a/src/dev/build/tasks/bin/scripts/kibana-health-gateway b/src/dev/build/tasks/bin/scripts/kibana-health-gateway new file mode 100755 index 0000000000000..6b190ee2d82c4 --- /dev/null +++ b/src/dev/build/tasks/bin/scripts/kibana-health-gateway @@ -0,0 +1,29 @@ +#!/bin/sh +SCRIPT=$0 + +# SCRIPT may be an arbitrarily deep series of symlinks. Loop until we have the concrete path. +while [ -h "$SCRIPT" ] ; do + ls=$(ls -ld "$SCRIPT") + # Drop everything prior to -> + link=$(expr "$ls" : '.*-> \(.*\)$') + if expr "$link" : '/.*' > /dev/null; then + SCRIPT="$link" + else + SCRIPT=$(dirname "$SCRIPT")/"$link" + fi +done + +DIR="$(dirname "${SCRIPT}")/.." +CONFIG_DIR=${KBN_PATH_CONF:-"$DIR/config"} +NODE="${DIR}/node/bin/node" +test -x "$NODE" +if [ ! -x "$NODE" ]; then + echo "unable to find usable node.js executable." + exit 1 +fi + +if [ -f "${CONFIG_DIR}/node.options" ]; then + KBN_NODE_OPTS="$(grep -v ^# < ${CONFIG_DIR}/node.options | xargs)" +fi + +NODE_OPTIONS="$KBN_NODE_OPTS $NODE_OPTIONS" NODE_ENV=production exec "${NODE}" "${DIR}/src/cli_health_gateway/dist" "$@" diff --git a/src/dev/build/tasks/bin/scripts/kibana-health-gateway.bat b/src/dev/build/tasks/bin/scripts/kibana-health-gateway.bat new file mode 100755 index 0000000000000..fec208990ebb0 --- /dev/null +++ b/src/dev/build/tasks/bin/scripts/kibana-health-gateway.bat @@ -0,0 +1,36 @@ +@echo off + +SETLOCAL ENABLEDELAYEDEXPANSION + +set SCRIPT_DIR=%~dp0 +for %%I in ("%SCRIPT_DIR%..") do set DIR=%%~dpfI + +set NODE=%DIR%\node\node.exe +set NODE_ENV=production + +If Not Exist "%NODE%" ( + Echo unable to find usable node.js executable. + Exit /B 1 +) + +set CONFIG_DIR=%KBN_PATH_CONF% +If ["%KBN_PATH_CONF%"] == [] ( + set "CONFIG_DIR=%DIR%\config" +) + +IF EXIST "%CONFIG_DIR%\node.options" ( + for /F "usebackq eol=# tokens=*" %%i in ("%CONFIG_DIR%\node.options") do ( + If [!NODE_OPTIONS!] == [] ( + set "NODE_OPTIONS=%%i" + ) Else ( + set "NODE_OPTIONS=!NODE_OPTIONS! %%i" + ) + ) +) + +TITLE Health Gateway +"%NODE%" "%DIR%\src\cli_health_gateway\dist" %* + +:finally + +ENDLOCAL diff --git a/test/package/deb.yml b/test/package/deb.yml index 030fbad39b167..b6674e996a7e9 100644 --- a/test/package/deb.yml +++ b/test/package/deb.yml @@ -7,6 +7,7 @@ - assert_encryption_keys_cli - assert_plugin_cli - assert_setup_cli + - assert_health_gateway_cli - assert_verification_code_cli - assert_kibana_yml - assert_kibana_listening diff --git a/test/package/roles/assert_health_gateway_cli/tasks/main.yml b/test/package/roles/assert_health_gateway_cli/tasks/main.yml new file mode 100644 index 0000000000000..0f873e4e9868f --- /dev/null +++ b/test/package/roles/assert_health_gateway_cli/tasks/main.yml @@ -0,0 +1,13 @@ +- name: "--help" + become: true + command: + cmd: /usr/share/kibana/bin/kibana-health-gateway --help + register: health_gateway_help + +- debug: + msg: "{{ health_gateway_help }}" + +- name: assert health-gateway provides help + assert: + that: + - health_gateway_help.failed == false diff --git a/test/package/rpm.yml b/test/package/rpm.yml index f717b38797123..6c5bb5b845adf 100644 --- a/test/package/rpm.yml +++ b/test/package/rpm.yml @@ -7,6 +7,7 @@ - assert_encryption_keys_cli - assert_plugin_cli - assert_setup_cli + - assert_health_gateway_cli - assert_verification_code_cli - assert_kibana_yml - assert_kibana_listening diff --git a/tsconfig.base.json b/tsconfig.base.json index b5372e27d631c..fa549137e0609 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -476,6 +476,8 @@ "@kbn/handlebars/*": ["packages/kbn-handlebars/*"], "@kbn/hapi-mocks": ["packages/kbn-hapi-mocks"], "@kbn/hapi-mocks/*": ["packages/kbn-hapi-mocks/*"], + "@kbn/health-gateway-server": ["packages/kbn-health-gateway-server"], + "@kbn/health-gateway-server/*": ["packages/kbn-health-gateway-server/*"], "@kbn/i18n": ["packages/kbn-i18n"], "@kbn/i18n/*": ["packages/kbn-i18n/*"], "@kbn/i18n-react": ["packages/kbn-i18n-react"], diff --git a/tsconfig.json b/tsconfig.json index a03576565d124..0e1d602ee945a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,4 +10,4 @@ "kbn_references": [ { "path": "./src/core/tsconfig.json" }, ] -} +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 94a1ba62f2102..523dc458e026e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3595,6 +3595,10 @@ version "0.0.0" uid "" +"@kbn/health-gateway-server@link:bazel-bin/packages/kbn-health-gateway-server": + version "0.0.0" + uid "" + "@kbn/home-sample-data-card@link:bazel-bin/packages/home/sample_data_card": version "0.0.0" uid "" From ae9dd591374f5ea2f06e1646b65e6bed1dda5b9b Mon Sep 17 00:00:00 2001 From: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> Date: Mon, 7 Nov 2022 11:22:43 -0500 Subject: [PATCH 008/192] [ResponseOps][Stack Connectors] Opsgenie UI phase 2 (#143480) * Starting opsgenie backend * Adding more integration tests * Updating readme * Starting ui * Adding hash and alias * Fixing tests * Switch to platinum for now * Adding server side translations * Fixing merge issues * Fixing file location error * Working ui * Default alias is working * Almost working validation fails sometimes * Adding end to end tests * Adding more tests * Adding note and description fields * Removing todo * Adding in advanced sections * Adding tags and finish mode * Working editor and toggle * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * Refactoring code * Adding tests * Fixing tests and reordering input fields * Using io-ts for schema validation in ui * Adding more e2e tests and clean up * Fixing type errors * Adding spacing and label * Adding more tests and fixing come failure message errors * Making json editor errors more readable * Fixing errors and adding docs * Updating enabled action types * Update docs/management/connectors/action-types/opsgenie.asciidoc Co-authored-by: nastasha-solomon <79124755+nastasha-solomon@users.noreply.github.com> * Update docs/management/connectors/action-types/opsgenie.asciidoc Co-authored-by: nastasha-solomon <79124755+nastasha-solomon@users.noreply.github.com> * Update docs/management/connectors/action-types/opsgenie.asciidoc Co-authored-by: nastasha-solomon <79124755+nastasha-solomon@users.noreply.github.com> * Update docs/management/connectors/action-types/opsgenie.asciidoc Co-authored-by: nastasha-solomon <79124755+nastasha-solomon@users.noreply.github.com> * Update docs/management/connectors/action-types/opsgenie.asciidoc Co-authored-by: nastasha-solomon <79124755+nastasha-solomon@users.noreply.github.com> * Update docs/management/connectors/action-types/opsgenie.asciidoc Co-authored-by: nastasha-solomon <79124755+nastasha-solomon@users.noreply.github.com> * Addressing feedback * Adding new image with lowercase tags * Addressing feedback * Making executionMode optional * [CI] Auto-commit changed files from 'node scripts/generate codeowners' Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Tyler Smalley Co-authored-by: nastasha-solomon <79124755+nastasha-solomon@users.noreply.github.com> --- docs/management/action-types.asciidoc | 4 + .../connectors/action-types/opsgenie.asciidoc | 175 +++++++++++++++ .../images/opsgenie-add-api-integration.png | Bin 0 -> 33133 bytes .../connectors/images/opsgenie-connector.png | Bin 0 -> 55074 bytes .../images/opsgenie-integrations.png | Bin 0 -> 15755 bytes .../images/opsgenie-params-test.png | Bin 0 -> 56953 bytes .../images/opsgenie-save-integration.png | Bin 0 -> 87213 bytes .../connectors/images/opsgenie-teams.png | Bin 0 -> 11007 bytes docs/management/connectors/index.asciidoc | 1 + docs/settings/alert-action-settings.asciidoc | 2 +- .../sub_action_connector.ts | 6 +- .../common/{opsgenie.ts => opsgenie/index.ts} | 0 .../stack/opsgenie/close_alert.test.tsx | 128 +++++++++++ .../stack/opsgenie/close_alert.tsx | 148 ++++++++++++ .../create_alert/additional_options.test.tsx | 77 +++++++ .../create_alert/additional_options.tsx | 88 ++++++++ .../opsgenie/create_alert/index.test.tsx | 178 +++++++++++++++ .../stack/opsgenie/create_alert/index.tsx | 198 ++++++++++++++++ .../create_alert/json_editor.test.tsx | 211 ++++++++++++++++++ .../opsgenie/create_alert/json_editor.tsx | 119 ++++++++++ .../opsgenie/create_alert/priority.test.tsx | 43 ++++ .../stack/opsgenie/create_alert/priority.tsx | 73 ++++++ .../opsgenie/create_alert/schema.test.ts | 108 +++++++++ .../stack/opsgenie/create_alert/schema.ts | 142 ++++++++++++ .../stack/opsgenie/create_alert/tags.test.tsx | 79 +++++++ .../stack/opsgenie/create_alert/tags.tsx | 68 ++++++ .../opsgenie/create_alert/translations.ts | 110 +++++++++ .../opsgenie/display_more_options.test.tsx | 42 ++++ .../stack/opsgenie/display_more_options.tsx | 37 +++ .../connector_types/stack/opsgenie/logo.tsx | 49 +++- .../stack/opsgenie/model.test.tsx | 18 ++ .../connector_types/stack/opsgenie/model.tsx | 71 +++--- .../stack/opsgenie/params.test.tsx | 53 ++++- .../connector_types/stack/opsgenie/params.tsx | 210 +++++++---------- .../stack/opsgenie/translations.ts | 68 ++++-- .../connector_types/stack/opsgenie/types.ts | 27 ++- .../stack/opsgenie/connector.test.ts | 58 +++++ .../stack/opsgenie/connector.ts | 53 +++-- .../stack/opsgenie/schema.test.ts | 20 ++ .../connector_types/stack/opsgenie/schema.ts | 88 +++++--- .../stack/opsgenie/test_schema.ts | 96 ++++++++ .../stack/opsgenie/translations.ts | 7 + .../connector_types/stack/opsgenie/types.ts | 5 +- .../public/application/components/index.ts | 1 + .../action_type_form.test.tsx | 74 +++++- .../action_type_form.tsx | 2 + .../test_connector_form.test.tsx | 79 ++++++- .../test_connector_form.tsx | 8 +- .../triggers_actions_ui/public/index.ts | 2 + .../triggers_actions_ui/public/types.ts | 6 + .../actions/connector_types/stack/opsgenie.ts | 10 +- .../functional/services/actions/common.ts | 11 + .../functional/services/actions/opsgenie.ts | 16 ++ .../connectors/opsgenie.ts | 180 ++++++++++++--- 54 files changed, 2980 insertions(+), 269 deletions(-) create mode 100644 docs/management/connectors/action-types/opsgenie.asciidoc create mode 100644 docs/management/connectors/images/opsgenie-add-api-integration.png create mode 100644 docs/management/connectors/images/opsgenie-connector.png create mode 100644 docs/management/connectors/images/opsgenie-integrations.png create mode 100644 docs/management/connectors/images/opsgenie-params-test.png create mode 100644 docs/management/connectors/images/opsgenie-save-integration.png create mode 100644 docs/management/connectors/images/opsgenie-teams.png rename x-pack/plugins/stack_connectors/common/{opsgenie.ts => opsgenie/index.ts} (100%) create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/close_alert.test.tsx create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/close_alert.tsx create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/additional_options.test.tsx create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/additional_options.tsx create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/index.test.tsx create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/index.tsx create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/json_editor.test.tsx create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/json_editor.tsx create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/priority.test.tsx create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/priority.tsx create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/schema.test.ts create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/schema.ts create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/tags.test.tsx create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/tags.tsx create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/translations.ts create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/display_more_options.test.tsx create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/display_more_options.tsx create mode 100644 x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/schema.test.ts create mode 100644 x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/test_schema.ts diff --git a/docs/management/action-types.asciidoc b/docs/management/action-types.asciidoc index 415080c12a65f..0cc3caf722467 100644 --- a/docs/management/action-types.asciidoc +++ b/docs/management/action-types.asciidoc @@ -27,6 +27,10 @@ a| <> | Send a message to a Microsoft Teams channel. +a| <> + +| Create or close an alert in Opsgenie. + a| <> | Send an event in PagerDuty. diff --git a/docs/management/connectors/action-types/opsgenie.asciidoc b/docs/management/connectors/action-types/opsgenie.asciidoc new file mode 100644 index 0000000000000..9ca081b1e55f1 --- /dev/null +++ b/docs/management/connectors/action-types/opsgenie.asciidoc @@ -0,0 +1,175 @@ +[role="xpack"] +[[opsgenie-action-type]] +=== Opsgenie connector and action +++++ +Opsgenie +++++ + +The Opsgenie connector uses the https://docs.opsgenie.com/docs/alert-api[Opsgenie alert API]. + +[float] +[[opsgenie-connector-configuration]] +==== Connector configuration + +Opsgenie connectors have the following configuration properties. + +Name:: The name of the connector. The name is used to identify a connector in the management UI connector listing, or in the connector list when configuring an action. +URL:: The Opsgenie URL. For example, https://api.opsgenie.com or https://api.eu.opsgenie.com. ++ +NOTE: If you are using the <> setting, make sure the hostname is added to the allowed hosts. +API Key:: The Opsgenie API authentication key for HTTP Basic authentication. For more details about generating Opsgenie API keys, refer to https://support.atlassian.com/opsgenie/docs/create-a-default-api-integration/[Opsgenie documentation]. + +[float] +[[opgenie-connector-networking-configuration]] +==== Connector networking configuration + +Use the <> to customize connector networking configurations, such as proxies, certificates, or TLS settings. You can set configurations that apply to all your connectors or use `xpack.actions.customHostSettings` to set per-host configurations. + +[float] +[[Preconfigured-opsgenie-configuration]] +==== Preconfigured connector type + +[source,text] +-- + my-opsgenie: + name: preconfigured-opsgenie-connector-type + actionTypeId: .opsgenie + config: + apiUrl: https://api.opsgenie.com + secrets: + apiKey: apikey +-- + +Config defines information for the connector type. + +`apiUrl`:: A string that corresponds to *URL*. + +Secrets defines sensitive information for the connector type. + +`apiKey`:: A string that corresponds to *API Key*. + +[float] +[[define-opsgenie-ui]] +==== Define connector in {stack-manage-app} + +Define Opsgenie connector properties. + +[role="screenshot"] +image::management/connectors/images/opsgenie-connector.png[Opsgenie connector] + +Test Opsgenie action parameters. + +[role="screenshot"] +image::management/connectors/images/opsgenie-params-test.png[Opsgenie params test] + +[float] +[[opsgenie-action-configuration]] +==== Action configuration + +The Opsgenie connector supports two types of actions: Create alert and Close alert. The properties supported for each action are different because Opsgenie defines different properties for each operation. + +When testing the Opsgenie connector, choose the appropriate action from the selector. Each action has different properties that can be configured. + +Action:: Select *Create alert* to configure the actions that occur when a rule's conditions are met. Select *Close alert* to define the recovery actions that occur when a rule's conditions are no longer met. + +[float] +[[opsgenie-action-create-alert-configuration]] +===== Configure the create alert action + +You can configure the create alert action through the form view or using a JSON editor. + +[float] +[[opsgenie-action-create-alert-form-configuration]] +====== Form view + +The create alert action form has the following configuration properties. + +Message:: The message for the alert (required). +Opsgenie tags:: The tags for the alert (optional). +Priority:: The priority level for the alert. +Description:: A description that provides detailed information about the alert (optional). +Alias:: The alert identifier, which is used for alert de-duplication in Opsgenie. For more information, refer to the https://support.atlassian.com/opsgenie/docs/what-is-alert-de-duplication/[Opsgenie documentation] (optional). +Entity:: The domain of the alert (optional). +Source:: The source of the alert (optional). +User:: The display name of the owner (optional). +Note:: Additional information for the alert (optional). + +[float] +[[opsgenie-action-create-alert-json-configuration]] +====== JSON editor + +A JSON editor is provided as an alternative to the form view and supports additional fields not shown in the form view. The JSON editor supports all of the forms configuration properties but as lowercase keys as https://docs.opsgenie.com/docs/alert-api#create-alert[described in the Opsgenie API documentation]. The JSON editor supports the following additional properties: + +responders:: The entities to receive notifications about the alert (optional). +visibleTo:: The teams and users that the alert will be visible to without sending a notification to them (optional). +actions:: The custom actions available to the alert (optional). +details:: The custom properties of the alert (optional). + +[float] +[[opsgenie-action-create-alert-json-example-configuration]] +Example JSON editor contents + +[source,json] +-- +{ + "message": "An example alert message", + "alias": "Life is too short for no alias", + "description":"Every alert needs a description", + "responders":[ + {"id":"4513b7ea-3b91-438f-b7e4-e3e54af9147c", "type":"team"}, + {"name":"NOC", "type":"team"}, + {"id":"bb4d9938-c3c2-455d-aaab-727aa701c0d8", "type":"user"}, + {"username":"trinity@opsgenie.com", "type":"user"}, + {"id":"aee8a0de-c80f-4515-a232-501c0bc9d715", "type":"escalation"}, + {"name":"Nightwatch Escalation", "type":"escalation"}, + {"id":"80564037-1984-4f38-b98e-8a1f662df552", "type":"schedule"}, + {"name":"First Responders Schedule", "type":"schedule"} + ], + "visibleTo":[ + {"id":"4513b7ea-3b91-438f-b7e4-e3e54af9147c","type":"team"}, + {"name":"rocket_team","type":"team"}, + {"id":"bb4d9938-c3c2-455d-aaab-727aa701c0d8","type":"user"}, + {"username":"trinity@opsgenie.com","type":"user"} + ], + "actions": ["Restart", "AnExampleAction"], + "tags": ["OverwriteQuietHours","Critical"], + "details":{"key1":"value1","key2":"value2"}, + "entity":"An example entity", + "priority":"P1" +} +-- + +[float] +[[opsgenie-action-close-alert-configuration]] +===== Close alert configuration + +The close alert action has the following configuration properties. + +Alias:: The alert identifier, which is used for alert de-duplication in Opsgenie (required). The alias must match the value used when creating the alert. For more information, refer to the https://support.atlassian.com/opsgenie/docs/what-is-alert-de-duplication/[Opsgenie documentation]. +Note:: Additional information for the alert (optional). +Source:: The display name of the source (optional). +User:: The display name of the owner (optional). + +[float] +[[configuring-opsgenie]] +==== Configure an Opsgenie account + +After obtaining an Opsgenie instance, configure the API integration. For details, refer to the https://support.atlassian.com/opsgenie/docs/create-a-default-api-integration/[Opsgenie documentation]. + +After creating an Opsgenie instance, https://support.atlassian.com/opsgenie/docs/create-a-default-api-integration/[configure the API integration]. + +If you're using a free trial, go to the `Teams` dashboard and select the appropriate team. + +image::management/connectors/images/opsgenie-teams.png[Opsgenie teams dashboard] + +Select the `Integrations` menu item, then select `Add integration`. + +image::management/connectors/images/opsgenie-integrations.png[Opsgenie teams integrations] + +Search for `API` and select the `API` integration. + +image::management/connectors/images/opsgenie-add-api-integration.png[Opsgenie API integration] + +Configure the integration and ensure you record the `API Key`. This key will be used to populate the `API Key` field when creating the Kibana Opsgenie connector. Click `Save Integration` after you finish configuring the integration. + +image::management/connectors/images/opsgenie-save-integration.png[Opsgenie save integration] diff --git a/docs/management/connectors/images/opsgenie-add-api-integration.png b/docs/management/connectors/images/opsgenie-add-api-integration.png new file mode 100644 index 0000000000000000000000000000000000000000..747afcba8354abf31fb286465b3167ed112d50d8 GIT binary patch literal 33133 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 I*L(H900`Bl`Tzg` literal 0 HcmV?d00001 diff --git a/docs/management/connectors/images/opsgenie-connector.png b/docs/management/connectors/images/opsgenie-connector.png new file mode 100644 index 0000000000000000000000000000000000000000..ccb08b27d69344ec1f0f4bc833969bb7cf253e1b GIT binary patch literal 55074 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 z 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 Se>WW9@8JVw+2Z?8{r(SATjNpy literal 0 HcmV?d00001 diff --git a/docs/management/connectors/images/opsgenie-save-integration.png b/docs/management/connectors/images/opsgenie-save-integration.png new file mode 100644 index 0000000000000000000000000000000000000000..797508fb790cf8789793c21c3e249efecd07c823 GIT binary patch literal 87213 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 F{TFYgJyiez literal 0 HcmV?d00001 diff --git a/docs/management/connectors/images/opsgenie-teams.png b/docs/management/connectors/images/opsgenie-teams.png new file mode 100644 index 0000000000000000000000000000000000000000..38f04a28960153e7be185d8605eff349ce4dd1ef GIT binary patch literal 11007 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 Z;T@d5wLm&)u|~Szl;t(#YGf=z{~ri~)b#)W literal 0 HcmV?d00001 diff --git a/docs/management/connectors/index.asciidoc b/docs/management/connectors/index.asciidoc index 5529370171061..d93e36f9e4ca8 100644 --- a/docs/management/connectors/index.asciidoc +++ b/docs/management/connectors/index.asciidoc @@ -12,5 +12,6 @@ include::action-types/swimlane.asciidoc[] include::action-types/slack.asciidoc[] include::action-types/webhook.asciidoc[] include::action-types/cases-webhook.asciidoc[leveloffset=+1] +include::action-types/opsgenie.asciidoc[] include::action-types/xmatters.asciidoc[] include::pre-configured-connectors.asciidoc[] diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index 014ee4e69dca1..9501acbfa22bb 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -131,7 +131,7 @@ A list of allowed email domains which can be used with the email connector. When WARNING: This feature is available in {kib} 7.17.4 and 8.3.0 onwards but is not supported in {kib} 8.0, 8.1 or 8.2. As such, this setting should be removed before upgrading from 7.17 to 8.0, 8.1 or 8.2. It is possible to configure the settings in 7.17.4 and then upgrade to 8.3.0 directly. `xpack.actions.enabledActionTypes` {ess-icon}:: -A list of action types that are enabled. It defaults to `[*]`, enabling all types. The names for built-in {kib} action types are prefixed with a `.` and include: `.email`, `.index`, `.jira`, `.pagerduty`, `.resilient`, `.server-log`, `.servicenow`, .`servicenow-itom`, `.servicenow-sir`, `.slack`, `.swimlane`, `.teams`, `.xmatters`, and `.webhook`. An empty list `[]` will disable all action types. +A list of action types that are enabled. It defaults to `[*]`, enabling all types. The names for built-in {kib} action types are prefixed with a `.` and include: `.email`, `.index`, `.jira`, `.opsgenie`, `.pagerduty`, `.resilient`, `.server-log`, `.servicenow`, .`servicenow-itom`, `.servicenow-sir`, `.slack`, `.swimlane`, `.teams`, `.xmatters`, and `.webhook`. An empty list `[]` will disable all action types. + Disabled action types will not appear as an option when creating new connectors, but existing connectors and actions of that type will remain in {kib} and will not function. diff --git a/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts b/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts index 01b7876d69afd..5b8a9fdcbf1c4 100644 --- a/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts +++ b/x-pack/plugins/actions/server/sub_action_framework/sub_action_connector.ts @@ -146,9 +146,9 @@ export abstract class SubActionConnector { `Request to external service failed. Connector Id: ${this.connector.id}. Connector type: ${this.connector.type}. Method: ${error.config.method}. URL: ${error.config.url}` ); - const errorMessage = `Status code: ${error.status}. Message: ${this.getResponseErrorMessage( - error - )}`; + const errorMessage = `Status code: ${ + error.status ?? error.response?.status + }. Message: ${this.getResponseErrorMessage(error)}`; throw new Error(errorMessage); } diff --git a/x-pack/plugins/stack_connectors/common/opsgenie.ts b/x-pack/plugins/stack_connectors/common/opsgenie/index.ts similarity index 100% rename from x-pack/plugins/stack_connectors/common/opsgenie.ts rename to x-pack/plugins/stack_connectors/common/opsgenie/index.ts diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/close_alert.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/close_alert.test.tsx new file mode 100644 index 0000000000000..b876e19354e7a --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/close_alert.test.tsx @@ -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 React from 'react'; +import { screen, render, within, fireEvent, waitFor } from '@testing-library/react'; +import { CloseAlert } from './close_alert'; +import userEvent from '@testing-library/user-event'; + +describe('CloseAlert', () => { + const editSubAction = jest.fn(); + const editOptionalSubAction = jest.fn(); + + const options = { + showSaveError: false, + errors: { + 'subActionParams.message': [], + 'subActionParams.alias': [], + }, + index: 0, + editSubAction, + editOptionalSubAction, + }; + + beforeEach(() => jest.clearAllMocks()); + + it('does not render the additional options by default', () => { + render(); + + expect(screen.queryByTestId('opsgenie-source-row')).not.toBeInTheDocument(); + }); + + it('renders the form fields by default', () => { + render(); + + expect(screen.getByTestId('opsgenie-alias-row')).toBeInTheDocument(); + expect(screen.getByText('Note')).toBeInTheDocument(); + }); + + it('renders the form fields with the subActionParam values', () => { + render( + + ); + + expect( + within(screen.getByTestId('opsgenie-alias-row')).getByDisplayValue('an alias') + ).toBeInTheDocument(); + expect(within(screen.getByTestId('noteTextArea')).getByText('a note')).toBeInTheDocument(); + }); + + it('renders the additional form fields with the subActionParam values', () => { + render( + + ); + + userEvent.click(screen.getByTestId('opsgenie-display-more-options')); + + expect( + within(screen.getByTestId('opsgenie-source-row')).getByDisplayValue('a source') + ).toBeInTheDocument(); + expect( + within(screen.getByTestId('opsgenie-user-row')).getByDisplayValue('a user') + ).toBeInTheDocument(); + }); + + it.each([ + ['alias', 'aliasInput', 'an alias', editSubAction], + ['note', 'noteTextArea', 'a note', editOptionalSubAction], + ['source', 'sourceInput', 'a source', editOptionalSubAction], + ['user', 'userInput', 'a user', editOptionalSubAction], + ])( + 'calls the callback for field %s data-test-subj %s with input %s', + (field, dataTestSubj, input, callback) => { + render(); + + userEvent.click(screen.getByTestId('opsgenie-display-more-options')); + + fireEvent.change(screen.getByTestId(dataTestSubj), { target: { value: input } }); + + expect(callback.mock.calls[0]).toEqual([field, input, 0]); + } + ); + + it('shows the additional options when clicking the more options button', () => { + render(); + + userEvent.click(screen.getByTestId('opsgenie-display-more-options')); + + expect(screen.getByTestId('opsgenie-source-row')).toBeInTheDocument(); + }); + + it('shows the message required error when showSaveError is true', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByText('MessageError')).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/close_alert.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/close_alert.tsx new file mode 100644 index 0000000000000..82b0b5c061f77 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/close_alert.tsx @@ -0,0 +1,148 @@ +/* + * 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, { useCallback, useState } from 'react'; +import { + ActionParamsProps, + TextAreaWithMessageVariables, + TextFieldWithMessageVariables, +} from '@kbn/triggers-actions-ui-plugin/public'; +import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSpacer, RecursivePartial } from '@elastic/eui'; +import type { + OpsgenieActionParams, + OpsgenieCloseAlertParams, +} from '../../../../server/connector_types/stack'; +import * as i18n from './translations'; +import { EditActionCallback } from './types'; +import { DisplayMoreOptions } from './display_more_options'; + +type AdditionalOptionsProps = Pick< + CloseAlertProps, + 'subActionParams' | 'editOptionalSubAction' | 'index' | 'messageVariables' +>; + +const AdditionalOptions: React.FC = ({ + subActionParams, + editOptionalSubAction, + index, + messageVariables, +}) => { + return ( + <> + + + + + + + + + + + + + + + ); +}; + +AdditionalOptions.displayName = 'AdditionalOptions'; + +type CloseAlertProps = Pick< + ActionParamsProps, + 'errors' | 'index' | 'messageVariables' +> & { + subActionParams?: RecursivePartial; + editSubAction: EditActionCallback; + editOptionalSubAction: EditActionCallback; + showSaveError: boolean; +}; + +const CloseAlertComponent: React.FC = ({ + editSubAction, + editOptionalSubAction, + errors, + index, + messageVariables, + subActionParams, + showSaveError, +}) => { + const isAliasInvalid = + (errors['subActionParams.alias'] !== undefined && + errors['subActionParams.alias'].length > 0 && + subActionParams?.alias !== undefined) || + showSaveError; + + const [showingMoreOptions, setShowingMoreOptions] = useState(false); + const toggleShowingMoreOptions = useCallback( + () => setShowingMoreOptions((previousState) => !previousState), + [] + ); + + return ( + <> + + + + + + {showingMoreOptions ? ( + + ) : null} + + + + ); +}; + +CloseAlertComponent.displayName = 'CloseAlert'; + +export const CloseAlert = React.memo(CloseAlertComponent); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/additional_options.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/additional_options.test.tsx new file mode 100644 index 0000000000000..89fe3678372ad --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/additional_options.test.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, render, within, fireEvent } from '@testing-library/react'; +import { AdditionalOptions } from './additional_options'; + +describe('AdditionalOptions', () => { + const editOptionalSubAction = jest.fn(); + + const options = { + index: 0, + editOptionalSubAction, + }; + + beforeEach(() => jest.clearAllMocks()); + + it('renders the component with empty states', () => { + render(); + + expect(screen.getByTestId('opsgenie-entity-row')).toBeInTheDocument(); + expect(screen.getByTestId('opsgenie-source-row')).toBeInTheDocument(); + expect(screen.getByTestId('opsgenie-user-row')).toBeInTheDocument(); + expect(screen.getByText('Note')).toBeInTheDocument(); + }); + + it('renders with the subActionParams displayed in the fields', async () => { + render( + + ); + + expect( + within(screen.getByTestId('opsgenie-entity-row')).getByDisplayValue('entity') + ).toBeInTheDocument(); + expect( + within(screen.getByTestId('opsgenie-entity-row')).getByDisplayValue('entity') + ).toBeInTheDocument(); + expect( + within(screen.getByTestId('opsgenie-source-row')).getByDisplayValue('source') + ).toBeInTheDocument(); + expect( + within(screen.getByTestId('opsgenie-user-row')).getByDisplayValue('user') + ).toBeInTheDocument(); + expect(within(screen.getByTestId('noteTextArea')).getByText('note')).toBeInTheDocument(); + }); + + it.each([ + ['entity', 'entityInput', 'an entity'], + ['source', 'sourceInput', 'a source'], + ['user', 'userInput', 'a user'], + ['note', 'noteTextArea', 'a note'], + ])( + 'calls the callback for field %s data-test-subj %s with input %s', + (field, dataTestSubj, input) => { + render(); + + fireEvent.change(screen.getByTestId(dataTestSubj), { target: { value: input } }); + + expect(editOptionalSubAction.mock.calls[0]).toEqual([field, input, 0]); + } + ); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/additional_options.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/additional_options.tsx new file mode 100644 index 0000000000000..7c931d8834639 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/additional_options.tsx @@ -0,0 +1,88 @@ +/* + * 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 { + TextAreaWithMessageVariables, + TextFieldWithMessageVariables, +} from '@kbn/triggers-actions-ui-plugin/public'; +import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSpacer } from '@elastic/eui'; + +import * as i18n from './translations'; +import { CreateAlertProps } from '.'; + +type AdditionalOptionsProps = Pick< + CreateAlertProps, + 'subActionParams' | 'editOptionalSubAction' | 'messageVariables' | 'index' +>; + +const AdditionalOptionsComponent: React.FC = ({ + subActionParams, + editOptionalSubAction, + messageVariables, + index, +}) => { + return ( + <> + + + + + + + + + + + + + + + + + + + + ); +}; + +AdditionalOptionsComponent.displayName = 'AdditionalOptions'; + +export const AdditionalOptions = React.memo(AdditionalOptionsComponent); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/index.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/index.test.tsx new file mode 100644 index 0000000000000..4bdd32fbb1386 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/index.test.tsx @@ -0,0 +1,178 @@ +/* + * 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 { screen, render, within, fireEvent, waitFor } from '@testing-library/react'; +import { CreateAlert } from '.'; +import { MockCodeEditor } from '@kbn/triggers-actions-ui-plugin/public/application/code_editor.mock'; +import userEvent from '@testing-library/user-event'; + +const kibanaReactPath = '../../../../../../../../src/plugins/kibana_react/public'; + +jest.mock(kibanaReactPath, () => { + const original = jest.requireActual(kibanaReactPath); + return { + ...original, + CodeEditor: (props: any) => { + return ; + }, + }; +}); + +describe('CreateAlert', () => { + const editSubAction = jest.fn(); + const editAction = jest.fn(); + const editOptionalSubAction = jest.fn(); + + const options = { + showSaveError: false, + errors: { + 'subActionParams.message': [], + 'subActionParams.alias': [], + }, + index: 0, + editAction, + editSubAction, + editOptionalSubAction, + }; + + beforeEach(() => jest.clearAllMocks()); + + it('does not render the json editor by default', () => { + render(); + + expect(screen.queryByTestId('actionJsonEditor')).not.toBeInTheDocument(); + }); + + it('does not render the additional options by default', () => { + render(); + + expect(screen.queryByTestId('opsgenie-entity-row')).not.toBeInTheDocument(); + }); + + it('renders the form fields by default', () => { + render(); + + expect(screen.getByTestId('opsgenie-message-row')).toBeInTheDocument(); + expect(screen.getByTestId('opsgenie-alias-row')).toBeInTheDocument(); + expect(screen.getByTestId('opsgenie-tags')).toBeInTheDocument(); + expect(screen.getByTestId('opsgenie-prioritySelect')).toBeInTheDocument(); + expect(screen.getByText('Description')).toBeInTheDocument(); + }); + + it('renders the form fields with the subActionParam values', () => { + render( + + ); + + expect(within(screen.getByTestId('opsgenie-tags')).getByText('super tag')).toBeInTheDocument(); + expect( + within(screen.getByTestId('opsgenie-message-row')).getByDisplayValue('a message') + ).toBeInTheDocument(); + expect( + within(screen.getByTestId('opsgenie-alias-row')).getByDisplayValue('an alias') + ).toBeInTheDocument(); + expect( + within(screen.getByTestId('descriptionTextArea')).getByText('a description') + ).toBeInTheDocument(); + }); + + it.each([ + ['message', 'messageInput', 'a message', editSubAction], + ['alias', 'aliasInput', 'an alias', editOptionalSubAction], + ['description', 'descriptionTextArea', 'a description', editOptionalSubAction], + ])( + 'calls the callback for field %s data-test-subj %s with input %s', + (field, dataTestSubj, input, callback) => { + render(); + + fireEvent.change(screen.getByTestId(dataTestSubj), { target: { value: input } }); + + expect(callback.mock.calls[0]).toEqual([field, input, 0]); + } + ); + + it('shows the json editor when clicking the editor toggle', async () => { + render(); + + userEvent.click(screen.getByTestId('opsgenie-show-json-editor-toggle')); + + await waitFor(() => { + expect(screen.getByTestId('actionJsonEditor')).toBeInTheDocument(); + expect(screen.queryByTestId('opsgenie-message-row')).not.toBeInTheDocument(); + expect(screen.queryByTestId('opsgenie-alias-row')).not.toBeInTheDocument(); + expect(screen.queryByText('Description')).not.toBeInTheDocument(); + }); + }); + + it('shows the additional options when clicking the more options button', () => { + render(); + + userEvent.click(screen.getByTestId('opsgenie-display-more-options')); + + expect(screen.getByTestId('opsgenie-entity-row')).toBeInTheDocument(); + }); + + it('sets the json editor error to undefined when the toggle is switched off', async () => { + render(); + + userEvent.click(screen.getByTestId('opsgenie-show-json-editor-toggle')); + + await waitFor(() => { + expect(screen.getByTestId('actionJsonEditor')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('opsgenie-show-json-editor-toggle')); + + await waitFor(() => { + expect(screen.queryByTestId('actionJsonEditor')).not.toBeInTheDocument(); + // first call to edit actions is because the editor was rendered and validation failed + expect(editAction.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "jsonEditorError", + true, + 0, + ], + Array [ + "jsonEditorError", + undefined, + 0, + ], + ] + `); + }); + }); + + it('shows the message required error when showSaveError is true', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByText('MessageError')).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/index.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/index.tsx new file mode 100644 index 0000000000000..e5ca5c3741f5d --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/index.tsx @@ -0,0 +1,198 @@ +/* + * 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, { lazy, Suspense, useCallback, useState } from 'react'; +import { + ActionParamsProps, + TextAreaWithMessageVariables, + TextFieldWithMessageVariables, + SectionLoading, +} from '@kbn/triggers-actions-ui-plugin/public'; +import { + EuiErrorBoundary, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiSpacer, + EuiSwitch, +} from '@elastic/eui'; +import type { + OpsgenieActionParams, + OpsgenieCreateAlertParams, +} from '../../../../../server/connector_types/stack'; +import * as i18n from './translations'; +import { EditActionCallback } from '../types'; +import { DisplayMoreOptions } from '../display_more_options'; +import { AdditionalOptions } from './additional_options'; +import { Tags } from './tags'; +import { Priority } from './priority'; +import type { JsonEditorProps } from './json_editor'; + +const JsonEditorLazy: React.FC = lazy(() => import('./json_editor')); + +type FormViewProps = Omit; + +const FormView: React.FC = ({ + editSubAction, + editOptionalSubAction, + errors, + index, + messageVariables, + subActionParams, + showSaveError, +}) => { + const isMessageInvalid = + (errors['subActionParams.message'] !== undefined && + errors['subActionParams.message'].length > 0 && + subActionParams?.message !== undefined) || + showSaveError; + + return ( + <> + + + + + + + + + + + + + + + + + + + ); +}; + +FormView.displayName = 'FormView'; + +export type CreateAlertProps = Pick< + ActionParamsProps, + 'errors' | 'index' | 'messageVariables' | 'editAction' +> & { + subActionParams?: Partial; + editSubAction: EditActionCallback; + editOptionalSubAction: EditActionCallback; + showSaveError: boolean; +}; + +const CreateAlertComponent: React.FC = ({ + editSubAction, + editAction, + editOptionalSubAction, + errors, + index, + messageVariables, + subActionParams, + showSaveError, +}) => { + const [showingMoreOptions, setShowingMoreOptions] = useState(false); + const [showJsonEditor, setShowJsonEditor] = useState(false); + + const toggleShowJsonEditor = useCallback( + (event) => { + if (!event.target.checked) { + // when the user switches back remove the json editor error if there was one + // must mark as undefined to remove the field so it is not sent to the server side + editAction('jsonEditorError', undefined, index); + } + setShowJsonEditor(event.target.checked); + }, + [editAction, index] + ); + + const toggleShowingMoreOptions = useCallback( + () => setShowingMoreOptions((previousState) => !previousState), + [] + ); + + return ( + <> + + + + {showJsonEditor ? ( + + {i18n.LOADING_JSON_EDITOR}}> + + + + ) : ( + <> + + {showingMoreOptions ? ( + + ) : null} + + + + )} + + ); +}; + +CreateAlertComponent.displayName = 'CreateAlert'; + +export const CreateAlert = React.memo(CreateAlertComponent); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/json_editor.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/json_editor.test.tsx new file mode 100644 index 0000000000000..9de476b33d7bc --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/json_editor.test.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, render, within, fireEvent, waitFor } from '@testing-library/react'; +import JsonEditor from './json_editor'; +import { MockCodeEditor } from '@kbn/triggers-actions-ui-plugin/public/application/code_editor.mock'; + +const kibanaReactPath = '../../../../../../../../src/plugins/kibana_react/public'; + +jest.mock(kibanaReactPath, () => { + const original = jest.requireActual(kibanaReactPath); + return { + ...original, + CodeEditor: (props: any) => { + return ; + }, + }; +}); + +describe('JsonEditor', () => { + const editAction = jest.fn(); + + const options = { + index: 0, + editAction, + }; + + beforeEach(() => jest.clearAllMocks()); + + it('sets the default value for the json editor to {}', () => { + render(); + + expect( + within(screen.getByTestId('actionJsonEditor')).getByDisplayValue('{}') + ).toBeInTheDocument(); + }); + + it('displays an error for the message field initially', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('[message]: expected value of type [string] but got [undefined]') + ).toBeInTheDocument(); + }); + }); + + it('calls editActions setting the error state to true', async () => { + render(); + + await waitFor(() => { + expect( + screen.getByText('[message]: expected value of type [string] but got [undefined]') + ).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(editAction).toHaveBeenCalledWith('jsonEditorError', true, 0); + }); + }); + + it('calls editActions setting the error state to true twice', async () => { + render(); + + fireEvent.change(screen.getByTestId('subActionParamsJsonEditor'), { + target: { value: 'invalid json' }, + }); + + // first time is from the useEffect, second is from the fireEvent + expect(editAction.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "jsonEditorError", + true, + 0, + ], + Array [ + "jsonEditorError", + true, + 0, + ], + ] + `); + }); + + it('calls the callback when the json input is valid', async () => { + render(); + + const validJson = JSON.stringify({ message: 'awesome' }); + + fireEvent.change(screen.getByTestId('subActionParamsJsonEditor'), { + target: { value: validJson }, + }); + + expect(editAction).toHaveBeenCalledWith('subActionParams', { message: 'awesome' }, 0); + }); + + it('does not show an error when the message field is a valid non empty string', async () => { + render(); + + const validJson = JSON.stringify({ message: 'awesome' }); + + fireEvent.change(screen.getByTestId('subActionParamsJsonEditor'), { + target: { value: validJson }, + }); + + expect( + screen.queryByText('[message]: expected value of type [string] but got [undefined]') + ).not.toBeInTheDocument(); + }); + + it('shows an error when the message field is only spaces', async () => { + render(); + + const validJson = JSON.stringify({ message: ' ' }); + + fireEvent.change(screen.getByTestId('subActionParamsJsonEditor'), { + target: { value: validJson }, + }); + + expect( + screen.getByText('[message]: must be populated with a value other than just whitespace') + ).toBeInTheDocument(); + }); + + it('calls editAction setting editor error to true when validation fails', async () => { + render(); + + const validJson = JSON.stringify({ + tags: 'tags should be an array not a string', + message: 'a message', + }); + + fireEvent.change(screen.getByTestId('subActionParamsJsonEditor'), { + target: { value: validJson }, + }); + + expect( + screen.getByText('Invalid value "tags should be an array not a string" supplied to "tags"') + ).toBeInTheDocument(); + expect(editAction.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "jsonEditorError", + true, + 0, + ], + Array [ + "jsonEditorError", + true, + 0, + ], + ] + `); + }); + + it('calls the callback with only the message field after editing the json', async () => { + render( + + ); + + const validJson = JSON.stringify({ + message: 'a new message', + }); + + fireEvent.change(screen.getByTestId('subActionParamsJsonEditor'), { + target: { value: validJson }, + }); + + expect(editAction).toHaveBeenCalledWith('subActionParams', { message: 'a new message' }, 0); + }); + + it('sets the editor error to undefined when validation succeeds', async () => { + render( + + ); + + await waitFor(() => { + expect(editAction.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "jsonEditorError", + undefined, + 0, + ], + ] + `); + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/json_editor.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/json_editor.tsx new file mode 100644 index 0000000000000..91147204e46f4 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/json_editor.tsx @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { isEmpty } from 'lodash'; +import { JsonEditorWithMessageVariables } from '@kbn/triggers-actions-ui-plugin/public'; +import type { OpsgenieCreateAlertParams } from '../../../../../server/connector_types/stack'; +import * as i18n from './translations'; +import { CreateAlertProps } from '.'; +import { decodeCreateAlert, isDecodeError } from './schema'; + +export type JsonEditorProps = Pick< + CreateAlertProps, + 'editAction' | 'index' | 'messageVariables' | 'subActionParams' +>; + +const JsonEditorComponent: React.FC = ({ + editAction, + index, + messageVariables, + subActionParams, +}) => { + const [jsonEditorErrors, setJsonEditorErrors] = useState([]); + + const jsonEditorValue = useMemo(() => getJsonEditorValue(subActionParams), [subActionParams]); + + const decodeJsonWithSchema = useCallback((jsonBlob: unknown) => { + try { + const decodedValue = decodeCreateAlert(jsonBlob); + setJsonEditorErrors([]); + return decodedValue; + } catch (error) { + if (isDecodeError(error)) { + setJsonEditorErrors(error.decodeErrors); + } else { + setJsonEditorErrors([error.message]); + } + + return; + } + }, []); + + const onAdvancedEditorChange = useCallback( + (json: string) => { + const parsedJson = parseJson(json); + if (!parsedJson) { + editAction('jsonEditorError', true, index); + + return; + } + + const decodedValue = decodeJsonWithSchema(parsedJson); + if (!decodedValue) { + editAction('jsonEditorError', true, index); + + return; + } + + editAction('subActionParams', decodedValue, index); + }, + [editAction, index, decodeJsonWithSchema] + ); + + useEffect(() => { + // show the initial error messages + const decodedValue = decodeJsonWithSchema(subActionParams ?? {}); + if (!decodedValue) { + editAction('jsonEditorError', true, index); + } else { + // must mark as undefined to remove the field so it is not sent to the server side + editAction('jsonEditorError', undefined, index); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [subActionParams, decodeJsonWithSchema, index]); + + return ( + + ); +}; + +JsonEditorComponent.displayName = 'JsonEditor'; + +const JsonEditor = React.memo(JsonEditorComponent); + +// eslint-disable-next-line import/no-default-export +export { JsonEditor as default }; + +const parseJson = (jsonValue: string): Record | undefined => { + try { + return JSON.parse(jsonValue); + } catch (error) { + return; + } +}; + +const getJsonEditorValue = (subActionParams?: Partial) => { + const defaultValue = '{}'; + try { + const value = JSON.stringify(subActionParams, null, 2); + if (isEmpty(value)) { + return defaultValue; + } + return value; + } catch (error) { + return defaultValue; + } +}; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/priority.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/priority.test.tsx new file mode 100644 index 0000000000000..dd6478ae7e653 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/priority.test.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Priority } from './priority'; + +describe('Priority', () => { + const onChange = jest.fn(); + + const options = { + priority: undefined, + onChange, + }; + + beforeEach(() => jest.clearAllMocks()); + + it('renders the priority selectable', () => { + render(); + + expect(screen.getByTestId('opsgenie-prioritySelect')).toBeInTheDocument(); + }); + + it('calls onChange when P1 is selected', async () => { + render(); + + userEvent.selectOptions(screen.getByTestId('opsgenie-prioritySelect'), 'P1'); + + await waitFor(() => + expect(onChange.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "priority", + "P1", + ] + `) + ); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/priority.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/priority.tsx new file mode 100644 index 0000000000000..2a47e67b86963 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/priority.tsx @@ -0,0 +1,73 @@ +/* + * 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, { useCallback } from 'react'; + +import { EuiFormRow, EuiSelect } from '@elastic/eui'; + +import type { OpsgenieCreateAlertParams } from '../../../../../server/connector_types/stack'; +import * as i18n from './translations'; +import { EditActionCallback } from '../types'; + +interface PriorityComponentProps { + priority: OpsgenieCreateAlertParams['priority']; + onChange: EditActionCallback; +} + +const PriorityComponent: React.FC = ({ priority, onChange }) => { + const onPriorityChange = useCallback( + (event: React.ChangeEvent) => { + onChange('priority', event.target.value); + }, + [onChange] + ); + + return ( + + + + ); +}; + +PriorityComponent.displayName = 'Priority'; + +export const Priority = React.memo(PriorityComponent); + +const priorityOptions = [ + { + value: i18n.PRIORITY_1, + text: i18n.PRIORITY_1, + ['data-test-subj']: 'opsgenie-priority-p1', + }, + { + value: i18n.PRIORITY_2, + text: i18n.PRIORITY_2, + ['data-test-subj']: 'opsgenie-priority-p2', + }, + { + value: i18n.PRIORITY_3, + text: i18n.PRIORITY_3, + ['data-test-subj']: 'opsgenie-priority-p3', + }, + { + value: i18n.PRIORITY_4, + text: i18n.PRIORITY_4, + ['data-test-subj']: 'opsgenie-priority-p4', + }, + { + value: i18n.PRIORITY_5, + text: i18n.PRIORITY_5, + ['data-test-subj']: 'opsgenie-priority-p5', + }, +]; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/schema.test.ts b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/schema.test.ts new file mode 100644 index 0000000000000..f2aec179e8676 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/schema.test.ts @@ -0,0 +1,108 @@ +/* + * 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 { decodeCreateAlert } from './schema'; +import { + OpsgenieCreateAlertExample, + ValidCreateAlertSchema, +} from '../../../../../server/connector_types/stack/opsgenie/test_schema'; + +describe('decodeCreateAlert', () => { + it('throws an error when the message field is not present', () => { + expect(() => decodeCreateAlert({ alias: '123' })).toThrowErrorMatchingInlineSnapshot( + `"[message]: expected value of type [string] but got [undefined]"` + ); + }); + + it('throws an error when the message field is only spaces', () => { + expect(() => decodeCreateAlert({ message: ' ' })).toThrowErrorMatchingInlineSnapshot( + `"[message]: must be populated with a value other than just whitespace"` + ); + }); + + it('throws an error when the message field is an empty string', () => { + expect(() => decodeCreateAlert({ message: '' })).toThrowErrorMatchingInlineSnapshot( + `"[message]: must be populated with a value other than just whitespace"` + ); + }); + + it('throws an error when additional fields are present in the data that are not defined in the schema', () => { + expect(() => + decodeCreateAlert({ invalidField: 'hi', message: 'hi' }) + ).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`); + }); + + it('throws an error when additional fields are present in responders with name field than in the schema', () => { + expect(() => + decodeCreateAlert({ + message: 'hi', + responders: [{ name: 'sam', type: 'team', invalidField: 'scott' }], + }) + ).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`); + }); + + it('throws an error when additional fields are present in responders with id field than in the schema', () => { + expect(() => + decodeCreateAlert({ + message: 'hi', + responders: [{ id: 'id', type: 'team', invalidField: 'scott' }], + }) + ).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`); + }); + + it('throws an error when additional fields are present in visibleTo with name and type=team', () => { + expect(() => + decodeCreateAlert({ + message: 'hi', + visibleTo: [{ name: 'sam', type: 'team', invalidField: 'scott' }], + }) + ).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`); + }); + + it('throws an error when additional fields are present in visibleTo with id and type=team', () => { + expect(() => + decodeCreateAlert({ + message: 'hi', + visibleTo: [{ id: 'id', type: 'team', invalidField: 'scott' }], + }) + ).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`); + }); + + it('throws an error when additional fields are present in visibleTo with id and type=user', () => { + expect(() => + decodeCreateAlert({ + message: 'hi', + visibleTo: [{ id: 'id', type: 'user', invalidField: 'scott' }], + }) + ).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`); + }); + + it('throws an error when additional fields are present in visibleTo with username and type=user', () => { + expect(() => + decodeCreateAlert({ + message: 'hi', + visibleTo: [{ username: 'sam', type: 'user', invalidField: 'scott' }], + }) + ).toThrowErrorMatchingInlineSnapshot(`"invalid keys \\"invalidField\\""`); + }); + + it('throws an error when details is a record of string to number', () => { + expect(() => + decodeCreateAlert({ + message: 'hi', + details: { id: 1 }, + }) + ).toThrowErrorMatchingInlineSnapshot(`"Invalid value \\"1\\" supplied to \\"details.id\\""`); + }); + + it.each([ + ['ValidCreateAlertSchema', ValidCreateAlertSchema], + ['OpsgenieCreateAlertExample', OpsgenieCreateAlertExample], + ])('validates the test object [%s] correctly', (objectName, testObject) => { + expect(() => decodeCreateAlert(testObject)).not.toThrow(); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/schema.ts b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/schema.ts new file mode 100644 index 0000000000000..2a76527c6c355 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/schema.ts @@ -0,0 +1,142 @@ +/* + * 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 { Either, fold } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; +import * as rt from 'io-ts'; +import { exactCheck } from '@kbn/securitysolution-io-ts-utils'; +import { identity } from 'fp-ts/lib/function'; +import { isEmpty, isObject } from 'lodash'; +import * as i18n from './translations'; + +const MessageNonEmptyString = new rt.Type( + 'MessageNonEmptyString', + rt.string.is, + (input, context): Either => { + if (input === undefined) { + return rt.failure(input, context, i18n.MESSAGE_NOT_DEFINED); + } else if (typeof input !== 'string') { + return rt.failure(input, context); + } else if (isEmpty(input.trim())) { + return rt.failure(input, context, i18n.MESSAGE_NON_WHITESPACE); + } else { + return rt.success(input); + } + }, + rt.identity +); + +const ResponderTypes = rt.union([ + rt.literal('team'), + rt.literal('user'), + rt.literal('escalation'), + rt.literal('schedule'), +]); + +/** + * This schema is duplicated from the server. The only difference is that it is using io-ts vs kbn-schema. + * NOTE: This schema must be the same as defined here: x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/schema.ts + * + * The reason it is duplicated here is because the server uses kbn-schema which uses Joi under the hood. If we import + * Joi on the frontend it will cause ~500KB of data to be loaded on page loads. To avoid this we'll use io-ts in the frontend. + * Ideally we could use io-ts in the backend as well but the server requires kbn-schema to be used. + * + * Issue: https://github.com/elastic/kibana/issues/143891 + * + * For more information on the Opsgenie create alert schema see: https://docs.opsgenie.com/docs/alert-api#create-alert + */ +const CreateAlertSchema = rt.intersection([ + rt.strict({ message: MessageNonEmptyString }), + rt.exact( + rt.partial({ + alias: rt.string, + description: rt.string, + responders: rt.array( + rt.union([ + rt.strict({ name: rt.string, type: ResponderTypes }), + rt.strict({ id: rt.string, type: ResponderTypes }), + rt.strict({ username: rt.string, type: rt.literal('user') }), + ]) + ), + visibleTo: rt.array( + rt.union([ + rt.strict({ name: rt.string, type: rt.literal('team') }), + rt.strict({ id: rt.string, type: rt.literal('team') }), + rt.strict({ id: rt.string, type: rt.literal('user') }), + rt.strict({ username: rt.string, type: rt.literal('user') }), + ]) + ), + actions: rt.array(rt.string), + tags: rt.array(rt.string), + details: rt.record(rt.string, rt.string), + entity: rt.string, + source: rt.string, + priority: rt.union([ + rt.literal('P1'), + rt.literal('P2'), + rt.literal('P3'), + rt.literal('P4'), + rt.literal('P5'), + ]), + user: rt.string, + note: rt.string, + }) + ), +]); + +export const formatErrors = (errors: rt.Errors): string[] => { + const err = errors.map((error) => { + if (error.message != null) { + return error.message; + } else { + const keyContext = error.context + .filter( + (entry) => entry.key != null && !Number.isInteger(+entry.key) && entry.key.trim() !== '' + ) + .map((entry) => entry.key) + .join('.'); + + const nameContext = error.context.find( + (entry) => entry.type != null && entry.type.name != null && entry.type.name.length > 0 + ); + + const suppliedValue = + keyContext !== '' ? keyContext : nameContext != null ? nameContext.type.name : ''; + const value = isObject(error.value) ? JSON.stringify(error.value) : error.value; + return `Invalid value "${value}" supplied to "${suppliedValue}"`; + } + }); + + return [...new Set(err)]; +}; + +type CreateAlertSchemaType = rt.TypeOf; + +export const decodeCreateAlert = (data: unknown): CreateAlertSchemaType => { + const onLeft = (errors: rt.Errors) => { + throw new DecodeError(formatErrors(errors)); + }; + + const onRight = (a: CreateAlertSchemaType): CreateAlertSchemaType => identity(a); + + return pipe( + CreateAlertSchema.decode(data), + (decoded) => exactCheck(data, decoded), + fold(onLeft, onRight) + ); +}; + +export class DecodeError extends Error { + constructor(public readonly decodeErrors: string[]) { + super(decodeErrors.join()); + this.name = this.constructor.name; + } +} + +export function isDecodeError(error: unknown): error is DecodeError { + return error instanceof DecodeError; +} diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/tags.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/tags.test.tsx new file mode 100644 index 0000000000000..21deccf70b3c4 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/tags.test.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Tags } from './tags'; + +describe('Tags', () => { + const onChange = jest.fn(); + + const options = { + values: [], + onChange, + }; + + beforeEach(() => jest.clearAllMocks()); + + it('renders tags initially', () => { + render(); + + expect(screen.getByText('super')).toBeInTheDocument(); + expect(screen.getByText('hello')).toBeInTheDocument(); + }); + + it('clears the tags', async () => { + render(); + + userEvent.click(screen.getByTestId('comboBoxClearButton')); + + await waitFor(() => + expect(onChange.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "tags", + Array [], + ] + `) + ); + }); + + it('calls onChange when removing a tag', async () => { + render(); + + userEvent.click(screen.getByTitle('Remove super from selection in this group')); + + await waitFor(() => + expect(onChange.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "tags", + Array [ + "hello", + ], + ] + `) + ); + }); + + it('calls onChange when adding a tag', async () => { + render(); + + userEvent.click(screen.getByTestId('opsgenie-tags')); + userEvent.type(screen.getByTestId('comboBoxSearchInput'), 'awesome{enter}'); + + await waitFor(() => + expect(onChange.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "tags", + Array [ + "awesome", + ], + ] + `) + ); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/tags.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/tags.tsx new file mode 100644 index 0000000000000..67fa34bb76877 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/tags.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, { useCallback, useMemo } from 'react'; + +import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui'; + +import * as i18n from './translations'; +import { EditActionCallback } from '../types'; + +interface TagsProps { + onChange: EditActionCallback; + values: string[]; +} + +const TagsComponent: React.FC = ({ onChange, values }) => { + const tagOptions = useMemo(() => values.map((value) => getTagAsOption(value)), [values]); + + const onCreateOption = useCallback( + (tagValue: string) => { + const newTags = [...tagOptions, getTagAsOption(tagValue)]; + onChange( + 'tags', + newTags.map((tag) => tag.label) + ); + }, + [onChange, tagOptions] + ); + + const onTagsChange = useCallback( + (newOptions: EuiComboBoxOptionOption[]) => { + onChange( + 'tags', + newOptions.map((option) => option.label) + ); + }, + [onChange] + ); + + return ( + + + + ); +}; + +TagsComponent.displayName = 'Tags'; + +export const Tags = React.memo(TagsComponent); + +const getTagAsOption = (value: string) => ({ label: value, key: value }); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/translations.ts b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/translations.ts new file mode 100644 index 0000000000000..5f7e9051af95f --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/create_alert/translations.ts @@ -0,0 +1,110 @@ +/* + * 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'; +export * from '../translations'; + +export const MESSAGE_NOT_DEFINED = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.messageNotDefined', + { defaultMessage: '[message]: expected value of type [string] but got [undefined]' } +); + +export const MESSAGE_NON_WHITESPACE = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.messageNotWhitespace', + { defaultMessage: '[message]: must be populated with a value other than just whitespace' } +); + +export const LOADING_JSON_EDITOR = i18n.translate( + 'xpack.stackConnectors.sections.ospgenie.loadingJsonEditor', + { defaultMessage: 'Loading JSON editor' } +); + +export const MESSAGE_FIELD_LABEL = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.messageLabel', + { + defaultMessage: 'Message (required)', + } +); + +export const DESCRIPTION_FIELD_LABEL = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.descriptionLabel', + { + defaultMessage: 'Description', + } +); + +export const MESSAGE_FIELD_IS_REQUIRED = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.messageFieldRequired', + { + defaultMessage: '"message" field must be populated with a value other than just whitespace', + } +); + +export const USE_JSON_EDITOR_LABEL = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.useJsonEditorLabel', + { + defaultMessage: 'Use JSON editor', + } +); + +export const ALERT_FIELDS_LABEL = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.alertFieldsLabel', + { + defaultMessage: 'Alert fields', + } +); + +export const JSON_EDITOR_ARIA = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.jsonEditorAriaLabel', + { + defaultMessage: 'JSON editor', + } +); + +export const ENTITY_FIELD_LABEL = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.entityLabel', + { + defaultMessage: 'Entity', + } +); + +export const TAGS_HELP = i18n.translate('xpack.stackConnectors.components.opsgenie.tagsHelp', { + defaultMessage: + 'Type one or more custom identifying tags for this case. Press enter after each tag to begin a new one.', +}); + +export const TAGS_FIELD_LABEL = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.tagsLabel', + { defaultMessage: 'Opsgenie tags' } +); + +export const PRIORITY_LABEL = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.priorityLabel', + { + defaultMessage: 'Priority', + } +); + +export const PRIORITY_1 = i18n.translate('xpack.stackConnectors.components.opsgenie.priority1', { + defaultMessage: 'P1', +}); + +export const PRIORITY_2 = i18n.translate('xpack.stackConnectors.components.opsgenie.priority2', { + defaultMessage: 'P2', +}); + +export const PRIORITY_3 = i18n.translate('xpack.stackConnectors.components.opsgenie.priority3', { + defaultMessage: 'P3', +}); + +export const PRIORITY_4 = i18n.translate('xpack.stackConnectors.components.opsgenie.priority4', { + defaultMessage: 'P4', +}); + +export const PRIORITY_5 = i18n.translate('xpack.stackConnectors.components.opsgenie.priority5', { + defaultMessage: 'P5', +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/display_more_options.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/display_more_options.test.tsx new file mode 100644 index 0000000000000..f2cfbe3911732 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/display_more_options.test.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DisplayMoreOptions } from './display_more_options'; + +describe('DisplayMoreOptions', () => { + const toggleShowingMoreOptions = jest.fn(); + + const options = { + showingMoreOptions: false, + toggleShowingMoreOptions, + }; + + beforeEach(() => jest.clearAllMocks()); + + it('renders the more options text', () => { + render(); + + expect(screen.getByText('More options')).toBeInTheDocument(); + }); + + it('renders the hide options text', () => { + render(); + + expect(screen.getByText('Hide options')).toBeInTheDocument(); + }); + + it('calls toggleShowingMoreOptions when clicked', () => { + render(); + + userEvent.click(screen.getByTestId('opsgenie-display-more-options')); + + expect(toggleShowingMoreOptions).toHaveBeenCalledTimes(1); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/display_more_options.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/display_more_options.tsx new file mode 100644 index 0000000000000..fe58d18f7f179 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/display_more_options.tsx @@ -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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiButtonEmpty } from '@elastic/eui'; +import React from 'react'; +import * as i18n from './translations'; + +interface DisplayMoreOptionsProps { + showingMoreOptions: boolean; + toggleShowingMoreOptions: () => void; +} + +const DisplayMoreOptionsComponent: React.FC = ({ + showingMoreOptions, + toggleShowingMoreOptions, +}) => { + return ( + + {showingMoreOptions ? i18n.HIDE_OPTIONS : i18n.MORE_OPTIONS} + + ); +}; + +DisplayMoreOptionsComponent.displayName = 'MoreOptions'; + +export const DisplayMoreOptions = React.memo(DisplayMoreOptionsComponent); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/logo.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/logo.tsx index 7e705a788c0c7..bf03bf0dafb44 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/logo.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/logo.tsx @@ -5,11 +5,56 @@ * 2.0. */ -import { EuiIcon } from '@elastic/eui'; import React from 'react'; import { LogoProps } from '../../types'; -const Logo = (props: LogoProps) => ; +const Logo = (props: LogoProps) => ( + + + + + + + + + + + + + + + +); // eslint-disable-next-line import/no-default-export export { Logo as default }; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/model.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/model.test.tsx index f3b2c70a6e09f..c9bcdd08f111e 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/model.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/model.test.tsx @@ -41,6 +41,7 @@ describe('opsgenie action params validation', () => { errors: { 'subActionParams.message': [], 'subActionParams.alias': [], + jsonEditorError: [], }, }); }); @@ -57,6 +58,7 @@ describe('opsgenie action params validation', () => { errors: { 'subActionParams.message': [], 'subActionParams.alias': [], + jsonEditorError: [], }, }); }); @@ -71,6 +73,7 @@ describe('opsgenie action params validation', () => { errors: { 'subActionParams.message': ['Message is required.'], 'subActionParams.alias': [], + jsonEditorError: [], }, }); }); @@ -85,6 +88,21 @@ describe('opsgenie action params validation', () => { errors: { 'subActionParams.message': [], 'subActionParams.alias': ['Alias is required.'], + jsonEditorError: [], + }, + }); + }); + + it('sets the jsonEditorError when the jsonEditorError field is set to true', async () => { + const actionParams = { + jsonEditorError: true, + }; + + expect(await connectorTypeModel.validateParams(actionParams)).toEqual({ + errors: { + 'subActionParams.message': [], + 'subActionParams.alias': [], + jsonEditorError: ['JSON editor error exists'], }, }); }); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/model.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/model.tsx index 03290fbeca7ad..0e3c0e311d3a0 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/model.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/model.tsx @@ -11,14 +11,13 @@ import { ActionTypeModel as ConnectorTypeModel, GenericValidationResult, } from '@kbn/triggers-actions-ui-plugin/public'; -import { RecursivePartial } from '@elastic/eui'; import { OpsgenieSubActions } from '../../../../common'; import type { OpsgenieActionConfig, - OpsgenieActionParams, OpsgenieActionSecrets, } from '../../../../server/connector_types/stack'; import { DEFAULT_ALIAS } from './constants'; +import { OpsgenieConnectorTypeParams, ValidationParams } from './types'; const SELECT_MESSAGE = i18n.translate( 'xpack.stackConnectors.components.opsgenie.selectMessageText', @@ -34,42 +33,14 @@ const TITLE = i18n.translate('xpack.stackConnectors.components.opsgenie.connecto export const getConnectorType = (): ConnectorTypeModel< OpsgenieActionConfig, OpsgenieActionSecrets, - OpsgenieActionParams + OpsgenieConnectorTypeParams > => { return { id: '.opsgenie', iconClass: lazy(() => import('./logo')), selectMessage: SELECT_MESSAGE, actionTypeTitle: TITLE, - validateParams: async ( - actionParams: RecursivePartial - ): Promise> => { - const translations = await import('./translations'); - const errors = { - 'subActionParams.message': new Array(), - 'subActionParams.alias': new Array(), - }; - - const validationResult = { - errors, - }; - - if ( - actionParams.subAction === OpsgenieSubActions.CreateAlert && - !actionParams?.subActionParams?.message?.length - ) { - errors['subActionParams.message'].push(translations.MESSAGE_IS_REQUIRED); - } - - if ( - actionParams.subAction === OpsgenieSubActions.CloseAlert && - !actionParams?.subActionParams?.alias?.length - ) { - errors['subActionParams.alias'].push(translations.ALIAS_IS_REQUIRED); - } - - return validationResult; - }, + validateParams, actionConnectorFields: lazy(() => import('./connector')), actionParamsFields: lazy(() => import('./params')), defaultActionParams: { @@ -86,3 +57,39 @@ export const getConnectorType = (): ConnectorTypeModel< }, }; }; + +const validateParams = async ( + actionParams: ValidationParams +): Promise> => { + const translations = await import('./translations'); + const errors = { + 'subActionParams.message': new Array(), + 'subActionParams.alias': new Array(), + jsonEditorError: new Array(), + }; + + const validationResult = { + errors, + }; + + if ( + actionParams.subAction === OpsgenieSubActions.CreateAlert && + !actionParams?.subActionParams?.message?.length + ) { + errors['subActionParams.message'].push(translations.MESSAGE_IS_REQUIRED); + } + + if ( + actionParams.subAction === OpsgenieSubActions.CloseAlert && + !actionParams?.subActionParams?.alias?.length + ) { + errors['subActionParams.alias'].push(translations.ALIAS_IS_REQUIRED); + } + + if (actionParams.jsonEditorError) { + // This error doesn't actually get displayed it is used to cause the run/save button to fail within the action form + errors.jsonEditorError.push(translations.JSON_EDITOR_ERROR); + } + + return validationResult; +}; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/params.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/params.test.tsx index 4a7650f8c6cff..7f20f15294878 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/params.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/params.test.tsx @@ -11,6 +11,20 @@ import userEvent from '@testing-library/user-event'; import OpsgenieParamFields from './params'; import { OpsgenieSubActions } from '../../../../common'; import { OpsgenieActionParams } from '../../../../server/connector_types/stack'; +import { ActionConnectorMode } from '@kbn/triggers-actions-ui-plugin/public'; +import { MockCodeEditor } from '@kbn/triggers-actions-ui-plugin/public/application/code_editor.mock'; + +const kibanaReactPath = '../../../../../../../src/plugins/kibana_react/public'; + +jest.mock(kibanaReactPath, () => { + const original = jest.requireActual(kibanaReactPath); + return { + ...original, + CodeEditor: (props: any) => { + return ; + }, + }; +}); describe('OpsgenieParamFields', () => { const editAction = jest.fn(); @@ -44,6 +58,7 @@ describe('OpsgenieParamFields', () => { index: 0, messageVariables: [], actionConnector: connector, + executionMode: ActionConnectorMode.Test, }; const defaultCloseAlertProps = { @@ -56,6 +71,7 @@ describe('OpsgenieParamFields', () => { index: 0, messageVariables: [], actionConnector: connector, + executionMode: ActionConnectorMode.Test, }; beforeEach(() => { @@ -65,7 +81,7 @@ describe('OpsgenieParamFields', () => { it('renders the create alert component', async () => { render(); - expect(screen.getByText('Message')).toBeInTheDocument(); + expect(screen.getByText('Message (required)')).toBeInTheDocument(); expect(screen.getByText('Alias')).toBeInTheDocument(); expect(screen.getByTestId('opsgenie-subActionSelect')); @@ -77,7 +93,7 @@ describe('OpsgenieParamFields', () => { render(); expect(screen.queryByText('Message')).not.toBeInTheDocument(); - expect(screen.getByText('Alias')).toBeInTheDocument(); + expect(screen.getByText('Alias (required)')).toBeInTheDocument(); expect(screen.getByTestId('opsgenie-subActionSelect')); expect(screen.queryByDisplayValue('hello')).not.toBeInTheDocument(); @@ -85,6 +101,37 @@ describe('OpsgenieParamFields', () => { expect(screen.getByDisplayValue('456')).toBeInTheDocument(); }); + it('does not render the sub action select for creating an alert when execution mode is ActionForm', async () => { + render( + + ); + + expect(screen.getByText('Message (required)')).toBeInTheDocument(); + expect(screen.getByText('Alias')).toBeInTheDocument(); + expect(screen.queryByTestId('opsgenie-subActionSelect')).not.toBeInTheDocument(); + + expect(screen.getByDisplayValue('123')).toBeInTheDocument(); + expect(screen.getByDisplayValue('hello')).toBeInTheDocument(); + }); + + it('does not render the sub action select for closing an alert when execution mode is ActionForm', async () => { + render( + + ); + + expect(screen.queryByTestId('opsgenie-subActionSelect')).not.toBeInTheDocument(); + }); + + it('does not render the sub action select for closing an alert when execution mode is undefined', async () => { + render(); + + expect(screen.queryByTestId('opsgenie-subActionSelect')).not.toBeInTheDocument(); + }); + it('calls editAction when the message field is changed', async () => { render(); @@ -231,7 +278,7 @@ describe('OpsgenieParamFields', () => { act(() => userEvent.selectOptions( screen.getByTestId('opsgenie-subActionSelect'), - screen.getByText('Close Alert') + screen.getByText('Close alert') ) ); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/params.tsx b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/params.tsx index 8dd6ab450af75..33dc1740c5ad8 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/params.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/params.tsx @@ -8,131 +8,30 @@ import React, { useCallback, useEffect, useRef } from 'react'; import { ActionParamsProps, - TextAreaWithMessageVariables, - TextFieldWithMessageVariables, + ActionConnectorMode, + IErrorObject, } from '@kbn/triggers-actions-ui-plugin/public'; -import { EuiFormRow, EuiSelect, RecursivePartial } from '@elastic/eui'; +import { EuiFormRow, EuiSelect } from '@elastic/eui'; +import { isEmpty, unset, cloneDeep } from 'lodash'; import { OpsgenieSubActions } from '../../../../common'; import type { OpsgenieActionParams, - OpsgenieCloseAlertParams, - OpsgenieCreateAlertParams, + OpsgenieCreateAlertSubActionParams, } from '../../../../server/connector_types/stack'; import * as i18n from './translations'; - -type SubActionProps = Omit< - ActionParamsProps, - 'actionParams' | 'editAction' -> & { - subActionParams?: RecursivePartial; - editSubAction: ActionParamsProps['editAction']; -}; - -const CreateAlertComponent: React.FC> = ({ - editSubAction, - errors, - index, - messageVariables, - subActionParams, -}) => { - const isMessageInvalid = - errors['subActionParams.message'] !== undefined && - errors['subActionParams.message'].length > 0 && - subActionParams?.message !== undefined; - - return ( - <> - - - - - - - - - ); -}; - -CreateAlertComponent.displayName = 'CreateAlertComponent'; - -const CloseAlertComponent: React.FC> = ({ - editSubAction, - errors, - index, - messageVariables, - subActionParams, -}) => { - const isAliasInvalid = - errors['subActionParams.alias'] !== undefined && - errors['subActionParams.alias'].length > 0 && - subActionParams?.alias !== undefined; - - return ( - <> - - - - - - ); -}; - -CloseAlertComponent.displayName = 'CloseAlertComponent'; +import { CreateAlert } from './create_alert'; +import { CloseAlert } from './close_alert'; const actionOptions = [ { value: OpsgenieSubActions.CreateAlert, text: i18n.CREATE_ALERT_ACTION, + 'data-test-subj': 'opsgenie-subActionSelect-create-alert', }, { value: OpsgenieSubActions.CloseAlert, text: i18n.CLOSE_ALERT_ACTION, + 'data-test-subj': 'opsgenie-subActionSelect-close-alert', }, ]; @@ -142,6 +41,7 @@ const OpsgenieParamFields: React.FC> = ( errors, index, messageVariables, + executionMode, }) => { const { subAction, subActionParams } = actionParams; @@ -154,6 +54,20 @@ const OpsgenieParamFields: React.FC> = ( [editAction, index] ); + const editOptionalSubAction = useCallback( + (key, value) => { + if (isEmpty(value)) { + const paramsCopy = cloneDeep(subActionParams); + unset(paramsCopy, key); + editAction('subActionParams', paramsCopy, index); + return; + } + + editAction('subActionParams', { ...subActionParams, [key]: value }, index); + }, + [editAction, index, subActionParams] + ); + const editSubAction = useCallback( (key, value) => { editAction('subActionParams', { ...subActionParams, [key]: value }, index); @@ -175,35 +89,42 @@ const OpsgenieParamFields: React.FC> = ( editAction('subActionParams', params, index); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [subAction, currentSubAction]); + }, [subAction, currentSubAction, subActionParams?.alias, index]); return ( <> - - - + {executionMode === ActionConnectorMode.Test && ( + + + + )} - {subAction != null && subAction === OpsgenieSubActions.CreateAlert && ( - )} - {subAction != null && subAction === OpsgenieSubActions.CloseAlert && ( - > = ( OpsgenieParamFields.displayName = 'OpsgenieParamFields'; +/** + * The show*AlertSaveError functions are used to cause a rerender when fields are set to `null` when a user attempts to + * save the form before providing values for the required fields (message for creating an alert and alias for closing an alert). + * If we only passed in subActionParams the child components would not rerender because the objects field is only updated + * and not the entire object. + */ + +const showCreateAlertSaveError = ( + params: Partial, + errors: IErrorObject +): boolean => { + const errorArray = errors['subActionParams.message'] as string[] | undefined; + const errorsLength = errorArray?.length ?? 0; + + return ( + isCreateAlertParams(params) && params.subActionParams?.message === null && errorsLength > 0 + ); +}; + +const showCloseAlertSaveError = ( + params: Partial, + errors: IErrorObject +): boolean => { + const errorArray = errors['subActionParams.alias'] as string[] | undefined; + const errorsLength = errorArray?.length ?? 0; + + return isCloseAlertParams(params) && params.subActionParams?.alias === null && errorsLength > 0; +}; + +const isCreateAlertParams = ( + params: Partial +): params is Partial => + params.subAction === OpsgenieSubActions.CreateAlert; + +const isCloseAlertParams = ( + params: Partial +): params is OpsgenieCreateAlertSubActionParams => + params.subAction === OpsgenieSubActions.CloseAlert; + // eslint-disable-next-line import/no-default-export export { OpsgenieParamFields as default }; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/translations.ts b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/translations.ts index a5dd4e14c9c13..7c82f09f690be 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/translations.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/translations.ts @@ -21,6 +21,13 @@ export const API_KEY_LABEL = i18n.translate( } ); +export const MESSAGE_IS_REQUIRED = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.requiredMessageTextField', + { + defaultMessage: 'Message is required.', + } +); + export const ACTION_LABEL = i18n.translate( 'xpack.stackConnectors.components.opsgenie.actionLabel', { @@ -31,55 +38,76 @@ export const ACTION_LABEL = i18n.translate( export const CREATE_ALERT_ACTION = i18n.translate( 'xpack.stackConnectors.components.opsgenie.createAlertAction', { - defaultMessage: 'Create Alert', + defaultMessage: 'Create alert', } ); export const CLOSE_ALERT_ACTION = i18n.translate( 'xpack.stackConnectors.components.opsgenie.closeAlertAction', { - defaultMessage: 'Close Alert', + defaultMessage: 'Close alert', } ); -export const MESSAGE_FIELD_LABEL = i18n.translate( - 'xpack.stackConnectors.components.opsgenie.messageLabel', +export const NOTE_FIELD_LABEL = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.noteLabel', { - defaultMessage: 'Message', + defaultMessage: 'Note', } ); -export const NOTE_FIELD_LABEL = i18n.translate( - 'xpack.stackConnectors.components.opsgenie.noteLabel', +export const ALIAS_FIELD_LABEL = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.aliasLabel', { - defaultMessage: 'Note (optional)', + defaultMessage: 'Alias', } ); -export const DESCRIPTION_FIELD_LABEL = i18n.translate( - 'xpack.stackConnectors.components.opsgenie.descriptionLabel', +export const ALIAS_REQUIRED_FIELD_LABEL = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.aliasRequiredLabel', { - defaultMessage: 'Description (optional)', + defaultMessage: 'Alias (required)', } ); -export const MESSAGE_IS_REQUIRED = i18n.translate( - 'xpack.stackConnectors.components.opsgenie.requiredMessageTextField', +export const ALIAS_IS_REQUIRED = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.requiredAliasTextField', { - defaultMessage: 'Message is required.', + defaultMessage: 'Alias is required.', } ); -export const ALIAS_FIELD_LABEL = i18n.translate( - 'xpack.stackConnectors.components.opsgenie.aliasLabel', +export const MORE_OPTIONS = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.moreOptions', { - defaultMessage: 'Alias', + defaultMessage: 'More options', } ); -export const ALIAS_IS_REQUIRED = i18n.translate( - 'xpack.stackConnectors.components.opsgenie.requiredAliasTextField', +export const HIDE_OPTIONS = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.hideOptions', { - defaultMessage: 'Alias is required.', + defaultMessage: 'Hide options', + } +); + +export const USER_FIELD_LABEL = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.userLabel', + { + defaultMessage: 'User', + } +); + +export const SOURCE_FIELD_LABEL = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.sourceLabel', + { + defaultMessage: 'Source', + } +); + +export const JSON_EDITOR_ERROR = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.jsonEditorError', + { + defaultMessage: 'JSON editor error exists', } ); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/types.ts b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/types.ts index e1637e99f2149..f58c10334207b 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/types.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/stack/opsgenie/types.ts @@ -5,13 +5,38 @@ * 2.0. */ -import { UserConfiguredActionConnector } from '@kbn/triggers-actions-ui-plugin/public/types'; +import { RecursivePartial } from '@elastic/eui'; +import { + ActionParamsProps, + UserConfiguredActionConnector, +} from '@kbn/triggers-actions-ui-plugin/public/types'; import type { OpsgenieActionConfig, OpsgenieActionSecrets, + OpsgenieActionParams, } from '../../../../server/connector_types/stack'; export type OpsgenieActionConnector = UserConfiguredActionConnector< OpsgenieActionConfig, OpsgenieActionSecrets >; + +/** + * These fields will never be sent to Opsgenie or the sub actions framework. This allows us to pass a value to the + * validation functions so it cause a validation failure if the json editor has an error. That way the user can't save + * test. + */ +interface JsonEditorError { + jsonEditorError: boolean; +} + +export type OpsgenieConnectorTypeParams = OpsgenieActionParams & JsonEditorError; + +export type ValidationParams = RecursivePartial & JsonEditorError; + +type EditActionParameters = Parameters['editAction']>; + +export type EditActionCallback = ( + key: EditActionParameters[0], + value: EditActionParameters[1] +) => ReturnType['editAction']>; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/connector.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/connector.test.ts index 89dd11a2effcb..63c6f6a8466f3 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/connector.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/connector.test.ts @@ -147,4 +147,62 @@ describe('OpsgenieConnector', () => { data: { user: 'sam' }, }); }); + + describe('getResponseErrorMessage', () => { + it('returns an unknown error message', () => { + // @ts-expect-error expects an axios error as the parameter + expect(connector.getResponseErrorMessage({})).toMatchInlineSnapshot(`"unknown error"`); + }); + + it('returns the error.message', () => { + // @ts-expect-error expects an axios error as the parameter + expect(connector.getResponseErrorMessage({ message: 'a message' })).toMatchInlineSnapshot( + `"a message"` + ); + }); + + it('returns the error.response.data.message', () => { + expect( + // @ts-expect-error expects an axios error as the parameter + connector.getResponseErrorMessage({ response: { data: { message: 'a message' } } }) + ).toMatchInlineSnapshot(`"a message"`); + }); + + it('returns detailed message', () => { + // @ts-expect-error expects an axios error as the parameter + const error: AxiosError = { + response: { + data: { + errors: { + message: 'message field had a problem', + }, + message: 'toplevel message', + }, + }, + }; + + expect(connector.getResponseErrorMessage(error)).toMatchInlineSnapshot( + `"toplevel message: {\\"message\\":\\"message field had a problem\\"}"` + ); + }); + + it('returns detailed message with multiple entires', () => { + // @ts-expect-error expects an axios error as the parameter + const error: AxiosError = { + response: { + data: { + errors: { + message: 'message field had a problem', + visibleTo: 'visibleTo field had a problem', + }, + message: 'toplevel message', + }, + }, + }; + + expect(connector.getResponseErrorMessage(error)).toMatchInlineSnapshot( + `"toplevel message: {\\"message\\":\\"message field had a problem\\",\\"visibleTo\\":\\"visibleTo field had a problem\\"}"` + ); + }); + }); }); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/connector.ts b/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/connector.ts index cb454d87bf7bd..bdcb21d230df6 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/connector.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/connector.ts @@ -8,18 +8,12 @@ import crypto from 'crypto'; import { ServiceParams, SubActionConnector } from '@kbn/actions-plugin/server'; import { AxiosError } from 'axios'; +import { isEmpty } from 'lodash'; import { OpsgenieSubActions } from '../../../../common'; -import { CloseAlertParamsSchema, CreateAlertParamsSchema, Response } from './schema'; -import { CloseAlertParams, Config, CreateAlertParams, Secrets } from './types'; +import { CreateAlertParamsSchema, CloseAlertParamsSchema, Response } from './schema'; +import { CloseAlertParams, Config, CreateAlertParams, FailureResponseType, Secrets } from './types'; import * as i18n from './translations'; -interface ErrorSchema { - message?: string; - errors?: { - message?: string; - }; -} - export class OpsgenieConnector extends SubActionConnector { constructor(params: ServiceParams) { super(params); @@ -37,13 +31,40 @@ export class OpsgenieConnector extends SubActionConnector { }); } - public getResponseErrorMessage(error: AxiosError) { - return `Message: ${ - error.response?.data.errors?.message ?? - error.response?.data.message ?? - error.message ?? - i18n.UNKNOWN_ERROR - }`; + public getResponseErrorMessage(error: AxiosError) { + const mainMessage = error.response?.data.message ?? error.message ?? i18n.UNKNOWN_ERROR; + + if (error.response?.data?.errors != null) { + const message = this.getDetailedErrorMessage(error.response?.data?.errors); + if (!isEmpty(message)) { + return `${mainMessage}: ${message}`; + } + } + + return mainMessage; + } + + /** + * When testing invalid requests with Opsgenie the response seems to take the form: + * { + * ['field that is invalid']: 'message about what the issue is' + * } + * + * e.g. + * + * { + * "message": "Message can not be empty.", + * "username": "must be a well-formed email address" + * } + * + * So we'll just stringify it. + */ + private getDetailedErrorMessage(errorField: unknown) { + try { + return JSON.stringify(errorField); + } catch (error) { + return; + } } public async createAlert(params: CreateAlertParams) { diff --git a/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/schema.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/schema.test.ts new file mode 100644 index 0000000000000..b46ddb61be135 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/schema.test.ts @@ -0,0 +1,20 @@ +/* + * 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 { CreateAlertParamsSchema } from './schema'; +import { OpsgenieCreateAlertExample, ValidCreateAlertSchema } from './test_schema'; + +describe('opsgenie schema', () => { + describe('CreateAlertParamsSchema', () => { + it.each([ + ['ValidCreateAlertSchema', ValidCreateAlertSchema], + ['OpsgenieCreateAlertExample', OpsgenieCreateAlertExample], + ])('validates the test object [%s] correctly', (objectName, testObject) => { + expect(() => CreateAlertParamsSchema.validate(testObject)).not.toThrow(); + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/schema.ts b/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/schema.ts index 23fbe6be32b97..950b1b7117474 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/schema.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/schema.ts @@ -6,6 +6,8 @@ */ import { schema } from '@kbn/config-schema'; +import { isEmpty } from 'lodash'; +import * as i18n from './translations'; export const ConfigSchema = schema.object({ apiUrl: schema.string(), @@ -15,6 +17,48 @@ export const SecretsSchema = schema.object({ apiKey: schema.string(), }); +const SuccessfulResponse = schema.object( + { + took: schema.number(), + requestId: schema.string(), + result: schema.string(), + }, + { unknowns: 'allow' } +); + +export const FailureResponse = schema.object( + { + took: schema.number(), + requestId: schema.string(), + message: schema.maybe(schema.string()), + result: schema.maybe(schema.string()), + /** + * When testing invalid requests with Opsgenie the response seems to take the form: + * { + * ['field that is invalid']: 'message about what the issue is' + * } + * + * e.g. + * + * { + * "message": "Message can not be empty.", + * "username": "must be a well-formed email address" + * } + */ + errors: schema.maybe(schema.any()), + }, + { unknowns: 'allow' } +); + +export const Response = schema.oneOf([SuccessfulResponse, FailureResponse]); + +export const CloseAlertParamsSchema = schema.object({ + alias: schema.string(), + user: schema.maybe(schema.string({ maxLength: 100 })), + source: schema.maybe(schema.string({ maxLength: 100 })), + note: schema.maybe(schema.string({ maxLength: 25000 })), +}); + const responderTypes = schema.oneOf([ schema.literal('team'), schema.literal('user'), @@ -22,8 +66,15 @@ const responderTypes = schema.oneOf([ schema.literal('schedule'), ]); +/** + * For more information on the Opsgenie create alert schema see: https://docs.opsgenie.com/docs/alert-api#create-alert + */ export const CreateAlertParamsSchema = schema.object({ - message: schema.string({ maxLength: 130 }), + message: schema.string({ + maxLength: 130, + minLength: 1, + validate: (message) => (isEmpty(message.trim()) ? i18n.MESSAGE_NON_EMPTY : undefined), + }), /** * The max length here should be 512 according to Opsgenie's docs but we will sha256 hash the alias if it is longer than 512 * so we'll not impose a limit on the schema otherwise it'll get rejected prematurely. @@ -38,6 +89,12 @@ export const CreateAlertParamsSchema = schema.object({ type: responderTypes, }), schema.object({ id: schema.string(), type: responderTypes }), + /** + * This field is not explicitly called out in the description of responders within Opsgenie's API docs but it is + * shown in an example and when I tested it, it seems to work as they throw an error if you try to specify a username + * without a valid email + */ + schema.object({ username: schema.string(), type: schema.literal('user') }), ]), { maxSize: 50 } ) @@ -87,32 +144,3 @@ export const CreateAlertParamsSchema = schema.object({ user: schema.maybe(schema.string({ maxLength: 100 })), note: schema.maybe(schema.string({ maxLength: 25000 })), }); - -const SuccessfulResponse = schema.object( - { - took: schema.number(), - requestId: schema.string(), - result: schema.string(), - }, - { unknowns: 'allow' } -); - -const FailureResponse = schema.object( - { - took: schema.number(), - requestId: schema.string(), - message: schema.maybe(schema.string()), - result: schema.maybe(schema.string()), - errors: schema.maybe(schema.object({ message: schema.string() })), - }, - { unknowns: 'allow' } -); - -export const Response = schema.oneOf([SuccessfulResponse, FailureResponse]); - -export const CloseAlertParamsSchema = schema.object({ - alias: schema.string(), - user: schema.maybe(schema.string({ maxLength: 100 })), - source: schema.maybe(schema.string({ maxLength: 100 })), - note: schema.maybe(schema.string({ maxLength: 25000 })), -}); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/test_schema.ts b/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/test_schema.ts new file mode 100644 index 0000000000000..748423ef22381 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/test_schema.ts @@ -0,0 +1,96 @@ +/* + * 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 { CreateAlertParams } from './types'; + +export const ValidCreateAlertSchema: CreateAlertParams = { + message: 'a message', + alias: 'an alias', + description: 'a description', + responders: [ + { name: 'name for team', type: 'team' }, + { name: 'name for user', type: 'user' }, + { name: 'name for escalation', type: 'escalation' }, + { name: 'name for schedule', type: 'schedule' }, + { + id: '4513b7ea-3b91-438f-b7e4-e3e54af9147c', + type: 'team', + }, + { + name: 'NOC', + type: 'team', + }, + { + id: 'bb4d9938-c3c2-455d-aaab-727aa701c0d8', + type: 'user', + }, + { + username: 'trinity@opsgenie.com', + type: 'user', + }, + { + id: 'aee8a0de-c80f-4515-a232-501c0bc9d715', + type: 'escalation', + }, + { + name: 'Nightwatch Escalation', + type: 'escalation', + }, + { + id: '80564037-1984-4f38-b98e-8a1f662df552', + type: 'schedule', + }, + { + name: 'First Responders Schedule', + type: 'schedule', + }, + ], + visibleTo: [ + { name: 'name for team', type: 'team' }, + { id: 'id for team', type: 'team' }, + { id: 'id for user', type: 'user' }, + { username: 'username for user', type: 'user' }, + ], + actions: ['action1', 'action2'], + tags: ['tag1', 'tag2'], + details: { keyA: 'valueA', keyB: 'valueB' }, + entity: 'an entity', + source: 'a source', + priority: 'P2', + user: 'a user', + note: 'a note', +}; + +/** + * This example is pulled from the sample curl request here: https://docs.opsgenie.com/docs/alert-api#create-alert + */ +export const OpsgenieCreateAlertExample: CreateAlertParams = { + message: 'An example alert message', + alias: 'Life is too short for no alias', + description: 'Every alert needs a description', + responders: [ + { id: '4513b7ea-3b91-438f-b7e4-e3e54af9147c', type: 'team' }, + { name: 'NOC', type: 'team' }, + { id: 'bb4d9938-c3c2-455d-aaab-727aa701c0d8', type: 'user' }, + { username: 'trinity@opsgenie.com', type: 'user' }, + { id: 'aee8a0de-c80f-4515-a232-501c0bc9d715', type: 'escalation' }, + { name: 'Nightwatch Escalation', type: 'escalation' }, + { id: '80564037-1984-4f38-b98e-8a1f662df552', type: 'schedule' }, + { name: 'First Responders Schedule', type: 'schedule' }, + ], + visibleTo: [ + { id: '4513b7ea-3b91-438f-b7e4-e3e54af9147c', type: 'team' }, + { name: 'rocket_team', type: 'team' }, + { id: 'bb4d9938-c3c2-455d-aaab-727aa701c0d8', type: 'user' }, + { username: 'trinity@opsgenie.com', type: 'user' }, + ], + actions: ['Restart', 'AnExampleAction'], + tags: ['OverwriteQuietHours', 'Critical'], + details: { key1: 'value1', key2: 'value2' }, + entity: 'An example entity', + priority: 'P1', +}; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/translations.ts b/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/translations.ts index d5cd24f10a329..2a11e9482e15c 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/translations.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/translations.ts @@ -14,3 +14,10 @@ export const UNKNOWN_ERROR = i18n.translate('xpack.stackConnectors.opsgenie.unkn export const OPSGENIE_NAME = i18n.translate('xpack.stackConnectors.opsgenie.name', { defaultMessage: 'Opsgenie', }); + +export const MESSAGE_NON_EMPTY = i18n.translate( + 'xpack.stackConnectors.components.opsgenie.nonEmptyMessageField', + { + defaultMessage: 'must be populated with a value other than just whitespace', + } +); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/types.ts b/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/types.ts index 572a31b201cd0..a460edee30436 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/types.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/stack/opsgenie/types.ts @@ -6,10 +6,11 @@ */ import { TypeOf } from '@kbn/config-schema'; import { + CreateAlertParamsSchema, CloseAlertParamsSchema, ConfigSchema, - CreateAlertParamsSchema, SecretsSchema, + FailureResponse, } from './schema'; import { OpsgenieSubActions } from '../../../../common'; @@ -30,3 +31,5 @@ export interface CloseAlertSubActionParams { } export type Params = CreateAlertSubActionParams | CloseAlertSubActionParams; + +export type FailureResponseType = TypeOf; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/index.ts index 3bc994313093b..08f1a600c58a9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/index.ts @@ -15,3 +15,4 @@ export type { ConfigFieldSchema, SecretsFieldSchema } from './simple_connector_f export { ButtonGroupField } from './button_group_field'; export { JsonFieldWrapper } from './json_field_wrapper'; export { MustacheTextFieldWrapper } from './mustache_text_field_wrapper'; +export { SectionLoading } from './section_loading'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.test.tsx index f937663cd27a3..11c4b8bf5f017 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.test.tsx @@ -8,9 +8,17 @@ import * as React from 'react'; import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers'; import { ActionTypeForm } from './action_type_form'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; -import { ActionConnector, ActionType, RuleAction, GenericValidationResult } from '../../../types'; +import { + ActionConnector, + ActionType, + RuleAction, + GenericValidationResult, + ActionConnectorMode, +} from '../../../types'; import { act } from 'react-dom/test-utils'; import { EuiFieldText } from '@elastic/eui'; +import { I18nProvider } from '@kbn/i18n-react'; +import { render, waitFor, screen } from '@testing-library/react'; jest.mock('../../../common/lib/kibana'); const actionTypeRegistry = actionTypeRegistryMock.create(); @@ -31,6 +39,24 @@ describe('action_type_form', () => { }, })); + const mockedActionParamsFieldsWithExecutionMode = React.lazy(async () => ({ + default({ executionMode }: { executionMode?: ActionConnectorMode }) { + return ( + <> + {executionMode === ActionConnectorMode.Test && ( + + )} + {executionMode === ActionConnectorMode.ActionForm && ( + + )} + {executionMode === undefined && ( + + )} + + ); + }, + })); + it('calls "setActionParamsProperty" to set the default value for the empty dedupKey', async () => { const actionType = actionTypeRegistryMock.createMockActionTypeModel({ id: '.pagerduty', @@ -81,6 +107,52 @@ describe('action_type_form', () => { ); }); + it('renders the actionParamsField with the execution mode set to ActionForm', async () => { + const actionType = actionTypeRegistryMock.createMockActionTypeModel({ + id: '.pagerduty', + iconClass: 'test', + selectMessage: 'test', + validateParams: (): Promise> => { + const validationResult = { errors: {} }; + return Promise.resolve(validationResult); + }, + actionConnectorFields: null, + actionParamsFields: mockedActionParamsFieldsWithExecutionMode, + defaultActionParams: { + dedupKey: 'test', + eventAction: 'resolve', + }, + }); + actionTypeRegistry.get.mockReturnValue(actionType); + + render( + + {getActionTypeForm(1, undefined, { + id: '123', + actionTypeId: '.pagerduty', + group: 'recovered', + params: { + eventAction: 'recovered', + dedupKey: undefined, + summary: '2323', + source: 'source', + severity: '1', + timestamp: new Date().toISOString(), + component: 'test', + group: 'group', + class: 'test class', + }, + })} + + ); + + await waitFor(() => { + expect(screen.getByTestId('executionModeFieldActionForm')).toBeInTheDocument(); + expect(screen.queryByTestId('executionModeFieldTest')).not.toBeInTheDocument(); + expect(screen.queryByTestId('executionModeFieldUndefined')).not.toBeInTheDocument(); + }); + }); + it('does not call "setActionParamsProperty" because dedupKey is not empty', async () => { const actionType = actionTypeRegistryMock.createMockActionTypeModel({ id: '.pagerduty', diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx index c3ca4b0275610..eff0ff126d3bc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx @@ -36,6 +36,7 @@ import { ActionConnector, ActionVariables, ActionTypeRegistryContract, + ActionConnectorMode, } from '../../../types'; import { checkActionFormActionTypeEnabled } from '../../lib/check_action_type_enabled'; import { hasSaveActionsCapability } from '../../lib/capabilities'; @@ -280,6 +281,7 @@ export const ActionTypeForm = ({ messageVariables={availableActionVariables} defaultMessage={selectedActionGroup?.defaultActionMessage ?? defaultActionMessage} actionConnector={actionConnector} + executionMode={ActionConnectorMode.ActionForm} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.test.tsx index 17aaf90e0ddbf..e17cdf6e13a17 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.test.tsx @@ -9,10 +9,16 @@ import React, { lazy } from 'react'; import { I18nProvider } from '@kbn/i18n-react'; import TestConnectorForm from './test_connector_form'; import { none, some } from 'fp-ts/lib/Option'; -import { ActionConnector, GenericValidationResult } from '../../../types'; +import { + ActionConnector, + ActionConnectorMode, + ActionParamsProps, + GenericValidationResult, +} from '../../../types'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { EuiFormRow, EuiFieldText, EuiText, EuiLink, EuiForm, EuiSelect } from '@elastic/eui'; import { mountWithIntl } from '@kbn/test-jest-helpers'; +import { waitFor, screen, render } from '@testing-library/react'; jest.mock('../../../common/lib/kibana'); const mockedActionParamsFields = lazy(async () => ({ @@ -59,6 +65,34 @@ const actionType = { const actionTypeRegistry = actionTypeRegistryMock.create(); actionTypeRegistry.get.mockReturnValue(actionType); +const ExecutionModeComponent: React.FC, 'executionMode'>> = ({ + executionMode, +}) => { + return ( + + + <> + {executionMode === ActionConnectorMode.Test && ( + + )} + {executionMode === ActionConnectorMode.ActionForm && ( + + )} + {executionMode === undefined && ( + + )} + + + + ); +}; + +const mockedActionParamsFieldsExecutionMode = lazy(async () => ({ + default: ({ executionMode }: { executionMode?: ActionConnectorMode }) => { + return ; + }, +})); + describe('test_connector_form', () => { it('renders initially as the action form and execute button and no result', async () => { const connector = { @@ -88,6 +122,49 @@ describe('test_connector_form', () => { expect(result?.exists()).toBeTruthy(); }); + it('renders the execution test field', async () => { + const actionTypeExecutionMode = { + id: 'execution-mode-type', + iconClass: 'test', + selectMessage: 'test', + validateParams: (): Promise> => { + const validationResult = { errors: {} }; + return Promise.resolve(validationResult); + }, + actionConnectorFields: null, + actionParamsFields: mockedActionParamsFieldsExecutionMode, + }; + const actionTypeRegistryExecutionMode = actionTypeRegistryMock.create(); + actionTypeRegistryExecutionMode.get.mockReturnValue(actionTypeExecutionMode); + + const connector = { + actionTypeId: actionTypeExecutionMode.id, + config: {}, + secrets: {}, + } as ActionConnector; + + render( + + {}} + isExecutingAction={false} + onExecutionAction={async () => {}} + executionResult={none} + actionTypeRegistry={actionTypeRegistryExecutionMode} + /> + + ); + + await waitFor(() => { + expect(screen.getByTestId('executionModeFieldTest')).toBeInTheDocument(); + expect(screen.queryByTestId('executionModeFieldActionForm')).not.toBeInTheDocument(); + expect(screen.queryByTestId('executionModeFieldUndefined')).not.toBeInTheDocument(); + }); + }); + it('renders successful results', async () => { const connector = { actionTypeId: actionType.id, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.tsx index 739ee4100d535..e13ca322a7cf2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/test_connector_form.tsx @@ -23,7 +23,12 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import { ActionTypeExecutorResult } from '@kbn/actions-plugin/common'; -import { ActionConnector, ActionTypeRegistryContract, IErrorObject } from '../../../types'; +import { + ActionConnector, + ActionConnectorMode, + ActionTypeRegistryContract, + IErrorObject, +} from '../../../types'; export interface TestConnectorFormProps { connector: ActionConnector; @@ -90,6 +95,7 @@ export const TestConnectorForm = ({ } messageVariables={[]} actionConnector={connector} + executionMode={ActionConnectorMode.Test} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/index.ts b/x-pack/plugins/triggers_actions_ui/public/index.ts index 0a560be762eb5..0228ac4dec11b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/index.ts @@ -55,6 +55,7 @@ export { ALERT_HISTORY_PREFIX, AlertHistoryDocumentTemplate, AlertHistoryEsIndexConnectorId, + ActionConnectorMode, } from './types'; export { useConnectorContext } from './application/context/use_connector_context'; @@ -79,6 +80,7 @@ export { SimpleConnectorForm, TextAreaWithMessageVariables, TextFieldWithMessageVariables, + SectionLoading, } from './application/components'; export { diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index c001c7b90fa39..53813632af435 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -162,6 +162,11 @@ export interface BulkEditResponse { total: number; } +export enum ActionConnectorMode { + Test = 'test', + ActionForm = 'actionForm', +} + export interface ActionParamsProps { actionParams: Partial; index: number; @@ -173,6 +178,7 @@ export interface ActionParamsProps { isLoading?: boolean; isDisabled?: boolean; showEmailSubjectAndMessage?: boolean; + executionMode?: ActionConnectorMode; } export interface Pagination { diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/stack/opsgenie.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/stack/opsgenie.ts index adce611ec3d1d..1c404a4431d81 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/stack/opsgenie.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/stack/opsgenie.ts @@ -253,7 +253,7 @@ export default function opsgenieTest({ getService }: FtrProviderContext) { retry: true, message: 'an error occurred while running the action', service_message: - 'Request validation failed (Error: [responders.0]: types that failed validation:\n- [responders.0.0.type]: types that failed validation:\n - [responders.0.type.0]: expected value to equal [team]\n - [responders.0.type.1]: expected value to equal [user]\n - [responders.0.type.2]: expected value to equal [escalation]\n - [responders.0.type.3]: expected value to equal [schedule]\n- [responders.0.1.id]: expected value of type [string] but got [undefined])', + 'Request validation failed (Error: [responders.0]: types that failed validation:\n- [responders.0.0.type]: types that failed validation:\n - [responders.0.type.0]: expected value to equal [team]\n - [responders.0.type.1]: expected value to equal [user]\n - [responders.0.type.2]: expected value to equal [escalation]\n - [responders.0.type.3]: expected value to equal [schedule]\n- [responders.0.1.id]: expected value of type [string] but got [undefined]\n- [responders.0.2.username]: expected value of type [string] but got [undefined])', }); }); @@ -282,7 +282,7 @@ export default function opsgenieTest({ getService }: FtrProviderContext) { retry: true, message: 'an error occurred while running the action', service_message: - 'Request validation failed (Error: [responders.0]: types that failed validation:\n- [responders.0.0.name]: expected value of type [string] but got [undefined]\n- [responders.0.1.id]: expected value of type [string] but got [undefined])', + 'Request validation failed (Error: [responders.0]: types that failed validation:\n- [responders.0.0.name]: expected value of type [string] but got [undefined]\n- [responders.0.1.id]: expected value of type [string] but got [undefined]\n- [responders.0.2.username]: expected value of type [string] but got [undefined])', }); }); @@ -682,7 +682,8 @@ export default function opsgenieTest({ getService }: FtrProviderContext) { message: 'an error occurred while running the action', retry: true, connector_id: opsgenieActionId, - service_message: 'Status code: undefined. Message: Message: failed', + service_message: + 'Status code: 422. Message: Request failed with status code 422: {"message":"failed"}', }); }); @@ -704,7 +705,8 @@ export default function opsgenieTest({ getService }: FtrProviderContext) { message: 'an error occurred while running the action', retry: true, connector_id: opsgenieActionId, - service_message: 'Status code: undefined. Message: Message: failed', + service_message: + 'Status code: 422. Message: Request failed with status code 422: {"message":"failed"}', }); }); }); diff --git a/x-pack/test/functional/services/actions/common.ts b/x-pack/test/functional/services/actions/common.ts index 55ba48001bfe3..145f56e49a351 100644 --- a/x-pack/test/functional/services/actions/common.ts +++ b/x-pack/test/functional/services/actions/common.ts @@ -25,5 +25,16 @@ export function ActionsCommonServiceProvider({ getService, getPageObject }: FtrP await testSubjects.click(`.${name}-card`); }, + + async cancelConnectorForm() { + const flyOutCancelButton = await testSubjects.find('edit-connector-flyout-close-btn'); + const isEnabled = await flyOutCancelButton.isEnabled(); + const isDisplayed = await flyOutCancelButton.isDisplayed(); + + if (isEnabled && isDisplayed) { + await flyOutCancelButton.click(); + await testSubjects.missingOrFail('edit-connector-flyout-close-btn'); + } + }, }; } diff --git a/x-pack/test/functional/services/actions/opsgenie.ts b/x-pack/test/functional/services/actions/opsgenie.ts index 32f4d82068354..6a0fb18c8658b 100644 --- a/x-pack/test/functional/services/actions/opsgenie.ts +++ b/x-pack/test/functional/services/actions/opsgenie.ts @@ -20,6 +20,7 @@ export function ActionsOpsgenieServiceProvider( common: ActionsCommon ) { const testSubjects = getService('testSubjects'); + const find = getService('find'); return { async createNewConnector(fields: ConnectorFormFields) { @@ -44,5 +45,20 @@ export function ActionsOpsgenieServiceProvider( expect(await editFlyOutSaveButton.isEnabled()).to.be(true); await editFlyOutSaveButton.click(); }, + + async getObjFromJsonEditor() { + const jsonEditor = await find.byCssSelector('.monaco-editor .view-lines'); + + return JSON.parse(await jsonEditor.getVisibleText()); + }, + + async setJsonEditor(value: object) { + const stringified = JSON.stringify(value); + + await find.clickByCssSelector('.monaco-editor'); + const input = await find.activeElement(); + await input.clearValueWithKeyboard({ charByChar: true }); + await input.type(stringified); + }, }; } diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/opsgenie.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/opsgenie.ts index ff428bdb44e2e..574505a7c4e88 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/opsgenie.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/opsgenie.ts @@ -140,6 +140,149 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { expect(await (await testSubjects.find('executeActionButton')).isEnabled()).to.be(false); }); + + describe('test page', () => { + let connectorId = ''; + + before(async () => { + const connectorName = generateUniqueKey(); + const createdAction = await createOpsgenieConnector(connectorName); + connectorId = createdAction.id; + objectRemover.add(createdAction.id, 'action', 'actions'); + }); + + beforeEach(async () => { + await testSubjects.click(`edit${connectorId}`); + await testSubjects.click('testConnectorTab'); + }); + + afterEach(async () => { + await actions.common.cancelConnectorForm(); + }); + + it('should show the sub action selector when in test mode', async () => { + await testSubjects.existOrFail('opsgenie-subActionSelect'); + }); + + it('should preserve the alias when switching between create and close alert actions', async () => { + await testSubjects.setValue('aliasInput', 'new alias'); + await testSubjects.selectValue('opsgenie-subActionSelect', 'closeAlert'); + + expect(await testSubjects.getAttribute('opsgenie-subActionSelect', 'value')).to.be( + 'closeAlert' + ); + expect(await testSubjects.getAttribute('aliasInput', 'value')).to.be('new alias'); + }); + + it('should not preserve the message when switching to close alert and back to create alert', async () => { + await testSubjects.setValue('messageInput', 'a message'); + await testSubjects.selectValue('opsgenie-subActionSelect', 'closeAlert'); + + await testSubjects.missingOrFail('messageInput'); + await retry.waitFor('message input to be displayed', async () => { + await testSubjects.selectValue('opsgenie-subActionSelect', 'createAlert'); + return await testSubjects.exists('messageInput'); + }); + + expect(await testSubjects.getAttribute('messageInput', 'value')).to.be(''); + }); + + describe('createAlert', () => { + it('should show the additional options for creating an alert when clicking more options', async () => { + await testSubjects.click('opsgenie-display-more-options'); + + await testSubjects.existOrFail('entityInput'); + await testSubjects.existOrFail('sourceInput'); + await testSubjects.existOrFail('userInput'); + await testSubjects.existOrFail('noteTextArea'); + }); + + it('should show and then hide the additional form options for creating an alert when clicking the button twice', async () => { + await testSubjects.click('opsgenie-display-more-options'); + + await testSubjects.existOrFail('entityInput'); + + await testSubjects.click('opsgenie-display-more-options'); + await testSubjects.missingOrFail('entityInput'); + }); + + it('should populate the json editor with the message, description, and alias', async () => { + await testSubjects.setValue('messageInput', 'a message'); + await testSubjects.setValue('descriptionTextArea', 'a description'); + await testSubjects.setValue('aliasInput', 'an alias'); + await testSubjects.setValue('opsgenie-prioritySelect', 'P5'); + await testSubjects.setValue('opsgenie-tags', 'a tag'); + + await testSubjects.click('opsgenie-show-json-editor-toggle'); + + const parsedValue = await actions.opsgenie.getObjFromJsonEditor(); + expect(parsedValue).to.eql({ + message: 'a message', + description: 'a description', + alias: 'an alias', + priority: 'P5', + tags: ['a tag'], + }); + }); + + it('should populate the form with the values from the json editor', async () => { + await testSubjects.click('opsgenie-show-json-editor-toggle'); + + await actions.opsgenie.setJsonEditor({ + message: 'a message', + description: 'a description', + alias: 'an alias', + priority: 'P3', + tags: ['tag1'], + }); + await testSubjects.click('opsgenie-show-json-editor-toggle'); + + expect(await testSubjects.getAttribute('messageInput', 'value')).to.be('a message'); + expect(await testSubjects.getAttribute('descriptionTextArea', 'value')).to.be( + 'a description' + ); + expect(await testSubjects.getAttribute('aliasInput', 'value')).to.be('an alias'); + expect(await testSubjects.getAttribute('opsgenie-prioritySelect', 'value')).to.eql( + 'P3' + ); + expect(await (await testSubjects.find('opsgenie-tags')).getVisibleText()).to.eql( + 'tag1' + ); + }); + + it('should disable the run button when the json editor validation fails', async () => { + await testSubjects.click('opsgenie-show-json-editor-toggle'); + + await actions.opsgenie.setJsonEditor({ + message: '', + }); + + expect(await testSubjects.isEnabled('executeActionButton')).to.be(false); + }); + }); + + describe('closeAlert', () => { + it('should show the additional options for closing an alert when clicking more options', async () => { + await testSubjects.selectValue('opsgenie-subActionSelect', 'closeAlert'); + + await testSubjects.click('opsgenie-display-more-options'); + + await testSubjects.existOrFail('sourceInput'); + await testSubjects.existOrFail('userInput'); + }); + + it('should show and then hide the additional form options for closing an alert when clicking the button twice', async () => { + await testSubjects.selectValue('opsgenie-subActionSelect', 'closeAlert'); + + await testSubjects.click('opsgenie-display-more-options'); + + await testSubjects.existOrFail('sourceInput'); + + await testSubjects.click('opsgenie-display-more-options'); + await testSubjects.missingOrFail('sourceInput'); + }); + }); + }); }); describe('alerts page', () => { @@ -163,9 +306,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); it('should default to the create alert action', async () => { - expect(await testSubjects.getAttribute('opsgenie-subActionSelect', 'value')).to.eql( - 'createAlert' - ); + await testSubjects.existOrFail('messageInput'); expect(await testSubjects.getAttribute('aliasInput', 'value')).to.eql(defaultAlias); }); @@ -174,33 +315,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.click('addNewActionConnectorActionGroup-0'); await testSubjects.click('addNewActionConnectorActionGroup-0-option-recovered'); - expect(await testSubjects.getAttribute('opsgenie-subActionSelect', 'value')).to.eql( - 'closeAlert' - ); expect(await testSubjects.getAttribute('aliasInput', 'value')).to.eql(defaultAlias); - }); - - it('should preserve the alias when switching between create and close alert actions', async () => { - await testSubjects.setValue('aliasInput', 'new alias'); - await testSubjects.selectValue('opsgenie-subActionSelect', 'closeAlert'); - - expect(await testSubjects.getAttribute('opsgenie-subActionSelect', 'value')).to.be( - 'closeAlert' - ); - expect(await testSubjects.getAttribute('aliasInput', 'value')).to.be('new alias'); - }); - - it('should not preserve the message when switching to close alert and back to create alert', async () => { - await testSubjects.setValue('messageInput', 'a message'); - await testSubjects.selectValue('opsgenie-subActionSelect', 'closeAlert'); - + await testSubjects.existOrFail('noteTextArea'); await testSubjects.missingOrFail('messageInput'); - await retry.waitFor('message input to be displayed', async () => { - await testSubjects.selectValue('opsgenie-subActionSelect', 'createAlert'); - return await testSubjects.exists('messageInput'); - }); - - expect(await testSubjects.getAttribute('messageInput', 'value')).to.be(''); }); it('should not preserve the alias when switching run when to recover', async () => { @@ -225,6 +342,13 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { expect(await testSubjects.getAttribute('aliasInput', 'value')).to.be(defaultAlias); }); + + it('should show the message is required error when clicking the save button', async () => { + await testSubjects.click('saveRuleButton'); + const messageError = await find.byClassName('euiFormErrorText'); + + expect(await messageError.getVisibleText()).to.eql('Message is required.'); + }); }); const setupRule = async () => { From 2619f0488f857739030daa099d42c805adc72044 Mon Sep 17 00:00:00 2001 From: Maja Grubic Date: Mon, 7 Nov 2022 17:48:45 +0100 Subject: [PATCH 009/192] [SharedUX] Allow creation of adHoc data views from no data page (#144596) * [NoDataPage][Discover] Support creation of adHoc data views * [SharedUX] Allow option to create adHoc data views * [SharedUX] Allow option to create adHoc data views Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../analytics_no_data_page.component.test.tsx | 17 +++++++++++++++++ .../src/analytics_no_data_page.component.tsx | 11 ++++++++--- .../impl/src/analytics_no_data_page.test.tsx | 3 ++- .../impl/src/analytics_no_data_page.tsx | 6 +++++- .../page/analytics_no_data/types/index.d.ts | 2 ++ .../impl/src/kibana_no_data_page.tsx | 13 +++++++++++-- .../page/kibana_no_data/types/index.d.ts | 4 ++++ .../no_data_views/impl/src/no_data_views.tsx | 14 ++++++++++++-- .../prompt/no_data_views/types/index.d.ts | 4 ++++ 9 files changed, 65 insertions(+), 9 deletions(-) diff --git a/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.component.test.tsx b/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.component.test.tsx index ae9459272e37d..4bd3fca6cb902 100644 --- a/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.component.test.tsx +++ b/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.component.test.tsx @@ -42,4 +42,21 @@ describe('AnalyticsNoDataPageComponent', () => { expect(noDataConfig.docsLink).toEqual('http://www.test.com'); expect(noDataConfig.action.elasticAgent).not.toBeNull(); }); + + it('allows ad-hoc data view creation', async () => { + const component = mountWithIntl( + + + + ); + + await act(() => new Promise(setImmediate)); + + expect(component.find(KibanaNoDataPage).length).toBe(1); + expect(component.find(KibanaNoDataPage).props().allowAdHocDataView).toBe(true); + }); }); diff --git a/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.component.tsx b/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.component.tsx index 657d823606155..fe607b70120df 100644 --- a/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.component.tsx +++ b/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.component.tsx @@ -17,6 +17,8 @@ export interface Props { kibanaGuideDocLink: string; /** Handler for successfully creating a new data view. */ onDataViewCreated: (dataView: unknown) => void; + /** if set to true allows creation of an ad-hoc dataview from data view editor */ + allowAdHocDataView?: boolean; } const solution = i18n.translate('sharedUXPackages.noDataConfig.analytics', { @@ -41,7 +43,11 @@ const addIntegrationsDescription = i18n.translate( /** * A pure component of an entire page that can be displayed when Kibana "has no data", specifically for Analytics. */ -export const AnalyticsNoDataPage = ({ kibanaGuideDocLink, onDataViewCreated }: Props) => { +export const AnalyticsNoDataPage = ({ + kibanaGuideDocLink, + onDataViewCreated, + allowAdHocDataView, +}: Props) => { const noDataConfig = { solution, pageTitle, @@ -55,6 +61,5 @@ export const AnalyticsNoDataPage = ({ kibanaGuideDocLink, onDataViewCreated }: P }, docsLink: kibanaGuideDocLink, }; - - return ; + return ; }; diff --git a/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.test.tsx b/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.test.tsx index 191ea9010ba1e..996b9d062becf 100644 --- a/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.test.tsx +++ b/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.test.tsx @@ -27,7 +27,7 @@ describe('AnalyticsNoDataPage', () => { it('renders correctly', async () => { const component = mountWithIntl( - + ); @@ -36,5 +36,6 @@ describe('AnalyticsNoDataPage', () => { expect(component.find(Component).length).toBe(1); expect(component.find(Component).props().kibanaGuideDocLink).toBe(services.kibanaGuideDocLink); expect(component.find(Component).props().onDataViewCreated).toBe(onDataViewCreated); + expect(component.find(Component).props().allowAdHocDataView).toBe(true); }); }); diff --git a/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.tsx b/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.tsx index 8a67467939a92..df1fc2486c1b3 100644 --- a/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.tsx +++ b/packages/shared-ux/page/analytics_no_data/impl/src/analytics_no_data_page.tsx @@ -15,7 +15,10 @@ import { AnalyticsNoDataPage as Component } from './analytics_no_data_page.compo * An entire page that can be displayed when Kibana "has no data", specifically for Analytics. Uses * services from a Provider to supply props to a pure component. */ -export const AnalyticsNoDataPage = ({ onDataViewCreated }: AnalyticsNoDataPageProps) => { +export const AnalyticsNoDataPage = ({ + onDataViewCreated, + allowAdHocDataView, +}: AnalyticsNoDataPageProps) => { const services = useServices(); const { kibanaGuideDocLink } = services; @@ -23,6 +26,7 @@ export const AnalyticsNoDataPage = ({ onDataViewCreated }: AnalyticsNoDataPagePr diff --git a/packages/shared-ux/page/analytics_no_data/types/index.d.ts b/packages/shared-ux/page/analytics_no_data/types/index.d.ts index 1e36aae41df77..d4021360bea23 100644 --- a/packages/shared-ux/page/analytics_no_data/types/index.d.ts +++ b/packages/shared-ux/page/analytics_no_data/types/index.d.ts @@ -47,4 +47,6 @@ export type AnalyticsNoDataPageKibanaDependencies = KibanaDependencies & export interface AnalyticsNoDataPageProps { /** Handler for successfully creating a new data view. */ onDataViewCreated: (dataView: unknown) => void; + /** if set to true allows creation of an ad-hoc data view from data view editor */ + allowAdHocDataView?: boolean; } diff --git a/packages/shared-ux/page/kibana_no_data/impl/src/kibana_no_data_page.tsx b/packages/shared-ux/page/kibana_no_data/impl/src/kibana_no_data_page.tsx index 73726d7b82eaa..c3fbccd3a60fb 100644 --- a/packages/shared-ux/page/kibana_no_data/impl/src/kibana_no_data_page.tsx +++ b/packages/shared-ux/page/kibana_no_data/impl/src/kibana_no_data_page.tsx @@ -16,7 +16,11 @@ import { useServices } from './services'; /** * A page to display when Kibana has no data, prompting a person to add integrations or create a new data view. */ -export const KibanaNoDataPage = ({ onDataViewCreated, noDataConfig }: KibanaNoDataPageProps) => { +export const KibanaNoDataPage = ({ + onDataViewCreated, + noDataConfig, + allowAdHocDataView, +}: KibanaNoDataPageProps) => { // These hooks are temporary, until this component is moved to a package. const services = useServices(); const { hasESData, hasUserDataView } = services; @@ -43,7 +47,12 @@ export const KibanaNoDataPage = ({ onDataViewCreated, noDataConfig }: KibanaNoDa } if (!hasUserDataViews && dataExists) { - return ; + return ( + + ); } if (!dataExists) { diff --git a/packages/shared-ux/page/kibana_no_data/types/index.d.ts b/packages/shared-ux/page/kibana_no_data/types/index.d.ts index 18fe5499e93c3..1cce51f372021 100644 --- a/packages/shared-ux/page/kibana_no_data/types/index.d.ts +++ b/packages/shared-ux/page/kibana_no_data/types/index.d.ts @@ -22,6 +22,8 @@ export interface Services { hasESData: () => Promise; /** True if Kibana instance contains user-created data view, false otherwise. */ hasUserDataView: () => Promise; + /** if set to true allows creation of an ad-hoc data view from data view editor */ + allowAdHocDataView?: boolean; } /** @@ -53,4 +55,6 @@ export interface KibanaNoDataPageProps { onDataViewCreated: (dataView: unknown) => void; /** `NoDataPage` configuration; see `NoDataPageProps`. */ noDataConfig: NoDataPageProps; + /** if set to true allows creation of an ad-hoc dataview from data view editor */ + allowAdHocDataView?: boolean; } diff --git a/packages/shared-ux/prompt/no_data_views/impl/src/no_data_views.tsx b/packages/shared-ux/prompt/no_data_views/impl/src/no_data_views.tsx index 2248f1f6cc1c0..4f668a1017b28 100644 --- a/packages/shared-ux/prompt/no_data_views/impl/src/no_data_views.tsx +++ b/packages/shared-ux/prompt/no_data_views/impl/src/no_data_views.tsx @@ -24,7 +24,10 @@ type CloseDataViewEditorFn = ReturnType { +export const NoDataViewsPrompt = ({ + onDataViewCreated, + allowAdHocDataView = false, +}: NoDataViewsPromptProps) => { const { canCreateNewDataView, openDataViewEditor, dataViewsDocLink } = useServices(); const closeDataViewEditor = useRef(); @@ -54,12 +57,19 @@ export const NoDataViewsPrompt = ({ onDataViewCreated }: NoDataViewsPromptProps) onSave: (dataView) => { onDataViewCreated(dataView); }, + allowAdHocDataView, }); if (setDataViewEditorRef) { setDataViewEditorRef(ref); } - }, [canCreateNewDataView, openDataViewEditor, setDataViewEditorRef, onDataViewCreated]); + }, [ + canCreateNewDataView, + openDataViewEditor, + allowAdHocDataView, + setDataViewEditorRef, + onDataViewCreated, + ]); return ( diff --git a/packages/shared-ux/prompt/no_data_views/types/index.d.ts b/packages/shared-ux/prompt/no_data_views/types/index.d.ts index 4b428cc64ad3d..eff6ad60e2aa4 100644 --- a/packages/shared-ux/prompt/no_data_views/types/index.d.ts +++ b/packages/shared-ux/prompt/no_data_views/types/index.d.ts @@ -26,6 +26,8 @@ type DataView = unknown; interface DataViewEditorOptions { /** Handler to be invoked when the Data View Editor completes a save operation. */ onSave: (dataView: DataView) => void; + /** if set to true allows creation of an ad-hoc data view from data view editor */ + allowAdHocDataView?: boolean; } /** @@ -75,4 +77,6 @@ export interface NoDataViewsPromptComponentProps { export interface NoDataViewsPromptProps { /** Handler for successfully creating a new data view. */ onDataViewCreated: (dataView: unknown) => void; + /** if set to true allows creation of an ad-hoc data view from data view editor */ + allowAdHocDataView?: boolean; } From 508f0127e340e0e29055389af362add64eae7529 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Mon, 7 Nov 2022 09:51:11 -0700 Subject: [PATCH 010/192] [Dashboard] [Control] Remove support for scripted fields for options list (#144643) * Remove support for scripted fields for all controls * Remove support only for options list * Add functional test --- .../embeddable/options_list_embeddable_factory.tsx | 7 ++++--- .../apps/dashboard_elements/controls/options_list.ts | 9 +++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/plugins/controls/public/options_list/embeddable/options_list_embeddable_factory.tsx b/src/plugins/controls/public/options_list/embeddable/options_list_embeddable_factory.tsx index ea36ede0e1c9d..2292555316b82 100644 --- a/src/plugins/controls/public/options_list/embeddable/options_list_embeddable_factory.tsx +++ b/src/plugins/controls/public/options_list/embeddable/options_list_embeddable_factory.tsx @@ -56,9 +56,10 @@ export class OptionsListEmbeddableFactory public isFieldCompatible = (dataControlField: DataControlField) => { if ( - (dataControlField.field.aggregatable && dataControlField.field.type === 'string') || - dataControlField.field.type === 'boolean' || - dataControlField.field.type === 'ip' + !dataControlField.field.spec.scripted && + ((dataControlField.field.aggregatable && dataControlField.field.type === 'string') || + dataControlField.field.type === 'boolean' || + dataControlField.field.type === 'ip') ) { dataControlField.compatibleControlTypes.push(this.type); } diff --git a/test/functional/apps/dashboard_elements/controls/options_list.ts b/test/functional/apps/dashboard_elements/controls/options_list.ts index 6cfe2c31fa0c1..2a16d33775397 100644 --- a/test/functional/apps/dashboard_elements/controls/options_list.ts +++ b/test/functional/apps/dashboard_elements/controls/options_list.ts @@ -195,6 +195,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboard.clearUnsavedChanges(); }); + it('cannot create options list for scripted field', async () => { + expect(await dashboardControls.optionsListEditorGetCurrentDataView(true)).to.eql( + 'animals-*' + ); + await dashboardControls.openCreateControlFlyout(); + await testSubjects.missingOrFail('field-picker-select-isDog'); + await dashboardControls.controlEditorCancel(true); + }); + after(async () => { await dashboardControls.clearAllControls(); }); From e0c858f48742d4ee1d8f4b53af4778469143d3d7 Mon Sep 17 00:00:00 2001 From: Alison Goryachev Date: Mon, 7 Nov 2022 11:55:48 -0500 Subject: [PATCH 011/192] [Guided onboarding] Update EuiTour button label (#144577) --- .../fleet/public/components/with_guided_onboarding_tour.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/fleet/public/components/with_guided_onboarding_tour.tsx b/x-pack/plugins/fleet/public/components/with_guided_onboarding_tour.tsx index 9065cb723d8b5..ba4afddf9cc99 100644 --- a/x-pack/plugins/fleet/public/components/with_guided_onboarding_tour.tsx +++ b/x-pack/plugins/fleet/public/components/with_guided_onboarding_tour.tsx @@ -97,7 +97,7 @@ export const WithGuidedOnboardingTour: FunctionComponent<{ footerAction={ setIsGuidedOnboardingTourOpen(false)} size="s" color="success"> {i18n.translate('xpack.fleet.guidedOnboardingTour.nextButtonLabel', { - defaultMessage: 'Next', + defaultMessage: 'Got it', })} } From 5e7989844de566316e51fb48e09ca1dfdfa01f4a Mon Sep 17 00:00:00 2001 From: gchaps <33642766+gchaps@users.noreply.github.com> Date: Mon, 7 Nov 2022 09:12:05 -0800 Subject: [PATCH 012/192] [DOCS] Improves examples in KQL doc (#144072) * [DOCS] Improves examples in KQL doc * Update docs/concepts/kuery.asciidoc Co-authored-by: Lukas Olson Co-authored-by: Lukas Olson --- docs/concepts/kuery.asciidoc | 302 ++++++++++------------------------- 1 file changed, 81 insertions(+), 221 deletions(-) diff --git a/docs/concepts/kuery.asciidoc b/docs/concepts/kuery.asciidoc index a7d2e83717411..4e8b6bc4043e0 100644 --- a/docs/concepts/kuery.asciidoc +++ b/docs/concepts/kuery.asciidoc @@ -1,326 +1,186 @@ [[kuery-query]] -=== Kibana Query Language +=== {kib} Query Language -The Kibana Query Language (KQL) is a simple syntax for filtering {es} data using -free text search or field-based search. KQL is only used for filtering data, and has -no role in sorting or aggregating the data. +The {kib} Query Language (KQL) is a simple text-based query language for filtering data. -KQL is able to suggest field names, values, and operators as you type. -The performance of the suggestions is controlled by <>. +* KQL only filters data, and has no role in aggregating, transforming, or sorting data. +* KQL is not to be confused with the <>, which has a different feature set. -KQL has a different set of features than the <>. KQL is able to query -nested fields and <>. KQL does not support regular expressions -or searching with fuzzy terms. +Use KQL to filter documents where a value for a field exists, matches a given value, or is within a given range. [discrete] -=== Terms query +=== Filter for documents where a field exists -A terms query uses *exact search terms*. Spaces separate each search term, and only one term -is required to match the document. Use quotation marks to indicate a *phrase match*. - -To query using *exact search terms*, enter the field name followed by `:` and -then the values separated by spaces: - -[source,yaml] -------------------- -http.response.status_code:400 401 404 -------------------- - -For text fields, this will match any value regardless of order: +To filter documents for which an indexed value exists for a given field, use the `*` operator. +For example, to filter for documents where the `http.request.method` field exists, use the following syntax: [source,yaml] ------------------- -http.response.body.content.text:quick brown fox +http.request.method: * ------------------- -To query for an *exact phrase*, use quotation marks around the values: - -[source,yaml] -------------------- -http.response.body.content.text:"quick brown fox" -------------------- - -Field names are not required by KQL. When a field name is not provided, terms -will be matched by the default fields in your index settings. To search across fields: - -[source,yaml] -------------------- -"quick brown fox" -------------------- +This checks for any indexed value, including an empty string. [discrete] -=== Boolean queries +=== Filter for documents that match a value -KQL supports `or`, `and`, and `not`. By default, `and` has a higher precedence than `or`. -To override the default precedence, group operators in parentheses. These operators can -be upper or lower case. - -To match documents where response is `200`, extension is `php`, or both: - -[source,yaml] -------------------- -response:200 or extension:php -------------------- - -To match documents where response is `200` and extension is `php`: +Use KQL to filter for documents that match a specific number, text, date, or boolean value. +For example, to filter for documents where the `http.request.method` is GET, use the following query: [source,yaml] ------------------- -response:200 and extension:php +http.request.method: GET ------------------- -To match documents where response is `200` or `404`. +The field parameter is optional. If not provided, all fields are searched for the given value. +For example, to search all fields for “Hello”, use the following: [source,yaml] ------------------- -response:(200 or 404) +Hello ------------------- -To match documents where response is `200` and extension is either `php` or `css`: +When querying keyword, numeric, date, or boolean fields, the value must be an exact match, +including punctuation and case. However, when querying text fields, {es} analyzes the +value provided according to the {ref}/analysis.html[field’s mapping settings]. +For example, to search for documents where `http.request.body.content` (a `text` field) +contains the text “null pointer”: [source,yaml] ------------------- -response:200 and (extension:php or extension:css) +http.request.body.content: null pointer ------------------- -To match documents where `response` is 200 and `extension` is -`php` or extension is `css`, and response is anything: +Because this is a `text` field, the order of these search terms does not matter, and +even documents containing “pointer null” are returned. To search `text` fields where the +terms are in the order provided, surround the value in quotation marks, as follows: [source,yaml] ------------------- -response:200 and extension:php or extension:css +http.request.body.content: "null pointer" ------------------- -To match documents where response is not `200`: +Certain characters must be escaped by a backslash (unless surrounded by quotes). +For example, to search for documents where `http.request.referrer` is https://example.com, +use either of the following queries: [source,yaml] ------------------- -not response:200 +http.request.referrer: "https://example.com" +http.request.referrer: https\://example.com ------------------- -To match documents where response is `200` but extension is not `php` or `css`. +You must escape following characters: [source,yaml] ------------------- -response:200 and not (extension:php or extension:css) -------------------- - -To match multi-value fields that contain a list of terms: - -[source,yaml] -------------------- -tags:(success and info and security) +\():<>"* ------------------- [discrete] -=== Range queries +=== Filter for documents within a range -KQL supports `>`, `>=`, `<`, and `<=` on numeric and date types. +To search documents that contain terms within a provided range, use KQL’s range syntax. +For example, to search for all documents for which `http.response.bytes` is less than 10000, +use the following syntax: [source,yaml] ------------------- -account_number >= 100 and items_sold <= 200 +http.response.bytes < 10000 ------------------- -[discrete] -=== Date range queries - -Typically, Kibana's <> is sufficient for setting a time range, -but in some cases you might need to search on dates. Include the date range in quotes. +To search for an inclusive range, combine multiple range queries. +For example, to search for documents where `http.response.bytes` is greater than 10000 +but less than or equal to 20000, use the following syntax: [source,yaml] ------------------- -@timestamp < "2021-01-02T21:55:59" +http.response.bytes > 10000 and http.response.bytes <= 20000 ------------------- -[source,yaml] -------------------- -@timestamp < "2021-01" -------------------- +You can also use range syntax for string values, IP addresses, and timestamps. +For example, to search for documents earlier than two weeks ago, use the following syntax: [source,yaml] ------------------- -@timestamp < "2021" +@timestamp < now-2w ------------------- -KQL supports date math expressions. - -[source,yaml] -------------------- -@timestamp < now-1d -------------------- - -[source,yaml] -------------------- -updated_at > 2022-02-17||+1M/d -------------------- - -Check the -{ref}/common-options.html#date-math[date math documentation] for more examples. +For more examples on acceptable date formats, refer to {ref}/common-options.html#date-math[Date Math]. [discrete] -=== Exist queries +=== Filter for documents using wildcards -An exist query matches documents that contain any value for a field, in this case, -response: +To search for documents matching a pattern, use the wildcard syntax. +For example, to find documents where `http.response.status_code` begins with a 4, use the following syntax: [source,yaml] ------------------- -response:* +http.response.status_code: 4* ------------------- -Existence is defined by {es} and includes all values, including empty text. - -[discrete] -=== Wildcard queries +By default, leading wildcards are not allowed for performance reasons. +You can modify this with the <> advanced setting. -Wildcards queries can be used to *search by a term prefix* or to *search multiple fields*. -The default settings of {kib} *prevent leading wildcards* for performance reasons, -but this can be allowed with an <>. +NOTE: Only `*` is currently supported. This matches zero or more characters. -To match documents where `machine.os` starts with `win`, such -as "windows 7" and "windows 10": - -[source,yaml] -------------------- -machine.os:win* -------------------- +[discrete] +=== Negating a query -To match multiple fields: +To negate or exclude a set of documents, use the `not` keyword (not case-sensitive). +For example, to filter documents where the `http.request.method` is *not* GET, use the following query: [source,yaml] ------------------- -machine.os*:windows 10 +NOT http.request.method: GET ------------------- -This syntax is handy when you have text and keyword -versions of a field. The query checks machine.os and machine.os.keyword -for the term -`windows 10`. - - [discrete] -=== Nested field queries - -A main consideration for querying {ref}/nested.html[nested fields] is how to -match parts of the nested query to the individual nested documents. -You can: - -* *Match parts of the query to a single nested document only.* This is what most users want when querying on a nested field. -* *Match parts of the query to different nested documents.* This is how a regular object field works. - This query is generally less useful than matching to a single document. - -In the following document, `items` is a nested field. Each document in the nested -field contains a name, stock, and category. - -[source,json] ----------------------------------- -{ - "grocery_name": "Elastic Eats", - "items": [ - { - "name": "banana", - "stock": "12", - "category": "fruit" - }, - { - "name": "peach", - "stock": "10", - "category": "fruit" - }, - { - "name": "carrot", - "stock": "9", - "category": "vegetable" - }, - { - "name": "broccoli", - "stock": "5", - "category": "vegetable" - } - ] -} ----------------------------------- +=== Combining multiple queries -[discrete] -==== Match a single document - -To match stores that have more than 10 bananas in stock: +To combine multiple queries, use the `and`/`or` keywords (not case-sensitive). +For example, to find documents where the `http.request.method` is GET *or* the `http.response.status_code` is 400, +use the following query: [source,yaml] ------------------- -items:{ name:banana and stock > 10 } +http.request.method: GET OR http.response.status_code: 400 ------------------- -`items` is the nested path. Everything inside the curly braces (the nested group) -must match a single nested document. - -The following query does not return any matches because no single nested -document has bananas with a stock of 9. +Similarly, to find documents where the `http.request.method` is GET *and* the +`http.response.status_code` is 400, use this query: [source,yaml] ------------------- -items:{ name:banana and stock:9 } +http.request.method: GET AND http.response.status_code: 400 ------------------- -[discrete] -==== Match different documents - -The following subqueries are in separate nested groups -and can match different nested documents: +To specify precedence when combining multiple queries, use parentheses. +For example, to find documents where the `http.request.method` is GET *and* +the `http.response.status_code` is 200, *or* the `http.request.method` is POST *and* +`http.response.status_code` is 400, use the following: [source,yaml] ------------------- -items:{ name:banana } and items:{ stock:9 } +(http.request.method: GET AND http.response.status_code: 200) OR +(http.request.method: POST AND http.response.status_code: 400) ------------------- -`name:banana` matches the first document in the array and `stock:9` -matches the third document in the array. - -[discrete] -==== Match single and different documents - -To find a store with more than 10 -bananas that *also* stocks vegetables: +You can also use parentheses for shorthand syntax when querying multiple values for the same field. +For example, to find documents where the `http.request.method` is GET, POST, *or* DELETE, use the following: [source,yaml] ------------------- -items:{ name:banana and stock > 10 } and items:{ category:vegetable } +http.request.method: (GET OR POST OR DELETE) ------------------- -The first nested group (`name:banana and stock > 10`) must match a single document, but the `category:vegetables` -subquery can match a different nested document because it is in a separate group. - [discrete] -==== Nested fields inside other nested fields - -KQL supports nested fields inside other nested fields—you have to -specify the full path. In this document, -`level1` and `level2` are nested fields: - -[source,json] ----------------------------------- -{ - "level1": [ - { - "level2": [ - { - "prop1": "foo", - "prop2": "bar" - }, - { - "prop1": "baz", - "prop2": "qux" - } - ] - } - ] -} ----------------------------------- - -To match on a single nested document: +=== Matching multiple fields + +Wildcards can also be used to query multiple fields. For example, to search for +documents where any sub-field of `http.response` contains “error”, use the following: [source,yaml] ------------------- -level1.level2:{ prop1:foo and prop2:bar } +http.response.*: error ------------------- From c5bcfd6762d0971a6d77e97d636076251b6dd4ac Mon Sep 17 00:00:00 2001 From: Ersin Erdal <92688503+ersin-erdal@users.noreply.github.com> Date: Mon, 7 Nov 2022 18:15:30 +0100 Subject: [PATCH 013/192] Add flapping state object and interface in AAD index and Event Log (#143920) * move flapping to kibana.alerting in event log * move flapping back to under kibana.alert. Add integration tests * add default flapping state to alert logs --- .../src/technical_field_names.ts | 3 + .../plugins/alerting/common/alert_summary.ts | 1 + .../lib/alert_summary_from_event_log.test.ts | 58 ++++++++++++++++++- .../lib/alert_summary_from_event_log.ts | 6 ++ .../alerting_event_logger.test.ts | 1 + .../alerting_event_logger.ts | 2 + .../create_alert_event_log_record_object.ts | 3 + .../tests/get_alert_summary.test.ts | 5 +- .../alerting/server/task_runner/fixtures.ts | 1 + .../server/task_runner/log_alerts.test.ts | 8 +++ .../alerting/server/task_runner/log_alerts.ts | 3 + .../plugins/event_log/generated/mappings.json | 3 + x-pack/plugins/event_log/generated/schemas.ts | 5 ++ .../event_log/scripts/create_schemas.js | 9 +++ x-pack/plugins/event_log/scripts/mappings.js | 3 + .../technical_rule_field_map.test.ts | 3 + .../field_maps/technical_rule_field_map.ts | 1 + .../server/utils/create_lifecycle_executor.ts | 1 + .../lib/rule_api/rule_summary.test.ts | 16 ++++- .../rule_details/components/rule.test.tsx | 18 +++++- .../components/rule_route.test.tsx | 1 + .../rule_details/components/test_helpers.ts | 1 + .../spaces_only/tests/alerting/event_log.ts | 57 ++++++++++-------- .../tests/alerting/event_log_alerts.ts | 4 ++ .../tests/alerting/get_alert_summary.ts | 9 +++ 25 files changed, 192 insertions(+), 30 deletions(-) diff --git a/packages/kbn-rule-data-utils/src/technical_field_names.ts b/packages/kbn-rule-data-utils/src/technical_field_names.ts index 0e61cba7511ac..ae37273c8aefb 100644 --- a/packages/kbn-rule-data-utils/src/technical_field_names.ts +++ b/packages/kbn-rule-data-utils/src/technical_field_names.ts @@ -30,6 +30,7 @@ const ALERT_DURATION = `${ALERT_NAMESPACE}.duration.us` as const; const ALERT_END = `${ALERT_NAMESPACE}.end` as const; const ALERT_EVALUATION_THRESHOLD = `${ALERT_NAMESPACE}.evaluation.threshold` as const; const ALERT_EVALUATION_VALUE = `${ALERT_NAMESPACE}.evaluation.value` as const; +const ALERT_FLAPPING = `${ALERT_NAMESPACE}.flapping` as const; const ALERT_INSTANCE_ID = `${ALERT_NAMESPACE}.instance.id` as const; const ALERT_REASON = `${ALERT_NAMESPACE}.reason` as const; const ALERT_RISK_SCORE = `${ALERT_NAMESPACE}.risk_score` as const; @@ -115,6 +116,7 @@ const fields = { ALERT_END, ALERT_EVALUATION_THRESHOLD, ALERT_EVALUATION_VALUE, + ALERT_FLAPPING, ALERT_INSTANCE_ID, ALERT_RULE_CONSUMER, ALERT_RULE_PRODUCER, @@ -176,6 +178,7 @@ export { ALERT_END, ALERT_EVALUATION_THRESHOLD, ALERT_EVALUATION_VALUE, + ALERT_FLAPPING, ALERT_INSTANCE_ID, ALERT_NAMESPACE, ALERT_RULE_NAMESPACE, diff --git a/x-pack/plugins/alerting/common/alert_summary.ts b/x-pack/plugins/alerting/common/alert_summary.ts index fc35e3403fe92..f9675e64a7f95 100644 --- a/x-pack/plugins/alerting/common/alert_summary.ts +++ b/x-pack/plugins/alerting/common/alert_summary.ts @@ -36,4 +36,5 @@ export interface AlertStatus { muted: boolean; actionGroupId?: string; activeStartDate?: string; + flapping: boolean; } diff --git a/x-pack/plugins/alerting/server/lib/alert_summary_from_event_log.test.ts b/x-pack/plugins/alerting/server/lib/alert_summary_from_event_log.test.ts index 56a862f2ad6ca..3bf01caaead1a 100644 --- a/x-pack/plugins/alerting/server/lib/alert_summary_from_event_log.test.ts +++ b/x-pack/plugins/alerting/server/lib/alert_summary_from_event_log.test.ts @@ -122,12 +122,14 @@ describe('alertSummaryFromEventLog', () => { "alert-1": Object { "actionGroupId": undefined, "activeStartDate": undefined, + "flapping": false, "muted": true, "status": "OK", }, "alert-2": Object { "actionGroupId": undefined, "activeStartDate": undefined, + "flapping": false, "muted": true, "status": "OK", }, @@ -232,6 +234,7 @@ describe('alertSummaryFromEventLog', () => { "alert-1": Object { "actionGroupId": undefined, "activeStartDate": undefined, + "flapping": false, "muted": false, "status": "OK", }, @@ -272,6 +275,7 @@ describe('alertSummaryFromEventLog', () => { "alert-1": Object { "actionGroupId": undefined, "activeStartDate": undefined, + "flapping": false, "muted": false, "status": "OK", }, @@ -311,6 +315,7 @@ describe('alertSummaryFromEventLog', () => { "alert-1": Object { "actionGroupId": undefined, "activeStartDate": undefined, + "flapping": false, "muted": false, "status": "OK", }, @@ -351,6 +356,7 @@ describe('alertSummaryFromEventLog', () => { "alert-1": Object { "actionGroupId": "action group A", "activeStartDate": "2020-06-18T00:00:00.000Z", + "flapping": false, "muted": false, "status": "Active", }, @@ -391,6 +397,7 @@ describe('alertSummaryFromEventLog', () => { "alert-1": Object { "actionGroupId": undefined, "activeStartDate": "2020-06-18T00:00:00.000Z", + "flapping": false, "muted": false, "status": "Active", }, @@ -431,6 +438,7 @@ describe('alertSummaryFromEventLog', () => { "alert-1": Object { "actionGroupId": "action group B", "activeStartDate": "2020-06-18T00:00:00.000Z", + "flapping": false, "muted": false, "status": "Active", }, @@ -469,6 +477,7 @@ describe('alertSummaryFromEventLog', () => { "alert-1": Object { "actionGroupId": "action group A", "activeStartDate": undefined, + "flapping": false, "muted": false, "status": "Active", }, @@ -511,12 +520,14 @@ describe('alertSummaryFromEventLog', () => { "alert-1": Object { "actionGroupId": "action group A", "activeStartDate": "2020-06-18T00:00:00.000Z", + "flapping": false, "muted": true, "status": "Active", }, "alert-2": Object { "actionGroupId": undefined, "activeStartDate": undefined, + "flapping": false, "muted": true, "status": "OK", }, @@ -566,12 +577,14 @@ describe('alertSummaryFromEventLog', () => { "alert-1": Object { "actionGroupId": "action group B", "activeStartDate": "2020-06-18T00:00:00.000Z", + "flapping": false, "muted": false, "status": "Active", }, "alert-2": Object { "actionGroupId": undefined, "activeStartDate": undefined, + "flapping": false, "muted": false, "status": "OK", }, @@ -584,6 +597,43 @@ describe('alertSummaryFromEventLog', () => { testExecutionDurations(eventsFactory.getExecutionDurations(), executionDuration); }); + test('rule with currently active alert, flapping', async () => { + const rule = createRule({}); + const eventsFactory = new EventsFactory(); + const events = eventsFactory + .addExecute() + .addActiveAlert('alert-1', 'action group A', true) + .getEvents(); + + const executionEvents = eventsFactory.getEvents(); + + const summary: AlertSummary = alertSummaryFromEventLog({ + rule, + events, + executionEvents, + dateStart, + dateEnd, + }); + const { lastRun, status, alerts, executionDuration } = summary; + expect({ lastRun, status, alerts }).toMatchInlineSnapshot(` + Object { + "alerts": Object { + "alert-1": Object { + "actionGroupId": "action group A", + "activeStartDate": undefined, + "flapping": true, + "muted": false, + "status": "Active", + }, + }, + "lastRun": "2020-06-18T00:00:00.000Z", + "status": "Active", + } + `); + + testExecutionDurations(eventsFactory.getExecutionDurations(), executionDuration); + }); + const testExecutionDurations = ( actualDurations: Record, executionDuration?: { @@ -642,7 +692,11 @@ export class EventsFactory { return this; } - addActiveAlert(alertId: string, actionGroupId: string | undefined): EventsFactory { + addActiveAlert( + alertId: string, + actionGroupId: string | undefined, + flapping = false + ): EventsFactory { const kibanaAlerting = actionGroupId ? { instance_id: alertId, action_group_id: actionGroupId } : { instance_id: alertId }; @@ -652,7 +706,7 @@ export class EventsFactory { provider: EVENT_LOG_PROVIDER, action: EVENT_LOG_ACTIONS.activeInstance, }, - kibana: { alerting: kibanaAlerting }, + kibana: { alerting: kibanaAlerting, alert: { flapping } }, }); return this; } diff --git a/x-pack/plugins/alerting/server/lib/alert_summary_from_event_log.ts b/x-pack/plugins/alerting/server/lib/alert_summary_from_event_log.ts index d8e5f4dea9b41..f1aedf078800f 100644 --- a/x-pack/plugins/alerting/server/lib/alert_summary_from_event_log.ts +++ b/x-pack/plugins/alerting/server/lib/alert_summary_from_event_log.ts @@ -80,6 +80,11 @@ export function alertSummaryFromEventLog(params: AlertSummaryFromEventLogParams) if (alertId === undefined) continue; const status = getAlertStatus(alerts, alertId); + + if (event?.kibana?.alert?.flapping) { + status.flapping = true; + } + switch (action) { case EVENT_LOG_ACTIONS.newInstance: status.activeStartDate = timeStamp; @@ -152,6 +157,7 @@ function getAlertStatus(alerts: Map, alertId: string): Aler muted: false, actionGroupId: undefined, activeStartDate: undefined, + flapping: false, }; alerts.set(alertId, status); return status; diff --git a/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts index 3dea32f4f45a4..7af3c963814f6 100644 --- a/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts +++ b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.test.ts @@ -66,6 +66,7 @@ const alert = { end: '2020-01-01T03:00:00.000Z', duration: '2343252346', }, + flapping: false, }; const action = { diff --git a/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts index 4fd3afafba5c2..3422fb21bb1f9 100644 --- a/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts +++ b/x-pack/plugins/alerting/server/lib/alerting_event_logger/alerting_event_logger.ts @@ -48,6 +48,7 @@ interface AlertOpts { message: string; group?: string; state?: AlertInstanceState; + flapping: boolean; } interface ActionOpts { @@ -247,6 +248,7 @@ export function createAlertRecord(context: RuleContextOpts, alert: AlertOpts) { }, ], ruleName: context.ruleName, + flapping: alert.flapping, }); } diff --git a/x-pack/plugins/alerting/server/lib/create_alert_event_log_record_object.ts b/x-pack/plugins/alerting/server/lib/create_alert_event_log_record_object.ts index a0f229c0b46d9..ea74cab5d11cf 100644 --- a/x-pack/plugins/alerting/server/lib/create_alert_event_log_record_object.ts +++ b/x-pack/plugins/alerting/server/lib/create_alert_event_log_record_object.ts @@ -35,6 +35,7 @@ interface CreateAlertEventLogRecordParams { typeId: string; relation?: string; }>; + flapping?: boolean; } export function createAlertEventLogRecordObject(params: CreateAlertEventLogRecordParams): Event { @@ -50,6 +51,7 @@ export function createAlertEventLogRecordObject(params: CreateAlertEventLogRecor namespace, consumer, spaceId, + flapping, } = params; const alerting = params.instanceId || group @@ -72,6 +74,7 @@ export function createAlertEventLogRecordObject(params: CreateAlertEventLogRecor }, kibana: { alert: { + ...(flapping !== undefined ? { flapping } : {}), rule: { rule_type_id: ruleType.id, ...(consumer ? { consumer } : {}), diff --git a/x-pack/plugins/alerting/server/rules_client/tests/get_alert_summary.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/get_alert_summary.test.ts index 4aa7ae40f8782..f0f634538d6f7 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/get_alert_summary.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/get_alert_summary.test.ts @@ -128,7 +128,7 @@ describe('getAlertSummary()', () => { .advanceTime(10000) .addExecute() .addRecoveredAlert('alert-previously-active') - .addActiveAlert('alert-currently-active', 'action group A') + .addActiveAlert('alert-currently-active', 'action group A', true) .getEvents(); const eventsResult = { ...AlertSummaryFindEventsResult, @@ -157,18 +157,21 @@ describe('getAlertSummary()', () => { "alert-currently-active": Object { "actionGroupId": "action group A", "activeStartDate": "2019-02-12T21:01:22.479Z", + "flapping": true, "muted": false, "status": "Active", }, "alert-muted-no-activity": Object { "actionGroupId": undefined, "activeStartDate": undefined, + "flapping": false, "muted": true, "status": "OK", }, "alert-previously-active": Object { "actionGroupId": undefined, "activeStartDate": undefined, + "flapping": false, "muted": false, "status": "OK", }, diff --git a/x-pack/plugins/alerting/server/task_runner/fixtures.ts b/x-pack/plugins/alerting/server/task_runner/fixtures.ts index 0a90456dbdad6..ca9ddd0c48db6 100644 --- a/x-pack/plugins/alerting/server/task_runner/fixtures.ts +++ b/x-pack/plugins/alerting/server/task_runner/fixtures.ts @@ -198,6 +198,7 @@ export const generateAlertOpts = ({ action, group, state, id }: GeneratorParams message, state, ...(group ? { group } : {}), + flapping: false, }; }; diff --git a/x-pack/plugins/alerting/server/task_runner/log_alerts.test.ts b/x-pack/plugins/alerting/server/task_runner/log_alerts.test.ts index d6dfd42cb0dcf..43f191fc0a3aa 100644 --- a/x-pack/plugins/alerting/server/task_runner/log_alerts.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/log_alerts.test.ts @@ -158,48 +158,56 @@ describe('logAlerts', () => { id: '7', message: "test-rule-type-id:123: 'test rule' alert '7' has recovered", state: {}, + flapping: false, }); expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith(2, { action: 'recovered-instance', id: '8', message: "test-rule-type-id:123: 'test rule' alert '8' has recovered", state: {}, + flapping: false, }); expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith(3, { action: 'recovered-instance', id: '9', message: "test-rule-type-id:123: 'test rule' alert '9' has recovered", state: {}, + flapping: false, }); expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith(4, { action: 'recovered-instance', id: '10', message: "test-rule-type-id:123: 'test rule' alert '10' has recovered", state: {}, + flapping: false, }); expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith(5, { action: 'new-instance', id: '4', message: "test-rule-type-id:123: 'test rule' created new alert: '4'", state: {}, + flapping: false, }); expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith(6, { action: 'active-instance', id: '1', message: "test-rule-type-id:123: 'test rule' active alert: '1' in actionGroup: 'undefined'", state: {}, + flapping: false, }); expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith(7, { action: 'active-instance', id: '2', message: "test-rule-type-id:123: 'test rule' active alert: '2' in actionGroup: 'undefined'", state: {}, + flapping: false, }); expect(alertingEventLogger.logAlert).toHaveBeenNthCalledWith(8, { action: 'active-instance', id: '4', message: "test-rule-type-id:123: 'test rule' active alert: '4' in actionGroup: 'undefined'", state: {}, + flapping: false, }); }); diff --git a/x-pack/plugins/alerting/server/task_runner/log_alerts.ts b/x-pack/plugins/alerting/server/task_runner/log_alerts.ts index 2abe72ed06cb5..b7abaf4c236be 100644 --- a/x-pack/plugins/alerting/server/task_runner/log_alerts.ts +++ b/x-pack/plugins/alerting/server/task_runner/log_alerts.ts @@ -102,6 +102,7 @@ export function logAlerts< group: actionGroup, message, state, + flapping: false, }); } @@ -115,6 +116,7 @@ export function logAlerts< group: actionGroup, message, state, + flapping: false, }); } @@ -128,6 +130,7 @@ export function logAlerts< group: actionGroup, message, state, + flapping: false, }); } } diff --git a/x-pack/plugins/event_log/generated/mappings.json b/x-pack/plugins/event_log/generated/mappings.json index 1db28528efd57..67b60230c33aa 100644 --- a/x-pack/plugins/event_log/generated/mappings.json +++ b/x-pack/plugins/event_log/generated/mappings.json @@ -276,6 +276,9 @@ }, "alert": { "properties": { + "flapping": { + "type": "boolean" + }, "rule": { "properties": { "consumer": { diff --git a/x-pack/plugins/event_log/generated/schemas.ts b/x-pack/plugins/event_log/generated/schemas.ts index e40be778755af..acad4d86a6aa5 100644 --- a/x-pack/plugins/event_log/generated/schemas.ts +++ b/x-pack/plugins/event_log/generated/schemas.ts @@ -120,6 +120,7 @@ export const EventSchema = schema.maybe( ), alert: schema.maybe( schema.object({ + flapping: ecsBoolean(), rule: schema.maybe( schema.object({ consumer: ecsString(), @@ -199,6 +200,10 @@ function ecsDate() { return schema.maybe(schema.string({ validate: validateDate })); } +function ecsBoolean() { + return schema.maybe(schema.boolean()); +} + const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; function validateDate(isoDate: string) { diff --git a/x-pack/plugins/event_log/scripts/create_schemas.js b/x-pack/plugins/event_log/scripts/create_schemas.js index dc4eeb01c8957..6ba8df5e0d46d 100755 --- a/x-pack/plugins/event_log/scripts/create_schemas.js +++ b/x-pack/plugins/event_log/scripts/create_schemas.js @@ -161,6 +161,11 @@ function generateSchemaLines(lineWriter, prop, mappings) { return; } + if (mappings.type === 'boolean') { + lineWriter.addLine(`${propKey}: ecsBoolean(),`); + return; + } + // only handling objects for the rest of this function if (mappings.properties == null) { logError(`unknown properties to map: ${prop}: ${JSON.stringify(mappings)}`); @@ -324,6 +329,10 @@ function ecsDate() { return schema.maybe(schema.string({ validate: validateDate })); } +function ecsBoolean() { + return schema.maybe(schema.boolean()); +} + const ISO_DATE_PATTERN = /^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$/; function validateDate(isoDate: string) { diff --git a/x-pack/plugins/event_log/scripts/mappings.js b/x-pack/plugins/event_log/scripts/mappings.js index d47ef4be6cac2..ff69ef9160352 100644 --- a/x-pack/plugins/event_log/scripts/mappings.js +++ b/x-pack/plugins/event_log/scripts/mappings.js @@ -58,6 +58,9 @@ exports.EcsCustomPropertyMappings = { }, alert: { properties: { + flapping: { + type: 'boolean', + }, rule: { properties: { consumer: { diff --git a/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.test.ts b/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.test.ts index 32406f7a87fca..e01ab0105a5d5 100644 --- a/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.test.ts +++ b/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.test.ts @@ -43,6 +43,9 @@ it('matches snapshot', () => { "kibana.alert.end": Object { "type": "date", }, + "kibana.alert.flapping": Object { + "type": "boolean", + }, "kibana.alert.instance.id": Object { "required": true, "type": "keyword", diff --git a/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.ts b/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.ts index 2233f2d977010..82994950dfd04 100644 --- a/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.ts +++ b/x-pack/plugins/rule_registry/common/assets/field_maps/technical_rule_field_map.ts @@ -33,6 +33,7 @@ export const technicalRuleFieldMap = { [Fields.ALERT_DURATION]: { type: 'long' }, [Fields.ALERT_SEVERITY]: { type: 'keyword' }, [Fields.ALERT_STATUS]: { type: 'keyword', required: true }, + [Fields.ALERT_FLAPPING]: { type: 'boolean' }, [Fields.VERSION]: { type: 'version', array: false, diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.ts index abf5ec53b537c..6f7dafd3e495f 100644 --- a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.ts +++ b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_executor.ts @@ -37,6 +37,7 @@ import { TAGS, TIMESTAMP, VERSION, + // ALERT_FLAPPING, } from '../../common/technical_rule_data_field_names'; import { CommonAlertFieldNameLatest, CommonAlertIdFieldNameLatest } from '../../common/schemas'; import { IRuleDataClient } from '../rule_data_client'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rule_summary.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rule_summary.test.ts index e75e3d132671c..43beb66b40f9e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rule_summary.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/rule_summary.test.ts @@ -14,7 +14,13 @@ const http = httpServiceMock.createStartContract(); describe('loadRuleSummary', () => { test('should call rule summary API', async () => { const resolvedValue: RuleSummary = { - alerts: {}, + alerts: { + '1': { + flapping: true, + status: 'OK', + muted: false, + }, + }, consumer: 'alerts', enabled: true, errorMessages: [], @@ -35,7 +41,13 @@ describe('loadRuleSummary', () => { }; http.get.mockResolvedValueOnce({ - alerts: {}, + alerts: { + '1': { + flapping: true, + status: 'OK', + muted: false, + }, + }, consumer: 'alerts', enabled: true, error_messages: [], diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule.test.tsx index 5eac73c4e87ac..df698c8597a09 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule.test.tsx @@ -72,11 +72,13 @@ describe('rules', () => { status: 'OK', muted: false, actionGroupId: 'default', + flapping: false, }, second_rule: { status: 'Active', muted: false, actionGroupId: 'action group id unknown', + flapping: false, }, }, }); @@ -134,10 +136,12 @@ describe('rules', () => { ['us-central']: { status: 'OK', muted: false, + flapping: false, }, ['us-east']: { status: 'OK', muted: false, + flapping: false, }, }; @@ -169,8 +173,8 @@ describe('rules', () => { mutedInstanceIds: ['us-west', 'us-east'], }); const ruleType = mockRuleType(); - const ruleUsWest: AlertStatus = { status: 'OK', muted: false }; - const ruleUsEast: AlertStatus = { status: 'OK', muted: false }; + const ruleUsWest: AlertStatus = { status: 'OK', muted: false, flapping: false }; + const ruleUsEast: AlertStatus = { status: 'OK', muted: false, flapping: false }; const wrapper = mountWithIntl( { 'us-west': { status: 'OK', muted: false, + flapping: false, }, 'us-east': { status: 'OK', muted: false, + flapping: false, }, }, })} @@ -219,6 +225,7 @@ describe('alertToListItem', () => { muted: false, activeStartDate: fake2MinutesAgo.toISOString(), actionGroupId: 'testing', + flapping: false, }; expect(alertToListItem(fakeNow.getTime(), ruleType, 'id', alert)).toEqual({ @@ -238,6 +245,7 @@ describe('alertToListItem', () => { status: 'Active', muted: false, activeStartDate: fake2MinutesAgo.toISOString(), + flapping: false, }; expect(alertToListItem(fakeNow.getTime(), ruleType, 'id', alert)).toEqual({ @@ -258,6 +266,7 @@ describe('alertToListItem', () => { muted: true, activeStartDate: fake2MinutesAgo.toISOString(), actionGroupId: 'default', + flapping: false, }; expect(alertToListItem(fakeNow.getTime(), ruleType, 'id', alert)).toEqual({ @@ -276,6 +285,7 @@ describe('alertToListItem', () => { status: 'Active', muted: false, actionGroupId: 'default', + flapping: false, }; expect(alertToListItem(fakeNow.getTime(), ruleType, 'id', alert)).toEqual({ @@ -294,6 +304,7 @@ describe('alertToListItem', () => { status: 'OK', muted: true, actionGroupId: 'default', + flapping: false, }; expect(alertToListItem(fakeNow.getTime(), ruleType, 'id', alert)).toEqual({ alert: 'id', @@ -389,11 +400,13 @@ describe('tabbed content', () => { status: 'OK', muted: false, actionGroupId: 'default', + flapping: false, }, second_rule: { status: 'Active', muted: false, actionGroupId: 'action group id unknown', + flapping: false, }, }, }); @@ -473,6 +486,7 @@ function mockRuleSummary(overloads: Partial = {}): RuleSummary { status: 'OK', muted: false, actionGroupId: 'testActionGroup', + flapping: false, }, }, executionDuration: { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_route.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_route.test.tsx index 34af31146eb0d..a29fc2a2ee9a3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_route.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_route.test.tsx @@ -167,6 +167,7 @@ function mockRuleSummary(overloads: Partial = {}): any { foo: { status: 'OK', muted: false, + flapping: false, }, }, executionDuration: { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/test_helpers.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/test_helpers.ts index 9e96487b167a4..b7a6876535a64 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/test_helpers.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/test_helpers.ts @@ -101,6 +101,7 @@ export function mockRuleSummary(overloads: Partial = {}): RuleSumma status: 'OK', muted: false, actionGroupId: 'testActionGroup', + flapping: false, }, }, executionDuration: { diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts index e2a3736e57764..b5509096727fb 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts @@ -193,6 +193,7 @@ export default function eventLogTests({ getService }: FtrProviderContext) { event, `created new alert: 'instance'`, false, + false, currentExecutionId ); break; @@ -202,6 +203,7 @@ export default function eventLogTests({ getService }: FtrProviderContext) { event, `alert 'instance' has recovered`, true, + false, currentExecutionId ); break; @@ -211,6 +213,7 @@ export default function eventLogTests({ getService }: FtrProviderContext) { event, `active alert: 'instance' in actionGroup: 'default'`, false, + false, currentExecutionId ); break; @@ -259,33 +262,11 @@ export default function eventLogTests({ getService }: FtrProviderContext) { }); }); - for (const event of actionEvents) { - switch (event?.event?.action) { - case 'execute': - expect(event?.kibana?.alert?.rule?.execution?.uuid).not.to.be(undefined); - expect( - executionIds.indexOf(event?.kibana?.alert?.rule?.execution?.uuid) - ).to.be.greaterThan(-1); - validateEvent(event, { - spaceId: space.id, - savedObjects: [ - { type: 'action', id: createdAction.id, rel: 'primary', type_id: 'test.noop' }, - ], - message: `action executed: test.noop:${createdAction.id}: MY action`, - outcome: 'success', - shouldHaveTask: true, - ruleTypeId: response.body.rule_type_id, - rule: undefined, - consumer: 'alertsFixture', - }); - break; - } - } - function validateInstanceEvent( event: IValidatedEvent, subMessage: string, shouldHaveEventEnd: boolean, + flapping: boolean, executionId?: string ) { validateEvent(event, { @@ -307,8 +288,32 @@ export default function eventLogTests({ getService }: FtrProviderContext) { name: response.body.name, }, consumer: 'alertsFixture', + flapping, }); } + + for (const event of actionEvents) { + switch (event?.event?.action) { + case 'execute': + expect(event?.kibana?.alert?.rule?.execution?.uuid).not.to.be(undefined); + expect( + executionIds.indexOf(event?.kibana?.alert?.rule?.execution?.uuid) + ).to.be.greaterThan(-1); + validateEvent(event, { + spaceId: space.id, + savedObjects: [ + { type: 'action', id: createdAction.id, rel: 'primary', type_id: 'test.noop' }, + ], + message: `action executed: test.noop:${createdAction.id}: MY action`, + outcome: 'success', + shouldHaveTask: true, + ruleTypeId: response.body.rule_type_id, + rule: undefined, + consumer: 'alertsFixture', + }); + break; + } + } }); it('should generate expected events for rules with multiple searches', async () => { @@ -567,6 +572,7 @@ interface ValidateEventLogParams { ruleset?: string; namespace?: string; }; + flapping?: boolean; } export function validateEvent(event: IValidatedEvent, params: ValidateEventLogParams): void { @@ -585,6 +591,7 @@ export function validateEvent(event: IValidatedEvent, params: ValidateEventLogPa numRecoveredAlerts, consumer, ruleTypeId, + flapping, } = params; const { status, actionGroupId, instanceId, reason, shouldHaveEventEnd } = params; @@ -634,6 +641,10 @@ export function validateEvent(event: IValidatedEvent, params: ValidateEventLogPa expect(event?.kibana?.alert?.rule?.execution?.metrics?.alert_counts?.new).to.be(numNewAlerts); } + if (flapping !== undefined) { + expect(event?.kibana?.alert?.flapping).to.be(flapping); + } + expect(event?.kibana?.alert?.rule?.rule_type_id).to.be(ruleTypeId); expect(event?.kibana?.space_ids?.[0]).to.equal(spaceId); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log_alerts.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log_alerts.ts index 46da0e597e66a..29046ae028f6a 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log_alerts.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log_alerts.ts @@ -93,10 +93,12 @@ export default function eventLogAlertTests({ getService }: FtrProviderContext) { start?: string; durationToDate?: string; } = {}; + for (let i = 0; i < instanceEvents.length; ++i) { switch (instanceEvents[i]?.event?.action) { case 'new-instance': expect(instanceEvents[i]?.kibana?.alerting?.instance_id).to.equal('instance'); + expect(instanceEvents[i]?.kibana?.alert?.flapping).to.equal(false); // a new alert should generate a unique UUID for the duration of its activeness expect(instanceEvents[i]?.event?.end).to.be(undefined); @@ -107,6 +109,7 @@ export default function eventLogAlertTests({ getService }: FtrProviderContext) { case 'active-instance': expect(instanceEvents[i]?.kibana?.alerting?.instance_id).to.equal('instance'); + expect(instanceEvents[i]?.kibana?.alert?.flapping).to.equal(false); expect(instanceEvents[i]?.event?.start).to.equal(currentAlertSpan.start); expect(instanceEvents[i]?.event?.end).to.be(undefined); @@ -121,6 +124,7 @@ export default function eventLogAlertTests({ getService }: FtrProviderContext) { case 'recovered-instance': expect(instanceEvents[i]?.kibana?.alerting?.instance_id).to.equal('instance'); + expect(instanceEvents[i]?.kibana?.alert?.flapping).to.equal(false); expect(instanceEvents[i]?.event?.start).to.equal(currentAlertSpan.start); expect(instanceEvents[i]?.event?.end).not.to.be(undefined); expect( diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_alert_summary.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_alert_summary.ts index d13da4694bbe2..1ec570f6c4f71 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_alert_summary.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_alert_summary.ts @@ -180,6 +180,7 @@ export default function createGetAlertSummaryTests({ getService }: FtrProviderCo '1': { status: 'OK', muted: true, + flapping: false, }, }); }); @@ -239,20 +240,24 @@ export default function createGetAlertSummaryTests({ getService }: FtrProviderCo muted: false, actionGroupId: 'default', activeStartDate: actualAlerts.alertA.activeStartDate, + flapping: false, }, alertB: { status: 'OK', muted: false, + flapping: false, }, alertC: { status: 'Active', muted: true, actionGroupId: 'default', activeStartDate: actualAlerts.alertC.activeStartDate, + flapping: false, }, alertD: { status: 'OK', muted: true, + flapping: false, }, }; expect(actualAlerts).to.eql(expectedAlerts); @@ -294,20 +299,24 @@ export default function createGetAlertSummaryTests({ getService }: FtrProviderCo muted: false, actionGroupId: 'default', activeStartDate: actualAlerts.alertA.activeStartDate, + flapping: false, }, alertB: { status: 'OK', muted: false, + flapping: false, }, alertC: { status: 'Active', muted: true, actionGroupId: 'default', activeStartDate: actualAlerts.alertC.activeStartDate, + flapping: false, }, alertD: { status: 'OK', muted: true, + flapping: false, }, }; expect(actualAlerts).to.eql(expectedAlerts); From 8d5ba8706fa8219f77c10dc158ddbf60066bcbd0 Mon Sep 17 00:00:00 2001 From: Coen Warmer Date: Mon, 7 Nov 2022 18:25:25 +0100 Subject: [PATCH 014/192] [Actionable Observability] Add context.alertDetailsUrl to connector template for Logs threshold rule (#144623) --- .../log_threshold/log_threshold_executor.ts | 82 +++++++++++++------ .../register_log_threshold_rule_type.ts | 7 ++ 2 files changed, 62 insertions(+), 27 deletions(-) diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts index a475e6beea011..e8c884e2cd21d 100644 --- a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts @@ -22,6 +22,7 @@ import { RuleExecutorServices, RuleTypeState, } from '@kbn/alerting-plugin/server'; +import { addSpaceIdToPath } from '@kbn/spaces-plugin/common'; import { RuleParams, @@ -47,7 +48,7 @@ import { decodeOrThrow } from '../../../../common/runtime_types'; import { getLogsAppAlertUrl } from '../../../../common/formatters/alert_link'; import { getIntervalInSeconds } from '../../../../common/utils/get_interval_in_seconds'; import { InfraBackendLibs } from '../../infra_types'; -import { UNGROUPED_FACTORY_KEY } from '../common/utils'; +import { getAlertDetailsUrl, UNGROUPED_FACTORY_KEY } from '../common/utils'; import { getReasonMessageForGroupedCountAlert, getReasonMessageForGroupedRatioAlert, @@ -101,13 +102,14 @@ export const createLogThresholdExecutor = (libs: InfraBackendLibs) => LogThresholdAlertState, LogThresholdAlertContext, LogThresholdActionGroups - >(async ({ services, params, startedAt }) => { + >(async ({ services, params, spaceId, startedAt }) => { const { alertFactory: { alertLimit }, alertWithLifecycle, savedObjectsClient, scopedClusterClient, getAlertStartedDate, + getAlertUuid, } = services; const { basePath } = libs; @@ -124,17 +126,30 @@ export const createLogThresholdExecutor = (libs: InfraBackendLibs) => if (actions && actions.length > 0) { const indexedStartedAt = getAlertStartedDate(id) ?? startedAt.toISOString(); const relativeViewInAppUrl = getLogsAppAlertUrl(new Date(indexedStartedAt).getTime()); - const viewInAppUrl = basePath.publicBaseUrl - ? new URL(basePath.prepend(relativeViewInAppUrl), basePath.publicBaseUrl).toString() - : relativeViewInAppUrl; + + const viewInAppUrl = addSpaceIdToPath( + basePath.publicBaseUrl, + spaceId, + relativeViewInAppUrl + ); const sharedContext = { timestamp: startedAt.toISOString(), viewInAppUrl, }; + actions.forEach((actionSet) => { const { actionGroup, context } = actionSet; - alert.scheduleActions(actionGroup, { ...sharedContext, ...context }); + + const alertInstanceId = (context.group || id) as string; + + const alertUuid = getAlertUuid(alertInstanceId); + + alert.scheduleActions(actionGroup, { + ...sharedContext, + ...context, + alertDetailsUrl: getAlertDetailsUrl(libs.basePath, spaceId, alertUuid), + }); }); } @@ -179,13 +194,15 @@ export const createLogThresholdExecutor = (libs: InfraBackendLibs) => const { getRecoveredAlerts } = services.alertFactory.done(); const recoveredAlerts = getRecoveredAlerts(); - processRecoveredAlerts( + processRecoveredAlerts({ + basePath, + getAlertStartedDate, + getAlertUuid, recoveredAlerts, + spaceId, startedAt, - getAlertStartedDate, - basePath, - validatedParams - ); + validatedParams, + }); } catch (e) { throw new Error(e); } @@ -876,22 +893,33 @@ type LogThresholdRecoveredAlert = { getId: () => string; } & LogThresholdAlert; -const processRecoveredAlerts = ( - recoveredAlerts: LogThresholdRecoveredAlert[], - startedAt: Date, - getAlertStartedDate: (alertId: string) => string | null, - basePath: IBasePath, - validatedParams: RuleParams -) => { +const processRecoveredAlerts = ({ + basePath, + getAlertStartedDate, + getAlertUuid, + recoveredAlerts, + spaceId, + startedAt, + validatedParams, +}: { + basePath: IBasePath; + getAlertStartedDate: (alertId: string) => string | null; + getAlertUuid: (alertId: string) => string | null; + recoveredAlerts: LogThresholdRecoveredAlert[]; + spaceId: string; + startedAt: Date; + validatedParams: RuleParams; +}) => { for (const alert of recoveredAlerts) { const recoveredAlertId = alert.getId(); const indexedStartedAt = getAlertStartedDate(recoveredAlertId) ?? startedAt.toISOString(); const relativeViewInAppUrl = getLogsAppAlertUrl(new Date(indexedStartedAt).getTime()); - const viewInAppUrl = basePath.publicBaseUrl - ? new URL(basePath.prepend(relativeViewInAppUrl), basePath.publicBaseUrl).toString() - : relativeViewInAppUrl; + const alertUuid = getAlertUuid(recoveredAlertId); + + const viewInAppUrl = addSpaceIdToPath(basePath.publicBaseUrl, spaceId, relativeViewInAppUrl); const baseContext = { + alertDetailsUrl: getAlertDetailsUrl(basePath, spaceId, alertUuid), group: hasGroupBy(validatedParams) ? recoveredAlertId : null, timestamp: startedAt.toISOString(), viewInAppUrl, @@ -899,21 +927,21 @@ const processRecoveredAlerts = ( if (isRatioRuleParams(validatedParams)) { const { criteria } = validatedParams; - const context = { + + alert.setContext({ ...baseContext, numeratorConditions: createConditionsMessageForCriteria(getNumerator(criteria)), denominatorConditions: createConditionsMessageForCriteria(getDenominator(criteria)), isRatio: true, - }; - alert.setContext(context); + }); } else { const { criteria } = validatedParams; - const context = { + + alert.setContext({ ...baseContext, conditions: createConditionsMessageForCriteria(criteria), isRatio: false, - }; - alert.setContext(context); + }); } } }; diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_rule_type.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_rule_type.ts index 3e611bbefeef2..169d674bfd475 100644 --- a/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_rule_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_rule_type.ts @@ -14,6 +14,8 @@ import { } from '../../../../common/alerting/logs/log_threshold'; import { InfraBackendLibs } from '../../infra_types'; import { decodeOrThrow } from '../../../../common/runtime_types'; +import { getAlertDetailsPageEnabledForApp } from '../common/utils'; +import { alertDetailUrlActionVariableDescription } from '../common/messages'; const timestampActionVariableDescription = i18n.translate( 'xpack.infra.logs.alerting.threshold.timestampActionVariableDescription', @@ -96,6 +98,8 @@ export async function registerLogThresholdRuleType( ); } + const config = libs.getAlertDetailsConfig(); + alertingPlugin.registerType({ id: LOG_DOCUMENT_COUNT_RULE_TYPE_ID, name: i18n.translate('xpack.infra.logs.alertName', { @@ -127,6 +131,9 @@ export async function registerLogThresholdRuleType( name: 'denominatorConditions', description: denominatorConditionsActionVariableDescription, }, + ...(getAlertDetailsPageEnabledForApp(config, 'logs') + ? [{ name: 'alertDetailsUrl', description: alertDetailUrlActionVariableDescription }] + : []), { name: 'viewInAppUrl', description: viewInAppUrlActionVariableDescription, From 455fb1d1c729cfe31ed3d1d3868e635a978a77b0 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Mon, 7 Nov 2022 10:35:06 -0700 Subject: [PATCH 015/192] [Reporting] use point-in-time for paging search results (#144201) * [Reporting] use point-in-time for paging search results * add new PIT tests to data plugin * fix deprecation * update point-in-time ID to the latest one received * add warning for shard failure * fix/cleanup csv generation test * add requestTimeout to openPit request * logging polishes * fix test * remove confusing comment Co-authored-by: Jean-Louis Leysens --- .../search_source/search_source.test.ts | 7 + .../search/search_source/search_source.ts | 8 +- .../data/common/search/search_source/types.ts | 11 +- .../es_search/es_search_strategy.ts | 6 +- .../strategies/es_search/request_utils.ts | 18 +- .../__snapshots__/generate_csv.test.ts.snap | 2 +- .../generate_csv/generate_csv.test.ts | 521 ++++++++---------- .../generate_csv/generate_csv.ts | 190 ++++--- 8 files changed, 392 insertions(+), 371 deletions(-) diff --git a/src/plugins/data/common/search/search_source/search_source.test.ts b/src/plugins/data/common/search/search_source/search_source.test.ts index b5cabc654a3f7..4e6192d24e8eb 100644 --- a/src/plugins/data/common/search/search_source/search_source.test.ts +++ b/src/plugins/data/common/search/search_source/search_source.test.ts @@ -903,6 +903,13 @@ describe('SearchSource', () => { expect(Object.keys(JSON.parse(searchSourceJSON))).toEqual(['highlightAll', 'from', 'sort']); }); + test('should add pit', () => { + const pit = { id: 'flimflam', keep_alive: '1m' }; + searchSource.setField('pit', pit); + const { searchSourceJSON } = searchSource.serialize(); + expect(searchSourceJSON).toBe(JSON.stringify({ pit })); + }); + test('should serialize filters', () => { const filter = [ { diff --git a/src/plugins/data/common/search/search_source/search_source.ts b/src/plugins/data/common/search/search_source/search_source.ts index 497a247668694..fad799c7915b1 100644 --- a/src/plugins/data/common/search/search_source/search_source.ts +++ b/src/plugins/data/common/search/search_source/search_source.ts @@ -667,6 +667,8 @@ export class SearchSource { getConfig(UI_SETTINGS.SORT_OPTIONS) ); return addToBody(key, sort); + case 'pit': + return addToRoot(key, val); case 'aggs': if ((val as unknown) instanceof AggConfigs) { return addToBody('aggs', val.toDsl()); @@ -768,7 +770,7 @@ export class SearchSource { const { getConfig } = this.dependencies; const searchRequest = this.mergeProps(); searchRequest.body = searchRequest.body || {}; - const { body, index, query, filters, highlightAll } = searchRequest; + const { body, index, query, filters, highlightAll, pit } = searchRequest; searchRequest.indexType = this.getIndexType(index); const metaFields = getConfig(UI_SETTINGS.META_FIELDS) ?? []; @@ -911,6 +913,10 @@ export class SearchSource { delete searchRequest.highlightAll; } + if (pit) { + body.pit = pit; + } + return searchRequest; } diff --git a/src/plugins/data/common/search/search_source/types.ts b/src/plugins/data/common/search/search_source/types.ts index a583a1d1112cc..140c2dd59a59d 100644 --- a/src/plugins/data/common/search/search_source/types.ts +++ b/src/plugins/data/common/search/search_source/types.ts @@ -39,6 +39,9 @@ export interface ISearchStartSearchSource createEmpty: () => ISearchSource; } +/** + * @deprecated use {@link estypes.SortResults} instead. + */ export type EsQuerySearchAfter = [string | number, string | number]; export enum SortDirection { @@ -112,9 +115,13 @@ export interface SearchSourceFields { * {@link IndexPatternService} */ index?: DataView; - searchAfter?: EsQuerySearchAfter; timeout?: string; terminate_after?: number; + searchAfter?: estypes.SortResults; + /** + * Allow querying to use a point-in-time ID for paging results + */ + pit?: estypes.SearchPointInTimeReference; parent?: SearchSourceFields; } @@ -160,7 +167,7 @@ export type SerializedSearchSourceFields = { * {@link IndexPatternService} */ index?: string | DataViewSpec; - searchAfter?: EsQuerySearchAfter; + searchAfter?: estypes.SortResults; timeout?: string; terminate_after?: number; diff --git a/src/plugins/data/server/search/strategies/es_search/es_search_strategy.ts b/src/plugins/data/server/search/strategies/es_search/es_search_strategy.ts index 73a3b58704877..b2aed5804f248 100644 --- a/src/plugins/data/server/search/strategies/es_search/es_search_strategy.ts +++ b/src/plugins/data/server/search/strategies/es_search/es_search_strategy.ts @@ -35,13 +35,17 @@ export const esSearchStrategyProvider = ( throw new KbnServerError(`Unsupported index pattern type ${request.indexType}`, 400); } + const isPit = request.params?.body?.pit != null; + const search = async () => { try { const config = await firstValueFrom(config$); // @ts-expect-error params fall back to any, but should be valid SearchRequest params const { terminateAfter, ...requestParams } = request.params ?? {}; + const defaults = await getDefaultSearchParams(uiSettingsClient, { isPit }); + const params = { - ...(await getDefaultSearchParams(uiSettingsClient)), + ...defaults, ...getShardTimeout(config), ...(terminateAfter ? { terminate_after: terminateAfter } : {}), ...requestParams, diff --git a/src/plugins/data/server/search/strategies/es_search/request_utils.ts b/src/plugins/data/server/search/strategies/es_search/request_utils.ts index 2418ccfb49a0c..11fd271902e1f 100644 --- a/src/plugins/data/server/search/strategies/es_search/request_utils.ts +++ b/src/plugins/data/server/search/strategies/es_search/request_utils.ts @@ -18,19 +18,29 @@ export function getShardTimeout( } export async function getDefaultSearchParams( - uiSettingsClient: Pick + uiSettingsClient: Pick, + options = { isPit: false } ): Promise<{ max_concurrent_shard_requests?: number; - ignore_unavailable: boolean; + ignore_unavailable?: boolean; track_total_hits: boolean; }> { const maxConcurrentShardRequests = await uiSettingsClient.get( UI_SETTINGS.COURIER_MAX_CONCURRENT_SHARD_REQUESTS ); - return { + + const defaults: Awaited> = { max_concurrent_shard_requests: maxConcurrentShardRequests > 0 ? maxConcurrentShardRequests : undefined, - ignore_unavailable: true, // Don't fail if the index/indices don't exist track_total_hits: true, }; + + // If the request has a point-in-time ID attached, it can not include ignore_unavailable from {@link estypes.IndicesOptions}. + // ES will reject the request as that option was set when the point-in-time was created. + // Otherwise, this option allows search to not fail when the index/indices don't exist + if (!options.isPit) { + defaults.ignore_unavailable = true; + } + + return defaults; } diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/__snapshots__/generate_csv.test.ts.snap b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/__snapshots__/generate_csv.test.ts.snap index 855b447d85ced..c10911d7687d3 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/__snapshots__/generate_csv.test.ts.snap +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/__snapshots__/generate_csv.test.ts.snap @@ -73,7 +73,7 @@ exports[`keeps order of the columns during the scroll 1`] = ` " `; -exports[`uses the scrollId to page all the data 1`] = ` +exports[`uses the pit ID to page all the data 1`] = ` "date,ip,message \\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from the initial search\\" \\"2020-12-31T00:14:28.000Z\\",\\"110.135.176.89\\",\\"hit from the initial search\\" diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.test.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.test.ts index ee00ea28cc05e..804fa4bcdd4a6 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.test.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { errors as esErrors } from '@elastic/elasticsearch'; +import { errors as esErrors, estypes } from '@elastic/elasticsearch'; import type { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; import type { IScopedClusterClient, IUiSettingsClient, Logger } from '@kbn/core/server'; import { @@ -50,6 +50,7 @@ const searchSourceMock = { ...searchSourceInstanceMock, getSearchRequestBody: jest.fn(() => ({})), }; + const mockSearchSourceService: jest.Mocked = { create: jest.fn().mockReturnValue(searchSourceMock), createEmpty: jest.fn().mockReturnValue(searchSourceMock), @@ -58,19 +59,21 @@ const mockSearchSourceService: jest.Mocked = { extract: jest.fn(), getAllMigrations: jest.fn(), }; + +const mockPitId = 'oju9fs3698s3902f02-8qg3-u9w36oiewiuyew6'; + +const getMockRawResponse = (hits: Array> = [], total = hits.length) => ({ + took: 1, + timed_out: false, + pit_id: mockPitId, + _shards: { total: 1, successful: 1, failed: 0, skipped: 0 }, + hits: { hits, total, max_score: 0 }, +}); + const mockDataClientSearchDefault = jest.fn().mockImplementation( (): Rx.Observable<{ rawResponse: SearchResponse }> => Rx.of({ - rawResponse: { - took: 1, - timed_out: false, - _shards: { total: 1, successful: 1, failed: 0, skipped: 0 }, - hits: { - hits: [], - total: 0, - max_score: 0, - }, - }, + rawResponse: getMockRawResponse(), }) ); @@ -92,6 +95,8 @@ beforeEach(async () => { mockDataClient = dataPluginMock.createStartContract().search.asScoped({} as any); mockDataClient.search = mockDataClientSearchDefault; + mockEsClient.asCurrentUser.openPointInTime = jest.fn().mockResolvedValueOnce({ id: mockPitId }); + uiSettingsClient = uiSettingsServiceMock .createStartContract() .asScopedToClient(savedObjectsClientMock.create()); @@ -117,6 +122,8 @@ beforeEach(async () => { searchSourceMock.getField = jest.fn((key: string) => { switch (key) { + case 'pit': + return { id: mockPitId }; case 'index': return { fields: { @@ -125,6 +132,7 @@ beforeEach(async () => { }, metaFields: ['_id', '_index', '_type', '_score'], getFormatterForField: jest.fn(), + getIndexPattern: () => 'logstash-*', }; } }); @@ -157,20 +165,15 @@ it('formats an empty search result to CSV content', async () => { it('formats a search result to CSV content', async () => { mockDataClient.search = jest.fn().mockImplementation(() => Rx.of({ - rawResponse: { - hits: { - hits: [ - { - fields: { - date: `["2020-12-31T00:14:28.000Z"]`, - ip: `["110.135.176.89"]`, - message: `["This is a great message!"]`, - }, - }, - ], - total: 1, - }, - }, + rawResponse: getMockRawResponse([ + { + fields: { + date: `["2020-12-31T00:14:28.000Z"]`, + ip: `["110.135.176.89"]`, + message: `["This is a great message!"]`, + }, + } as unknown as estypes.SearchHit, + ]), }) ); const generateCsv = new CsvGenerator( @@ -199,16 +202,16 @@ const HITS_TOTAL = 100; it('calculates the bytes of the content', async () => { mockDataClient.search = jest.fn().mockImplementation(() => Rx.of({ - rawResponse: { - hits: { - hits: range(0, HITS_TOTAL).map(() => ({ - fields: { - message: ['this is a great message'], - }, - })), - total: HITS_TOTAL, - }, - }, + rawResponse: getMockRawResponse( + range(0, HITS_TOTAL).map( + () => + ({ + fields: { + message: ['this is a great message'], + }, + } as unknown as estypes.SearchHit) + ) + ), }) ); @@ -246,18 +249,18 @@ it('warns if max size was reached', async () => { mockDataClient.search = jest.fn().mockImplementation(() => Rx.of({ - rawResponse: { - hits: { - hits: range(0, HITS_TOTAL).map(() => ({ - fields: { - date: ['2020-12-31T00:14:28.000Z'], - ip: ['110.135.176.89'], - message: ['super cali fragile istic XPLA docious'], - }, - })), - total: HITS_TOTAL, - }, - }, + rawResponse: getMockRawResponse( + range(0, HITS_TOTAL).map( + () => + ({ + fields: { + date: ['2020-12-31T00:14:28.000Z'], + ip: ['110.135.176.89'], + message: ['super cali fragile istic XPLA docious'], + }, + } as unknown as estypes.SearchHit) + ) + ), }) ); @@ -283,36 +286,42 @@ it('warns if max size was reached', async () => { expect(content).toMatchSnapshot(); }); -it('uses the scrollId to page all the data', async () => { - mockDataClient.search = jest.fn().mockImplementation(() => - Rx.of({ - rawResponse: { - _scroll_id: 'awesome-scroll-hero', - hits: { - hits: range(0, HITS_TOTAL / 10).map(() => ({ - fields: { - date: ['2020-12-31T00:14:28.000Z'], - ip: ['110.135.176.89'], - message: ['hit from the initial search'], - }, - })), - total: HITS_TOTAL, - }, - }, - }) - ); - - mockEsClient.asCurrentUser.scroll = jest.fn().mockResolvedValue({ - hits: { - hits: range(0, HITS_TOTAL / 10).map(() => ({ - fields: { - date: ['2020-12-31T00:14:28.000Z'], - ip: ['110.135.176.89'], - message: ['hit from a subsequent scroll'], - }, - })), - }, - }); +it('uses the pit ID to page all the data', async () => { + mockDataClient.search = jest + .fn() + .mockImplementationOnce(() => + Rx.of({ + rawResponse: getMockRawResponse( + range(0, HITS_TOTAL / 10).map( + () => + ({ + fields: { + date: ['2020-12-31T00:14:28.000Z'], + ip: ['110.135.176.89'], + message: ['hit from the initial search'], + }, + } as unknown as estypes.SearchHit) + ), + HITS_TOTAL + ), + }) + ) + .mockImplementation(() => + Rx.of({ + rawResponse: getMockRawResponse( + range(0, HITS_TOTAL / 10).map( + () => + ({ + fields: { + date: ['2020-12-31T00:14:28.000Z'], + ip: ['110.135.176.89'], + message: ['hit from a subsequent scroll'], + }, + } as unknown as estypes.SearchHit) + ) + ), + }) + ); const generateCsv = new CsvGenerator( createMockJob({ columns: ['date', 'ip', 'message'] }), @@ -334,70 +343,55 @@ it('uses the scrollId to page all the data', async () => { expect(csvResult.warnings).toEqual([]); expect(content).toMatchSnapshot(); - expect(mockDataClient.search).toHaveBeenCalledTimes(1); + expect(mockDataClient.search).toHaveBeenCalledTimes(10); expect(mockDataClient.search).toBeCalledWith( - { params: { body: {}, ignore_throttled: undefined, scroll: '30s', size: 500 } }, + { params: { body: {}, ignore_throttled: undefined } }, { strategy: 'es', transport: { maxRetries: 0, requestTimeout: '30s' } } ); - // `scroll` and `clearScroll` must be called with scroll ID in the post body! - expect(mockEsClient.asCurrentUser.scroll).toHaveBeenCalledTimes(9); - expect(mockEsClient.asCurrentUser.scroll).toHaveBeenCalledWith({ - scroll: '30s', - scroll_id: 'awesome-scroll-hero', - }); + expect(mockEsClient.asCurrentUser.openPointInTime).toHaveBeenCalledTimes(1); + expect(mockEsClient.asCurrentUser.openPointInTime).toHaveBeenCalledWith( + { + ignore_unavailable: true, + index: 'logstash-*', + keep_alive: '30s', + }, + { maxRetries: 0, requestTimeout: '30s' } + ); - expect(mockEsClient.asCurrentUser.clearScroll).toHaveBeenCalledTimes(1); - expect(mockEsClient.asCurrentUser.clearScroll).toHaveBeenCalledWith({ - scroll_id: ['awesome-scroll-hero'], + expect(mockEsClient.asCurrentUser.closePointInTime).toHaveBeenCalledTimes(1); + expect(mockEsClient.asCurrentUser.closePointInTime).toHaveBeenCalledWith({ + body: { id: mockPitId }, }); }); it('keeps order of the columns during the scroll', async () => { - mockDataClient.search = jest.fn().mockImplementation(() => - Rx.of({ - rawResponse: { - _scroll_id: 'awesome-scroll-hero', - hits: { - hits: [ - { - fields: { - a: ['a1'], - b: ['b1'], - }, - }, - ], - total: 3, - }, - }, - }) - ); - - mockEsClient.asCurrentUser.scroll = jest + mockDataClient.search = jest .fn() - .mockResolvedValueOnce({ - hits: { - hits: [ - { - fields: { - b: ['b2'], - }, - }, - ], - }, - }) - .mockResolvedValueOnce({ - hits: { - hits: [ - { - fields: { - a: ['a3'], - c: ['c3'], - }, - }, - ], - }, - }); + .mockImplementationOnce(() => + Rx.of({ + rawResponse: getMockRawResponse( + [{ fields: { a: ['a1'], b: ['b1'] } } as unknown as estypes.SearchHit], + 3 + ), + }) + ) + .mockImplementationOnce(() => + Rx.of({ + rawResponse: getMockRawResponse( + [{ fields: { b: ['b2'] } } as unknown as estypes.SearchHit], + 3 + ), + }) + ) + .mockImplementationOnce(() => + Rx.of({ + rawResponse: getMockRawResponse( + [{ fields: { a: ['a3'], c: ['c3'] } } as unknown as estypes.SearchHit], + 3 + ), + }) + ); const generateCsv = new CsvGenerator( createMockJob({ searchSource: {}, columns: [] }), @@ -424,21 +418,16 @@ describe('fields from job.searchSource.getFields() (7.12 generated)', () => { it('cells can be multi-value', async () => { mockDataClient.search = jest.fn().mockImplementation(() => Rx.of({ - rawResponse: { - hits: { - hits: [ - { - _id: 'my-cool-id', - _index: 'my-cool-index', - _version: 4, - fields: { - sku: [`This is a cool SKU.`, `This is also a cool SKU.`], - }, - }, - ], - total: 1, + rawResponse: getMockRawResponse([ + { + _id: 'my-cool-id', + _index: 'my-cool-index', + _version: 4, + fields: { + sku: [`This is a cool SKU.`, `This is also a cool SKU.`], + }, }, - }, + ]), }) ); @@ -466,22 +455,17 @@ describe('fields from job.searchSource.getFields() (7.12 generated)', () => { it('provides top-level underscored fields as columns', async () => { mockDataClient.search = jest.fn().mockImplementation(() => Rx.of({ - rawResponse: { - hits: { - hits: [ - { - _id: 'my-cool-id', - _index: 'my-cool-index', - _version: 4, - fields: { - date: ['2020-12-31T00:14:28.000Z'], - message: [`it's nice to see you`], - }, - }, - ], - total: 1, + rawResponse: getMockRawResponse([ + { + _id: 'my-cool-id', + _index: 'my-cool-index', + _version: 4, + fields: { + date: ['2020-12-31T00:14:28.000Z'], + message: [`it's nice to see you`], + }, }, - }, + ]), }) ); @@ -520,28 +504,23 @@ describe('fields from job.searchSource.getFields() (7.12 generated)', () => { it('sorts the fields when they are to be used as table column names', async () => { mockDataClient.search = jest.fn().mockImplementation(() => Rx.of({ - rawResponse: { - hits: { - hits: [ - { - _id: 'my-cool-id', - _index: 'my-cool-index', - _version: 4, - fields: { - date: ['2020-12-31T00:14:28.000Z'], - message_z: [`test field Z`], - message_y: [`test field Y`], - message_x: [`test field X`], - message_w: [`test field W`], - message_v: [`test field V`], - message_u: [`test field U`], - message_t: [`test field T`], - }, - }, - ], - total: 1, + rawResponse: getMockRawResponse([ + { + _id: 'my-cool-id', + _index: 'my-cool-index', + _version: 4, + fields: { + date: ['2020-12-31T00:14:28.000Z'], + message_z: [`test field Z`], + message_y: [`test field Y`], + message_x: [`test field X`], + message_w: [`test field W`], + message_v: [`test field V`], + message_u: [`test field U`], + message_t: [`test field T`], + }, }, - }, + ]), }) ); @@ -581,22 +560,17 @@ describe('fields from job.columns (7.13+ generated)', () => { it('cells can be multi-value', async () => { mockDataClient.search = jest.fn().mockImplementation(() => Rx.of({ - rawResponse: { - hits: { - hits: [ - { - _id: 'my-cool-id', - _index: 'my-cool-index', - _version: 4, - fields: { - product: 'coconut', - category: [`cool`, `rad`], - }, - }, - ], - total: 1, + rawResponse: getMockRawResponse([ + { + _id: 'my-cool-id', + _index: 'my-cool-index', + _version: 4, + fields: { + product: 'coconut', + category: [`cool`, `rad`], + }, }, - }, + ]), }) ); @@ -624,22 +598,17 @@ describe('fields from job.columns (7.13+ generated)', () => { it('columns can be top-level fields such as _id and _index', async () => { mockDataClient.search = jest.fn().mockImplementation(() => Rx.of({ - rawResponse: { - hits: { - hits: [ - { - _id: 'my-cool-id', - _index: 'my-cool-index', - _version: 4, - fields: { - product: 'coconut', - category: [`cool`, `rad`], - }, - }, - ], - total: 1, + rawResponse: getMockRawResponse([ + { + _id: 'my-cool-id', + _index: 'my-cool-index', + _version: 4, + fields: { + product: 'coconut', + category: [`cool`, `rad`], + }, }, - }, + ]), }) ); @@ -667,22 +636,17 @@ describe('fields from job.columns (7.13+ generated)', () => { it('default column names come from tabify', async () => { mockDataClient.search = jest.fn().mockImplementation(() => Rx.of({ - rawResponse: { - hits: { - hits: [ - { - _id: 'my-cool-id', - _index: 'my-cool-index', - _version: 4, - fields: { - product: 'coconut', - category: [`cool`, `rad`], - }, - }, - ], - total: 1, + rawResponse: getMockRawResponse([ + { + _id: 'my-cool-id', + _index: 'my-cool-index', + _version: 4, + fields: { + product: 'coconut', + category: [`cool`, `rad`], + }, }, - }, + ]), }) ); @@ -714,20 +678,15 @@ describe('formulas', () => { it(`escapes formula values in a cell, doesn't warn the csv contains formulas`, async () => { mockDataClient.search = jest.fn().mockImplementation(() => Rx.of({ - rawResponse: { - hits: { - hits: [ - { - fields: { - date: ['2020-12-31T00:14:28.000Z'], - ip: ['110.135.176.89'], - message: [TEST_FORMULA], - }, - }, - ], - total: 1, - }, - }, + rawResponse: getMockRawResponse([ + { + fields: { + date: ['2020-12-31T00:14:28.000Z'], + ip: ['110.135.176.89'], + message: [TEST_FORMULA], + }, + } as unknown as estypes.SearchHit, + ]), }) ); @@ -757,20 +716,15 @@ describe('formulas', () => { it(`escapes formula values in a header, doesn't warn the csv contains formulas`, async () => { mockDataClient.search = jest.fn().mockImplementation(() => Rx.of({ - rawResponse: { - hits: { - hits: [ - { - fields: { - date: ['2020-12-31T00:14:28.000Z'], - ip: ['110.135.176.89'], - [TEST_FORMULA]: 'This is great data', - }, - }, - ], - total: 1, - }, - }, + rawResponse: getMockRawResponse([ + { + fields: { + date: ['2020-12-31T00:14:28.000Z'], + ip: ['110.135.176.89'], + [TEST_FORMULA]: 'This is great data', + }, + } as unknown as estypes.SearchHit, + ]), }) ); @@ -808,20 +762,15 @@ describe('formulas', () => { }); mockDataClient.search = jest.fn().mockImplementation(() => Rx.of({ - rawResponse: { - hits: { - hits: [ - { - fields: { - date: ['2020-12-31T00:14:28.000Z'], - ip: ['110.135.176.89'], - message: [TEST_FORMULA], - }, - }, - ], - total: 1, - }, - }, + rawResponse: getMockRawResponse([ + { + fields: { + date: ['2020-12-31T00:14:28.000Z'], + ip: ['110.135.176.89'], + message: [TEST_FORMULA], + }, + } as unknown as estypes.SearchHit, + ]), }) ); @@ -875,8 +824,6 @@ it('can override ignoring frozen indices', async () => { params: { body: {}, ignore_throttled: false, - scroll: '30s', - size: 500, }, }, { strategy: 'es', transport: { maxRetries: 0, requestTimeout: '30s' } } @@ -928,7 +875,7 @@ it('will return partial data if the scroll or search fails', async () => { expect(mockLogger.error.mock.calls).toMatchInlineSnapshot(` Array [ Array [ - "CSV export scan error: ResponseError: my error", + "CSV export search error: ResponseError: my error", ], Array [ [ResponseError: my error], @@ -978,27 +925,27 @@ it('handles unknown errors', async () => { describe('error codes', () => { it('returns the expected error code when authentication expires', async () => { - mockDataClient.search = jest.fn().mockImplementation(() => - Rx.of({ - rawResponse: { - _scroll_id: 'test', - hits: { - hits: range(0, 5).map(() => ({ + mockDataClient.search = jest + .fn() + .mockImplementationOnce(() => + Rx.of({ + rawResponse: getMockRawResponse( + range(0, 5).map(() => ({ + _index: 'lasdf', + _id: 'lasdf123', fields: { date: ['2020-12-31T00:14:28.000Z'], ip: ['110.135.176.89'], message: ['super cali fragile istic XPLA docious'], }, })), - total: 10, - }, - }, - }) - ); - - mockEsClient.asCurrentUser.scroll = jest.fn().mockImplementation(() => { - throw new esErrors.ResponseError({ statusCode: 403, meta: {} as any, warnings: [] }); - }); + 10 + ), + }) + ) + .mockImplementationOnce(() => { + throw new esErrors.ResponseError({ statusCode: 403, meta: {} as any, warnings: [] }); + }); const generateCsv = new CsvGenerator( createMockJob({ columns: ['date', 'ip', 'message'] }), @@ -1029,7 +976,7 @@ describe('error codes', () => { expect(mockLogger.error.mock.calls).toMatchInlineSnapshot(` Array [ Array [ - "CSV export scroll error: ResponseError: Response Error", + "CSV export search error: ResponseError: Response Error", ], Array [ [ResponseError: Response Error], diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts index d287ec58530b9..f527956d5c7fa 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts @@ -5,15 +5,9 @@ * 2.0. */ -import { errors as esErrors } from '@elastic/elasticsearch'; -import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { errors as esErrors, estypes } from '@elastic/elasticsearch'; import type { IScopedClusterClient, IUiSettingsClient, Logger } from '@kbn/core/server'; -import type { - DataView, - ISearchSource, - ISearchStartSearchSource, - SearchRequest, -} from '@kbn/data-plugin/common'; +import type { ISearchSource, ISearchStartSearchSource } from '@kbn/data-plugin/common'; import { cellHasFormulas, ES_SEARCH_STRATEGY, tabifyDocs } from '@kbn/data-plugin/common'; import type { IScopedSearchClient } from '@kbn/data-plugin/server'; import type { Datatable } from '@kbn/expressions-plugin/server'; @@ -61,21 +55,63 @@ export class CsvGenerator { private stream: Writable ) {} - private async scan(index: DataView, searchSource: ISearchSource, settings: CsvExportSettings) { + private async openPointInTime(indexPatternTitle: string, settings: CsvExportSettings) { + const { duration } = settings.scroll; + let pitId: string | undefined; + this.logger.debug(`Requesting point-in-time for: [${indexPatternTitle}]...`); + try { + // NOTE: if ES is overloaded, this request could time out + const response = await this.clients.es.asCurrentUser.openPointInTime( + { + index: indexPatternTitle, + keep_alive: duration, + ignore_unavailable: true, + }, + { + requestTimeout: duration, + maxRetries: 0, + } + ); + pitId = response.id; + } catch (err) { + this.logger.error(err); + } + + if (!pitId) { + throw new Error(`Could not receive a point-in-time ID!`); + } + + this.logger.debug(`Opened PIT ID: ${this.truncatePitId(pitId)}`); + + return pitId; + } + + private async doSearch( + searchSource: ISearchSource, + settings: CsvExportSettings, + searchAfter?: estypes.SortResults + ) { const { scroll: scrollSettings, includeFrozen } = settings; - const searchBody: SearchRequest | undefined = searchSource.getSearchRequestBody(); + searchSource.setField('size', scrollSettings.size); + + if (searchAfter) { + searchSource.setField('searchAfter', searchAfter); + } + + const pitId = searchSource.getField('pit')?.id; + this.logger.debug( + `Executing search request with PIT ID: [${this.truncatePitId(pitId)}]` + + (searchAfter ? ` search_after: [${searchAfter}]` : '') + ); + + const searchBody: estypes.SearchRequest = searchSource.getSearchRequestBody(); if (searchBody == null) { throw new Error('Could not retrieve the search body!'); } - this.logger.debug(`Tracking total hits with: track_total_hits=${searchBody.track_total_hits}`); - this.logger.info(`Executing search request...`); const searchParams = { params: { body: searchBody, - index: index.title, - scroll: scrollSettings.duration, - size: scrollSettings.size, ignore_throttled: includeFrozen ? false : undefined, // "true" will cause deprecation warnings logged in ES }, }; @@ -88,35 +124,19 @@ export class CsvGenerator { strategy: ES_SEARCH_STRATEGY, transport: { maxRetries: 0, // retrying reporting jobs is handled in the task manager scheduling logic - requestTimeout: this.config.scroll.duration, + requestTimeout: scrollSettings.duration, }, }) ) - ).rawResponse as estypes.SearchResponse; + ).rawResponse; } catch (err) { - this.logger.error(`CSV export scan error: ${err}`); + this.logger.error(`CSV export search error: ${err}`); throw err; } return results; } - private async scroll(scrollId: string, scrollSettings: CsvExportSettings['scroll']) { - this.logger.info(`Executing scroll request...`); - - let results: estypes.SearchResponse | undefined; - try { - results = await this.clients.es.asCurrentUser.scroll({ - scroll: scrollSettings.duration, - scroll_id: scrollId, - }); - } catch (err) { - this.logger.error(`CSV export scroll error: ${err}`); - throw err; - } - return results; - } - /* * Load field formats for each field in the list */ @@ -202,7 +222,7 @@ export class CsvGenerator { builder: MaxSizeStringBuilder, settings: CsvExportSettings ) { - this.logger.debug(`Building CSV header row...`); + this.logger.debug(`Building CSV header row`); const header = Array.from(columns).map(this.escapeValues(settings)).join(settings.separator) + '\n'; @@ -225,7 +245,7 @@ export class CsvGenerator { formatters: Record, settings: CsvExportSettings ) { - this.logger.debug(`Building ${table.rows.length} CSV data rows...`); + this.logger.debug(`Building ${table.rows.length} CSV data rows`); for (const dataTableRow of table.rows) { if (this.cancellationToken.isCancelled()) { break; @@ -293,26 +313,28 @@ export class CsvGenerator { throw new Error(`The search must have a reference to an index pattern!`); } - const { maxSizeBytes, bom, escapeFormulaValues, scroll: scrollSettings } = settings; - + const { maxSizeBytes, bom, escapeFormulaValues, timezone } = settings; + const indexPatternTitle = index.getIndexPattern(); const builder = new MaxSizeStringBuilder(this.stream, byteSizeValueToNumber(maxSizeBytes), bom); const warnings: string[] = []; let first = true; let currentRecord = -1; let totalRecords: number | undefined; let totalRelation = 'eq'; - let scrollId: string | undefined; + let searchAfter: estypes.SortResults | undefined; + + let pitId = await this.openPointInTime(indexPatternTitle, settings); // apply timezone from the job to all date field formatters try { index.fields.getByType('date').forEach(({ name }) => { - this.logger.debug(`setting timezone on ${name}`); + this.logger.debug(`Setting timezone on ${name}`); const format: FieldFormatConfig = { ...index.fieldFormatMap[name], id: index.fieldFormatMap[name]?.id || 'date', // allow id: date_nanos params: { ...index.fieldFormatMap[name]?.params, - timezone: settings.timezone, + timezone, }, }; index.setFieldFormat(name, format); @@ -327,24 +349,20 @@ export class CsvGenerator { if (this.cancellationToken.isCancelled()) { break; } - let results: estypes.SearchResponse | undefined; - if (scrollId == null) { - // open a scroll cursor in Elasticsearch - results = await this.scan(index, searchSource, settings); - scrollId = results?._scroll_id; - if (results?.hits?.total != null) { - const { hits } = results; - if (typeof hits.total === 'number') { - totalRecords = hits.total; - } else { - totalRecords = hits.total?.value; - totalRelation = hits.total?.relation ?? 'unknown'; - } - this.logger.info(`Total hits: [${totalRecords}].` + `Accuracy: ${totalRelation}`); + // set the latest pit, which could be different from the last request + searchSource.setField('pit', { id: pitId, keep_alive: settings.scroll.duration }); + + const results = await this.doSearch(searchSource, settings, searchAfter); + + const { hits } = results; + if (first && hits.total != null) { + if (typeof hits.total === 'number') { + totalRecords = hits.total; + } else { + totalRecords = hits.total?.value; + totalRelation = hits.total?.relation ?? 'unknown'; } - } else { - // use the scroll cursor in Elasticsearch - results = await this.scroll(scrollId, scrollSettings); + this.logger.info(`Total hits ${totalRelation} ${totalRecords}.`); } if (!results) { @@ -352,13 +370,35 @@ export class CsvGenerator { break; } - // TODO check for shard failures, log them and add a warning if found - { - const { - hits: { hits, ...hitsMeta }, - ...header - } = results; - this.logger.debug('Results metadata: ' + JSON.stringify({ header, hitsMeta })); + const { + hits: { hits: _hits, ...hitsMeta }, + ...headerWithPit + } = results; + + const { pit_id: newPitId, ...header } = headerWithPit; + + const logInfo = { + header: { pit_id: `${this.truncatePitId(newPitId)}`, ...header }, + hitsMeta, + }; + this.logger.debug(`Results metadata: ${JSON.stringify(logInfo)}`); + + // use the most recently received id for the next search request + this.logger.debug(`Received PIT ID: [${this.truncatePitId(results.pit_id)}]`); + pitId = results.pit_id ?? pitId; + + // Update last sort results for next query. PIT is used, so the sort results + // automatically include _shard_doc as a tiebreaker + searchAfter = hits.hits[hits.hits.length - 1]?.sort as estypes.SortResults | undefined; + this.logger.debug(`Received search_after: [${searchAfter}]`); + + // check for shard failures, log them and add a warning if found + const { _shards: shards } = header; + if (shards.failures) { + shards.failures.forEach(({ reason }) => { + warnings.push(`Shard failure: ${JSON.stringify(reason)}`); + this.logger.warn(JSON.stringify(reason)); + }); } let table: Datatable | undefined; @@ -411,16 +451,12 @@ export class CsvGenerator { warnings.push(i18nTexts.unknownError(err?.message ?? err)); } } finally { - // clear scrollID - if (scrollId) { - this.logger.debug(`Executing clearScroll request`); - try { - await this.clients.es.asCurrentUser.clearScroll({ scroll_id: [scrollId] }); - } catch (err) { - this.logger.error(err); - } + // + if (pitId) { + this.logger.debug(`Closing point-in-time`); + await this.clients.es.asCurrentUser.closePointInTime({ body: { id: pitId } }); } else { - this.logger.warn(`No scrollId to clear!`); + this.logger.warn(`No PIT ID to clear!`); } } @@ -429,7 +465,7 @@ export class CsvGenerator { if (!this.maxSizeReached && this.csvRowCount !== totalRecords) { this.logger.warn( `ES scroll returned fewer total hits than expected! ` + - `Search result total hits: ${totalRecords}. Row count: ${this.csvRowCount}.` + `Search result total hits: ${totalRecords}. Row count: ${this.csvRowCount}` ); warnings.push( i18nTexts.csvRowCountError({ expected: totalRecords ?? NaN, received: this.csvRowCount }) @@ -447,4 +483,8 @@ export class CsvGenerator { error_code: reportingError?.code, }; } + + private truncatePitId(pitId: string | undefined) { + return pitId?.substring(0, 12) + '...'; + } } From e30ff8dfa15af1e1aa6693e9b19437b21a0ab6ab Mon Sep 17 00:00:00 2001 From: Spencer Date: Mon, 7 Nov 2022 10:41:00 -0700 Subject: [PATCH 016/192] always require ci on codeowners changes (#144728) Fixes https://github.com/elastic/kibana/issues/144714 --- .buildkite/pull_requests.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.buildkite/pull_requests.json b/.buildkite/pull_requests.json index 027c2de8bf915..51f9ab6a34be6 100644 --- a/.buildkite/pull_requests.json +++ b/.buildkite/pull_requests.json @@ -37,6 +37,7 @@ ], "always_require_ci_on_changed": [ "^docs/developer/plugin-list.asciidoc$", + "^\\.github/CODEOWNERS$", "/plugins/[^/]+/readme\\.(md|asciidoc)$" ], "kibana_versions_check": true, From c2eb7b782c3de90d64f3b6afa08986b8dde73adf Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 7 Nov 2022 12:15:58 -0600 Subject: [PATCH 017/192] Update babel to ^7.20.0 (main) (#144556) * Update babel to ^7.20.0 * dedupe Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Jonathan Budzenski --- package.json | 10 ++++----- yarn.lock | 58 ++++++++++++++++++++++++++-------------------------- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/package.json b/package.json index a35493afce1fc..735fec31c6b0b 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,7 @@ }, "dependencies": { "@appland/sql-parser": "^1.5.1", - "@babel/runtime": "^7.19.4", + "@babel/runtime": "^7.20.0", "@dnd-kit/core": "^3.1.1", "@dnd-kit/sortable": "^4.0.0", "@dnd-kit/utilities": "^2.0.0", @@ -686,9 +686,9 @@ "@babel/core": "^7.19.6", "@babel/eslint-parser": "^7.19.1", "@babel/eslint-plugin": "^7.19.1", - "@babel/generator": "^7.19.6", + "@babel/generator": "^7.20.0", "@babel/helper-plugin-utils": "^7.19.0", - "@babel/parser": "^7.19.6", + "@babel/parser": "^7.20.0", "@babel/plugin-proposal-class-properties": "^7.18.6", "@babel/plugin-proposal-export-namespace-from": "^7.18.9", "@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.6", @@ -700,8 +700,8 @@ "@babel/preset-react": "^7.18.6", "@babel/preset-typescript": "^7.18.6", "@babel/register": "^7.18.9", - "@babel/traverse": "^7.19.6", - "@babel/types": "^7.19.4", + "@babel/traverse": "^7.20.0", + "@babel/types": "^7.20.0", "@bazel/ibazel": "^0.16.2", "@bazel/typescript": "4.6.2", "@cypress/code-coverage": "^3.10.0", diff --git a/yarn.lock b/yarn.lock index 523dc458e026e..3c71c72ec6979 100644 --- a/yarn.lock +++ b/yarn.lock @@ -150,12 +150,12 @@ dependencies: eslint-rule-composer "^0.3.0" -"@babel/generator@^7.12.11", "@babel/generator@^7.12.5", "@babel/generator@^7.19.6", "@babel/generator@^7.7.2": - version "7.19.6" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.19.6.tgz#9e481a3fe9ca6261c972645ae3904ec0f9b34a1d" - integrity sha512-oHGRUQeoX1QrKeJIKVe0hwjGqNnVYsM5Nep5zo0uE0m42sLH+Fsd2pStJ5sRM1bNyTUUoz0pe2lTeMJrb/taTA== +"@babel/generator@^7.12.11", "@babel/generator@^7.12.5", "@babel/generator@^7.19.6", "@babel/generator@^7.20.0", "@babel/generator@^7.20.1", "@babel/generator@^7.7.2": + version "7.20.1" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.20.1.tgz#ef32ecd426222624cbd94871a7024639cf61a9fa" + integrity sha512-u1dMdBUmA7Z0rBB97xh8pIhviK7oItYOkjbsCxTWMknyvbQRBwX7/gn4JXurRdirWMFh+ZtYARqkA6ydogVZpg== dependencies: - "@babel/types" "^7.19.4" + "@babel/types" "^7.20.0" "@jridgewell/gen-mapping" "^0.3.2" jsesc "^2.5.1" @@ -388,10 +388,10 @@ chalk "^2.0.0" js-tokens "^4.0.0" -"@babel/parser@^7.1.0", "@babel/parser@^7.10.3", "@babel/parser@^7.12.11", "@babel/parser@^7.12.7", "@babel/parser@^7.14.7", "@babel/parser@^7.18.10", "@babel/parser@^7.19.6": - version "7.19.6" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.19.6.tgz#b923430cb94f58a7eae8facbffa9efd19130e7f8" - integrity sha512-h1IUp81s2JYJ3mRkdxJgs4UvmSsRvDrx5ICSJbPvtWYv5i1nTBGcBpnog+89rAFMwvvru6E5NUHdBe01UeSzYA== +"@babel/parser@^7.1.0", "@babel/parser@^7.10.3", "@babel/parser@^7.12.11", "@babel/parser@^7.12.7", "@babel/parser@^7.14.7", "@babel/parser@^7.18.10", "@babel/parser@^7.19.6", "@babel/parser@^7.20.0", "@babel/parser@^7.20.1": + version "7.20.1" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.20.1.tgz#3e045a92f7b4623cafc2425eddcb8cf2e54f9cc5" + integrity sha512-hp0AYxaZJhxULfM1zyp7Wgr+pSUKBcP3M+PHnSzWGdXOzg/kHWIgiUWARvubhUKGOEw3xqY4x+lyZ9ytBVcELw== "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.18.6": version "7.18.6" @@ -1180,12 +1180,12 @@ core-js-pure "^3.0.0" regenerator-runtime "^0.13.4" -"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.17.8", "@babel/runtime@^7.18.3", "@babel/runtime@^7.19.4", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": - version "7.19.4" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.19.4.tgz#a42f814502ee467d55b38dd1c256f53a7b885c78" - integrity sha512-EXpLCrk55f+cYqmHsSR+yD/0gAIMxxA9QK9lnQWzhMCvt+YmoBN7Zx94s++Kv0+unHk39vxNO8t+CMA2WSS3wA== +"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.17.8", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.0", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": + version "7.20.1" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.20.1.tgz#1148bb33ab252b165a06698fde7576092a78b4a9" + integrity sha512-mrzLkl6U9YLF8qpqI7TB82PESyEGjm/0Ly91jG575eVxMMlb8fYfOXFZIJ8XfLrJZQbm7dlKry2bJmXBUEkdFg== dependencies: - regenerator-runtime "^0.13.4" + regenerator-runtime "^0.13.10" "@babel/template@^7.12.7", "@babel/template@^7.18.10", "@babel/template@^7.18.6", "@babel/template@^7.3.3": version "7.18.10" @@ -1196,26 +1196,26 @@ "@babel/parser" "^7.18.10" "@babel/types" "^7.18.10" -"@babel/traverse@^7.10.3", "@babel/traverse@^7.12.11", "@babel/traverse@^7.12.9", "@babel/traverse@^7.13.0", "@babel/traverse@^7.18.9", "@babel/traverse@^7.19.4", "@babel/traverse@^7.19.6", "@babel/traverse@^7.4.5", "@babel/traverse@^7.7.2": - version "7.19.6" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.19.6.tgz#7b4c865611df6d99cb131eec2e8ac71656a490dc" - integrity sha512-6l5HrUCzFM04mfbG09AagtYyR2P0B71B1wN7PfSPiksDPz2k5H9CBC1tcZpz2M8OxbKTPccByoOJ22rUKbpmQQ== +"@babel/traverse@^7.10.3", "@babel/traverse@^7.12.11", "@babel/traverse@^7.12.9", "@babel/traverse@^7.13.0", "@babel/traverse@^7.18.9", "@babel/traverse@^7.19.4", "@babel/traverse@^7.19.6", "@babel/traverse@^7.20.0", "@babel/traverse@^7.4.5", "@babel/traverse@^7.7.2": + version "7.20.1" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.20.1.tgz#9b15ccbf882f6d107eeeecf263fbcdd208777ec8" + integrity sha512-d3tN8fkVJwFLkHkBN479SOsw4DMZnz8cdbL/gvuDuzy3TS6Nfw80HuQqhw1pITbIruHyh7d1fMA47kWzmcUEGA== dependencies: "@babel/code-frame" "^7.18.6" - "@babel/generator" "^7.19.6" + "@babel/generator" "^7.20.1" "@babel/helper-environment-visitor" "^7.18.9" "@babel/helper-function-name" "^7.19.0" "@babel/helper-hoist-variables" "^7.18.6" "@babel/helper-split-export-declaration" "^7.18.6" - "@babel/parser" "^7.19.6" - "@babel/types" "^7.19.4" + "@babel/parser" "^7.20.1" + "@babel/types" "^7.20.0" debug "^4.1.0" globals "^11.1.0" -"@babel/types@^7.0.0", "@babel/types@^7.10.3", "@babel/types@^7.12.11", "@babel/types@^7.12.7", "@babel/types@^7.18.10", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.19.0", "@babel/types@^7.19.4", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4": - version "7.19.4" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.19.4.tgz#0dd5c91c573a202d600490a35b33246fed8a41c7" - integrity sha512-M5LK7nAeS6+9j7hAq+b3fQs+pNfUtTGq+yFFfHnauFA8zQtLRfmuipmsKDKKLuyG+wC8ABW43A153YNawNTEtw== +"@babel/types@^7.0.0", "@babel/types@^7.10.3", "@babel/types@^7.12.11", "@babel/types@^7.12.7", "@babel/types@^7.18.10", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.19.0", "@babel/types@^7.19.4", "@babel/types@^7.20.0", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4": + version "7.20.0" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.20.0.tgz#52c94cf8a7e24e89d2a194c25c35b17a64871479" + integrity sha512-Jlgt3H0TajCW164wkTOTzHkZb075tMQMULzrLUoUeKmO7eFL96GgDxf7/Axhc5CAuKE3KFyVW1p6ysKsi2oXAg== dependencies: "@babel/helper-string-parser" "^7.19.4" "@babel/helper-validator-identifier" "^7.19.1" @@ -23071,10 +23071,10 @@ regenerator-runtime@^0.11.0: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg== -regenerator-runtime@^0.13.4, regenerator-runtime@^0.13.7: - version "0.13.7" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz#cac2dacc8a1ea675feaabaeb8ae833898ae46f55" - integrity sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew== +regenerator-runtime@^0.13.10, regenerator-runtime@^0.13.4, regenerator-runtime@^0.13.7: + version "0.13.10" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.10.tgz#ed07b19616bcbec5da6274ebc75ae95634bfc2ee" + integrity sha512-KepLsg4dU12hryUO7bp/axHAKvwGOCV0sGloQtpagJ12ai+ojVDqkeGSiRX1zlq+kjIMZ1t7gpze+26QqtdGqw== regenerator-transform@^0.15.0: version "0.15.0" From 53037ef848471133c390f6e7965d59292c56c25b Mon Sep 17 00:00:00 2001 From: Julia Date: Mon, 7 Nov 2022 19:19:56 +0100 Subject: [PATCH 018/192] fix logger text and fix bulk error type (#144598) * fix logger text and fix bulk error type * add logging of failed to delete task ids Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/alerting/server/index.ts | 2 +- .../lib/retry_if_bulk_delete_conflicts.ts | 8 ++--- .../lib/retry_if_bulk_edit_conflicts.ts | 8 ++--- .../server/rules_client/rules_client.ts | 34 +++++++++---------- .../rules_client/tests/bulk_delete.test.ts | 8 +++-- .../api/rules/bulk_actions/route.ts | 9 +++-- .../logic/bulk_actions/bulk_edit_rules.ts | 4 +-- .../triggers_actions_ui/public/types.ts | 4 +-- 8 files changed, 40 insertions(+), 37 deletions(-) diff --git a/x-pack/plugins/alerting/server/index.ts b/x-pack/plugins/alerting/server/index.ts index 4a11667319b1e..48f293fdd0043 100644 --- a/x-pack/plugins/alerting/server/index.ts +++ b/x-pack/plugins/alerting/server/index.ts @@ -35,7 +35,7 @@ export type { PluginSetupContract, PluginStartContract } from './plugin'; export type { FindResult, BulkEditOperation, - BulkEditError, + BulkOperationError, BulkEditOptions, BulkEditOptionsFilter, BulkEditOptionsIds, diff --git a/x-pack/plugins/alerting/server/rules_client/lib/retry_if_bulk_delete_conflicts.ts b/x-pack/plugins/alerting/server/rules_client/lib/retry_if_bulk_delete_conflicts.ts index 529055b85e44a..0c2bac9695c85 100644 --- a/x-pack/plugins/alerting/server/rules_client/lib/retry_if_bulk_delete_conflicts.ts +++ b/x-pack/plugins/alerting/server/rules_client/lib/retry_if_bulk_delete_conflicts.ts @@ -10,20 +10,20 @@ import { chunk } from 'lodash'; import { KueryNode } from '@kbn/es-query'; import { Logger } from '@kbn/core/server'; import { convertRuleIdsToKueryNode } from '../../lib'; -import { BulkDeleteError } from '../rules_client'; +import { BulkOperationError } from '../rules_client'; import { waitBeforeNextRetry, RETRY_IF_CONFLICTS_ATTEMPTS } from './wait_before_next_retry'; const MAX_RULES_IDS_IN_RETRY = 1000; export type BulkDeleteOperation = (filter: KueryNode | null) => Promise<{ apiKeysToInvalidate: string[]; - errors: BulkDeleteError[]; + errors: BulkOperationError[]; taskIdsToDelete: string[]; }>; interface ReturnRetry { apiKeysToInvalidate: string[]; - errors: BulkDeleteError[]; + errors: BulkOperationError[]; taskIdsToDelete: string[]; } @@ -46,7 +46,7 @@ export const retryIfBulkDeleteConflicts = async ( filter: KueryNode | null, retries: number = RETRY_IF_CONFLICTS_ATTEMPTS, accApiKeysToInvalidate: string[] = [], - accErrors: BulkDeleteError[] = [], + accErrors: BulkOperationError[] = [], accTaskIdsToDelete: string[] = [] ): Promise => { try { diff --git a/x-pack/plugins/alerting/server/rules_client/lib/retry_if_bulk_edit_conflicts.ts b/x-pack/plugins/alerting/server/rules_client/lib/retry_if_bulk_edit_conflicts.ts index 550e13a6bffe5..d893f2e9b5df8 100644 --- a/x-pack/plugins/alerting/server/rules_client/lib/retry_if_bulk_edit_conflicts.ts +++ b/x-pack/plugins/alerting/server/rules_client/lib/retry_if_bulk_edit_conflicts.ts @@ -10,7 +10,7 @@ import { chunk } from 'lodash'; import { KueryNode } from '@kbn/es-query'; import { Logger, SavedObjectsBulkUpdateObject, SavedObjectsUpdateResponse } from '@kbn/core/server'; import { convertRuleIdsToKueryNode } from '../../lib'; -import { BulkEditError } from '../rules_client'; +import { BulkOperationError } from '../rules_client'; import { RawRule } from '../../types'; import { waitBeforeNextRetry, RETRY_IF_CONFLICTS_ATTEMPTS } from './wait_before_next_retry'; @@ -21,13 +21,13 @@ type BulkEditOperation = (filter: KueryNode | null) => Promise<{ apiKeysToInvalidate: string[]; rules: Array>; resultSavedObjects: Array>; - errors: BulkEditError[]; + errors: BulkOperationError[]; }>; interface ReturnRetry { apiKeysToInvalidate: string[]; results: Array>; - errors: BulkEditError[]; + errors: BulkOperationError[]; } /** @@ -52,7 +52,7 @@ export const retryIfBulkEditConflicts = async ( retries: number = RETRY_IF_CONFLICTS_ATTEMPTS, accApiKeysToInvalidate: string[] = [], accResults: Array> = [], - accErrors: BulkEditError[] = [] + accErrors: BulkOperationError[] = [] ): Promise => { // run the operation, return if no errors or throw if not a conflict error try { diff --git a/x-pack/plugins/alerting/server/rules_client/rules_client.ts b/x-pack/plugins/alerting/server/rules_client/rules_client.ts index bd4f9deb36b5d..0d1a03ff6f3ed 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -309,17 +309,9 @@ export interface BulkDeleteOptionsIds { export type BulkDeleteOptions = BulkDeleteOptionsFilter | BulkDeleteOptionsIds; -export interface BulkEditError { +export interface BulkOperationError { message: string; - rule: { - id: string; - name: string; - }; -} - -export interface BulkDeleteError { - message: string; - status: number; + status?: number; rule: { id: string; name: string; @@ -1908,18 +1900,24 @@ export class RulesClient { ); const taskIdsFailedToBeDeleted: string[] = []; + const taskIdsSuccessfullyDeleted: string[] = []; if (taskIdsToDelete.length > 0) { try { const resultFromDeletingTasks = await this.taskManager.bulkRemoveIfExist(taskIdsToDelete); resultFromDeletingTasks?.statuses.forEach((status) => { - if (!status.success) { + if (status.success) { + taskIdsSuccessfullyDeleted.push(status.id); + } else { taskIdsFailedToBeDeleted.push(status.id); } }); this.logger.debug( - `Successfully deleted schedules for underlying tasks: ${taskIdsToDelete - .filter((id) => taskIdsFailedToBeDeleted.includes(id)) - .join(', ')}` + `Successfully deleted schedules for underlying tasks: ${taskIdsSuccessfullyDeleted.join( + ', ' + )}` + ); + this.logger.error( + `Failure to delete schedules for underlying tasks: ${taskIdsFailedToBeDeleted.join(', ')}` ); } catch (error) { this.logger.error( @@ -1953,7 +1951,7 @@ export class RulesClient { const rules: SavedObjectsBulkDeleteObject[] = []; const apiKeysToInvalidate: string[] = []; const taskIdsToDelete: string[] = []; - const errors: BulkDeleteError[] = []; + const errors: BulkOperationError[] = []; const apiKeyToRuleIdMapping: Record = {}; const taskIdToRuleIdMapping: Record = {}; const ruleNameToRuleIdMapping: Record = {}; @@ -2009,7 +2007,7 @@ export class RulesClient { options: BulkEditOptions ): Promise<{ rules: Array>; - errors: BulkEditError[]; + errors: BulkOperationError[]; total: number; }> { const queryFilter = (options as BulkEditOptionsFilter).filter; @@ -2176,7 +2174,7 @@ export class RulesClient { apiKeysToInvalidate: string[]; rules: Array>; resultSavedObjects: Array>; - errors: BulkEditError[]; + errors: BulkOperationError[]; }> { const rulesFinder = await this.encryptedSavedObjectsClient.createPointInTimeFinderDecryptedAsInternalUser( @@ -2189,7 +2187,7 @@ export class RulesClient { ); const rules: Array> = []; - const errors: BulkEditError[] = []; + const errors: BulkOperationError[] = []; const apiKeysToInvalidate: string[] = []; const apiKeysMap = new Map(); const username = await this.getUserName(); diff --git a/x-pack/plugins/alerting/server/rules_client/tests/bulk_delete.test.ts b/x-pack/plugins/alerting/server/rules_client/tests/bulk_delete.test.ts index 5e0a71edcae51..9c1295e7f0c27 100644 --- a/x-pack/plugins/alerting/server/rules_client/tests/bulk_delete.test.ts +++ b/x-pack/plugins/alerting/server/rules_client/tests/bulk_delete.test.ts @@ -56,7 +56,7 @@ const rulesClientParams: jest.Mocked = { beforeEach(() => { getBeforeSetup(rulesClientParams, taskManager, ruleTypeRegistry); - (auditLogger.log as jest.Mock).mockClear(); + jest.clearAllMocks(); }); setGlobalDate(); @@ -392,9 +392,11 @@ describe('bulkDelete', () => { const result = await rulesClient.bulkDeleteRules({ filter: 'fake_filter' }); - expect(logger.debug).toBeCalledTimes(1); expect(logger.debug).toBeCalledWith( - 'Successfully deleted schedules for underlying tasks: taskId2' + 'Successfully deleted schedules for underlying tasks: taskId1' + ); + expect(logger.error).toBeCalledWith( + 'Failure to delete schedules for underlying tasks: taskId2' ); expect(result).toStrictEqual({ errors: [], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts index c3bd79322c318..da19964e5b7ec 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts @@ -10,7 +10,7 @@ import moment from 'moment'; import { BadRequestError, transformError } from '@kbn/securitysolution-es-utils'; import type { KibanaResponseFactory, Logger, SavedObjectsClientContract } from '@kbn/core/server'; -import type { RulesClient, BulkEditError } from '@kbn/alerting-plugin/server'; +import type { RulesClient, BulkOperationError } from '@kbn/alerting-plugin/server'; import type { SanitizedRule } from '@kbn/alerting-plugin/common'; import { AbortError } from '@kbn/kibana-utils-plugin/common'; import type { RuleAlertType, RuleParams } from '../../../../rule_schema'; @@ -66,7 +66,10 @@ interface NormalizedRuleError { rules: RuleDetailsInError[]; } -type BulkActionError = PromisePoolError | PromisePoolError | BulkEditError; +type BulkActionError = + | PromisePoolError + | PromisePoolError + | BulkOperationError; const normalizeErrorResponse = (errors: BulkActionError[]): NormalizedRuleError[] => { const errorsMap = new Map(); @@ -76,7 +79,7 @@ const normalizeErrorResponse = (errors: BulkActionError[]): NormalizedRuleError[ let statusCode: number = 500; let errorCode: BulkActionsDryRunErrCode | undefined; let rule: RuleDetailsInError; - // transform different error types (PromisePoolError | PromisePoolError | BulkEditError) + // transform different error types (PromisePoolError | PromisePoolError | BulkOperationError) // to one common used in NormalizedRuleError if ('rule' in errorObj) { rule = errorObj.rule; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/bulk_actions/bulk_edit_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/bulk_actions/bulk_edit_rules.ts index 85610fcddb436..89abe357bb58b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/bulk_actions/bulk_edit_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_management/logic/bulk_actions/bulk_edit_rules.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { BulkEditError, RulesClient } from '@kbn/alerting-plugin/server'; +import type { BulkOperationError, RulesClient } from '@kbn/alerting-plugin/server'; import pMap from 'p-map'; import { @@ -82,7 +82,7 @@ export const bulkEditRules = async ({ const rulesAction = ruleActions.pop(); if (rulesAction) { - const unmuteErrors: BulkEditError[] = []; + const unmuteErrors: BulkOperationError[] = []; const rulesToUnmute = await pMap( result.rules, async (rule) => { diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index 53813632af435..18c408a2e77ad 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -41,7 +41,7 @@ import { ActionVariable, RuleType as CommonRuleType, } from '@kbn/alerting-plugin/common'; -import type { BulkEditError } from '@kbn/alerting-plugin/server'; +import type { BulkOperationError } from '@kbn/alerting-plugin/server'; import { RuleRegistrySearchRequestPagination } from '@kbn/rule-registry-plugin/common'; import { EcsFieldsResponse } from '@kbn/rule-registry-plugin/common/search_strategy'; import { SortCombinations } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; @@ -158,7 +158,7 @@ export enum RuleFlyoutCloseReason { export interface BulkEditResponse { rules: Rule[]; - errors: BulkEditError[]; + errors: BulkOperationError[]; total: number; } From 31f31056001625e1539ef7c0416bc447e064e3bb Mon Sep 17 00:00:00 2001 From: Justin Kambic Date: Mon, 7 Nov 2022 13:20:26 -0500 Subject: [PATCH 019/192] [Synthetics] Add monitor detail flyout (#136156) Co-authored-by: Dominique Clarke Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Shahzad Resolves https://github.com/elastic/kibana/issues/135150 --- .../synthetics/e2e/journeys/detail_flyout.ts | 50 ++ .../plugins/synthetics/e2e/journeys/index.ts | 1 + .../monitor_list_table/monitor_enabled.tsx | 1 + .../overview/actions_popover.test.tsx | 31 +- .../overview/overview/actions_popover.tsx | 196 +++--- .../overview/overview/metric_item.tsx | 9 +- .../overview/monitor_detail_flyout.test.tsx | 148 +++++ .../overview/monitor_detail_flyout.tsx | 584 ++++++++++++++++++ .../overview/overview/overview_grid.tsx | 34 +- .../overview/overview/overview_grid_item.tsx | 16 +- .../synthetics/hooks/use_monitor_detail.ts | 58 ++ .../hooks/use_monitor_enable_handler.tsx | 86 ++- .../synthetics/state/monitor_list/actions.ts | 15 + .../synthetics/state/monitor_list/effects.ts | 33 +- .../state/monitor_list/toast_title.tsx | 13 + .../apps/synthetics/state/overview/actions.ts | 4 +- .../apps/synthetics/state/overview/api.ts | 10 +- .../apps/synthetics/state/overview/index.ts | 8 +- .../apps/synthetics/state/overview/models.ts | 5 + .../__mocks__/synthetics_store.mock.ts | 1 + 20 files changed, 1169 insertions(+), 134 deletions(-) create mode 100644 x-pack/plugins/synthetics/e2e/journeys/detail_flyout.ts create mode 100644 x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/monitor_detail_flyout.test.tsx create mode 100644 x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/monitor_detail_flyout.tsx create mode 100644 x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_monitor_detail.ts create mode 100644 x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_list/toast_title.tsx diff --git a/x-pack/plugins/synthetics/e2e/journeys/detail_flyout.ts b/x-pack/plugins/synthetics/e2e/journeys/detail_flyout.ts new file mode 100644 index 0000000000000..c8fac9dcb1e9a --- /dev/null +++ b/x-pack/plugins/synthetics/e2e/journeys/detail_flyout.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { before, expect, journey, step } from '@elastic/synthetics'; +import { syntheticsAppPageProvider } from '../page_objects/synthetics_app'; + +journey('Test Monitor Detail Flyout', async ({ page, params }) => { + const syntheticsApp = syntheticsAppPageProvider({ page, kibanaUrl: params.kibanaUrl }); + const monitorName = 'test-flyout-http-monitor'; + + before(async () => { + await syntheticsApp.waitForLoadingToFinish(); + }); + + step('Go to monitor-management', async () => { + await syntheticsApp.navigateToAddMonitor(); + }); + + step('login to Kibana', async () => { + await syntheticsApp.loginToKibana(); + const invalid = await page.locator(`text=Username or password is incorrect. Please try again.`); + expect(await invalid.isVisible()).toBeFalsy(); + }); + + step('create http monitor', async () => { + await syntheticsApp.createBasicHTTPMonitorDetails({ + name: monitorName, + url: 'https://www.elastic.co', + apmServiceName: 'dev', + locations: ['US Central'], + }); + expect(await syntheticsApp.confirmAndSave()).toBeTruthy(); + }); + + step('open overview flyout', async () => { + await syntheticsApp.navigateToOverview(); + await page.click(`[data-test-subj="${monitorName}-metric-item"]`); + const flyoutHeader = await page.waitForSelector('.euiFlyoutHeader'); + expect(await flyoutHeader.innerText()).toContain(monitorName); + }); + + step('delete monitors', async () => { + await syntheticsApp.navigateToMonitorManagement(); + expect(await syntheticsApp.deleteMonitors()).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/synthetics/e2e/journeys/index.ts b/x-pack/plugins/synthetics/e2e/journeys/index.ts index 5651b092544de..0e795fbe631f7 100644 --- a/x-pack/plugins/synthetics/e2e/journeys/index.ts +++ b/x-pack/plugins/synthetics/e2e/journeys/index.ts @@ -18,3 +18,4 @@ export * from './monitor_management_enablement.journey'; export * from './monitor_details'; export * from './locations'; export * from './private_locations'; +export * from './detail_flyout'; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/monitor_enabled.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/monitor_enabled.tsx index e5e7c6bbe3781..61e6f9534692e 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/monitor_enabled.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/management/monitor_list_table/monitor_enabled.tsx @@ -42,6 +42,7 @@ export const MonitorEnabled = ({ const { isEnabled, updateMonitorEnabledState, status } = useMonitorEnableHandler({ id, + isEnabled: monitor[ConfigKey.ENABLED], reloadPage, labels: statusLabels, }); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/actions_popover.test.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/actions_popover.test.tsx index d37fb8c2a3001..cad8a173a1ba5 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/actions_popover.test.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/actions_popover.test.tsx @@ -36,7 +36,12 @@ describe('ActionsPopover', () => { it('renders the popover button', () => { const { queryByText, getByLabelText } = render( - + ); expect(getByLabelText('Open actions menu')); expect(queryByText('Actions')).not.toBeInTheDocument(); @@ -47,6 +52,7 @@ describe('ActionsPopover', () => { const isPopoverOpen = false; const { getByLabelText } = render( { const isPopoverOpen = true; const { getByLabelText } = render( { .spyOn(editMonitorLocatorModule, 'useEditMonitorLocator') .mockReturnValue('/a/test/edit/url'); const { getByRole } = render( - + ); expect(getByRole('link')?.getAttribute('href')).toBe('/a/test/edit/url'); }); @@ -93,7 +105,12 @@ describe('ActionsPopover', () => { .spyOn(monitorDetailLocatorModule, 'useMonitorDetailLocator') .mockReturnValue('/a/test/detail/url'); const { getByRole } = render( - + ); expect(getByRole('link')?.getAttribute('href')).toBe('/a/test/detail/url'); }); @@ -106,7 +123,12 @@ describe('ActionsPopover', () => { updateMonitorEnabledState, }); const { getByText } = render( - + ); const enableButton = getByText('Disable monitor'); fireEvent.click(enableButton); @@ -126,6 +148,7 @@ describe('ActionsPopover', () => { isPopoverOpen={true} setIsPopoverOpen={jest.fn()} monitor={{ ...testMonitor, isEnabled: false }} + position="relative" /> ); const enableButton = getByText('Enable monitor'); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/actions_popover.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/actions_popover.tsx index 1267c2ab43c52..08c5dfd81d9c0 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/actions_popover.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/actions_popover.tsx @@ -6,49 +6,86 @@ */ import { i18n } from '@kbn/i18n'; -import { EuiPopover, EuiButtonIcon, EuiContextMenu, useEuiShadow } from '@elastic/eui'; +import { EuiPopover, EuiButtonIcon, EuiContextMenu, useEuiShadow, EuiPanel } from '@elastic/eui'; import { FETCH_STATUS } from '@kbn/observability-plugin/public'; -import { useTheme } from '@kbn/observability-plugin/public'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import React, { useEffect, useMemo, useState } from 'react'; +import { useDispatch } from 'react-redux'; import styled from 'styled-components'; import { MonitorOverviewItem } from '../../../../../../../common/runtime_types'; import { useMonitorEnableHandler } from '../../../../hooks/use_monitor_enable_handler'; -import { quietFetchOverviewAction } from '../../../../state/overview/actions'; -import { selectOverviewState } from '../../../../state/overview/selectors'; +import { setFlyoutConfig } from '../../../../state/overview/actions'; import { useEditMonitorLocator } from '../../hooks/use_edit_monitor_locator'; import { useMonitorDetailLocator } from '../../hooks/use_monitor_detail_locator'; +import { useLocationName } from '../../../../hooks'; + +type PopoverPosition = 'relative' | 'default'; interface ActionContainerProps { boxShadow: string; + position: PopoverPosition; } -const ActionContainer = styled.div` - // position +const Container = styled.div` + ${({ position }) => + position === 'relative' + ? // custom styles used to overlay the popover button on `MetricItem` + ` display: inline-block; position: relative; bottom: 42px; left: 12px; z-index: 1; +` + : // otherwise, no custom position needed + ''} - // style border-radius: ${({ theme }) => theme.eui.euiBorderRadius}; - ${({ boxShadow }) => boxShadow} + ${({ boxShadow, position }) => (position === 'relative' ? boxShadow : '')} `; +interface Props { + isPopoverOpen: boolean; + isInspectView?: boolean; + monitor: MonitorOverviewItem; + setIsPopoverOpen: React.Dispatch>; + position: PopoverPosition; + iconHasPanel?: boolean; + iconSize?: 's' | 'xs'; +} + +const CustomShadowPanel = styled(EuiPanel)<{ shadow: string }>` + ${(props) => props.shadow} +`; + +function IconPanel({ children, hasPanel }: { children: JSX.Element; hasPanel: boolean }) { + const shadow = useEuiShadow('s'); + if (!hasPanel) return children; + return ( + + {children} + + ); +} + export function ActionsPopover({ isPopoverOpen, + isInspectView, setIsPopoverOpen, monitor, -}: { - isPopoverOpen: boolean; - monitor: MonitorOverviewItem; - setIsPopoverOpen: React.Dispatch>; -}) { - const theme = useTheme(); + position, + iconHasPanel = true, + iconSize = 's', +}: Props) { const euiShadow = useEuiShadow('l'); const dispatch = useDispatch(); - const { pageState } = useSelector(selectOverviewState); + const locationName = useLocationName({ locationId: monitor.location.id }); const detailUrl = useMonitorDetailLocator({ monitorId: monitor.id, @@ -66,10 +103,7 @@ export function ActionsPopover({ ); const { status, isEnabled, updateMonitorEnabledState } = useMonitorEnableHandler({ id: monitor.id, - reloadPage: useCallback(() => { - dispatch(quietFetchOverviewAction.get(pageState)); - setIsPopoverOpen(false); - }, [dispatch, pageState, setIsPopoverOpen]), + isEnabled: monitor.isEnabled, labels, }); @@ -79,25 +113,69 @@ export function ActionsPopover({ useEffect(() => { if (status === FETCH_STATUS.LOADING) { - setEnableLabel(enableLabelLoading); + setEnableLabel(loadingLabel(monitor.isEnabled)); } else if (status === FETCH_STATUS.SUCCESS) { setEnableLabel(isEnabled ? disableMonitorLabel : enableMonitorLabel); + if (isPopoverOpen) setIsPopoverOpen(false); } - }, [setEnableLabel, status, isEnabled, monitor.isEnabled]); + }, [setEnableLabel, status, isEnabled, monitor.isEnabled, isPopoverOpen, setIsPopoverOpen]); + + const quickInspectPopoverItem = { + name: quickInspectName, + icon: 'inspect', + disabled: !locationName, + onClick: () => { + if (locationName) { + dispatch(setFlyoutConfig({ monitorId: monitor.id, location: locationName })); + setIsPopoverOpen(false); + } + }, + }; + + let popoverItems = [ + { + name: actionsMenuGoToMonitorName, + icon: 'sortRight', + href: detailUrl, + }, + quickInspectPopoverItem, + // not rendering this for now because the manual test flyout is + // still in the design phase + // { + // name: 'Run test manually', + // icon: 'beaker', + // }, + { + name: actionsMenuEditMonitorName, + icon: 'pencil', + href: editUrl, + }, + { + name: enableLabel, + icon: 'invert', + onClick: () => { + if (status !== FETCH_STATUS.LOADING) { + updateMonitorEnabledState(!monitor.isEnabled); + } + }, + }, + ]; + if (isInspectView) popoverItems = popoverItems.filter((i) => i !== quickInspectPopoverItem); return ( - + setIsPopoverOpen((b: boolean) => !b)} - /> + + setIsPopoverOpen((b: boolean) => !b)} + /> + } color="lightestShade" isOpen={isPopoverOpen} @@ -111,48 +189,19 @@ export function ActionsPopover({ { id: '0', title: actionsMenuTitle, - items: [ - { - name: actionsMenuGoToMonitorName, - icon: 'sortRight', - href: detailUrl, - }, - // not rendering this for now because it requires the detail flyout - // which is not merged yet. Also, this needs to be rendered conditionally, - // the actions menu can be opened within the flyout so there is no point in showing this - // if the user is already in the flyout. - // { - // name: 'Quick inspect', - // icon: 'inspect', - // }, - // not rendering this for now because the manual test flyout is - // still in the design phase - // { - // name: 'Run test manually', - // icon: 'beaker', - // }, - { - name: actionsMenuEditMonitorName, - icon: 'pencil', - href: editUrl, - }, - { - name: enableLabel, - icon: 'invert', - onClick: () => { - if (status !== FETCH_STATUS.LOADING) - updateMonitorEnabledState(!monitor.isEnabled); - }, - }, - ], + items: popoverItems, }, ]} /> - + ); } +const quickInspectName = i18n.translate('xpack.synthetics.overview.actions.quickInspect.title', { + defaultMessage: 'Quick inspect', +}); + const openActionsMenuAria = i18n.translate( 'xpack.synthetics.overview.actions.openPopover.ariaLabel', { @@ -183,9 +232,14 @@ const actionsMenuEditMonitorName = i18n.translate( } ); -const enableLabelLoading = i18n.translate('xpack.synthetics.overview.actions.enableLabel', { - defaultMessage: 'Loading...', -}); +const loadingLabel = (isEnabled: boolean) => + isEnabled + ? i18n.translate('xpack.synthetics.overview.actions.disablingLabel', { + defaultMessage: 'Disabling monitor', + }) + : i18n.translate('xpack.synthetics.overview.actions.enablingLabel', { + defaultMessage: 'Enabling monitor', + }); const enableMonitorLabel = i18n.translate( 'xpack.synthetics.overview.actions.enableLabelEnableMonitor', diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/metric_item.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/metric_item.tsx index bf93da5a6adea..c9abcf852c421 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/metric_item.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/metric_item.tsx @@ -30,11 +30,13 @@ export const MetricItem = ({ averageDuration, data, loaded, + onClick, }: { monitor: MonitorOverviewItem; data: Array<{ x: number; y: number }>; averageDuration: number; loaded: boolean; + onClick: (id: string, location: string) => void; }) => { const [isMouseOver, setIsMouseOver] = useState(false); const [isPopoverOpen, setIsPopoverOpen] = useState(false); @@ -45,6 +47,7 @@ export const MetricItem = ({ return (

- + monitor.id && locationName && onClick(monitor.id, locationName)} + baseTheme={DARK_THEME} + /> )} diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/monitor_detail_flyout.test.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/monitor_detail_flyout.test.tsx new file mode 100644 index 0000000000000..34b76441bd52b --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/monitor_detail_flyout.test.tsx @@ -0,0 +1,148 @@ +/* + * 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 { render } from '../../../../utils/testing/rtl_helpers'; +import { fireEvent } from '@testing-library/react'; +import { MonitorDetailFlyout } from './monitor_detail_flyout'; +import * as observabilityPublic from '@kbn/observability-plugin/public'; +import * as monitorDetail from '../../../../hooks/use_monitor_detail'; +import * as statusByLocation from '../../../../hooks/use_status_by_location'; +import * as monitorDetailLocator from '../../hooks/use_monitor_detail_locator'; + +jest.mock('@kbn/observability-plugin/public'); + +describe('Monitor Detail Flyout', () => { + beforeEach(() => { + jest.spyOn(observabilityPublic, 'useFetcher').mockReturnValue({ + status: observabilityPublic.FETCH_STATUS.PENDING, + data: null, + refetch: () => null, + }); + jest.spyOn(monitorDetail, 'useMonitorDetail').mockReturnValue({ + data: { + docId: 'docId', + timestamp: '2013-03-01 12:54:23', + monitor: { + id: 'test-id', + status: 'up', + type: 'http', + check_group: 'check-group', + }, + url: { + full: 'https://www.elastic.co', + }, + }, + }); + jest.spyOn(statusByLocation, 'useStatusByLocation').mockReturnValue({ + locations: [], + loading: false, + }); + }); + + afterEach(() => jest.clearAllMocks()); + + it('close prop is called for built-in flyout close', () => { + const onCloseMock = jest.fn(); + const { getByLabelText } = render( + + ); + const closeButton = getByLabelText('Close this dialog'); + fireEvent.click(closeButton); + expect(onCloseMock).toHaveBeenCalledTimes(1); + }); + + it('renders error boundary for fetch failure', () => { + const testErrorText = 'This is a test error'; + jest.spyOn(observabilityPublic, 'useFetcher').mockReturnValue({ + status: observabilityPublic.FETCH_STATUS.FAILURE, + error: new Error('This is a test error'), + refetch: () => null, + }); + + const { getByText } = render( + + ); + getByText(testErrorText); + }); + + it('renders loading state while fetching', () => { + jest.spyOn(observabilityPublic, 'useFetcher').mockReturnValue({ + status: observabilityPublic.FETCH_STATUS.LOADING, + refetch: jest.fn(), + }); + + const { getByRole } = render( + + ); + + expect(getByRole('progressbar')); + }); + + it('renders details for fetch success', () => { + jest.spyOn(observabilityPublic, 'useFetcher').mockReturnValue({ + status: observabilityPublic.FETCH_STATUS.SUCCESS, + data: { + attributes: { + enabled: true, + name: 'test-monitor', + schedule: { + number: '1', + unit: 'm', + }, + }, + tags: ['prod'], + type: 'browser', + updated_at: '1996-02-27', + }, + refetch: jest.fn(), + }); + const detailLink = '/app/synthetics/monitor/test-id'; + jest.spyOn(monitorDetailLocator, 'useMonitorDetailLocator').mockReturnValue(detailLink); + + const { getByRole, getByText, getAllByRole } = render( + + ); + + expect(getByText('Every 1 minute')); + expect(getByText('test-id')); + expect(getByText('Up')); + expect( + getByRole('heading', { + level: 2, + }) + ).toHaveTextContent('test-monitor'); + const links = getAllByRole('link'); + expect(links).toHaveLength(2); + expect(links[0]).toHaveAttribute('href', 'https://www.elastic.co'); + expect(links[1]).toHaveAttribute('href', detailLink); + }); +}); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/monitor_detail_flyout.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/monitor_detail_flyout.tsx new file mode 100644 index 0000000000000..be5a979bc6e77 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/monitor_detail_flyout.tsx @@ -0,0 +1,584 @@ +/* + * 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 { + EuiBadge, + EuiBadgeGroup, + EuiButton, + EuiButtonEmpty, + EuiButtonIcon, + EuiContextMenu, + EuiDescriptionList, + EuiDescriptionListDescription, + EuiDescriptionListProps, + EuiDescriptionListTitle, + EuiErrorBoundary, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiHealth, + EuiLink, + EuiLoadingSpinner, + EuiPageSection, + EuiPanel, + EuiPopover, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; +import { SavedObject } from '@kbn/core/public'; +import { FetcherResult } from '@kbn/observability-plugin/public/hooks/use_fetcher'; +import { i18n } from '@kbn/i18n'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { FETCH_STATUS, useFetcher } from '@kbn/observability-plugin/public'; +import moment from 'moment'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { capitalize } from 'lodash'; +import { useTheme } from '@kbn/observability-plugin/public'; +import { useKibanaDateFormat } from '../../../../../../hooks/use_kibana_date_format'; +import { ClientPluginsStart } from '../../../../../../plugin'; +import { useStatusByLocation } from '../../../../hooks/use_status_by_location'; +import { MonitorEnabled } from '../../management/monitor_list_table/monitor_enabled'; +import { ActionsPopover } from './actions_popover'; +import { selectOverviewState } from '../../../../state'; +import { useMonitorDetail } from '../../../../hooks/use_monitor_detail'; +import { + ConfigKey, + EncryptedSyntheticsMonitor, + MonitorOverviewItem, + SyntheticsMonitor, +} from '../types'; +import { useMonitorDetailLocator } from '../../hooks/use_monitor_detail_locator'; +import { fetchSyntheticsMonitor } from '../../../../state/overview/api'; + +interface Props { + id: string; + location: string; + onClose: () => void; + onEnabledChange: () => void; + onLocationChange: (id: string, location: string) => void; + currentDurationChartFrom?: string; + currentDurationChartTo?: string; + previousDurationChartFrom?: string; + previousDurationChartTo?: string; +} + +const DEFAULT_DURATION_CHART_FROM = 'now-12h'; +const DEFAULT_CURRENT_DURATION_CHART_TO = 'now'; +const DEFAULT_PREVIOUS_DURATION_CHART_FROM = 'now-24h'; +const DEFAULT_PREVIOUS_DURATION_CHART_TO = 'now-12h'; + +function DetailFlyoutDurationChart({ + id, + location, + currentDurationChartFrom, + currentDurationChartTo, + previousDurationChartFrom, + previousDurationChartTo, +}: Pick< + Props, + | 'id' + | 'location' + | 'currentDurationChartFrom' + | 'currentDurationChartTo' + | 'previousDurationChartFrom' + | 'previousDurationChartTo' +>) { + const theme = useTheme(); + + const { observability } = useKibana().services; + const { ExploratoryViewEmbeddable } = observability; + return ( + + +

{DURATION_HEADER_TEXT}

+
+ +
+ ); +} + +function LocationSelect({ + locations, + currentLocation, + id, + setCurrentLocation, + monitor, + onEnabledChange, +}: { + locations: ReturnType['locations']; + currentLocation: string; + id: string; + monitor: EncryptedSyntheticsMonitor; + onEnabledChange: () => void; + setCurrentLocation: (location: string) => void; +}) { + const [isOpen, setIsOpen] = useState(false); + const isDown = !!locations.find((l) => l.observer?.geo?.name === currentLocation)?.summary?.down; + return ( + + + + {ENABLED_ITEM_TEXT} + + + + + + + + {LOCATION_TITLE_TEXT} + + {currentLocation} + setIsOpen(!isOpen)} + size="xs" + /> + } + isOpen={isOpen} + closePopover={() => setIsOpen(false)} + panelPaddingSize="none" + > + { + return { + name: l.observer?.geo?.name, + icon: , + disabled: !l.observer?.geo?.name || l.observer.geo.name === currentLocation, + onClick: () => { + if (l.observer?.geo?.name && currentLocation !== l.observer.geo.name) + setCurrentLocation(l.observer?.geo?.name); + }, + }; + }), + }, + ]} + /> + + + + + + + {STATUS_TITLE_TEXT} + + + {isDown ? MONITOR_STATUS_DOWN_LABEL : MONITOR_STATUS_UP_LABEL} + + + + + + ); +} + +export function MonitorDetailFlyout(props: Props) { + const { id, onLocationChange } = props; + const { + data: { monitors }, + } = useSelector(selectOverviewState); + + const monitor: MonitorOverviewItem | undefined = useMemo(() => { + const overviewItem = monitors.filter(({ id: overviewItemId }) => overviewItemId === id)[0]; + if (overviewItem) return overviewItem; + }, [id, monitors]); + + const setLocation = useCallback( + (location: string) => onLocationChange(id, location), + [id, onLocationChange] + ); + + const detailLink = useMonitorDetailLocator({ + monitorId: id, + }); + + const { + data: monitorSavedObject, + error, + status, + }: FetcherResult> = useFetcher( + () => fetchSyntheticsMonitor(id), + [id] + ); + + const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false); + + const monitorDetail = useMonitorDetail(id, props.location); + const locationStatuses = useStatusByLocation(id); + const locations = locationStatuses.locations?.filter((l: any) => !!l?.observer?.geo?.name) ?? []; + + return ( + + {status === FETCH_STATUS.FAILURE && {error?.message}} + {status === FETCH_STATUS.LOADING && } + {status === FETCH_STATUS.SUCCESS && monitorSavedObject && ( + <> + + + + + +

{monitorSavedObject?.attributes[ConfigKey.NAME]}

+
+
+ + {monitor && ( + + )} + +
+ + +
+
+ + + + +

{MONITOR_DETAILS_HEADER_TEXT}

+
+ + + {monitorDetail.data.url.full} + + ) : ( + '' + ), + }, + { + title: LAST_RUN_HEADER_TEXT, + description:
+
+ + + + + {CLOSE_FLYOUT_TEXT} + + + + {GO_TO_MONITOR_LINK_TEXT} + + + + + + + )} +
+ ); +} + +function freqeuncyStr(frequency: { number: string; unit: string }) { + return translateUnitMessage( + `${frequency.number} ${unitToString(frequency.unit, parseInt(frequency.number, 10))}` + ); +} + +const Time = ({ timestamp }: { timestamp?: string }) => { + const formatStr = useKibanaDateFormat(); + + return timestamp ? : null; +}; + +function unitToString(unit: string, n: number) { + switch (unit) { + case 's': + return secondsString(n); + case 'm': + return minutesString(n); + case 'h': + return hoursString(n); + case 'd': + return daysString(n); + default: + return unit; + } +} + +const secondsString = (n: number) => + i18n.translate('xpack.synthetics.monitorDetail.seconds', { + defaultMessage: '{n, plural, one {second} other {seconds}}', + values: { n }, + }); + +const minutesString = (n: number) => + i18n.translate('xpack.synthetics.monitorDetail.minutes', { + defaultMessage: '{n, plural, one {minute} other {minutes}}', + values: { n }, + }); + +const hoursString = (n: number) => + i18n.translate('xpack.synthetics.monitorDetail.hours', { + defaultMessage: '{n, plural, one {hour} other {hours}}', + values: { n }, + }); + +const daysString = (n: number) => + i18n.translate('xpack.synthetics.monitorDetail.days', { + defaultMessage: '{n, plural, one {day} other {days}}', + values: { n }, + }); + +const URL_HEADER_TEXT = i18n.translate('xpack.synthetics.monitorList.urlHeaderText', { + defaultMessage: 'URL', +}); + +const TAGS_HEADER_TEXT = i18n.translate('xpack.synthetics.monitorList.tagsHeaderText', { + defaultMessage: 'Tags', +}); + +const FREQUENCY_HEADER_TEXT = i18n.translate('xpack.synthetics.monitorList.frequencyHeaderText', { + defaultMessage: 'Frequency', +}); + +const MONITOR_TYPE_HEADER_TEXT = i18n.translate('xpack.synthetics.monitorList.monitorType', { + defaultMessage: 'Monitor type', +}); + +const LAST_MODIFIED_HEADER_TEXT = i18n.translate('xpack.synthetics.monitorList.lastModified', { + defaultMessage: 'Last modified', +}); + +const LAST_RUN_HEADER_TEXT = i18n.translate('xpack.synthetics.monitorList.lastRunHeaderText', { + defaultMessage: 'Last run', +}); + +const STATUS_TITLE_TEXT = i18n.translate('xpack.synthetics.monitorList.statusColumnName', { + defaultMessage: 'Status', +}); + +const LOCATION_TITLE_TEXT = i18n.translate('xpack.synthetics.monitorList.locationColumnName', { + defaultMessage: 'Location', +}); + +const DURATION_HEADER_TEXT = i18n.translate('xpack.synthetics.monitorList.durationHeaderText', { + defaultMessage: 'Duration', +}); + +const DURATION_SERIES_NAME = i18n.translate( + 'xpack.synthetics.monitorList.durationChart.durationSeriesName', + { + defaultMessage: 'Duration', + } +); + +const PREVIOUS_PERIOD_SERIES_NAME = i18n.translate( + 'xpack.synthetics.monitorList.durationChart.previousPeriodSeriesName', + { + defaultMessage: 'Previous period', + } +); + +const MONITOR_DETAILS_HEADER_TEXT = i18n.translate( + 'xpack.synthetics.monitorList.monitorDetailsHeaderText', + { + defaultMessage: 'Monitor Details', + } +); + +const ENABLED_ITEM_TEXT = i18n.translate('xpack.synthetics.monitorList.enabledItemText', { + defaultMessage: 'Enabled', +}); + +const PROJECT_ID_HEADER_TEXT = i18n.translate('xpack.synthetics.monitorList.projectIdHeaderText', { + defaultMessage: 'Project ID', +}); + +const MONITOR_ID_ITEM_TEXT = i18n.translate('xpack.synthetics.monitorList.monitorIdItemText', { + defaultMessage: 'Monitor ID', +}); + +const CLOSE_FLYOUT_TEXT = i18n.translate('xpack.synthetics.monitorList.closeFlyoutText', { + defaultMessage: 'Close', +}); + +const GO_TO_MONITOR_LINK_TEXT = i18n.translate('xpack.synthetics.monitorList.goToMonitorLinkText', { + defaultMessage: 'Go to monitor', +}); + +const GO_TO_LOCATIONS_LABEL = i18n.translate( + 'xpack.synthetics.monitorList.flyoutHeader.goToLocations', + { + defaultMessage: 'Go to location', + } +); + +const LOCATION_SELECT_POPOVER_ICON_BUTTON_LABEL = i18n.translate( + 'xpack.synthetics.monitorList.flyout.locationSelect.iconButton.label', + { + defaultMessage: + "This icon button opens a context menu that will allow you to change the monitor's selected location. If you change the location, the flyout will display metrics for the monitor's performance in that location.", + } +); + +const MONITOR_STATUS_UP_LABEL = i18n.translate( + 'xpack.synthetics.monitorList.flyout.monitorStatus.up', + { + defaultMessage: 'Up', + description: '"Up" in the sense that a process is running and available.', + } +); + +const MONITOR_STATUS_DOWN_LABEL = i18n.translate( + 'xpack.synthetics.monitorList.flyout.monitorStatus.down', + { + defaultMessage: 'Down', + description: '"Down" in the sense that a process is not running or available.', + } +); + +function translateUnitMessage(unitMsg: string) { + return i18n.translate('xpack.synthetics.monitorList.flyout.unitStr', { + defaultMessage: 'Every {unitMsg}', + values: { unitMsg }, + description: 'This displays a message like "Every 10 minutes" or "Every 30 seconds"', + }); +} diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid.tsx index cf3163052bd73..cd8b32fafdeeb 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid.tsx @@ -4,10 +4,10 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useEffect, useState, useRef } from 'react'; +import React, { useEffect, useState, useRef, useCallback } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; import { i18n } from '@kbn/i18n'; import useThrottle from 'react-use/lib/useThrottle'; -import { useSelector } from 'react-redux'; import useIntersection from 'react-use/lib/useIntersection'; import { EuiFlexGroup, @@ -17,18 +17,24 @@ import { EuiButtonEmpty, EuiText, } from '@elastic/eui'; -import { selectOverviewState } from '../../../../state/overview'; import { MonitorOverviewItem } from '../../../../../../../common/runtime_types'; +import { + quietFetchOverviewAction, + selectOverviewState, + setFlyoutConfig, +} from '../../../../state/overview'; import { OverviewPaginationInfo } from './overview_pagination_info'; import { OverviewGridItem } from './overview_grid_item'; import { SortFields } from './sort_fields'; import { useMonitorsSortedByStatus } from '../../../../hooks/use_monitors_sorted_by_status'; import { OverviewLoader } from './overview_loader'; +import { MonitorDetailFlyout } from './monitor_detail_flyout'; import { OverviewStatus } from './overview_status'; export const OverviewGrid = () => { const { data: { monitors }, + flyoutConfig, loaded, pageState, } = useSelector(selectOverviewState); @@ -47,6 +53,17 @@ export const OverviewGrid = () => { sortField, }); + const dispatch = useDispatch(); + + const setFlyoutConfigCallback = useCallback( + (monitorId: string, location: string) => dispatch(setFlyoutConfig({ monitorId, location })), + [dispatch] + ); + const hideFlyout = useCallback(() => dispatch(setFlyoutConfig(null)), [dispatch]); + const forceRefreshCallback = useCallback( + () => dispatch(quietFetchOverviewAction.get(pageState)), + [dispatch, pageState] + ); const intersectionRef = useRef(null); const intersection = useIntersection(intersectionRef, { root: null, @@ -98,7 +115,7 @@ export const OverviewGrid = () => { key={`${monitor.id}-${monitor.location?.id}`} data-test-subj="syntheticsOverviewGridItem" > - + ))} @@ -127,6 +144,15 @@ export const OverviewGrid = () => { )} + {flyoutConfig?.monitorId && flyoutConfig?.location && ( + + )} ); }; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid_item.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid_item.tsx index d9141c5cbeb85..4265ac57b1432 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid_item.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid_item.tsx @@ -9,12 +9,24 @@ import { MetricItem } from './metric_item'; import { useLast50DurationChart } from '../../../../hooks'; import { MonitorOverviewItem } from '../../../../../../../common/runtime_types'; -export const OverviewGridItem = ({ monitor }: { monitor: MonitorOverviewItem }) => { +export const OverviewGridItem = ({ + monitor, + onClick, +}: { + monitor: MonitorOverviewItem; + onClick: (id: string, location: string) => void; +}) => { const { data, loading, averageDuration } = useLast50DurationChart({ locationId: monitor.location?.id, monitorId: monitor.id, }); return ( - + ); }; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_monitor_detail.ts b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_monitor_detail.ts new file mode 100644 index 0000000000000..63a9f556f4b2b --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_monitor_detail.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SearchRequest } from '@elastic/elasticsearch/lib/api/types'; +import { useEsSearch } from '@kbn/observability-plugin/public'; +import { SYNTHETICS_INDEX_PATTERN } from '../../../../common/constants'; +import { Ping } from '../../../../common/runtime_types'; + +export const useMonitorDetail = ( + monitorId: string, + location: string +): { data?: Ping; loading?: boolean } => { + const params = { + index: SYNTHETICS_INDEX_PATTERN, + body: { + size: 1, + query: { + bool: { + filter: [ + { + term: { + config_id: monitorId, + }, + }, + { + term: { + 'observer.geo.name': location, + }, + }, + { + exists: { + field: 'summary', + }, + }, + ], + }, + }, + sort: [{ '@timestamp': 'desc' }], + }, + }; + const { data: result, loading } = useEsSearch( + params, + [monitorId, location], + { + name: 'getMonitorStatusByLocation', + } + ); + + if (!result || result.hits.hits.length !== 1) return { data: undefined, loading }; + return { + data: { ...result.hits.hits[0]._source, timestamp: result.hits.hits[0]._source['@timestamp'] }, + loading, + }; +}; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_monitor_enable_handler.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_monitor_enable_handler.tsx index e00b54ec4b75d..0049fcdad8fad 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_monitor_enable_handler.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_monitor_enable_handler.tsx @@ -5,16 +5,11 @@ * 2.0. */ -import { useKibana } from '@kbn/kibana-react-plugin/public'; import { FETCH_STATUS } from '@kbn/observability-plugin/public'; -import React, { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { ConfigKey } from '../components/monitors_page/overview/types'; -import { - clearMonitorUpsertStatus, - fetchUpsertMonitorAction, - selectMonitorUpsertStatuses, -} from '../state'; +import { fetchUpsertMonitorAction, selectMonitorUpsertStatuses } from '../state'; export interface EnableStateMonitorLabels { failureLabel: string; @@ -28,59 +23,54 @@ export function useMonitorEnableHandler({ labels, }: { id: string; - reloadPage: () => void; - labels?: EnableStateMonitorLabels; + isEnabled: boolean; + reloadPage?: () => void; + labels: EnableStateMonitorLabels; }) { const dispatch = useDispatch(); const upsertStatuses = useSelector(selectMonitorUpsertStatuses); - const status = upsertStatuses[id]?.status; - const savedObjEnabledState = upsertStatuses[id]?.enabled; - const [isEnabled, setIsEnabled] = useState(null); + const status: FETCH_STATUS | undefined = upsertStatuses[id]?.status; + const [nextEnabled, setNextEnabled] = useState(null); + + useEffect(() => { + if (status === FETCH_STATUS.FAILURE) { + setNextEnabled(null); + } + }, [setNextEnabled, status]); + const updateMonitorEnabledState = useCallback( (enabled: boolean) => { dispatch( fetchUpsertMonitorAction({ id, monitor: { [ConfigKey.ENABLED]: enabled }, + success: { + message: enabled ? labels.enabledSuccessLabel : labels.disabledSuccessLabel, + lifetimeMs: 3000, + testAttribute: 'uptimeMonitorEnabledUpdateSuccess', + }, + error: { + message: { + title: labels.failureLabel, + }, + lifetimeMs: 10000, + testAttribute: 'uptimeMonitorEnabledUpdateFailure', + }, }) ); + setNextEnabled(enabled); + if (reloadPage) reloadPage(); }, - [dispatch, id] + [ + dispatch, + id, + labels.disabledSuccessLabel, + labels.enabledSuccessLabel, + labels.failureLabel, + setNextEnabled, + reloadPage, + ] ); - const { notifications } = useKibana(); - - useEffect(() => { - if (status === FETCH_STATUS.SUCCESS && labels) { - notifications.toasts.success({ - title: ( -

- {savedObjEnabledState ? labels.enabledSuccessLabel : labels.disabledSuccessLabel} -

- ), - toastLifeTimeMs: 3000, - }); - setIsEnabled(!!savedObjEnabledState); - dispatch(clearMonitorUpsertStatus(id)); - reloadPage(); - } else if (status === FETCH_STATUS.FAILURE && labels) { - notifications.toasts.danger({ - title:

{labels.failureLabel}

, - toastLifeTimeMs: 3000, - }); - setIsEnabled(null); - dispatch(clearMonitorUpsertStatus(id)); - } - }, [ - status, - labels, - notifications.toasts, - isEnabled, - dispatch, - id, - reloadPage, - savedObjEnabledState, - ]); - - return { isEnabled, updateMonitorEnabledState, status }; + return { isEnabled: nextEnabled, updateMonitorEnabledState, status }; } diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_list/actions.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_list/actions.ts index 5a8c38284e034..6cb8ddad47aac 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_list/actions.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_list/actions.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { ErrorToastOptions } from '@kbn/core-notifications-browser'; import { createAction } from '@reduxjs/toolkit'; import { EncryptedSyntheticsMonitor, @@ -20,10 +21,24 @@ export const fetchMonitorListAction = createAsyncAction< MonitorManagementListResult >('fetchMonitorListAction'); +interface ToastParams { + message: MessageType; + lifetimeMs: number; + testAttribute?: string; +} + export interface UpsertMonitorRequest { id: string; monitor: Partial; + success: ToastParams; + error: ToastParams; + /** + * The effect will perform a quiet refresh of the overview state + * after a successful upsert. The default behavior is to perform the fetch. + */ + shouldQuietFetchAfterSuccess?: boolean; } + export const fetchUpsertMonitorAction = createAction('fetchUpsertMonitor'); export const fetchUpsertSuccessAction = createAction<{ id: string; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_list/effects.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_list/effects.ts index 67aaa4ec982ed..764fd0640f67d 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_list/effects.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_list/effects.ts @@ -6,10 +6,15 @@ */ import { PayloadAction } from '@reduxjs/toolkit'; -import { call, put, takeEvery, takeLeading } from 'redux-saga/effects'; +import { call, put, takeEvery, takeLeading, select } from 'redux-saga/effects'; +import { kibanaService } from '../../../../utils/kibana_service'; +import { MonitorOverviewPageState } from '../overview'; +import { quietFetchOverviewAction } from '../overview/actions'; +import { selectOverviewState } from '../overview/selectors'; import { fetchEffectFactory } from '../utils/fetch_effect'; import { serializeHttpFetchError } from '../utils/http_error'; import { + clearMonitorUpsertStatus, fetchMonitorListAction, fetchUpsertFailureAction, fetchUpsertMonitorAction, @@ -17,6 +22,7 @@ import { UpsertMonitorRequest, } from './actions'; import { fetchMonitorManagementList, fetchUpsertMonitor } from './api'; +import { toastTitle } from './toast_title'; export function* fetchMonitorListEffect() { yield takeLeading( @@ -38,11 +44,36 @@ export function* upsertMonitorEffect() { yield put( fetchUpsertSuccessAction(response as { id: string; attributes: { enabled: boolean } }) ); + kibanaService.toasts.addSuccess({ + title: toastTitle({ + title: action.payload.success.message, + testAttribute: action.payload.success.testAttribute, + }), + toastLifeTimeMs: action.payload.success.lifetimeMs, + }); } catch (error) { + kibanaService.toasts.addError(error, { + ...action.payload.error.message, + toastLifeTimeMs: action.payload.error.lifetimeMs, + }); yield put( fetchUpsertFailureAction({ id: action.payload.id, error: serializeHttpFetchError(error) }) ); + } finally { + if (action.payload.shouldQuietFetchAfterSuccess !== false) { + const monitorState = yield select(selectOverviewState); + if (hasPageState(monitorState)) { + yield put( + quietFetchOverviewAction.get(monitorState.pageState as MonitorOverviewPageState) + ); + } + } + yield put(clearMonitorUpsertStatus(action.payload.id)); } } ); } + +function hasPageState(value: any): value is { pageState: MonitorOverviewPageState } { + return Object.keys(value).includes('pageState'); +} diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_list/toast_title.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_list/toast_title.tsx new file mode 100644 index 0000000000000..0ff25d5773732 --- /dev/null +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/monitor_list/toast_title.tsx @@ -0,0 +1,13 @@ +/* + * 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 { toMountPoint } from '@kbn/kibana-react-plugin/public'; +import React from 'react'; + +export function toastTitle({ title, testAttribute }: { title: string; testAttribute?: string }) { + return toMountPoint(

{title}

); +} diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/overview/actions.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/overview/actions.ts index d94ab2d965216..9bf7869c4ed9f 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/state/overview/actions.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/overview/actions.ts @@ -7,7 +7,7 @@ import { createAction } from '@reduxjs/toolkit'; import { createAsyncAction } from '../utils/actions'; -import { MonitorOverviewPageState } from './models'; +import { MonitorOverviewFlyoutConfig, MonitorOverviewPageState } from './models'; import { MonitorOverviewResult, OverviewStatus } from '../../../../../common/runtime_types'; export const fetchMonitorOverviewAction = createAsyncAction< @@ -19,6 +19,8 @@ export const setOverviewPageStateAction = createAction('setFlyoutConfig'); + export const quietFetchOverviewAction = createAsyncAction< MonitorOverviewPageState, MonitorOverviewResult diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/overview/api.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/overview/api.ts index db470a18b0423..bb678eeae7262 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/state/overview/api.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/overview/api.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { SavedObject } from '@kbn/core/types'; import { SYNTHETICS_API_URLS } from '../../../../../common/constants'; import { MonitorOverviewResult, @@ -13,8 +14,15 @@ import { OverviewStatusType, } from '../../../../../common/runtime_types'; import { apiService } from '../../../../utils/api_service'; - import { MonitorOverviewPageState } from './models'; +import { SyntheticsMonitor } from '../../../../../common/runtime_types'; +import { API_URLS } from '../../../../../common/constants'; + +export const fetchSyntheticsMonitor = async ( + monitorId: string +): Promise> => { + return apiService.get(`${API_URLS.SYNTHETICS_MONITORS}/${monitorId}`); +}; export const fetchMonitorOverview = async ( pageState: MonitorOverviewPageState diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/overview/index.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/overview/index.ts index 8f2031480f115..83cbc7bdbdecc 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/state/overview/index.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/overview/index.ts @@ -11,18 +11,20 @@ import { MonitorOverviewResult, OverviewStatus } from '../../../../../common/run import { IHttpSerializedFetchError } from '../utils/http_error'; -import { MonitorOverviewPageState } from './models'; +import { MonitorOverviewPageState, MonitorOverviewFlyoutConfig } from './models'; import { clearOverviewStatusErrorAction, fetchMonitorOverviewAction, fetchOverviewStatusAction, quietFetchOverviewAction, + setFlyoutConfig, setOverviewPageStateAction, } from './actions'; export interface MonitorOverviewState { data: MonitorOverviewResult; pageState: MonitorOverviewPageState; + flyoutConfig: MonitorOverviewFlyoutConfig; loading: boolean; loaded: boolean; error: IHttpSerializedFetchError | null; @@ -41,6 +43,7 @@ const initialState: MonitorOverviewState = { sortOrder: 'asc', sortField: 'status', }, + flyoutConfig: null, loading: false, loaded: false, error: null, @@ -77,6 +80,9 @@ export const monitorOverviewReducer = createReducer(initialState, (builder) => { }; state.loaded = false; }) + .addCase(setFlyoutConfig, (state, action) => { + state.flyoutConfig = action.payload; + }) .addCase(fetchOverviewStatusAction.success, (state, action) => { state.status = action.payload; }) diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/state/overview/models.ts b/x-pack/plugins/synthetics/public/apps/synthetics/state/overview/models.ts index 6cc423a3bddda..6b1549c098c2a 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/state/overview/models.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/state/overview/models.ts @@ -10,3 +10,8 @@ export interface MonitorOverviewPageState { sortOrder: 'asc' | 'desc'; sortField: string; } + +export type MonitorOverviewFlyoutConfig = { + monitorId: string; + location: string; +} | null; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts b/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts index cd1223ac6df4b..2c9953a51a26e 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/utils/testing/__mocks__/synthetics_store.mock.ts @@ -98,6 +98,7 @@ export const mockState: SyntheticsAppState = { error: null, loaded: false, loading: false, + flyoutConfig: null, status: null, statusError: null, }, From 21326b1eaeb1e36845111dcb6c945485222cb830 Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Mon, 7 Nov 2022 12:23:57 -0600 Subject: [PATCH 020/192] Bump terser to 5.15.1 (#139519) --- package.json | 2 +- yarn.lock | 120 +++++---------------------------------------------- 2 files changed, 11 insertions(+), 111 deletions(-) diff --git a/package.json b/package.json index 735fec31c6b0b..a8a40fca02780 100644 --- a/package.json +++ b/package.json @@ -1115,7 +1115,7 @@ "svgo": "^2.8.0", "tape": "^5.0.1", "tempy": "^0.3.0", - "terser": "^5.14.1", + "terser": "^5.15.1", "terser-webpack-plugin": "^4.2.3", "tough-cookie": "^4.1.2", "tree-kill": "^1.2.2", diff --git a/yarn.lock b/yarn.lock index 3c71c72ec6979..a9f90acfc547d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2413,13 +2413,6 @@ "@types/node" "*" jest-mock "^27.5.1" -"@jest/expect-utils@^28.1.1": - version "28.1.1" - resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-28.1.1.tgz#d84c346025b9f6f3886d02c48a6177e2b0360587" - integrity sha512-n/ghlvdhCdMI/hTcnn4qV57kQuV9OTsZzH1TTCVARANKhl6hXJqLKUkwX69ftMGpsbpt96SsDD8n8LD2d9+FRw== - dependencies: - jest-get-type "^28.0.2" - "@jest/expect-utils@^28.1.3": version "28.1.3" resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-28.1.3.tgz#58561ce5db7cd253a7edddbc051fb39dda50f525" @@ -2479,13 +2472,6 @@ terminal-link "^2.0.0" v8-to-istanbul "^8.1.0" -"@jest/schemas@^28.0.2": - version "28.0.2" - resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-28.0.2.tgz#08c30df6a8d07eafea0aef9fb222c5e26d72e613" - integrity sha512-YVDJZjd4izeTDkij00vHHAymNXQ6WWsdChFRK86qck6Jpr3DCL5W3Is3vslviRlP+bLuMYRLbdp98amMvqudhA== - dependencies: - "@sinclair/typebox" "^0.23.3" - "@jest/schemas@^28.1.3": version "28.1.3" resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-28.1.3.tgz#ad8b86a66f11f33619e3d7e1dcddd7f2d40ff905" @@ -2586,7 +2572,7 @@ "@types/yargs" "^16.0.0" chalk "^4.0.0" -"@jest/types@^28.1.1", "@jest/types@^28.1.3": +"@jest/types@^28.1.3": version "28.1.3" resolved "https://registry.yarnpkg.com/@jest/types/-/types-28.1.3.tgz#b05de80996ff12512bc5ceb1d208285a7d11748b" integrity sha512-RyjiyMUZrKz/c+zlMFO1pm70DcIlST8AeWTkoUdZevew44wcNZQHsEVOiCVtgVnlFFD82FPaXycys58cf2muVQ== @@ -4901,11 +4887,6 @@ resolved "https://registry.yarnpkg.com/@sideway/pinpoint/-/pinpoint-2.0.0.tgz#cff8ffadc372ad29fd3f78277aeb29e632cc70df" integrity sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ== -"@sinclair/typebox@^0.23.3": - version "0.23.5" - resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.23.5.tgz#93f7b9f4e3285a7a9ade7557d9a8d36809cbc47d" - integrity sha512-AFBVi/iT4g20DHoujvMH1aEDn8fGJh4xsRGCP6d8RpLPMqsNPvW01Jcn0QysXTsg++/xj25NmJsGyH9xug/wKg== - "@sinclair/typebox@^0.24.1": version "0.24.46" resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.24.46.tgz#57501b58023776dbbae9e25619146286440be34c" @@ -12507,15 +12488,7 @@ domutils@^2.0.0, domutils@^2.5.2, domutils@^2.6.0, domutils@^2.7.0: domelementtype "^2.2.0" domhandler "^4.2.0" -dot-case@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/dot-case/-/dot-case-3.0.3.tgz#21d3b52efaaba2ea5fda875bb1aa8124521cf4aa" - integrity sha512-7hwEmg6RiSQfm/GwPL4AAWXKy3YNNZA3oFv2Pdiey0mwkRCPZ9x6SZbkLcn8Ma5PYeVokzoD4Twv2n7LKp5WeA== - dependencies: - no-case "^3.0.3" - tslib "^1.10.0" - -dot-case@^3.0.4: +dot-case@^3.0.3, dot-case@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/dot-case/-/dot-case-3.0.4.tgz#9b2b670d00a431667a8a75ba29cd1b98809ce751" integrity sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w== @@ -13793,18 +13766,7 @@ expect@^27.5.1: jest-matcher-utils "^27.5.1" jest-message-util "^27.5.1" -expect@^28.1.1: - version "28.1.1" - resolved "https://registry.yarnpkg.com/expect/-/expect-28.1.1.tgz#ca6fff65f6517cf7220c2e805a49c19aea30b420" - integrity sha512-/AANEwGL0tWBwzLNOvO0yUdy2D52jVdNXppOqswC49sxMN2cPWsGCQdzuIf9tj6hHoBQzNvx75JUYuQAckPo3w== - dependencies: - "@jest/expect-utils" "^28.1.1" - jest-get-type "^28.0.2" - jest-matcher-utils "^28.1.1" - jest-message-util "^28.1.1" - jest-util "^28.1.1" - -expect@^28.1.3: +expect@^28.1.1, expect@^28.1.3: version "28.1.3" resolved "https://registry.yarnpkg.com/expect/-/expect-28.1.3.tgz#90a7c1a124f1824133dd4533cce2d2bdcb6603ec" integrity sha512-eEh0xn8HlsuOBxFgIss+2mX85VAS4Qy3OSkjV7rlBWljtA4oWH37glVGyOZSZvErDT/yBywZdPGwCXuTvSG85g== @@ -17058,16 +17020,6 @@ jest-diff@^27.0.2, jest-diff@^27.5.1: jest-get-type "^27.5.1" pretty-format "^27.5.1" -jest-diff@^28.1.1: - version "28.1.1" - resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-28.1.1.tgz#1a3eedfd81ae79810931c63a1d0f201b9120106c" - integrity sha512-/MUUxeR2fHbqHoMMiffe/Afm+U8U4olFRJ0hiVG2lZatPJcnGxx292ustVu7bULhjV65IYMxRdploAKLbcrsyg== - dependencies: - chalk "^4.0.0" - diff-sequences "^28.1.1" - jest-get-type "^28.0.2" - pretty-format "^28.1.1" - jest-diff@^28.1.3: version "28.1.3" resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-28.1.3.tgz#948a192d86f4e7a64c5264ad4da4877133d8792f" @@ -17238,16 +17190,6 @@ jest-matcher-utils@^27.0.0, jest-matcher-utils@^27.5.1: jest-get-type "^27.5.1" pretty-format "^27.5.1" -jest-matcher-utils@^28.1.1: - version "28.1.1" - resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-28.1.1.tgz#a7c4653c2b782ec96796eb3088060720f1e29304" - integrity sha512-NPJPRWrbmR2nAJ+1nmnfcKKzSwgfaciCCrYZzVnNoxVoyusYWIjkBMNvu0RHJe7dNj4hH3uZOPZsQA+xAYWqsw== - dependencies: - chalk "^4.0.0" - jest-diff "^28.1.1" - jest-get-type "^28.0.2" - pretty-format "^28.1.1" - jest-matcher-utils@^28.1.3: version "28.1.3" resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-28.1.3.tgz#5a77f1c129dd5ba3b4d7fc20728806c78893146e" @@ -17288,7 +17230,7 @@ jest-message-util@^27.5.1: slash "^3.0.0" stack-utils "^2.0.3" -jest-message-util@^28.1.1, jest-message-util@^28.1.3: +jest-message-util@^28.1.3: version "28.1.3" resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-28.1.3.tgz#232def7f2e333f1eecc90649b5b94b0055e7c43d" integrity sha512-PFdn9Iewbt575zKPf1286Ht9EPoJmYT7P0kY+RibeYZ2XtOr53pDLEFoTWXbd1h4JiGiWpTBC84fc8xMXQMb7g== @@ -17544,18 +17486,6 @@ jest-util@^27.5.1: graceful-fs "^4.2.9" picomatch "^2.2.3" -jest-util@^28.1.1: - version "28.1.1" - resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-28.1.1.tgz#ff39e436a1aca397c0ab998db5a51ae2b7080d05" - integrity sha512-FktOu7ca1DZSyhPAxgxB6hfh2+9zMoJ7aEQA759Z6p45NuO8mWcqujH+UdHlCm/V6JTWwDztM2ITCzU1ijJAfw== - dependencies: - "@jest/types" "^28.1.1" - "@types/node" "*" - chalk "^4.0.0" - ci-info "^3.2.0" - graceful-fs "^4.2.9" - picomatch "^2.2.3" - jest-util@^28.1.3: version "28.1.3" resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-28.1.3.tgz#f4f932aa0074f0679943220ff9cbba7e497028b0" @@ -18627,13 +18557,6 @@ loud-rejection@^1.0.0: currently-unhandled "^0.4.1" signal-exit "^3.0.0" -lower-case@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-2.0.1.tgz#39eeb36e396115cc05e29422eaea9e692c9408c7" - integrity sha512-LiWgfDLLb1dwbFQZsSglpRj+1ctGnayXz3Uv0/WO8n558JycT5fg6zkNcnW0G68Nn0aEldTFeEfmjCfmqry/rQ== - dependencies: - tslib "^1.10.0" - lower-case@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-2.0.2.tgz#6fa237c63dbdc4a82ca0fd882e4722dc5e634e28" @@ -19833,15 +19756,7 @@ nise@^1.5.2: lolex "^5.0.1" path-to-regexp "^1.7.0" -no-case@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.3.tgz#c21b434c1ffe48b39087e86cfb4d2582e9df18f8" - integrity sha512-ehY/mVQCf9BL0gKfsJBvFJen+1V//U+0HQMPrWct40ixE4jnv0bfvxDbWtAHL9EcaPEOJHVVYKoQn1TlZUB8Tw== - dependencies: - lower-case "^2.0.1" - tslib "^1.10.0" - -no-case@^3.0.4: +no-case@^3.0.3, no-case@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.4.tgz#d361fd5c9800f558551a8369fc0dcd4662b6124d" integrity sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg== @@ -21670,16 +21585,6 @@ pretty-format@^27.0.0, pretty-format@^27.0.2, pretty-format@^27.5.1: ansi-styles "^5.0.0" react-is "^17.0.1" -pretty-format@^28.1.1: - version "28.1.1" - resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-28.1.1.tgz#f731530394e0f7fcd95aba6b43c50e02d86b95cb" - integrity sha512-wwJbVTGFHeucr5Jw2bQ9P+VYHyLdAqedFLEkdQUVaBF/eiidDwH5OpilINq4mEfhbCjLnirt6HTTDhv1HaTIQw== - dependencies: - "@jest/schemas" "^28.0.2" - ansi-regex "^5.0.1" - ansi-styles "^5.0.0" - react-is "^18.0.0" - pretty-format@^28.1.3: version "28.1.3" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-28.1.3.tgz#c9fba8cedf99ce50963a11b27d982a9ae90970d5" @@ -25516,10 +25421,10 @@ terser@^4.1.2, terser@^4.6.3: source-map "~0.6.1" source-map-support "~0.5.12" -terser@^5.14.1, terser@^5.3.4, terser@^5.9.0: - version "5.14.1" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.14.1.tgz#7c95eec36436cb11cf1902cc79ac564741d19eca" - integrity sha512-+ahUAE+iheqBTDxXhTisdA8hgvbEG1hHOQ9xmNjeUJSoi6DU/gMrKNcfZjHkyY6Alnuyc+ikYJaxxfHkT3+WuQ== +terser@^5.14.1, terser@^5.15.1, terser@^5.3.4, terser@^5.9.0: + version "5.15.1" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.15.1.tgz#8561af6e0fd6d839669c73b92bdd5777d870ed6c" + integrity sha512-K1faMUvpm/FBxjBXud0LWVAGxmvoPbZbfTCYbSgaaYQaIXI3/TdI7a7ZGA73Zrou6Q8Zmz3oeUTsp/dj+ag2Xw== dependencies: "@jridgewell/source-map" "^0.3.2" acorn "^8.5.0" @@ -26174,12 +26079,7 @@ undefsafe@^2.0.5: resolved "https://registry.yarnpkg.com/undefsafe/-/undefsafe-2.0.5.tgz#38733b9327bdcd226db889fb723a6efd162e6e2c" integrity sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA== -undici@^5.1.1: - version "5.8.2" - resolved "https://registry.yarnpkg.com/undici/-/undici-5.8.2.tgz#071fc8a6a5d24db0ad510ad442f607d9b09d5eec" - integrity sha512-3KLq3pXMS0Y4IELV045fTxqz04Nk9Ms7yfBBHum3yxsTR4XNn+ZCaUbf/mWitgYDAhsplQ0B1G4S5D345lMO3A== - -undici@^5.10.0: +undici@^5.1.1, undici@^5.10.0: version "5.11.0" resolved "https://registry.yarnpkg.com/undici/-/undici-5.11.0.tgz#1db25f285821828fc09d3804b9e2e934ae86fc13" integrity sha512-oWjWJHzFet0Ow4YZBkyiJwiK5vWqEYoH7BINzJAJOLedZ++JpAlCbUktW2GQ2DS2FpKmxD/JMtWUUWl1BtghGw== From ce9a253796517877108c03208499ab70403408f3 Mon Sep 17 00:00:00 2001 From: Georgii Gorbachev Date: Mon, 7 Nov 2022 19:25:58 +0100 Subject: [PATCH 021/192] [Security Solution] Increase default Cypress timeouts (#144722) ## Summary The default timeouts are increased: - Locally: from 20s to 60s - On CI: from 120s to 150s Reasoning: - Locally for some of the devs many tests fail with timeouts pretty often and pretty randomly. - It looks like on CI flakiness has increased recently. --- x-pack/plugins/security_solution/cypress/cypress.config.ts | 6 +++--- .../plugins/security_solution/cypress/cypress_ci.config.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/cypress.config.ts b/x-pack/plugins/security_solution/cypress/cypress.config.ts index 8361a89fef826..548bafa5be281 100644 --- a/x-pack/plugins/security_solution/cypress/cypress.config.ts +++ b/x-pack/plugins/security_solution/cypress/cypress.config.ts @@ -9,9 +9,9 @@ import { defineConfig } from 'cypress'; // eslint-disable-next-line import/no-default-export export default defineConfig({ - defaultCommandTimeout: 20000, - execTimeout: 20000, - pageLoadTimeout: 20000, + defaultCommandTimeout: 60000, + execTimeout: 60000, + pageLoadTimeout: 60000, screenshotsFolder: '../../../target/kibana-security-solution/cypress/screenshots', trashAssetsBeforeRuns: false, video: false, diff --git a/x-pack/plugins/security_solution/cypress/cypress_ci.config.ts b/x-pack/plugins/security_solution/cypress/cypress_ci.config.ts index c9f16520a1b07..a7862819b0e6e 100644 --- a/x-pack/plugins/security_solution/cypress/cypress_ci.config.ts +++ b/x-pack/plugins/security_solution/cypress/cypress_ci.config.ts @@ -9,9 +9,9 @@ import { defineConfig } from 'cypress'; // eslint-disable-next-line import/no-default-export export default defineConfig({ - defaultCommandTimeout: 120000, - execTimeout: 120000, - pageLoadTimeout: 120000, + defaultCommandTimeout: 150000, + execTimeout: 150000, + pageLoadTimeout: 150000, numTestsKeptInMemory: 0, retries: { runMode: 2, From adfa8f7ddd2d200731f8cdf3d75cacaef91e1b12 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Mon, 7 Nov 2022 11:47:05 -0700 Subject: [PATCH 022/192] [Maps] add ungroup layers action (#144574) * [Maps] add ungroup layers action * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * tslint Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../maps/public/actions/layer_actions.ts | 2 +- .../toc_entry_actions_popover/index.ts | 4 ++ .../toc_entry_actions_popover.test.tsx | 1 + .../toc_entry_actions_popover.tsx | 38 +++++++++++-------- 4 files changed, 29 insertions(+), 16 deletions(-) diff --git a/x-pack/plugins/maps/public/actions/layer_actions.ts b/x-pack/plugins/maps/public/actions/layer_actions.ts index 38cadc8d1363a..6ab985e0cc274 100644 --- a/x-pack/plugins/maps/public/actions/layer_actions.ts +++ b/x-pack/plugins/maps/public/actions/layer_actions.ts @@ -876,7 +876,7 @@ export function createLayerGroup(draggedLayerId: string, combineLayerId: string) }; } -function ungroupLayer(layerId: string) { +export function ungroupLayer(layerId: string) { return ( dispatch: ThunkDispatch, getState: () => MapStoreState diff --git a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/index.ts b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/index.ts index 407eb558c1038..157a86f41204a 100644 --- a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/index.ts +++ b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/index.ts @@ -16,6 +16,7 @@ import { setDrawMode, showThisLayerOnly, toggleLayerVisible, + ungroupLayer, updateEditLayer, } from '../../../../../../actions'; import { getLayerListRaw } from '../../../../../../selectors/map_selectors'; @@ -55,6 +56,9 @@ function mapDispatchToProps(dispatch: ThunkDispatch { dispatch(showThisLayerOnly(layerId)); }, + ungroupLayer: (layerId: string) => { + dispatch(ungroupLayer(layerId)); + }, }; } diff --git a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.test.tsx b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.test.tsx index cb2697663766a..a51b687ea9bc7 100644 --- a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.test.tsx +++ b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.test.tsx @@ -50,6 +50,7 @@ const defaultProps = { openLayerSettings: () => {}, numLayers: 2, showThisLayerOnly: () => {}, + ungroupLayer: () => {}, }; describe('TOCEntryActionsPopover', () => { diff --git a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx index a67c12d2928a4..4aa151e89c913 100644 --- a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx +++ b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_toc/toc_entry/toc_entry_actions_popover/toc_entry_actions_popover.tsx @@ -21,6 +21,7 @@ import { ESSearchSource } from '../../../../../../classes/sources/es_search_sour import { isVectorLayer, IVectorLayer } from '../../../../../../classes/layers/vector_layer'; import { SCALING_TYPES, VECTOR_SHAPE_TYPE } from '../../../../../../../common/constants'; import { RemoveLayerConfirmModal } from '../../../../../../components/remove_layer_confirm_modal'; +import { isLayerGroup, LayerGroup } from '../../../../../../classes/layers/layer_group'; export interface Props { cloneLayer: (layerId: string) => void; @@ -38,6 +39,7 @@ export interface Props { supportsFitToBounds: boolean; toggleVisible: (layerId: string) => void; numLayers: number; + ungroupLayer: (layerId: string) => void; } interface State { @@ -114,18 +116,6 @@ export class TOCEntryActionsPopover extends Component { })); }; - _cloneLayer() { - this.props.cloneLayer(this.props.layer.getId()); - } - - _fitToBounds() { - this.props.fitToBounds(this.props.layer.getId()); - } - - _toggleVisible() { - this.props.toggleVisible(this.props.layer.getId()); - } - _getActionsPanel() { const actionItems = [ { @@ -140,7 +130,7 @@ export class TOCEntryActionsPopover extends Component { disabled: !this.props.supportsFitToBounds, onClick: () => { this._closePopover(); - this._fitToBounds(); + this.props.fitToBounds(this.props.layer.getId()); }, }, { @@ -150,7 +140,7 @@ export class TOCEntryActionsPopover extends Component { toolTipContent: null, onClick: () => { this._closePopover(); - this._toggleVisible(); + this.props.toggleVisible(this.props.layer.getId()); }, }, ]; @@ -218,9 +208,27 @@ export class TOCEntryActionsPopover extends Component { 'data-test-subj': 'cloneLayerButton', onClick: () => { this._closePopover(); - this._cloneLayer(); + this.props.cloneLayer(this.props.layer.getId()); }, }); + if ( + isLayerGroup(this.props.layer) && + (this.props.layer as LayerGroup).getChildren().length > 0 + ) { + actionItems.push({ + name: i18n.translate('xpack.maps.layerTocActions.ungroupLayerTitle', { + defaultMessage: 'Ungroup layers', + }), + icon: , + toolTipContent: null, + 'data-test-subj': 'removeLayerButton', + onClick: () => { + this._closePopover(); + this.props.ungroupLayer(this.props.layer.getId()); + this.props.removeLayer(this.props.layer.getId()); + }, + }); + } actionItems.push({ name: i18n.translate('xpack.maps.layerTocActions.removeLayerTitle', { defaultMessage: 'Remove layer', From 4eeeb0db8f94b3b7adc692917e291d466e3dc653 Mon Sep 17 00:00:00 2001 From: Kyle Pollich Date: Mon, 7 Nov 2022 14:25:05 -0500 Subject: [PATCH 023/192] [Fleet] Add docs for package verification GPG key (#144707) * Add docs for package verification GPG key * Update fleet-settings.asciidoc * Update docs/settings/fleet-settings.asciidoc Co-authored-by: DeDe Morton Co-authored-by: DeDe Morton --- docs/settings/fleet-settings.asciidoc | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/settings/fleet-settings.asciidoc b/docs/settings/fleet-settings.asciidoc index 38f042284218f..6d907ba23a807 100644 --- a/docs/settings/fleet-settings.asciidoc +++ b/docs/settings/fleet-settings.asciidoc @@ -33,6 +33,9 @@ The address to use to reach the {package-manager} registry. The proxy address to use to reach the {package-manager} registry if an internet connection is not directly available. Refer to {fleet-guide}/air-gapped.html[Air-gapped environments] for details. +`xpack.fleet.packageVerification.gpgKeyPath`:: +The path on disk to the GPG key used to verify {package-manager} packages. If the Elastic public key +is ever reissued as a security precaution, you can use this setting to specify the new key. ==== {fleet} settings From 0ed00652d75dca3fa9781375d9f5663354c5e4ab Mon Sep 17 00:00:00 2001 From: Alison Goryachev Date: Mon, 7 Nov 2022 15:52:00 -0500 Subject: [PATCH 024/192] [Guided onboarding] Implement search E2E guide (#144488) --- .../public/constants/guides_config/search.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/plugins/guided_onboarding/public/constants/guides_config/search.ts b/src/plugins/guided_onboarding/public/constants/guides_config/search.ts index f074d0924fdea..0066205b36158 100644 --- a/src/plugins/guided_onboarding/public/constants/guides_config/search.ts +++ b/src/plugins/guided_onboarding/public/constants/guides_config/search.ts @@ -57,8 +57,23 @@ export const searchConfig: GuideConfig = { }), ], location: { - appID: 'enterpriseSearch', - path: '/search_experiences', + appID: 'searchExperiences', + path: '', + }, + manualCompletion: { + title: i18n.translate( + 'guidedOnboarding.searchGuide.searchExperienceStep.manualCompletionPopoverTitle', + { + defaultMessage: 'Explore Search UI', + } + ), + description: i18n.translate( + 'guidedOnboarding.searchGuide.searchExperienceStep.manualCompletionPopoverDescription', + { + defaultMessage: `Take your time to explore how to use Search UI to build world-class search experiences. When you’re ready, click the Setup guide button to continue.`, + } + ), + readyToCompleteOnNavigation: true, }, }, ], From 494aa9cc8e02e2d9c039fc95b09b78cb7674e2f4 Mon Sep 17 00:00:00 2001 From: christineweng <18648970+christineweng@users.noreply.github.com> Date: Mon, 7 Nov 2022 14:57:00 -0600 Subject: [PATCH 025/192] [Security Solution][Analyzer] Fix graph overlay persist despite filter group changes (#144291) * [Security Solution][Resolver] bug fix - added filter status check to disable graph overlay * update reference to existing status type --- .../timeline/events/all/index.ts | 3 +- .../components/t_grid/integrated/index.tsx | 1 + .../timelines/public/container/index.tsx | 35 ++++++++++++++++--- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/timelines/common/search_strategy/timeline/events/all/index.ts b/x-pack/plugins/timelines/common/search_strategy/timeline/events/all/index.ts index 8e50d6db48f24..9f1dec9a2737b 100644 --- a/x-pack/plugins/timelines/common/search_strategy/timeline/events/all/index.ts +++ b/x-pack/plugins/timelines/common/search_strategy/timeline/events/all/index.ts @@ -12,7 +12,7 @@ import type { IEsSearchResponse } from '@kbn/data-plugin/common'; import type { Ecs } from '../../../../ecs'; import type { CursorType, Inspect, Maybe, PaginationInputPaginated } from '../../../common'; import type { TimelineRequestOptionsPaginated } from '../..'; - +import type { AlertStatus } from '../../../../types/timeline'; export interface TimelineEdges { node: TimelineItem; cursor: CursorType; @@ -45,4 +45,5 @@ export interface TimelineEventsAllRequestOptions extends TimelineRequestOptionsP fields: string[] | Array<{ field: string; include_unmapped: boolean }>; language: 'eql' | 'kuery' | 'lucene'; runtimeMappings: MappingRuntimeFields; + filterStatus?: AlertStatus; } diff --git a/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx b/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx index f27a76fb4e55b..c6b2c4e9c1a15 100644 --- a/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx +++ b/x-pack/plugins/timelines/public/components/t_grid/integrated/index.tsx @@ -263,6 +263,7 @@ const TGridIntegratedComponent: React.FC = ({ skip: !canQueryTimeline, sort: sortField, startDate: start, + filterStatus, }); useEffect(() => { diff --git a/x-pack/plugins/timelines/public/container/index.tsx b/x-pack/plugins/timelines/public/container/index.tsx index bae9f5729bc16..3445f961e0b2e 100644 --- a/x-pack/plugins/timelines/public/container/index.tsx +++ b/x-pack/plugins/timelines/public/container/index.tsx @@ -4,7 +4,6 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import type { AlertConsumers } from '@kbn/rule-data-utils'; import deepEqual from 'fast-deep-equal'; import { isEmpty, isString, noop } from 'lodash/fp'; @@ -13,10 +12,15 @@ import { useDispatch } from 'react-redux'; import { Subscription } from 'rxjs'; import { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { DataView } from '@kbn/data-views-plugin/public'; - import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import { isCompleteResponse, isErrorResponse } from '@kbn/data-plugin/common'; -import { clearEventsLoading, clearEventsDeleted, setTableUpdatedAt } from '../store/t_grid/actions'; + +import { + clearEventsLoading, + clearEventsDeleted, + setTableUpdatedAt, + updateGraphEventId, +} from '../store/t_grid/actions'; import { Direction, TimelineFactoryQueryTypes, @@ -34,7 +38,7 @@ import type { TimelineRequestSortField, } from '../../common/search_strategy'; import type { ESQuery } from '../../common/typed_json'; -import type { KueryFilterQueryKind } from '../../common/types/timeline'; +import type { KueryFilterQueryKind, AlertStatus } from '../../common/types/timeline'; import { useAppToasts } from '../hooks/use_app_toasts'; import { TableId } from '../store/t_grid/types'; import * as i18n from './translations'; @@ -82,6 +86,7 @@ export interface UseTimelineEventsProps { sort?: TimelineRequestSortField[]; startDate: string; timerangeKind?: 'absolute' | 'relative'; + filterStatus?: AlertStatus; } const createFilter = (filterQuery: ESQuery | string | undefined) => @@ -154,6 +159,7 @@ export const useTimelineEvents = ({ skip = false, timerangeKind, data, + filterStatus, }: UseTimelineEventsProps): [boolean, TimelineArgs] => { const dispatch = useDispatch(); const { startTracking } = useApmTracking(id); @@ -165,6 +171,7 @@ export const useTimelineEvents = ({ const [timelineRequest, setTimelineRequest] = useState | null>( null ); + const [prevFilterStatus, setFilterStatus] = useState(filterStatus); const prevTimelineRequest = useRef | null>(null); const clearSignalsState = useCallback(() => { @@ -259,6 +266,10 @@ export const useTimelineEvents = ({ setUpdated(newTimelineResponse.updatedAt); return newTimelineResponse; }); + if (prevFilterStatus !== request.filterStatus) { + dispatch(updateGraphEventId({ id, graphEventId: '' })); + } + setFilterStatus(request.filterStatus); setLoading(false); searchSubscription$.current.unsubscribe(); @@ -284,7 +295,18 @@ export const useTimelineEvents = ({ asyncSearch(); refetch.current = asyncSearch; }, - [skip, data, entityType, dataViewId, setUpdated, addWarning, startTracking] + [ + skip, + data, + entityType, + dataViewId, + setUpdated, + addWarning, + startTracking, + dispatch, + id, + prevFilterStatus, + ] ); useEffect(() => { @@ -300,6 +322,7 @@ export const useTimelineEvents = ({ sort: prevRequest?.sort ?? initSortDefault, timerange: prevRequest?.timerange ?? {}, runtimeMappings: prevRequest?.runtimeMappings ?? {}, + filterStatus: prevRequest?.filterStatus, }; const currentSearchParameters = { @@ -339,6 +362,7 @@ export const useTimelineEvents = ({ from: startDate, to: endDate, }, + filterStatus, }; if (activePage !== newActivePage) { @@ -364,6 +388,7 @@ export const useTimelineEvents = ({ sort, fields, runtimeMappings, + filterStatus, ]); useEffect(() => { From 5b44aa8295968649fdcfb4d887dc5cf6c22a355d Mon Sep 17 00:00:00 2001 From: spalger Date: Mon, 7 Nov 2022 15:10:42 -0600 Subject: [PATCH 026/192] rename codeowners from shared-ux to global-experience --- packages/content-management/table_list/kibana.jsonc | 2 +- packages/home/sample_data_card/kibana.jsonc | 2 +- packages/home/sample_data_tab/kibana.jsonc | 2 +- packages/home/sample_data_types/kibana.jsonc | 2 +- packages/kbn-shared-ux-utility/kibana.jsonc | 2 +- packages/shared-ux/avatar/solution/kibana.jsonc | 2 +- packages/shared-ux/avatar/user_profile/impl/kibana.jsonc | 2 +- packages/shared-ux/button/exit_full_screen/impl/kibana.jsonc | 2 +- packages/shared-ux/button/exit_full_screen/mocks/kibana.jsonc | 2 +- packages/shared-ux/button/exit_full_screen/types/kibana.jsonc | 2 +- packages/shared-ux/button_toolbar/kibana.jsonc | 2 +- packages/shared-ux/card/no_data/impl/kibana.jsonc | 2 +- packages/shared-ux/card/no_data/mocks/kibana.jsonc | 2 +- packages/shared-ux/card/no_data/types/kibana.jsonc | 2 +- packages/shared-ux/link/redirect_app/impl/kibana.jsonc | 2 +- packages/shared-ux/link/redirect_app/mocks/kibana.jsonc | 2 +- packages/shared-ux/link/redirect_app/types/kibana.jsonc | 2 +- packages/shared-ux/markdown/impl/kibana.jsonc | 2 +- packages/shared-ux/markdown/mocks/kibana.jsonc | 2 +- packages/shared-ux/markdown/types/kibana.jsonc | 2 +- packages/shared-ux/page/analytics_no_data/impl/kibana.jsonc | 2 +- packages/shared-ux/page/analytics_no_data/mocks/kibana.jsonc | 2 +- packages/shared-ux/page/analytics_no_data/types/kibana.jsonc | 2 +- packages/shared-ux/page/kibana_no_data/impl/kibana.jsonc | 2 +- packages/shared-ux/page/kibana_no_data/mocks/kibana.jsonc | 2 +- packages/shared-ux/page/kibana_no_data/types/kibana.jsonc | 2 +- packages/shared-ux/page/kibana_template/impl/kibana.jsonc | 2 +- packages/shared-ux/page/kibana_template/mocks/kibana.jsonc | 2 +- packages/shared-ux/page/kibana_template/types/kibana.jsonc | 2 +- packages/shared-ux/page/no_data/impl/kibana.jsonc | 2 +- packages/shared-ux/page/no_data/mocks/kibana.jsonc | 2 +- packages/shared-ux/page/no_data/types/kibana.jsonc | 2 +- packages/shared-ux/page/no_data_config/impl/kibana.jsonc | 2 +- packages/shared-ux/page/no_data_config/mocks/kibana.jsonc | 2 +- packages/shared-ux/page/no_data_config/types/kibana.jsonc | 2 +- packages/shared-ux/page/solution_nav/kibana.jsonc | 2 +- packages/shared-ux/prompt/no_data_views/impl/kibana.jsonc | 2 +- packages/shared-ux/prompt/no_data_views/mocks/kibana.jsonc | 2 +- packages/shared-ux/prompt/no_data_views/types/kibana.jsonc | 2 +- packages/shared-ux/router/impl/kibana.jsonc | 2 +- packages/shared-ux/router/mocks/kibana.jsonc | 2 +- packages/shared-ux/router/types/kibana.jsonc | 2 +- packages/shared-ux/storybook/config/kibana.jsonc | 2 +- packages/shared-ux/storybook/mock/kibana.jsonc | 2 +- 44 files changed, 44 insertions(+), 44 deletions(-) diff --git a/packages/content-management/table_list/kibana.jsonc b/packages/content-management/table_list/kibana.jsonc index 7f22b8c8f56c4..0808195639877 100644 --- a/packages/content-management/table_list/kibana.jsonc +++ b/packages/content-management/table_list/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/content-management-table-list", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/home/sample_data_card/kibana.jsonc b/packages/home/sample_data_card/kibana.jsonc index 2dd9813151b2c..37c9a72d03e46 100644 --- a/packages/home/sample_data_card/kibana.jsonc +++ b/packages/home/sample_data_card/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/home-sample-data-card", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/home/sample_data_tab/kibana.jsonc b/packages/home/sample_data_tab/kibana.jsonc index d734b947444a9..9e57d400caa61 100644 --- a/packages/home/sample_data_tab/kibana.jsonc +++ b/packages/home/sample_data_tab/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/home-sample-data-tab", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/home/sample_data_types/kibana.jsonc b/packages/home/sample_data_types/kibana.jsonc index 9b7458fe54946..db7884dd0d07f 100644 --- a/packages/home/sample_data_types/kibana.jsonc +++ b/packages/home/sample_data_types/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/home-sample-data-types", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/kbn-shared-ux-utility/kibana.jsonc b/packages/kbn-shared-ux-utility/kibana.jsonc index 55c3996ae4dbc..63b05a89f558b 100644 --- a/packages/kbn-shared-ux-utility/kibana.jsonc +++ b/packages/kbn-shared-ux-utility/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-utility", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/shared-ux/avatar/solution/kibana.jsonc b/packages/shared-ux/avatar/solution/kibana.jsonc index 6a2ea7f756381..e44bc8bd68b43 100644 --- a/packages/shared-ux/avatar/solution/kibana.jsonc +++ b/packages/shared-ux/avatar/solution/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-avatar-solution", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/shared-ux/avatar/user_profile/impl/kibana.jsonc b/packages/shared-ux/avatar/user_profile/impl/kibana.jsonc index 1fab1b9cb7d84..a9ee7697b2d18 100644 --- a/packages/shared-ux/avatar/user_profile/impl/kibana.jsonc +++ b/packages/shared-ux/avatar/user_profile/impl/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-avatar-user-profile-components", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/shared-ux/button/exit_full_screen/impl/kibana.jsonc b/packages/shared-ux/button/exit_full_screen/impl/kibana.jsonc index 328b3ebbdc7bb..7cee5d9ff3702 100644 --- a/packages/shared-ux/button/exit_full_screen/impl/kibana.jsonc +++ b/packages/shared-ux/button/exit_full_screen/impl/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-button-exit-full-screen", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/shared-ux/button/exit_full_screen/mocks/kibana.jsonc b/packages/shared-ux/button/exit_full_screen/mocks/kibana.jsonc index 4cfb6b20a297b..d1599c1a48bc1 100644 --- a/packages/shared-ux/button/exit_full_screen/mocks/kibana.jsonc +++ b/packages/shared-ux/button/exit_full_screen/mocks/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-button-exit-full-screen-mocks", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/shared-ux/button/exit_full_screen/types/kibana.jsonc b/packages/shared-ux/button/exit_full_screen/types/kibana.jsonc index 00597791c1c83..29b91b8142082 100644 --- a/packages/shared-ux/button/exit_full_screen/types/kibana.jsonc +++ b/packages/shared-ux/button/exit_full_screen/types/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-button-exit-full-screen-types", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/shared-ux/button_toolbar/kibana.jsonc b/packages/shared-ux/button_toolbar/kibana.jsonc index 45d7bb2c935a9..4a019c6775702 100644 --- a/packages/shared-ux/button_toolbar/kibana.jsonc +++ b/packages/shared-ux/button_toolbar/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-button-toolbar", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/shared-ux/card/no_data/impl/kibana.jsonc b/packages/shared-ux/card/no_data/impl/kibana.jsonc index 38ce883168de3..111ee94f8608d 100644 --- a/packages/shared-ux/card/no_data/impl/kibana.jsonc +++ b/packages/shared-ux/card/no_data/impl/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-card-no-data", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/shared-ux/card/no_data/mocks/kibana.jsonc b/packages/shared-ux/card/no_data/mocks/kibana.jsonc index 79230acd9e35c..0fb2ac6bc8cd5 100644 --- a/packages/shared-ux/card/no_data/mocks/kibana.jsonc +++ b/packages/shared-ux/card/no_data/mocks/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-card-no-data-mocks", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/shared-ux/card/no_data/types/kibana.jsonc b/packages/shared-ux/card/no_data/types/kibana.jsonc index d612fb80bfcd5..b92d11dee07c0 100644 --- a/packages/shared-ux/card/no_data/types/kibana.jsonc +++ b/packages/shared-ux/card/no_data/types/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-card-no-data-types", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/shared-ux/link/redirect_app/impl/kibana.jsonc b/packages/shared-ux/link/redirect_app/impl/kibana.jsonc index 38f34416473b9..0e18f1baa7d05 100644 --- a/packages/shared-ux/link/redirect_app/impl/kibana.jsonc +++ b/packages/shared-ux/link/redirect_app/impl/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-link-redirect-app", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/shared-ux/link/redirect_app/mocks/kibana.jsonc b/packages/shared-ux/link/redirect_app/mocks/kibana.jsonc index f9991820df022..4157e73efb5cb 100644 --- a/packages/shared-ux/link/redirect_app/mocks/kibana.jsonc +++ b/packages/shared-ux/link/redirect_app/mocks/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-link-redirect-app-mocks", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/shared-ux/link/redirect_app/types/kibana.jsonc b/packages/shared-ux/link/redirect_app/types/kibana.jsonc index 4de2b13ec358f..c337cfd460355 100644 --- a/packages/shared-ux/link/redirect_app/types/kibana.jsonc +++ b/packages/shared-ux/link/redirect_app/types/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-link-redirect-app-types", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/shared-ux/markdown/impl/kibana.jsonc b/packages/shared-ux/markdown/impl/kibana.jsonc index cb2df4b2e2140..8e5c57b8efdd6 100644 --- a/packages/shared-ux/markdown/impl/kibana.jsonc +++ b/packages/shared-ux/markdown/impl/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-markdown", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [], } diff --git a/packages/shared-ux/markdown/mocks/kibana.jsonc b/packages/shared-ux/markdown/mocks/kibana.jsonc index e616c2d1096f1..12aea510169c8 100644 --- a/packages/shared-ux/markdown/mocks/kibana.jsonc +++ b/packages/shared-ux/markdown/mocks/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-markdown-mocks", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [], } diff --git a/packages/shared-ux/markdown/types/kibana.jsonc b/packages/shared-ux/markdown/types/kibana.jsonc index f35376047e41c..fdc2a59e089a1 100644 --- a/packages/shared-ux/markdown/types/kibana.jsonc +++ b/packages/shared-ux/markdown/types/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-markdown-types", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [], } diff --git a/packages/shared-ux/page/analytics_no_data/impl/kibana.jsonc b/packages/shared-ux/page/analytics_no_data/impl/kibana.jsonc index 6b44af223ee87..1ef76a4f761fe 100644 --- a/packages/shared-ux/page/analytics_no_data/impl/kibana.jsonc +++ b/packages/shared-ux/page/analytics_no_data/impl/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-page-analytics-no-data", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/shared-ux/page/analytics_no_data/mocks/kibana.jsonc b/packages/shared-ux/page/analytics_no_data/mocks/kibana.jsonc index 30284a98f4b10..45fc9923f1825 100644 --- a/packages/shared-ux/page/analytics_no_data/mocks/kibana.jsonc +++ b/packages/shared-ux/page/analytics_no_data/mocks/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-page-analytics-no-data-mocks", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/shared-ux/page/analytics_no_data/types/kibana.jsonc b/packages/shared-ux/page/analytics_no_data/types/kibana.jsonc index c5f393e68b31d..622d986a5eec3 100644 --- a/packages/shared-ux/page/analytics_no_data/types/kibana.jsonc +++ b/packages/shared-ux/page/analytics_no_data/types/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-page-analytics-no-data-types", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/shared-ux/page/kibana_no_data/impl/kibana.jsonc b/packages/shared-ux/page/kibana_no_data/impl/kibana.jsonc index afbc38e5c31ea..311fb97cadc1a 100644 --- a/packages/shared-ux/page/kibana_no_data/impl/kibana.jsonc +++ b/packages/shared-ux/page/kibana_no_data/impl/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-page-kibana-no-data", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/shared-ux/page/kibana_no_data/mocks/kibana.jsonc b/packages/shared-ux/page/kibana_no_data/mocks/kibana.jsonc index 40b9b7ae6949f..f505fe3e4b9fa 100644 --- a/packages/shared-ux/page/kibana_no_data/mocks/kibana.jsonc +++ b/packages/shared-ux/page/kibana_no_data/mocks/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-page-kibana-no-data-mocks", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/shared-ux/page/kibana_no_data/types/kibana.jsonc b/packages/shared-ux/page/kibana_no_data/types/kibana.jsonc index b29d44238995f..5fd40e9bde6a2 100644 --- a/packages/shared-ux/page/kibana_no_data/types/kibana.jsonc +++ b/packages/shared-ux/page/kibana_no_data/types/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-page-kibana-no-data-types", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/shared-ux/page/kibana_template/impl/kibana.jsonc b/packages/shared-ux/page/kibana_template/impl/kibana.jsonc index 6d1d8ff74de77..cfcef7ec0a150 100644 --- a/packages/shared-ux/page/kibana_template/impl/kibana.jsonc +++ b/packages/shared-ux/page/kibana_template/impl/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-page-kibana-template", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/shared-ux/page/kibana_template/mocks/kibana.jsonc b/packages/shared-ux/page/kibana_template/mocks/kibana.jsonc index 43a17e3257739..bb870c6ef0f41 100644 --- a/packages/shared-ux/page/kibana_template/mocks/kibana.jsonc +++ b/packages/shared-ux/page/kibana_template/mocks/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-page-kibana-template-mocks", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/shared-ux/page/kibana_template/types/kibana.jsonc b/packages/shared-ux/page/kibana_template/types/kibana.jsonc index b4b18d25baf0b..91518cf552986 100644 --- a/packages/shared-ux/page/kibana_template/types/kibana.jsonc +++ b/packages/shared-ux/page/kibana_template/types/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-page-kibana-template-types", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/shared-ux/page/no_data/impl/kibana.jsonc b/packages/shared-ux/page/no_data/impl/kibana.jsonc index 2a0576ad190bd..e651af8c1de04 100644 --- a/packages/shared-ux/page/no_data/impl/kibana.jsonc +++ b/packages/shared-ux/page/no_data/impl/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-page-no-data", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/shared-ux/page/no_data/mocks/kibana.jsonc b/packages/shared-ux/page/no_data/mocks/kibana.jsonc index 6735d457c937d..0062ac6548d65 100644 --- a/packages/shared-ux/page/no_data/mocks/kibana.jsonc +++ b/packages/shared-ux/page/no_data/mocks/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-page-no-data-mocks", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/shared-ux/page/no_data/types/kibana.jsonc b/packages/shared-ux/page/no_data/types/kibana.jsonc index 02a6dbc2f8fa1..504a436c62f48 100644 --- a/packages/shared-ux/page/no_data/types/kibana.jsonc +++ b/packages/shared-ux/page/no_data/types/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-page-no-data-types", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/shared-ux/page/no_data_config/impl/kibana.jsonc b/packages/shared-ux/page/no_data_config/impl/kibana.jsonc index 991cb4ef781b9..bc2c3be18cb38 100644 --- a/packages/shared-ux/page/no_data_config/impl/kibana.jsonc +++ b/packages/shared-ux/page/no_data_config/impl/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-page-no-data-config", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/shared-ux/page/no_data_config/mocks/kibana.jsonc b/packages/shared-ux/page/no_data_config/mocks/kibana.jsonc index d87174032f553..b1da5fdb4c0c3 100644 --- a/packages/shared-ux/page/no_data_config/mocks/kibana.jsonc +++ b/packages/shared-ux/page/no_data_config/mocks/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-page-no-data-config-mocks", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/shared-ux/page/no_data_config/types/kibana.jsonc b/packages/shared-ux/page/no_data_config/types/kibana.jsonc index 2491f5442db8c..968460e151429 100644 --- a/packages/shared-ux/page/no_data_config/types/kibana.jsonc +++ b/packages/shared-ux/page/no_data_config/types/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-page-no-data-config-types", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/shared-ux/page/solution_nav/kibana.jsonc b/packages/shared-ux/page/solution_nav/kibana.jsonc index 8c62fb2f3e750..0b89ad3a44b0c 100644 --- a/packages/shared-ux/page/solution_nav/kibana.jsonc +++ b/packages/shared-ux/page/solution_nav/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-page-solution-nav", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/shared-ux/prompt/no_data_views/impl/kibana.jsonc b/packages/shared-ux/prompt/no_data_views/impl/kibana.jsonc index 4861e3d3b6edd..416e58e250d10 100644 --- a/packages/shared-ux/prompt/no_data_views/impl/kibana.jsonc +++ b/packages/shared-ux/prompt/no_data_views/impl/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-prompt-no-data-views", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/shared-ux/prompt/no_data_views/mocks/kibana.jsonc b/packages/shared-ux/prompt/no_data_views/mocks/kibana.jsonc index 65532173dd0ed..07cf434cde4a3 100644 --- a/packages/shared-ux/prompt/no_data_views/mocks/kibana.jsonc +++ b/packages/shared-ux/prompt/no_data_views/mocks/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-prompt-no-data-views-mocks", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/shared-ux/prompt/no_data_views/types/kibana.jsonc b/packages/shared-ux/prompt/no_data_views/types/kibana.jsonc index 1385b91ec370e..54785567d8b03 100644 --- a/packages/shared-ux/prompt/no_data_views/types/kibana.jsonc +++ b/packages/shared-ux/prompt/no_data_views/types/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-prompt-no-data-views-types", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/shared-ux/router/impl/kibana.jsonc b/packages/shared-ux/router/impl/kibana.jsonc index 77f3eca900702..e1e6e614e9d6f 100644 --- a/packages/shared-ux/router/impl/kibana.jsonc +++ b/packages/shared-ux/router/impl/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-router", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/shared-ux/router/mocks/kibana.jsonc b/packages/shared-ux/router/mocks/kibana.jsonc index 8f3aef23a2081..858c88c76e201 100644 --- a/packages/shared-ux/router/mocks/kibana.jsonc +++ b/packages/shared-ux/router/mocks/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-router-mocks", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/shared-ux/router/types/kibana.jsonc b/packages/shared-ux/router/types/kibana.jsonc index 4e328b93d6081..2a8021f3a203d 100644 --- a/packages/shared-ux/router/types/kibana.jsonc +++ b/packages/shared-ux/router/types/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-router-types", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/shared-ux/storybook/config/kibana.jsonc b/packages/shared-ux/storybook/config/kibana.jsonc index 9a3b26cb20f83..943577a87ff96 100644 --- a/packages/shared-ux/storybook/config/kibana.jsonc +++ b/packages/shared-ux/storybook/config/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-storybook-config", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } diff --git a/packages/shared-ux/storybook/mock/kibana.jsonc b/packages/shared-ux/storybook/mock/kibana.jsonc index 305626d6f3cdc..50fc306e62ef3 100644 --- a/packages/shared-ux/storybook/mock/kibana.jsonc +++ b/packages/shared-ux/storybook/mock/kibana.jsonc @@ -1,7 +1,7 @@ { "type": "shared-common", "id": "@kbn/shared-ux-storybook-mock", - "owner": "@elastic/shared-ux", + "owner": "@elastic/kibana-global-experience", "runtimeDeps": [], "typeDeps": [] } From 4e9f1c0d049b522a89c3dbbb3dd61223bfd76f07 Mon Sep 17 00:00:00 2001 From: Bree Hall <40739624+breehall@users.noreply.github.com> Date: Mon, 7 Nov 2022 16:45:50 -0500 Subject: [PATCH 027/192] Bumping EUI to version 67.1.8 (#141279) * Updated EUI to version 67.1.2. Updated instaces of ButtonColor from EUI to EuiButtonColor. * Updated to EuiCard instances that utilize the betaBadgeProps object to return an empty string instead of undefined when the label is unavailable * Removed two instances of the deprecated internetExplorerOnly() mixin * Updated two instances of the ButtonColor import to EuiButtonColor as is was renamed in PR #6150 * Updated snapshots in Jest Test Suite #1 to account for EuiButton and EuiCard Emotion conversions. Updated snapshots for EuiTooltip as it now contains the new EuiToolTipAnchor component that replaced the tooltip anchor styles * Updated snapshots in Jest Test Suite #2 to account forEuiButton, EuiDescriptionList, EuiButtonIcon, and EuiBadge Emotion conversions. * Updated snapshots in Jest Test Suite #3 to account for EuiDescriptionList, EuiButton, and EuiBadge Emotion conversions. Updated snapshots for EuiTooltip as if now contains the new EuiTooltipAnchor component that replaced the tooltop anchor styles * Updated snapshots in Jest Test Suite #4 to account for EuiButton Emotion conversion. * Updated snapshots in Jest Test Suite #5 to account for EuiButton Emotion conversion. * Updated snapshots in Jest Test Suite #8 to account for EuiButtonIcon and EuiButton Emotion conversions. Updated snapshots for EuiTooltip as it now contains the new EuiTooltipAnchor component that replaced the tooltip anchor styles. * Updated snapshots in Jest Test Suite #9 to account for EuiFlyout and EuiButton Emotion conversions. * Updated snapshots in Jest Test Suite #10 to account for EuiButton, EuiBadge, EuiButtonIcon, and EuiCard Emotion conversions. Updated snapshots for EuiToolTtip as it now contains the new EuiTooltipAnchor component that replaced the tooltip anchor styles * Updated instances of EuiButtonIconColor to use EuiButtonIconProps['color'] as it was removed in PR #6150 * Updated tests that target EuiButton to simulate click events to target a generic button to prevent undefined click event errors * Updated snapshots in Jest Test Suite #1 to account for EuiButton and EuiCard Emotion conversions * Added the EuiFlyout mixins and variables to Lens Sass file as EuiFlyout has been converted to Emotion and the Sass styles are no longer available in EUI * Added the EuiCallOutTypes variable to Step Progress Sass file as EuiCallOut has been converted to Emotion and the Sass styles are no longer available in EUI * Updated snapshots in Jest Test Suite #2 to account for recent Emotion conversions. Updated snapshots in server_status.test.tsx to render EuiBadge before checking the snapshots to reduce the snapshot churn caused by Emotion. Updated tests that target EuiButton to simulate click events to target a generic button to prevent undefined click event errors * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * Added imports for the added flyout mixin. Removed references to EuiCallOut mixin as the component has been converted to Emotion and is no longer available for use. * Updated unit tests and snapshots in Jest Test Suite #10. Updated snaphshots to account for EuiBadge, EuiDescriptionList, EuiFlyout, and EuiCard Emotion conversions. Updated snapshots for EuiTooltip as it now contains the new EuiTooltipAnchor component that replaced the tooltip anchor styles. Updated tests that target EuiButton to simulate click events to target a generic button element to prevent undefined click event errors * Updated unit tests in Jest Test Suite #11 that target EuiButton to simulate click events to target a generic button to prevent undefined click event errors * Updated unit tests in Jest Test Suite #12 by updating tests that target EuiButton to simulate click events. Instead, these tests now target a generic button element to prevent undefined click event errors * Updated unit tests in Jest Test Suite #1 by updating tests that target EuiButton to simulate click events. Instead, these tests now target a generic button element to prevent undefined click event errors * Updated unit tests in Jest Test Suite #2 by updating tests that use EuiButton to simulate click events. Instead, these test have been updated to target a button element to prevent undefined click event errors. * [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' * Updated reference to mixins Sass file. Updated snapshots for Jest Test Suite #5 to account for EuiButton Emotion conversion. Updated unit tests that target EuiButton to simulate click events. These tests have been updated to target a button element to prevent undefined click event errors * Updated unit tests in Jest Test Suites 3, 7, 8, 13, and 14. Updated snapshot to account for EuiButton Emotion conversion. Updated tests that target EuiButton to simulate click events. These tests now target a generic button element to prevent undefined click event errors. Updated a few snapshots by adding .render() before checking the snapshot. This will prevent large snapshots coming from recent Emotion conversions * Updated snapshots in Jest Test Suite #10 to account for the recent EuiButton Emotion conversion * Updated unit tests in Jest Test Suite #2 by editing tests that target EuiButton to simulate click events. These tests now target a button element in order to prevent undefinde click event errors * Updated snapshots in Jest Test Suite #10 to account for EuiButton and EuiDescriptionList Emotion conversions * Updated test cases in Jest Test Suites 3, 7, and 8. Updated snapshots to account for EuiButton and EuiPagination Emotion conversions. Updated tests that target EuiButton to simulate click events. These tests now target a button element to prevent undefined click errors * Updated test cases in Jest Test Suite 14. Updated snapshots to account for EuiButton Emotion conversion. Opted to use .render() when updating a few snapshots to reduce the large length of snapshots caused by Emotion * [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' * Revised a change to betaBadgeProps to ensure that the label is available. If not, the value for the badge with be set to undefined. * Resolved two linting errors * Resolved two linting errors * Updated Jest unit tests in various suites. Updated snapshots to account for EuiButton Emotion conversion. Updated snapshots for EuiTooltip as it now contains the new EuiTooltipAnchor component that replaced the tooltip anchor styles. * Updated EuiFlyout in query_flyout.tsx to remove the onClick function from maskProps as it is no longer available. Updated this flyout to use ownFocus and not to close when the overlay mask is clicked. * Removed the use of EuiButtonIconColor in favor of EuiButtonIconProps['color'] * [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' * Updated Cypress test looking for strict equality on EuiPaginationButton class names to match a substring of the Emotion generated class name * Removed unneeded debugging code. Updated snapshots for various test suites to account for the recent EuiButton Emotion conversion * Updated a few EuiButton, EuiButtonEmpty, and EuiText components that set the color as ghost. The ghost color mode has been deprecated as of PR #6150. These components now are wrapped in EuiThemeProvider with a dark colorMode to create the previous ghost color. * Resolved TS error with EuiCard betaBadgeProps * [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' * Remove references to now-removed EuiFlyout CSS classes/vars * Remove now-removed euiBadge className references - Convert directly to EuiBadge instead of using CSS - Remove confusing and now-possibly-irrelevant CSS badge overrides - left/right icons are now set via JSX and not via flex-direction * Pre-emptively fix various euiOverlayMask CSS overrides - this data attr isn't technically in yet but will be once https://github.com/elastic/eui/pull/6289 merges - at the very least this isn't breaking any more than it currently already is! * Update to v67.1.3 * v67.1.4 * Resolved test failing test case in Security/Manage/Blocklist. The test did not remove focus from the last combo box in the form, which didn't allow the disbaled attribute to be removed from the flyout submit button. I've updated the mock file for Blocklist to return focus to the first form element in the flyout to allow the disabled attribute to be removed. * Updated snapshots to account for the recent EuiText Emotion conversion * Fix Log's custom tooltips relying on EuiTooltip classNames that no longer exist * Fix Vega vis custom tooltips relying on EuiTooltip classNames that no longer exist - this one is trickier than Log's as it's not using React, so we need to use Emotion's Global to set a static className * Convert remaining vega_vis.scss to Emotion - as an example of how other global + non global styles could be handled in the future * Fix references to removed `euiPaginationButton-isActive` className - use aria-current attribute instead * Added missing EuiFlyoutAnimation keyframes for EuiFlyout. This resolved test that failed because they used onAnimationEnd because the FlyoutAnimation could not be found. * Reolved Jest Tests in suites 1 and 5. Updated snapshots to account for the recent EuiButton Emotion conversion. Updated snapshots for EuiToolTip as it now contains the new EuiToolTipAnchor component that replaced the tooltip anchor styles. * iterate on rules_list.test.tsx * bump eui to v67.1.5 * Updatde snapshots for jest test suites to account for the recent EuiButton, EuiOverlayMask, EuiTooltip, and EuiBadge Emotion conversions * Resolved failing security test by updating the target element for CONNECTOR_TITLE. EuiCard has recently been converted to Emotion and the card title is no longer wrapper in a span. * Resolved failing test case in Runtime Fields. The modify runtime field test was failing because the combobox responsbible for adding and updating scripts was not appearing. The textbox did not appear because the shared setFieldScript function targets and toggles the script textbox when opening the flyout. When a runtime field is being modified, the toggle is already active and using the shared function will trigger the toggle again (losing access to the script textbox). Also resolved an issue that prevented the warning EuiCallout to appear when changing the type of a runtime field from its original type. Resolved this by adding an enter keypress at the end of setFieldType function to confirm the type selection, thus triggering the EuiCallout * Resolved two tests that were failing in Lens. These test were failing because they were checking for equality in class names that no longer exist within EuiButtonGroup as it was recently converted to Emotion. These tests were updated to check for a substring of the new and longer class name * Quick fix in test case failing because of misspelling in data-test-sub * Updated snapshot for Jest test case as EuiButton as recently been converted to Emotion * Removed console.log statement. Oops! * Resolved a failing test case in Lens. They were failing because they were checking for equality in class names that no longer exist within EuiButtonGroup as it was recently converted to Emotion. These tests were updated to check for a substring of the new and longer class name. Updated a Security test case by giving a target button the data-test-subj attribute for easier querying * Removed reference to EuiFlyout mixin as it has been converted to Emotion. Updated the reference to an interal copy of EuiFlyout styles * Corrected spelling error in EuiFlyout animation in Lens app * Update EUI with latest backport * Update button snapshots * fix another button snapshot * More snapshot fixes * [EuiButton][Security] Fix button relying on now-removed `euiButton__text` CSS - replace removed CSS with `eui-textTruncate` util instead - combine/DRY out unnecessary span - was affecting min-width of truncation util + increase screenshot diff limit - this was smaller than updating the actual baseline screenshots for whatever reason (likely render diff between local and CI) * Fix remaining Jest tests affected by Emotion conversions - because Emotion creates its own wrapper, `.first()` can no longer be used - prefer `.last()` instead * Fix Jest test affected by EuiButton Emotion conversion + removed modifier class - targeting the native DOM node + filtering by disabled true/false gets us back to the 'correct' lengths * Fix + improve flyout test - `.last()` changes to account for EuiButton Emotion conversion is needed, but the last onClose assertion still fails due to us having modified inputs, and the confirm modal being displayed - split test into two separate tests - one testing the onClose call, and the other testing the confirm modal * derpin * Skip rules_list Jest suite * Update new EuiButton snapshot * Upgraded EUI version to 67.1.7 * [EuiCard] Update snapshots * [EuiPopover] Update snapshots * [QA] Fix missing Vega warn/error message colors ;_; * [CI] Auto-commit changed files from 'node scripts/generate codeowners' * Fix Lens kbnToolbarButton regressions - Caused by flattening of EUI button CSS specificity - background-color was previously relying on isDisabled CSS specificity to override its #fff color - `text` color modifier & `!important` is no longer needed and overrides Emotion CSS flatly - isDisabled class is no longer needed - euiButton no longer sets `pointer-events: none` on disabled buttons (fixes tooltip bug in webkit as well) * Backport EUI 67.1.8 fixes * Update EuiCard snapshots * Fix EuiModal form wrapper causing overflow issues - see https://elastic.github.io/eui/#/layout/modal#forms-in-a-modal * Workaround for `.kbnOverlayMountWrapper` mount point causing overflow issues - not sure what all is using this modal service to be honest, but the wrapper is causing issues with the modal layout, this fixes overflow issues but will not fix any mask-image issues as a result * more snapshot updates * EuiButton - added textProps to EuiButton to prevent very long button names from spilling over outside of the container * EuiButton - Update EuiButton related snapshots. Updated tests that target EuiButton directly to use a data-telementary-id for more specific element querying required by Emotion * QA - Removed unnecessary comment in code * Temporary fix for EuiCard[selectable][layout=horizontal] instances on security solutions' rule page * Temporary fix for EuiCard[selectable][layout=horizontal] instances on osquery live query and canvas's datasource selector * [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' * Fix CSS specificity, where canvas's solutionToolbarButton's background-color now takes precedence over EuiButton's primary styles * Removed update to search_marker_tooltip that removed the euiTooltip styles and replaced then with Emotion styling. Added EuiTooltip Sass styles for the component to rely on to test for a styling bug that is causing the tooltip and the tooltip arrow to be out of sync with each other. * Lint Sass file * Lint Sass file * Removed overflow:hidden style from .vgaVis_view as it was causing euiScrollStyles not to present the scroll bars in Vega Vis * Remove typo from EuiButton textProps object. 'className' should not have been included in the actual class name * Revert tooltip Sass This reverts commit 20e6ead5713781c35fdd15c73e9886f9abcecfac, a5cd2de901caec2d485c162eed184613e6a7b32b, and c605cbd7b92311b76f05ed1d6fab0651eeeae0cb * Fix Emotion tooltip arrows Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Constance Chen Co-authored-by: Chandler Prall --- package.json | 2 +- .../src/application_leave.tsx | 4 +- .../core-application-browser/src/app_leave.ts | 6 +- .../__snapshots__/server_status.test.tsx.snap | 105 +- .../status/components/server_status.test.tsx | 6 +- .../collapsible_nav.test.tsx.snap | 10 +- .../header/__snapshots__/header.test.tsx.snap | 4 +- .../fatal_errors_screen.test.tsx.snap | 1 + .../src/mount_wrapper.scss | 1 + .../__snapshots__/modal_service.test.tsx.snap | 156 +- .../sample_data_card.test.tsx.snap | 105 +- .../disabled_footer.test.tsx.snap | 8 +- .../install_footer.test.tsx.snap | 6 +- .../__snapshots__/remove_footer.test.tsx.snap | 8 +- .../__snapshots__/view_button.test.tsx.snap | 36 +- .../src/sample_data_card.component.tsx | 5 +- .../__snapshots__/index.test.tsx.snap | 4 + .../guide_card_footer.test.tsx.snap | 10 + .../observability_link_card.test.tsx.snap | 2 + .../__snapshots__/details_info.test.tsx.snap | 48 +- .../__snapshots__/header_menu.test.tsx.snap | 26 +- .../__snapshots__/list_header.test.tsx.snap | 282 ++- .../__snapshots__/edit_modal.test.tsx.snap | 200 ++- .../__snapshots__/menu_items.test.tsx.snap | 76 +- .../text_with_edit.test.tsx.snap | 4 +- .../exit_full_screen_button.test.tsx.snap | 36 +- .../add_from_library.test.tsx.snap | 596 +------ .../add_from_library.test.tsx | 2 +- .../icon_button_group.test.tsx.snap | 560 +----- .../icon_button_group.test.tsx | 2 +- .../__snapshots__/primary.test.tsx.snap | 470 +---- .../src/buttons/primary/primary.test.tsx | 4 +- .../__snapshots__/popover.test.tsx.snap | 6 +- .../__snapshots__/toolbar.test.tsx.snap | 215 +-- .../src/toolbar/toolbar.test.tsx | 2 +- .../no_data_card.component.test.tsx.snap | 6 + .../__snapshots__/no_data_card.test.tsx.snap | 112 +- .../impl/__snapshots__/markdown.test.tsx.snap | 43 +- .../impl/src/no_data_views.test.tsx | 3 +- src/dev/license_checker/config.ts | 2 +- .../management_app/_advanced_settings.scss | 8 - .../management_app/components/form/form.tsx | 6 +- .../dashboard_listing.test.tsx.snap | 9 + .../__snapshots__/clone_modal.test.js.snap | 2 + .../search/errors/timeout_error.test.tsx | 2 +- .../shard_failure_modal.test.tsx.snap | 2 + .../__snapshots__/data_view.test.tsx.snap | 52 +- .../color/__snapshots__/color.test.tsx.snap | 3 + .../__snapshots__/static_lookup.test.tsx.snap | 2 + .../table/__snapshots__/table.test.tsx.snap | 2 + .../__snapshots__/add_filter.test.tsx.snap | 4 + .../open_search_panel.test.tsx.snap | 2 + .../build_copy_column_button.test.tsx | 8 +- .../get_render_cell_value.test.tsx | 4 +- .../discover_tour/discover_tour.test.tsx | 2 +- .../__snapshots__/table_header.test.tsx.snap | 62 +- .../__snapshots__/table_cell.test.tsx.snap | 264 ++- .../__snapshots__/field_name.test.tsx.snap | 8 +- .../saved_object_finder_create_new.test.tsx | 4 +- .../view_api_request_flyout.test.tsx.snap | 14 +- .../file_picker/file_picker.test.tsx | 3 +- .../__snapshots__/add_data.test.tsx.snap | 2 + .../__snapshots__/footer.test.js.snap | 2 + .../instruction_set.test.js.snap | 10 + .../saved_objects_installer.test.js.snap | 14 +- .../__snapshots__/controls_tab.test.tsx.snap | 2 + .../input_control_vis.test.tsx.snap | 8 + .../inspector_panel.test.tsx.snap | 8 +- .../public/submit_error_callout.test.tsx | 6 + .../__snapshots__/code_editor.test.tsx.snap | 116 +- .../__snapshots__/page_template.test.tsx.snap | 2 +- .../elastic_agent_card.test.tsx.snap | 30 +- .../__snapshots__/no_data_card.test.tsx.snap | 80 +- .../no_data_card/elastic_agent_card.tsx | 2 +- .../no_data_card/no_data_card.tsx | 2 +- .../public/toolbar_button/toolbar_button.scss | 17 +- .../top_nav_menu_item.test.tsx.snap | 1 + .../solution_toolbar/items/button.tsx | 2 +- .../solution_toolbar/items/popover.tsx | 3 +- .../saved_object_save_modal.test.tsx.snap | 696 ++++---- .../save_modal/saved_object_save_modal.tsx | 71 +- .../__snapshots__/header.test.tsx.snap | 22 +- .../__snapshots__/flyout.test.tsx.snap | 2 + .../__snapshots__/table.test.tsx.snap | 10 + .../components/delete_confirm_modal.test.tsx | 6 +- .../components/export_modal.test.tsx | 2 +- .../__snapshots__/opt_in_banner.test.tsx.snap | 2 + .../opted_in_notice_banner.test.tsx.snap | 1 + .../field_visualize_button.test.tsx | 4 +- .../dataview_picker/change_dataview.tsx | 1 + .../saved_query_management_list.test.tsx | 2 +- .../public/search_bar/search_bar.test.tsx | 6 +- .../public/components/vega_vis.styles.tsx | 31 + .../public/components/vega_vis_component.tsx | 23 +- .../public/vega_view/vega_base_view.styles.ts | 144 ++ .../vega/public/vega_view/vega_tooltip.js | 6 +- .../public/vega_view/vega_tooltip.styles.ts | 102 ++ .../legend/__snapshots__/legend.test.tsx.snap | 2 +- .../apps/management/_runtime_fields.ts | 2 +- test/functional/page_objects/settings_page.ts | 7 + .../shared/license_prompt/index.tsx | 29 +- .../transaction_action_menu.test.tsx.snap | 15 +- .../time_filter.stories.storyshot | 20 +- .../__snapshots__/asset.stories.storyshot | 40 +- .../asset_manager.stories.storyshot | 930 +++++----- .../color_manager.stories.storyshot | 20 +- .../color_picker.stories.storyshot | 19 +- .../custom_element_modal.stories.storyshot | 1563 ++++++++--------- .../datasource_component.stories.storyshot | 17 +- .../datasource/datasource_selector.js | 60 +- .../element_card.stories.storyshot | 128 +- .../home/__snapshots__/home.stories.storyshot | 13 +- .../workpad_table.stories.storyshot | 44 +- .../keyboard_shortcuts_doc.stories.storyshot | 170 +- .../element_controls.stories.storyshot | 10 +- .../element_grid.stories.storyshot | 84 +- .../saved_elements_modal.stories.storyshot | 1178 ++++++------- .../sidebar_header.stories.storyshot | 20 +- .../__snapshots__/tag.stories.storyshot | 16 +- .../__snapshots__/tag_list.stories.storyshot | 90 +- .../text_style_picker.stories.storyshot | 24 +- .../delete_var.stories.storyshot | 15 +- .../__snapshots__/edit_var.stories.storyshot | 64 +- .../var_config.stories.storyshot | 42 +- .../filter.component.stories.storyshot | 52 +- .../filters_group.component.stories.storyshot | 78 +- ...orkpad_filters.component.stories.storyshot | 231 +-- .../__snapshots__/edit_menu.stories.storyshot | 12 +- .../editor_menu.stories.storyshot | 26 +- .../element_menu.stories.storyshot | 13 +- .../share_menu.stories.storyshot | 4 +- .../__snapshots__/view_menu.stories.storyshot | 8 +- .../view_menu/custom_interval.tsx | 4 +- .../simple_template.stories.storyshot | 2 +- .../api/__snapshots__/shareable.test.tsx.snap | 50 +- .../__snapshots__/canvas.stories.storyshot | 53 +- .../__snapshots__/footer.stories.storyshot | 35 +- .../page_controls.stories.storyshot | 45 +- .../components/footer/page_controls.tsx | 77 +- .../__snapshots__/settings.test.tsx.snap | 18 +- .../autoplay_settings.stories.storyshot | 18 +- .../__snapshots__/settings.stories.storyshot | 6 +- .../shareable_runtime/test/selectors.ts | 4 +- .../components/add_comment/index.test.tsx | 2 +- .../all_cases/all_cases_list.test.tsx | 2 +- .../all_cases/table_filters.test.tsx | 5 +- .../public/components/create/index.test.tsx | 2 +- .../components/create/submit_button.test.tsx | 6 +- .../public/components/links/index.test.tsx | 6 +- .../plugins/lens/processor.tsx | 2 +- .../status/status_popover_button.test.tsx | 2 +- .../callout/callout.test.tsx | 8 +- .../components/user_actions/index.test.tsx | 6 +- .../public/components/user_actions/index.tsx | 2 +- .../user_actions/markdown_form.test.tsx | 4 +- .../field_type_icon/field_type_icon.test.tsx | 2 +- .../start_crawl_context_menu.test.tsx | 3 +- .../stop_crawl_popover_context_menu.test.tsx | 3 +- .../components/curations/constants.ts | 10 +- .../app_search/components/library/library.tsx | 4 +- .../components/result/result_actions.test.tsx | 4 +- .../app_search/components/result/types.ts | 4 +- .../syncs_context_menu.test.tsx | 9 +- .../start_crawl_context_menu.test.tsx | 3 +- .../stop_crawl_popover_context_menu.test.tsx | 3 +- .../credential_item/credential_item.test.tsx | 4 +- .../add_source/configuration_choice.tsx | 2 +- .../public/components/search_bar.test.tsx | 2 +- .../components/settings/settings.test.tsx | 6 +- .../extend_index_management.test.tsx.snap | 44 +- .../__snapshots__/policy_table.test.tsx.snap | 31 +- .../__snapshots__/index_table.test.js.snap | 20 +- .../log_minimap/search_marker_tooltip.tsx | 19 +- .../__snapshots__/index.test.tsx.snap | 88 +- x-pack/plugins/lens/public/_mixins.scss | 30 + .../config_panel/config_panel.test.tsx | 4 +- .../config_panel/flyout_container.scss | 7 +- .../workspace_panel/chart_switch.test.tsx | 2 +- .../coloring/palette_panel_container.scss | 5 +- x-pack/plugins/lens/public/types.ts | 4 +- .../datatable/components/toolbar.test.tsx | 3 +- .../toolbar_component/gauge_toolbar.test.tsx | 3 +- .../visualizations/metric/toolbar.test.tsx | 3 +- .../__snapshots__/add_license.test.js.snap | 56 +- .../request_trial_extension.test.js.snap | 112 +- .../revert_to_basic.test.js.snap | 84 +- .../__snapshots__/start_trial.test.js.snap | 112 +- .../upload_license.test.tsx.snap | 60 +- .../pipeline_editor.test.js.snap | 24 + .../pipelines_table.test.js.snap | 2 + .../confirm_delete_modal.test.js | 2 +- .../pipeline_list/pipelines_table.test.js | 4 +- .../custom_icon_modal.test.tsx.snap | 5 + .../geometry_filter_form.test.js.snap | 2 + .../add_tooltip_field_popover.test.tsx.snap | 2 + .../layer_wizard_select.test.tsx.snap | 4 +- .../flyout_body/layer_wizard_select.tsx | 2 +- .../__snapshots__/layer_control.test.tsx.snap | 4 + .../__snapshots__/tools_control.test.tsx.snap | 1 + .../full_time_range_selector.test.tsx.snap | 2 + .../rule_editor_flyout.test.js.snap | 6 + .../validate_job_view.test.js.snap | 3 + .../__snapshots__/calendar_form.test.js.snap | 4 + .../__snapshots__/events_table.test.js.snap | 2 + .../__snapshots__/import_modal.test.js.snap | 2 + .../calendars/edit/new_calendar.test.js | 4 +- .../table/__snapshots__/table.test.js.snap | 1 + .../add_item_popover.test.js.snap | 6 + .../delete_filter_list_modal.test.js.snap | 5 + .../edit_filter_list.test.js.snap | 16 + .../nodes/__snapshots__/cells.test.js.snap | 4 +- .../flyout/__snapshots__/flyout.test.js.snap | 10 + .../__snapshots__/checker_errors.test.js.snap | 4 +- .../__snapshots__/no_data.test.js.snap | 24 +- .../collection_enabled.test.js.snap | 10 +- .../collection_interval.test.js.snap | 23 +- .../__snapshots__/reason_found.test.js.snap | 10 +- .../columns/filter_value_btn.test.tsx | 2 +- .../views/view_actions.test.tsx | 4 +- .../field_value_selection.test.tsx | 4 +- .../alerts/containers/alerts_page/styles.scss | 2 +- .../form/query_pack_selectable.tsx | 15 + .../public/packs/queries/query_flyout.tsx | 6 +- .../saved_queries/form/playground_flyout.tsx | 2 +- ...screen_capture_panel_content.test.tsx.snap | 42 +- .../percentage_badge/_percentage_badge.scss | 20 - .../__snapshots__/login_page.test.tsx.snap | 4 + .../__snapshots__/login_form.test.tsx.snap | 1 + .../components/login_form/login_form.test.tsx | 8 +- .../overwritten_session_page.test.tsx.snap | 10 +- .../elasticsearch_privileges.test.tsx.snap | 2 + .../privilege_space_form.tsx | 4 +- .../confirm_delete_users.test.tsx | 4 +- .../nav_control/nav_control_service.test.ts | 2 +- .../__snapshots__/prompt_page.test.tsx.snap | 4 +- .../unauthenticated_page.test.tsx.snap | 2 +- .../reset_session_page.test.tsx.snap | 2 +- .../cypress/e2e/detection_rules/sorting.cy.ts | 12 +- .../cypress/e2e/pagination/pagination.cy.ts | 34 +- .../cypress/screens/case_details.ts | 2 +- .../cypress/tasks/sourcerer.ts | 3 +- .../tooltip_with_keyboard_shortcut/index.tsx | 4 +- .../authentications_host_table.test.tsx.snap | 24 +- .../authentications_user_table.test.tsx.snap | 24 +- .../components/callouts/callout.test.tsx | 2 +- .../__snapshots__/index.test.tsx.snap | 4 + .../__snapshots__/link_to_app.test.tsx.snap | 20 +- .../__snapshots__/index.test.tsx.snap | 8 +- .../exit_full_screen/index.test.tsx | 2 +- .../__snapshots__/index.test.tsx.snap | 178 +- .../components/last_event_time/index.test.tsx | 2 +- .../common/components/links/index.test.tsx | 2 +- .../create_descriptions_list.test.tsx.snap | 1 + .../upgrade_contents.test.tsx.snap | 4 + .../public/common/components/page/index.tsx | 2 +- .../components/paginated_table/index.test.tsx | 6 +- .../components/sourcerer/index.test.tsx | 6 +- .../modal_all_errors.test.tsx.snap | 2 + .../components/error_callout/index.test.tsx | 2 +- .../value_with_space_warning.test.tsx.snap | 4 +- .../__snapshots__/index.test.tsx.snap | 2 + .../rules/select_rule_type/index.tsx | 24 + .../components/policy_form_layout.test.tsx | 4 +- .../components/policy_form_layout.tsx | 23 +- ...ndex_patterns_missing_prompt.test.tsx.snap | 1 + .../resolver/view/process_event_dot.tsx | 18 +- .../public/resolver/view/use_cube_assets.ts | 4 +- .../field_renderers.test.tsx.snap | 16 +- .../flyout/__snapshots__/index.test.tsx.snap | 6 +- .../netflow/__snapshots__/index.test.tsx.snap | 186 +- .../components/notes/add_note/index.test.tsx | 6 +- .../delete_timeline_modal.test.tsx | 2 +- .../components/open_timeline/index.test.tsx | 2 +- .../open_timeline/title_row/index.test.tsx | 2 +- .../__snapshots__/index.test.tsx.snap | 58 +- .../body/actions/add_note_icon_item.test.tsx | 4 +- .../body/actions/pin_event_action.test.tsx | 2 +- .../netflow_row_renderer.test.tsx.snap | 190 +- .../data_providers/provider_badge.tsx | 12 - .../components/timeline/footer/index.test.tsx | 2 +- .../properties/use_create_timeline.test.tsx | 4 +- .../__snapshots__/index.test.tsx.snap | 4 +- .../__snapshots__/index.test.tsx.snap | 28 +- .../components/session_view/index.test.tsx | 4 +- .../confirm_delete_modal.test.tsx | 2 +- .../delete_spaces_button.test.tsx.snap | 1 + .../manage_spaces_button.test.tsx.snap | 1 + .../expression/es_query_expression.test.tsx | 6 +- .../swimlane/swimlane_connectors.test.tsx | 8 +- .../lib/servicenow/sn_store_button.test.tsx | 2 +- .../lib/servicenow/update_connector.test.tsx | 2 +- .../waterfall/waterfall_filter.test.tsx | 10 +- .../__snapshots__/cert_monitors.test.tsx.snap | 6 +- .../__snapshots__/monitor_tags.test.tsx.snap | 36 +- .../ml_integerations.test.tsx.snap | 9 +- .../monitor/ml/ml_manage_job.test.tsx | 7 +- .../__snapshots__/expanded_row.test.tsx.snap | 2 +- .../monitor_status.bar.test.tsx.snap | 16 +- .../ssl_certificate.test.tsx.snap | 6 +- .../__snapshots__/tag_label.test.tsx.snap | 6 +- .../waterfall/waterfall_filter.test.tsx | 14 +- .../monitor_list/delete_monitor.test.tsx | 2 +- .../toggle_alert_flyout_button.test.tsx | 10 +- .../filter_status_button.test.tsx.snap | 2 +- .../__snapshots__/status_filter.test.tsx.snap | 6 +- .../most_recent_error.test.tsx.snap | 2 +- .../integrations_guard.test.tsx.snap | 11 +- .../copy_to_clipboard.test.tsx.snap | 12 +- .../__snapshots__/field.test.tsx.snap | 6 +- .../__snapshots__/tlp_badge.test.tsx.snap | 12 +- .../__snapshots__/filter_in.test.tsx.snap | 24 +- .../__snapshots__/filter_out.test.tsx.snap | 24 +- .../add_to_timeline.test.tsx.snap | 8 +- .../investigate_in_timeline.test.tsx.snap | 28 +- .../components/t_grid/footer/index.test.tsx | 2 +- .../tooltip_with_keyboard_shortcut/index.tsx | 4 +- .../__snapshots__/popover_form.test.tsx.snap | 2 + .../create_transform_button.test.tsx.snap | 2 + .../transform_list.test.tsx.snap | 1 + .../action_type_form.test.tsx | 2 +- .../actions_connectors_list.test.tsx | 4 +- .../alerts_flyout/alerts_flyout.test.tsx | 4 +- .../field_table/field_table.test.tsx | 2 +- .../field_browser_modal.test.tsx | 2 +- .../components/rule_details.test.tsx | 22 +- .../components/rule_error_log.test.tsx | 6 +- .../sections/rule_form/rule_add.test.tsx | 26 +- .../sections/rule_form/rule_edit.test.tsx | 4 +- .../rules_list/components/rules_list.test.tsx | 35 +- .../upgrade_step/upgrade_step.test.tsx | 2 +- .../checklist_step.test.tsx.snap | 1 + .../__snapshots__/warning_step.test.tsx.snap | 1 + .../reindex/flyout/_step_progress.scss | 16 +- .../__snapshots__/map_tooltip.test.tsx.snap | 6 +- .../apps/lens/group3/annotations.ts | 2 +- .../apps/lens/group3/reference_lines.ts | 9 +- .../test/functional/services/ml/swim_lane.ts | 2 +- .../test_suites/resolver/index.ts | 2 +- .../apps/endpoint/mocks.ts | 8 + yarn.lock | 8 +- 340 files changed, 6016 insertions(+), 6946 deletions(-) create mode 100644 src/plugins/vis_types/vega/public/components/vega_vis.styles.tsx create mode 100644 src/plugins/vis_types/vega/public/vega_view/vega_base_view.styles.ts create mode 100644 src/plugins/vis_types/vega/public/vega_view/vega_tooltip.styles.ts diff --git a/package.json b/package.json index a8a40fca02780..e3b06c55f284e 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ "@elastic/datemath": "5.0.3", "@elastic/elasticsearch": "npm:@elastic/elasticsearch-canary@8.4.0-canary.1", "@elastic/ems-client": "8.3.3", - "@elastic/eui": "64.0.5", + "@elastic/eui": "67.1.8", "@elastic/filesaver": "1.1.2", "@elastic/node-crypto": "1.2.1", "@elastic/numeral": "^2.5.1", diff --git a/packages/core/application/core-application-browser-internal/src/application_leave.tsx b/packages/core/application/core-application-browser-internal/src/application_leave.tsx index e6c7bc8b4ad7a..d2a82cbb129c8 100644 --- a/packages/core/application/core-application-browser-internal/src/application_leave.tsx +++ b/packages/core/application/core-application-browser-internal/src/application_leave.tsx @@ -5,7 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import type { ButtonColor } from '@elastic/eui'; +import type { EuiButtonColor } from '@elastic/eui'; import { AppLeaveActionType, type AppLeaveActionFactory, @@ -20,7 +20,7 @@ const appLeaveActionFactory: AppLeaveActionFactory = { title?: string, callback?: () => void, confirmButtonText?: string, - buttonColor?: ButtonColor + buttonColor?: EuiButtonColor ) { return { type: AppLeaveActionType.confirm, diff --git a/packages/core/application/core-application-browser/src/app_leave.ts b/packages/core/application/core-application-browser/src/app_leave.ts index 683d55296ff66..edc18c5ef8a48 100644 --- a/packages/core/application/core-application-browser/src/app_leave.ts +++ b/packages/core/application/core-application-browser/src/app_leave.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import type { ButtonColor } from '@elastic/eui'; +import type { EuiButtonColor } from '@elastic/eui'; /** * A handler that will be executed before leaving the application, either when @@ -61,7 +61,7 @@ export interface AppLeaveConfirmAction { text: string; title?: string; confirmButtonText?: string; - buttonColor?: ButtonColor; + buttonColor?: EuiButtonColor; callback?: () => void; } @@ -95,7 +95,7 @@ export interface AppLeaveActionFactory { title?: string, callback?: () => void, confirmButtonText?: string, - buttonColor?: ButtonColor + buttonColor?: EuiButtonColor ): AppLeaveConfirmAction; /** diff --git a/packages/core/apps/core-apps-browser-internal/src/status/components/__snapshots__/server_status.test.tsx.snap b/packages/core/apps/core-apps-browser-internal/src/status/components/__snapshots__/server_status.test.tsx.snap index bcc60f5908592..60d1bad95e3e1 100644 --- a/packages/core/apps/core-apps-browser-internal/src/status/components/__snapshots__/server_status.test.tsx.snap +++ b/packages/core/apps/core-apps-browser-internal/src/status/components/__snapshots__/server_status.test.tsx.snap @@ -1,100 +1,61 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`ServerStatus renders correctly for green state 2`] = ` - - + - - - Green - - + Green - - + + `; exports[`ServerStatus renders correctly for red state 2`] = ` - - + - - - Red - - + Red - - + + `; exports[`ServerStatus renders correctly for yellow state 2`] = ` - - + - - - Yellow - - + Yellow - - + + `; diff --git a/packages/core/apps/core-apps-browser-internal/src/status/components/server_status.test.tsx b/packages/core/apps/core-apps-browser-internal/src/status/components/server_status.test.tsx index 13e6a36a65cfd..af27e2ba54ea2 100644 --- a/packages/core/apps/core-apps-browser-internal/src/status/components/server_status.test.tsx +++ b/packages/core/apps/core-apps-browser-internal/src/status/components/server_status.test.tsx @@ -24,7 +24,7 @@ describe('ServerStatus', () => { const status = getStatus(); const component = mount(); expect(component.find('EuiTitle').text()).toMatchInlineSnapshot(`"Kibana status is Green"`); - expect(component.find('EuiBadge')).toMatchSnapshot(); + expect(component.find('EuiBadge').render()).toMatchSnapshot(); }); it('renders correctly for yellow state', () => { @@ -34,7 +34,7 @@ describe('ServerStatus', () => { }); const component = mount(); expect(component.find('EuiTitle').text()).toMatchInlineSnapshot(`"Kibana status is Yellow"`); - expect(component.find('EuiBadge')).toMatchSnapshot(); + expect(component.find('EuiBadge').render()).toMatchSnapshot(); }); it('renders correctly for red state', () => { @@ -44,7 +44,7 @@ describe('ServerStatus', () => { }); const component = mount(); expect(component.find('EuiTitle').text()).toMatchInlineSnapshot(`"Kibana status is Red"`); - expect(component.find('EuiBadge')).toMatchSnapshot(); + expect(component.find('EuiBadge').render()).toMatchSnapshot(); }); it('displays the correct `name`', () => { diff --git a/packages/core/chrome/core-chrome-browser-internal/src/ui/header/__snapshots__/collapsible_nav.test.tsx.snap b/packages/core/chrome/core-chrome-browser-internal/src/ui/header/__snapshots__/collapsible_nav.test.tsx.snap index 63e5e8dd748be..1475e5fbedd01 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/ui/header/__snapshots__/collapsible_nav.test.tsx.snap +++ b/packages/core/chrome/core-chrome-browser-internal/src/ui/header/__snapshots__/collapsible_nav.test.tsx.snap @@ -136,7 +136,7 @@ Array [ aria-controls="generated-id" aria-expanded="true" aria-labelledby="generated-id" - class="euiButtonIcon euiButtonIcon--text euiButtonIcon--empty euiButtonIcon--xSmall euiAccordion__iconButton euiAccordion__iconButton-isOpen euiAccordion__iconButton--right emotion-euiAccordion__iconButton-isOpen-arrowRight" + class="euiButtonIcon euiButtonIcon--xSmall euiAccordion__iconButton euiAccordion__iconButton-isOpen euiAccordion__iconButton--right emotion-euiButtonIcon-empty-text-hoverStyles-euiAccordion__iconButton-isOpen-arrowRight" tabindex="-1" type="button" > @@ -261,7 +261,7 @@ Array [ aria-controls="generated-id" aria-expanded="true" aria-labelledby="generated-id" - class="euiButtonIcon euiButtonIcon--text euiButtonIcon--empty euiButtonIcon--xSmall euiAccordion__iconButton euiAccordion__iconButton-isOpen euiAccordion__iconButton--right emotion-euiAccordion__iconButton-isOpen-arrowRight" + class="euiButtonIcon euiButtonIcon--xSmall euiAccordion__iconButton euiAccordion__iconButton-isOpen euiAccordion__iconButton--right emotion-euiButtonIcon-empty-text-hoverStyles-euiAccordion__iconButton-isOpen-arrowRight" tabindex="-1" type="button" > @@ -393,7 +393,7 @@ Array [ aria-controls="generated-id" aria-expanded="true" aria-labelledby="generated-id" - class="euiButtonIcon euiButtonIcon--text euiButtonIcon--empty euiButtonIcon--xSmall euiAccordion__iconButton euiAccordion__iconButton-isOpen euiAccordion__iconButton--right emotion-euiAccordion__iconButton-isOpen-arrowRight" + class="euiButtonIcon euiButtonIcon--xSmall euiAccordion__iconButton euiAccordion__iconButton-isOpen euiAccordion__iconButton--right emotion-euiButtonIcon-empty-text-hoverStyles-euiAccordion__iconButton-isOpen-arrowRight" tabindex="-1" type="button" > @@ -508,7 +508,7 @@ Array [ aria-controls="generated-id" aria-expanded="true" aria-labelledby="generated-id" - class="euiButtonIcon euiButtonIcon--text euiButtonIcon--empty euiButtonIcon--xSmall euiAccordion__iconButton euiAccordion__iconButton-isOpen euiAccordion__iconButton--right emotion-euiAccordion__iconButton-isOpen-arrowRight" + class="euiButtonIcon euiButtonIcon--xSmall euiAccordion__iconButton euiAccordion__iconButton-isOpen euiAccordion__iconButton--right emotion-euiButtonIcon-empty-text-hoverStyles-euiAccordion__iconButton-isOpen-arrowRight" tabindex="-1" type="button" > @@ -606,7 +606,7 @@ Array [ aria-controls="generated-id" aria-expanded="true" aria-labelledby="generated-id" - class="euiButtonIcon euiButtonIcon--text euiButtonIcon--empty euiButtonIcon--xSmall euiAccordion__iconButton euiAccordion__iconButton-isOpen euiAccordion__iconButton--right emotion-euiAccordion__iconButton-isOpen-arrowRight" + class="euiButtonIcon euiButtonIcon--xSmall euiAccordion__iconButton euiAccordion__iconButton-isOpen euiAccordion__iconButton--right emotion-euiButtonIcon-empty-text-hoverStyles-euiAccordion__iconButton-isOpen-arrowRight" tabindex="-1" type="button" > diff --git a/packages/core/chrome/core-chrome-browser-internal/src/ui/header/__snapshots__/header.test.tsx.snap b/packages/core/chrome/core-chrome-browser-internal/src/ui/header/__snapshots__/header.test.tsx.snap index 2a42112f30a81..aa77a58ad7202 100644 --- a/packages/core/chrome/core-chrome-browser-internal/src/ui/header/__snapshots__/header.test.tsx.snap +++ b/packages/core/chrome/core-chrome-browser-internal/src/ui/header/__snapshots__/header.test.tsx.snap @@ -83,7 +83,7 @@ exports[`Header renders 1`] = ` aria-expanded="false" aria-haspopup="true" aria-label="Help menu" - class="euiButtonEmpty euiButtonEmpty--text euiHeaderSectionItemButton" + class="euiButtonEmpty euiHeaderSectionItemButton css-wvaqcf-empty-text" type="button" >
@@ -307,7 +303,7 @@ exports[`ModalService openConfirm() renders a string confirm message 2`] = ` >
-
-

- Some message -

-
+

+ Some message +

-
+
+ - + -
+ +
@@ -1078,7 +1070,7 @@ exports[`ModalService openModal() renders a modal to the DOM 2`] = ` >
-
- - Modal content - -
+ + Modal content +
diff --git a/packages/home/sample_data_card/src/__snapshots__/sample_data_card.test.tsx.snap b/packages/home/sample_data_card/src/__snapshots__/sample_data_card.test.tsx.snap index 0e1f5bc5b45f1..1a32d9ace74f4 100644 --- a/packages/home/sample_data_card/src/__snapshots__/sample_data_card.test.tsx.snap +++ b/packages/home/sample_data_card/src/__snapshots__/sample_data_card.test.tsx.snap @@ -2,14 +2,14 @@ exports[`SampleDataCard installed renders with app links 1`] = `
- - Sample Data Set - + + Sample Data Set + +

@@ -36,10 +40,10 @@ exports[`SampleDataCard installed renders with app links 1`] = `

@@ -47,7 +51,7 @@ exports[`SampleDataCard installed renders with app links 1`] = `
diff --git a/packages/kbn-securitysolution-exception-list-components/src/list_header/menu_items/__snapshots__/menu_items.test.tsx.snap b/packages/kbn-securitysolution-exception-list-components/src/list_header/menu_items/__snapshots__/menu_items.test.tsx.snap index 3dfd3002393ce..be9608a44a7c7 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/list_header/menu_items/__snapshots__/menu_items.test.tsx.snap +++ b/packages/kbn-securitysolution-exception-list-components/src/list_header/menu_items/__snapshots__/menu_items.test.tsx.snap @@ -23,15 +23,15 @@ Object { class="euiFlexItem" > - - - - + Add from library + + + `; diff --git a/packages/shared-ux/button_toolbar/src/buttons/add_from_library/add_from_library.test.tsx b/packages/shared-ux/button_toolbar/src/buttons/add_from_library/add_from_library.test.tsx index f573f6654e664..f59760278cb14 100644 --- a/packages/shared-ux/button_toolbar/src/buttons/add_from_library/add_from_library.test.tsx +++ b/packages/shared-ux/button_toolbar/src/buttons/add_from_library/add_from_library.test.tsx @@ -14,6 +14,6 @@ import { AddFromLibraryButton } from './add_from_library'; describe('', () => { test('is rendered', () => { const component = mountWithIntl(); - expect(component).toMatchSnapshot(); + expect(component.render()).toMatchSnapshot(); }); }); diff --git a/packages/shared-ux/button_toolbar/src/buttons/icon_button_group/__snapshots__/icon_button_group.test.tsx.snap b/packages/shared-ux/button_toolbar/src/buttons/icon_button_group/__snapshots__/icon_button_group.test.tsx.snap index 085a547b14d91..277373c0cae07 100644 --- a/packages/shared-ux/button_toolbar/src/buttons/icon_button_group/__snapshots__/icon_button_group.test.tsx.snap +++ b/packages/shared-ux/button_toolbar/src/buttons/icon_button_group/__snapshots__/icon_button_group.test.tsx.snap @@ -1,536 +1,42 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[` is rendered 1`] = ` - - -
+
+ - - - -
-
-
-
+ Text + + + +
+ `; diff --git a/packages/shared-ux/button_toolbar/src/buttons/icon_button_group/icon_button_group.test.tsx b/packages/shared-ux/button_toolbar/src/buttons/icon_button_group/icon_button_group.test.tsx index db5722986bcc0..c9957c70ad2e6 100644 --- a/packages/shared-ux/button_toolbar/src/buttons/icon_button_group/icon_button_group.test.tsx +++ b/packages/shared-ux/button_toolbar/src/buttons/icon_button_group/icon_button_group.test.tsx @@ -26,6 +26,6 @@ describe('', () => { /> ); - expect(component).toMatchSnapshot(); + expect(component.render()).toMatchSnapshot(); }); }); diff --git a/packages/shared-ux/button_toolbar/src/buttons/primary/__snapshots__/primary.test.tsx.snap b/packages/shared-ux/button_toolbar/src/buttons/primary/__snapshots__/primary.test.tsx.snap index 3f00e19ce3a91..e721b91c10f81 100644 --- a/packages/shared-ux/button_toolbar/src/buttons/primary/__snapshots__/primary.test.tsx.snap +++ b/packages/shared-ux/button_toolbar/src/buttons/primary/__snapshots__/primary.test.tsx.snap @@ -1,465 +1,19 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[` is rendered 1`] = ` - - - - - - - + test + + + `; diff --git a/packages/shared-ux/button_toolbar/src/buttons/primary/primary.test.tsx b/packages/shared-ux/button_toolbar/src/buttons/primary/primary.test.tsx index 10cbe7cb4dff0..8478ca1842c41 100644 --- a/packages/shared-ux/button_toolbar/src/buttons/primary/primary.test.tsx +++ b/packages/shared-ux/button_toolbar/src/buttons/primary/primary.test.tsx @@ -14,13 +14,13 @@ import { PrimaryButton } from './primary'; describe('', () => { test('is rendered', () => { const component = mountWithIntl(); - expect(component).toMatchSnapshot(); + expect(component.render()).toMatchSnapshot(); }); test('it can be passed a functional onClick handler', () => { const mockHandler = jest.fn(); const component = mountWithIntl(); - component.simulate('click'); + component.find('button').simulate('click'); expect(mockHandler).toHaveBeenCalled(); }); }); diff --git a/packages/shared-ux/button_toolbar/src/popover/__snapshots__/popover.test.tsx.snap b/packages/shared-ux/button_toolbar/src/popover/__snapshots__/popover.test.tsx.snap index 319ea90381f9e..dd63af4b39fac 100644 --- a/packages/shared-ux/button_toolbar/src/popover/__snapshots__/popover.test.tsx.snap +++ b/packages/shared-ux/button_toolbar/src/popover/__snapshots__/popover.test.tsx.snap @@ -9,14 +9,14 @@ exports[` is rendered 1`] = ` class="euiPopover__anchor css-16vtueo-render" > - - - - -
- -
- -
- -
- -
-
- + Create chart + + + + +
+
+
+
`; diff --git a/packages/shared-ux/button_toolbar/src/toolbar/toolbar.test.tsx b/packages/shared-ux/button_toolbar/src/toolbar/toolbar.test.tsx index 545ed8d4f7b93..729695edab5e8 100644 --- a/packages/shared-ux/button_toolbar/src/toolbar/toolbar.test.tsx +++ b/packages/shared-ux/button_toolbar/src/toolbar/toolbar.test.tsx @@ -18,7 +18,7 @@ describe('', () => { const children = { primaryButton }; const component = mountWithIntl(); - expect(component).toMatchSnapshot(); + expect(component.render()).toMatchSnapshot(); }); test('onClick works as expected when the primary button is clicked', () => { diff --git a/packages/shared-ux/card/no_data/impl/src/__snapshots__/no_data_card.component.test.tsx.snap b/packages/shared-ux/card/no_data/impl/src/__snapshots__/no_data_card.component.test.tsx.snap index 17eb4ef8804cd..a74b8433433cb 100644 --- a/packages/shared-ux/card/no_data/impl/src/__snapshots__/no_data_card.component.test.tsx.snap +++ b/packages/shared-ux/card/no_data/impl/src/__snapshots__/no_data_card.component.test.tsx.snap @@ -11,7 +11,9 @@ exports[`NoDataCardComponent props button 1`] = ` description="Use Elastic Agent for a simple, unified way to collect data from your machines." footer={ Button @@ -40,7 +42,9 @@ exports[`NoDataCardComponent props href 1`] = ` description="Use Elastic Agent for a simple, unified way to collect data from your machines." footer={ Add Elastic Agent @@ -70,7 +74,9 @@ exports[`NoDataCardComponent renders 1`] = ` description="Use Elastic Agent for a simple, unified way to collect data from your machines." footer={ Add Elastic Agent diff --git a/packages/shared-ux/card/no_data/impl/src/__snapshots__/no_data_card.test.tsx.snap b/packages/shared-ux/card/no_data/impl/src/__snapshots__/no_data_card.test.tsx.snap index 949570f3e8b5a..89967755cbb11 100644 --- a/packages/shared-ux/card/no_data/impl/src/__snapshots__/no_data_card.test.tsx.snap +++ b/packages/shared-ux/card/no_data/impl/src/__snapshots__/no_data_card.test.tsx.snap @@ -3,13 +3,13 @@ exports[`NoDataCard props button 1`] = `
- @@ -42,9 +42,9 @@ exports[`NoDataCard props button 1`] = ` Card title - +

@@ -53,17 +53,17 @@ exports[`NoDataCard props button 1`] = `