From 38a647539643700d66179532bec78d62ffd8676c Mon Sep 17 00:00:00 2001 From: Zacqary Adam Xeper Date: Tue, 9 Feb 2021 20:30:25 -0600 Subject: [PATCH 01/14] Revert "Revert "[Metrics UI] Add Metrics Anomaly Alert Type (#89244)"" (#90889) * Revert "Revert "[Metrics UI] Add Metrics Anomaly Alert Type (#89244)"" This reverts commit 8166becc5555f132636bc1e8662370d1b4bf7b6a. * Fix type error --- .../infra/common/alerting/metrics/types.ts | 43 ++- .../infra/common/infra_ml/anomaly_results.ts | 56 +-- .../common/components/alert_preview.tsx | 5 +- .../common/components/get_alert_preview.ts | 4 +- .../components/metrics_alert_dropdown.tsx | 151 +++++++++ .../inventory/components/alert_dropdown.tsx | 59 ---- .../inventory/components/alert_flyout.tsx | 18 +- .../inventory/components/node_type.tsx | 2 +- .../components/alert_flyout.tsx | 53 +++ .../components/expression.test.tsx | 74 ++++ .../metric_anomaly/components/expression.tsx | 320 ++++++++++++++++++ .../components/influencer_filter.tsx | 193 +++++++++++ .../metric_anomaly/components/node_type.tsx | 117 +++++++ .../components/severity_threshold.tsx | 140 ++++++++ .../metric_anomaly/components/validation.tsx | 35 ++ .../public/alerting/metric_anomaly/index.ts | 46 +++ .../components/alert_dropdown.tsx | 57 ---- .../components/alert_flyout.tsx | 11 +- .../logging/log_analysis_setup/index.ts | 1 - .../subscription_splash_content.tsx | 176 ---------- .../source_configuration_settings.tsx | 4 +- .../subscription_splash_content.tsx | 110 +++--- .../containers/ml/infra_ml_capabilities.tsx | 4 +- .../containers/with_kuery_autocompletion.tsx | 11 +- .../log_entry_categories/page_content.tsx | 2 +- .../logs/log_entry_rate/page_content.tsx | 2 +- .../infra/public/pages/metrics/index.tsx | 8 +- ...lyout.tsx => anomaly_detection_flyout.tsx} | 0 .../ml/anomaly_detection/flyout_home.tsx | 6 +- .../metrics_explorer/components/kuery_bar.tsx | 21 +- x-pack/plugins/infra/public/plugin.ts | 2 + x-pack/plugins/infra/public/types.ts | 3 +- .../metric_anomaly/evaluate_condition.ts | 51 +++ .../metric_anomaly/metric_anomaly_executor.ts | 142 ++++++++ .../preview_metric_anomaly_alert.ts | 120 +++++++ .../register_metric_anomaly_alert_type.ts | 110 ++++++ .../lib/alerting/register_alert_types.ts | 10 +- .../infra/server/lib/infra_ml/common.ts | 17 + .../infra/server/lib/infra_ml/index.ts | 1 + .../lib/infra_ml/metrics_hosts_anomalies.ts | 39 +-- .../lib/infra_ml/metrics_k8s_anomalies.ts | 39 +-- .../server/lib/infra_ml/queries/common.ts | 32 ++ .../queries/metrics_hosts_anomalies.ts | 12 +- .../infra_ml/queries/metrics_k8s_anomalies.ts | 12 +- x-pack/plugins/infra/server/plugin.ts | 2 +- .../infra/server/routes/alerting/preview.ts | 51 ++- .../results/metrics_hosts_anomalies.ts | 2 +- .../infra_ml/results/metrics_k8s_anomalies.ts | 2 +- .../translations/translations/ja-JP.json | 11 - .../translations/translations/zh-CN.json | 11 - 50 files changed, 1918 insertions(+), 480 deletions(-) create mode 100644 x-pack/plugins/infra/public/alerting/common/components/metrics_alert_dropdown.tsx delete mode 100644 x-pack/plugins/infra/public/alerting/inventory/components/alert_dropdown.tsx create mode 100644 x-pack/plugins/infra/public/alerting/metric_anomaly/components/alert_flyout.tsx create mode 100644 x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.test.tsx create mode 100644 x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.tsx create mode 100644 x-pack/plugins/infra/public/alerting/metric_anomaly/components/influencer_filter.tsx create mode 100644 x-pack/plugins/infra/public/alerting/metric_anomaly/components/node_type.tsx create mode 100644 x-pack/plugins/infra/public/alerting/metric_anomaly/components/severity_threshold.tsx create mode 100644 x-pack/plugins/infra/public/alerting/metric_anomaly/components/validation.tsx create mode 100644 x-pack/plugins/infra/public/alerting/metric_anomaly/index.ts delete mode 100644 x-pack/plugins/infra/public/alerting/metric_threshold/components/alert_dropdown.tsx delete mode 100644 x-pack/plugins/infra/public/components/logging/log_analysis_setup/subscription_splash_content.tsx rename x-pack/plugins/infra/public/{pages/metrics/inventory_view/components/ml/anomaly_detection => components}/subscription_splash_content.tsx (58%) rename x-pack/plugins/infra/public/pages/metrics/inventory_view/components/ml/anomaly_detection/{anomoly_detection_flyout.tsx => anomaly_detection_flyout.tsx} (100%) create mode 100644 x-pack/plugins/infra/server/lib/alerting/metric_anomaly/evaluate_condition.ts create mode 100644 x-pack/plugins/infra/server/lib/alerting/metric_anomaly/metric_anomaly_executor.ts create mode 100644 x-pack/plugins/infra/server/lib/alerting/metric_anomaly/preview_metric_anomaly_alert.ts create mode 100644 x-pack/plugins/infra/server/lib/alerting/metric_anomaly/register_metric_anomaly_alert_type.ts diff --git a/x-pack/plugins/infra/common/alerting/metrics/types.ts b/x-pack/plugins/infra/common/alerting/metrics/types.ts index a89f82e931fd4..7a4edb8f49189 100644 --- a/x-pack/plugins/infra/common/alerting/metrics/types.ts +++ b/x-pack/plugins/infra/common/alerting/metrics/types.ts @@ -4,14 +4,15 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import * as rt from 'io-ts'; +import { ANOMALY_THRESHOLD } from '../../infra_ml'; import { ItemTypeRT } from '../../inventory_models/types'; // TODO: Have threshold and inventory alerts import these types from this file instead of from their // local directories export const METRIC_THRESHOLD_ALERT_TYPE_ID = 'metrics.alert.threshold'; export const METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID = 'metrics.alert.inventory.threshold'; +export const METRIC_ANOMALY_ALERT_TYPE_ID = 'metrics.alert.anomaly'; export enum Comparator { GT = '>', @@ -34,6 +35,26 @@ export enum Aggregators { P99 = 'p99', } +const metricAnomalyNodeTypeRT = rt.union([rt.literal('hosts'), rt.literal('k8s')]); +const metricAnomalyMetricRT = rt.union([ + rt.literal('memory_usage'), + rt.literal('network_in'), + rt.literal('network_out'), +]); +const metricAnomalyInfluencerFilterRT = rt.type({ + fieldName: rt.string, + fieldValue: rt.string, +}); + +export interface MetricAnomalyParams { + nodeType: rt.TypeOf; + metric: rt.TypeOf; + alertInterval?: string; + sourceId?: string; + threshold: Exclude; + influencerFilter: rt.TypeOf | undefined; +} + // Alert Preview API const baseAlertRequestParamsRT = rt.intersection([ rt.partial({ @@ -51,7 +72,6 @@ const baseAlertRequestParamsRT = rt.intersection([ rt.literal('M'), rt.literal('y'), ]), - criteria: rt.array(rt.any), alertInterval: rt.string, alertThrottle: rt.string, alertOnNoData: rt.boolean, @@ -65,6 +85,7 @@ const metricThresholdAlertPreviewRequestParamsRT = rt.intersection([ }), rt.type({ alertType: rt.literal(METRIC_THRESHOLD_ALERT_TYPE_ID), + criteria: rt.array(rt.any), }), ]); export type MetricThresholdAlertPreviewRequestParams = rt.TypeOf< @@ -76,15 +97,33 @@ const inventoryAlertPreviewRequestParamsRT = rt.intersection([ rt.type({ nodeType: ItemTypeRT, alertType: rt.literal(METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID), + criteria: rt.array(rt.any), }), ]); export type InventoryAlertPreviewRequestParams = rt.TypeOf< typeof inventoryAlertPreviewRequestParamsRT >; +const metricAnomalyAlertPreviewRequestParamsRT = rt.intersection([ + baseAlertRequestParamsRT, + rt.type({ + nodeType: metricAnomalyNodeTypeRT, + metric: metricAnomalyMetricRT, + threshold: rt.number, + alertType: rt.literal(METRIC_ANOMALY_ALERT_TYPE_ID), + }), + rt.partial({ + influencerFilter: metricAnomalyInfluencerFilterRT, + }), +]); +export type MetricAnomalyAlertPreviewRequestParams = rt.TypeOf< + typeof metricAnomalyAlertPreviewRequestParamsRT +>; + export const alertPreviewRequestParamsRT = rt.union([ metricThresholdAlertPreviewRequestParamsRT, inventoryAlertPreviewRequestParamsRT, + metricAnomalyAlertPreviewRequestParamsRT, ]); export type AlertPreviewRequestParams = rt.TypeOf; diff --git a/x-pack/plugins/infra/common/infra_ml/anomaly_results.ts b/x-pack/plugins/infra/common/infra_ml/anomaly_results.ts index 589e57a1388b5..81e46d85ba220 100644 --- a/x-pack/plugins/infra/common/infra_ml/anomaly_results.ts +++ b/x-pack/plugins/infra/common/infra_ml/anomaly_results.ts @@ -5,36 +5,44 @@ * 2.0. */ -export const ML_SEVERITY_SCORES = { - warning: 3, - minor: 25, - major: 50, - critical: 75, -}; +export enum ANOMALY_SEVERITY { + CRITICAL = 'critical', + MAJOR = 'major', + MINOR = 'minor', + WARNING = 'warning', + LOW = 'low', + UNKNOWN = 'unknown', +} -export type MLSeverityScoreCategories = keyof typeof ML_SEVERITY_SCORES; +export enum ANOMALY_THRESHOLD { + CRITICAL = 75, + MAJOR = 50, + MINOR = 25, + WARNING = 3, + LOW = 0, +} -export const ML_SEVERITY_COLORS = { - critical: 'rgb(228, 72, 72)', - major: 'rgb(229, 113, 0)', - minor: 'rgb(255, 221, 0)', - warning: 'rgb(125, 180, 226)', +export const SEVERITY_COLORS = { + CRITICAL: '#fe5050', + MAJOR: '#fba740', + MINOR: '#fdec25', + WARNING: '#8bc8fb', + LOW: '#d2e9f7', + BLANK: '#ffffff', }; -export const getSeverityCategoryForScore = ( - score: number -): MLSeverityScoreCategories | undefined => { - if (score >= ML_SEVERITY_SCORES.critical) { - return 'critical'; - } else if (score >= ML_SEVERITY_SCORES.major) { - return 'major'; - } else if (score >= ML_SEVERITY_SCORES.minor) { - return 'minor'; - } else if (score >= ML_SEVERITY_SCORES.warning) { - return 'warning'; +export const getSeverityCategoryForScore = (score: number): ANOMALY_SEVERITY | undefined => { + if (score >= ANOMALY_THRESHOLD.CRITICAL) { + return ANOMALY_SEVERITY.CRITICAL; + } else if (score >= ANOMALY_THRESHOLD.MAJOR) { + return ANOMALY_SEVERITY.MAJOR; + } else if (score >= ANOMALY_THRESHOLD.MINOR) { + return ANOMALY_SEVERITY.MINOR; + } else if (score >= ANOMALY_THRESHOLD.WARNING) { + return ANOMALY_SEVERITY.WARNING; } else { // Category is too low to include - return undefined; + return ANOMALY_SEVERITY.LOW; } }; diff --git a/x-pack/plugins/infra/public/alerting/common/components/alert_preview.tsx b/x-pack/plugins/infra/public/alerting/common/components/alert_preview.tsx index fac87e20dfe7d..57c6f695453ef 100644 --- a/x-pack/plugins/infra/public/alerting/common/components/alert_preview.tsx +++ b/x-pack/plugins/infra/public/alerting/common/components/alert_preview.tsx @@ -37,7 +37,7 @@ interface Props { alertInterval: string; alertThrottle: string; alertType: PreviewableAlertTypes; - alertParams: { criteria: any[]; sourceId: string } & Record; + alertParams: { criteria?: any[]; sourceId: string } & Record; validate: (params: any) => ValidationResult; showNoDataResults?: boolean; groupByDisplayName?: string; @@ -109,6 +109,7 @@ export const AlertPreview: React.FC = (props) => { }, [previewLookbackInterval, alertInterval]); const isPreviewDisabled = useMemo(() => { + if (!alertParams.criteria) return false; const validationResult = validate({ criteria: alertParams.criteria } as any); const hasValidationErrors = Object.values(validationResult.errors).some((result) => Object.values(result).some((arr) => Array.isArray(arr) && arr.length) @@ -124,7 +125,7 @@ export const AlertPreview: React.FC = (props) => { }, [previewResult, showNoDataResults]); const hasWarningThreshold = useMemo( - () => alertParams.criteria?.some((c) => Reflect.has(c, 'warningThreshold')), + () => alertParams.criteria?.some((c) => Reflect.has(c, 'warningThreshold')) ?? false, [alertParams] ); diff --git a/x-pack/plugins/infra/public/alerting/common/components/get_alert_preview.ts b/x-pack/plugins/infra/public/alerting/common/components/get_alert_preview.ts index a1cee1361a18f..2bb98e83cbe70 100644 --- a/x-pack/plugins/infra/public/alerting/common/components/get_alert_preview.ts +++ b/x-pack/plugins/infra/public/alerting/common/components/get_alert_preview.ts @@ -10,13 +10,15 @@ import { INFRA_ALERT_PREVIEW_PATH, METRIC_THRESHOLD_ALERT_TYPE_ID, METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID, + METRIC_ANOMALY_ALERT_TYPE_ID, AlertPreviewRequestParams, AlertPreviewSuccessResponsePayload, } from '../../../../common/alerting/metrics'; export type PreviewableAlertTypes = | typeof METRIC_THRESHOLD_ALERT_TYPE_ID - | typeof METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID; + | typeof METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID + | typeof METRIC_ANOMALY_ALERT_TYPE_ID; export async function getAlertPreview({ fetch, diff --git a/x-pack/plugins/infra/public/alerting/common/components/metrics_alert_dropdown.tsx b/x-pack/plugins/infra/public/alerting/common/components/metrics_alert_dropdown.tsx new file mode 100644 index 0000000000000..f1236c4fc2c2b --- /dev/null +++ b/x-pack/plugins/infra/public/alerting/common/components/metrics_alert_dropdown.tsx @@ -0,0 +1,151 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import React, { useState, useCallback, useMemo } from 'react'; +import { + EuiPopover, + EuiButtonEmpty, + EuiContextMenu, + EuiContextMenuPanelDescriptor, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useInfraMLCapabilities } from '../../../containers/ml/infra_ml_capabilities'; +import { PrefilledInventoryAlertFlyout } from '../../inventory/components/alert_flyout'; +import { PrefilledThresholdAlertFlyout } from '../../metric_threshold/components/alert_flyout'; +import { PrefilledAnomalyAlertFlyout } from '../../metric_anomaly/components/alert_flyout'; +import { useLinkProps } from '../../../hooks/use_link_props'; + +type VisibleFlyoutType = 'inventory' | 'threshold' | 'anomaly' | null; + +export const MetricsAlertDropdown = () => { + const [popoverOpen, setPopoverOpen] = useState(false); + const [visibleFlyoutType, setVisibleFlyoutType] = useState(null); + const { hasInfraMLCapabilities } = useInfraMLCapabilities(); + + const closeFlyout = useCallback(() => setVisibleFlyoutType(null), [setVisibleFlyoutType]); + + const manageAlertsLinkProps = useLinkProps({ + app: 'management', + pathname: '/insightsAndAlerting/triggersActions/alerts', + }); + + const panels: EuiContextMenuPanelDescriptor[] = useMemo( + () => [ + { + id: 0, + title: i18n.translate('xpack.infra.alerting.alertDropdownTitle', { + defaultMessage: 'Alerts', + }), + items: [ + { + name: i18n.translate('xpack.infra.alerting.infrastructureDropdownMenu', { + defaultMessage: 'Infrastructure', + }), + panel: 1, + }, + { + name: i18n.translate('xpack.infra.alerting.metricsDropdownMenu', { + defaultMessage: 'Metrics', + }), + panel: 2, + }, + { + name: i18n.translate('xpack.infra.alerting.manageAlerts', { + defaultMessage: 'Manage alerts', + }), + icon: 'tableOfContents', + onClick: manageAlertsLinkProps.onClick, + }, + ], + }, + { + id: 1, + title: i18n.translate('xpack.infra.alerting.infrastructureDropdownTitle', { + defaultMessage: 'Infrastructure alerts', + }), + items: [ + { + name: i18n.translate('xpack.infra.alerting.createInventoryAlertButton', { + defaultMessage: 'Create inventory alert', + }), + onClick: () => setVisibleFlyoutType('inventory'), + }, + ].concat( + hasInfraMLCapabilities + ? { + name: i18n.translate('xpack.infra.alerting.createAnomalyAlertButton', { + defaultMessage: 'Create anomaly alert', + }), + onClick: () => setVisibleFlyoutType('anomaly'), + } + : [] + ), + }, + { + id: 2, + title: i18n.translate('xpack.infra.alerting.metricsDropdownTitle', { + defaultMessage: 'Metrics alerts', + }), + items: [ + { + name: i18n.translate('xpack.infra.alerting.createThresholdAlertButton', { + defaultMessage: 'Create threshold alert', + }), + onClick: () => setVisibleFlyoutType('threshold'), + }, + ], + }, + ], + [manageAlertsLinkProps, setVisibleFlyoutType, hasInfraMLCapabilities] + ); + + const closePopover = useCallback(() => { + setPopoverOpen(false); + }, [setPopoverOpen]); + + const openPopover = useCallback(() => { + setPopoverOpen(true); + }, [setPopoverOpen]); + + return ( + <> + + + + } + isOpen={popoverOpen} + closePopover={closePopover} + > + + + + + ); +}; + +interface AlertFlyoutProps { + visibleFlyoutType: VisibleFlyoutType; + onClose(): void; +} + +const AlertFlyout = ({ visibleFlyoutType, onClose }: AlertFlyoutProps) => { + switch (visibleFlyoutType) { + case 'inventory': + return ; + case 'threshold': + return ; + case 'anomaly': + return ; + default: + return null; + } +}; diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/alert_dropdown.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/alert_dropdown.tsx deleted file mode 100644 index a7b6c9fb7104c..0000000000000 --- a/x-pack/plugins/infra/public/alerting/inventory/components/alert_dropdown.tsx +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useState, useCallback } from 'react'; -import { EuiPopover, EuiButtonEmpty, EuiContextMenuItem, EuiContextMenuPanel } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { useAlertPrefillContext } from '../../../alerting/use_alert_prefill'; -import { AlertFlyout } from './alert_flyout'; -import { ManageAlertsContextMenuItem } from './manage_alerts_context_menu_item'; - -export const InventoryAlertDropdown = () => { - const [popoverOpen, setPopoverOpen] = useState(false); - const [flyoutVisible, setFlyoutVisible] = useState(false); - - const { inventoryPrefill } = useAlertPrefillContext(); - const { nodeType, metric, filterQuery } = inventoryPrefill; - - const closePopover = useCallback(() => { - setPopoverOpen(false); - }, [setPopoverOpen]); - - const openPopover = useCallback(() => { - setPopoverOpen(true); - }, [setPopoverOpen]); - - const menuItems = [ - setFlyoutVisible(true)}> - - , - , - ]; - - return ( - <> - - - - } - isOpen={popoverOpen} - closePopover={closePopover} - > - - - - - ); -}; diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/alert_flyout.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/alert_flyout.tsx index 815e1f2be33f2..33fe3c7af30c7 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/components/alert_flyout.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/alert_flyout.tsx @@ -8,8 +8,7 @@ import React, { useCallback, useContext, useMemo } from 'react'; import { TriggerActionsContext } from '../../../utils/triggers_actions_context'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID } from '../../../../server/lib/alerting/inventory_metric_threshold/types'; +import { METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID } from '../../../../common/alerting/metrics'; import { InfraWaffleMapOptions } from '../../../lib/lib'; import { InventoryItemType } from '../../../../common/inventory_models/types'; import { useAlertPrefillContext } from '../../../alerting/use_alert_prefill'; @@ -49,3 +48,18 @@ export const AlertFlyout = ({ options, nodeType, filter, visible, setVisible }: return <>{visible && AddAlertFlyout}; }; + +export const PrefilledInventoryAlertFlyout = ({ onClose }: { onClose(): void }) => { + const { inventoryPrefill } = useAlertPrefillContext(); + const { nodeType, metric, filterQuery } = inventoryPrefill; + + return ( + + ); +}; diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/node_type.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/node_type.tsx index f02f98c49f01a..bd7812acac678 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/components/node_type.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/node_type.tsx @@ -68,7 +68,7 @@ export const NodeTypeExpression = ({ setAggTypePopoverOpen(false)}> { + const { triggersActionsUI } = useContext(TriggerActionsContext); + + const onCloseFlyout = useCallback(() => setVisible(false), [setVisible]); + const AddAlertFlyout = useMemo( + () => + triggersActionsUI && + triggersActionsUI.getAddAlertFlyout({ + consumer: 'infrastructure', + onClose: onCloseFlyout, + canChangeTrigger: false, + alertTypeId: METRIC_ANOMALY_ALERT_TYPE_ID, + metadata: { + metric, + nodeType, + }, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [triggersActionsUI, visible] + ); + + return <>{visible && AddAlertFlyout}; +}; + +export const PrefilledAnomalyAlertFlyout = ({ onClose }: { onClose(): void }) => { + const { inventoryPrefill } = useAlertPrefillContext(); + const { nodeType, metric } = inventoryPrefill; + + return ; +}; diff --git a/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.test.tsx b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.test.tsx new file mode 100644 index 0000000000000..ae2c6ed81badb --- /dev/null +++ b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.test.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mountWithIntl, nextTick } from '@kbn/test/jest'; +// We are using this inside a `jest.mock` call. Jest requires dynamic dependencies to be prefixed with `mock` +import { coreMock as mockCoreMock } from 'src/core/public/mocks'; +import React from 'react'; +import { Expression, AlertContextMeta } from './expression'; +import { act } from 'react-dom/test-utils'; + +jest.mock('../../../containers/source/use_source_via_http', () => ({ + useSourceViaHttp: () => ({ + source: { id: 'default' }, + createDerivedIndexPattern: () => ({ fields: [], title: 'metricbeat-*' }), + }), +})); + +jest.mock('../../../hooks/use_kibana', () => ({ + useKibanaContextForPlugin: () => ({ + services: mockCoreMock.createStart(), + }), +})); + +jest.mock('../../../containers/ml/infra_ml_capabilities', () => ({ + useInfraMLCapabilities: () => ({ + isLoading: false, + hasInfraMLCapabilities: true, + }), +})); + +describe('Expression', () => { + async function setup(currentOptions: AlertContextMeta) { + const alertParams = { + metric: undefined, + nodeType: undefined, + threshold: 50, + }; + const wrapper = mountWithIntl( + Reflect.set(alertParams, key, value)} + setAlertProperty={() => {}} + metadata={currentOptions} + /> + ); + + const update = async () => + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + await update(); + + return { wrapper, update, alertParams }; + } + + it('should prefill the alert using the context metadata', async () => { + const currentOptions = { + nodeType: 'pod', + metric: { type: 'tx' }, + }; + const { alertParams } = await setup(currentOptions as AlertContextMeta); + expect(alertParams.nodeType).toBe('k8s'); + expect(alertParams.metric).toBe('network_out'); + }); +}); diff --git a/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.tsx b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.tsx new file mode 100644 index 0000000000000..5938c7119616f --- /dev/null +++ b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/expression.tsx @@ -0,0 +1,320 @@ +/* + * 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 { pick } from 'lodash'; +import React, { useCallback, useState, useMemo, useEffect } from 'react'; +import { EuiFlexGroup, EuiSpacer, EuiText, EuiLoadingContent } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { useInfraMLCapabilities } from '../../../containers/ml/infra_ml_capabilities'; +import { SubscriptionSplashContent } from '../../../components/subscription_splash_content'; +import { AlertPreview } from '../../common'; +import { + METRIC_ANOMALY_ALERT_TYPE_ID, + MetricAnomalyParams, +} from '../../../../common/alerting/metrics'; +import { euiStyled, EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; +import { + WhenExpression, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../triggers_actions_ui/public/common'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { IErrorObject } from '../../../../../triggers_actions_ui/public/types'; +import { useSourceViaHttp } from '../../../containers/source/use_source_via_http'; +import { findInventoryModel } from '../../../../common/inventory_models'; +import { InventoryItemType, SnapshotMetricType } from '../../../../common/inventory_models/types'; +import { NodeTypeExpression } from './node_type'; +import { SeverityThresholdExpression } from './severity_threshold'; +import { InfraWaffleMapOptions } from '../../../lib/lib'; +import { ANOMALY_THRESHOLD } from '../../../../common/infra_ml'; + +import { validateMetricAnomaly } from './validation'; +import { InfluencerFilter } from './influencer_filter'; +import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; + +export interface AlertContextMeta { + metric?: InfraWaffleMapOptions['metric']; + nodeType?: InventoryItemType; +} + +interface Props { + errors: IErrorObject[]; + alertParams: MetricAnomalyParams & { + sourceId: string; + }; + alertInterval: string; + alertThrottle: string; + setAlertParams(key: string, value: any): void; + setAlertProperty(key: string, value: any): void; + metadata: AlertContextMeta; +} + +export const defaultExpression = { + metric: 'memory_usage' as MetricAnomalyParams['metric'], + threshold: ANOMALY_THRESHOLD.MAJOR, + nodeType: 'hosts', + influencerFilter: undefined, +}; + +export const Expression: React.FC = (props) => { + const { hasInfraMLCapabilities, isLoading: isLoadingMLCapabilities } = useInfraMLCapabilities(); + const { http, notifications } = useKibanaContextForPlugin().services; + const { setAlertParams, alertParams, alertInterval, alertThrottle, metadata } = props; + const { source, createDerivedIndexPattern } = useSourceViaHttp({ + sourceId: 'default', + type: 'metrics', + fetch: http.fetch, + toastWarning: notifications.toasts.addWarning, + }); + + const derivedIndexPattern = useMemo(() => createDerivedIndexPattern('metrics'), [ + createDerivedIndexPattern, + ]); + + const [influencerFieldName, updateInfluencerFieldName] = useState( + alertParams.influencerFilter?.fieldName ?? 'host.name' + ); + + useEffect(() => { + setAlertParams('hasInfraMLCapabilities', hasInfraMLCapabilities); + }, [setAlertParams, hasInfraMLCapabilities]); + + useEffect(() => { + if (alertParams.influencerFilter) { + setAlertParams('influencerFilter', { + ...alertParams.influencerFilter, + fieldName: influencerFieldName, + }); + } + }, [influencerFieldName, alertParams, setAlertParams]); + const updateInfluencerFieldValue = useCallback( + (value: string) => { + if (value) { + setAlertParams('influencerFilter', { + ...alertParams.influencerFilter, + fieldValue: value, + }); + } else { + setAlertParams('influencerFilter', undefined); + } + }, + [setAlertParams, alertParams] + ); + + useEffect(() => { + setAlertParams('alertInterval', alertInterval); + }, [setAlertParams, alertInterval]); + + const updateNodeType = useCallback( + (nt: any) => { + setAlertParams('nodeType', nt); + }, + [setAlertParams] + ); + + const updateMetric = useCallback( + (metric: string) => { + setAlertParams('metric', metric); + }, + [setAlertParams] + ); + + const updateSeverityThreshold = useCallback( + (threshold: any) => { + setAlertParams('threshold', threshold); + }, + [setAlertParams] + ); + + const prefillNodeType = useCallback(() => { + const md = metadata; + if (md && md.nodeType) { + setAlertParams( + 'nodeType', + getMLNodeTypeFromInventoryNodeType(md.nodeType) ?? defaultExpression.nodeType + ); + } else { + setAlertParams('nodeType', defaultExpression.nodeType); + } + }, [metadata, setAlertParams]); + + const prefillMetric = useCallback(() => { + const md = metadata; + if (md && md.metric) { + setAlertParams( + 'metric', + getMLMetricFromInventoryMetric(md.metric.type) ?? defaultExpression.metric + ); + } else { + setAlertParams('metric', defaultExpression.metric); + } + }, [metadata, setAlertParams]); + + useEffect(() => { + if (!alertParams.nodeType) { + prefillNodeType(); + } + + if (!alertParams.threshold) { + setAlertParams('threshold', defaultExpression.threshold); + } + + if (!alertParams.metric) { + prefillMetric(); + } + + if (!alertParams.sourceId) { + setAlertParams('sourceId', source?.id || 'default'); + } + }, [metadata, derivedIndexPattern, defaultExpression, source]); // eslint-disable-line react-hooks/exhaustive-deps + + if (isLoadingMLCapabilities) return ; + if (!hasInfraMLCapabilities) return ; + + return ( + // https://github.com/elastic/kibana/issues/89506 + + +

+ +

+
+ + + + + + + + + + + + + + + + + + + +
+ ); +}; + +// required for dynamic import +// eslint-disable-next-line import/no-default-export +export default Expression; + +const StyledExpressionRow = euiStyled(EuiFlexGroup)` + display: flex; + flex-wrap: wrap; + margin: 0 -4px; +`; + +const StyledExpression = euiStyled.div` + padding: 0 4px; +`; + +const getDisplayNameForType = (type: InventoryItemType) => { + const inventoryModel = findInventoryModel(type); + return inventoryModel.displayName; +}; + +export const nodeTypes: { [key: string]: any } = { + hosts: { + text: getDisplayNameForType('host'), + value: 'hosts', + }, + k8s: { + text: getDisplayNameForType('pod'), + value: 'k8s', + }, +}; + +const getMLMetricFromInventoryMetric = (metric: SnapshotMetricType) => { + switch (metric) { + case 'memory': + return 'memory_usage'; + case 'tx': + return 'network_out'; + case 'rx': + return 'network_in'; + default: + return null; + } +}; + +const getMLNodeTypeFromInventoryNodeType = (nodeType: InventoryItemType) => { + switch (nodeType) { + case 'host': + return 'hosts'; + case 'pod': + return 'k8s'; + default: + return null; + } +}; diff --git a/x-pack/plugins/infra/public/alerting/metric_anomaly/components/influencer_filter.tsx b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/influencer_filter.tsx new file mode 100644 index 0000000000000..34a917a77dcf5 --- /dev/null +++ b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/influencer_filter.tsx @@ -0,0 +1,193 @@ +/* + * 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 { debounce } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import React, { useState, useCallback, useEffect, useMemo } from 'react'; +import { first } from 'lodash'; +import { EuiFlexGroup, EuiFormRow, EuiCheckbox, EuiFlexItem, EuiSelect } from '@elastic/eui'; +import { + MetricsExplorerKueryBar, + CurryLoadSuggestionsType, +} from '../../../pages/metrics/metrics_explorer/components/kuery_bar'; +import { MetricAnomalyParams } from '../../../../common/alerting/metrics'; + +interface Props { + fieldName: string; + fieldValue: string; + nodeType: MetricAnomalyParams['nodeType']; + onChangeFieldName: (v: string) => void; + onChangeFieldValue: (v: string) => void; + derivedIndexPattern: Parameters[0]['derivedIndexPattern']; +} + +const FILTER_TYPING_DEBOUNCE_MS = 500; + +export const InfluencerFilter = ({ + fieldName, + fieldValue, + nodeType, + onChangeFieldName, + onChangeFieldValue, + derivedIndexPattern, +}: Props) => { + const fieldNameOptions = useMemo(() => (nodeType === 'k8s' ? k8sFieldNames : hostFieldNames), [ + nodeType, + ]); + + // If initial props contain a fieldValue, assume it was passed in from loaded alertParams, + // and enable the UI element + const [isEnabled, updateIsEnabled] = useState(fieldValue ? true : false); + const [storedFieldValue, updateStoredFieldValue] = useState(fieldValue); + + useEffect( + () => + nodeType === 'k8s' + ? onChangeFieldName(first(k8sFieldNames)!.value) + : onChangeFieldName(first(hostFieldNames)!.value), + [nodeType, onChangeFieldName] + ); + + const onSelectFieldName = useCallback((e) => onChangeFieldName(e.target.value), [ + onChangeFieldName, + ]); + const onUpdateFieldValue = useCallback( + (value) => { + updateStoredFieldValue(value); + onChangeFieldValue(value); + }, + [onChangeFieldValue] + ); + + const toggleEnabled = useCallback(() => { + const nextState = !isEnabled; + updateIsEnabled(nextState); + if (!nextState) { + onChangeFieldValue(''); + } else { + onChangeFieldValue(storedFieldValue); + } + }, [isEnabled, updateIsEnabled, onChangeFieldValue, storedFieldValue]); + + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + const debouncedOnUpdateFieldValue = useCallback( + debounce(onUpdateFieldValue, FILTER_TYPING_DEBOUNCE_MS), + [onUpdateFieldValue] + ); + + const affixFieldNameToQuery: CurryLoadSuggestionsType = (fn) => ( + expression, + cursorPosition, + maxSuggestions + ) => { + // Add the field name to the front of the passed-in query + const prefix = `${fieldName}:`; + // Trim whitespace to prevent AND/OR suggestions + const modifiedExpression = `${prefix}${expression}`.trim(); + // Move the cursor position forward by the length of the field name + const modifiedPosition = cursorPosition + prefix.length; + return fn(modifiedExpression, modifiedPosition, maxSuggestions, (suggestions) => + suggestions + .map((s) => ({ + ...s, + // Remove quotes from suggestions + text: s.text.replace(/\"/g, '').trim(), + // Offset the returned suggestions' cursor positions so that they can be autocompleted accurately + start: s.start - prefix.length, + end: s.end - prefix.length, + })) + // Removing quotes can lead to an already-selected suggestion still coming up in the autocomplete list, + // so filter these out + .filter((s) => !expression.startsWith(s.text)) + ); + }; + + return ( + + } + helpText={ + isEnabled ? ( + <> + {i18n.translate('xpack.infra.metrics.alertFlyout.anomalyFilterHelpText', { + defaultMessage: + 'Limit the scope of your alert trigger to anomalies influenced by certain node(s).', + })} +
+ {i18n.translate('xpack.infra.metrics.alertFlyout.anomalyFilterHelpTextExample', { + defaultMessage: 'For example: "my-node-1" or "my-node-*"', + })} + + ) : null + } + fullWidth + display="rowCompressed" + > + {isEnabled ? ( + + + + + + + + + ) : ( + <> + )} +
+ ); +}; + +const hostFieldNames = [ + { + value: 'host.name', + text: 'host.name', + }, +]; + +const k8sFieldNames = [ + { + value: 'kubernetes.pod.uid', + text: 'kubernetes.pod.uid', + }, + { + value: 'kubernetes.node.name', + text: 'kubernetes.node.name', + }, + { + value: 'kubernetes.namespace', + text: 'kubernetes.namespace', + }, +]; + +const filterByNodeLabel = i18n.translate('xpack.infra.metrics.alertFlyout.filterByNodeLabel', { + defaultMessage: 'Filter by node', +}); diff --git a/x-pack/plugins/infra/public/alerting/metric_anomaly/components/node_type.tsx b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/node_type.tsx new file mode 100644 index 0000000000000..6ddcf8fd5cb65 --- /dev/null +++ b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/node_type.tsx @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiExpression, EuiPopover, EuiFlexGroup, EuiFlexItem, EuiSelect } from '@elastic/eui'; +import { EuiPopoverTitle, EuiButtonIcon } from '@elastic/eui'; +import { MetricAnomalyParams } from '../../../../common/alerting/metrics'; + +type Node = MetricAnomalyParams['nodeType']; + +interface WhenExpressionProps { + value: Node; + options: { [key: string]: { text: string; value: Node } }; + onChange: (value: Node) => void; + popupPosition?: + | 'upCenter' + | 'upLeft' + | 'upRight' + | 'downCenter' + | 'downLeft' + | 'downRight' + | 'leftCenter' + | 'leftUp' + | 'leftDown' + | 'rightCenter' + | 'rightUp' + | 'rightDown'; +} + +export const NodeTypeExpression = ({ + value, + options, + onChange, + popupPosition, +}: WhenExpressionProps) => { + const [aggTypePopoverOpen, setAggTypePopoverOpen] = useState(false); + + return ( + { + setAggTypePopoverOpen(true); + }} + /> + } + isOpen={aggTypePopoverOpen} + closePopover={() => { + setAggTypePopoverOpen(false); + }} + ownFocus + anchorPosition={popupPosition ?? 'downLeft'} + > +
+ setAggTypePopoverOpen(false)}> + + + { + onChange(e.target.value as Node); + setAggTypePopoverOpen(false); + }} + options={Object.values(options).map((o) => o)} + /> +
+
+ ); +}; + +interface ClosablePopoverTitleProps { + children: JSX.Element; + onClose: () => void; +} + +export const ClosablePopoverTitle = ({ children, onClose }: ClosablePopoverTitleProps) => { + return ( + + + {children} + + onClose()} + /> + + + + ); +}; diff --git a/x-pack/plugins/infra/public/alerting/metric_anomaly/components/severity_threshold.tsx b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/severity_threshold.tsx new file mode 100644 index 0000000000000..2dc561ff172b9 --- /dev/null +++ b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/severity_threshold.tsx @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiExpression, EuiPopover, EuiFlexGroup, EuiFlexItem, EuiSelect } from '@elastic/eui'; +import { EuiPopoverTitle, EuiButtonIcon } from '@elastic/eui'; +import { ANOMALY_THRESHOLD } from '../../../../common/infra_ml'; + +interface WhenExpressionProps { + value: Exclude; + onChange: (value: ANOMALY_THRESHOLD) => void; + popupPosition?: + | 'upCenter' + | 'upLeft' + | 'upRight' + | 'downCenter' + | 'downLeft' + | 'downRight' + | 'leftCenter' + | 'leftUp' + | 'leftDown' + | 'rightCenter' + | 'rightUp' + | 'rightDown'; +} + +const options = { + [ANOMALY_THRESHOLD.CRITICAL]: { + text: i18n.translate('xpack.infra.metrics.alertFlyout.expression.severityScore.criticalLabel', { + defaultMessage: 'Critical', + }), + value: ANOMALY_THRESHOLD.CRITICAL, + }, + [ANOMALY_THRESHOLD.MAJOR]: { + text: i18n.translate('xpack.infra.metrics.alertFlyout.expression.severityScore.majorLabel', { + defaultMessage: 'Major', + }), + value: ANOMALY_THRESHOLD.MAJOR, + }, + [ANOMALY_THRESHOLD.MINOR]: { + text: i18n.translate('xpack.infra.metrics.alertFlyout.expression.severityScore.minorLabel', { + defaultMessage: 'Minor', + }), + value: ANOMALY_THRESHOLD.MINOR, + }, + [ANOMALY_THRESHOLD.WARNING]: { + text: i18n.translate('xpack.infra.metrics.alertFlyout.expression.severityScore.warningLabel', { + defaultMessage: 'Warning', + }), + value: ANOMALY_THRESHOLD.WARNING, + }, +}; + +export const SeverityThresholdExpression = ({ + value, + onChange, + popupPosition, +}: WhenExpressionProps) => { + const [aggTypePopoverOpen, setAggTypePopoverOpen] = useState(false); + + return ( + { + setAggTypePopoverOpen(true); + }} + /> + } + isOpen={aggTypePopoverOpen} + closePopover={() => { + setAggTypePopoverOpen(false); + }} + ownFocus + anchorPosition={popupPosition ?? 'downLeft'} + > +
+ setAggTypePopoverOpen(false)}> + + + { + onChange(Number(e.target.value) as ANOMALY_THRESHOLD); + setAggTypePopoverOpen(false); + }} + options={Object.values(options).map((o) => o)} + /> +
+
+ ); +}; + +interface ClosablePopoverTitleProps { + children: JSX.Element; + onClose: () => void; +} + +export const ClosablePopoverTitle = ({ children, onClose }: ClosablePopoverTitleProps) => { + return ( + + + {children} + + onClose()} + /> + + + + ); +}; diff --git a/x-pack/plugins/infra/public/alerting/metric_anomaly/components/validation.tsx b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/validation.tsx new file mode 100644 index 0000000000000..8e254fb2b67a8 --- /dev/null +++ b/x-pack/plugins/infra/public/alerting/metric_anomaly/components/validation.tsx @@ -0,0 +1,35 @@ +/* + * 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'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { ValidationResult } from '../../../../../triggers_actions_ui/public/types'; + +export function validateMetricAnomaly({ + hasInfraMLCapabilities, +}: { + hasInfraMLCapabilities: boolean; +}): ValidationResult { + const validationResult = { errors: {} }; + const errors: { + hasInfraMLCapabilities: string[]; + } = { + hasInfraMLCapabilities: [], + }; + + validationResult.errors = errors; + + if (!hasInfraMLCapabilities) { + errors.hasInfraMLCapabilities.push( + i18n.translate('xpack.infra.metrics.alertFlyout.error.mlCapabilitiesRequired', { + defaultMessage: 'Cannot create an anomaly alert when machine learning is disabled.', + }) + ); + } + + return validationResult; +} diff --git a/x-pack/plugins/infra/public/alerting/metric_anomaly/index.ts b/x-pack/plugins/infra/public/alerting/metric_anomaly/index.ts new file mode 100644 index 0000000000000..31fed514bdacc --- /dev/null +++ b/x-pack/plugins/infra/public/alerting/metric_anomaly/index.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { METRIC_ANOMALY_ALERT_TYPE_ID } from '../../../common/alerting/metrics'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { AlertTypeModel } from '../../../../triggers_actions_ui/public/types'; +import { AlertTypeParams } from '../../../../alerts/common'; +import { validateMetricAnomaly } from './components/validation'; + +interface MetricAnomalyAlertTypeParams extends AlertTypeParams { + hasInfraMLCapabilities: boolean; +} + +export function createMetricAnomalyAlertType(): AlertTypeModel { + return { + id: METRIC_ANOMALY_ALERT_TYPE_ID, + description: i18n.translate('xpack.infra.metrics.anomaly.alertFlyout.alertDescription', { + defaultMessage: 'Alert when the anomaly score exceeds a defined threshold.', + }), + iconClass: 'bell', + documentationUrl(docLinks) { + return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/observability/${docLinks.DOC_LINK_VERSION}/metric-anomaly-alert.html`; + }, + alertParamsExpression: React.lazy(() => import('./components/expression')), + validate: validateMetricAnomaly, + defaultActionMessage: i18n.translate( + 'xpack.infra.metrics.alerting.anomaly.defaultActionMessage', + { + defaultMessage: `\\{\\{alertName\\}\\} is in a state of \\{\\{context.alertState\\}\\} + +\\{\\{context.metric\\}\\} was \\{\\{context.summary\\}\\} than normal at \\{\\{context.timestamp\\}\\} + +Typical value: \\{\\{context.typical\\}\\} +Actual value: \\{\\{context.actual\\}\\} +`, + } + ), + requiresAppContext: false, + }; +} diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/alert_dropdown.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/alert_dropdown.tsx deleted file mode 100644 index 3bbe811225825..0000000000000 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/alert_dropdown.tsx +++ /dev/null @@ -1,57 +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, { useState, useCallback } from 'react'; -import { EuiPopover, EuiButtonEmpty, EuiContextMenuItem, EuiContextMenuPanel } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { useAlertPrefillContext } from '../../use_alert_prefill'; -import { AlertFlyout } from './alert_flyout'; -import { ManageAlertsContextMenuItem } from '../../inventory/components/manage_alerts_context_menu_item'; - -export const MetricsAlertDropdown = () => { - const [popoverOpen, setPopoverOpen] = useState(false); - const [flyoutVisible, setFlyoutVisible] = useState(false); - - const { metricThresholdPrefill } = useAlertPrefillContext(); - const { groupBy, filterQuery, metrics } = metricThresholdPrefill; - - const closePopover = useCallback(() => { - setPopoverOpen(false); - }, [setPopoverOpen]); - - const openPopover = useCallback(() => { - setPopoverOpen(true); - }, [setPopoverOpen]); - - const menuItems = [ - setFlyoutVisible(true)}> - - , - , - ]; - - return ( - <> - - - - } - isOpen={popoverOpen} - closePopover={closePopover} - > - - - - - ); -}; diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/alert_flyout.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/alert_flyout.tsx index 929654ecb4693..e7e4ade5257fc 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/alert_flyout.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/alert_flyout.tsx @@ -7,10 +7,10 @@ import React, { useCallback, useContext, useMemo } from 'react'; import { TriggerActionsContext } from '../../../utils/triggers_actions_context'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { METRIC_THRESHOLD_ALERT_TYPE_ID } from '../../../../server/lib/alerting/metric_threshold/types'; +import { METRIC_THRESHOLD_ALERT_TYPE_ID } from '../../../../common/alerting/metrics'; import { MetricsExplorerSeries } from '../../../../common/http_api/metrics_explorer'; import { MetricsExplorerOptions } from '../../../pages/metrics/metrics_explorer/hooks/use_metrics_explorer_options'; +import { useAlertPrefillContext } from '../../../alerting/use_alert_prefill'; interface Props { visible?: boolean; @@ -42,3 +42,10 @@ export const AlertFlyout = (props: Props) => { return <>{visible && AddAlertFlyout}; }; + +export const PrefilledThresholdAlertFlyout = ({ onClose }: { onClose(): void }) => { + const { metricThresholdPrefill } = useAlertPrefillContext(); + const { groupBy, filterQuery, metrics } = metricThresholdPrefill; + + return ; +}; diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/index.ts b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/index.ts index 1bcc9e7157a51..db5a996c604fc 100644 --- a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/index.ts +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/index.ts @@ -14,4 +14,3 @@ export * from './missing_results_privileges_prompt'; export * from './missing_setup_privileges_prompt'; export * from './ml_unavailable_prompt'; export * from './setup_status_unknown_prompt'; -export * from './subscription_splash_content'; diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/subscription_splash_content.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/subscription_splash_content.tsx deleted file mode 100644 index c91c1d82afe9b..0000000000000 --- a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/subscription_splash_content.tsx +++ /dev/null @@ -1,176 +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, { useEffect } from 'react'; -import { i18n } from '@kbn/i18n'; -import { - EuiPage, - EuiPageBody, - EuiPageContent, - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, - EuiTitle, - EuiText, - EuiButton, - EuiButtonEmpty, - EuiImage, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { HttpStart } from 'src/core/public'; -import { LoadingPage } from '../../loading_page'; - -import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; -import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; -import { useTrialStatus } from '../../../hooks/use_trial_status'; - -export const SubscriptionSplashContent: React.FC = () => { - const { services } = useKibana<{ http: HttpStart }>(); - const { loadState, isTrialAvailable, checkTrialAvailability } = useTrialStatus(); - - useEffect(() => { - checkTrialAvailability(); - }, [checkTrialAvailability]); - - if (loadState === 'pending') { - return ( - - ); - } - - const canStartTrial = isTrialAvailable && loadState === 'resolved'; - - let title; - let description; - let cta; - - if (canStartTrial) { - title = ( - - ); - - description = ( - - ); - - cta = ( - - - - ); - } else { - title = ( - - ); - - description = ( - - ); - - cta = ( - - - - ); - } - - return ( - - - - - - -

{title}

-
- - -

{description}

-
- -
{cta}
-
- - - -
- - -

- -

-
- - - -
-
-
-
- ); -}; - -const SubscriptionPage = euiStyled(EuiPage)` - height: 100% -`; - -const SubscriptionPageContent = euiStyled(EuiPageContent)` - max-width: 768px !important; -`; - -const SubscriptionPageFooter = euiStyled.div` - background: ${(props) => props.theme.eui.euiColorLightestShade}; - margin: 0 -${(props) => props.theme.eui.paddingSizes.l} -${(props) => - props.theme.eui.paddingSizes.l}; - padding: ${(props) => props.theme.eui.paddingSizes.l}; -`; diff --git a/x-pack/plugins/infra/public/components/source_configuration/source_configuration_settings.tsx b/x-pack/plugins/infra/public/components/source_configuration/source_configuration_settings.tsx index 4b609a881bd18..e63f43470497d 100644 --- a/x-pack/plugins/infra/public/components/source_configuration/source_configuration_settings.tsx +++ b/x-pack/plugins/infra/public/components/source_configuration/source_configuration_settings.tsx @@ -75,7 +75,7 @@ export const SourceConfigurationSettings = ({ source, ]); - const { hasInfraMLCapabilites } = useInfraMLCapabilitiesContext(); + const { hasInfraMLCapabilities } = useInfraMLCapabilitiesContext(); if ((isLoading || isUninitialized) && !source) { return ; @@ -128,7 +128,7 @@ export const SourceConfigurationSettings = ({ /> - {hasInfraMLCapabilites && ( + {hasInfraMLCapabilities && ( <> { const { services } = useKibana<{ http: HttpStart }>(); @@ -102,58 +102,60 @@ export const SubscriptionSplashContent: React.FC = () => { } return ( - - - - - - -

{title}

+ + + + + + + +

{title}

+
+ + +

{description}

+
+ +
{cta}
+
+ + + +
+ + +

+ +

- - -

{description}

-
- -
{cta}
-
- - - -
- - -

+ -

-
- - - -
-
-
-
+ + + + + + ); }; diff --git a/x-pack/plugins/infra/public/containers/ml/infra_ml_capabilities.tsx b/x-pack/plugins/infra/public/containers/ml/infra_ml_capabilities.tsx index 72dc4da01d867..661ce8f8a253c 100644 --- a/x-pack/plugins/infra/public/containers/ml/infra_ml_capabilities.tsx +++ b/x-pack/plugins/infra/public/containers/ml/infra_ml_capabilities.tsx @@ -52,11 +52,11 @@ export const useInfraMLCapabilities = () => { const hasInfraMLSetupCapabilities = mlCapabilities.capabilities.canCreateJob; const hasInfraMLReadCapabilities = mlCapabilities.capabilities.canGetJobs; - const hasInfraMLCapabilites = + const hasInfraMLCapabilities = mlCapabilities.isPlatinumOrTrialLicense && mlCapabilities.mlFeatureEnabledInSpace; return { - hasInfraMLCapabilites, + hasInfraMLCapabilities, hasInfraMLReadCapabilities, hasInfraMLSetupCapabilities, isLoading, diff --git a/x-pack/plugins/infra/public/containers/with_kuery_autocompletion.tsx b/x-pack/plugins/infra/public/containers/with_kuery_autocompletion.tsx index 379ac9774c242..1a759950f640d 100644 --- a/x-pack/plugins/infra/public/containers/with_kuery_autocompletion.tsx +++ b/x-pack/plugins/infra/public/containers/with_kuery_autocompletion.tsx @@ -56,7 +56,8 @@ class WithKueryAutocompletionComponent extends React.Component< private loadSuggestions = async ( expression: string, cursorPosition: number, - maxSuggestions?: number + maxSuggestions?: number, + transformSuggestions?: (s: QuerySuggestion[]) => QuerySuggestion[] ) => { const { indexPattern } = this.props; const language = 'kuery'; @@ -86,6 +87,10 @@ class WithKueryAutocompletionComponent extends React.Component< boolFilter: [], })) || []; + const transformedSuggestions = transformSuggestions + ? transformSuggestions(suggestions) + : suggestions; + this.setState((state) => state.currentRequest && state.currentRequest.expression !== expression && @@ -94,7 +99,9 @@ class WithKueryAutocompletionComponent extends React.Component< : { ...state, currentRequest: null, - suggestions: maxSuggestions ? suggestions.slice(0, maxSuggestions) : suggestions, + suggestions: maxSuggestions + ? transformedSuggestions.slice(0, maxSuggestions) + : transformedSuggestions, } ); }; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx index f0fdd79bcd93d..628df397998ee 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx @@ -7,13 +7,13 @@ import { i18n } from '@kbn/i18n'; import React, { useCallback, useEffect } from 'react'; +import { SubscriptionSplashContent } from '../../../components/subscription_splash_content'; import { isJobStatusWithResults } from '../../../../common/log_analysis'; import { LoadingPage } from '../../../components/loading_page'; import { LogAnalysisSetupStatusUnknownPrompt, MissingResultsPrivilegesPrompt, MissingSetupPrivilegesPrompt, - SubscriptionSplashContent, } from '../../../components/logging/log_analysis_setup'; import { LogAnalysisSetupFlyout, diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_content.tsx index 4d06d23ef93ef..5fd00527b8b70 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_content.tsx @@ -7,13 +7,13 @@ import { i18n } from '@kbn/i18n'; import React, { memo, useEffect, useCallback } from 'react'; +import { SubscriptionSplashContent } from '../../../components/subscription_splash_content'; import { isJobStatusWithResults } from '../../../../common/log_analysis'; import { LoadingPage } from '../../../components/loading_page'; import { LogAnalysisSetupStatusUnknownPrompt, MissingResultsPrivilegesPrompt, MissingSetupPrivilegesPrompt, - SubscriptionSplashContent, } from '../../../components/logging/log_analysis_setup'; import { LogAnalysisSetupFlyout, diff --git a/x-pack/plugins/infra/public/pages/metrics/index.tsx b/x-pack/plugins/infra/public/pages/metrics/index.tsx index 52c2a70f2d359..8fd32bda7fbc8 100644 --- a/x-pack/plugins/infra/public/pages/metrics/index.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/index.tsx @@ -35,12 +35,11 @@ import { WaffleOptionsProvider } from './inventory_view/hooks/use_waffle_options import { WaffleTimeProvider } from './inventory_view/hooks/use_waffle_time'; import { WaffleFiltersProvider } from './inventory_view/hooks/use_waffle_filters'; -import { InventoryAlertDropdown } from '../../alerting/inventory/components/alert_dropdown'; -import { MetricsAlertDropdown } from '../../alerting/metric_threshold/components/alert_dropdown'; +import { MetricsAlertDropdown } from '../../alerting/common/components/metrics_alert_dropdown'; import { SavedView } from '../../containers/saved_view/saved_view'; import { AlertPrefillProvider } from '../../alerting/use_alert_prefill'; import { InfraMLCapabilitiesProvider } from '../../containers/ml/infra_ml_capabilities'; -import { AnomalyDetectionFlyout } from './inventory_view/components/ml/anomaly_detection/anomoly_detection_flyout'; +import { AnomalyDetectionFlyout } from './inventory_view/components/ml/anomaly_detection/anomaly_detection_flyout'; import { HeaderMenuPortal } from '../../../../observability/public'; import { HeaderActionMenuContext } from '../../utils/header_action_menu_provider'; @@ -83,8 +82,7 @@ export const InfrastructurePage = ({ match }: RouteComponentProps) => { - - + { jobSummaries: k8sJobSummaries, } = useMetricK8sModuleContext(); const { - hasInfraMLCapabilites, + hasInfraMLCapabilities, hasInfraMLReadCapabilities, hasInfraMLSetupCapabilities, } = useInfraMLCapabilitiesContext(); @@ -69,7 +69,7 @@ export const FlyoutHome = (props: Props) => { } }, [fetchK8sJobStatus, fetchHostJobStatus, hasInfraMLReadCapabilities]); - if (!hasInfraMLCapabilites) { + if (!hasInfraMLCapabilities) { return ; } else if (!hasInfraMLReadCapabilities) { return ; diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/kuery_bar.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/kuery_bar.tsx index 44391568741f3..e22c6fa661181 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/kuery_bar.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/kuery_bar.tsx @@ -10,7 +10,19 @@ import { i18n } from '@kbn/i18n'; import React, { useEffect, useState } from 'react'; import { WithKueryAutocompletion } from '../../../../containers/with_kuery_autocompletion'; import { AutocompleteField } from '../../../../components/autocomplete_field'; -import { esKuery, IIndexPattern } from '../../../../../../../../src/plugins/data/public'; +import { + esKuery, + IIndexPattern, + QuerySuggestion, +} from '../../../../../../../../src/plugins/data/public'; + +type LoadSuggestionsFn = ( + e: string, + p: number, + m?: number, + transform?: (s: QuerySuggestion[]) => QuerySuggestion[] +) => void; +export type CurryLoadSuggestionsType = (loadSuggestions: LoadSuggestionsFn) => LoadSuggestionsFn; interface Props { derivedIndexPattern: IIndexPattern; @@ -18,6 +30,7 @@ interface Props { onChange?: (query: string) => void; value?: string | null; placeholder?: string; + curryLoadSuggestions?: CurryLoadSuggestionsType; } function validateQuery(query: string) { @@ -35,6 +48,7 @@ export const MetricsExplorerKueryBar = ({ onChange, value, placeholder, + curryLoadSuggestions = defaultCurryLoadSuggestions, }: Props) => { const [draftQuery, setDraftQuery] = useState(value || ''); const [isValid, setValidation] = useState(true); @@ -73,7 +87,7 @@ export const MetricsExplorerKueryBar = ({ aria-label={placeholder} isLoadingSuggestions={isLoadingSuggestions} isValid={isValid} - loadSuggestions={loadSuggestions} + loadSuggestions={curryLoadSuggestions(loadSuggestions)} onChange={handleChange} onSubmit={onSubmit} placeholder={placeholder || defaultPlaceholder} @@ -84,3 +98,6 @@ export const MetricsExplorerKueryBar = ({ ); }; + +const defaultCurryLoadSuggestions: CurryLoadSuggestionsType = (loadSuggestions) => (...args) => + loadSuggestions(...args); diff --git a/x-pack/plugins/infra/public/plugin.ts b/x-pack/plugins/infra/public/plugin.ts index 8e7d165f8a535..d4bb83e8668ba 100644 --- a/x-pack/plugins/infra/public/plugin.ts +++ b/x-pack/plugins/infra/public/plugin.ts @@ -10,6 +10,7 @@ import { AppMountParameters, PluginInitializerContext } from 'kibana/public'; import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/public'; import { createMetricThresholdAlertType } from './alerting/metric_threshold'; import { createInventoryMetricAlertType } from './alerting/inventory'; +import { createMetricAnomalyAlertType } from './alerting/metric_anomaly'; import { getAlertType as getLogsAlertType } from './alerting/log_threshold'; import { registerFeatures } from './register_feature'; import { @@ -35,6 +36,7 @@ export class Plugin implements InfraClientPluginClass { pluginsSetup.triggersActionsUi.alertTypeRegistry.register(createInventoryMetricAlertType()); pluginsSetup.triggersActionsUi.alertTypeRegistry.register(getLogsAlertType()); pluginsSetup.triggersActionsUi.alertTypeRegistry.register(createMetricThresholdAlertType()); + pluginsSetup.triggersActionsUi.alertTypeRegistry.register(createMetricAnomalyAlertType()); if (pluginsSetup.observability) { pluginsSetup.observability.dashboard.register({ diff --git a/x-pack/plugins/infra/public/types.ts b/x-pack/plugins/infra/public/types.ts index b18b6e8a6eba6..4d70676d25e40 100644 --- a/x-pack/plugins/infra/public/types.ts +++ b/x-pack/plugins/infra/public/types.ts @@ -23,7 +23,7 @@ import type { ObservabilityPluginStart, } from '../../observability/public'; import type { SpacesPluginStart } from '../../spaces/public'; -import { MlPluginStart } from '../../ml/public'; +import { MlPluginStart, MlPluginSetup } from '../../ml/public'; import type { EmbeddableStart } from '../../../../src/plugins/embeddable/public'; // Our own setup and start contract values @@ -36,6 +36,7 @@ export interface InfraClientSetupDeps { observability: ObservabilityPluginSetup; triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; usageCollection: UsageCollectionSetup; + ml: MlPluginSetup; embeddable: EmbeddableSetup; } diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/evaluate_condition.ts b/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/evaluate_condition.ts new file mode 100644 index 0000000000000..b7ef8ec7d2312 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/evaluate_condition.ts @@ -0,0 +1,51 @@ +/* + * 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 { MetricAnomalyParams } from '../../../../common/alerting/metrics'; +import { getMetricsHostsAnomalies, getMetricK8sAnomalies } from '../../infra_ml'; +import { MlSystem, MlAnomalyDetectors } from '../../../types'; + +type ConditionParams = Omit & { + spaceId: string; + startTime: number; + endTime: number; + mlSystem: MlSystem; + mlAnomalyDetectors: MlAnomalyDetectors; +}; + +export const evaluateCondition = async ({ + nodeType, + spaceId, + sourceId, + mlSystem, + mlAnomalyDetectors, + startTime, + endTime, + metric, + threshold, + influencerFilter, +}: ConditionParams) => { + const getAnomalies = nodeType === 'k8s' ? getMetricK8sAnomalies : getMetricsHostsAnomalies; + + const result = await getAnomalies( + { + spaceId, + mlSystem, + mlAnomalyDetectors, + }, + sourceId ?? 'default', + threshold, + startTime, + endTime, + metric, + { field: 'anomalyScore', direction: 'desc' }, + { pageSize: 100 }, + influencerFilter + ); + + return result; +}; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/metric_anomaly_executor.ts b/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/metric_anomaly_executor.ts new file mode 100644 index 0000000000000..ec95aac7268ad --- /dev/null +++ b/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/metric_anomaly_executor.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 { i18n } from '@kbn/i18n'; +import { first } from 'lodash'; +import moment from 'moment'; +import { stateToAlertMessage } from '../common/messages'; +import { MetricAnomalyParams } from '../../../../common/alerting/metrics'; +import { MappedAnomalyHit } from '../../infra_ml'; +import { AlertStates } from '../common/types'; +import { + ActionGroup, + AlertInstanceContext, + AlertInstanceState, +} from '../../../../../alerts/common'; +import { AlertExecutorOptions } from '../../../../../alerts/server'; +import { getIntervalInSeconds } from '../../../utils/get_interval_in_seconds'; +import { MetricAnomalyAllowedActionGroups } from './register_metric_anomaly_alert_type'; +import { MlPluginSetup } from '../../../../../ml/server'; +import { KibanaRequest } from '../../../../../../../src/core/server'; +import { InfraBackendLibs } from '../../infra_types'; +import { evaluateCondition } from './evaluate_condition'; + +export const createMetricAnomalyExecutor = (libs: InfraBackendLibs, ml?: MlPluginSetup) => async ({ + services, + params, + startedAt, +}: AlertExecutorOptions< + /** + * TODO: Remove this use of `any` by utilizing a proper type + */ + Record, + Record, + AlertInstanceState, + AlertInstanceContext, + MetricAnomalyAllowedActionGroups +>) => { + if (!ml) { + return; + } + const request = {} as KibanaRequest; + const mlSystem = ml.mlSystemProvider(request, services.savedObjectsClient); + const mlAnomalyDetectors = ml.anomalyDetectorsProvider(request, services.savedObjectsClient); + + const { + metric, + alertInterval, + influencerFilter, + sourceId, + nodeType, + threshold, + } = params as MetricAnomalyParams; + + const alertInstance = services.alertInstanceFactory(`${nodeType}-${metric}`); + + const bucketInterval = getIntervalInSeconds('15m') * 1000; + const alertIntervalInMs = getIntervalInSeconds(alertInterval ?? '1m') * 1000; + + const endTime = startedAt.getTime(); + // Anomalies are bucketed at :00, :15, :30, :45 minutes every hour + const previousBucketStartTime = endTime - (endTime % bucketInterval); + + // If the alert interval is less than 15m, make sure that it actually queries an anomaly bucket + const startTime = Math.min(endTime - alertIntervalInMs, previousBucketStartTime); + + const { data } = await evaluateCondition({ + sourceId: sourceId ?? 'default', + spaceId: 'default', + mlSystem, + mlAnomalyDetectors, + startTime, + endTime, + metric, + threshold, + nodeType, + influencerFilter, + }); + + const shouldAlertFire = data.length > 0; + + if (shouldAlertFire) { + const { startTime: anomalyStartTime, anomalyScore, actual, typical, influencers } = first( + data as MappedAnomalyHit[] + )!; + + alertInstance.scheduleActions(FIRED_ACTIONS_ID, { + alertState: stateToAlertMessage[AlertStates.ALERT], + timestamp: moment(anomalyStartTime).toISOString(), + anomalyScore, + actual, + typical, + metric: metricNameMap[metric], + summary: generateSummaryMessage(actual, typical), + influencers: influencers.join(', '), + }); + } +}; + +export const FIRED_ACTIONS_ID = 'metrics.anomaly.fired'; +export const FIRED_ACTIONS: ActionGroup = { + id: FIRED_ACTIONS_ID, + name: i18n.translate('xpack.infra.metrics.alerting.anomaly.fired', { + defaultMessage: 'Fired', + }), +}; + +const generateSummaryMessage = (actual: number, typical: number) => { + const differential = (Math.max(actual, typical) / Math.min(actual, typical)) + .toFixed(1) + .replace('.0', ''); + if (actual > typical) { + return i18n.translate('xpack.infra.metrics.alerting.anomaly.summaryHigher', { + defaultMessage: '{differential}x higher', + values: { + differential, + }, + }); + } else { + return i18n.translate('xpack.infra.metrics.alerting.anomaly.summaryLower', { + defaultMessage: '{differential}x lower', + values: { + differential, + }, + }); + } +}; + +const metricNameMap = { + memory_usage: i18n.translate('xpack.infra.metrics.alerting.anomaly.memoryUsage', { + defaultMessage: 'Memory usage', + }), + network_in: i18n.translate('xpack.infra.metrics.alerting.anomaly.networkIn', { + defaultMessage: 'Network in', + }), + network_out: i18n.translate('xpack.infra.metrics.alerting.anomaly.networkOut', { + defaultMessage: 'Network out', + }), +}; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/preview_metric_anomaly_alert.ts b/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/preview_metric_anomaly_alert.ts new file mode 100644 index 0000000000000..98992701e3bb4 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/preview_metric_anomaly_alert.ts @@ -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 { Unit } from '@elastic/datemath'; +import { countBy } from 'lodash'; +import { MappedAnomalyHit } from '../../infra_ml'; +import { MlSystem, MlAnomalyDetectors } from '../../../types'; +import { MetricAnomalyParams } from '../../../../common/alerting/metrics'; +import { + TOO_MANY_BUCKETS_PREVIEW_EXCEPTION, + isTooManyBucketsPreviewException, +} from '../../../../common/alerting/metrics'; +import { getIntervalInSeconds } from '../../../utils/get_interval_in_seconds'; +import { evaluateCondition } from './evaluate_condition'; + +interface PreviewMetricAnomalyAlertParams { + mlSystem: MlSystem; + mlAnomalyDetectors: MlAnomalyDetectors; + spaceId: string; + params: MetricAnomalyParams; + sourceId: string; + lookback: Unit; + alertInterval: string; + alertThrottle: string; + alertOnNoData: boolean; +} + +export const previewMetricAnomalyAlert = async ({ + mlSystem, + mlAnomalyDetectors, + spaceId, + params, + sourceId, + lookback, + alertInterval, + alertThrottle, +}: PreviewMetricAnomalyAlertParams) => { + const { metric, threshold, influencerFilter, nodeType } = params as MetricAnomalyParams; + + const alertIntervalInSeconds = getIntervalInSeconds(alertInterval); + const throttleIntervalInSeconds = getIntervalInSeconds(alertThrottle); + const executionsPerThrottle = Math.floor(throttleIntervalInSeconds / alertIntervalInSeconds); + + const lookbackInterval = `1${lookback}`; + const lookbackIntervalInSeconds = getIntervalInSeconds(lookbackInterval); + const endTime = Date.now(); + const startTime = endTime - lookbackIntervalInSeconds * 1000; + + const numberOfExecutions = Math.floor(lookbackIntervalInSeconds / alertIntervalInSeconds); + const bucketIntervalInSeconds = getIntervalInSeconds('15m'); + const bucketsPerExecution = Math.max( + 1, + Math.floor(alertIntervalInSeconds / bucketIntervalInSeconds) + ); + + try { + let anomalies: MappedAnomalyHit[] = []; + const { data } = await evaluateCondition({ + nodeType, + spaceId, + sourceId, + mlSystem, + mlAnomalyDetectors, + startTime, + endTime, + metric, + threshold, + influencerFilter, + }); + anomalies = [...anomalies, ...data]; + + const anomaliesByTime = countBy(anomalies, ({ startTime: anomStartTime }) => anomStartTime); + + let numberOfTimesFired = 0; + let numberOfNotifications = 0; + let throttleTracker = 0; + const notifyWithThrottle = () => { + if (throttleTracker === 0) numberOfNotifications++; + throttleTracker++; + }; + // Mock each alert evaluation + for (let i = 0; i < numberOfExecutions; i++) { + const executionTime = startTime + alertIntervalInSeconds * 1000 * i; + // Get an array of bucket times this mock alert evaluation will be looking at + // Anomalies are bucketed at :00, :15, :30, :45 minutes every hour, + // so this is an array of how many of those times occurred between this evaluation + // and the previous one + const bucketsLookedAt = Array.from(Array(bucketsPerExecution), (_, idx) => { + const previousBucketStartTime = + executionTime - + (executionTime % (bucketIntervalInSeconds * 1000)) - + idx * bucketIntervalInSeconds * 1000; + return previousBucketStartTime; + }); + const anomaliesDetectedInBuckets = bucketsLookedAt.some((bucketTime) => + Reflect.has(anomaliesByTime, bucketTime) + ); + + if (anomaliesDetectedInBuckets) { + numberOfTimesFired++; + notifyWithThrottle(); + } else if (throttleTracker > 0) { + throttleTracker++; + } + if (throttleTracker === executionsPerThrottle) { + throttleTracker = 0; + } + } + + return { fired: numberOfTimesFired, notifications: numberOfNotifications }; + } catch (e) { + if (!isTooManyBucketsPreviewException(e)) throw e; + const { maxBuckets } = e; + throw new Error(`${TOO_MANY_BUCKETS_PREVIEW_EXCEPTION}:${maxBuckets}`); + } +}; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/register_metric_anomaly_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/register_metric_anomaly_alert_type.ts new file mode 100644 index 0000000000000..8ac62c125515a --- /dev/null +++ b/x-pack/plugins/infra/server/lib/alerting/metric_anomaly/register_metric_anomaly_alert_type.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 { schema } from '@kbn/config-schema'; +import { i18n } from '@kbn/i18n'; +import { MlPluginSetup } from '../../../../../ml/server'; +import { AlertType, AlertInstanceState, AlertInstanceContext } from '../../../../../alerts/server'; +import { + createMetricAnomalyExecutor, + FIRED_ACTIONS, + FIRED_ACTIONS_ID, +} from './metric_anomaly_executor'; +import { METRIC_ANOMALY_ALERT_TYPE_ID } from '../../../../common/alerting/metrics'; +import { InfraBackendLibs } from '../../infra_types'; +import { oneOfLiterals, validateIsStringElasticsearchJSONFilter } from '../common/utils'; +import { alertStateActionVariableDescription } from '../common/messages'; +import { RecoveredActionGroupId } from '../../../../../alerts/common'; + +export type MetricAnomalyAllowedActionGroups = typeof FIRED_ACTIONS_ID; + +export const registerMetricAnomalyAlertType = ( + libs: InfraBackendLibs, + ml?: MlPluginSetup +): AlertType< + /** + * TODO: Remove this use of `any` by utilizing a proper type + */ + Record, + Record, + AlertInstanceState, + AlertInstanceContext, + MetricAnomalyAllowedActionGroups, + RecoveredActionGroupId +> => ({ + id: METRIC_ANOMALY_ALERT_TYPE_ID, + name: i18n.translate('xpack.infra.metrics.anomaly.alertName', { + defaultMessage: 'Infrastructure anomaly', + }), + validate: { + params: schema.object( + { + nodeType: oneOfLiterals(['hosts', 'k8s']), + alertInterval: schema.string(), + metric: oneOfLiterals(['memory_usage', 'network_in', 'network_out']), + threshold: schema.number(), + filterQuery: schema.maybe( + schema.string({ validate: validateIsStringElasticsearchJSONFilter }) + ), + sourceId: schema.string(), + }, + { unknowns: 'allow' } + ), + }, + defaultActionGroupId: FIRED_ACTIONS_ID, + actionGroups: [FIRED_ACTIONS], + producer: 'infrastructure', + minimumLicenseRequired: 'basic', + executor: createMetricAnomalyExecutor(libs, ml), + actionVariables: { + context: [ + { name: 'alertState', description: alertStateActionVariableDescription }, + { + name: 'metric', + description: i18n.translate('xpack.infra.metrics.alerting.anomalyMetricDescription', { + defaultMessage: 'The metric name in the specified condition.', + }), + }, + { + name: 'timestamp', + description: i18n.translate('xpack.infra.metrics.alerting.anomalyTimestampDescription', { + defaultMessage: 'A timestamp of when the anomaly was detected.', + }), + }, + { + name: 'anomalyScore', + description: i18n.translate('xpack.infra.metrics.alerting.anomalyScoreDescription', { + defaultMessage: 'The exact severity score of the detected anomaly.', + }), + }, + { + name: 'actual', + description: i18n.translate('xpack.infra.metrics.alerting.anomalyActualDescription', { + defaultMessage: 'The actual value of the monitored metric at the time of the anomaly.', + }), + }, + { + name: 'typical', + description: i18n.translate('xpack.infra.metrics.alerting.anomalyTypicalDescription', { + defaultMessage: 'The typical value of the monitored metric at the time of the anomaly.', + }), + }, + { + name: 'summary', + description: i18n.translate('xpack.infra.metrics.alerting.anomalySummaryDescription', { + defaultMessage: 'A description of the anomaly, e.g. "2x higher."', + }), + }, + { + name: 'influencers', + description: i18n.translate('xpack.infra.metrics.alerting.anomalyInfluencersDescription', { + defaultMessage: 'A list of node names that influenced the anomaly.', + }), + }, + ], + }, +}); diff --git a/x-pack/plugins/infra/server/lib/alerting/register_alert_types.ts b/x-pack/plugins/infra/server/lib/alerting/register_alert_types.ts index 0b4df6805759e..11fbe269b854d 100644 --- a/x-pack/plugins/infra/server/lib/alerting/register_alert_types.ts +++ b/x-pack/plugins/infra/server/lib/alerting/register_alert_types.ts @@ -8,13 +8,21 @@ import { PluginSetupContract } from '../../../../alerts/server'; import { registerMetricThresholdAlertType } from './metric_threshold/register_metric_threshold_alert_type'; import { registerMetricInventoryThresholdAlertType } from './inventory_metric_threshold/register_inventory_metric_threshold_alert_type'; +import { registerMetricAnomalyAlertType } from './metric_anomaly/register_metric_anomaly_alert_type'; + import { registerLogThresholdAlertType } from './log_threshold/register_log_threshold_alert_type'; import { InfraBackendLibs } from '../infra_types'; +import { MlPluginSetup } from '../../../../ml/server'; -const registerAlertTypes = (alertingPlugin: PluginSetupContract, libs: InfraBackendLibs) => { +const registerAlertTypes = ( + alertingPlugin: PluginSetupContract, + libs: InfraBackendLibs, + ml?: MlPluginSetup +) => { if (alertingPlugin) { alertingPlugin.registerType(registerMetricThresholdAlertType(libs)); alertingPlugin.registerType(registerMetricInventoryThresholdAlertType(libs)); + alertingPlugin.registerType(registerMetricAnomalyAlertType(libs, ml)); const registerFns = [registerLogThresholdAlertType]; registerFns.forEach((fn) => { diff --git a/x-pack/plugins/infra/server/lib/infra_ml/common.ts b/x-pack/plugins/infra/server/lib/infra_ml/common.ts index 0182cb0e4099a..686f27d714cc1 100644 --- a/x-pack/plugins/infra/server/lib/infra_ml/common.ts +++ b/x-pack/plugins/infra/server/lib/infra_ml/common.ts @@ -17,6 +17,23 @@ import { import { decodeOrThrow } from '../../../common/runtime_types'; import { startTracingSpan, TracingSpan } from '../../../common/performance_tracing'; +export interface MappedAnomalyHit { + id: string; + anomalyScore: number; + typical: number; + actual: number; + jobId: string; + startTime: number; + duration: number; + influencers: string[]; + categoryId?: string; +} + +export interface InfluencerFilter { + fieldName: string; + fieldValue: string; +} + export async function fetchMlJob(mlAnomalyDetectors: MlAnomalyDetectors, jobId: string) { const finalizeMlGetJobSpan = startTracingSpan('Fetch ml job from ES'); const { diff --git a/x-pack/plugins/infra/server/lib/infra_ml/index.ts b/x-pack/plugins/infra/server/lib/infra_ml/index.ts index d346b71d76aa8..82093b1a359d0 100644 --- a/x-pack/plugins/infra/server/lib/infra_ml/index.ts +++ b/x-pack/plugins/infra/server/lib/infra_ml/index.ts @@ -8,3 +8,4 @@ export * from './errors'; export * from './metrics_hosts_anomalies'; export * from './metrics_k8s_anomalies'; +export { MappedAnomalyHit } from './common'; diff --git a/x-pack/plugins/infra/server/lib/infra_ml/metrics_hosts_anomalies.ts b/x-pack/plugins/infra/server/lib/infra_ml/metrics_hosts_anomalies.ts index 7873fd8e43a7b..f6e11f5294191 100644 --- a/x-pack/plugins/infra/server/lib/infra_ml/metrics_hosts_anomalies.ts +++ b/x-pack/plugins/infra/server/lib/infra_ml/metrics_hosts_anomalies.ts @@ -5,11 +5,10 @@ * 2.0. */ -import type { InfraPluginRequestHandlerContext } from '../../types'; import { InfraRequestHandlerContext } from '../../types'; import { TracingSpan, startTracingSpan } from '../../../common/performance_tracing'; -import { fetchMlJob } from './common'; -import { getJobId, metricsHostsJobTypes } from '../../../common/infra_ml'; +import { fetchMlJob, MappedAnomalyHit, InfluencerFilter } from './common'; +import { getJobId, metricsHostsJobTypes, ANOMALY_THRESHOLD } from '../../../common/infra_ml'; import { Sort, Pagination } from '../../../common/http_api/infra_ml'; import type { MlSystem, MlAnomalyDetectors } from '../../types'; import { InsufficientAnomalyMlJobsConfigured, isMlPrivilegesError } from './errors'; @@ -19,18 +18,6 @@ import { createMetricsHostsAnomaliesQuery, } from './queries/metrics_hosts_anomalies'; -interface MappedAnomalyHit { - id: string; - anomalyScore: number; - typical: number; - actual: number; - jobId: string; - startTime: number; - duration: number; - influencers: string[]; - categoryId?: string; -} - async function getCompatibleAnomaliesJobIds( spaceId: string, sourceId: string, @@ -74,14 +61,15 @@ async function getCompatibleAnomaliesJobIds( } export async function getMetricsHostsAnomalies( - context: InfraPluginRequestHandlerContext & { infra: Required }, + context: Required, sourceId: string, - anomalyThreshold: number, + anomalyThreshold: ANOMALY_THRESHOLD, startTime: number, endTime: number, metric: 'memory_usage' | 'network_in' | 'network_out' | undefined, sort: Sort, - pagination: Pagination + pagination: Pagination, + influencerFilter?: InfluencerFilter ) { const finalizeMetricsHostsAnomaliesSpan = startTracingSpan('get metrics hosts entry anomalies'); @@ -89,10 +77,10 @@ export async function getMetricsHostsAnomalies( jobIds, timing: { spans: jobSpans }, } = await getCompatibleAnomaliesJobIds( - context.infra.spaceId, + context.spaceId, sourceId, metric, - context.infra.mlAnomalyDetectors + context.mlAnomalyDetectors ); if (jobIds.length === 0) { @@ -108,13 +96,14 @@ export async function getMetricsHostsAnomalies( hasMoreEntries, timing: { spans: fetchLogEntryAnomaliesSpans }, } = await fetchMetricsHostsAnomalies( - context.infra.mlSystem, + context.mlSystem, anomalyThreshold, jobIds, startTime, endTime, sort, - pagination + pagination, + influencerFilter ); const data = anomalies.map((anomaly) => { @@ -164,12 +153,13 @@ const parseAnomalyResult = (anomaly: MappedAnomalyHit, jobId: string) => { async function fetchMetricsHostsAnomalies( mlSystem: MlSystem, - anomalyThreshold: number, + anomalyThreshold: ANOMALY_THRESHOLD, jobIds: string[], startTime: number, endTime: number, sort: Sort, - pagination: Pagination + pagination: Pagination, + influencerFilter?: InfluencerFilter ) { // We'll request 1 extra entry on top of our pageSize to determine if there are // more entries to be fetched. This avoids scenarios where the client side can't @@ -188,6 +178,7 @@ async function fetchMetricsHostsAnomalies( endTime, sort, pagination: expandedPagination, + influencerFilter, }), jobIds ) diff --git a/x-pack/plugins/infra/server/lib/infra_ml/metrics_k8s_anomalies.ts b/x-pack/plugins/infra/server/lib/infra_ml/metrics_k8s_anomalies.ts index 0c87b2f0f8b53..34039e9107f00 100644 --- a/x-pack/plugins/infra/server/lib/infra_ml/metrics_k8s_anomalies.ts +++ b/x-pack/plugins/infra/server/lib/infra_ml/metrics_k8s_anomalies.ts @@ -5,11 +5,10 @@ * 2.0. */ -import type { InfraPluginRequestHandlerContext } from '../../types'; import { InfraRequestHandlerContext } from '../../types'; import { TracingSpan, startTracingSpan } from '../../../common/performance_tracing'; -import { fetchMlJob } from './common'; -import { getJobId, metricsK8SJobTypes } from '../../../common/infra_ml'; +import { fetchMlJob, MappedAnomalyHit, InfluencerFilter } from './common'; +import { getJobId, metricsK8SJobTypes, ANOMALY_THRESHOLD } from '../../../common/infra_ml'; import { Sort, Pagination } from '../../../common/http_api/infra_ml'; import type { MlSystem, MlAnomalyDetectors } from '../../types'; import { InsufficientAnomalyMlJobsConfigured, isMlPrivilegesError } from './errors'; @@ -19,18 +18,6 @@ import { createMetricsK8sAnomaliesQuery, } from './queries/metrics_k8s_anomalies'; -interface MappedAnomalyHit { - id: string; - anomalyScore: number; - typical: number; - actual: number; - jobId: string; - startTime: number; - influencers: string[]; - duration: number; - categoryId?: string; -} - async function getCompatibleAnomaliesJobIds( spaceId: string, sourceId: string, @@ -74,14 +61,15 @@ async function getCompatibleAnomaliesJobIds( } export async function getMetricK8sAnomalies( - context: InfraPluginRequestHandlerContext & { infra: Required }, + context: Required, sourceId: string, - anomalyThreshold: number, + anomalyThreshold: ANOMALY_THRESHOLD, startTime: number, endTime: number, metric: 'memory_usage' | 'network_in' | 'network_out' | undefined, sort: Sort, - pagination: Pagination + pagination: Pagination, + influencerFilter?: InfluencerFilter ) { const finalizeMetricsK8sAnomaliesSpan = startTracingSpan('get metrics k8s entry anomalies'); @@ -89,10 +77,10 @@ export async function getMetricK8sAnomalies( jobIds, timing: { spans: jobSpans }, } = await getCompatibleAnomaliesJobIds( - context.infra.spaceId, + context.spaceId, sourceId, metric, - context.infra.mlAnomalyDetectors + context.mlAnomalyDetectors ); if (jobIds.length === 0) { @@ -107,13 +95,14 @@ export async function getMetricK8sAnomalies( hasMoreEntries, timing: { spans: fetchLogEntryAnomaliesSpans }, } = await fetchMetricK8sAnomalies( - context.infra.mlSystem, + context.mlSystem, anomalyThreshold, jobIds, startTime, endTime, sort, - pagination + pagination, + influencerFilter ); const data = anomalies.map((anomaly) => { @@ -160,12 +149,13 @@ const parseAnomalyResult = (anomaly: MappedAnomalyHit, jobId: string) => { async function fetchMetricK8sAnomalies( mlSystem: MlSystem, - anomalyThreshold: number, + anomalyThreshold: ANOMALY_THRESHOLD, jobIds: string[], startTime: number, endTime: number, sort: Sort, - pagination: Pagination + pagination: Pagination, + influencerFilter?: InfluencerFilter | undefined ) { // We'll request 1 extra entry on top of our pageSize to determine if there are // more entries to be fetched. This avoids scenarios where the client side can't @@ -184,6 +174,7 @@ async function fetchMetricK8sAnomalies( endTime, sort, pagination: expandedPagination, + influencerFilter, }), jobIds ) diff --git a/x-pack/plugins/infra/server/lib/infra_ml/queries/common.ts b/x-pack/plugins/infra/server/lib/infra_ml/queries/common.ts index b3676fc54aeaa..6f996a672a44a 100644 --- a/x-pack/plugins/infra/server/lib/infra_ml/queries/common.ts +++ b/x-pack/plugins/infra/server/lib/infra_ml/queries/common.ts @@ -77,3 +77,35 @@ export const createDatasetsFilters = (datasets?: string[]) => }, ] : []; + +export const createInfluencerFilter = ({ + fieldName, + fieldValue, +}: { + fieldName: string; + fieldValue: string; +}) => [ + { + nested: { + path: 'influencers', + query: { + bool: { + must: [ + { + match: { + 'influencers.influencer_field_name': fieldName, + }, + }, + { + query_string: { + fields: ['influencers.influencer_field_values'], + query: fieldValue, + minimum_should_match: 1, + }, + }, + ], + }, + }, + }, + }, +]; diff --git a/x-pack/plugins/infra/server/lib/infra_ml/queries/metrics_hosts_anomalies.ts b/x-pack/plugins/infra/server/lib/infra_ml/queries/metrics_hosts_anomalies.ts index 45587cd258e5d..7808851508a7c 100644 --- a/x-pack/plugins/infra/server/lib/infra_ml/queries/metrics_hosts_anomalies.ts +++ b/x-pack/plugins/infra/server/lib/infra_ml/queries/metrics_hosts_anomalies.ts @@ -6,6 +6,7 @@ */ import * as rt from 'io-ts'; +import { ANOMALY_THRESHOLD } from '../../../../common/infra_ml'; import { commonSearchSuccessResponseFieldsRT } from '../../../utils/elasticsearch_runtime_types'; import { createJobIdsFilters, @@ -13,7 +14,9 @@ import { createResultTypeFilters, defaultRequestParameters, createAnomalyScoreFilter, + createInfluencerFilter, } from './common'; +import { InfluencerFilter } from '../common'; import { Sort, Pagination } from '../../../../common/http_api/infra_ml'; // TODO: Reassess validity of this against ML docs @@ -32,13 +35,15 @@ export const createMetricsHostsAnomaliesQuery = ({ endTime, sort, pagination, + influencerFilter, }: { jobIds: string[]; - anomalyThreshold: number; + anomalyThreshold: ANOMALY_THRESHOLD; startTime: number; endTime: number; sort: Sort; pagination: Pagination; + influencerFilter?: InfluencerFilter; }) => { const { field } = sort; const { pageSize } = pagination; @@ -50,6 +55,10 @@ export const createMetricsHostsAnomaliesQuery = ({ ...createResultTypeFilters(['record']), ]; + const influencerQuery = influencerFilter + ? { must: createInfluencerFilter(influencerFilter) } + : {}; + const sourceFields = [ 'job_id', 'record_score', @@ -77,6 +86,7 @@ export const createMetricsHostsAnomaliesQuery = ({ query: { bool: { filter: filters, + ...influencerQuery, }, }, search_after: queryCursor, diff --git a/x-pack/plugins/infra/server/lib/infra_ml/queries/metrics_k8s_anomalies.ts b/x-pack/plugins/infra/server/lib/infra_ml/queries/metrics_k8s_anomalies.ts index 56a4b99e7236c..54eea067177ed 100644 --- a/x-pack/plugins/infra/server/lib/infra_ml/queries/metrics_k8s_anomalies.ts +++ b/x-pack/plugins/infra/server/lib/infra_ml/queries/metrics_k8s_anomalies.ts @@ -6,6 +6,7 @@ */ import * as rt from 'io-ts'; +import { ANOMALY_THRESHOLD } from '../../../../common/infra_ml'; import { commonSearchSuccessResponseFieldsRT } from '../../../utils/elasticsearch_runtime_types'; import { createJobIdsFilters, @@ -13,7 +14,9 @@ import { createResultTypeFilters, defaultRequestParameters, createAnomalyScoreFilter, + createInfluencerFilter, } from './common'; +import { InfluencerFilter } from '../common'; import { Sort, Pagination } from '../../../../common/http_api/infra_ml'; // TODO: Reassess validity of this against ML docs @@ -32,13 +35,15 @@ export const createMetricsK8sAnomaliesQuery = ({ endTime, sort, pagination, + influencerFilter, }: { jobIds: string[]; - anomalyThreshold: number; + anomalyThreshold: ANOMALY_THRESHOLD; startTime: number; endTime: number; sort: Sort; pagination: Pagination; + influencerFilter?: InfluencerFilter; }) => { const { field } = sort; const { pageSize } = pagination; @@ -50,6 +55,10 @@ export const createMetricsK8sAnomaliesQuery = ({ ...createResultTypeFilters(['record']), ]; + const influencerQuery = influencerFilter + ? { must: createInfluencerFilter(influencerFilter) } + : {}; + const sourceFields = [ 'job_id', 'record_score', @@ -76,6 +85,7 @@ export const createMetricsK8sAnomaliesQuery = ({ query: { bool: { filter: filters, + ...influencerQuery, }, }, search_after: queryCursor, diff --git a/x-pack/plugins/infra/server/plugin.ts b/x-pack/plugins/infra/server/plugin.ts index 99555fa56acd5..0ac49e05b36b9 100644 --- a/x-pack/plugins/infra/server/plugin.ts +++ b/x-pack/plugins/infra/server/plugin.ts @@ -137,7 +137,7 @@ export class InfraServerPlugin implements Plugin { ]); initInfraServer(this.libs); - registerAlertTypes(plugins.alerts, this.libs); + registerAlertTypes(plugins.alerts, this.libs, plugins.ml); core.http.registerRouteHandlerContext( 'infra', diff --git a/x-pack/plugins/infra/server/routes/alerting/preview.ts b/x-pack/plugins/infra/server/routes/alerting/preview.ts index cc2cf4092520a..3da560135eaf4 100644 --- a/x-pack/plugins/infra/server/routes/alerting/preview.ts +++ b/x-pack/plugins/infra/server/routes/alerting/preview.ts @@ -9,17 +9,21 @@ import { PreviewResult } from '../../lib/alerting/common/types'; import { METRIC_THRESHOLD_ALERT_TYPE_ID, METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID, + METRIC_ANOMALY_ALERT_TYPE_ID, INFRA_ALERT_PREVIEW_PATH, TOO_MANY_BUCKETS_PREVIEW_EXCEPTION, alertPreviewRequestParamsRT, alertPreviewSuccessResponsePayloadRT, MetricThresholdAlertPreviewRequestParams, InventoryAlertPreviewRequestParams, + MetricAnomalyAlertPreviewRequestParams, } from '../../../common/alerting/metrics'; import { createValidationFunction } from '../../../common/runtime_types'; import { previewInventoryMetricThresholdAlert } from '../../lib/alerting/inventory_metric_threshold/preview_inventory_metric_threshold_alert'; import { previewMetricThresholdAlert } from '../../lib/alerting/metric_threshold/preview_metric_threshold_alert'; +import { previewMetricAnomalyAlert } from '../../lib/alerting/metric_anomaly/preview_metric_anomaly_alert'; import { InfraBackendLibs } from '../../lib/infra_types'; +import { assertHasInfraMlPlugins } from '../../utils/request_context'; export const initAlertPreviewRoute = ({ framework, sources }: InfraBackendLibs) => { const { callWithRequest } = framework; @@ -33,8 +37,6 @@ export const initAlertPreviewRoute = ({ framework, sources }: InfraBackendLibs) }, framework.router.handleLegacyErrors(async (requestContext, request, response) => { const { - criteria, - filterQuery, lookback, sourceId, alertType, @@ -55,7 +57,11 @@ export const initAlertPreviewRoute = ({ framework, sources }: InfraBackendLibs) try { switch (alertType) { case METRIC_THRESHOLD_ALERT_TYPE_ID: { - const { groupBy } = request.body as MetricThresholdAlertPreviewRequestParams; + const { + groupBy, + criteria, + filterQuery, + } = request.body as MetricThresholdAlertPreviewRequestParams; const previewResult = await previewMetricThresholdAlert({ callCluster, params: { criteria, filterQuery, groupBy }, @@ -72,7 +78,11 @@ export const initAlertPreviewRoute = ({ framework, sources }: InfraBackendLibs) }); } case METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID: { - const { nodeType } = request.body as InventoryAlertPreviewRequestParams; + const { + nodeType, + criteria, + filterQuery, + } = request.body as InventoryAlertPreviewRequestParams; const previewResult = await previewInventoryMetricThresholdAlert({ callCluster, params: { criteria, filterQuery, nodeType }, @@ -89,6 +99,39 @@ export const initAlertPreviewRoute = ({ framework, sources }: InfraBackendLibs) body: alertPreviewSuccessResponsePayloadRT.encode(payload), }); } + case METRIC_ANOMALY_ALERT_TYPE_ID: { + assertHasInfraMlPlugins(requestContext); + const { + nodeType, + metric, + threshold, + influencerFilter, + } = request.body as MetricAnomalyAlertPreviewRequestParams; + const { mlAnomalyDetectors, mlSystem, spaceId } = requestContext.infra; + + const previewResult = await previewMetricAnomalyAlert({ + mlAnomalyDetectors, + mlSystem, + spaceId, + params: { nodeType, metric, threshold, influencerFilter }, + lookback, + sourceId: source.id, + alertInterval, + alertThrottle, + alertOnNoData, + }); + + return response.ok({ + body: alertPreviewSuccessResponsePayloadRT.encode({ + numberOfGroups: 1, + resultTotals: { + ...previewResult, + error: 0, + noData: 0, + }, + }), + }); + } default: throw new Error('Unknown alert type'); } diff --git a/x-pack/plugins/infra/server/routes/infra_ml/results/metrics_hosts_anomalies.ts b/x-pack/plugins/infra/server/routes/infra_ml/results/metrics_hosts_anomalies.ts index 8ec0b83994e1a..6e227cfc12d11 100644 --- a/x-pack/plugins/infra/server/routes/infra_ml/results/metrics_hosts_anomalies.ts +++ b/x-pack/plugins/infra/server/routes/infra_ml/results/metrics_hosts_anomalies.ts @@ -53,7 +53,7 @@ export const initGetHostsAnomaliesRoute = ({ framework }: InfraBackendLibs) => { hasMoreEntries, timing, } = await getMetricsHostsAnomalies( - requestContext, + requestContext.infra, sourceId, anomalyThreshold, startTime, diff --git a/x-pack/plugins/infra/server/routes/infra_ml/results/metrics_k8s_anomalies.ts b/x-pack/plugins/infra/server/routes/infra_ml/results/metrics_k8s_anomalies.ts index d41fa0ffafecc..1c2c4947a02ea 100644 --- a/x-pack/plugins/infra/server/routes/infra_ml/results/metrics_k8s_anomalies.ts +++ b/x-pack/plugins/infra/server/routes/infra_ml/results/metrics_k8s_anomalies.ts @@ -52,7 +52,7 @@ export const initGetK8sAnomaliesRoute = ({ framework }: InfraBackendLibs) => { hasMoreEntries, timing, } = await getMetricK8sAnomalies( - requestContext, + requestContext.infra, sourceId, anomalyThreshold, startTime, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 6e9d0329eaff8..018d2d572eea0 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -9676,7 +9676,6 @@ "xpack.infra.alerting.alertFlyout.groupBy.placeholder": "なし(グループなし)", "xpack.infra.alerting.alertFlyout.groupByLabel": "グループ分けの条件", "xpack.infra.alerting.alertsButton": "アラート", - "xpack.infra.alerting.createAlertButton": "アラートの作成", "xpack.infra.alerting.logs.alertsButton": "アラート", "xpack.infra.alerting.logs.createAlertButton": "アラートの作成", "xpack.infra.alerting.logs.manageAlerts": "アラートを管理", @@ -9970,16 +9969,6 @@ "xpack.infra.logs.jumpToTailText": "最も新しいエントリーに移動", "xpack.infra.logs.lastUpdate": "前回の更新 {timestamp}", "xpack.infra.logs.loadingNewEntriesText": "新しいエントリーを読み込み中", - "xpack.infra.logs.logAnalysis.splash.learnMoreLink": "ドキュメンテーションを表示", - "xpack.infra.logs.logAnalysis.splash.learnMoreTitle": "詳細について", - "xpack.infra.logs.logAnalysis.splash.loadingMessage": "ライセンスを確認しています...", - "xpack.infra.logs.logAnalysis.splash.splashImageAlt": "プレースホルダー画像", - "xpack.infra.logs.logAnalysis.splash.startTrialCta": "トライアルを開始", - "xpack.infra.logs.logAnalysis.splash.startTrialDescription": "無料の試用版には、機械学習機能が含まれており、ログで異常を検出することができます。", - "xpack.infra.logs.logAnalysis.splash.startTrialTitle": "異常検知を利用するには、無料の試用版を開始してください", - "xpack.infra.logs.logAnalysis.splash.updateSubscriptionCta": "サブスクリプションのアップグレード", - "xpack.infra.logs.logAnalysis.splash.updateSubscriptionDescription": "機械学習機能を使用するには、プラチナサブスクリプションが必要です。", - "xpack.infra.logs.logAnalysis.splash.updateSubscriptionTitle": "異常検知を利用するには、プラチナサブスクリプションにアップグレードしてください", "xpack.infra.logs.logEntryActionsDetailsButton": "詳細を表示", "xpack.infra.logs.logEntryCategories.analyzeCategoryInMlButtonLabel": "ML で分析", "xpack.infra.logs.logEntryCategories.analyzeCategoryInMlTooltipDescription": "ML アプリでこのカテゴリーを分析します。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index eeda709104479..5a9695b8ddc3d 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -9702,7 +9702,6 @@ "xpack.infra.alerting.alertFlyout.groupBy.placeholder": "无内容(未分组)", "xpack.infra.alerting.alertFlyout.groupByLabel": "分组依据", "xpack.infra.alerting.alertsButton": "告警", - "xpack.infra.alerting.createAlertButton": "创建告警", "xpack.infra.alerting.logs.alertsButton": "告警", "xpack.infra.alerting.logs.createAlertButton": "创建告警", "xpack.infra.alerting.logs.manageAlerts": "管理告警", @@ -9997,16 +9996,6 @@ "xpack.infra.logs.jumpToTailText": "跳到最近的条目", "xpack.infra.logs.lastUpdate": "上次更新时间 {timestamp}", "xpack.infra.logs.loadingNewEntriesText": "正在加载新条目", - "xpack.infra.logs.logAnalysis.splash.learnMoreLink": "阅读文档", - "xpack.infra.logs.logAnalysis.splash.learnMoreTitle": "希望了解详情?", - "xpack.infra.logs.logAnalysis.splash.loadingMessage": "正在检查许可证......", - "xpack.infra.logs.logAnalysis.splash.splashImageAlt": "占位符图像", - "xpack.infra.logs.logAnalysis.splash.startTrialCta": "开始试用", - "xpack.infra.logs.logAnalysis.splash.startTrialDescription": "我们的免费试用版包含 Machine Learning 功能,可用于检测日志中的异常。", - "xpack.infra.logs.logAnalysis.splash.startTrialTitle": "要访问异常检测,请启动免费试用版", - "xpack.infra.logs.logAnalysis.splash.updateSubscriptionCta": "升级订阅", - "xpack.infra.logs.logAnalysis.splash.updateSubscriptionDescription": "必须具有白金级订阅,才能使用 Machine Learning 功能。", - "xpack.infra.logs.logAnalysis.splash.updateSubscriptionTitle": "要访问异常检测,请升级到白金级订阅", "xpack.infra.logs.logEntryActionsDetailsButton": "查看详情", "xpack.infra.logs.logEntryCategories.analyzeCategoryInMlButtonLabel": "在 ML 中分析", "xpack.infra.logs.logEntryCategories.analyzeCategoryInMlTooltipDescription": "在 ML 应用中分析此类别。", From ab401c98572780168ac589e1685b256004abe92e Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Tue, 9 Feb 2021 23:29:52 -0800 Subject: [PATCH 02/14] skip flaky suite (#90136) --- x-pack/test/api_integration/apis/security_solution/users.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/test/api_integration/apis/security_solution/users.ts b/x-pack/test/api_integration/apis/security_solution/users.ts index 45e06ab72adbb..b888be2bf6276 100644 --- a/x-pack/test/api_integration/apis/security_solution/users.ts +++ b/x-pack/test/api_integration/apis/security_solution/users.ts @@ -23,6 +23,7 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const supertest = getService('supertest'); // Failing: See https://github.com/elastic/kibana/issues/90135 + // Failing: See https://github.com/elastic/kibana/issues/90136 describe.skip('Users', () => { describe('With auditbeat', () => { before(() => esArchiver.load('auditbeat/default')); From af277b83962d4bef12094bbbc17bad805a5c02a1 Mon Sep 17 00:00:00 2001 From: Sonja Krause-Harder Date: Wed, 10 Feb 2021 09:16:19 +0100 Subject: [PATCH 03/14] Add deprecation warning to all Beats CM pages. (#90741) --- .../beats_management/public/application.tsx | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/x-pack/plugins/beats_management/public/application.tsx b/x-pack/plugins/beats_management/public/application.tsx index 6e81809b9c493..5a9b0a768856e 100644 --- a/x-pack/plugins/beats_management/public/application.tsx +++ b/x-pack/plugins/beats_management/public/application.tsx @@ -7,11 +7,13 @@ import * as euiVars from '@elastic/eui/dist/eui_theme_light.json'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; import ReactDOM from 'react-dom'; import { Router } from 'react-router-dom'; import { ThemeProvider } from 'styled-components'; import { Provider as UnstatedProvider, Subscribe } from 'unstated'; +import { EuiCallOut, EuiLink, EuiSpacer } from '@elastic/eui'; import { Background } from './components/layouts/background'; import { BreadcrumbProvider } from './components/navigation/breadcrumb'; import { Breadcrumb } from './components/navigation/breadcrumb/breadcrumb'; @@ -37,6 +39,38 @@ export const renderApp = ({ element, history }: ManagementAppMountParams, libs: defaultMessage: 'Management', })} /> + +

+ + + + ), + }} + /> +

+
+ )} From 240da2bf2a999501eddc378466e67ebb8e26df76 Mon Sep 17 00:00:00 2001 From: Brandon Kobel Date: Wed, 10 Feb 2021 00:38:37 -0800 Subject: [PATCH 04/14] Actually deleting x-pack/tsconfig.refs.json (#90898) --- x-pack/tsconfig.refs.json | 60 --------------------------------------- 1 file changed, 60 deletions(-) delete mode 100644 x-pack/tsconfig.refs.json diff --git a/x-pack/tsconfig.refs.json b/x-pack/tsconfig.refs.json deleted file mode 100644 index a36f4e205ab7d..0000000000000 --- a/x-pack/tsconfig.refs.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "include": [], - "references": [ - { "path": "./plugins/actions/tsconfig.json" }, - { "path": "./plugins/alerts/tsconfig.json" }, - { "path": "./plugins/beats_management/tsconfig.json" }, - { "path": "./plugins/canvas/tsconfig.json" }, - { "path": "./plugins/cloud/tsconfig.json" }, - { "path": "./plugins/code/tsconfig.json" }, - { "path": "./plugins/console_extensions/tsconfig.json" }, - { "path": "./plugins/dashboard_enhanced/tsconfig.json" }, - { "path": "./plugins/data_enhanced/tsconfig.json" }, - { "path": "./plugins/dashboard_mode/tsconfig.json" }, - { "path": "./plugins/discover_enhanced/tsconfig.json" }, - { "path": "./plugins/embeddable_enhanced/tsconfig.json" }, - { "path": "./plugins/encrypted_saved_objects/tsconfig.json" }, - { "path": "./plugins/enterprise_search/tsconfig.json" }, - { "path": "./plugins/event_log/tsconfig.json" }, - { "path": "./plugins/features/tsconfig.json" }, - { "path": "./plugins/file_upload/tsconfig.json" }, - { "path": "./plugins/fleet/tsconfig.json" }, - { "path": "./plugins/global_search_bar/tsconfig.json" }, - { "path": "./plugins/global_search_providers/tsconfig.json" }, - { "path": "./plugins/global_search/tsconfig.json" }, - { "path": "./plugins/graph/tsconfig.json" }, - { "path": "./plugins/grokdebugger/tsconfig.json" }, - { "path": "./plugins/infra/tsconfig.json" }, - { "path": "./plugins/ingest_pipelines/tsconfig.json" }, - { "path": "./plugins/lens/tsconfig.json" }, - { "path": "./plugins/license_management/tsconfig.json" }, - { "path": "./plugins/licensing/tsconfig.json" }, - { "path": "./plugins/maps_legacy_licensing/tsconfig.json" }, - { "path": "./plugins/maps/tsconfig.json" }, - { "path": "./plugins/ml/tsconfig.json" }, - { "path": "./plugins/observability/tsconfig.json" }, - { "path": "./plugins/painless_lab/tsconfig.json" }, - { "path": "./plugins/reporting/tsconfig.json" }, - { "path": "./plugins/saved_objects_tagging/tsconfig.json" }, - { "path": "./plugins/searchprofiler/tsconfig.json" }, - { "path": "./plugins/security/tsconfig.json" }, - { "path": "./plugins/snapshot_restore/tsconfig.json" }, - { "path": "./plugins/spaces/tsconfig.json" }, - { "path": "./plugins/stack_alerts/tsconfig.json" }, - { "path": "./plugins/task_manager/tsconfig.json" }, - { "path": "./plugins/telemetry_collection_xpack/tsconfig.json" }, - { "path": "./plugins/transform/tsconfig.json" }, - { "path": "./plugins/translations/tsconfig.json" }, - { "path": "./plugins/triggers_actions_ui/tsconfig.json" }, - { "path": "./plugins/ui_actions_enhanced/tsconfig.json" }, - { "path": "./plugins/upgrade_assistant/tsconfig.json" }, - { "path": "./plugins/runtime_fields/tsconfig.json" }, - { "path": "./plugins/index_management/tsconfig.json" }, - { "path": "./plugins/watcher/tsconfig.json" }, - { "path": "./plugins/rollup/tsconfig.json"}, - { "path": "./plugins/remote_clusters/tsconfig.json"}, - { "path": "./plugins/cross_cluster_replication/tsconfig.json"}, - { "path": "./plugins/index_lifecycle_management/tsconfig.json"}, - { "path": "./plugins/uptime/tsconfig.json" } - ] -} From 634c0b34242adf87c78b72ed73051b0fd1c41014 Mon Sep 17 00:00:00 2001 From: Nicolas Ruflin Date: Wed, 10 Feb 2021 09:57:09 +0100 Subject: [PATCH 05/14] [Fleet] Use staging registry for snapshot builds (#90327) The staging registry is used in Kibana builds which are not built of the master branch or release version. This means, any build ending with `-SNAPSHOT` not the master branch will use the staging registry. Closes https://github.com/elastic/kibana/issues/90131 Co-authored-by: Jen Huang --- .../fleet/server/services/epm/registry/registry_url.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/fleet/server/services/epm/registry/registry_url.ts b/x-pack/plugins/fleet/server/services/epm/registry/registry_url.ts index 1394d2738482d..8c637006fb0cd 100644 --- a/x-pack/plugins/fleet/server/services/epm/registry/registry_url.ts +++ b/x-pack/plugins/fleet/server/services/epm/registry/registry_url.ts @@ -12,7 +12,7 @@ import { appContextService, licenseService } from '../../'; // chose to comment them out vs @ts-ignore or @ts-expect-error on each line const PRODUCTION_REGISTRY_URL_CDN = 'https://epr.elastic.co'; -// const STAGING_REGISTRY_URL_CDN = 'https://epr-staging.elastic.co'; +const STAGING_REGISTRY_URL_CDN = 'https://epr-staging.elastic.co'; const SNAPSHOT_REGISTRY_URL_CDN = 'https://epr-snapshot.elastic.co'; // const PRODUCTION_REGISTRY_URL_NO_CDN = 'https://epr.ea-web.elastic.dev'; @@ -23,6 +23,8 @@ const getDefaultRegistryUrl = (): string => { const branch = appContextService.getKibanaBranch(); if (branch === 'master') { return SNAPSHOT_REGISTRY_URL_CDN; + } else if (appContextService.getKibanaVersion().includes('-SNAPSHOT')) { + return STAGING_REGISTRY_URL_CDN; } else { return PRODUCTION_REGISTRY_URL_CDN; } From 03a53b9f39a54a6accb8f37eb9fe84c882681511 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Wed, 10 Feb 2021 11:27:31 +0100 Subject: [PATCH 06/14] Do not generate an ephemeral encryption key in production. (#81511) --- .../server/action_type_registry.test.ts | 4 +- .../actions/server/actions_client.test.ts | 8 +- .../server/builtin_action_types/index.test.ts | 4 +- .../server/create_execute_function.test.ts | 14 +- .../actions/server/create_execute_function.ts | 8 +- .../server/lib/action_executor.test.ts | 8 +- .../actions/server/lib/action_executor.ts | 10 +- .../server/lib/task_runner_factory.test.ts | 8 +- x-pack/plugins/actions/server/plugin.test.ts | 44 +-- x-pack/plugins/actions/server/plugin.ts | 27 +- x-pack/plugins/alerts/server/plugin.test.ts | 18 +- x-pack/plugins/alerts/server/plugin.ts | 15 +- .../alerts/server/routes/health.test.ts | 26 +- x-pack/plugins/alerts/server/routes/health.ts | 2 +- .../server/config.test.ts | 68 ++-- .../encrypted_saved_objects/server/config.ts | 24 +- .../encrypted_saved_objects_service.test.ts | 334 ++++++++++++++++++ .../crypto/encrypted_saved_objects_service.ts | 53 ++- .../encrypted_saved_objects/server/mocks.ts | 6 +- .../server/plugin.test.ts | 28 +- .../encrypted_saved_objects/server/plugin.ts | 37 +- .../server/routes/index.mock.ts | 4 +- x-pack/plugins/fleet/kibana.json | 5 +- x-pack/plugins/fleet/server/plugin.ts | 8 +- .../fleet/server/routes/setup/handlers.ts | 4 +- .../elasticsearch/verify_alerting_security.ts | 2 +- .../privileges/read_privileges_route.test.ts | 2 +- .../privileges/read_privileges_route.ts | 4 +- .../security_solution/server/plugin.ts | 2 +- .../security_solution/server/routes/index.ts | 4 +- 30 files changed, 543 insertions(+), 238 deletions(-) diff --git a/x-pack/plugins/actions/server/action_type_registry.test.ts b/x-pack/plugins/actions/server/action_type_registry.test.ts index 813e47c2e9957..c8972d8113f16 100644 --- a/x-pack/plugins/actions/server/action_type_registry.test.ts +++ b/x-pack/plugins/actions/server/action_type_registry.test.ts @@ -26,9 +26,7 @@ beforeEach(() => { actionTypeRegistryParams = { licensing: licensingMock.createSetup(), taskManager: mockTaskManager, - taskRunnerFactory: new TaskRunnerFactory( - new ActionExecutor({ isESOUsingEphemeralEncryptionKey: false }) - ), + taskRunnerFactory: new TaskRunnerFactory(new ActionExecutor({ isESOCanEncrypt: true })), actionsConfigUtils: mockedActionsConfig, licenseState: mockedLicenseState, preconfiguredActions: [ diff --git a/x-pack/plugins/actions/server/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client.test.ts index 1bea3e1fc356d..3bd8bb5f1ba52 100644 --- a/x-pack/plugins/actions/server/actions_client.test.ts +++ b/x-pack/plugins/actions/server/actions_client.test.ts @@ -59,9 +59,7 @@ beforeEach(() => { actionTypeRegistryParams = { licensing: licensingMock.createSetup(), taskManager: mockTaskManager, - taskRunnerFactory: new TaskRunnerFactory( - new ActionExecutor({ isESOUsingEphemeralEncryptionKey: false }) - ), + taskRunnerFactory: new TaskRunnerFactory(new ActionExecutor({ isESOCanEncrypt: true })), actionsConfigUtils: actionsConfigMock.create(), licenseState: mockedLicenseState, preconfiguredActions: [], @@ -411,9 +409,7 @@ describe('create()', () => { const localActionTypeRegistryParams = { licensing: licensingMock.createSetup(), taskManager: mockTaskManager, - taskRunnerFactory: new TaskRunnerFactory( - new ActionExecutor({ isESOUsingEphemeralEncryptionKey: false }) - ), + taskRunnerFactory: new TaskRunnerFactory(new ActionExecutor({ isESOCanEncrypt: true })), actionsConfigUtils: localConfigUtils, licenseState: licenseStateMock.create(), preconfiguredActions: [], diff --git a/x-pack/plugins/actions/server/builtin_action_types/index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/index.test.ts index bad709247d080..10955af2f3b13 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/index.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/index.test.ts @@ -33,9 +33,7 @@ export function createActionTypeRegistry(): { const actionTypeRegistry = new ActionTypeRegistry({ taskManager: taskManagerMock.createSetup(), licensing: licensingMock.createSetup(), - taskRunnerFactory: new TaskRunnerFactory( - new ActionExecutor({ isESOUsingEphemeralEncryptionKey: false }) - ), + taskRunnerFactory: new TaskRunnerFactory(new ActionExecutor({ isESOCanEncrypt: true })), actionsConfigUtils: actionsConfigMock.create(), licenseState: licenseStateMock.create(), preconfiguredActions: [], diff --git a/x-pack/plugins/actions/server/create_execute_function.test.ts b/x-pack/plugins/actions/server/create_execute_function.test.ts index aaf11669c1d03..d4100537fa6b8 100644 --- a/x-pack/plugins/actions/server/create_execute_function.test.ts +++ b/x-pack/plugins/actions/server/create_execute_function.test.ts @@ -28,7 +28,7 @@ describe('execute()', () => { const executeFn = createExecutionEnqueuerFunction({ taskManager: mockTaskManager, actionTypeRegistry, - isESOUsingEphemeralEncryptionKey: false, + isESOCanEncrypt: true, preconfiguredActions: [], }); savedObjectsClient.get.mockResolvedValueOnce({ @@ -87,7 +87,7 @@ describe('execute()', () => { const executeFn = createExecutionEnqueuerFunction({ taskManager: mockTaskManager, actionTypeRegistry: actionTypeRegistryMock.create(), - isESOUsingEphemeralEncryptionKey: false, + isESOCanEncrypt: true, preconfiguredActions: [ { id: '123', @@ -158,10 +158,10 @@ describe('execute()', () => { ); }); - test('throws when passing isESOUsingEphemeralEncryptionKey with true as a value', async () => { + test('throws when passing isESOCanEncrypt with false as a value', async () => { const executeFn = createExecutionEnqueuerFunction({ taskManager: mockTaskManager, - isESOUsingEphemeralEncryptionKey: true, + isESOCanEncrypt: false, actionTypeRegistry: actionTypeRegistryMock.create(), preconfiguredActions: [], }); @@ -173,7 +173,7 @@ describe('execute()', () => { apiKey: null, }) ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Unable to execute action because the Encrypted Saved Objects plugin uses an ephemeral encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in the kibana.yml or use the bin/kibana-encryption-keys command."` + `"Unable to execute action because the Encrypted Saved Objects plugin is missing encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in the kibana.yml or use the bin/kibana-encryption-keys command."` ); }); @@ -181,7 +181,7 @@ describe('execute()', () => { const mockedActionTypeRegistry = actionTypeRegistryMock.create(); const executeFn = createExecutionEnqueuerFunction({ taskManager: mockTaskManager, - isESOUsingEphemeralEncryptionKey: false, + isESOCanEncrypt: true, actionTypeRegistry: mockedActionTypeRegistry, preconfiguredActions: [], }); @@ -211,7 +211,7 @@ describe('execute()', () => { const mockedActionTypeRegistry = actionTypeRegistryMock.create(); const executeFn = createExecutionEnqueuerFunction({ taskManager: mockTaskManager, - isESOUsingEphemeralEncryptionKey: false, + isESOCanEncrypt: true, actionTypeRegistry: mockedActionTypeRegistry, preconfiguredActions: [ { diff --git a/x-pack/plugins/actions/server/create_execute_function.ts b/x-pack/plugins/actions/server/create_execute_function.ts index 0d75c0b410e44..025b4d3107798 100644 --- a/x-pack/plugins/actions/server/create_execute_function.ts +++ b/x-pack/plugins/actions/server/create_execute_function.ts @@ -14,7 +14,7 @@ import { isSavedObjectExecutionSource } from './lib'; interface CreateExecuteFunctionOptions { taskManager: TaskManagerStartContract; - isESOUsingEphemeralEncryptionKey: boolean; + isESOCanEncrypt: boolean; actionTypeRegistry: ActionTypeRegistryContract; preconfiguredActions: PreConfiguredAction[]; } @@ -33,16 +33,16 @@ export type ExecutionEnqueuer = ( export function createExecutionEnqueuerFunction({ taskManager, actionTypeRegistry, - isESOUsingEphemeralEncryptionKey, + isESOCanEncrypt, preconfiguredActions, }: CreateExecuteFunctionOptions) { return async function execute( unsecuredSavedObjectsClient: SavedObjectsClientContract, { id, params, spaceId, source, apiKey }: ExecuteOptions ) { - if (isESOUsingEphemeralEncryptionKey === true) { + if (!isESOCanEncrypt) { throw new Error( - `Unable to execute action because the Encrypted Saved Objects plugin uses an ephemeral encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in the kibana.yml or use the bin/kibana-encryption-keys command.` + `Unable to execute action because the Encrypted Saved Objects plugin is missing encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in the kibana.yml or use the bin/kibana-encryption-keys command.` ); } diff --git a/x-pack/plugins/actions/server/lib/action_executor.test.ts b/x-pack/plugins/actions/server/lib/action_executor.test.ts index e9b72f9bf0e4e..8ec94c4d4a552 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.test.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.test.ts @@ -17,7 +17,7 @@ import { ActionType } from '../types'; import { actionsMock, actionsClientMock } from '../mocks'; import { pick } from 'lodash'; -const actionExecutor = new ActionExecutor({ isESOUsingEphemeralEncryptionKey: false }); +const actionExecutor = new ActionExecutor({ isESOCanEncrypt: true }); const services = actionsMock.createServices(); const actionsClient = actionsClientMock.create(); @@ -310,8 +310,8 @@ test('should not throws an error if actionType is preconfigured', async () => { }); }); -test('throws an error when passing isESOUsingEphemeralEncryptionKey with value of true', async () => { - const customActionExecutor = new ActionExecutor({ isESOUsingEphemeralEncryptionKey: true }); +test('throws an error when passing isESOCanEncrypt with value of false', async () => { + const customActionExecutor = new ActionExecutor({ isESOCanEncrypt: false }); customActionExecutor.initialize({ logger: loggingSystemMock.create().get(), spaces: spacesMock, @@ -325,7 +325,7 @@ test('throws an error when passing isESOUsingEphemeralEncryptionKey with value o await expect( customActionExecutor.execute(executeParams) ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Unable to execute action because the Encrypted Saved Objects plugin uses an ephemeral encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in the kibana.yml or use the bin/kibana-encryption-keys command."` + `"Unable to execute action because the Encrypted Saved Objects plugin is missing encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in the kibana.yml or use the bin/kibana-encryption-keys command."` ); }); diff --git a/x-pack/plugins/actions/server/lib/action_executor.ts b/x-pack/plugins/actions/server/lib/action_executor.ts index 7a54f88e2f27c..6deaa4d587904 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.ts @@ -48,10 +48,10 @@ export type ActionExecutorContract = PublicMethodsOf; export class ActionExecutor { private isInitialized = false; private actionExecutorContext?: ActionExecutorContext; - private readonly isESOUsingEphemeralEncryptionKey: boolean; + private readonly isESOCanEncrypt: boolean; - constructor({ isESOUsingEphemeralEncryptionKey }: { isESOUsingEphemeralEncryptionKey: boolean }) { - this.isESOUsingEphemeralEncryptionKey = isESOUsingEphemeralEncryptionKey; + constructor({ isESOCanEncrypt }: { isESOCanEncrypt: boolean }) { + this.isESOCanEncrypt = isESOCanEncrypt; } public initialize(actionExecutorContext: ActionExecutorContext) { @@ -72,9 +72,9 @@ export class ActionExecutor { throw new Error('ActionExecutor not initialized'); } - if (this.isESOUsingEphemeralEncryptionKey === true) { + if (!this.isESOCanEncrypt) { throw new Error( - `Unable to execute action because the Encrypted Saved Objects plugin uses an ephemeral encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in the kibana.yml or use the bin/kibana-encryption-keys command.` + `Unable to execute action because the Encrypted Saved Objects plugin is missing encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in the kibana.yml or use the bin/kibana-encryption-keys command.` ); } diff --git a/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts b/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts index e42fc363f328b..9e101f2ee76b0 100644 --- a/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts +++ b/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts @@ -84,18 +84,14 @@ beforeEach(() => { }); test(`throws an error if factory isn't initialized`, () => { - const factory = new TaskRunnerFactory( - new ActionExecutor({ isESOUsingEphemeralEncryptionKey: false }) - ); + const factory = new TaskRunnerFactory(new ActionExecutor({ isESOCanEncrypt: true })); expect(() => factory.create({ taskInstance: mockedTaskInstance }) ).toThrowErrorMatchingInlineSnapshot(`"TaskRunnerFactory not initialized"`); }); test(`throws an error if factory is already initialized`, () => { - const factory = new TaskRunnerFactory( - new ActionExecutor({ isESOUsingEphemeralEncryptionKey: false }) - ); + const factory = new TaskRunnerFactory(new ActionExecutor({ isESOCanEncrypt: true })); factory.initialize(taskRunnerFactoryInitializerParams); expect(() => factory.initialize(taskRunnerFactoryInitializerParams) diff --git a/x-pack/plugins/actions/server/plugin.test.ts b/x-pack/plugins/actions/server/plugin.test.ts index 187cba9d3240c..0e916220ca946 100644 --- a/x-pack/plugins/actions/server/plugin.test.ts +++ b/x-pack/plugins/actions/server/plugin.test.ts @@ -51,25 +51,21 @@ describe('Actions Plugin', () => { }; }); - it('should log warning when Encrypted Saved Objects plugin is using an ephemeral encryption key', async () => { - // coreMock.createSetup doesn't support Plugin generics - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await plugin.setup(coreSetup as any, pluginsSetup); - expect(pluginsSetup.encryptedSavedObjects.usingEphemeralEncryptionKey).toEqual(true); + it('should log warning when Encrypted Saved Objects plugin is missing encryption key', async () => { + await plugin.setup(coreSetup, pluginsSetup); + expect(pluginsSetup.encryptedSavedObjects.canEncrypt).toEqual(false); expect(context.logger.get().warn).toHaveBeenCalledWith( - 'APIs are disabled because the Encrypted Saved Objects plugin uses an ephemeral encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in the kibana.yml or use the bin/kibana-encryption-keys command.' + 'APIs are disabled because the Encrypted Saved Objects plugin is missing encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in the kibana.yml or use the bin/kibana-encryption-keys command.' ); }); describe('routeHandlerContext.getActionsClient()', () => { - it('should not throw error when ESO plugin not using a generated key', async () => { - // coreMock.createSetup doesn't support Plugin generics - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await plugin.setup(coreSetup as any, { + it('should not throw error when ESO plugin has encryption key', async () => { + await plugin.setup(coreSetup, { ...pluginsSetup, encryptedSavedObjects: { ...pluginsSetup.encryptedSavedObjects, - usingEphemeralEncryptionKey: false, + canEncrypt: true, }, }); @@ -99,10 +95,8 @@ describe('Actions Plugin', () => { actionsContextHandler!.getActionsClient(); }); - it('should throw error when ESO plugin using a generated key', async () => { - // coreMock.createSetup doesn't support Plugin generics - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await plugin.setup(coreSetup as any, pluginsSetup); + it('should throw error when ESO plugin is missing encryption key', async () => { + await plugin.setup(coreSetup, pluginsSetup); expect(coreSetup.http.registerRouteHandlerContext).toHaveBeenCalledTimes(1); const handler = coreSetup.http.registerRouteHandlerContext.mock.calls[0] as [ @@ -123,7 +117,7 @@ describe('Actions Plugin', () => { httpServerMock.createResponseFactory() )) as unknown) as ActionsApiRequestHandlerContext; expect(() => actionsContextHandler!.getActionsClient()).toThrowErrorMatchingInlineSnapshot( - `"Unable to create actions client because the Encrypted Saved Objects plugin uses an ephemeral encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in the kibana.yml or use the bin/kibana-encryption-keys command."` + `"Unable to create actions client because the Encrypted Saved Objects plugin is missing encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in the kibana.yml or use the bin/kibana-encryption-keys command."` ); }); }); @@ -234,14 +228,12 @@ describe('Actions Plugin', () => { expect(pluginStart.isActionExecutable('preconfiguredServerLog', '.server-log')).toBe(true); }); - it('should not throw error when ESO plugin not using a generated key', async () => { - // coreMock.createSetup doesn't support Plugin generics - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await plugin.setup(coreSetup as any, { + it('should not throw error when ESO plugin has encryption key', async () => { + await plugin.setup(coreSetup, { ...pluginsSetup, encryptedSavedObjects: { ...pluginsSetup.encryptedSavedObjects, - usingEphemeralEncryptionKey: false, + canEncrypt: true, }, }); const pluginStart = await plugin.start(coreStart, pluginsStart); @@ -249,17 +241,15 @@ describe('Actions Plugin', () => { await pluginStart.getActionsClientWithRequest(httpServerMock.createKibanaRequest()); }); - it('should throw error when ESO plugin using generated key', async () => { - // coreMock.createSetup doesn't support Plugin generics - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await plugin.setup(coreSetup as any, pluginsSetup); + it('should throw error when ESO plugin is missing encryption key', async () => { + await plugin.setup(coreSetup, pluginsSetup); const pluginStart = await plugin.start(coreStart, pluginsStart); - expect(pluginsSetup.encryptedSavedObjects.usingEphemeralEncryptionKey).toEqual(true); + expect(pluginsSetup.encryptedSavedObjects.canEncrypt).toEqual(false); await expect( pluginStart.getActionsClientWithRequest(httpServerMock.createKibanaRequest()) ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Unable to create actions client because the Encrypted Saved Objects plugin uses an ephemeral encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in the kibana.yml or use the bin/kibana-encryption-keys command."` + `"Unable to create actions client because the Encrypted Saved Objects plugin is missing encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in the kibana.yml or use the bin/kibana-encryption-keys command."` ); }); }); diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 8fbacc71d30cb..c4159c80e806f 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -144,7 +144,7 @@ export class ActionsPlugin implements Plugin ) => { - if (isESOUsingEphemeralEncryptionKey === true) { + if (isESOCanEncrypt !== true) { throw new Error( - `Unable to create actions client because the Encrypted Saved Objects plugin uses an ephemeral encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in the kibana.yml or use the bin/kibana-encryption-keys command.` + `Unable to create actions client because the Encrypted Saved Objects plugin is missing encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in the kibana.yml or use the bin/kibana-encryption-keys command.` ); } @@ -314,7 +313,7 @@ export class ActionsPlugin implements Plugin => { const { actionTypeRegistry, - isESOUsingEphemeralEncryptionKey, + isESOCanEncrypt, preconfiguredActions, actionExecutor, instantiateAuthorization, @@ -448,9 +447,9 @@ export class ActionsPlugin implements Plugin { - if (isESOUsingEphemeralEncryptionKey === true) { + if (isESOCanEncrypt !== true) { throw new Error( - `Unable to create actions client because the Encrypted Saved Objects plugin uses an ephemeral encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in the kibana.yml or use the bin/kibana-encryption-keys command.` + `Unable to create actions client because the Encrypted Saved Objects plugin is missing encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in the kibana.yml or use the bin/kibana-encryption-keys command.` ); } return new ActionsClient({ @@ -468,7 +467,7 @@ export class ActionsPlugin implements Plugin { let coreSetup: ReturnType; let pluginsSetup: jest.Mocked; - it('should log warning when Encrypted Saved Objects plugin is using an ephemeral encryption key', async () => { + it('should log warning when Encrypted Saved Objects plugin is missing encryption key', async () => { const context = coreMock.createPluginInitializerContext({ healthCheck: { interval: '5m', @@ -40,7 +40,7 @@ describe('Alerting Plugin', () => { const encryptedSavedObjectsSetup = encryptedSavedObjectsMock.createSetup(); const setupMocks = coreMock.createSetup(); - // need await to test number of calls of setupMocks.status.set, becuase it is under async function which awaiting core.getStartServices() + // need await to test number of calls of setupMocks.status.set, because it is under async function which awaiting core.getStartServices() await plugin.setup(setupMocks, { licensing: licensingMock.createSetup(), encryptedSavedObjects: encryptedSavedObjectsSetup, @@ -51,9 +51,9 @@ describe('Alerting Plugin', () => { }); expect(setupMocks.status.set).toHaveBeenCalledTimes(1); - expect(encryptedSavedObjectsSetup.usingEphemeralEncryptionKey).toEqual(true); + expect(encryptedSavedObjectsSetup.canEncrypt).toEqual(false); expect(context.logger.get().warn).toHaveBeenCalledWith( - 'APIs are disabled because the Encrypted Saved Objects plugin uses an ephemeral encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in the kibana.yml or use the bin/kibana-encryption-keys command.' + 'APIs are disabled because the Encrypted Saved Objects plugin is missing encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in the kibana.yml or use the bin/kibana-encryption-keys command.' ); }); @@ -110,7 +110,7 @@ describe('Alerting Plugin', () => { describe('start()', () => { describe('getAlertsClientWithRequest()', () => { - it('throws error when encryptedSavedObjects plugin has usingEphemeralEncryptionKey set to true', async () => { + it('throws error when encryptedSavedObjects plugin is missing encryption key', async () => { const context = coreMock.createPluginInitializerContext({ healthCheck: { interval: '5m', @@ -141,15 +141,15 @@ describe('Alerting Plugin', () => { taskManager: taskManagerMock.createStart(), }); - expect(encryptedSavedObjectsSetup.usingEphemeralEncryptionKey).toEqual(true); + expect(encryptedSavedObjectsSetup.canEncrypt).toEqual(false); expect(() => startContract.getAlertsClientWithRequest({} as KibanaRequest) ).toThrowErrorMatchingInlineSnapshot( - `"Unable to create alerts client because the Encrypted Saved Objects plugin uses an ephemeral encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in the kibana.yml or use the bin/kibana-encryption-keys command."` + `"Unable to create alerts client because the Encrypted Saved Objects plugin is missing encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in the kibana.yml or use the bin/kibana-encryption-keys command."` ); }); - it(`doesn't throw error when encryptedSavedObjects plugin has usingEphemeralEncryptionKey set to false`, async () => { + it(`doesn't throw error when encryptedSavedObjects plugin has encryption key`, async () => { const context = coreMock.createPluginInitializerContext({ healthCheck: { interval: '5m', @@ -163,7 +163,7 @@ describe('Alerting Plugin', () => { const encryptedSavedObjectsSetup = { ...encryptedSavedObjectsMock.createSetup(), - usingEphemeralEncryptionKey: false, + canEncrypt: true, }; plugin.setup(coreMock.createSetup(), { licensing: licensingMock.createSetup(), diff --git a/x-pack/plugins/alerts/server/plugin.ts b/x-pack/plugins/alerts/server/plugin.ts index aaec0bb8a080d..8dba4453d5682 100644 --- a/x-pack/plugins/alerts/server/plugin.ts +++ b/x-pack/plugins/alerts/server/plugin.ts @@ -153,7 +153,7 @@ export class AlertingPlugin { private alertTypeRegistry?: AlertTypeRegistry; private readonly taskRunnerFactory: TaskRunnerFactory; private licenseState: ILicenseState | null = null; - private isESOUsingEphemeralEncryptionKey?: boolean; + private isESOCanEncrypt?: boolean; private security?: SecurityPluginSetup; private readonly alertsClientFactory: AlertsClientFactory; private readonly telemetryLogger: Logger; @@ -189,12 +189,11 @@ export class AlertingPlugin { }; }); - this.isESOUsingEphemeralEncryptionKey = - plugins.encryptedSavedObjects.usingEphemeralEncryptionKey; + this.isESOCanEncrypt = plugins.encryptedSavedObjects.canEncrypt; - if (this.isESOUsingEphemeralEncryptionKey) { + if (!this.isESOCanEncrypt) { this.logger.warn( - 'APIs are disabled because the Encrypted Saved Objects plugin uses an ephemeral encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in the kibana.yml or use the bin/kibana-encryption-keys command.' + 'APIs are disabled because the Encrypted Saved Objects plugin is missing encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in the kibana.yml or use the bin/kibana-encryption-keys command.' ); } @@ -311,7 +310,7 @@ export class AlertingPlugin { public start(core: CoreStart, plugins: AlertingPluginsStart): PluginStartContract { const { - isESOUsingEphemeralEncryptionKey, + isESOCanEncrypt, logger, taskRunnerFactory, alertTypeRegistry, @@ -353,9 +352,9 @@ export class AlertingPlugin { }); const getAlertsClientWithRequest = (request: KibanaRequest) => { - if (isESOUsingEphemeralEncryptionKey === true) { + if (isESOCanEncrypt !== true) { throw new Error( - `Unable to create alerts client because the Encrypted Saved Objects plugin uses an ephemeral encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in the kibana.yml or use the bin/kibana-encryption-keys command.` + `Unable to create alerts client because the Encrypted Saved Objects plugin is missing encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in the kibana.yml or use the bin/kibana-encryption-keys command.` ); } return alertsClientFactory!.create(request, core.savedObjects); diff --git a/x-pack/plugins/alerts/server/routes/health.test.ts b/x-pack/plugins/alerts/server/routes/health.test.ts index 38bae896e40ba..22df0e6a00046 100644 --- a/x-pack/plugins/alerts/server/routes/health.test.ts +++ b/x-pack/plugins/alerts/server/routes/health.test.ts @@ -47,8 +47,7 @@ describe('healthRoute', () => { const router = httpServiceMock.createRouter(); const licenseState = licenseStateMock.create(); - const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup(); - encryptedSavedObjects.usingEphemeralEncryptionKey = false; + const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); healthRoute(router, licenseState, encryptedSavedObjects); const [config] = router.get.mock.calls[0]; @@ -60,8 +59,7 @@ describe('healthRoute', () => { const router = httpServiceMock.createRouter(); const licenseState = licenseStateMock.create(); - const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup(); - encryptedSavedObjects.usingEphemeralEncryptionKey = false; + const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); healthRoute(router, licenseState, encryptedSavedObjects); const [, handler] = router.get.mock.calls[0]; @@ -85,12 +83,11 @@ describe('healthRoute', () => { `); }); - it('evaluates whether Encrypted Saved Objects is using an ephemeral encryption key', async () => { + it('evaluates whether Encrypted Saved Objects is missing encryption key', async () => { const router = httpServiceMock.createRouter(); const licenseState = licenseStateMock.create(); - const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup(); - encryptedSavedObjects.usingEphemeralEncryptionKey = true; + const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: false }); healthRoute(router, licenseState, encryptedSavedObjects); const [, handler] = router.get.mock.calls[0]; @@ -129,8 +126,7 @@ describe('healthRoute', () => { const router = httpServiceMock.createRouter(); const licenseState = licenseStateMock.create(); - const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup(); - encryptedSavedObjects.usingEphemeralEncryptionKey = false; + const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); healthRoute(router, licenseState, encryptedSavedObjects); const [, handler] = router.get.mock.calls[0]; @@ -169,8 +165,7 @@ describe('healthRoute', () => { const router = httpServiceMock.createRouter(); const licenseState = licenseStateMock.create(); - const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup(); - encryptedSavedObjects.usingEphemeralEncryptionKey = false; + const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); healthRoute(router, licenseState, encryptedSavedObjects); const [, handler] = router.get.mock.calls[0]; @@ -209,8 +204,7 @@ describe('healthRoute', () => { const router = httpServiceMock.createRouter(); const licenseState = licenseStateMock.create(); - const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup(); - encryptedSavedObjects.usingEphemeralEncryptionKey = false; + const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); healthRoute(router, licenseState, encryptedSavedObjects); const [, handler] = router.get.mock.calls[0]; @@ -249,8 +243,7 @@ describe('healthRoute', () => { const router = httpServiceMock.createRouter(); const licenseState = licenseStateMock.create(); - const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup(); - encryptedSavedObjects.usingEphemeralEncryptionKey = false; + const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); healthRoute(router, licenseState, encryptedSavedObjects); const [, handler] = router.get.mock.calls[0]; @@ -291,8 +284,7 @@ describe('healthRoute', () => { const router = httpServiceMock.createRouter(); const licenseState = licenseStateMock.create(); - const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup(); - encryptedSavedObjects.usingEphemeralEncryptionKey = false; + const encryptedSavedObjects = encryptedSavedObjectsMock.createSetup({ canEncrypt: true }); healthRoute(router, licenseState, encryptedSavedObjects); const [, handler] = router.get.mock.calls[0]; diff --git a/x-pack/plugins/alerts/server/routes/health.ts b/x-pack/plugins/alerts/server/routes/health.ts index 24b3642ca2085..9e1f01041e091 100644 --- a/x-pack/plugins/alerts/server/routes/health.ts +++ b/x-pack/plugins/alerts/server/routes/health.ts @@ -55,7 +55,7 @@ export function healthRoute( const frameworkHealth: AlertingFrameworkHealth = { isSufficientlySecure: !isSecurityEnabled || (isSecurityEnabled && isTLSEnabled), - hasPermanentEncryptionKey: !encryptedSavedObjects.usingEphemeralEncryptionKey, + hasPermanentEncryptionKey: encryptedSavedObjects.canEncrypt, alertingFrameworkHeath, }; diff --git a/x-pack/plugins/encrypted_saved_objects/server/config.test.ts b/x-pack/plugins/encrypted_saved_objects/server/config.test.ts index 3633dae824a2b..1cc5f7974cb13 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/config.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/config.test.ts @@ -5,10 +5,7 @@ * 2.0. */ -jest.mock('crypto', () => ({ randomBytes: jest.fn() })); - -import { loggingSystemMock } from 'src/core/server/mocks'; -import { createConfig, ConfigSchema } from './config'; +import { ConfigSchema } from './config'; describe('config schema', () => { it('generates proper defaults', () => { @@ -32,6 +29,17 @@ describe('config schema', () => { } `); + expect(ConfigSchema.validate({ encryptionKey: 'z'.repeat(32) }, { dist: true })) + .toMatchInlineSnapshot(` + Object { + "enabled": true, + "encryptionKey": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + "keyRotation": Object { + "decryptionOnlyKeys": Array [], + }, + } + `); + expect(ConfigSchema.validate({}, { dist: true })).toMatchInlineSnapshot(` Object { "enabled": true, @@ -79,6 +87,18 @@ describe('config schema', () => { ); }); + it('should not allow `null` value for the encryption key', () => { + expect(() => ConfigSchema.validate({ encryptionKey: null })).toThrowErrorMatchingInlineSnapshot( + `"[encryptionKey]: expected value of type [string] but got [null]"` + ); + + expect(() => + ConfigSchema.validate({ encryptionKey: null }, { dist: true }) + ).toThrowErrorMatchingInlineSnapshot( + `"[encryptionKey]: expected value of type [string] but got [null]"` + ); + }); + it('should throw error if any of the xpack.encryptedSavedObjects.keyRotation.decryptionOnlyKeys is less than 32 characters', () => { expect(() => ConfigSchema.validate({ @@ -121,43 +141,3 @@ describe('config schema', () => { ); }); }); - -describe('createConfig()', () => { - it('should log a warning, set xpack.encryptedSavedObjects.encryptionKey and usingEphemeralEncryptionKey=true when encryptionKey is not set', () => { - const mockRandomBytes = jest.requireMock('crypto').randomBytes; - mockRandomBytes.mockReturnValue('ab'.repeat(16)); - - const logger = loggingSystemMock.create().get(); - const config = createConfig(ConfigSchema.validate({}, { dist: true }), logger); - expect(config).toEqual({ - enabled: true, - encryptionKey: 'ab'.repeat(16), - keyRotation: { decryptionOnlyKeys: [] }, - usingEphemeralEncryptionKey: true, - }); - - expect(loggingSystemMock.collect(logger).warn).toMatchInlineSnapshot(` - Array [ - Array [ - "Generating a random key for xpack.encryptedSavedObjects.encryptionKey. To decrypt encrypted saved objects attributes after restart, please set xpack.encryptedSavedObjects.encryptionKey in the kibana.yml or use the bin/kibana-encryption-keys command.", - ], - ] - `); - }); - - it('should not log a warning and set usingEphemeralEncryptionKey=false when encryptionKey is set', async () => { - const logger = loggingSystemMock.create().get(); - const config = createConfig( - ConfigSchema.validate({ encryptionKey: 'supersecret'.repeat(3) }, { dist: true }), - logger - ); - expect(config).toEqual({ - enabled: true, - encryptionKey: 'supersecret'.repeat(3), - keyRotation: { decryptionOnlyKeys: [] }, - usingEphemeralEncryptionKey: false, - }); - - expect(loggingSystemMock.collect(logger).warn).toEqual([]); - }); -}); diff --git a/x-pack/plugins/encrypted_saved_objects/server/config.ts b/x-pack/plugins/encrypted_saved_objects/server/config.ts index 40db0187162d0..2bcf0e9b69511 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/config.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/config.ts @@ -5,11 +5,9 @@ * 2.0. */ -import crypto from 'crypto'; import { schema, TypeOf } from '@kbn/config-schema'; -import { Logger } from 'src/core/server'; -export type ConfigType = ReturnType; +export type ConfigType = TypeOf; export const ConfigSchema = schema.object( { @@ -33,23 +31,3 @@ export const ConfigSchema = schema.object( }, } ); - -export function createConfig(config: TypeOf, logger: Logger) { - let encryptionKey = config.encryptionKey; - const usingEphemeralEncryptionKey = encryptionKey === undefined; - if (encryptionKey === undefined) { - logger.warn( - 'Generating a random key for xpack.encryptedSavedObjects.encryptionKey. ' + - 'To decrypt encrypted saved objects attributes after restart, ' + - 'please set xpack.encryptedSavedObjects.encryptionKey in the kibana.yml or use the bin/kibana-encryption-keys command.' - ); - - encryptionKey = crypto.randomBytes(16).toString('hex'); - } - - return { - ...config, - encryptionKey, - usingEphemeralEncryptionKey, - }; -} diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts index 1760a85806786..f70810943d179 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts @@ -226,6 +226,72 @@ describe('#stripOrDecryptAttributes', () => { ); }); }); + + describe('without encryption key', () => { + beforeEach(() => { + service = new EncryptedSavedObjectsService({ + logger: loggingSystemMock.create().get(), + audit: mockAuditLogger, + }); + }); + + it('does not fail if none of attributes are supposed to be encrypted', async () => { + const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; + + service.registerType({ type: 'known-type-1', attributesToEncrypt: new Set(['attrFour']) }); + + await expect( + service.stripOrDecryptAttributes({ id: 'known-id', type: 'known-type-1' }, attributes) + ).resolves.toEqual({ attributes: { attrOne: 'one', attrTwo: 'two', attrThree: 'three' } }); + }); + + it('does not fail if there are attributes are supposed to be encrypted, but should be stripped', async () => { + const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; + + service.registerType({ + type: 'known-type-1', + attributesToEncrypt: new Set(['attrOne', 'attrThree']), + }); + + await expect( + service.stripOrDecryptAttributes({ id: 'known-id', type: 'known-type-1' }, attributes) + ).resolves.toEqual({ attributes: { attrTwo: 'two' } }); + }); + + it('fails if needs to decrypt any attribute', async () => { + service.registerType({ + type: 'known-type-1', + attributesToEncrypt: new Set([ + 'attrOne', + { key: 'attrThree', dangerouslyExposeValue: true }, + ]), + }); + + const mockUser = mockAuthenticatedUser(); + const { attributes, error } = await service.stripOrDecryptAttributes( + { type: 'known-type-1', id: 'object-id' }, + { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }, + undefined, + { user: mockUser } + ); + + expect(attributes).toEqual({ attrTwo: 'two' }); + + const encryptionError = error as EncryptionError; + expect(encryptionError.attributeName).toBe('attrThree'); + expect(encryptionError.message).toBe('Unable to decrypt attribute "attrThree"'); + expect(encryptionError.cause).toEqual( + new Error('Decryption is disabled because of missing decryption keys.') + ); + + expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith( + 'attrThree', + { type: 'known-type-1', id: 'object-id' }, + mockUser + ); + }); + }); }); describe('#encryptAttributes', () => { @@ -465,6 +531,58 @@ describe('#encryptAttributes', () => { mockUser ); }); + + describe('without encryption key', () => { + beforeEach(() => { + service = new EncryptedSavedObjectsService({ + logger: loggingSystemMock.create().get(), + audit: mockAuditLogger, + }); + }); + + it('does not fail if none of attributes are supposed to be encrypted', async () => { + const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; + + service.registerType({ type: 'known-type-1', attributesToEncrypt: new Set(['attrFour']) }); + + await expect( + service.encryptAttributes({ type: 'known-type-1', id: 'object-id' }, attributes) + ).resolves.toEqual({ + attrOne: 'one', + attrTwo: 'two', + attrThree: 'three', + }); + expect(mockAuditLogger.encryptAttributesSuccess).not.toHaveBeenCalled(); + }); + + it('fails if needs to encrypt any attribute', async () => { + const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; + service.registerType({ + type: 'known-type-1', + attributesToEncrypt: new Set(['attrOne', 'attrThree']), + }); + + const mockUser = mockAuthenticatedUser(); + await expect( + service.encryptAttributes({ type: 'known-type-1', id: 'object-id' }, attributes, { + user: mockUser, + }) + ).rejects.toThrowError(EncryptionError); + + expect(attributes).toEqual({ + attrOne: 'one', + attrTwo: 'two', + attrThree: 'three', + }); + expect(mockAuditLogger.encryptAttributesSuccess).not.toHaveBeenCalled(); + expect(mockAuditLogger.encryptAttributeFailure).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.encryptAttributeFailure).toHaveBeenCalledWith( + 'attrOne', + { type: 'known-type-1', id: 'object-id' }, + mockUser + ); + }); + }); }); describe('#decryptAttributes', () => { @@ -1099,6 +1217,88 @@ describe('#decryptAttributes', () => { expect(decryptionOnlyCryptoTwo.decrypt).not.toHaveBeenCalled(); }); }); + + describe('without encryption key', () => { + beforeEach(() => { + service = new EncryptedSavedObjectsService({ + logger: loggingSystemMock.create().get(), + audit: mockAuditLogger, + }); + }); + + it('does not fail if none of attributes are supposed to be decrypted', async () => { + const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; + + service = new EncryptedSavedObjectsService({ + decryptionOnlyCryptos: [], + logger: loggingSystemMock.create().get(), + audit: mockAuditLogger, + }); + service.registerType({ type: 'known-type-1', attributesToEncrypt: new Set(['attrFour']) }); + + await expect( + service.decryptAttributes({ type: 'known-type-1', id: 'object-id' }, attributes) + ).resolves.toEqual({ + attrOne: 'one', + attrTwo: 'two', + attrThree: 'three', + }); + expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled(); + }); + + it('does not fail if can decrypt attributes with decryption only keys', async () => { + const decryptionOnlyCryptoOne = createNodeCryptMock('old-key-one'); + decryptionOnlyCryptoOne.decrypt.mockImplementation( + async (encryptedOutput: string | Buffer, aad?: string) => `${encryptedOutput}||${aad}` + ); + + service = new EncryptedSavedObjectsService({ + decryptionOnlyCryptos: [decryptionOnlyCryptoOne], + logger: loggingSystemMock.create().get(), + audit: mockAuditLogger, + }); + service.registerType({ + type: 'known-type-1', + attributesToEncrypt: new Set(['attrOne', 'attrThree', 'attrFour']), + }); + + const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three', attrFour: null }; + await expect( + service.decryptAttributes({ type: 'known-type-1', id: 'object-id' }, attributes) + ).resolves.toEqual({ + attrOne: 'one||["known-type-1","object-id",{"attrTwo":"two"}]', + attrTwo: 'two', + attrThree: 'three||["known-type-1","object-id",{"attrTwo":"two"}]', + attrFour: null, + }); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith( + ['attrOne', 'attrThree'], + { type: 'known-type-1', id: 'object-id' }, + undefined + ); + }); + + it('fails if needs to decrypt any attribute', async () => { + const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; + + service.registerType({ type: 'known-type-1', attributesToEncrypt: new Set(['attrOne']) }); + + const mockUser = mockAuthenticatedUser(); + await expect( + service.decryptAttributes({ type: 'known-type-1', id: 'object-id' }, attributes, { + user: mockUser, + }) + ).rejects.toThrowError(EncryptionError); + + expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled(); + expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith( + 'attrOne', + { type: 'known-type-1', id: 'object-id' }, + mockUser + ); + }); + }); }); describe('#encryptAttributesSync', () => { @@ -1283,6 +1483,58 @@ describe('#encryptAttributesSync', () => { attrThree: 'three', }); }); + + describe('without encryption key', () => { + beforeEach(() => { + service = new EncryptedSavedObjectsService({ + logger: loggingSystemMock.create().get(), + audit: mockAuditLogger, + }); + }); + + it('does not fail if none of attributes are supposed to be encrypted', () => { + const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; + + service.registerType({ type: 'known-type-1', attributesToEncrypt: new Set(['attrFour']) }); + + expect( + service.encryptAttributesSync({ type: 'known-type-1', id: 'object-id' }, attributes) + ).toEqual({ + attrOne: 'one', + attrTwo: 'two', + attrThree: 'three', + }); + expect(mockAuditLogger.encryptAttributesSuccess).not.toHaveBeenCalled(); + }); + + it('fails if needs to encrypt any attribute', () => { + const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; + service.registerType({ + type: 'known-type-1', + attributesToEncrypt: new Set(['attrOne', 'attrThree']), + }); + + const mockUser = mockAuthenticatedUser(); + expect(() => + service.encryptAttributesSync({ type: 'known-type-1', id: 'object-id' }, attributes, { + user: mockUser, + }) + ).toThrowError(EncryptionError); + + expect(attributes).toEqual({ + attrOne: 'one', + attrTwo: 'two', + attrThree: 'three', + }); + expect(mockAuditLogger.encryptAttributesSuccess).not.toHaveBeenCalled(); + expect(mockAuditLogger.encryptAttributeFailure).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.encryptAttributeFailure).toHaveBeenCalledWith( + 'attrOne', + { type: 'known-type-1', id: 'object-id' }, + mockUser + ); + }); + }); }); describe('#decryptAttributesSync', () => { @@ -1784,4 +2036,86 @@ describe('#decryptAttributesSync', () => { expect(decryptionOnlyCryptoTwo.decryptSync).not.toHaveBeenCalled(); }); }); + + describe('without encryption key', () => { + beforeEach(() => { + service = new EncryptedSavedObjectsService({ + logger: loggingSystemMock.create().get(), + audit: mockAuditLogger, + }); + }); + + it('does not fail if none of attributes are supposed to be decrypted', () => { + const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; + + service = new EncryptedSavedObjectsService({ + decryptionOnlyCryptos: [], + logger: loggingSystemMock.create().get(), + audit: mockAuditLogger, + }); + service.registerType({ type: 'known-type-1', attributesToEncrypt: new Set(['attrFour']) }); + + expect( + service.decryptAttributesSync({ type: 'known-type-1', id: 'object-id' }, attributes) + ).toEqual({ + attrOne: 'one', + attrTwo: 'two', + attrThree: 'three', + }); + expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled(); + }); + + it('does not fail if can decrypt attributes with decryption only keys', () => { + const decryptionOnlyCryptoOne = createNodeCryptMock('old-key-one'); + decryptionOnlyCryptoOne.decryptSync.mockImplementation( + (encryptedOutput: string | Buffer, aad?: string) => `${encryptedOutput}||${aad}` + ); + + service = new EncryptedSavedObjectsService({ + decryptionOnlyCryptos: [decryptionOnlyCryptoOne], + logger: loggingSystemMock.create().get(), + audit: mockAuditLogger, + }); + service.registerType({ + type: 'known-type-1', + attributesToEncrypt: new Set(['attrOne', 'attrThree', 'attrFour']), + }); + + const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three', attrFour: null }; + expect( + service.decryptAttributesSync({ type: 'known-type-1', id: 'object-id' }, attributes) + ).toEqual({ + attrOne: 'one||["known-type-1","object-id",{"attrTwo":"two"}]', + attrTwo: 'two', + attrThree: 'three||["known-type-1","object-id",{"attrTwo":"two"}]', + attrFour: null, + }); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith( + ['attrOne', 'attrThree'], + { type: 'known-type-1', id: 'object-id' }, + undefined + ); + }); + + it('fails if needs to decrypt any attribute', () => { + const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; + + service.registerType({ type: 'known-type-1', attributesToEncrypt: new Set(['attrOne']) }); + + const mockUser = mockAuthenticatedUser(); + expect(() => + service.decryptAttributesSync({ type: 'known-type-1', id: 'object-id' }, attributes, { + user: mockUser, + }) + ).toThrowError(EncryptionError); + + expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled(); + expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith( + 'attrOne', + { type: 'known-type-1', id: 'object-id' }, + mockUser + ); + }); + }); }); diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts index 91a3cfc921624..23aef07ff8781 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts @@ -77,7 +77,7 @@ interface EncryptedSavedObjectsServiceOptions { /** * NodeCrypto instance used for both encryption and decryption. */ - primaryCrypto: Crypto; + primaryCrypto?: Crypto; /** * NodeCrypto instances used ONLY for decryption (i.e. rotated encryption keys). @@ -293,12 +293,17 @@ export class EncryptedSavedObjectsService { let iteratorResult = iterator.next(); while (!iteratorResult.done) { const [attributeValue, encryptionAAD] = iteratorResult.value; - try { - iteratorResult = iterator.next( - await this.options.primaryCrypto.encrypt(attributeValue, encryptionAAD) - ); - } catch (err) { - iterator.throw!(err); + // We check this inside of the iterator to throw only if we do need to encrypt anything. + if (this.options.primaryCrypto) { + try { + iteratorResult = iterator.next( + await this.options.primaryCrypto.encrypt(attributeValue, encryptionAAD) + ); + } catch (err) { + iterator.throw!(err); + } + } else { + iterator.throw!(new Error('Encryption is disabled because of missing encryption key.')); } } @@ -324,12 +329,17 @@ export class EncryptedSavedObjectsService { let iteratorResult = iterator.next(); while (!iteratorResult.done) { const [attributeValue, encryptionAAD] = iteratorResult.value; - try { - iteratorResult = iterator.next( - this.options.primaryCrypto.encryptSync(attributeValue, encryptionAAD) - ); - } catch (err) { - iterator.throw!(err); + // We check this inside of the iterator to throw only if we do need to encrypt anything. + if (this.options.primaryCrypto) { + try { + iteratorResult = iterator.next( + this.options.primaryCrypto.encryptSync(attributeValue, encryptionAAD) + ); + } catch (err) { + iterator.throw!(err); + } + } else { + iterator.throw!(new Error('Encryption is disabled because of missing encryption key.')); } } @@ -358,7 +368,11 @@ export class EncryptedSavedObjectsService { while (!iteratorResult.done) { const [attributeValue, encryptionAAD] = iteratorResult.value; - let decryptionError; + // We check this inside of the iterator to throw only if we do need to decrypt anything. + let decryptionError = + decrypters.length === 0 + ? new Error('Decryption is disabled because of missing decryption keys.') + : undefined; for (const decrypter of decrypters) { try { iteratorResult = iterator.next(await decrypter.decrypt(attributeValue, encryptionAAD)); @@ -402,7 +416,11 @@ export class EncryptedSavedObjectsService { while (!iteratorResult.done) { const [attributeValue, encryptionAAD] = iteratorResult.value; - let decryptionError; + // We check this inside of the iterator to throw only if we do need to decrypt anything. + let decryptionError = + decrypters.length === 0 + ? new Error('Decryption is disabled because of missing decryption keys.') + : undefined; for (const decrypter of decrypters) { try { iteratorResult = iterator.next(decrypter.decryptSync(attributeValue, encryptionAAD)); @@ -541,6 +559,9 @@ export class EncryptedSavedObjectsService { return this.options.decryptionOnlyCryptos; } - return [this.options.primaryCrypto, ...(this.options.decryptionOnlyCryptos ?? [])]; + return [ + ...(this.options.primaryCrypto ? [this.options.primaryCrypto] : []), + ...(this.options.decryptionOnlyCryptos ?? []), + ]; } } diff --git a/x-pack/plugins/encrypted_saved_objects/server/mocks.ts b/x-pack/plugins/encrypted_saved_objects/server/mocks.ts index 6c8196b2ae03c..edb55513aabf5 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/mocks.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/mocks.ts @@ -8,11 +8,13 @@ import { EncryptedSavedObjectsPluginSetup, EncryptedSavedObjectsPluginStart } from './plugin'; import { EncryptedSavedObjectsClient, EncryptedSavedObjectsClientOptions } from './saved_objects'; -function createEncryptedSavedObjectsSetupMock() { +function createEncryptedSavedObjectsSetupMock( + { canEncrypt }: { canEncrypt: boolean } = { canEncrypt: false } +) { return { registerType: jest.fn(), __legacyCompat: { registerLegacyAPI: jest.fn() }, - usingEphemeralEncryptionKey: true, + canEncrypt, createMigration: jest.fn(), } as jest.Mocked; } diff --git a/x-pack/plugins/encrypted_saved_objects/server/plugin.test.ts b/x-pack/plugins/encrypted_saved_objects/server/plugin.test.ts index 823a6b0afa9dc..e71332b1c5aa7 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/plugin.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/plugin.test.ts @@ -19,12 +19,28 @@ describe('EncryptedSavedObjects Plugin', () => { ); expect(plugin.setup(coreMock.createSetup(), { security: securityMock.createSetup() })) .toMatchInlineSnapshot(` - Object { - "createMigration": [Function], - "registerType": [Function], - "usingEphemeralEncryptionKey": true, - } - `); + Object { + "canEncrypt": false, + "createMigration": [Function], + "registerType": [Function], + } + `); + }); + + it('exposes proper contract when encryption key is set', () => { + const plugin = new EncryptedSavedObjectsPlugin( + coreMock.createPluginInitializerContext( + ConfigSchema.validate({ encryptionKey: 'z'.repeat(32) }, { dist: true }) + ) + ); + expect(plugin.setup(coreMock.createSetup(), { security: securityMock.createSetup() })) + .toMatchInlineSnapshot(` + Object { + "canEncrypt": true, + "createMigration": [Function], + "registerType": [Function], + } + `); }); }); diff --git a/x-pack/plugins/encrypted_saved_objects/server/plugin.ts b/x-pack/plugins/encrypted_saved_objects/server/plugin.ts index e846b133c26e0..c99d6bd32287d 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/plugin.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/plugin.ts @@ -6,10 +6,9 @@ */ import nodeCrypto from '@elastic/node-crypto'; -import { Logger, PluginInitializerContext, CoreSetup, Plugin } from 'src/core/server'; -import { TypeOf } from '@kbn/config-schema'; -import { SecurityPluginSetup } from '../../security/server'; -import { createConfig, ConfigSchema } from './config'; +import type { Logger, PluginInitializerContext, CoreSetup, Plugin } from 'src/core/server'; +import type { SecurityPluginSetup } from '../../security/server'; +import type { ConfigType } from './config'; import { EncryptedSavedObjectsService, EncryptedSavedObjectTypeRegistration, @@ -26,8 +25,11 @@ export interface PluginsSetup { } export interface EncryptedSavedObjectsPluginSetup { + /** + * Indicates if Saved Object encryption is possible. Requires an encryption key to be explicitly set via `xpack.encryptedSavedObjects.encryptionKey`. + */ + canEncrypt: boolean; registerType: (typeRegistration: EncryptedSavedObjectTypeRegistration) => void; - usingEphemeralEncryptionKey: boolean; createMigration: CreateEncryptedSavedObjectsMigrationFn; } @@ -50,19 +52,24 @@ export class EncryptedSavedObjectsPlugin } public setup(core: CoreSetup, deps: PluginsSetup): EncryptedSavedObjectsPluginSetup { - const config = createConfig( - this.initializerContext.config.get>(), - this.initializerContext.logger.get('config') - ); - const auditLogger = new EncryptedSavedObjectsAuditLogger( - deps.security?.audit.getLogger('encryptedSavedObjects') - ); + const config = this.initializerContext.config.get(); + const canEncrypt = config.encryptionKey !== undefined; + if (!canEncrypt) { + this.logger.warn( + 'Saved objects encryption key is not set. This will severely limit Kibana functionality. ' + + 'Please set xpack.encryptedSavedObjects.encryptionKey in the kibana.yml or use the bin/kibana-encryption-keys command.' + ); + } - const primaryCrypto = nodeCrypto({ encryptionKey: config.encryptionKey }); + const primaryCrypto = config.encryptionKey + ? nodeCrypto({ encryptionKey: config.encryptionKey }) + : undefined; const decryptionOnlyCryptos = config.keyRotation.decryptionOnlyKeys.map((decryptionKey) => nodeCrypto({ encryptionKey: decryptionKey }) ); - + const auditLogger = new EncryptedSavedObjectsAuditLogger( + deps.security?.audit.getLogger('encryptedSavedObjects') + ); const service = Object.freeze( new EncryptedSavedObjectsService({ primaryCrypto, @@ -94,9 +101,9 @@ export class EncryptedSavedObjectsPlugin }); return { + canEncrypt, registerType: (typeRegistration: EncryptedSavedObjectTypeRegistration) => service.registerType(typeRegistration), - usingEphemeralEncryptionKey: config.usingEphemeralEncryptionKey, createMigration: getCreateMigration( service, (typeRegistration: EncryptedSavedObjectTypeRegistration) => { diff --git a/x-pack/plugins/encrypted_saved_objects/server/routes/index.mock.ts b/x-pack/plugins/encrypted_saved_objects/server/routes/index.mock.ts index c2dbc4c163b44..32ac1617f4a7e 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/routes/index.mock.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/routes/index.mock.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ConfigSchema, createConfig } from '../config'; +import { ConfigSchema, ConfigType } from '../config'; import { httpServiceMock, loggingSystemMock } from '../../../../../src/core/server/mocks'; import { encryptionKeyRotationServiceMock } from '../crypto/index.mock'; @@ -14,7 +14,7 @@ export const routeDefinitionParamsMock = { create: (config: Record = {}) => ({ router: httpServiceMock.createRouter(), logger: loggingSystemMock.create().get(), - config: createConfig(ConfigSchema.validate(config), loggingSystemMock.create().get()), + config: ConfigSchema.validate(config) as ConfigType, encryptionKeyRotationService: encryptionKeyRotationServiceMock.create(), }), }; diff --git a/x-pack/plugins/fleet/kibana.json b/x-pack/plugins/fleet/kibana.json index aa0761c8a39bd..4a4019e3e9e47 100644 --- a/x-pack/plugins/fleet/kibana.json +++ b/x-pack/plugins/fleet/kibana.json @@ -4,14 +4,13 @@ "server": true, "ui": true, "configPath": ["xpack", "fleet"], - "requiredPlugins": ["licensing", "data"], + "requiredPlugins": ["licensing", "data", "encryptedSavedObjects"], "optionalPlugins": [ "security", "features", "cloud", "usageCollection", - "home", - "encryptedSavedObjects" + "home" ], "extraPublicDirs": ["common"], "requiredBundles": ["kibanaReact", "esUiShared", "home", "infra", "kibanaUtils"] diff --git a/x-pack/plugins/fleet/server/plugin.ts b/x-pack/plugins/fleet/server/plugin.ts index 7378d45e1bb3a..d89db7f1ac341 100644 --- a/x-pack/plugins/fleet/server/plugin.ts +++ b/x-pack/plugins/fleet/server/plugin.ts @@ -95,7 +95,7 @@ export interface FleetSetupDeps { } export interface FleetStartDeps { - encryptedSavedObjects?: EncryptedSavedObjectsPluginStart; + encryptedSavedObjects: EncryptedSavedObjectsPluginStart; security?: SecurityPluginStart; } @@ -255,11 +255,11 @@ export class FleetPlugin // Conditional config routes if (config.agents.enabled) { - const isESOUsingEphemeralEncryptionKey = !deps.encryptedSavedObjects; - if (isESOUsingEphemeralEncryptionKey) { + const isESOCanEncrypt = deps.encryptedSavedObjects.canEncrypt; + if (!isESOCanEncrypt) { if (this.logger) { this.logger.warn( - 'Fleet APIs are disabled because the Encrypted Saved Objects plugin uses an ephemeral encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in the kibana.yml or use the bin/kibana-encryption-keys command.' + 'Fleet APIs are disabled because the Encrypted Saved Objects plugin is missing encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in the kibana.yml or use the bin/kibana-encryption-keys command.' ); } } else { diff --git a/x-pack/plugins/fleet/server/routes/setup/handlers.ts b/x-pack/plugins/fleet/server/routes/setup/handlers.ts index 1e74469107db4..0c6ba6d14b1be 100644 --- a/x-pack/plugins/fleet/server/routes/setup/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/setup/handlers.ts @@ -24,7 +24,7 @@ export const getFleetStatusHandler: RequestHandler = async (context, request, re const isProductionMode = appContextService.getIsProductionMode(); const isCloud = appContextService.getCloud()?.isCloudEnabled ?? false; const isTLSCheckDisabled = appContextService.getConfig()?.agents?.tlsCheckDisabled ?? false; - const isUsingEphemeralEncryptionKey = !appContextService.getEncryptedSavedObjectsSetup(); + const canEncrypt = appContextService.getEncryptedSavedObjectsSetup()?.canEncrypt === true; const missingRequirements: GetFleetStatusResponse['missing_requirements'] = []; if (!isAdminUserSetup) { @@ -37,7 +37,7 @@ export const getFleetStatusHandler: RequestHandler = async (context, request, re missingRequirements.push('tls_required'); } - if (isUsingEphemeralEncryptionKey) { + if (!canEncrypt) { missingRequirements.push('encrypted_saved_object_encryption_key_required'); } diff --git a/x-pack/plugins/monitoring/server/lib/elasticsearch/verify_alerting_security.ts b/x-pack/plugins/monitoring/server/lib/elasticsearch/verify_alerting_security.ts index c81b9632f0cd7..facb6e29236e3 100644 --- a/x-pack/plugins/monitoring/server/lib/elasticsearch/verify_alerting_security.ts +++ b/x-pack/plugins/monitoring/server/lib/elasticsearch/verify_alerting_security.ts @@ -44,7 +44,7 @@ export class AlertingSecurity { return { isSufficientlySecure: !isSecurityEnabled || (isSecurityEnabled && isTLSEnabled), - hasPermanentEncryptionKey: Boolean(encryptedSavedObjects), + hasPermanentEncryptionKey: encryptedSavedObjects?.canEncrypt === true, }; }; } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/privileges/read_privileges_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/privileges/read_privileges_route.test.ts index 3ee3c6884a3ec..2efb65c4a49a2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/privileges/read_privileges_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/privileges/read_privileges_route.test.ts @@ -18,7 +18,7 @@ describe('read_privileges route', () => { ({ clients, context } = requestContextMock.createTools()); clients.clusterClient.callAsCurrentUser.mockResolvedValue(getMockPrivilegesResult()); - readPrivilegesRoute(server.router, false); + readPrivilegesRoute(server.router, true); }); describe('normal status codes', () => { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/privileges/read_privileges_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/privileges/read_privileges_route.ts index a934f0a0ce134..f006d9250d369 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/privileges/read_privileges_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/privileges/read_privileges_route.ts @@ -14,7 +14,7 @@ import { readPrivileges } from '../../privileges/read_privileges'; export const readPrivilegesRoute = ( router: SecuritySolutionPluginRouter, - usingEphemeralEncryptionKey: boolean + hasEncryptionKey: boolean ) => { router.get( { @@ -39,7 +39,7 @@ export const readPrivilegesRoute = ( const clusterPrivileges = await readPrivileges(clusterClient.callAsCurrentUser, index); const privileges = merge(clusterPrivileges, { is_authenticated: request.auth.isAuthenticated ?? false, - has_encryption_key: !usingEphemeralEncryptionKey, + has_encryption_key: hasEncryptionKey, }); return response.ok({ body: privileges }); diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 8c35fd2ce8f8b..a34193937c788 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -183,7 +183,7 @@ export class Plugin implements IPlugin { @@ -102,5 +102,5 @@ export const initRoutes = ( readTagsRoute(router); // Privileges API to get the generic user privileges - readPrivilegesRoute(router, usingEphemeralEncryptionKey); + readPrivilegesRoute(router, hasEncryptionKey); }; From 4ee960380152b825862fa621e8b30474818cd9bd Mon Sep 17 00:00:00 2001 From: Sonja Krause-Harder Date: Wed, 10 Feb 2021 11:33:17 +0100 Subject: [PATCH 07/14] Use new shortcut links to Fleet discuss forums. (#90786) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../fleet/public/applications/fleet/components/alpha_flyout.tsx | 2 +- .../plugins/fleet/public/applications/fleet/layouts/default.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/alpha_flyout.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/alpha_flyout.tsx index 82b2d20005225..c91d80124dd35 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/alpha_flyout.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/alpha_flyout.tsx @@ -61,7 +61,7 @@ export const AlphaFlyout: React.FunctionComponent = ({ onClose }) => { ), forumLink: ( - + = ({ Date: Wed, 10 Feb 2021 13:50:41 +0100 Subject: [PATCH 08/14] [Lens] (Accessibility) Fix focus on drag and drop actions (#90561) --- .../config_panel/config_panel.tsx | 55 +++------------ .../draggable_dimension_button.tsx | 15 +++- .../config_panel/layer_panel.test.tsx | 20 ++++-- .../editor_frame/config_panel/layer_panel.tsx | 32 +++++++-- .../config_panel/use_focus_update.tsx | 69 +++++++++++++++++++ .../functional/apps/lens/drag_and_drop.ts | 9 +++ .../test/functional/page_objects/lens_page.ts | 26 +++++++ 7 files changed, 168 insertions(+), 58 deletions(-) create mode 100644 x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/use_focus_update.tsx diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx index e5b07aacee16e..393c7363dc03f 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/config_panel.tsx @@ -7,7 +7,7 @@ import './config_panel.scss'; -import React, { useMemo, memo, useEffect, useState, useCallback } from 'react'; +import React, { useMemo, memo } from 'react'; import { EuiFlexItem, EuiToolTip, EuiButton, EuiForm } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { Visualization } from '../../../types'; @@ -16,6 +16,7 @@ import { trackUiEvent } from '../../../lens_ui_telemetry'; import { generateId } from '../../../id_generator'; import { removeLayer, appendLayer } from './layer_actions'; import { ConfigPanelWrapperProps } from './types'; +import { useFocusUpdate } from './use_focus_update'; export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: ConfigPanelWrapperProps) { const activeVisualization = props.visualizationMap[props.activeVisualizationId || '']; @@ -26,50 +27,6 @@ export const ConfigPanelWrapper = memo(function ConfigPanelWrapper(props: Config ) : null; }); -function useFocusUpdate(layerIds: string[]) { - const [nextFocusedLayerId, setNextFocusedLayerId] = useState(null); - const [layerRefs, setLayersRefs] = useState>({}); - - useEffect(() => { - const focusable = nextFocusedLayerId && layerRefs[nextFocusedLayerId]; - if (focusable) { - focusable.focus(); - setNextFocusedLayerId(null); - } - }, [layerIds, layerRefs, nextFocusedLayerId]); - - const setLayerRef = useCallback((layerId, el) => { - if (el) { - setLayersRefs((refs) => ({ - ...refs, - [layerId]: el, - })); - } - }, []); - - const removeLayerRef = useCallback( - (layerId) => { - if (layerIds.length <= 1) { - return setNextFocusedLayerId(layerId); - } - - const removedLayerIndex = layerIds.findIndex((l) => l === layerId); - const nextFocusedLayerIdId = - removedLayerIndex === 0 ? layerIds[1] : layerIds[removedLayerIndex - 1]; - - setLayersRefs((refs) => { - const newLayerRefs = { ...refs }; - delete newLayerRefs[layerId]; - return newLayerRefs; - }); - return setNextFocusedLayerId(nextFocusedLayerIdId); - }, - [layerIds] - ); - - return { setNextFocusedLayerId, removeLayerRef, setLayerRef }; -} - export function LayerPanels( props: ConfigPanelWrapperProps & { activeDatasourceId: string; @@ -85,7 +42,11 @@ export function LayerPanels( } = props; const layerIds = activeVisualization.getLayerIds(visualizationState); - const { setNextFocusedLayerId, removeLayerRef, setLayerRef } = useFocusUpdate(layerIds); + const { + setNextFocusedId: setNextFocusedLayerId, + removeRef: removeLayerRef, + registerNewRef: registerNewLayerRef, + } = useFocusUpdate(layerIds); const setVisualizationState = useMemo( () => (newState: unknown) => { @@ -145,7 +106,7 @@ export function LayerPanels( void; }) { const dropType = layerDatasource.getDropTypes({ ...layerDatasourceDropProps, @@ -94,8 +96,17 @@ export function DraggableDimensionButton({ [group.accessors] ); + const registerNewButtonRefMemoized = useCallback((el) => registerNewButtonRef(columnId, el), [ + registerNewButtonRef, + columnId, + ]); + return ( -
+
{ dispatch: jest.fn(), core: coreMock.createStart(), layerIndex: 0, - setLayerRef: jest.fn(), + registerNewLayerRef: jest.fn(), }; } @@ -620,17 +620,26 @@ describe('LayerPanel', () => { ); - - component.find(DragDrop).at(1).prop('onDrop')!(draggingOperation, 'reorder'); + act(() => { + component.find(DragDrop).at(1).prop('onDrop')!(draggingOperation, 'reorder'); + }); expect(mockDatasource.onDrop).toHaveBeenCalledWith( expect.objectContaining({ dropType: 'reorder', droppedItem: draggingOperation, }) ); + const secondButton = component + .find(DragDrop) + .at(1) + .find('[data-test-subj="lnsDragDrop-keyboardHandler"]') + .instance(); + const focusedEl = document.activeElement; + expect(focusedEl).toEqual(secondButton); }); it('should copy when dropping on empty slot in the same group', () => { + (generateId as jest.Mock).mockReturnValue(`newid`); mockVisualization.getConfiguration.mockReturnValue({ groups: [ { @@ -657,9 +666,12 @@ describe('LayerPanel', () => { ); - component.find(DragDrop).at(2).prop('onDrop')!(draggingOperation, 'duplicate_in_group'); + act(() => { + component.find(DragDrop).at(2).prop('onDrop')!(draggingOperation, 'duplicate_in_group'); + }); expect(mockDatasource.onDrop).toHaveBeenCalledWith( expect.objectContaining({ + columnId: 'newid', dropType: 'duplicate_in_group', droppedItem: draggingOperation, }) diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index 80e9ed05b982d..5ba73e98b42c1 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -26,6 +26,7 @@ import { RemoveLayerButton } from './remove_layer_button'; import { EmptyDimensionButton } from './empty_dimension_button'; import { DimensionButton } from './dimension_button'; import { DraggableDimensionButton } from './draggable_dimension_button'; +import { useFocusUpdate } from './use_focus_update'; const initialActiveDimensionState = { isNew: false, @@ -45,7 +46,7 @@ export function LayerPanel( newVisualizationState: unknown ) => void; onRemoveLayer: () => void; - setLayerRef: (layerId: string, instance: HTMLDivElement | null) => void; + registerNewLayerRef: (layerId: string, instance: HTMLDivElement | null) => void; } ) { const dragDropContext = useContext(DragContext); @@ -58,7 +59,7 @@ export function LayerPanel( layerId, isOnlyLayer, onRemoveLayer, - setLayerRef, + registerNewLayerRef, layerIndex, activeVisualization, updateVisualization, @@ -70,7 +71,10 @@ export function LayerPanel( setActiveDimension(initialActiveDimensionState); }, [activeVisualization.id]); - const setLayerRefMemoized = useCallback((el) => setLayerRef(layerId, el), [layerId, setLayerRef]); + const registerLayerRef = useCallback((el) => registerNewLayerRef(layerId, el), [ + layerId, + registerNewLayerRef, + ]); const layerVisualizationConfigProps = { layerId, @@ -114,6 +118,16 @@ export function LayerPanel( const { setDimension, removeDimension } = activeVisualization; const layerDatasourceOnDrop = layerDatasource.onDrop; + const allAccessors = groups.flatMap((group) => + group.accessors.map((accessor) => accessor.columnId) + ); + + const { + setNextFocusedId: setNextFocusedButtonId, + removeRef: removeButtonRef, + registerNewRef: registerNewButtonRef, + } = useFocusUpdate(allAccessors); + const onDrop = useMemo(() => { return ( droppedItem: DragDropIdentifier, @@ -127,7 +141,12 @@ export function LayerPanel( columnId, groupId, layerId: targetLayerId, - } = (targetItem as unknown) as DraggedOperation; // TODO: correct misleading name + } = (targetItem as unknown) as DraggedOperation; + if (dropType === 'reorder' || dropType === 'field_replace' || dropType === 'field_add') { + setNextFocusedButtonId(droppedItem.id); + } else { + setNextFocusedButtonId(columnId); + } const filterOperations = groups.find(({ groupId: gId }) => gId === targetItem.groupId)?.filterOperations || @@ -171,11 +190,12 @@ export function LayerPanel( setDimension, removeDimension, layerDatasourceDropProps, + setNextFocusedButtonId, ]); return ( -
+
@@ -264,6 +284,7 @@ export function LayerPanel( return ( { + const focusableSelector = 'button, [href], input, select, textarea, [tabindex]'; + if (!el) { + return null; + } + if (el.matches(focusableSelector)) { + return el; + } + const firstFocusable = el.querySelector(focusableSelector); + if (!firstFocusable) { + return null; + } + return (firstFocusable as unknown) as { focus: () => void }; +}; + +type RefsById = Record; + +export function useFocusUpdate(ids: string[]) { + const [nextFocusedId, setNextFocusedId] = useState(null); + const [refsById, setRefsById] = useState({}); + + useEffect(() => { + const element = nextFocusedId && refsById[nextFocusedId]; + if (element) { + const focusable = getFirstFocusable(element); + focusable?.focus(); + setNextFocusedId(null); + } + }, [ids, refsById, nextFocusedId]); + + const registerNewRef = useCallback((id, el) => { + if (el) { + setRefsById((r) => ({ + ...r, + [id]: el, + })); + } + }, []); + + const removeRef = useCallback( + (id) => { + if (ids.length <= 1) { + return setNextFocusedId(id); + } + + const removedIndex = ids.findIndex((l) => l === id); + + setRefsById((refs) => { + const newRefsById = { ...refs }; + delete newRefsById[id]; + return newRefsById; + }); + const next = removedIndex === 0 ? ids[1] : ids[removedIndex - 1]; + return setNextFocusedId(next); + }, + [ids] + ); + + return { setNextFocusedId, removeRef, registerNewRef }; +} diff --git a/x-pack/test/functional/apps/lens/drag_and_drop.ts b/x-pack/test/functional/apps/lens/drag_and_drop.ts index 5b3a984f00519..a272b67de1b0a 100644 --- a/x-pack/test/functional/apps/lens/drag_and_drop.ts +++ b/x-pack/test/functional/apps/lens/drag_and_drop.ts @@ -143,6 +143,7 @@ export default function ({ getPageObjects }: FtrProviderContext) { expect(await PageObjects.lens.getDimensionTriggerText('lnsXY_xDimensionPanel')).to.eql( '@timestamp' ); + await PageObjects.lens.assertFocusedField('@timestamp'); }); it('should drop a field to empty dimension', async () => { await PageObjects.lens.dragFieldWithKeyboard('bytes', 4); @@ -154,12 +155,15 @@ export default function ({ getPageObjects }: FtrProviderContext) { expect( await PageObjects.lens.getDimensionTriggersTexts('lnsXY_splitDimensionPanel') ).to.eql(['Top values of @message.raw']); + await PageObjects.lens.assertFocusedField('@message.raw'); }); it('should drop a field to an existing dimension replacing the old one', async () => { await PageObjects.lens.dragFieldWithKeyboard('clientip', 1, true); expect( await PageObjects.lens.getDimensionTriggersTexts('lnsXY_splitDimensionPanel') ).to.eql(['Top values of clientip']); + + await PageObjects.lens.assertFocusedField('clientip'); }); it('should duplicate an element in a group', async () => { await PageObjects.lens.dimensionKeyboardDragDrop('lnsXY_yDimensionPanel', 0, 1); @@ -168,6 +172,8 @@ export default function ({ getPageObjects }: FtrProviderContext) { 'Average of bytes', 'Count of records [1]', ]); + + await PageObjects.lens.assertFocusedDimension('Count of records [1]'); }); it('should move dimension to compatible dimension', async () => { @@ -186,6 +192,7 @@ export default function ({ getPageObjects }: FtrProviderContext) { expect( await PageObjects.lens.getDimensionTriggersTexts('lnsXY_splitDimensionPanel') ).to.eql([]); + await PageObjects.lens.assertFocusedDimension('@timestamp'); }); it('should move dimension to incompatible dimension', async () => { await PageObjects.lens.dimensionKeyboardDragDrop('lnsXY_yDimensionPanel', 1, 2); @@ -198,6 +205,7 @@ export default function ({ getPageObjects }: FtrProviderContext) { 'Count of records', 'Unique count of @timestamp', ]); + await PageObjects.lens.assertFocusedDimension('Unique count of @timestamp'); }); it('should reorder elements with keyboard', async () => { await PageObjects.lens.dimensionKeyboardReorder('lnsXY_yDimensionPanel', 0, 1); @@ -205,6 +213,7 @@ export default function ({ getPageObjects }: FtrProviderContext) { 'Unique count of @timestamp', 'Count of records', ]); + await PageObjects.lens.assertFocusedDimension('Count of records'); }); }); diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index aae161ef9fcf1..add6979c2dde1 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -707,5 +707,31 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont await this.saveAndReturn(); } }, + + /** + * Asserts that the focused element is a field with a specified text + * + * @param name - the element visible text + */ + async assertFocusedField(name: string) { + const input = await find.activeElement(); + const fieldAncestor = await input.findByXpath('./../../..'); + const focusedElementText = await fieldAncestor.getVisibleText(); + const dataTestSubj = await fieldAncestor.getAttribute('data-test-subj'); + expect(focusedElementText).to.eql(name); + expect(dataTestSubj).to.eql('lnsFieldListPanelField'); + }, + + /** + * Asserts that the focused element is a dimension with with a specified text + * + * @param name - the element visible text + */ + async assertFocusedDimension(name: string) { + const input = await find.activeElement(); + const fieldAncestor = await input.findByXpath('./../../..'); + const focusedElementText = await fieldAncestor.getVisibleText(); + expect(focusedElementText).to.eql(name); + }, }); } From ce441bdc3258f1f35da3c787236d14b4d133e54c Mon Sep 17 00:00:00 2001 From: Rudolf Meijering Date: Wed, 10 Feb 2021 13:54:52 +0100 Subject: [PATCH 09/14] RFC Improve saved object migrations algorithm (#84333) * Instead of cloning, reindex legacy index * Reindex for every v2 migration * Use _reindex?require_alias=true and a write block toggle to prevent lost deletes * Use a ..._reindex_in_progress alias so that waiting for and preventing other reindex operations is idempotent The first version of the reindex block had only the instance which was able to mark the migration as complete set and remove the write block. This means other instances couldn't know if any reindex operaitons were in progress if the migration was already marked as complete. It also meant that a failure in this critical step could result in a permanent write block. * Revert "Use a ..._reindex_in_progress alias so that waiting for and preventing other reindex operations is idempotent" This reverts commit 8baf9b13dbbe50c1dec0d4844f170304ecc0b883. * Revert "Use _reindex?require_alias=true and a write block toggle to prevent lost deletes" This reverts commit d7237ca42c4167b6931fe8c544ac7c40e27afc6c. * Use reindex + clone as a way to prevent lost deletes * Fix numbering and ignore index_not_found_exceptionfor temporary index * Apply suggestions from code review Co-authored-by: Josh Dover Co-authored-by: Josh Dover Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- rfcs/text/0013_saved_object_migrations.md | 48 ++++++++++++----------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/rfcs/text/0013_saved_object_migrations.md b/rfcs/text/0013_saved_object_migrations.md index 6f5ab280a4612..88879e5e706eb 100644 --- a/rfcs/text/0013_saved_object_migrations.md +++ b/rfcs/text/0013_saved_object_migrations.md @@ -248,45 +248,49 @@ Note: 6. Use the reindexed legacy `.kibana_pre6.5.0_001` as the source for the rest of the migration algorithm. 3. If `.kibana` and `.kibana_7.10.0` both exists and are pointing to the same index this version's migration has already been completed. 1. Because the same version can have plugins enabled at any point in time, - perform the mappings update in step (7) and migrate outdated documents - with step (8). - 2. Skip to step (10) to start serving traffic. + migrate outdated documents with step (9) and perform the mappings update in step (10). + 2. Skip to step (12) to start serving traffic. 4. Fail the migration if: 1. `.kibana` is pointing to an index that belongs to a later version of Kibana .e.g. `.kibana_7.12.0_001` 2. (Only in 8.x) The source index contains documents that belong to an unknown Saved Object type (from a disabled plugin). Log an error explaining that the plugin that created these documents needs to be enabled again or that these objects should be deleted. See section (4.2.1.4). -5. Mark the source index as read-only and wait for all in-flight operations to drain (requires https://github.com/elastic/elasticsearch/pull/58094). This prevents any further writes from outdated nodes. Assuming this API is similar to the existing `//_close` API, we expect to receive `"acknowledged" : true` and `"shards_acknowledged" : true`. If all shards don’t acknowledge within the timeout, retry the operation until it succeeds. -6. Clone the source index into a new target index which has writes enabled. All nodes on the same version will use the same fixed index name e.g. `.kibana_7.10.0_001`. The `001` postfix isn't used by Kibana, but allows for re-indexing an index should this be required by an Elasticsearch upgrade. E.g. re-index `.kibana_7.10.0_001` into `.kibana_7.10.0_002` and point the `.kibana_7.10.0` alias to `.kibana_7.10.0_002`. - 1. `POST /.kibana_n/_clone/.kibana_7.10.0_001?wait_for_active_shards=all {"settings": {"index.blocks.write": false}}`. Ignore errors if the clone already exists. - 2. Wait for the cloning to complete `GET /_cluster/health/.kibana_7.10.0_001?wait_for_status=green&timeout=60s` If cloning doesn’t complete within the 60s timeout, log a warning for visibility and poll again. -7. Update the mappings of the target index +5. Set a write block on the source index. This prevents any further writes from outdated nodes. +6. Create a new temporary index `.kibana_7.10.0_reindex_temp` with `dynamic: false` on the top-level mappings so that any kind of document can be written to the index. This allows us to write untransformed documents to the index which might have fields which have been removed from the latest mappings defined by the plugin. Define minimal mappings for the `migrationVersion` and `type` fields so that we're still able to search for outdated documents that need to be transformed. + 1. Ignore errors if the target index already exists. +7. Reindex the source index into the new temporary index. + 1. Use `op_type=create` `conflicts=proceed` and `wait_for_completion=false` so that multiple instances can perform the reindex in parallel but only one write per document will succeed. + 2. Wait for the reindex task to complete. If reindexing doesn’t complete within the 60s timeout, log a warning for visibility and poll again. +8. Clone the temporary index into the target index `.kibana_7.10.0_001`. Since any further writes will only happen against the cloned target index this prevents a lost delete from occuring where one instance finishes the migration and deletes a document and another instance's reindex operation re-creates the deleted document. + 1. Set a write block on the temporary index + 2. Clone the temporary index into the target index while specifying that the target index should have writes enabled. + 3. If the clone operation fails because the target index already exist, ignore the error and wait for the target index to become green before proceeding. + 4. (The `001` postfix in the target index name isn't used by Kibana, but allows for re-indexing an index should this be required by an Elasticsearch upgrade. E.g. re-index `.kibana_7.10.0_001` into `.kibana_7.10.0_002` and point the `.kibana_7.10.0` alias to `.kibana_7.10.0_002`.) +9. Transform documents by reading batches of outdated documents from the target index then transforming and updating them with optimistic concurrency control. + 1. Ignore any version conflict errors. + 2. If a document transform throws an exception, add the document to a failure list and continue trying to transform all other documents. If any failures occured, log the complete list of documents that failed to transform. Fail the migration. +10. Update the mappings of the target index 1. Retrieve the existing mappings including the `migrationMappingPropertyHashes` metadata. - 2. Update the mappings with `PUT /.kibana_7.10.0_001/_mapping`. The API deeply merges any updates so this won't remove the mappings of any plugins that were enabled in a previous version but are now disabled. + 2. Update the mappings with `PUT /.kibana_7.10.0_001/_mapping`. The API deeply merges any updates so this won't remove the mappings of any plugins that are disabled on this instance but have been enabled on another instance that also migrated this index. 3. Ensure that fields are correctly indexed using the target index's latest mappings `POST /.kibana_7.10.0_001/_update_by_query?conflicts=proceed`. In the future we could optimize this query by only targeting documents: 1. That belong to a known saved object type. - 2. Which don't have outdated migrationVersion numbers since these will be transformed anyway. - 3. That belong to a type whose mappings were changed by comparing the `migrationMappingPropertyHashes`. (Metadata, unlike the mappings isn't commutative, so there is a small chance that the metadata hashes do not accurately reflect the latest mappings, however, this will just result in an less efficient query). -8. Transform documents by reading batches of outdated documents from the target index then transforming and updating them with optimistic concurrency control. - 1. Ignore any version conflict errors. - 2. If a document transform throws an exception, add the document to a failure list and continue trying to transform all other documents. If any failures occured, log the complete list of documents that failed to transform. Fail the migration. -9. Mark the migration as complete. This is done as a single atomic +11. Mark the migration as complete. This is done as a single atomic operation (requires https://github.com/elastic/elasticsearch/pull/58100) - to guarantees when multiple versions of Kibana are performing the + to guarantee that when multiple versions of Kibana are performing the migration in parallel, only one version will win. E.g. if 7.11 and 7.12 are started in parallel and migrate from a 7.9 index, either 7.11 or 7.12 should succeed and accept writes, but not both. - 3. Checks that `.kibana` alias is still pointing to the source index - 4. Points the `.kibana_7.10.0` and `.kibana` aliases to the target index. - 5. If this fails with a "required alias [.kibana] does not exist" error fetch `.kibana` again: + 1. Check that `.kibana` alias is still pointing to the source index + 2. Point the `.kibana_7.10.0` and `.kibana` aliases to the target index. + 3. Remove the temporary index `.kibana_7.10.0_reindex_temp` + 4. If this fails with a "required alias [.kibana] does not exist" error or "index_not_found_exception" for the temporary index, fetch `.kibana` again: 1. If `.kibana` is _not_ pointing to our target index fail the migration. - 2. If `.kibana` is pointing to our target index the migration has succeeded and we can proceed to step (10). -10. Start serving traffic. All saved object reads/writes happen through the + 2. If `.kibana` is pointing to our target index the migration has succeeded and we can proceed to step (12). +12. Start serving traffic. All saved object reads/writes happen through the version-specific alias `.kibana_7.10.0`. Together with the limitations, this algorithm ensures that migrations are idempotent. If two nodes are started simultaneously, both of them will start transforming documents in that version's target index, but because migrations are idempotent, it doesn’t matter which node’s writes win. - #### Known weaknesses: (Also present in our existing migration algorithm since v7.4) When the task manager index gets reindexed a reindex script is applied. From 3e91bc728d7cf13163a4528b530246f1a34bd7a6 Mon Sep 17 00:00:00 2001 From: ymao1 Date: Wed, 10 Feb 2021 08:06:09 -0500 Subject: [PATCH 10/14] [Alerting] License Errors on Alert List View (#89920) * Adding tooltips to alert list and modal for license upgrade * Fixing typings * Custom License Error status. Moving modal to alerts list page * Adding unit test * Cleanup * Unit tests * Removing tooltip from alert name * License * PR fixes * Updating modal wording * Updating license state error message * i18n fix * Fixing functional test Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../alerts/server/lib/license_state.test.ts | 2 +- .../alerts/server/lib/license_state.ts | 8 +- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - .../components/alerts_list.test.tsx | 289 +++++++++++------- .../alerts_list/components/alerts_list.tsx | 132 +++++--- .../components/manage_license_modal.tsx | 62 ++++ .../sections/alerts_list/translations.ts | 7 + .../tests/alerts/gold_noop_alert_type.ts | 2 +- 9 files changed, 355 insertions(+), 149 deletions(-) create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/manage_license_modal.tsx diff --git a/x-pack/plugins/alerts/server/lib/license_state.test.ts b/x-pack/plugins/alerts/server/lib/license_state.test.ts index 07074b9187547..a1c326656f735 100644 --- a/x-pack/plugins/alerts/server/lib/license_state.test.ts +++ b/x-pack/plugins/alerts/server/lib/license_state.test.ts @@ -248,7 +248,7 @@ describe('ensureLicenseForAlertType()', () => { expect(() => licenseState.ensureLicenseForAlertType(alertType) ).toThrowErrorMatchingInlineSnapshot( - `"Alert test is disabled because it requires a Gold license. Contact your administrator to upgrade your license."` + `"Alert test is disabled because it requires a Gold license. Go to License Management to view upgrade options."` ); }); diff --git a/x-pack/plugins/alerts/server/lib/license_state.ts b/x-pack/plugins/alerts/server/lib/license_state.ts index f95c6cb42a17b..238b2e97c4cdf 100644 --- a/x-pack/plugins/alerts/server/lib/license_state.ts +++ b/x-pack/plugins/alerts/server/lib/license_state.ts @@ -9,6 +9,7 @@ import Boom from '@hapi/boom'; import { i18n } from '@kbn/i18n'; import type { PublicMethodsOf } from '@kbn/utility-types'; import { assertNever } from '@kbn/std'; +import { capitalize } from 'lodash'; import { Observable, Subscription } from 'rxjs'; import { LicensingPluginStart } from '../../../licensing/server'; import { ILicense, LicenseType } from '../../../licensing/common/types'; @@ -190,8 +191,11 @@ export class LicenseState { throw new AlertTypeDisabledError( i18n.translate('xpack.alerts.serverSideErrors.invalidLicenseErrorMessage', { defaultMessage: - 'Alert {alertTypeId} is disabled because it requires a Gold license. Contact your administrator to upgrade your license.', - values: { alertTypeId: alertType.id }, + 'Alert {alertTypeId} is disabled because it requires a {licenseType} license. Go to License Management to view upgrade options.', + values: { + alertTypeId: alertType.id, + licenseType: capitalize(alertType.minimumLicenseRequired), + }, }), 'license_invalid' ); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 018d2d572eea0..5e0bf7501eb11 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -4830,7 +4830,6 @@ "xpack.alerts.server.healthStatus.degraded": "アラートフレームワークは劣化しました", "xpack.alerts.server.healthStatus.unavailable": "アラートフレームワークを使用できません", "xpack.alerts.serverSideErrors.expirerdLicenseErrorMessage": "{licenseType} ライセンスの期限が切れたのでアラートタイプ {alertTypeId} は無効です。", - "xpack.alerts.serverSideErrors.invalidLicenseErrorMessage": "アラート {alertTypeId} は無効です。Gold ライセンスが必要です。ライセンスをアップグレードするには、管理者に問い合わせてください。", "xpack.alerts.serverSideErrors.unavailableLicenseErrorMessage": "現時点でライセンス情報を入手できないため、アラートタイプ {alertTypeId} は無効です。", "xpack.alerts.serverSideErrors.unavailableLicenseInformationErrorMessage": "アラートを利用できません。現在ライセンス情報が利用できません。", "xpack.apm.a.thresholdMet": "しきい値一致", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 5a9695b8ddc3d..d0dbd750853a2 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -4836,7 +4836,6 @@ "xpack.alerts.server.healthStatus.degraded": "告警框架已降级", "xpack.alerts.server.healthStatus.unavailable": "告警框架不可用", "xpack.alerts.serverSideErrors.expirerdLicenseErrorMessage": "告警类型 {alertTypeId} 已禁用,因为您的{licenseType}许可证已过期。", - "xpack.alerts.serverSideErrors.invalidLicenseErrorMessage": "告警 {alertTypeId} 已禁用,因为它需要黄金级许可证。请联系管理员升级您的许可证。", "xpack.alerts.serverSideErrors.unavailableLicenseErrorMessage": "告警类型 {alertTypeId} 已禁用,因为许可证信息当前不可用。", "xpack.alerts.serverSideErrors.unavailableLicenseInformationErrorMessage": "告警不可用 - 许可信息当前不可用。", "xpack.apm.a.thresholdMet": "已达到阈值", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx index fb34c95f93de2..fc41022dfb7b0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx @@ -127,11 +127,16 @@ describe('alerts_list component empty', () => { wrapper.find('button[data-test-subj="createFirstAlertButton"]').simulate('click'); - // When the AlertAdd component is rendered, it waits for the healthcheck to resolve - await new Promise((resolve) => { - setTimeout(resolve, 1000); + await act(async () => { + // When the AlertAdd component is rendered, it waits for the healthcheck to resolve + await new Promise((resolve) => { + setTimeout(resolve, 1000); + }); + + await nextTick(); + wrapper.update(); }); - wrapper.update(); + expect(wrapper.find('AlertAdd').exists()).toEqual(true); }); }); @@ -139,104 +144,131 @@ describe('alerts_list component empty', () => { describe('alerts_list component with items', () => { let wrapper: ReactWrapper; + const mockedAlertsData = [ + { + id: '1', + name: 'test alert', + tags: ['tag1'], + enabled: true, + alertTypeId: 'test_alert_type', + schedule: { interval: '5d' }, + actions: [], + params: { name: 'test alert type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'active', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: null, + }, + }, + { + id: '2', + name: 'test alert ok', + tags: ['tag1'], + enabled: true, + alertTypeId: 'test_alert_type', + schedule: { interval: '5d' }, + actions: [], + params: { name: 'test alert type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'ok', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: null, + }, + }, + { + id: '3', + name: 'test alert pending', + tags: ['tag1'], + enabled: true, + alertTypeId: 'test_alert_type', + schedule: { interval: '5d' }, + actions: [], + params: { name: 'test alert type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'pending', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: null, + }, + }, + { + id: '4', + name: 'test alert error', + tags: ['tag1'], + enabled: true, + alertTypeId: 'test_alert_type', + schedule: { interval: '5d' }, + actions: [{ id: 'test', group: 'alert', params: { message: 'test' } }], + params: { name: 'test alert type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'error', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: { + reason: AlertExecutionStatusErrorReasons.Unknown, + message: 'test', + }, + }, + }, + { + id: '5', + name: 'test alert license error', + tags: ['tag1'], + enabled: true, + alertTypeId: 'test_alert_type', + schedule: { interval: '5d' }, + actions: [{ id: 'test', group: 'alert', params: { message: 'test' } }], + params: { name: 'test alert type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'error', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: { + reason: AlertExecutionStatusErrorReasons.License, + message: 'test', + }, + }, + }, + ]; + async function setup() { loadAlerts.mockResolvedValue({ page: 1, perPage: 10000, total: 4, - data: [ - { - id: '1', - name: 'test alert', - tags: ['tag1'], - enabled: true, - alertTypeId: 'test_alert_type', - schedule: { interval: '5d' }, - actions: [], - params: { name: 'test alert type name' }, - scheduledTaskId: null, - createdBy: null, - updatedBy: null, - apiKeyOwner: null, - throttle: '1m', - muteAll: false, - mutedInstanceIds: [], - executionStatus: { - status: 'active', - lastExecutionDate: new Date('2020-08-20T19:23:38Z'), - error: null, - }, - }, - { - id: '2', - name: 'test alert ok', - tags: ['tag1'], - enabled: true, - alertTypeId: 'test_alert_type', - schedule: { interval: '5d' }, - actions: [], - params: { name: 'test alert type name' }, - scheduledTaskId: null, - createdBy: null, - updatedBy: null, - apiKeyOwner: null, - throttle: '1m', - muteAll: false, - mutedInstanceIds: [], - executionStatus: { - status: 'ok', - lastExecutionDate: new Date('2020-08-20T19:23:38Z'), - error: null, - }, - }, - { - id: '3', - name: 'test alert pending', - tags: ['tag1'], - enabled: true, - alertTypeId: 'test_alert_type', - schedule: { interval: '5d' }, - actions: [], - params: { name: 'test alert type name' }, - scheduledTaskId: null, - createdBy: null, - updatedBy: null, - apiKeyOwner: null, - throttle: '1m', - muteAll: false, - mutedInstanceIds: [], - executionStatus: { - status: 'pending', - lastExecutionDate: new Date('2020-08-20T19:23:38Z'), - error: null, - }, - }, - { - id: '4', - name: 'test alert error', - tags: ['tag1'], - enabled: true, - alertTypeId: 'test_alert_type', - schedule: { interval: '5d' }, - actions: [{ id: 'test', group: 'alert', params: { message: 'test' } }], - params: { name: 'test alert type name' }, - scheduledTaskId: null, - createdBy: null, - updatedBy: null, - apiKeyOwner: null, - throttle: '1m', - muteAll: false, - mutedInstanceIds: [], - executionStatus: { - status: 'error', - lastExecutionDate: new Date('2020-08-20T19:23:38Z'), - error: { - reason: AlertExecutionStatusErrorReasons.Unknown, - message: 'test', - }, - }, - }, - ], + data: mockedAlertsData, }); loadActionTypes.mockResolvedValue([ { @@ -271,21 +303,66 @@ describe('alerts_list component with items', () => { it('renders table of alerts', async () => { await setup(); expect(wrapper.find('EuiBasicTable')).toHaveLength(1); - expect(wrapper.find('EuiTableRow')).toHaveLength(4); - expect(wrapper.find('[data-test-subj="alertsTableCell-status"]').length).toBeGreaterThan(0); - expect(wrapper.find('[data-test-subj="alertStatus-active"]').length).toBeGreaterThan(0); - expect(wrapper.find('[data-test-subj="alertStatus-error"]').length).toBeGreaterThan(0); - expect(wrapper.find('[data-test-subj="alertStatus-ok"]').length).toBeGreaterThan(0); - expect(wrapper.find('[data-test-subj="alertStatus-pending"]').length).toBeGreaterThan(0); - expect(wrapper.find('[data-test-subj="alertStatus-unknown"]').length).toBe(0); + expect(wrapper.find('EuiTableRow')).toHaveLength(mockedAlertsData.length); + expect(wrapper.find('EuiTableRowCell[data-test-subj="alertsTableCell-status"]').length).toEqual( + mockedAlertsData.length + ); + expect(wrapper.find('EuiHealth[data-test-subj="alertStatus-active"]').length).toEqual(1); + expect(wrapper.find('EuiHealth[data-test-subj="alertStatus-ok"]').length).toEqual(1); + expect(wrapper.find('EuiHealth[data-test-subj="alertStatus-pending"]').length).toEqual(1); + expect(wrapper.find('EuiHealth[data-test-subj="alertStatus-unknown"]').length).toEqual(0); + + expect(wrapper.find('EuiHealth[data-test-subj="alertStatus-error"]').length).toEqual(2); + expect(wrapper.find('[data-test-subj="alertStatus-error-tooltip"]').length).toEqual(2); + expect( + wrapper.find('EuiButtonEmpty[data-test-subj="alertStatus-error-license-fix"]').length + ).toEqual(1); + expect(wrapper.find('[data-test-subj="refreshAlertsButton"]').exists()).toBeTruthy(); + + expect(wrapper.find('EuiHealth[data-test-subj="alertStatus-error"]').first().text()).toEqual( + 'Error' + ); + expect(wrapper.find('EuiHealth[data-test-subj="alertStatus-error"]').last().text()).toEqual( + 'License Error' + ); }); it('loads alerts when refresh button is clicked', async () => { await setup(); wrapper.find('[data-test-subj="refreshAlertsButton"]').first().simulate('click'); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + expect(loadAlerts).toHaveBeenCalled(); }); + + it('renders license errors and manage license modal on click', async () => { + global.open = jest.fn(); + await setup(); + expect(wrapper.find('ManageLicenseModal').exists()).toBeFalsy(); + expect( + wrapper.find('EuiButtonEmpty[data-test-subj="alertStatus-error-license-fix"]').length + ).toEqual(1); + wrapper + .find('EuiButtonEmpty[data-test-subj="alertStatus-error-license-fix"]') + .simulate('click'); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find('ManageLicenseModal').exists()).toBeTruthy(); + expect(wrapper.find('EuiButton[data-test-subj="confirmModalConfirmButton"]').text()).toEqual( + 'Manage license' + ); + wrapper.find('EuiButton[data-test-subj="confirmModalConfirmButton"]').simulate('click'); + expect(global.open).toHaveBeenCalled(); + }); }); describe('alerts_list component empty with show only capability', () => { @@ -308,7 +385,9 @@ describe('alerts_list component empty with show only capability', () => { name: 'Test2', }, ]); - loadAlertTypes.mockResolvedValue([{ id: 'test_alert_type', name: 'some alert type' }]); + loadAlertTypes.mockResolvedValue([ + { id: 'test_alert_type', name: 'some alert type', authorizedConsumers: {} }, + ]); loadAllActions.mockResolvedValue([]); // eslint-disable-next-line react-hooks/rules-of-hooks useKibanaMock().services.alertTypeRegistry = alertTypeRegistry; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx index 76680a60a24e1..11761cec7cdbb 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx @@ -53,14 +53,15 @@ import { AlertExecutionStatus, AlertExecutionStatusValues, ALERTS_FEATURE_ID, + AlertExecutionStatusErrorReasons, } from '../../../../../../alerts/common'; import { hasAllPrivilege } from '../../../lib/capabilities'; -import { alertsStatusesTranslationsMapping } from '../translations'; +import { alertsStatusesTranslationsMapping, ALERT_STATUS_LICENSE_ERROR } from '../translations'; import { useKibana } from '../../../../common/lib/kibana'; -import { checkAlertTypeEnabled } from '../../../lib/check_alert_type_enabled'; import { DEFAULT_HIDDEN_ACTION_TYPES } from '../../../../common/constants'; import './alerts_list.scss'; import { CenterJustifiedSpinner } from '../../../components/center_justified_spinner'; +import { ManageLicenseModal } from './manage_license_modal'; const ENTER_KEY = 13; @@ -97,7 +98,11 @@ export const AlertsList: React.FunctionComponent = () => { const [actionTypesFilter, setActionTypesFilter] = useState([]); const [alertStatusesFilter, setAlertStatusesFilter] = useState([]); const [alertFlyoutVisible, setAlertFlyoutVisibility] = useState(false); - const [dissmissAlertErrors, setDissmissAlertErrors] = useState(false); + const [dismissAlertErrors, setDismissAlertErrors] = useState(false); + const [manageLicenseModalOpts, setManageLicenseModalOpts] = useState<{ + licenseType: string; + alertTypeId: string; + } | null>(null); const [alertsStatusesTotal, setAlertsStatusesTotal] = useState>( AlertExecutionStatusValues.reduce( (prev: Record, status: string) => @@ -238,25 +243,64 @@ export const AlertsList: React.FunctionComponent = () => { } } + const renderAlertExecutionStatus = ( + executionStatus: AlertExecutionStatus, + item: AlertTableItem + ) => { + const healthColor = getHealthColor(executionStatus.status); + const tooltipMessage = + executionStatus.status === 'error' ? `Error: ${executionStatus?.error?.message}` : null; + const isLicenseError = + executionStatus.error?.reason === AlertExecutionStatusErrorReasons.License; + const statusMessage = isLicenseError + ? ALERT_STATUS_LICENSE_ERROR + : alertsStatusesTranslationsMapping[executionStatus.status]; + + const health = ( + + {statusMessage} + + ); + + const healthWithTooltip = tooltipMessage ? ( + + {health} + + ) : ( + health + ); + + return ( + + {healthWithTooltip} + {isLicenseError && ( + + + setManageLicenseModalOpts({ + licenseType: alertTypesState.data.get(item.alertTypeId)?.minimumLicenseRequired!, + alertTypeId: item.alertTypeId, + }) + } + > + + + + )} + + ); + }; + const alertsTableColumns = [ - { - field: 'executionStatus', - name: i18n.translate( - 'xpack.triggersActionsUI.sections.alertsList.alertsListTable.columns.statusTitle', - { defaultMessage: 'Status' } - ), - sortable: false, - truncateText: false, - 'data-test-subj': 'alertsTableCell-status', - render: (executionStatus: AlertExecutionStatus) => { - const healthColor = getHealthColor(executionStatus.status); - return ( - - {alertsStatusesTranslationsMapping[executionStatus.status]} - - ); - }, - }, { field: 'name', name: i18n.translate( @@ -265,12 +309,10 @@ export const AlertsList: React.FunctionComponent = () => { ), sortable: false, truncateText: true, + width: '35%', 'data-test-subj': 'alertsTableCell-name', render: (name: string, alert: AlertTableItem) => { - const checkEnabledResult = checkAlertTypeEnabled( - alertTypesState.data.get(alert.alertTypeId) - ); - const link = ( + return ( { @@ -280,17 +322,20 @@ export const AlertsList: React.FunctionComponent = () => { {name} ); - return checkEnabledResult.isEnabled ? ( - link - ) : ( - - {link} - - ); + }, + }, + { + field: 'executionStatus', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.alertsListTable.columns.statusTitle', + { defaultMessage: 'Status' } + ), + sortable: false, + truncateText: false, + width: '150px', + 'data-test-subj': 'alertsTableCell-status', + render: (executionStatus: AlertExecutionStatus, item: AlertTableItem) => { + return renderAlertExecutionStatus(executionStatus, item); }, }, { @@ -492,7 +537,7 @@ export const AlertsList: React.FunctionComponent = () => { - {!dissmissAlertErrors && alertsStatusesTotal.error > 0 ? ( + {!dismissAlertErrors && alertsStatusesTotal.error > 0 ? ( { defaultMessage="View" /> - setDissmissAlertErrors(true)}> + setDismissAlertErrors(true)}> { setPage(changedPage); }} /> + {manageLicenseModalOpts && ( + { + window.open(`${http.basePath.get()}/app/management/stack/license_management`, '_blank'); + setManageLicenseModalOpts(null); + }} + onCancel={() => setManageLicenseModalOpts(null)} + /> + )} ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/manage_license_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/manage_license_modal.tsx new file mode 100644 index 0000000000000..f13e5fd96d2ad --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/manage_license_modal.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiOverlayMask, EuiConfirmModal } from '@elastic/eui'; +import { capitalize } from 'lodash'; + +interface Props { + licenseType: string; + alertTypeId: string; + onConfirm: () => void; + onCancel: () => void; +} + +export const ManageLicenseModal: React.FC = ({ + licenseType, + alertTypeId, + onConfirm, + onCancel, +}) => { + const licenseRequired = capitalize(licenseType); + return ( + + +

+ +

+
+
+ ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/translations.ts index 0b8bba9ffe95a..1a2c576b1fa28 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/translations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/translations.ts @@ -28,6 +28,13 @@ export const ALERT_STATUS_ERROR = i18n.translate( } ); +export const ALERT_STATUS_LICENSE_ERROR = i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.alertStatusLicenseError', + { + defaultMessage: 'License Error', + } +); + export const ALERT_STATUS_PENDING = i18n.translate( 'xpack.triggersActionsUI.sections.alertsList.alertStatusPending', { diff --git a/x-pack/test/alerting_api_integration/basic/tests/alerts/gold_noop_alert_type.ts b/x-pack/test/alerting_api_integration/basic/tests/alerts/gold_noop_alert_type.ts index 488b39eabb637..211d1acb2a005 100644 --- a/x-pack/test/alerting_api_integration/basic/tests/alerts/gold_noop_alert_type.ts +++ b/x-pack/test/alerting_api_integration/basic/tests/alerts/gold_noop_alert_type.ts @@ -22,7 +22,7 @@ export default function emailTest({ getService }: FtrProviderContext) { statusCode: 403, error: 'Forbidden', message: - 'Alert test.gold.noop is disabled because it requires a Gold license. Contact your administrator to upgrade your license.', + 'Alert test.gold.noop is disabled because it requires a Gold license. Go to License Management to view upgrade options.', }); }); }); From e17878ef330b152dac9fb0933b13be668c5f1867 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Wed, 10 Feb 2021 14:25:53 +0100 Subject: [PATCH 11/14] [ILM] Revisit searchable snapshot field after new redesign (#90793) * moved searchable snapshot field out of cold phase accordian * refactor styling to padding top and bottom to get advanced settings drop down to sit flush with side of panel * Error clearing fix and cosmetic changes - the error state of the form would not clear correctly if the erroring field was unmounted. The logic for clearing form errors was also incorrectly using "keys" instead of "values". - updated the width of wait for snapshot policy field to be the same as other fields * fix hook dependency causing clearError to be called * slight improvement to component integration test * re-add singleSelection to snapshot policiy field config * refactored Phase component API and fixed typo in comment Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../edit_policy/edit_policy.helpers.tsx | 4 +- .../edit_policy/edit_policy.test.ts | 37 ++++++++++++++++--- .../phases/cold_phase/cold_phase.tsx | 10 ++--- .../components/phases/phase/phase.scss | 3 +- .../components/phases/phase/phase.tsx | 16 +++++++- .../searchable_snapshot_field.tsx | 2 + .../shared_fields/snapshot_policies_field.tsx | 1 + .../form/components/enhanced_use_field.tsx | 9 +++++ .../edit_policy/form/form_errors_context.tsx | 15 +++++--- 9 files changed, 76 insertions(+), 21 deletions(-) diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx index 38049dd7c6cfa..7e1b7c5267a8b 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx @@ -237,7 +237,9 @@ export const setup = async (arg?: { appServicesContext: Partial { - await toggleSearchableSnapshot(true); + if (!exists(`searchableSnapshotField-${phase}.searchableSnapshotCombobox`)) { + await toggleSearchableSnapshot(true); + } act(() => { find(`searchableSnapshotField-${phase}.searchableSnapshotCombobox`).simulate('change', [ { label: value }, diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts index 282daf780b86c..6f325084938e8 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts @@ -916,7 +916,7 @@ describe('', () => { await actions.warm.enable(true); await actions.warm.toggleForceMerge(true); await actions.warm.setForcemergeSegmentsCount('-22'); - await runTimers(); + runTimers(); expect(actions.hasGlobalErrorCallout()).toBe(true); expect(actions.hot.hasErrorIndicator()).toBe(true); expect(actions.warm.hasErrorIndicator()).toBe(true); @@ -925,7 +925,7 @@ describe('', () => { // 3. Cold phase validation issue await actions.cold.enable(true); await actions.cold.setReplicas('-33'); - await runTimers(); + runTimers(); expect(actions.hasGlobalErrorCallout()).toBe(true); expect(actions.hot.hasErrorIndicator()).toBe(true); expect(actions.warm.hasErrorIndicator()).toBe(true); @@ -933,7 +933,7 @@ describe('', () => { // 4. Fix validation issue in hot await actions.hot.setForcemergeSegmentsCount('1'); - await runTimers(); + runTimers(); expect(actions.hasGlobalErrorCallout()).toBe(true); expect(actions.hot.hasErrorIndicator()).toBe(false); expect(actions.warm.hasErrorIndicator()).toBe(true); @@ -941,7 +941,7 @@ describe('', () => { // 5. Fix validation issue in warm await actions.warm.setForcemergeSegmentsCount('1'); - await runTimers(); + runTimers(); expect(actions.hasGlobalErrorCallout()).toBe(true); expect(actions.hot.hasErrorIndicator()).toBe(false); expect(actions.warm.hasErrorIndicator()).toBe(false); @@ -949,7 +949,7 @@ describe('', () => { // 6. Fix validation issue in cold await actions.cold.setReplicas('1'); - await runTimers(); + runTimers(); expect(actions.hasGlobalErrorCallout()).toBe(false); expect(actions.hot.hasErrorIndicator()).toBe(false); expect(actions.warm.hasErrorIndicator()).toBe(false); @@ -966,11 +966,36 @@ describe('', () => { await actions.saveAsNewPolicy(true); await actions.setPolicyName(''); - await runTimers(); + runTimers(); + + expect(actions.hasGlobalErrorCallout()).toBe(true); + expect(actions.hot.hasErrorIndicator()).toBe(false); + expect(actions.warm.hasErrorIndicator()).toBe(false); + expect(actions.cold.hasErrorIndicator()).toBe(false); + }); + + test('clears all error indicators if last erroring field is unmounted', async () => { + const { actions } = testBed; + + await actions.cold.enable(true); + // introduce validation error + await actions.cold.setSearchableSnapshot(''); + runTimers(); + + await actions.savePolicy(); + runTimers(); expect(actions.hasGlobalErrorCallout()).toBe(true); expect(actions.hot.hasErrorIndicator()).toBe(false); expect(actions.warm.hasErrorIndicator()).toBe(false); + expect(actions.cold.hasErrorIndicator()).toBe(true); + + // unmount the field + await actions.cold.toggleSearchableSnapshot(false); + + expect(actions.hasGlobalErrorCallout()).toBe(false); + expect(actions.hot.hasErrorIndicator()).toBe(false); + expect(actions.warm.hasErrorIndicator()).toBe(false); expect(actions.cold.hasErrorIndicator()).toBe(false); }); }); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx index 1e1e97789e105..27aacef1a368b 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx @@ -51,10 +51,8 @@ export const ColdPhase: FunctionComponent = () => { const showReplicasField = get(formData, formFieldPaths.searchableSnapshot) == null; return ( - - - - {showReplicasField && } + }> + {showReplicasField && } {/* Freeze section */} {!isUsingSearchableSnapshotInHotPhase && ( @@ -90,10 +88,10 @@ export const ColdPhase: FunctionComponent = () => { {/* Data tier allocation section */} - + ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase.scss b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase.scss index 15f2dc508a365..75d25c0bffa50 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase.scss +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase.scss @@ -9,7 +9,8 @@ } .ilmSettingsButton { color: $euiColorPrimary; - padding: $euiSizeS; + padding-top: $euiSizeS; + padding-bottom: $euiSizeS; } .euiCommentTimeline { padding-top: $euiSize; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase.tsx index 0ac6f6922ec1e..3a057f6204e24 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase.tsx @@ -37,9 +37,13 @@ import './phase.scss'; interface Props { phase: PhasesExceptDelete; + /** + * Settings that should always be visible on the phase when it is enabled. + */ + topLevelSettings?: React.ReactNode; } -export const Phase: FunctionComponent = ({ children, phase }) => { +export const Phase: FunctionComponent = ({ children, topLevelSettings, phase }) => { const enabledPath = `_meta.${phase}.enabled`; const [formData] = useFormData({ watch: [enabledPath], @@ -102,7 +106,15 @@ export const Phase: FunctionComponent = ({ children, phase }) => { {enabled && ( <> - + {!!topLevelSettings ? ( + <> + + {topLevelSettings} + + ) : ( + + )} + = ({ phase }) =>
config={{ + label: i18nTexts.editPolicy.searchableSnapshotsFieldLabel, defaultValue: cloud?.isCloudEnabled ? CLOUD_DEFAULT_REPO : undefined, validations: [ { @@ -209,6 +210,7 @@ export const SearchableSnapshotField: FunctionComponent = ({ phase }) => value: singleSelectionArray, } as any } + label={field.label} fullWidth={false} euiFieldProps={{ 'data-test-subj': 'searchableSnapshotCombobox', diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/snapshot_policies_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/snapshot_policies_field.tsx index f9c973d14b3e2..21dd083ccf7c5 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/snapshot_policies_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/snapshot_policies_field.tsx @@ -194,6 +194,7 @@ export const SnapshotPoliciesField: React.FunctionComponent = () => { } euiFieldProps={{ 'data-test-subj': 'snapshotPolicyCombobox', + fullWidth: false, options: policies, singleSelection: { asPlainText: true }, isLoading, diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/components/enhanced_use_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/components/enhanced_use_field.tsx index 85e854fb5f004..7210dc6b7ce2b 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/components/enhanced_use_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/components/enhanced_use_field.tsx @@ -70,5 +70,14 @@ export const EnhancedUseField = ( }; }, []); + // Make sure to clear error message if the field is unmounted. + useEffect(() => { + return () => { + if (isMounted.current === false) { + clearError(phase, path); + } + }; + }, [phase, path, clearError]); + return ; }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/form_errors_context.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/form_errors_context.tsx index b0903dbbc1b1a..9877a2ea9449c 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/form_errors_context.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/form_errors_context.tsx @@ -54,6 +54,8 @@ export const FormErrorsProvider: FunctionComponent = ({ children }) => { const [errors, setErrors] = useState(createEmptyErrors); const form = useFormContext(); + const { getErrors: getFormErrors } = form; + const addError: ContextValue['addError'] = useCallback( (phase, fieldPath, errorMessages) => { setErrors((previousErrors) => ({ @@ -70,20 +72,23 @@ export const FormErrorsProvider: FunctionComponent = ({ children }) => { const clearError: ContextValue['clearError'] = useCallback( (phase, fieldPath) => { - if (form.getErrors().length) { + if (getFormErrors().length) { setErrors((previousErrors) => { const { [phase]: { [fieldPath]: fieldErrorToOmit, ...restOfPhaseErrors }, + hasErrors, ...otherPhases } = previousErrors; - const hasErrors = + const nextHasErrors = Object.keys(restOfPhaseErrors).length === 0 && - Object.keys(otherPhases).some((phaseErrors) => !!Object.keys(phaseErrors).length); + Object.values(otherPhases).some((phaseErrors) => { + return !!Object.keys(phaseErrors).length; + }); return { ...previousErrors, - hasErrors, + hasErrors: nextHasErrors, [phase]: restOfPhaseErrors, }; }); @@ -91,7 +96,7 @@ export const FormErrorsProvider: FunctionComponent = ({ children }) => { setErrors(createEmptyErrors); } }, - [form, setErrors] + [getFormErrors, setErrors] ); return ( From 7e80bb32744ce3fa5481a82bad92da0e20b8f6a6 Mon Sep 17 00:00:00 2001 From: Liza Katz Date: Wed, 10 Feb 2021 15:26:25 +0200 Subject: [PATCH 12/14] [Search Sessions] added an info flyout to session management (#90559) * added an info flyout to session management * better filter serialization * Fix jest tests * jest * display the originalState as a json object * code review * Text improvements Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/actions/extend_button.tsx | 2 +- .../components/actions/get_action.tsx | 11 +- .../components/actions/inspect_button.scss | 6 + .../components/actions/inspect_button.tsx | 134 ++++++++++++++++++ .../sessions_mgmt/components/actions/types.ts | 1 + .../sessions_mgmt/components/status.test.tsx | 2 + .../search/sessions_mgmt/lib/api.test.ts | 11 +- .../public/search/sessions_mgmt/lib/api.ts | 3 + .../sessions_mgmt/lib/get_columns.test.tsx | 2 + .../public/search/sessions_mgmt/types.ts | 2 + 10 files changed, 171 insertions(+), 3 deletions(-) create mode 100644 x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/inspect_button.scss create mode 100644 x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/inspect_button.tsx diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/extend_button.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/extend_button.tsx index 06459db154f4a..381c44b1bf7be 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/extend_button.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/extend_button.tsx @@ -38,7 +38,7 @@ const ExtendConfirm = ({ defaultMessage: 'Extend search session expiration', }); const confirm = i18n.translate('xpack.data.mgmt.searchSessions.extendModal.extendButton', { - defaultMessage: 'Extend', + defaultMessage: 'Extend expiration', }); const extend = i18n.translate('xpack.data.mgmt.searchSessions.extendModal.dontExtendButton', { defaultMessage: 'Cancel', diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/get_action.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/get_action.tsx index edc5037f1dbec..1a2b2cfb4ecec 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/get_action.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/get_action.tsx @@ -12,15 +12,24 @@ import { SearchSessionsMgmtAPI } from '../../lib/api'; import { UISession } from '../../types'; import { DeleteButton } from './delete_button'; import { ExtendButton } from './extend_button'; +import { InspectButton } from './inspect_button'; import { ACTION, OnActionComplete } from './types'; export const getAction = ( api: SearchSessionsMgmtAPI, actionType: string, - { id, name, expires }: UISession, + uiSession: UISession, onActionComplete: OnActionComplete ): IClickActionDescriptor | null => { + const { id, name, expires } = uiSession; switch (actionType) { + case ACTION.INSPECT: + return { + iconType: 'document', + textColor: 'default', + label: , + }; + case ACTION.DELETE: return { iconType: 'crossInACircleFilled', diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/inspect_button.scss b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/inspect_button.scss new file mode 100644 index 0000000000000..a43bb65927ed4 --- /dev/null +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/inspect_button.scss @@ -0,0 +1,6 @@ +.searchSessionsFlyout .euiFlyoutBody__overflowContent { + height: 100%; + > div { + height: 100%; + } +} \ No newline at end of file diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/inspect_button.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/inspect_button.tsx new file mode 100644 index 0000000000000..86dca64909b55 --- /dev/null +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/inspect_button.tsx @@ -0,0 +1,134 @@ +/* + * 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 { + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiPortal, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React, { Component, Fragment } from 'react'; +import { UISession } from '../../types'; +import { TableText } from '..'; +import { CodeEditor } from '../../../../../../../../src/plugins/kibana_react/public'; +import './inspect_button.scss'; + +interface Props { + searchSession: UISession; +} + +interface State { + isFlyoutVisible: boolean; +} + +export class InspectButton extends Component { + constructor(props: Props) { + super(props); + + this.state = { + isFlyoutVisible: false, + }; + + this.closeFlyout = this.closeFlyout.bind(this); + this.showFlyout = this.showFlyout.bind(this); + } + + public renderInfo() { + return ( + + {}} + options={{ + readOnly: true, + lineNumbers: 'off', + fontSize: 12, + minimap: { + enabled: false, + }, + scrollBeyondLastLine: false, + wordWrap: 'on', + wrappingIndent: 'indent', + automaticLayout: true, + }} + /> + + ); + } + + public render() { + let flyout; + + if (this.state.isFlyoutVisible) { + flyout = ( + + + + +

+ +

+
+
+ + + +

+ +

+
+ + {this.renderInfo()} +
+
+
+
+ ); + } + + return ( + + + + + {flyout} + + ); + } + + private closeFlyout = () => { + this.setState({ + isFlyoutVisible: false, + }); + }; + + private showFlyout = () => { + this.setState({ isFlyoutVisible: true }); + }; +} diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/types.ts b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/types.ts index 5f82f16adcbb6..c94b6aa8495c7 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/types.ts +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/types.ts @@ -8,6 +8,7 @@ export type OnActionComplete = () => void; export enum ACTION { + INSPECT = 'inspect', EXTEND = 'extend', DELETE = 'delete', } diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/status.test.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/status.test.tsx index 3d92f349fd2d6..f1d4f2ab379a0 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/status.test.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/status.test.tsx @@ -31,6 +31,8 @@ describe('Background Search Session management status labels', () => { status: SearchSessionStatus.IN_PROGRESS, created: '2020-12-02T00:19:32Z', expires: '2020-12-07T00:19:32Z', + initialState: {}, + restoreState: {}, }; }); diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.test.ts b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.test.ts index 0fa13ac145223..10b2ac3ec1d4c 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.test.ts +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.test.ts @@ -46,7 +46,13 @@ describe('Search Sessions Management API', () => { saved_objects: [ { id: 'hello-pizza-123', - attributes: { name: 'Veggie', appId: 'pizza', status: 'complete' }, + attributes: { + name: 'Veggie', + appId: 'pizza', + status: 'complete', + initialState: {}, + restoreState: {}, + }, }, ], } as SavedObjectsFindResponse; @@ -61,6 +67,7 @@ describe('Search Sessions Management API', () => { Array [ Object { "actions": Array [ + "inspect", "extend", "delete", ], @@ -68,8 +75,10 @@ describe('Search Sessions Management API', () => { "created": undefined, "expires": undefined, "id": "hello-pizza-123", + "initialState": Object {}, "name": "Veggie", "reloadUrl": "hello-cool-undefined-url", + "restoreState": Object {}, "restoreUrl": "hello-cool-undefined-url", "status": "complete", }, diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.ts b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.ts index 42e9384cce2d8..39da58cb76918 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.ts +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.ts @@ -21,6 +21,7 @@ type UrlGeneratorsStart = SharePluginStart['urlGenerators']; function getActions(status: SearchSessionStatus) { const actions: ACTION[] = []; + actions.push(ACTION.INSPECT); if (status === SearchSessionStatus.IN_PROGRESS || status === SearchSessionStatus.COMPLETE) { actions.push(ACTION.EXTEND); actions.push(ACTION.DELETE); @@ -78,6 +79,8 @@ const mapToUISession = (urls: UrlGeneratorsStart, config: SessionsConfigSchema) actions, restoreUrl, reloadUrl, + initialState, + restoreState, }; }; diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.test.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.test.tsx index 2aab35e34a2d0..fc0a8849006d3 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.test.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/get_columns.test.tsx @@ -66,6 +66,8 @@ describe('Search Sessions Management table column factory', () => { status: SearchSessionStatus.IN_PROGRESS, created: '2020-12-02T00:19:32Z', expires: '2020-12-07T00:19:32Z', + initialState: {}, + restoreState: {}, }; }); diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/types.ts b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/types.ts index d9aea4ddae93e..e7b48f319a8a8 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/types.ts +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/types.ts @@ -32,4 +32,6 @@ export interface UISession { actions?: ACTION[]; reloadUrl: string; restoreUrl: string; + initialState: Record; + restoreState: Record; } From f95bfe83b706cea92b0862ad16d2b3d054433dae Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Wed, 10 Feb 2021 08:28:22 -0500 Subject: [PATCH 13/14] [Fleet] Use Fleet Server indices in the search bar (#90835) --- .../applications/fleet/components/search_bar.tsx | 14 ++++++++------ .../public/applications/fleet/constants/index.ts | 3 +++ .../components/search_and_filter_bar.tsx | 12 ++++++++++-- .../agents/enrollment_token_list_page/index.tsx | 15 +++++++++++++-- 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/components/search_bar.tsx b/x-pack/plugins/fleet/public/applications/fleet/components/search_bar.tsx index a402fd995a42e..9897d89881450 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/components/search_bar.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/components/search_bar.tsx @@ -14,13 +14,14 @@ import { import { useStartServices } from '../hooks'; import { INDEX_NAME, AGENT_SAVED_OBJECT_TYPE } from '../constants'; -const HIDDEN_FIELDS = [`${AGENT_SAVED_OBJECT_TYPE}.actions`]; +const HIDDEN_FIELDS = [`${AGENT_SAVED_OBJECT_TYPE}.actions`, '_id', '_index']; interface Props { value: string; - fieldPrefix: string; + fieldPrefix?: string; onChange: (newValue: string, submit?: boolean) => void; placeholder?: string; + indexPattern?: string; } export const SearchBar: React.FunctionComponent = ({ @@ -28,6 +29,7 @@ export const SearchBar: React.FunctionComponent = ({ fieldPrefix, onChange, placeholder, + indexPattern = INDEX_NAME, }) => { const { data } = useStartServices(); const [indexPatternFields, setIndexPatternFields] = useState(); @@ -49,10 +51,10 @@ export const SearchBar: React.FunctionComponent = ({ const fetchFields = async () => { try { const _fields: IFieldType[] = await data.indexPatterns.getFieldsForWildcard({ - pattern: INDEX_NAME, + pattern: indexPattern, }); const fields = (_fields || []).filter((field) => { - if (fieldPrefix && field.name.startsWith(fieldPrefix)) { + if (!fieldPrefix || field.name.startsWith(fieldPrefix)) { for (const hiddenField of HIDDEN_FIELDS) { if (field.name.startsWith(hiddenField)) { return false; @@ -67,7 +69,7 @@ export const SearchBar: React.FunctionComponent = ({ } }; fetchFields(); - }, [data.indexPatterns, fieldPrefix]); + }, [data.indexPatterns, fieldPrefix, indexPattern]); return ( = ({ indexPatternFields ? [ { - title: INDEX_NAME, + title: indexPattern, fields: indexPatternFields, }, ] diff --git a/x-pack/plugins/fleet/public/applications/fleet/constants/index.ts b/x-pack/plugins/fleet/public/applications/fleet/constants/index.ts index 249087eda5cb1..6686aa21a9f2e 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/constants/index.ts +++ b/x-pack/plugins/fleet/public/applications/fleet/constants/index.ts @@ -15,6 +15,9 @@ export { AGENT_SAVED_OBJECT_TYPE, ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE, PACKAGE_POLICY_SAVED_OBJECT_TYPE, + // Fleet Server index + AGENTS_INDEX, + ENROLLMENT_API_KEYS_INDEX, } from '../../../../common'; export * from './page_paths'; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx index e6f681e4b39ea..af990a36a7415 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx @@ -18,7 +18,8 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { AgentPolicy } from '../../../../types'; import { SearchBar } from '../../../../components'; -import { AGENT_SAVED_OBJECT_TYPE } from '../../../../constants'; +import { AGENTS_INDEX, AGENT_SAVED_OBJECT_TYPE } from '../../../../constants'; +import { useConfig } from '../../../../hooks'; const statusFilters = [ { @@ -76,6 +77,7 @@ export const SearchAndFilterBar: React.FunctionComponent<{ showUpgradeable, onShowUpgradeableChange, }) => { + const config = useConfig(); // Policies state for filtering const [isAgentPoliciesFilterOpen, setIsAgentPoliciesFilterOpen] = useState(false); @@ -109,7 +111,13 @@ export const SearchAndFilterBar: React.FunctionComponent<{ onSubmitSearch(newSearch); } }} - fieldPrefix={AGENT_SAVED_OBJECT_TYPE} + {...(config.agents.fleetServerEnabled + ? { + indexPattern: AGENTS_INDEX, + } + : { + fieldPrefix: AGENT_SAVED_OBJECT_TYPE, + })} /> diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/index.tsx index 1871e0c1f537b..bab3763ea4f6a 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/enrollment_token_list_page/index.tsx @@ -20,7 +20,10 @@ import { HorizontalAlignment, } from '@elastic/eui'; import { FormattedMessage, FormattedDate } from '@kbn/i18n/react'; -import { ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE } from '../../../constants'; +import { + ENROLLMENT_API_KEYS_INDEX, + ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE, +} from '../../../constants'; import { useBreadcrumbs, usePagination, @@ -29,6 +32,7 @@ import { sendGetOneEnrollmentAPIKey, useStartServices, sendDeleteOneEnrollmentAPIKey, + useConfig, } from '../../../hooks'; import { EnrollmentAPIKey } from '../../../types'; import { SearchBar } from '../../../components/search_bar'; @@ -154,6 +158,7 @@ const DeleteButton: React.FunctionComponent<{ apiKey: EnrollmentAPIKey; refresh: export const EnrollmentTokenListPage: React.FunctionComponent<{}> = () => { useBreadcrumbs('fleet_enrollment_tokens'); + const config = useConfig(); const [flyoutOpen, setFlyoutOpen] = useState(false); const [search, setSearch] = useState(''); const { pagination, setPagination, pageSizeOptions } = usePagination(); @@ -281,7 +286,13 @@ export const EnrollmentTokenListPage: React.FunctionComponent<{}> = () => { }); setSearch(newSearch); }} - fieldPrefix={ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE} + {...(config.agents.fleetServerEnabled + ? { + indexPattern: ENROLLMENT_API_KEYS_INDEX, + } + : { + fieldPrefix: ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE, + })} /> From 061cb50c712131077d4063a52f9eec0af7ccfa06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Wed, 10 Feb 2021 14:13:27 +0000 Subject: [PATCH 14/14] [Telemetry] Add stakeholders to schema changes (#90143) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .github/CODEOWNERS | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 87dc99fa33749..4b0479eedea98 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -223,9 +223,8 @@ /x-pack/plugins/telemetry_collection_xpack/ @elastic/kibana-core /.telemetryrc.json @elastic/kibana-core /x-pack/.telemetryrc.json @elastic/kibana-core -src/plugins/telemetry/schema/legacy_oss_plugins.json @elastic/kibana-core -src/plugins/telemetry/schema/oss_plugins.json @elastic/kibana-core -x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @elastic/kibana-core +src/plugins/telemetry/schema/ @elastic/kibana-core @elastic/kibana-telemetry @elastic/infra-telemetry +x-pack/plugins/telemetry_collection_xpack/schema/ @elastic/kibana-core @elastic/kibana-telemetry @elastic/infra-telemetry # Kibana Localization /src/dev/i18n/ @elastic/kibana-localization @elastic/kibana-core