From b6b602a689541ceb44d1d96026ac4bf059041dce Mon Sep 17 00:00:00 2001 From: Maryam Saeidi Date: Tue, 25 Apr 2023 12:31:20 +0200 Subject: [PATCH 01/29] [AO] Add the last step of the alert details page breadcrumb (#155623) Closes #153435 ## Summary This PR adds the last step of the alert details page Breadcrumb ![image](https://user-images.githubusercontent.com/12370520/234010388-6fbee7ec-fd25-4a4c-89bc-ab3469622a41.png) --- .../alert_details/alert_details.test.tsx | 17 ++++++++++++---- .../pages/alert_details/alert_details.tsx | 10 ++++++++-- .../alert_details/components/page_title.tsx | 20 ++++++++++++------- 3 files changed, 34 insertions(+), 13 deletions(-) diff --git a/x-pack/plugins/observability/public/pages/alert_details/alert_details.test.tsx b/x-pack/plugins/observability/public/pages/alert_details/alert_details.test.tsx index dce255a6d244b..f8d1b4b43af74 100644 --- a/x-pack/plugins/observability/public/pages/alert_details/alert_details.test.tsx +++ b/x-pack/plugins/observability/public/pages/alert_details/alert_details.test.tsx @@ -6,11 +6,12 @@ */ import React, { Fragment } from 'react'; -import * as useUiSettingHook from '@kbn/kibana-react-plugin/public/ui_settings/use_ui_setting'; import { useParams } from 'react-router-dom'; import { Chance } from 'chance'; import { waitFor } from '@testing-library/react'; import { casesPluginMock } from '@kbn/cases-plugin/public/mocks'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import * as useUiSettingHook from '@kbn/kibana-react-plugin/public/ui_settings/use_ui_setting'; import { Subset } from '../../typings'; import { render } from '../../utils/test_helper'; @@ -120,10 +121,18 @@ describe('Alert details', () => { mockKibana(); }); + const renderComponent = () => + render( + + + , + config + ); + it('should show the alert detail page with all necessary components', async () => { useFetchAlertDetailMock.mockReturnValue([false, alert]); - const alertDetails = render(, config); + const alertDetails = renderComponent(); await waitFor(() => expect(alertDetails.queryByTestId('centerJustifiedSpinner')).toBeFalsy()); @@ -136,7 +145,7 @@ describe('Alert details', () => { it('should show error loading the alert details', async () => { useFetchAlertDetailMock.mockReturnValue([false, alertWithNoData]); - const alertDetails = render(, config); + const alertDetails = renderComponent(); expect(alertDetails.queryByTestId('alertDetailsError')).toBeTruthy(); expect(alertDetails.queryByTestId('centerJustifiedSpinner')).toBeFalsy(); @@ -146,7 +155,7 @@ describe('Alert details', () => { it('should show loading spinner', async () => { useFetchAlertDetailMock.mockReturnValue([true, alertWithNoData]); - const alertDetails = render(, config); + const alertDetails = renderComponent(); expect(alertDetails.queryByTestId('centerJustifiedSpinner')).toBeTruthy(); expect(alertDetails.queryByTestId('alertDetailsError')).toBeFalsy(); diff --git a/x-pack/plugins/observability/public/pages/alert_details/alert_details.tsx b/x-pack/plugins/observability/public/pages/alert_details/alert_details.tsx index 9572c287257de..7bdb4d1054640 100644 --- a/x-pack/plugins/observability/public/pages/alert_details/alert_details.tsx +++ b/x-pack/plugins/observability/public/pages/alert_details/alert_details.tsx @@ -9,7 +9,7 @@ import React, { useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { useParams } from 'react-router-dom'; import { EuiEmptyPrompt, EuiPanel, EuiSpacer } from '@elastic/eui'; -import { ALERT_RULE_TYPE_ID, ALERT_RULE_UUID } from '@kbn/rule-data-utils'; +import { ALERT_RULE_CATEGORY, ALERT_RULE_TYPE_ID, ALERT_RULE_UUID } from '@kbn/rule-data-utils'; import { RuleTypeModel } from '@kbn/triggers-actions-ui-plugin/public'; import { useKibana } from '../../utils/kibana_react'; @@ -17,7 +17,7 @@ import { useFetchRule } from '../../hooks/use_fetch_rule'; import { usePluginContext } from '../../hooks/use_plugin_context'; import { useBreadcrumbs } from '../../hooks/use_breadcrumbs'; import { useFetchAlertDetail } from '../../hooks/use_fetch_alert_detail'; -import { PageTitle } from './components/page_title'; +import { PageTitle, pageTitleContent } from './components/page_title'; import { HeaderActions } from './components/header_actions'; import { AlertSummary, AlertSummaryField } from './components/alert_summary'; import { CenterJustifiedSpinner } from '../../components/center_justified_spinner'; @@ -33,6 +33,9 @@ interface AlertDetailsPathParams { } export const ALERT_DETAILS_PAGE_ID = 'alert-details-o11y'; +const defaultBreadcrumb = i18n.translate('xpack.observability.breadcrumbs.alertDetails', { + defaultMessage: 'Alert details', +}); export function AlertDetails() { const { @@ -69,6 +72,9 @@ export function AlertDetails() { defaultMessage: 'Alerts', }), }, + { + text: alert ? pageTitleContent(alert.fields[ALERT_RULE_CATEGORY]) : defaultBreadcrumb, + }, ]); if (isLoading) { diff --git a/x-pack/plugins/observability/public/pages/alert_details/components/page_title.tsx b/x-pack/plugins/observability/public/pages/alert_details/components/page_title.tsx index 41172644b9cf7..201e19a5c94a6 100644 --- a/x-pack/plugins/observability/public/pages/alert_details/components/page_title.tsx +++ b/x-pack/plugins/observability/public/pages/alert_details/components/page_title.tsx @@ -34,6 +34,18 @@ export interface PageTitleProps { alert: TopAlert | null; } +export function pageTitleContent(ruleCategory: string) { + return ( + + ); +} + export function PageTitle({ alert }: PageTitleProps) { const { euiTheme } = useEuiTheme(); @@ -41,13 +53,7 @@ export function PageTitle({ alert }: PageTitleProps) { return (
- + {pageTitleContent(alert.fields[ALERT_RULE_CATEGORY])} From bd6ae3e36ffcf39856f4834a245ca417961a557f Mon Sep 17 00:00:00 2001 From: Coen Warmer Date: Tue, 25 Apr 2023 12:49:56 +0200 Subject: [PATCH 02/29] Update CODEOWNERS (#155695) Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .github/CODEOWNERS | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2da78ed653a0d..ac389064e2986 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -776,21 +776,20 @@ packages/kbn-yarn-lock-validator @elastic/kibana-operations ### Observability Plugins -# Observability Shared -/x-pack/plugins/observability/public/components/shared/date_picker/ @elastic/uptime +# Observability Shared App +x-pack/plugins/observability_shared @elastic/observability-ui -# Unified Observability - on hold due to team capacity shortage -# For now, if you're changing these pages, get a review from someone who understand the changes -# /x-pack/plugins/observability/public/context @elastic/unified-observability -# /x-pack/test/observability_functional @elastic/unified-observability +# Observability App +x-pack/plugins/observability @elastic/actionable-observability + +# Observability App > Overview page +x-pack/plugins/observability/public/pages/overview @elastic/observability-ui -# Home/Overview/Landing Pages -/x-pack/plugins/observability/public/pages/home @elastic/observability-ui -/x-pack/plugins/observability/public/pages/landing @elastic/observability-ui -/x-pack/plugins/observability/public/pages/overview @elastic/observability-ui +# Observability App > Alert Details +x-pack/packages/observability/alert_details @elastic/actionable-observability -# Actionable Observability -/x-pack/test/observability_functional @elastic/actionable-observability +# Observability App > Functional Tests +x-pack/test/observability_functional @elastic/actionable-observability # Observability robots /.github/workflows/deploy-my-kibana.yml @elastic/observablt-robots From 65a4ae6a7f22019087740ce369bccc9c38ef26be Mon Sep 17 00:00:00 2001 From: Maxim Palenov Date: Tue, 25 Apr 2023 12:52:50 +0200 Subject: [PATCH 03/29] [Security Solution] Add rule snoozing on the rule editing page (#155612) **Addresses:** https://github.com/elastic/kibana/issues/147737 ## Summary This PR adds rule snooze feature on the Rule editing page. https://user-images.githubusercontent.com/3775283/234186169-72db1d91-ad34-4cea-922d-b0c96752c3d3.mov ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../pages/rule_editing/index.tsx | 2 + .../rule_details_snooze_settings/index.tsx | 35 ---------------- .../pages/rule_details/index.test.tsx | 4 +- .../pages/rule_details/index.tsx | 4 +- .../rule_management/api/api.test.ts | 36 ++++++++++++++++ .../rule_management/api/api.ts | 18 ++++++-- .../hooks/use_fetch_rules_snooze_settings.ts | 6 +-- .../components/rule_snooze_badge/index.ts | 8 ++++ .../rule_snooze_badge}/rule_snooze_badge.tsx | 42 +++++++++---------- .../rule_snooze_badge}/translations.ts | 4 +- .../use_rule_snooze_settings.ts | 40 ++++++++++++++++++ .../rule_management/logic/types.ts | 12 +++++- .../rules_table/rules_table_context.test.tsx | 16 +++---- .../components/rules_table/translations.ts | 7 ---- .../components/rules_table/use_columns.tsx | 25 ++--------- .../rules/step_rule_actions/index.tsx | 7 +++- .../step_rule_actions/rule_snooze_section.tsx | 42 +++++++++++++++++++ .../rules/step_rule_actions/translations.tsx | 15 +++++++ 18 files changed, 212 insertions(+), 111 deletions(-) delete mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/components/rule_details_snooze_settings/index.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_snooze_badge/index.ts rename x-pack/plugins/security_solution/public/detection_engine/{components => rule_management/components/rule_snooze_badge}/rule_snooze_badge.tsx (55%) rename x-pack/plugins/security_solution/public/detection_engine/{rule_details_ui/pages/rule_details/components/rule_details_snooze_settings => rule_management/components/rule_snooze_badge}/translations.ts (68%) create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_snooze_badge/use_rule_snooze_settings.ts create mode 100644 x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/rule_snooze_section.tsx diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_editing/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_editing/index.tsx index 829a6688f4b60..e07848c145d08 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_editing/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation_ui/pages/rule_editing/index.tsx @@ -303,6 +303,7 @@ const EditRulePageComponent: FC = () => { {actionsStep.data != null && ( { }, ], [ + rule?.id, rule?.immutable, rule?.type, loading, diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/components/rule_details_snooze_settings/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/components/rule_details_snooze_settings/index.tsx deleted file mode 100644 index e610715d676ce..0000000000000 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/components/rule_details_snooze_settings/index.tsx +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { useFetchRulesSnoozeSettings } from '../../../../../rule_management/api/hooks/use_fetch_rules_snooze_settings'; -import { RuleSnoozeBadge } from '../../../../../components/rule_snooze_badge'; -import * as i18n from './translations'; - -interface RuleDetailsSnoozeBadge { - /** - * Rule's SO id (not ruleId) - */ - id: string; -} - -export function RuleDetailsSnoozeSettings({ id }: RuleDetailsSnoozeBadge): JSX.Element { - const { data: rulesSnoozeSettings, isFetching, isError } = useFetchRulesSnoozeSettings([id]); - const snoozeSettings = rulesSnoozeSettings?.[0]; - - return ( - - ); -} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.test.tsx index 07cbd4294cb22..67a156e31edf2 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.test.tsx @@ -87,8 +87,8 @@ jest.mock('react-router-dom', () => { }); // RuleDetailsSnoozeSettings is an isolated component and not essential for existing tests -jest.mock('./components/rule_details_snooze_settings', () => ({ - RuleDetailsSnoozeSettings: () => <>, +jest.mock('../../../rule_management/components/rule_snooze_badge', () => ({ + RuleSnoozeBadge: () => <>, })); const mockRedirectLegacyUrl = jest.fn(); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx index 90f1d38f69774..211321618068f 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx @@ -140,7 +140,7 @@ import { EditRuleSettingButtonLink } from '../../../../detections/pages/detectio import { useStartMlJobs } from '../../../rule_management/logic/use_start_ml_jobs'; import { useBulkDuplicateExceptionsConfirmation } from '../../../rule_management_ui/components/rules_table/bulk_actions/use_bulk_duplicate_confirmation'; import { BulkActionDuplicateExceptionsConfirmation } from '../../../rule_management_ui/components/rules_table/bulk_actions/bulk_duplicate_exceptions_confirmation'; -import { RuleDetailsSnoozeSettings } from './components/rule_details_snooze_settings'; +import { RuleSnoozeBadge } from '../../../rule_management/components/rule_snooze_badge'; /** * Need a 100% height here to account for the graph/analyze tool, which sets no explicit height parameters, but fills the available space. @@ -559,7 +559,7 @@ const RuleDetailsPageComponent: React.FC = ({ )} - + ); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts index b1a2d0f95417a..5e3248818bf4e 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts @@ -791,6 +791,9 @@ describe('Detections Rules API', () => { describe('fetchRulesSnoozeSettings', () => { beforeEach(() => { fetchMock.mockClear(); + fetchMock.mockResolvedValue({ + data: [], + }); }); test('requests snooze settings of multiple rules by their IDs', () => { @@ -836,5 +839,38 @@ describe('Detections Rules API', () => { }) ); }); + + test('returns mapped data', async () => { + fetchMock.mockResolvedValue({ + data: [ + { + id: '1', + mute_all: false, + }, + { + id: '1', + mute_all: false, + active_snoozes: [], + is_snoozed_until: '2023-04-24T19:31:46.765Z', + }, + ], + }); + + const result = await fetchRulesSnoozeSettings({ ids: ['id1'] }); + + expect(result).toEqual([ + { + id: '1', + muteAll: false, + activeSnoozes: [], + }, + { + id: '1', + muteAll: false, + activeSnoozes: [], + isSnoozedUntil: new Date('2023-04-24T19:31:46.765Z'), + }, + ]); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts index b8078421ce683..24b66cada346c 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts @@ -57,7 +57,8 @@ import type { PrePackagedRulesStatusResponse, PreviewRulesProps, Rule, - RulesSnoozeSettingsResponse, + RuleSnoozeSettings, + RulesSnoozeSettingsBatchResponse, UpdateRulesProps, } from '../logic/types'; import { convertRulesFilterToKQL } from '../logic/utils'; @@ -197,8 +198,8 @@ export const fetchRuleById = async ({ id, signal }: FetchRuleProps): Promise => - KibanaServices.get().http.fetch( +}: FetchRuleSnoozingProps): Promise => { + const response = await KibanaServices.get().http.fetch( INTERNAL_ALERTING_API_FIND_RULES_PATH, { method: 'GET', @@ -211,6 +212,17 @@ export const fetchRulesSnoozeSettings = async ({ } ); + return response.data?.map((snoozeSettings) => ({ + id: snoozeSettings?.id ?? '', + muteAll: snoozeSettings?.mute_all ?? false, + activeSnoozes: snoozeSettings?.active_snoozes ?? [], + isSnoozedUntil: snoozeSettings?.is_snoozed_until + ? new Date(snoozeSettings.is_snoozed_until) + : undefined, + snoozeSchedule: snoozeSettings?.snooze_schedule, + })); +}; + export interface BulkActionSummary { failed: number; skipped: number; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_fetch_rules_snooze_settings.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_fetch_rules_snooze_settings.ts index bdc101fe18644..8e0ef31871826 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_fetch_rules_snooze_settings.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_fetch_rules_snooze_settings.ts @@ -29,11 +29,7 @@ export const useFetchRulesSnoozeSettings = ( ) => { return useQuery( [...FETCH_RULE_SNOOZE_SETTINGS_QUERY_KEY, ...ids], - async ({ signal }) => { - const response = await fetchRulesSnoozeSettings({ ids, signal }); - - return response.data; - }, + ({ signal }) => fetchRulesSnoozeSettings({ ids, signal }), { ...DEFAULT_QUERY_OPTIONS, ...queryOptions, diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_snooze_badge/index.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_snooze_badge/index.ts new file mode 100644 index 0000000000000..8e231398688f0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_snooze_badge/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './rule_snooze_badge'; diff --git a/x-pack/plugins/security_solution/public/detection_engine/components/rule_snooze_badge.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_snooze_badge/rule_snooze_badge.tsx similarity index 55% rename from x-pack/plugins/security_solution/public/detection_engine/components/rule_snooze_badge.tsx rename to x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_snooze_badge/rule_snooze_badge.tsx index 7fa16826eec60..e488127c25691 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/components/rule_snooze_badge.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_snooze_badge/rule_snooze_badge.tsx @@ -7,46 +7,44 @@ import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; import React, { useMemo } from 'react'; -import { useUserData } from '../../detections/components/user_info'; -import { hasUserCRUDPermission } from '../../common/utils/privileges'; -import { useKibana } from '../../common/lib/kibana'; -import type { RuleSnoozeSettings } from '../rule_management/logic'; -import { useInvalidateFetchRulesSnoozeSettingsQuery } from '../rule_management/api/hooks/use_fetch_rules_snooze_settings'; +import type { RuleObjectId } from '../../../../../common/detection_engine/rule_schema'; +import { useUserData } from '../../../../detections/components/user_info'; +import { hasUserCRUDPermission } from '../../../../common/utils/privileges'; +import { useKibana } from '../../../../common/lib/kibana'; +import { useInvalidateFetchRulesSnoozeSettingsQuery } from '../../api/hooks/use_fetch_rules_snooze_settings'; +import { useRuleSnoozeSettings } from './use_rule_snooze_settings'; interface RuleSnoozeBadgeProps { /** - * Rule's snooze settings, when set to `undefined` considered as a loading state + * Rule's SO id (not ruleId) */ - snoozeSettings: RuleSnoozeSettings | undefined; - /** - * It should represent a user readable error message happened during data snooze settings fetching - */ - error?: string; + ruleId: RuleObjectId; showTooltipInline?: boolean; } export function RuleSnoozeBadge({ - snoozeSettings, - error, + ruleId, showTooltipInline = false, }: RuleSnoozeBadgeProps): JSX.Element { const RulesListNotifyBadge = useKibana().services.triggersActionsUi.getRulesListNotifyBadge; + const { snoozeSettings, error } = useRuleSnoozeSettings(ruleId); const [{ canUserCRUD }] = useUserData(); const hasCRUDPermissions = hasUserCRUDPermission(canUserCRUD); const invalidateFetchRuleSnoozeSettings = useInvalidateFetchRulesSnoozeSettingsQuery(); const isLoading = !snoozeSettings; - const rule = useMemo(() => { - return { + const rule = useMemo( + () => ({ id: snoozeSettings?.id ?? '', - muteAll: snoozeSettings?.mute_all ?? false, - activeSnoozes: snoozeSettings?.active_snoozes ?? [], - isSnoozedUntil: snoozeSettings?.is_snoozed_until - ? new Date(snoozeSettings.is_snoozed_until) + muteAll: snoozeSettings?.muteAll ?? false, + activeSnoozes: snoozeSettings?.activeSnoozes ?? [], + isSnoozedUntil: snoozeSettings?.isSnoozedUntil + ? new Date(snoozeSettings.isSnoozedUntil) : undefined, - snoozeSchedule: snoozeSettings?.snooze_schedule, + snoozeSchedule: snoozeSettings?.snoozeSchedule, isEditable: hasCRUDPermissions, - }; - }, [snoozeSettings, hasCRUDPermissions]); + }), + [snoozeSettings, hasCRUDPermissions] + ); if (error) { return ( diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/components/rule_details_snooze_settings/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_snooze_badge/translations.ts similarity index 68% rename from x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/components/rule_details_snooze_settings/translations.ts rename to x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_snooze_badge/translations.ts index 37b3b6c75ba6e..2c67bdab2744f 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/components/rule_details_snooze_settings/translations.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_snooze_badge/translations.ts @@ -7,8 +7,8 @@ import { i18n } from '@kbn/i18n'; -export const UNABLE_TO_FETCH_RULE_SNOOZE_SETTINGS = i18n.translate( - 'xpack.securitySolution.detectionEngine.ruleDetails.rulesSnoozeSettings.error.unableToFetch', +export const UNABLE_TO_FETCH_RULES_SNOOZE_SETTINGS = i18n.translate( + 'xpack.securitySolution.detectionEngine.rulesSnoozeBadge.error.unableToFetch', { defaultMessage: 'Unable to fetch snooze settings', } diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_snooze_badge/use_rule_snooze_settings.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_snooze_badge/use_rule_snooze_settings.ts new file mode 100644 index 0000000000000..94a857b1e9842 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_snooze_badge/use_rule_snooze_settings.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { RuleSnoozeSettings } from '../../logic'; +import { useFetchRulesSnoozeSettings } from '../../api/hooks/use_fetch_rules_snooze_settings'; +import { useRulesTableContextOptional } from '../../../rule_management_ui/components/rules_table/rules_table/rules_table_context'; +import * as i18n from './translations'; + +interface UseRuleSnoozeSettingsResult { + snoozeSettings?: RuleSnoozeSettings; + error?: string; +} + +export function useRuleSnoozeSettings(id: string): UseRuleSnoozeSettingsResult { + const { + state: { rulesSnoozeSettings: rulesTableSnoozeSettings }, + } = useRulesTableContextOptional() ?? { state: {} }; + const { + data: rulesSnoozeSettings, + isFetching: isSingleSnoozeSettingsFetching, + isError: isSingleSnoozeSettingsError, + } = useFetchRulesSnoozeSettings([id], { + enabled: !rulesTableSnoozeSettings?.data[id] && !rulesTableSnoozeSettings?.isFetching, + }); + const snoozeSettings = rulesTableSnoozeSettings?.data[id] ?? rulesSnoozeSettings?.[0]; + const isFetching = rulesTableSnoozeSettings?.isFetching || isSingleSnoozeSettingsFetching; + const isError = rulesTableSnoozeSettings?.isError || isSingleSnoozeSettingsError; + + return { + snoozeSettings, + error: + isError || (!snoozeSettings && !isFetching) + ? i18n.UNABLE_TO_FETCH_RULES_SNOOZE_SETTINGS + : undefined, + }; +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts index ca71fa2680f17..e22be9467c6a1 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts @@ -219,6 +219,14 @@ export interface FetchRulesProps { } export interface RuleSnoozeSettings { + id: string; + muteAll: boolean; + snoozeSchedule?: RuleSnooze; + activeSnoozes?: string[]; + isSnoozedUntil?: Date; +} + +interface RuleSnoozeSettingsResponse { id: string; mute_all: boolean; snooze_schedule?: RuleSnooze; @@ -226,8 +234,8 @@ export interface RuleSnoozeSettings { is_snoozed_until?: string; } -export interface RulesSnoozeSettingsResponse { - data: RuleSnoozeSettings[]; +export interface RulesSnoozeSettingsBatchResponse { + data: RuleSnoozeSettingsResponse[]; } export type SortingOptions = t.TypeOf; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.test.tsx index 22a3af8ff0814..abc384cea3bfb 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/rules_table/rules_table_context.test.tsx @@ -190,8 +190,8 @@ describe('RulesTableContextProvider', () => { { id: '2', name: 'rule 2' }, ] as Rule[], rulesSnoozeSettings: [ - { id: '1', mute_all: true, snooze_schedule: [] }, - { id: '2', mute_all: false, snooze_schedule: [] }, + { id: '1', muteAll: true, snoozeSchedule: [] }, + { id: '2', muteAll: false, snoozeSchedule: [] }, ], }); @@ -216,21 +216,21 @@ describe('RulesTableContextProvider', () => { { id: '2', name: 'rule 2' }, ] as Rule[], rulesSnoozeSettings: [ - { id: '1', mute_all: true, snooze_schedule: [] }, - { id: '2', mute_all: false, snooze_schedule: [] }, + { id: '1', muteAll: true, snoozeSchedule: [] }, + { id: '2', muteAll: false, snoozeSchedule: [] }, ], }); expect(state.rulesSnoozeSettings.data).toEqual({ '1': { id: '1', - mute_all: true, - snooze_schedule: [], + muteAll: true, + snoozeSchedule: [], }, '2': { id: '2', - mute_all: false, - snooze_schedule: [], + muteAll: false, + snoozeSchedule: [], }, }); }); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/translations.ts index ad3cd89604030..52b4a5d4ba622 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/translations.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/translations.ts @@ -21,10 +21,3 @@ export const ML_RULE_JOBS_WARNING_BUTTON_LABEL = i18n.translate( defaultMessage: 'Visit rule details page to investigate', } ); - -export const UNABLE_TO_FETCH_RULES_SNOOZE_SETTINGS = i18n.translate( - 'xpack.securitySolution.detectionEngine.ruleManagement.rulesSnoozeSettings.error.unableToFetch', - { - defaultMessage: 'Unable to fetch snooze settings', - } -); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_columns.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_columns.tsx index 0ffb0ac7574a6..cccd9f394d65e 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_columns.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/use_columns.tsx @@ -22,7 +22,7 @@ import type { } from '../../../../../common/detection_engine/rule_monitoring'; import { isMlRule } from '../../../../../common/machine_learning/helpers'; import { getEmptyTagValue } from '../../../../common/components/empty_value'; -import { RuleSnoozeBadge } from '../../../components/rule_snooze_badge'; +import { RuleSnoozeBadge } from '../../../rule_management/components/rule_snooze_badge'; import { FormattedRelativePreferenceDate } from '../../../../common/components/formatted_date'; import { SecuritySolutionLinkAnchor } from '../../../../common/components/links'; import { getRuleDetailsTabUrl } from '../../../../common/components/link_to/redirect_to_detection_engine'; @@ -46,7 +46,6 @@ import { useHasActionsPrivileges } from './use_has_actions_privileges'; import { useHasMlPermissions } from './use_has_ml_permissions'; import { useRulesTableActions } from './use_rules_table_actions'; import { MlRuleWarningPopover } from './ml_rule_warning_popover'; -import * as rulesTableI18n from './translations'; export type TableColumn = EuiBasicTableColumn | EuiTableActionsColumnType; @@ -109,33 +108,15 @@ const useEnabledColumn = ({ hasCRUDPermissions, startMlJobs }: ColumnsProps): Ta }; const useRuleSnoozeColumn = (): TableColumn => { - const { - state: { rulesSnoozeSettings }, - } = useRulesTableContext(); - return useMemo( () => ({ field: 'snooze', name: i18n.COLUMN_SNOOZE, - render: (_, rule: Rule) => { - const snoozeSettings = rulesSnoozeSettings.data[rule.id]; - const { isFetching, isError } = rulesSnoozeSettings; - - return ( - - ); - }, + render: (_, rule: Rule) => , width: '100px', sortable: false, }), - [rulesSnoozeSettings] + [] ); }; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx index 86bbb7604add2..47c33f9282572 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx @@ -22,6 +22,7 @@ import { FormattedMessage } from '@kbn/i18n-react'; import type { ActionVariables } from '@kbn/triggers-actions-ui-plugin/public'; import { UseArray } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import type { Type } from '@kbn/securitysolution-io-ts-alerting-types'; +import type { RuleObjectId } from '../../../../../common/detection_engine/rule_schema'; import { isQueryRule } from '../../../../../common/detection_engine/utils'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { ResponseActionsForm } from '../../../../detection_engine/rule_response_actions/response_actions_form'; @@ -35,8 +36,10 @@ import { useKibana } from '../../../../common/lib/kibana'; import { getSchema } from './get_schema'; import * as I18n from './translations'; import { APP_UI_ID } from '../../../../../common/constants'; +import { RuleSnoozeSection } from './rule_snooze_section'; interface StepRuleActionsProps extends RuleStepProps { + ruleId?: RuleObjectId; // Rule SO's id (not ruleId) defaultValues?: ActionsStepRule | null; actionMessageParams: ActionVariables; ruleType?: Type; @@ -68,6 +71,7 @@ const DisplayActionsHeader = () => { }; const StepRuleActionsComponent: FC = ({ + ruleId, addPadding = false, defaultValues, isReadOnlyView, @@ -166,9 +170,9 @@ const StepRuleActionsComponent: FC = ({ return application.capabilities.actions.show ? ( <> + {ruleId && } {displayActionsOptions} {responseActionsEnabled && displayResponseActionsOptions} - @@ -178,6 +182,7 @@ const StepRuleActionsComponent: FC = ({ ); }, [ + ruleId, application.capabilities.actions.show, displayActionsOptions, displayResponseActionsOptions, diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/rule_snooze_section.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/rule_snooze_section.tsx new file mode 100644 index 0000000000000..d9586f80f3e93 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/rule_snooze_section.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { css } from '@emotion/react'; +import { EuiFlexGroup, EuiFlexItem, EuiText, useEuiTheme } from '@elastic/eui'; +import type { RuleObjectId } from '../../../../../common/detection_engine/rule_schema'; +import { RuleSnoozeBadge } from '../../../../detection_engine/rule_management/components/rule_snooze_badge'; +import * as i18n from './translations'; + +interface RuleSnoozeSectionProps { + ruleId: RuleObjectId; // Rule SO's id (not ruleId) +} + +export function RuleSnoozeSection({ ruleId }: RuleSnoozeSectionProps): JSX.Element { + const { euiTheme } = useEuiTheme(); + + return ( +
+ {i18n.RULE_SNOOZE_DESCRIPTION} + + + + + + + {i18n.SNOOZED_ACTIONS_WARNING} + + + +
+ ); +} diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/translations.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/translations.tsx index d467c3af05f8f..06368eadc30df 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/translations.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/translations.tsx @@ -28,3 +28,18 @@ export const NO_ACTIONS_READ_PERMISSIONS = i18n.translate( 'Cannot create rule actions. You do not have "Read" permissions for the "Actions" plugin.', } ); + +export const RULE_SNOOZE_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepRuleActions.snoozeDescription', + { + defaultMessage: + 'Select when automated actions should be performed if a rule evaluates as true.', + } +); + +export const SNOOZED_ACTIONS_WARNING = i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepRuleActions.snoozedActionsWarning', + { + defaultMessage: 'Actions will not be preformed until it is unsnoozed.', + } +); From 460e9a707cfe1db8e818ef8ba553636996eed985 Mon Sep 17 00:00:00 2001 From: jennypavlova Date: Tue, 25 Apr 2023 12:56:43 +0200 Subject: [PATCH 04/29] [Infrastructure UI] Telemetry: Host view flyout - Add / Remove filter (#155511) Closes [#155415](https://github.com/elastic/kibana/issues/155415) ## Summary This PR adds telemetry for the flyout add/remove filter events # Testing - Open the hosts view flyout, select the metadata tab and add/remove a filter from there - Check in the network tab - there should be a request to the telemetry cluster including the events: image - It should be also visible in [FS](https://app.fullstory.com/ui/1397FY/home) under API events (for some reason I don't see the events made from my machine in FS at all so I couldn't check it there) --- .../cloud_full_story/server/config.ts | 2 + .../metadata/add_metadata_filter_button.tsx | 11 ++++- .../telemetry/telemetry_client.mock.ts | 2 + .../services/telemetry/telemetry_client.ts | 17 +++++++ .../services/telemetry/telemetry_events.ts | 32 +++++++++++++- .../telemetry/telemetry_service.test.ts | 44 ++++++++++++++++++- .../infra/public/services/telemetry/types.ts | 21 ++++++++- 7 files changed, 125 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/cloud_integrations/cloud_full_story/server/config.ts b/x-pack/plugins/cloud_integrations/cloud_full_story/server/config.ts index 15bcfdc53512f..85822bae819eb 100644 --- a/x-pack/plugins/cloud_integrations/cloud_full_story/server/config.ts +++ b/x-pack/plugins/cloud_integrations/cloud_full_story/server/config.ts @@ -22,6 +22,8 @@ const configSchema = schema.object({ 'Loaded Kibana', // Sent once per page refresh (potentially, once per session) 'Hosts View Query Submitted', // Worst-case scenario 1 every 2 seconds 'Host Entry Clicked', // Worst-case scenario once per second - AT RISK, + 'Host Flyout Filter Removed', // Worst-case scenario once per second - AT RISK, + 'Host Flyout Filter Added', // Worst-case scenario once per second - AT RISK, ], }), }); diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/metadata/add_metadata_filter_button.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/metadata/add_metadata_filter_button.tsx index 51277427b6352..acefcbea5e304 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/metadata/add_metadata_filter_button.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/host_details_flyout/metadata/add_metadata_filter_button.tsx @@ -37,6 +37,7 @@ export const AddMetadataFilterButton = ({ item }: AddMetadataFilterButtonProps) query: { filterManager: filterManagerService }, }, notifications: { toasts: toastsService }, + telemetry, }, } = useKibanaContextForPlugin(); @@ -53,6 +54,9 @@ export const AddMetadataFilterButton = ({ item }: AddMetadataFilterButtonProps) negate: false, }); if (newFilter) { + telemetry.reportHostFlyoutFilterAdded({ + field_name: item.name, + }); filterManagerService.addFilters(newFilter); toastsService.addSuccess({ title: filterAddedToastTitle, @@ -84,7 +88,12 @@ export const AddMetadataFilterButton = ({ item }: AddMetadataFilterButtonProps) defaultMessage: 'Filter', } )} - onClick={() => filterManagerService.removeFilter(existingFilter)} + onClick={() => { + telemetry.reportHostFlyoutFilterRemoved({ + field_name: existingFilter.meta.key!, + }); + filterManagerService.removeFilter(existingFilter); + }} /> diff --git a/x-pack/plugins/infra/public/services/telemetry/telemetry_client.mock.ts b/x-pack/plugins/infra/public/services/telemetry/telemetry_client.mock.ts index 83e8fc2420440..298424f5c9db6 100644 --- a/x-pack/plugins/infra/public/services/telemetry/telemetry_client.mock.ts +++ b/x-pack/plugins/infra/public/services/telemetry/telemetry_client.mock.ts @@ -10,4 +10,6 @@ import { ITelemetryClient } from './types'; export const createTelemetryClientMock = (): jest.Mocked => ({ reportHostEntryClicked: jest.fn(), reportHostsViewQuerySubmitted: jest.fn(), + reportHostFlyoutFilterRemoved: jest.fn(), + reportHostFlyoutFilterAdded: jest.fn(), }); diff --git a/x-pack/plugins/infra/public/services/telemetry/telemetry_client.ts b/x-pack/plugins/infra/public/services/telemetry/telemetry_client.ts index 66ee3220c2935..f53a6b298de1d 100644 --- a/x-pack/plugins/infra/public/services/telemetry/telemetry_client.ts +++ b/x-pack/plugins/infra/public/services/telemetry/telemetry_client.ts @@ -8,6 +8,7 @@ import { AnalyticsServiceSetup } from '@kbn/core-analytics-server'; import { HostEntryClickedParams, + HostFlyoutFilterActionParams, HostsViewQuerySubmittedParams, InfraTelemetryEventTypes, ITelemetryClient, @@ -30,6 +31,22 @@ export class TelemetryClient implements ITelemetryClient { }); }; + public reportHostFlyoutFilterRemoved = ({ + field_name: fieldName, + }: HostFlyoutFilterActionParams) => { + this.analytics.reportEvent(InfraTelemetryEventTypes.HOST_FLYOUT_FILTER_REMOVED, { + field_name: fieldName, + }); + }; + + public reportHostFlyoutFilterAdded = ({ + field_name: fieldName, + }: HostFlyoutFilterActionParams) => { + this.analytics.reportEvent(InfraTelemetryEventTypes.HOST_FLYOUT_FILTER_ADDED, { + field_name: fieldName, + }); + }; + public reportHostsViewQuerySubmitted = (params: HostsViewQuerySubmittedParams) => { this.analytics.reportEvent(InfraTelemetryEventTypes.HOSTS_VIEW_QUERY_SUBMITTED, params); }; diff --git a/x-pack/plugins/infra/public/services/telemetry/telemetry_events.ts b/x-pack/plugins/infra/public/services/telemetry/telemetry_events.ts index ee7545022d9ee..597c9c56eacfd 100644 --- a/x-pack/plugins/infra/public/services/telemetry/telemetry_events.ts +++ b/x-pack/plugins/infra/public/services/telemetry/telemetry_events.ts @@ -66,4 +66,34 @@ const hostsEntryClickedEvent: InfraTelemetryEvent = { }, }; -export const infraTelemetryEvents = [hostsViewQuerySubmittedEvent, hostsEntryClickedEvent]; +const hostFlyoutRemoveFilter: InfraTelemetryEvent = { + eventType: InfraTelemetryEventTypes.HOST_FLYOUT_FILTER_REMOVED, + schema: { + field_name: { + type: 'keyword', + _meta: { + description: 'Removed filter field name for the selected host.', + optional: false, + }, + }, + }, +}; +const hostFlyoutAddFilter: InfraTelemetryEvent = { + eventType: InfraTelemetryEventTypes.HOST_FLYOUT_FILTER_ADDED, + schema: { + field_name: { + type: 'keyword', + _meta: { + description: 'Added filter field name for the selected host.', + optional: false, + }, + }, + }, +}; + +export const infraTelemetryEvents = [ + hostsViewQuerySubmittedEvent, + hostsEntryClickedEvent, + hostFlyoutRemoveFilter, + hostFlyoutAddFilter, +]; diff --git a/x-pack/plugins/infra/public/services/telemetry/telemetry_service.test.ts b/x-pack/plugins/infra/public/services/telemetry/telemetry_service.test.ts index d3516fc84600b..6fdd2105ec2df 100644 --- a/x-pack/plugins/infra/public/services/telemetry/telemetry_service.test.ts +++ b/x-pack/plugins/infra/public/services/telemetry/telemetry_service.test.ts @@ -49,6 +49,8 @@ describe('TelemetryService', () => { const telemetry = service.start(); expect(telemetry).toHaveProperty('reportHostEntryClicked'); + expect(telemetry).toHaveProperty('reportHostFlyoutFilterRemoved'); + expect(telemetry).toHaveProperty('reportHostFlyoutFilterAdded'); expect(telemetry).toHaveProperty('reportHostsViewQuerySubmitted'); }); }); @@ -74,7 +76,7 @@ describe('TelemetryService', () => { ); }); - it('should report hosts entry click with cloud provider equal to "unknow" if not exist', async () => { + it('should report hosts entry click with cloud provider equal to "unknown" if not exist', async () => { const setupParams = getSetupParams(); service.setup(setupParams); const telemetry = service.start(); @@ -119,4 +121,44 @@ describe('TelemetryService', () => { ); }); }); + + describe('#reportHostFlyoutFilterRemoved', () => { + it('should report Host Flyout Filter Removed click with field name', async () => { + const setupParams = getSetupParams(); + service.setup(setupParams); + const telemetry = service.start(); + + telemetry.reportHostFlyoutFilterRemoved({ + field_name: 'agent.version', + }); + + expect(setupParams.analytics.reportEvent).toHaveBeenCalledTimes(1); + expect(setupParams.analytics.reportEvent).toHaveBeenCalledWith( + InfraTelemetryEventTypes.HOST_FLYOUT_FILTER_REMOVED, + { + field_name: 'agent.version', + } + ); + }); + }); + + describe('#reportHostFlyoutFilterAdded', () => { + it('should report Host Flyout Filter Added click with field name', async () => { + const setupParams = getSetupParams(); + service.setup(setupParams); + const telemetry = service.start(); + + telemetry.reportHostFlyoutFilterAdded({ + field_name: 'agent.version', + }); + + expect(setupParams.analytics.reportEvent).toHaveBeenCalledTimes(1); + expect(setupParams.analytics.reportEvent).toHaveBeenCalledWith( + InfraTelemetryEventTypes.HOST_FLYOUT_FILTER_ADDED, + { + field_name: 'agent.version', + } + ); + }); + }); }); diff --git a/x-pack/plugins/infra/public/services/telemetry/types.ts b/x-pack/plugins/infra/public/services/telemetry/types.ts index a24f64e4c5f4a..f0f64ff00c918 100644 --- a/x-pack/plugins/infra/public/services/telemetry/types.ts +++ b/x-pack/plugins/infra/public/services/telemetry/types.ts @@ -15,6 +15,8 @@ export interface TelemetryServiceSetupParams { export enum InfraTelemetryEventTypes { HOSTS_VIEW_QUERY_SUBMITTED = 'Hosts View Query Submitted', HOSTS_ENTRY_CLICKED = 'Host Entry Clicked', + HOST_FLYOUT_FILTER_REMOVED = 'Host Flyout Filter Removed', + HOST_FLYOUT_FILTER_ADDED = 'Host Flyout Filter Added', } export interface HostsViewQuerySubmittedParams { @@ -29,10 +31,19 @@ export interface HostEntryClickedParams { cloud_provider?: string | null; } -export type InfraTelemetryEventParams = HostsViewQuerySubmittedParams | HostEntryClickedParams; +export interface HostFlyoutFilterActionParams { + field_name: string; +} + +export type InfraTelemetryEventParams = + | HostsViewQuerySubmittedParams + | HostEntryClickedParams + | HostFlyoutFilterActionParams; export interface ITelemetryClient { reportHostEntryClicked(params: HostEntryClickedParams): void; + reportHostFlyoutFilterRemoved(params: HostFlyoutFilterActionParams): void; + reportHostFlyoutFilterAdded(params: HostFlyoutFilterActionParams): void; reportHostsViewQuerySubmitted(params: HostsViewQuerySubmittedParams): void; } @@ -41,6 +52,14 @@ export type InfraTelemetryEvent = eventType: InfraTelemetryEventTypes.HOSTS_VIEW_QUERY_SUBMITTED; schema: RootSchema; } + | { + eventType: InfraTelemetryEventTypes.HOST_FLYOUT_FILTER_ADDED; + schema: RootSchema; + } + | { + eventType: InfraTelemetryEventTypes.HOST_FLYOUT_FILTER_REMOVED; + schema: RootSchema; + } | { eventType: InfraTelemetryEventTypes.HOSTS_ENTRY_CLICKED; schema: RootSchema; From b0c7c2d2899dc8aaa94ba3221eadf36b881f1645 Mon Sep 17 00:00:00 2001 From: Ignacio Rivas Date: Tue, 25 Apr 2023 13:06:32 +0200 Subject: [PATCH 05/29] [Ingest Pipelines] Allow inference processor for all licenses (#155689) --- .../components/shared/map_processor_type_to_form.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx index 60f66dbb415f3..6d232ba70557d 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx @@ -496,7 +496,6 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { }, inference: { FieldsComponent: Inference, - forLicenseAtLeast: 'platinum', docLinkPath: '/inference-processor.html', label: i18n.translate('xpack.ingestPipelines.processors.label.inference', { defaultMessage: 'Inference', From 880d1f275c644faab1336e90182d47e221cc6ba0 Mon Sep 17 00:00:00 2001 From: Florian Lehner Date: Tue, 25 Apr 2023 13:43:13 +0200 Subject: [PATCH 06/29] [FLEET] Add profiler_symbolizer (#155585) ## Summary Add the package profiler_symbolizer to the list of packages that are bundled with kibana. Bundling the profiler_symbolizer package with kibana is required to install the package in the Integration Server environment. ### For maintainers - [x] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) Signed-off-by: Florian Lehner Co-authored-by: Julia Bardi <90178898+juliaElastic@users.noreply.github.com> --- fleet_packages.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/fleet_packages.json b/fleet_packages.json index f9734cdd91380..eef982351e455 100644 --- a/fleet_packages.json +++ b/fleet_packages.json @@ -40,6 +40,12 @@ "name": "fleet_server", "version": "1.3.0" }, + { + "name": "profiler_symbolizer", + "version": "8.8.0-preview", + "forceAlignStackVersion": true, + "allowSyncToPrerelease": true + }, { "name": "synthetics", "version": "0.12.1" From 7761e2716be2b1c3a30d533e25b3ff24acac6bb3 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Tue, 25 Apr 2023 07:43:54 -0400 Subject: [PATCH 07/29] chore(slo): unset ecs usage for burn rate rule (#155466) --- .../observability/server/lib/rules/slo_burn_rate/register.ts | 2 +- x-pack/test/api_integration/apis/maps/maps_telemetry.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/observability/server/lib/rules/slo_burn_rate/register.ts b/x-pack/plugins/observability/server/lib/rules/slo_burn_rate/register.ts index 91c0b9f8c8ffd..6f61c5f6277f2 100644 --- a/x-pack/plugins/observability/server/lib/rules/slo_burn_rate/register.ts +++ b/x-pack/plugins/observability/server/lib/rules/slo_burn_rate/register.ts @@ -65,7 +65,7 @@ export function sloBurnRateRuleType( alerts: { context: SLO_RULE_REGISTRATION_CONTEXT, mappings: { fieldMap: { ...legacyExperimentalFieldMap, ...sloRuleFieldMap } }, - useEcs: true, + useEcs: false, useLegacyAlerts: true, }, }; diff --git a/x-pack/test/api_integration/apis/maps/maps_telemetry.ts b/x-pack/test/api_integration/apis/maps/maps_telemetry.ts index c953aff7000c5..2a055ce007fee 100644 --- a/x-pack/test/api_integration/apis/maps/maps_telemetry.ts +++ b/x-pack/test/api_integration/apis/maps/maps_telemetry.ts @@ -30,8 +30,8 @@ export default function ({ getService }: FtrProviderContext) { return fieldStat.name === 'geo_point'; } ); - expect(geoPointFieldStats.count).to.be(39); - expect(geoPointFieldStats.index_count).to.be(10); + expect(geoPointFieldStats.count).to.be(31); + expect(geoPointFieldStats.index_count).to.be(9); const geoShapeFieldStats = apiResponse.cluster_stats.indices.mappings.field_types.find( (fieldStat: estypes.ClusterStatsFieldTypes) => { From f1c18d940d1edf372baa86b3950ff9656ff1c424 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Tue, 25 Apr 2023 13:01:03 +0100 Subject: [PATCH 08/29] [Lens] Test field formatters for keyword fields (#155491) ## Summary Part of https://github.com/elastic/kibana/issues/147428 Adds field formatters test for keyword fields [Flaky runner 50 times https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/2155 ](https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/2157) ### Checklist - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Marco Liberati --- test/functional/services/field_editor.ts | 42 ++++++ .../apps/lens/group1/field_formatters.ts | 122 ++++++++++++++++++ .../test/functional/apps/lens/group1/index.ts | 1 + .../test/functional/page_objects/lens_page.ts | 12 ++ x-pack/test/tsconfig.json | 1 + 5 files changed, 178 insertions(+) create mode 100644 x-pack/test/functional/apps/lens/group1/field_formatters.ts diff --git a/test/functional/services/field_editor.ts b/test/functional/services/field_editor.ts index 68fc3707ca3a9..439eb289ec95f 100644 --- a/test/functional/services/field_editor.ts +++ b/test/functional/services/field_editor.ts @@ -12,6 +12,7 @@ export class FieldEditorService extends FtrService { private readonly browser = this.ctx.getService('browser'); private readonly testSubjects = this.ctx.getService('testSubjects'); private readonly retry = this.ctx.getService('retry'); + private readonly find = this.ctx.getService('find'); public async setName(name: string, clearFirst = false, typeCharByChar = false) { await this.testSubjects.setValue('nameField > input', name, { @@ -50,6 +51,47 @@ export class FieldEditorService extends FtrService { await this.testSubjects.click('fieldSaveButton'); } + async setUrlFieldFormat(template: string) { + const urlTemplateField = await this.find.byCssSelector( + 'input[data-test-subj="urlEditorUrlTemplate"]' + ); + await urlTemplateField.type(template); + } + + public async setStaticLookupFormat(oldValue: string, newValue: string) { + await this.testSubjects.click('staticLookupEditorAddEntry'); + await this.testSubjects.setValue('~staticLookupEditorKey', oldValue); + await this.testSubjects.setValue('~staticLookupEditorValue', newValue); + } + + public async setColorFormat(value: string, color: string, backgroundColor?: string) { + await this.testSubjects.click('colorEditorAddColor'); + await this.testSubjects.setValue('~colorEditorKeyPattern', value); + await this.testSubjects.setValue('~colorEditorColorPicker', color); + if (backgroundColor) { + await this.testSubjects.setValue('~colorEditorBackgroundPicker', backgroundColor); + } + } + + public async setStringFormat(transform: string) { + await this.testSubjects.selectValue('stringEditorTransform', transform); + } + + public async setTruncateFormatLength(length: string) { + await this.testSubjects.setValue('truncateEditorLength', length); + } + + public async setFieldFormat(format: string) { + await this.find.clickByCssSelector( + 'select[data-test-subj="editorSelectedFormatId"] > option[value="' + format + '"]' + ); + } + + public async setFormat(format: string) { + await this.testSubjects.setEuiSwitch('formatRow > toggle', 'check'); + await this.setFieldFormat(format); + } + public async confirmSave() { await this.retry.try(async () => { await this.testSubjects.setValue('saveModalConfirmText', 'change'); diff --git a/x-pack/test/functional/apps/lens/group1/field_formatters.ts b/x-pack/test/functional/apps/lens/group1/field_formatters.ts new file mode 100644 index 0000000000000..855515bd3d2ce --- /dev/null +++ b/x-pack/test/functional/apps/lens/group1/field_formatters.ts @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FIELD_FORMAT_IDS } from '@kbn/field-formats-plugin/common'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects([ + 'visualize', + 'lens', + 'header', + 'dashboard', + 'common', + 'settings', + ]); + const retry = getService('retry'); + const fieldEditor = getService('fieldEditor'); + + describe('lens fields formatters tests', () => { + before(async () => { + await PageObjects.visualize.navigateToNewVisualization(); + await PageObjects.visualize.clickVisType('lens'); + await PageObjects.lens.goToTimeRange(); + await PageObjects.lens.switchToVisualization('lnsDatatable'); + }); + + afterEach(async () => { + await PageObjects.lens.clickField('runtimefield'); + await PageObjects.lens.removeField('runtimefield'); + await fieldEditor.confirmDelete(); + await PageObjects.lens.waitForFieldMissing('runtimefield'); + }); + + it('should display url formatter correctly', async () => { + await retry.try(async () => { + await PageObjects.lens.clickAddField(); + await fieldEditor.setName('runtimefield'); + await fieldEditor.enableValue(); + await fieldEditor.typeScript("emit(doc['geo.dest'].value)"); + await fieldEditor.setFormat(FIELD_FORMAT_IDS.URL); + await fieldEditor.setUrlFieldFormat('https://www.elastic.co?{{value}}'); + await fieldEditor.save(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.lens.searchField('runtime'); + await PageObjects.lens.waitForField('runtimefield'); + await PageObjects.lens.dragFieldToWorkspace('runtimefield'); + }); + await PageObjects.lens.waitForVisualization(); + expect(await PageObjects.lens.getDatatableHeaderText(0)).to.equal( + 'Top 5 values of runtimefield' + ); + expect(await PageObjects.lens.getDatatableCellText(0, 0)).to.eql('https://www.elastic.co?CN'); + }); + + it('should display static lookup formatter correctly', async () => { + await retry.try(async () => { + await PageObjects.lens.clickAddField(); + await fieldEditor.setName('runtimefield'); + await fieldEditor.enableValue(); + await fieldEditor.typeScript("emit(doc['geo.dest'].value)"); + await fieldEditor.setFormat(FIELD_FORMAT_IDS.STATIC_LOOKUP); + await fieldEditor.setStaticLookupFormat('CN', 'China'); + await fieldEditor.save(); + await PageObjects.header.waitUntilLoadingHasFinished(); + }); + await PageObjects.lens.waitForVisualization(); + expect(await PageObjects.lens.getDatatableCellText(0, 0)).to.eql('China'); + }); + + it('should display color formatter correctly', async () => { + await retry.try(async () => { + await PageObjects.lens.clickAddField(); + await fieldEditor.setName('runtimefield'); + await fieldEditor.enableValue(); + await fieldEditor.typeScript("emit(doc['geo.dest'].value)"); + await fieldEditor.setFormat(FIELD_FORMAT_IDS.COLOR); + await fieldEditor.setColorFormat('CN', '#ffffff', '#ff0000'); + await fieldEditor.save(); + await PageObjects.header.waitUntilLoadingHasFinished(); + }); + await PageObjects.lens.waitForVisualization(); + const styleObj = await PageObjects.lens.getDatatableCellSpanStyle(0, 0); + expect(styleObj['background-color']).to.be('rgb(255, 0, 0)'); + expect(styleObj.color).to.be('rgb(255, 255, 255)'); + }); + + it('should display string formatter correctly', async () => { + await retry.try(async () => { + await PageObjects.lens.clickAddField(); + await fieldEditor.setName('runtimefield'); + await fieldEditor.enableValue(); + await fieldEditor.typeScript("emit(doc['geo.dest'].value)"); + await fieldEditor.setFormat(FIELD_FORMAT_IDS.STRING); + await fieldEditor.setStringFormat('lower'); + await fieldEditor.save(); + await PageObjects.header.waitUntilLoadingHasFinished(); + }); + await PageObjects.lens.waitForVisualization(); + expect(await PageObjects.lens.getDatatableCellText(0, 0)).to.eql('cn'); + }); + + it('should display truncate string formatter correctly', async () => { + await retry.try(async () => { + await PageObjects.lens.clickAddField(); + await fieldEditor.setName('runtimefield'); + await fieldEditor.enableValue(); + await fieldEditor.typeScript("emit(doc['links.raw'].value)"); + await fieldEditor.setFormat(FIELD_FORMAT_IDS.TRUNCATE); + await fieldEditor.setTruncateFormatLength('3'); + await fieldEditor.save(); + await PageObjects.header.waitUntilLoadingHasFinished(); + }); + await PageObjects.lens.waitForVisualization(); + expect(await PageObjects.lens.getDatatableCellText(0, 0)).to.eql('dal...'); + }); + }); +} diff --git a/x-pack/test/functional/apps/lens/group1/index.ts b/x-pack/test/functional/apps/lens/group1/index.ts index 03f4a4032154e..a129a66c519d9 100644 --- a/x-pack/test/functional/apps/lens/group1/index.ts +++ b/x-pack/test/functional/apps/lens/group1/index.ts @@ -83,6 +83,7 @@ export default ({ getService, loadTestFile, getPageObjects }: FtrProviderContext loadTestFile(require.resolve('./text_based_languages')); loadTestFile(require.resolve('./fields_list')); loadTestFile(require.resolve('./layer_actions')); + loadTestFile(require.resolve('./field_formatters')); } }); }; diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index acd78cb0da0ea..6db18af0abeaa 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -1045,6 +1045,18 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont }, {}); }, + async getDatatableCellSpanStyle(rowIndex = 0, colIndex = 0) { + const el = await (await this.getDatatableCell(rowIndex, colIndex)).findByCssSelector('span'); + const styleString = await el.getAttribute('style'); + return styleString.split(';').reduce>((memo, cssLine) => { + const [prop, value] = cssLine.split(':'); + if (prop && value) { + memo[prop.trim()] = value.trim(); + } + return memo; + }, {}); + }, + async getCountOfDatatableColumns() { const table = await find.byCssSelector('.euiDataGrid'); const $ = await table.parseDomContent(); diff --git a/x-pack/test/tsconfig.json b/x-pack/test/tsconfig.json index 2e92bd5f38c3d..a11346427456f 100644 --- a/x-pack/test/tsconfig.json +++ b/x-pack/test/tsconfig.json @@ -122,5 +122,6 @@ "@kbn/securitysolution-io-ts-alerting-types", "@kbn/alerting-state-types", "@kbn/assetManager-plugin", + "@kbn/field-formats-plugin", ] } From c3ccede36ede1d514c1b99a433d9a3f0e3e128a9 Mon Sep 17 00:00:00 2001 From: Dominique Clarke Date: Tue, 25 Apr 2023 08:03:10 -0400 Subject: [PATCH 09/29] [Synthetics] Adds additional heartbeat configuration options to the Synthetics app (#154390) ## Summary Closes https://github.com/elastic/kibana/issues/140864 Relates to https://github.com/elastic/synthetics-dev/issues/159 Relates to https://github.com/elastic/synthetics/issues/655 Adds missing heartbeat configs to the Synthetics app. The additional configs are specified below: HTTP (proxy_headers, mode, ipv4, ipv6, response.include_body_max_bytes, check.response.json) ![image](https://user-images.githubusercontent.com/11356435/231040397-4f80d3af-ff08-4ef1-bbd9-c49f63d29bd0.png) ![image](https://user-images.githubusercontent.com/11356435/231040371-b7baa693-d573-46e1-b0f1-6d21c32522b8.png) TCP (mode, ipv4, ivp6) ![image](https://user-images.githubusercontent.com/11356435/231040065-ad865160-7f6c-4450-ab1c-98c17aedd3f0.png) ICMP (mode, ipv4, ipv6) ![image](https://user-images.githubusercontent.com/11356435/231039925-d2a2e9ab-69aa-4d74-8c3e-91223dd963d1.png) ### Testing 1. Create a private location 2. Create an http monitor selecting both a public and private location, adjusting the settings for mode, ipv4, ipv6, response.include_body_max_bytes, check.response.json and proxy headers. 3. Navigate to the edit page for that monitor, ensure the configuration above was saved to the monitor settings 4. Create an icmp monitor selecting both a public and private location, adjusting the settings for mode, ipv6, and ipv4. 5. Navigate to the dit page for that monitor, ensure the configuration above was saved to the monitor settings 6. Create an tcp monitor selecting both a public and private location, adjusting the settings for mode, ipv6, and ipv4. 7. Navigate to the dit page for that monitor, ensure the configuration above was saved to the monitor settings 8. Navigate to the agent policy for the private location selected. Ensure that the configuration options are represented on the the individual integration policies --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: florent-leborgne Co-authored-by: Shahzad --- .../common/constants/monitor_defaults.ts | 19 +- .../common/constants/monitor_management.ts | 8 + .../common/formatters/http/formatters.ts | 6 + .../common/formatters/icmp/formatters.ts | 3 + .../common/formatters/tcp/formatters.ts | 3 + .../monitor_management/monitor_configs.ts | 19 +- .../monitor_management/monitor_types.ts | 76 ++++-- .../fields/header_field.test.tsx | 6 +- .../monitor_add_edit/fields/header_field.tsx | 14 +- .../fields/key_value_field.tsx | 16 +- .../fields/request_body_field.test.tsx | 6 +- .../fields/request_body_field.tsx | 65 +++--- .../monitor_add_edit/form/field.tsx | 10 +- .../monitor_add_edit/form/field_config.tsx | 221 ++++++++++++++++-- .../monitor_add_edit/form/field_wrappers.tsx | 14 ++ .../monitor_add_edit/form/form_config.tsx | 26 ++- .../components/monitor_add_edit/types.ts | 13 +- .../migrations/monitors/8.8.0.ts | 2 +- .../monitor_cruds/monitor_validation.test.ts | 4 +- .../formatters/format_configs.test.ts | 32 ++- .../synthetics_service/formatters/http.ts | 2 + .../normalizers/http_monitor.ts | 13 +- .../add_monitor_private_location.ts | 6 +- .../apis/synthetics/add_monitor_project.ts | 39 +++- .../synthetics/add_monitor_project_legacy.ts | 34 ++- .../apis/synthetics/delete_monitor.ts | 2 +- .../apis/synthetics/delete_monitor_project.ts | 2 +- .../apis/synthetics/get_monitor_project.ts | 2 +- .../sample_data/test_browser_policy.ts | 24 +- .../synthetics/sample_data/test_policy.ts | 27 ++- .../test_project_monitor_policy.ts | 24 +- .../apis/synthetics/sync_global_params.ts | 2 +- .../uptime/rest/fixtures/http_monitor.json | 8 +- .../uptime/rest/fixtures/icmp_monitor.json | 5 +- .../rest/fixtures/project_http_monitor.json | 6 +- .../uptime/rest/fixtures/tcp_monitor.json | 5 +- 36 files changed, 613 insertions(+), 151 deletions(-) diff --git a/x-pack/plugins/synthetics/common/constants/monitor_defaults.ts b/x-pack/plugins/synthetics/common/constants/monitor_defaults.ts index adfa002e8e20a..23ceb20ad75d6 100644 --- a/x-pack/plugins/synthetics/common/constants/monitor_defaults.ts +++ b/x-pack/plugins/synthetics/common/constants/monitor_defaults.ts @@ -6,6 +6,7 @@ */ import { i18n } from '@kbn/i18n'; import { + CodeEditorMode, BrowserAdvancedFields, BrowserSimpleFields, CommonFields, @@ -198,19 +199,25 @@ export const DEFAULT_HTTP_SIMPLE_FIELDS: HTTPSimpleFields = { export const DEFAULT_HTTP_ADVANCED_FIELDS: HTTPAdvancedFields = { [ConfigKey.PASSWORD]: '', [ConfigKey.PROXY_URL]: '', + [ConfigKey.PROXY_HEADERS]: {}, [ConfigKey.RESPONSE_BODY_CHECK_NEGATIVE]: [], [ConfigKey.RESPONSE_BODY_CHECK_POSITIVE]: [], + [ConfigKey.RESPONSE_JSON_CHECK]: [], [ConfigKey.RESPONSE_BODY_INDEX]: ResponseBodyIndexPolicy.ON_ERROR, [ConfigKey.RESPONSE_HEADERS_CHECK]: {}, [ConfigKey.RESPONSE_HEADERS_INDEX]: true, [ConfigKey.RESPONSE_STATUS_CHECK]: [], [ConfigKey.REQUEST_BODY_CHECK]: { value: '', - type: Mode.PLAINTEXT, + type: CodeEditorMode.PLAINTEXT, }, [ConfigKey.REQUEST_HEADERS_CHECK]: {}, [ConfigKey.REQUEST_METHOD_CHECK]: HTTPMethod.GET, [ConfigKey.USERNAME]: '', + [ConfigKey.MODE]: Mode.ANY, + [ConfigKey.RESPONSE_BODY_MAX_BYTES]: '1024', + [ConfigKey.IPV4]: true, + [ConfigKey.IPV6]: true, }; export const DEFAULT_ICMP_SIMPLE_FIELDS: ICMPSimpleFields = { @@ -238,6 +245,15 @@ export const DEFAULT_TCP_ADVANCED_FIELDS: TCPAdvancedFields = { [ConfigKey.PROXY_USE_LOCAL_RESOLVER]: false, [ConfigKey.RESPONSE_RECEIVE_CHECK]: '', [ConfigKey.REQUEST_SEND_CHECK]: '', + [ConfigKey.MODE]: Mode.ANY, + [ConfigKey.IPV4]: true, + [ConfigKey.IPV6]: true, +}; + +export const DEFAULT_ICMP_ADVANCED_FIELDS = { + [ConfigKey.MODE]: Mode.ANY, + [ConfigKey.IPV4]: true, + [ConfigKey.IPV6]: true, }; export const DEFAULT_TLS_FIELDS: TLSFields = { @@ -262,6 +278,7 @@ export const DEFAULT_FIELDS: MonitorDefaults = { }, [DataStream.ICMP]: { ...DEFAULT_ICMP_SIMPLE_FIELDS, + ...DEFAULT_ICMP_ADVANCED_FIELDS, }, [DataStream.BROWSER]: { ...DEFAULT_BROWSER_SIMPLE_FIELDS, diff --git a/x-pack/plugins/synthetics/common/constants/monitor_management.ts b/x-pack/plugins/synthetics/common/constants/monitor_management.ts index 01da8ebce5d43..be3802f132a58 100644 --- a/x-pack/plugins/synthetics/common/constants/monitor_management.ts +++ b/x-pack/plugins/synthetics/common/constants/monitor_management.ts @@ -27,6 +27,7 @@ export enum ConfigKey { JOURNEY_ID = 'journey_id', MAX_REDIRECTS = 'max_redirects', METADATA = '__ui', + MODE = 'mode', MONITOR_TYPE = 'type', NAME = 'name', NAMESPACE = 'namespace', @@ -37,12 +38,15 @@ export enum ConfigKey { ORIGINAL_SPACE = 'original_space', // the original space the montior was saved in. Used by push monitors to ensure uniqueness of monitor id sent to heartbeat and prevent data collisions PORT = 'url.port', PROXY_URL = 'proxy_url', + PROXY_HEADERS = 'proxy_headers', PROXY_USE_LOCAL_RESOLVER = 'proxy_use_local_resolver', RESPONSE_BODY_CHECK_NEGATIVE = 'check.response.body.negative', RESPONSE_BODY_CHECK_POSITIVE = 'check.response.body.positive', + RESPONSE_JSON_CHECK = 'check.response.json', RESPONSE_BODY_INDEX = 'response.include_body', RESPONSE_HEADERS_CHECK = 'check.response.headers', RESPONSE_HEADERS_INDEX = 'response.include_headers', + RESPONSE_BODY_MAX_BYTES = 'response.include_body_max_bytes', RESPONSE_RECEIVE_CHECK = 'check.receive', RESPONSE_STATUS_CHECK = 'check.response.status', REQUEST_BODY_CHECK = 'check.request.body', @@ -54,6 +58,8 @@ export enum ConfigKey { SCREENSHOTS = 'screenshots', SOURCE_PROJECT_CONTENT = 'source.project.content', SOURCE_INLINE = 'source.inline.script', + IPV4 = 'ipv4', + IPV6 = 'ipv6', PROJECT_ID = 'project_id', SYNTHETICS_ARGS = 'synthetics_args', TEXT_ASSERTION = 'playwright_text_assertion', @@ -73,6 +79,7 @@ export enum ConfigKey { } export const secretKeys = [ + ConfigKey.PROXY_HEADERS, ConfigKey.PARAMS, ConfigKey.PASSWORD, ConfigKey.REQUEST_BODY_CHECK, @@ -80,6 +87,7 @@ export const secretKeys = [ ConfigKey.REQUEST_SEND_CHECK, ConfigKey.RESPONSE_BODY_CHECK_NEGATIVE, ConfigKey.RESPONSE_BODY_CHECK_POSITIVE, + ConfigKey.RESPONSE_JSON_CHECK, ConfigKey.RESPONSE_HEADERS_CHECK, ConfigKey.RESPONSE_RECEIVE_CHECK, ConfigKey.SOURCE_INLINE, diff --git a/x-pack/plugins/synthetics/common/formatters/http/formatters.ts b/x-pack/plugins/synthetics/common/formatters/http/formatters.ts index 300e4f9fdb9ff..437112939f283 100644 --- a/x-pack/plugins/synthetics/common/formatters/http/formatters.ts +++ b/x-pack/plugins/synthetics/common/formatters/http/formatters.ts @@ -23,9 +23,11 @@ export const httpFormatters: HTTPFormatMap = { [ConfigKey.USERNAME]: null, [ConfigKey.PASSWORD]: null, [ConfigKey.PROXY_URL]: null, + [ConfigKey.PROXY_HEADERS]: objectToJsonFormatter, [ConfigKey.PORT]: null, [ConfigKey.RESPONSE_BODY_CHECK_NEGATIVE]: arrayToJsonFormatter, [ConfigKey.RESPONSE_BODY_CHECK_POSITIVE]: arrayToJsonFormatter, + [ConfigKey.RESPONSE_JSON_CHECK]: arrayToJsonFormatter, [ConfigKey.RESPONSE_HEADERS_CHECK]: objectToJsonFormatter, [ConfigKey.RESPONSE_STATUS_CHECK]: arrayToJsonFormatter, [ConfigKey.REQUEST_HEADERS_CHECK]: objectToJsonFormatter, @@ -33,6 +35,10 @@ export const httpFormatters: HTTPFormatMap = { fields[ConfigKey.REQUEST_BODY_CHECK]?.value ? JSON.stringify(fields[ConfigKey.REQUEST_BODY_CHECK]?.value) : null, + [ConfigKey.RESPONSE_BODY_MAX_BYTES]: null, + [ConfigKey.MODE]: null, + [ConfigKey.IPV4]: null, + [ConfigKey.IPV6]: null, ...tlsFormatters, ...commonFormatters, }; diff --git a/x-pack/plugins/synthetics/common/formatters/icmp/formatters.ts b/x-pack/plugins/synthetics/common/formatters/icmp/formatters.ts index 0ebf69db5b408..f58e15c86b3ad 100644 --- a/x-pack/plugins/synthetics/common/formatters/icmp/formatters.ts +++ b/x-pack/plugins/synthetics/common/formatters/icmp/formatters.ts @@ -15,5 +15,8 @@ export type ICMPFormatMap = Record; export const icmpFormatters: ICMPFormatMap = { [ConfigKey.HOSTS]: null, [ConfigKey.WAIT]: secondsToCronFormatter, + [ConfigKey.MODE]: null, + [ConfigKey.IPV4]: null, + [ConfigKey.IPV6]: null, ...commonFormatters, }; diff --git a/x-pack/plugins/synthetics/common/formatters/tcp/formatters.ts b/x-pack/plugins/synthetics/common/formatters/tcp/formatters.ts index 6acb9abe21877..2d850e95ceaf1 100644 --- a/x-pack/plugins/synthetics/common/formatters/tcp/formatters.ts +++ b/x-pack/plugins/synthetics/common/formatters/tcp/formatters.ts @@ -23,6 +23,9 @@ export const tcpFormatters: TCPFormatMap = { [ConfigKey.PROXY_URL]: null, [ConfigKey.PORT]: null, [ConfigKey.URLS]: null, + [ConfigKey.MODE]: null, + [ConfigKey.IPV4]: null, + [ConfigKey.IPV6]: null, ...tlsFormatters, ...commonFormatters, }; diff --git a/x-pack/plugins/synthetics/common/runtime_types/monitor_management/monitor_configs.ts b/x-pack/plugins/synthetics/common/runtime_types/monitor_management/monitor_configs.ts index e616d887df4f7..dcd6b18974da4 100644 --- a/x-pack/plugins/synthetics/common/runtime_types/monitor_management/monitor_configs.ts +++ b/x-pack/plugins/synthetics/common/runtime_types/monitor_management/monitor_configs.ts @@ -54,15 +54,15 @@ export const MonacoEditorLangIdCodec = tEnum( ); export type MonacoEditorLangIdType = t.TypeOf; -export enum Mode { +export enum CodeEditorMode { FORM = 'form', JSON = 'json', PLAINTEXT = 'text', XML = 'xml', } -export const ModeCodec = tEnum('Mode', Mode); -export type ModeType = t.TypeOf; +export const CodeEditorModeCodec = tEnum('CodeEditorMode', CodeEditorMode); +export type CodeEditorModeType = t.TypeOf; export enum ContentType { JSON = 'application/json', @@ -127,3 +127,16 @@ export enum FormMonitorType { } export const FormMonitorTypeCodec = tEnum('FormMonitorType', FormMonitorType); + +export enum Mode { + ANY = 'any', + ALL = 'all', +} +export const ModeCodec = tEnum('Mode', Mode); +export type ModeType = t.TypeOf; + +export const ResponseCheckJSONCodec = t.interface({ + description: t.string, + expression: t.string, +}); +export type ResponseCheckJSON = t.TypeOf; diff --git a/x-pack/plugins/synthetics/common/runtime_types/monitor_management/monitor_types.ts b/x-pack/plugins/synthetics/common/runtime_types/monitor_management/monitor_types.ts index 97363987afcc4..d5a8d26568633 100644 --- a/x-pack/plugins/synthetics/common/runtime_types/monitor_management/monitor_types.ts +++ b/x-pack/plugins/synthetics/common/runtime_types/monitor_management/monitor_types.ts @@ -11,11 +11,13 @@ import { secretKeys } from '../../constants/monitor_management'; import { ConfigKey } from './config_key'; import { MonitorServiceLocationCodec, ServiceLocationErrors } from './locations'; import { + CodeEditorModeCodec, DataStream, DataStreamCodec, FormMonitorTypeCodec, ModeCodec, ResponseBodyIndexPolicyCodec, + ResponseCheckJSONCodec, ScheduleUnitCodec, SourceTypeCodec, TLSVersionCodec, @@ -94,10 +96,17 @@ export const TCPSimpleFieldsCodec = t.intersection([ export type TCPSimpleFields = t.TypeOf; // TCPAdvancedFields -export const TCPAdvancedFieldsCodec = t.interface({ - [ConfigKey.PROXY_URL]: t.string, - [ConfigKey.PROXY_USE_LOCAL_RESOLVER]: t.boolean, -}); +export const TCPAdvancedFieldsCodec = t.intersection([ + t.interface({ + [ConfigKey.PROXY_URL]: t.string, + [ConfigKey.PROXY_USE_LOCAL_RESOLVER]: t.boolean, + }), + t.partial({ + [ConfigKey.MODE]: ModeCodec, + [ConfigKey.IPV4]: t.boolean, + [ConfigKey.IPV6]: t.boolean, + }), +]); export const TCPSensitiveAdvancedFieldsCodec = t.interface({ [ConfigKey.RESPONSE_RECEIVE_CHECK]: t.string, @@ -136,7 +145,18 @@ export const ICMPSimpleFieldsCodec = t.intersection([ ]); export type ICMPSimpleFields = t.TypeOf; -export type ICMPFields = t.TypeOf; + +// ICMPAdvancedFields +export const ICMPAdvancedFieldsCodec = t.partial({ + [ConfigKey.MODE]: ModeCodec, + [ConfigKey.IPV4]: t.boolean, + [ConfigKey.IPV6]: t.boolean, +}); + +// ICMPFields +export const ICMPFieldsCodec = t.intersection([ICMPSimpleFieldsCodec, ICMPAdvancedFieldsCodec]); + +export type ICMPFields = t.TypeOf; // HTTPSimpleFields export const HTTPSimpleFieldsCodec = t.intersection([ @@ -152,23 +172,37 @@ export const HTTPSimpleFieldsCodec = t.intersection([ export type HTTPSimpleFields = t.TypeOf; // HTTPAdvancedFields -export const HTTPAdvancedFieldsCodec = t.interface({ - [ConfigKey.PROXY_URL]: t.string, - [ConfigKey.RESPONSE_BODY_INDEX]: ResponseBodyIndexPolicyCodec, - [ConfigKey.RESPONSE_HEADERS_INDEX]: t.boolean, - [ConfigKey.RESPONSE_STATUS_CHECK]: t.array(t.string), - [ConfigKey.REQUEST_METHOD_CHECK]: t.string, -}); +export const HTTPAdvancedFieldsCodec = t.intersection([ + t.interface({ + [ConfigKey.PROXY_URL]: t.string, + [ConfigKey.RESPONSE_BODY_INDEX]: ResponseBodyIndexPolicyCodec, + [ConfigKey.RESPONSE_HEADERS_INDEX]: t.boolean, + [ConfigKey.RESPONSE_STATUS_CHECK]: t.array(t.string), + [ConfigKey.REQUEST_METHOD_CHECK]: t.string, + }), + t.partial({ + [ConfigKey.MODE]: ModeCodec, + [ConfigKey.RESPONSE_BODY_MAX_BYTES]: t.string, + [ConfigKey.IPV4]: t.boolean, + [ConfigKey.IPV6]: t.boolean, + }), +]); -export const HTTPSensitiveAdvancedFieldsCodec = t.interface({ - [ConfigKey.PASSWORD]: t.string, - [ConfigKey.RESPONSE_BODY_CHECK_NEGATIVE]: t.array(t.string), - [ConfigKey.RESPONSE_BODY_CHECK_POSITIVE]: t.array(t.string), - [ConfigKey.RESPONSE_HEADERS_CHECK]: t.record(t.string, t.string), - [ConfigKey.REQUEST_BODY_CHECK]: t.interface({ value: t.string, type: ModeCodec }), - [ConfigKey.REQUEST_HEADERS_CHECK]: t.record(t.string, t.string), - [ConfigKey.USERNAME]: t.string, -}); +export const HTTPSensitiveAdvancedFieldsCodec = t.intersection([ + t.interface({ + [ConfigKey.PASSWORD]: t.string, + [ConfigKey.RESPONSE_BODY_CHECK_NEGATIVE]: t.array(t.string), + [ConfigKey.RESPONSE_BODY_CHECK_POSITIVE]: t.array(t.string), + [ConfigKey.RESPONSE_HEADERS_CHECK]: t.record(t.string, t.string), + [ConfigKey.REQUEST_BODY_CHECK]: t.interface({ value: t.string, type: CodeEditorModeCodec }), + [ConfigKey.REQUEST_HEADERS_CHECK]: t.record(t.string, t.string), + [ConfigKey.USERNAME]: t.string, + }), + t.partial({ + [ConfigKey.PROXY_HEADERS]: t.record(t.string, t.string), + [ConfigKey.RESPONSE_JSON_CHECK]: t.array(ResponseCheckJSONCodec), + }), +]); export const HTTPAdvancedCodec = t.intersection([ HTTPAdvancedFieldsCodec, diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/header_field.test.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/header_field.test.tsx index 6f920bf10d84a..c08434460aab5 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/header_field.test.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/header_field.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { fireEvent, waitFor } from '@testing-library/react'; import { render } from '../../../utils/testing/rtl_helpers'; import { HeaderField, contentTypes } from './header_field'; -import { Mode } from '../types'; +import { CodeEditorMode } from '../types'; describe('', () => { const onChange = jest.fn(); @@ -95,14 +95,14 @@ describe('', () => { }); it('handles content mode', async () => { - const contentMode: Mode = Mode.PLAINTEXT; + const contentMode: CodeEditorMode = CodeEditorMode.PLAINTEXT; render( ); await waitFor(() => { expect(onChange).toBeCalledWith({ - 'Content-Type': contentTypes[Mode.PLAINTEXT], + 'Content-Type': contentTypes[CodeEditorMode.PLAINTEXT], }); }); }); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/header_field.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/header_field.tsx index a26fe8616d90b..c3159043b9958 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/header_field.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/header_field.tsx @@ -7,12 +7,12 @@ import React, { useEffect, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { ContentType, Mode } from '../types'; +import { ContentType, CodeEditorMode } from '../types'; import { KeyValuePairsField, Pair } from './key_value_field'; export interface HeaderFieldProps { - contentMode?: Mode; + contentMode?: CodeEditorMode; defaultValue: Record; onChange: (value: Record) => void; onBlur?: () => void; @@ -72,9 +72,9 @@ export const HeaderField = ({ ); }; -export const contentTypes: Record = { - [Mode.JSON]: ContentType.JSON, - [Mode.PLAINTEXT]: ContentType.TEXT, - [Mode.XML]: ContentType.XML, - [Mode.FORM]: ContentType.FORM, +export const contentTypes: Record = { + [CodeEditorMode.JSON]: ContentType.JSON, + [CodeEditorMode.PLAINTEXT]: ContentType.TEXT, + [CodeEditorMode.XML]: ContentType.XML, + [CodeEditorMode.FORM]: ContentType.FORM, }; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/key_value_field.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/key_value_field.tsx index 95b2348fd0219..7d062fbde7548 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/key_value_field.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/key_value_field.tsx @@ -46,13 +46,15 @@ export type Pair = [ string // value ]; -interface Props { +export interface KeyValuePairsFieldProps { addPairControlLabel: string | React.ReactElement; defaultPairs: Pair[]; onChange: (pairs: Pair[]) => void; onBlur?: () => void; 'data-test-subj'?: string; readOnly?: boolean; + keyLabel?: string | React.ReactElement; + valueLabel?: string | React.ReactElement; } export const KeyValuePairsField = ({ @@ -62,7 +64,9 @@ export const KeyValuePairsField = ({ onBlur, 'data-test-subj': dataTestSubj, readOnly, -}: Props) => { + keyLabel, + valueLabel, +}: KeyValuePairsFieldProps) => { const [pairs, setPairs] = useState(defaultPairs); const handleOnChange = useCallback( @@ -121,20 +125,20 @@ export const KeyValuePairsField = ({ children: ( - { + {keyLabel || ( - } + )} - { + {valueLabel || ( - } + )} ), diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/request_body_field.test.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/request_body_field.test.tsx index a472c3231053b..d3a8bd8076fe1 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/request_body_field.test.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/request_body_field.test.tsx @@ -13,7 +13,7 @@ import { fireEvent, waitFor } from '@testing-library/react'; import { mockGlobals } from '../../../utils/testing'; import { render } from '../../../utils/testing/rtl_helpers'; import { RequestBodyField } from './request_body_field'; -import { Mode } from '../types'; +import { CodeEditorMode } from '../types'; mockGlobals(); @@ -40,7 +40,7 @@ jest.mock('@kbn/kibana-react-plugin/public', () => { }); describe('', () => { - const defaultMode = Mode.PLAINTEXT; + const defaultMode = CodeEditorMode.PLAINTEXT; const defaultValue = 'sample value'; const WrappedComponent = ({ readOnly }: { readOnly?: boolean }) => { const [config, setConfig] = useState({ @@ -55,7 +55,7 @@ describe('', () => { type: config.type, }} onChange={useCallback( - (code) => setConfig({ type: code.type as Mode, value: code.value }), + (code) => setConfig({ type: code.type as CodeEditorMode, value: code.value }), [setConfig] )} readOnly={readOnly} diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/request_body_field.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/request_body_field.tsx index fe117a4703ffb..e6877942f4f14 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/request_body_field.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/fields/request_body_field.tsx @@ -9,15 +9,15 @@ import { stringify, parse } from 'query-string'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import { EuiTabbedContent } from '@elastic/eui'; -import { Mode, MonacoEditorLangId } from '../types'; +import { CodeEditorMode, MonacoEditorLangId } from '../types'; import { KeyValuePairsField, Pair } from './key_value_field'; import { CodeEditor } from './code_editor'; export interface RequestBodyFieldProps { - onChange: (requestBody: { type: Mode; value: string }) => void; + onChange: (requestBody: { type: CodeEditorMode; value: string }) => void; onBlur?: () => void; value: { - type: Mode; + type: CodeEditorMode; value: string; }; readOnly?: boolean; @@ -36,22 +36,27 @@ export const RequestBodyField = ({ readOnly, }: RequestBodyFieldProps) => { const [values, setValues] = useState>({ - [ResponseBodyType.FORM]: type === Mode.FORM ? value : '', - [ResponseBodyType.CODE]: type !== Mode.FORM ? value : '', + [ResponseBodyType.FORM]: type === CodeEditorMode.FORM ? value : '', + [ResponseBodyType.CODE]: type !== CodeEditorMode.FORM ? value : '', }); useEffect(() => { onChange({ type, - value: type === Mode.FORM ? values[ResponseBodyType.FORM] : values[ResponseBodyType.CODE], + value: + type === CodeEditorMode.FORM + ? values[ResponseBodyType.FORM] + : values[ResponseBodyType.CODE], }); }, [onChange, type, values]); const handleSetMode = useCallback( - (currentMode: Mode) => { + (currentMode: CodeEditorMode) => { onChange({ type: currentMode, value: - currentMode === Mode.FORM ? values[ResponseBodyType.FORM] : values[ResponseBodyType.CODE], + currentMode === CodeEditorMode.FORM + ? values[ResponseBodyType.FORM] + : values[ResponseBodyType.CODE], }); }, [onChange, values] @@ -71,14 +76,14 @@ export const RequestBodyField = ({ }, {}); return setValues((prevValues) => ({ ...prevValues, - [Mode.FORM]: stringify(formattedPairs), + [CodeEditorMode.FORM]: stringify(formattedPairs), })); }, [setValues] ); const defaultFormPairs: Pair[] = useMemo(() => { - const pairs = parse(values[Mode.FORM]); + const pairs = parse(values[CodeEditorMode.FORM]); const keys = Object.keys(pairs); const formattedPairs: Pair[] = keys.map((key: string) => { // key, value, checked; @@ -89,9 +94,9 @@ export const RequestBodyField = ({ const tabs = [ { - id: Mode.PLAINTEXT, - name: modeLabels[Mode.PLAINTEXT], - 'data-test-subj': `syntheticsRequestBodyTab__${Mode.PLAINTEXT}`, + id: CodeEditorMode.PLAINTEXT, + name: modeLabels[CodeEditorMode.PLAINTEXT], + 'data-test-subj': `syntheticsRequestBodyTab__${CodeEditorMode.PLAINTEXT}`, content: ( { setValues((prevValues) => ({ ...prevValues, [ResponseBodyType.CODE]: code })); @@ -112,9 +117,9 @@ export const RequestBodyField = ({ ), }, { - id: Mode.JSON, - name: modeLabels[Mode.JSON], - 'data-test-subj': `syntheticsRequestBodyTab__${Mode.JSON}`, + id: CodeEditorMode.JSON, + name: modeLabels[CodeEditorMode.JSON], + 'data-test-subj': `syntheticsRequestBodyTab__${CodeEditorMode.JSON}`, content: ( { setValues((prevValues) => ({ ...prevValues, [ResponseBodyType.CODE]: code })); @@ -135,9 +140,9 @@ export const RequestBodyField = ({ ), }, { - id: Mode.XML, - name: modeLabels[Mode.XML], - 'data-test-subj': `syntheticsRequestBodyTab__${Mode.XML}`, + id: CodeEditorMode.XML, + name: modeLabels[CodeEditorMode.XML], + 'data-test-subj': `syntheticsRequestBodyTab__${CodeEditorMode.XML}`, content: ( { setValues((prevValues) => ({ ...prevValues, [ResponseBodyType.CODE]: code })); @@ -158,9 +163,9 @@ export const RequestBodyField = ({ ), }, { - id: Mode.FORM, - name: modeLabels[Mode.FORM], - 'data-test-subj': `syntheticsRequestBodyTab__${Mode.FORM}`, + id: CodeEditorMode.FORM, + name: modeLabels[CodeEditorMode.FORM], + 'data-test-subj': `syntheticsRequestBodyTab__${CodeEditorMode.FORM}`, content: ( tab.id === type)} autoFocus="selected" onTabClick={(tab) => { - handleSetMode(tab.id as Mode); + handleSetMode(tab.id as CodeEditorMode); }} />
@@ -195,25 +200,25 @@ export const RequestBodyField = ({ }; const modeLabels = { - [Mode.FORM]: i18n.translate( + [CodeEditorMode.FORM]: i18n.translate( 'xpack.synthetics.createPackagePolicy.stepConfigure.requestBodyType.form', { defaultMessage: 'Form', } ), - [Mode.PLAINTEXT]: i18n.translate( + [CodeEditorMode.PLAINTEXT]: i18n.translate( 'xpack.synthetics.createPackagePolicy.stepConfigure.requestBodyType.text', { defaultMessage: 'Text', } ), - [Mode.JSON]: i18n.translate( + [CodeEditorMode.JSON]: i18n.translate( 'xpack.synthetics.createPackagePolicy.stepConfigure.requestBodyType.JSON', { defaultMessage: 'JSON', } ), - [Mode.XML]: i18n.translate( + [CodeEditorMode.XML]: i18n.translate( 'xpack.synthetics.createPackagePolicy.stepConfigure.requestBodyType.XML', { defaultMessage: 'XML', diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field.tsx index 7aed077680d4b..cd39245c1c7ec 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field.tsx @@ -24,7 +24,6 @@ export const Field = memo( props, fieldKey, controlled, - showWhen, shouldUseSetValue, required, validation, @@ -32,6 +31,7 @@ export const Field = memo( fieldError, dependencies, customHook, + hidden, }: Props) => { const { register, watch, control, setValue, reset, getFieldState, formState } = useFormContext(); @@ -41,13 +41,7 @@ export const Field = memo( const [dependenciesFieldMeta, setDependenciesFieldMeta] = useState< Record >({}); - let show = true; let dependenciesValues: unknown[] = []; - if (showWhen) { - const [showKey, expectedValue] = showWhen; - const [actualValue] = watch([showKey]); - show = actualValue === expectedValue; - } if (dependencies) { dependenciesValues = watch(dependencies); } @@ -64,7 +58,7 @@ export const Field = memo( // eslint-disable-next-line react-hooks/exhaustive-deps }, [JSON.stringify(dependenciesValues || []), dependencies, getFieldState]); - if (!show) { + if (hidden && hidden(dependenciesValues)) { return null; } diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field_config.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field_config.tsx index b2839da207ff8..9653c415e171a 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field_config.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field_config.tsx @@ -6,6 +6,7 @@ */ import React from 'react'; +import { isEqual } from 'lodash'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { isValidNamespace } from '@kbn/fleet-plugin/common'; @@ -15,7 +16,6 @@ import { EuiFlexGroup, EuiFlexItem, EuiLink, - EuiTextArea, EuiSelectProps, EuiFieldTextProps, EuiSwitchProps, @@ -54,6 +54,8 @@ import { ResponseBodyIndexField, ResponseBodyIndexFieldProps, ControlledFieldProp, + KeyValuePairsField, + TextArea, ThrottlingWrapper, } from './field_wrappers'; import { getDocLinks } from '../../../../../kibana_services'; @@ -64,16 +66,20 @@ import { FormMonitorType, HTTPMethod, ScreenshotOption, + Mode, MonitorFields, TLSVersion, VerificationMode, FieldMap, FormLocation, + ResponseBodyIndexPolicy, + ResponseCheckJSON, ThrottlingConfig, } from '../types'; import { AlertConfigKey, ALLOWED_SCHEDULES_IN_MINUTES } from '../constants'; import { getDefaultFormFields } from './defaults'; import { validate, validateHeaders, WHOLE_NUMBERS_ONLY, FLOATS_ONLY } from './validation'; +import { KeyValuePairsFieldProps } from '../fields/key_value_field'; const getScheduleContent = (value: number) => { if (value > 60) { @@ -765,7 +771,7 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ fieldKey: ConfigKey.RESPONSE_STATUS_CHECK, component: FormattedComboBox, label: i18n.translate('xpack.synthetics.monitorConfig.responseStatusCheck.label', { - defaultMessage: 'Check response status equals', + defaultMessage: 'Response status equals', }), helpText: i18n.translate('xpack.synthetics.monitorConfig.responseStatusCheck.helpText', { defaultMessage: @@ -794,7 +800,7 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ fieldKey: ConfigKey.RESPONSE_HEADERS_CHECK, component: HeaderField, label: i18n.translate('xpack.synthetics.monitorConfig.responseHeadersCheck.label', { - defaultMessage: 'Check response headers contain', + defaultMessage: 'Response headers contain', }), helpText: i18n.translate('xpack.synthetics.monitorConfig.responseHeadersCheck.helpText', { defaultMessage: 'A list of expected response headers.', @@ -814,7 +820,7 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ fieldKey: ConfigKey.RESPONSE_BODY_CHECK_POSITIVE, component: FormattedComboBox, label: i18n.translate('xpack.synthetics.monitorConfig.responseBodyCheck.label', { - defaultMessage: 'Check response body contains', + defaultMessage: 'Response body contains', }), helpText: i18n.translate('xpack.synthetics.monitorConfig.responseBodyCheck.helpText', { defaultMessage: @@ -830,7 +836,7 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ fieldKey: ConfigKey.RESPONSE_BODY_CHECK_NEGATIVE, component: FormattedComboBox, label: i18n.translate('xpack.synthetics.monitorConfig.responseBodyCheckNegative.label', { - defaultMessage: 'Check response body does not contain', + defaultMessage: 'Response body does not contain', }), helpText: i18n.translate('xpack.synthetics.monitorConfig.responseBodyCheckNegative.helpText', { defaultMessage: @@ -846,7 +852,7 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ fieldKey: ConfigKey.RESPONSE_RECEIVE_CHECK, component: FieldText, label: i18n.translate('xpack.synthetics.monitorConfig.responseReceiveCheck.label', { - defaultMessage: 'Check response contains', + defaultMessage: 'Response contains', }), helpText: i18n.translate('xpack.synthetics.monitorConfig.responseReceiveCheck.helpText', { defaultMessage: 'The expected remote host response.', @@ -986,7 +992,11 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ defaultMessage: 'Verifies that the provided certificate is signed by a trusted authority (CA) and also verifies that the server’s hostname (or IP address) matches the names identified within the certificate. If the Subject Alternative Name is empty, it returns an error.', }), - showWhen: ['isTLSEnabled', true], + hidden: (dependencies) => { + const [isTLSEnabled] = dependencies; + return !Boolean(isTLSEnabled); + }, + dependencies: ['isTLSEnabled'], props: (): EuiSelectProps => ({ options: Object.values(VerificationMode).map((method) => ({ value: method, @@ -1002,7 +1012,11 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ defaultMessage: 'Supported TLS protocols', }), controlled: true, - showWhen: ['isTLSEnabled', true], + hidden: (dependencies) => { + const [isTLSEnabled] = dependencies; + return !Boolean(isTLSEnabled); + }, + dependencies: ['isTLSEnabled'], props: ({ field, setValue }): EuiComboBoxProps => { return { options: Object.values(TLSVersion).map((version) => ({ @@ -1023,42 +1037,54 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ }, [ConfigKey.TLS_CERTIFICATE_AUTHORITIES]: { fieldKey: ConfigKey.TLS_CERTIFICATE_AUTHORITIES, - component: EuiTextArea, + component: TextArea, label: i18n.translate('xpack.synthetics.monitorConfig.certificateAuthorities.label', { defaultMessage: 'Certificate authorities', }), helpText: i18n.translate('xpack.synthetics.monitorConfig.certificateAuthorities.helpText', { defaultMessage: 'PEM-formatted custom certificate authorities.', }), - showWhen: ['isTLSEnabled', true], + hidden: (dependencies) => { + const [isTLSEnabled] = dependencies; + return !Boolean(isTLSEnabled); + }, + dependencies: ['isTLSEnabled'], props: (): EuiTextAreaProps => ({ readOnly, }), }, [ConfigKey.TLS_CERTIFICATE]: { fieldKey: ConfigKey.TLS_CERTIFICATE, - component: EuiTextArea, + component: TextArea, label: i18n.translate('xpack.synthetics.monitorConfig.clientCertificate.label', { defaultMessage: 'Client certificate', }), helpText: i18n.translate('xpack.synthetics.monitorConfig.clientCertificate.helpText', { defaultMessage: 'PEM-formatted certificate for TLS client authentication.', }), - showWhen: ['isTLSEnabled', true], + hidden: (dependencies) => { + const [isTLSEnabled] = dependencies; + return !Boolean(isTLSEnabled); + }, + dependencies: ['isTLSEnabled'], props: (): EuiTextAreaProps => ({ readOnly, }), }, [ConfigKey.TLS_KEY]: { fieldKey: ConfigKey.TLS_KEY, - component: EuiTextArea, + component: TextArea, label: i18n.translate('xpack.synthetics.monitorConfig.clientKey.label', { defaultMessage: 'Client key', }), helpText: i18n.translate('xpack.synthetics.monitorConfig.clientKey.helpText', { defaultMessage: 'PEM-formatted certificate key for TLS client authentication.', }), - showWhen: ['isTLSEnabled', true], + hidden: (dependencies) => { + const [isTLSEnabled] = dependencies; + return !Boolean(isTLSEnabled); + }, + dependencies: ['isTLSEnabled'], props: (): EuiTextAreaProps => ({ readOnly, }), @@ -1072,7 +1098,11 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ helpText: i18n.translate('xpack.synthetics.monitorConfig.clientKeyPassphrase.helpText', { defaultMessage: 'Certificate key passphrase for TLS client authentication.', }), - showWhen: ['isTLSEnabled', true], + hidden: (dependencies) => { + const [isTLSEnabled] = dependencies; + return !Boolean(isTLSEnabled); + }, + dependencies: ['isTLSEnabled'], props: (): EuiFieldPasswordProps => ({ readOnly, }), @@ -1252,4 +1282,165 @@ export const FIELD = (readOnly?: boolean): FieldMap => ({ isDisabled: readOnly, }), }, + [ConfigKey.MODE]: { + fieldKey: ConfigKey.MODE, + component: Select, + label: i18n.translate('xpack.synthetics.monitorConfig.mode.label', { + defaultMessage: 'Mode', + }), + helpText: ( + all, + any: any, + }} + /> + ), + props: (): EuiSelectProps => ({ + options: Object.values(Mode).map((value) => ({ + value, + text: value, + })), + disabled: readOnly, + }), + }, + [ConfigKey.RESPONSE_BODY_MAX_BYTES]: { + fieldKey: ConfigKey.RESPONSE_BODY_MAX_BYTES, + component: FieldNumber, + label: i18n.translate('xpack.synthetics.monitorConfig.responseBodyMaxBytes.label', { + defaultMessage: 'Response body max bytes', + }), + helpText: i18n.translate('xpack.synthetics.monitorConfig.responseBodyMaxBytes.helpText', { + defaultMessage: 'Controls the maximum size of the stored body contents.', + }), + hidden: (dependencies) => { + const [responseBodyIndex] = dependencies || []; + return responseBodyIndex === ResponseBodyIndexPolicy.NEVER; + }, + props: (): EuiFieldNumberProps => ({ min: 1, step: 'any', readOnly }), + dependencies: [ConfigKey.RESPONSE_BODY_INDEX], + }, + [ConfigKey.IPV4]: { + fieldKey: ConfigKey.IPV4, // also controls ipv6 + component: ComboBox, + label: i18n.translate('xpack.synthetics.monitorConfig.ipv4.label', { + defaultMessage: 'IP protocols', + }), + helpText: i18n.translate('xpack.synthetics.monitorConfig.ipv4.helpText', { + defaultMessage: 'IP protocols to use when pinging the remote host.', + }), + controlled: true, + dependencies: [ConfigKey.IPV6], + props: ({ field, setValue, dependencies }): EuiComboBoxProps => { + const [ipv6] = dependencies; + const ipv4 = field?.value; + const values: string[] = []; + if (ipv4) { + values.push('IPv4'); + } + if (ipv6) { + values.push('IPv6'); + } + return { + options: [ + { + label: 'IPv4', + }, + { + label: 'IPv6', + }, + ], + selectedOptions: values.map((version) => ({ + label: version, + })), + onChange: (updatedValues: Array>) => { + setValue( + ConfigKey.IPV4, + updatedValues.some((value) => value.label === 'IPv4') + ); + setValue( + ConfigKey.IPV6, + updatedValues.some((value) => value.label === 'IPv6') + ); + }, + isDisabled: readOnly, + }; + }, + }, + [ConfigKey.PROXY_HEADERS]: { + fieldKey: ConfigKey.PROXY_HEADERS, + component: HeaderField, + label: i18n.translate('xpack.synthetics.monitorConfig.proxyHeaders.label', { + defaultMessage: 'Proxy headers', + }), + helpText: i18n.translate('xpack.synthetics.monitorConfig.proxyHeaders.helpText', { + defaultMessage: 'Additional headers to send to proxies for CONNECT requests.', + }), + controlled: true, + validation: () => ({ + validate: (headers) => !validateHeaders(headers), + }), + error: i18n.translate('xpack.synthetics.monitorConfig.proxyHeaders.error', { + defaultMessage: 'The header key must be a valid HTTP token.', + }), + props: (): HeaderFieldProps => ({ + readOnly, + }), + }, + ['check.response.json']: { + fieldKey: ConfigKey.RESPONSE_JSON_CHECK, + component: KeyValuePairsField, + label: i18n.translate('xpack.synthetics.monitorConfig.responseJSON.label', { + defaultMessage: 'Response body contains JSON', + }), + helpText: i18n.translate('xpack.synthetics.monitorConfig.responseJSON.helpText', { + defaultMessage: + 'A list of expressions executed against the body when parsed as JSON. The body size must be less than or equal to 100 MiB.', + }), + controlled: true, + props: ({ field, setValue }): KeyValuePairsFieldProps => ({ + readOnly, + keyLabel: i18n.translate('xpack.synthetics.monitorConfig.responseJSON.key.label', { + defaultMessage: 'Description', + }), + valueLabel: i18n.translate('xpack.synthetics.monitorConfig.responseJSON.value.label', { + defaultMessage: 'Expression', + }), + addPairControlLabel: i18n.translate( + 'xpack.synthetics.monitorConfig.responseJSON.addPair.label', + { + defaultMessage: 'Add expression', + } + ), + onChange: (pairs) => { + const value: ResponseCheckJSON[] = pairs + .map((pair) => { + const [description, expression] = pair; + return { + description, + expression, + }; + }) + .filter((pair) => pair.description || pair.expression); + if (!isEqual(value, field?.value)) { + setValue(ConfigKey.RESPONSE_JSON_CHECK, value); + } + }, + defaultPairs: field?.value.map((check) => [check.description, check.expression]) || [], + }), + validation: () => { + return { + validate: (value: ResponseCheckJSON[]) => { + if (value.some((check) => !check.expression || !check.description)) { + return i18n.translate('xpack.synthetics.monitorConfig.responseJSON.error', { + defaultMessage: + "This JSON expression isn't valid. Make sure that both the label and expression are defined.", + }); + } + }, + }; + }, + }, }); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field_wrappers.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field_wrappers.tsx index 27a8a3ee5ab4d..b2ae5b290aecc 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field_wrappers.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/field_wrappers.tsx @@ -25,6 +25,8 @@ import { EuiButtonGroupProps, EuiComboBox, EuiComboBoxProps, + EuiTextArea, + EuiTextAreaProps, } from '@elastic/eui'; import { ThrottlingConfigField, @@ -47,6 +49,10 @@ import { HeaderField as DefaultHeaderField, HeaderFieldProps as DefaultHeaderFieldProps, } from '../fields/header_field'; +import { + KeyValuePairsField as DefaultKeyValuePairsField, + KeyValuePairsFieldProps as DefaultKeyValuePairsFieldProps, +} from '../fields/key_value_field'; import { RequestBodyField as DefaultRequestBodyField, RequestBodyFieldProps as DefaultRequestBodyFieldProps, @@ -81,6 +87,10 @@ export const FieldText = React.forwardRef( ) ); +export const TextArea = React.forwardRef((props, ref) => ( + +)); + export const FieldNumber = React.forwardRef((props, ref) => ( )); @@ -129,6 +139,10 @@ export const HeaderField = React.forwardRef((p )); +export const KeyValuePairsField = React.forwardRef( + (props, _ref) => +); + export const RequestBodyField = React.forwardRef( (props, _ref) => ); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/form_config.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/form_config.tsx index 46a482512413a..8e74162ecef77 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/form_config.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/form/form_config.tsx @@ -39,10 +39,13 @@ const HTTP_ADVANCED = (readOnly: boolean) => ({ components: [ FIELD(readOnly)[ConfigKey.USERNAME], FIELD(readOnly)[ConfigKey.PASSWORD], - FIELD(readOnly)[ConfigKey.PROXY_URL], FIELD(readOnly)[ConfigKey.REQUEST_METHOD_CHECK], FIELD(readOnly)[ConfigKey.REQUEST_HEADERS_CHECK], FIELD(readOnly)[ConfigKey.REQUEST_BODY_CHECK], + FIELD(readOnly)[ConfigKey.PROXY_URL], + FIELD(readOnly)[ConfigKey.PROXY_HEADERS], + FIELD(readOnly)[ConfigKey.MODE], + FIELD(readOnly)[ConfigKey.IPV4], ], }, responseConfig: { @@ -58,6 +61,7 @@ const HTTP_ADVANCED = (readOnly: boolean) => ({ components: [ FIELD(readOnly)[ConfigKey.RESPONSE_HEADERS_INDEX], FIELD(readOnly)[ConfigKey.RESPONSE_BODY_INDEX], + FIELD(readOnly)[ConfigKey.RESPONSE_BODY_MAX_BYTES], ], }, responseChecks: { @@ -75,6 +79,7 @@ const HTTP_ADVANCED = (readOnly: boolean) => ({ FIELD(readOnly)[ConfigKey.RESPONSE_HEADERS_CHECK], FIELD(readOnly)[ConfigKey.RESPONSE_BODY_CHECK_POSITIVE], FIELD(readOnly)[ConfigKey.RESPONSE_BODY_CHECK_NEGATIVE], + FIELD(readOnly)[ConfigKey.RESPONSE_JSON_CHECK], ], }, }); @@ -93,6 +98,8 @@ export const TCP_ADVANCED = (readOnly: boolean) => ({ components: [ FIELD(readOnly)[`${ConfigKey.PROXY_URL}__tcp`], FIELD(readOnly)[ConfigKey.REQUEST_SEND_CHECK], + FIELD(readOnly)[ConfigKey.MODE], + FIELD(readOnly)[ConfigKey.IPV4], ], }, responseChecks: { @@ -109,6 +116,21 @@ export const TCP_ADVANCED = (readOnly: boolean) => ({ }, }); +export const ICMP_ADVANCED = (readOnly: boolean) => ({ + requestConfig: { + title: i18n.translate('xpack.synthetics.monitorConfig.section.requestConfigICMP.title', { + defaultMessage: 'Request configuration', + }), + description: i18n.translate( + 'xpack.synthetics.monitorConfig.section.requestConfigICMP.description', + { + defaultMessage: 'Configure the payload sent to the remote host.', + } + ), + components: [FIELD(readOnly)[ConfigKey.MODE], FIELD(readOnly)[ConfigKey.IPV4]], + }, +}); + export const BROWSER_ADVANCED = (readOnly: boolean) => [ { title: i18n.translate('xpack.synthetics.monitorConfig.section.syntAgentOptions.title', { @@ -264,6 +286,6 @@ export const FORM_CONFIG = (readOnly: boolean): FieldConfig => ({ FIELD(readOnly)[ConfigKey.ENABLED], FIELD(readOnly)[AlertConfigKey.STATUS_ENABLED], ], - advanced: [DEFAULT_DATA_OPTIONS(readOnly)], + advanced: [DEFAULT_DATA_OPTIONS(readOnly), ICMP_ADVANCED(readOnly).requestConfig], }, }); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/types.ts b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/types.ts index b3b86eef542fe..6abe63786563e 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/types.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_add_edit/types.ts @@ -17,6 +17,7 @@ import { ServiceLocation, FormMonitorType, MonitorFields, + ResponseCheckJSON, } from '../../../../../common/runtime_types/monitor_management'; import { AlertConfigKey } from './constants'; @@ -55,6 +56,11 @@ export type FormConfig = MonitorFields & { ssl: { supported_protocols: MonitorFields[ConfigKey.TLS_VERSION]; }; + check: { + response: { + json: ResponseCheckJSON[]; + }; + }; }; export interface FieldMeta { @@ -63,6 +69,7 @@ export interface FieldMeta { label?: string; ariaLabel?: string; helpText?: string | React.ReactNode; + hidden?: (depenencies: unknown[]) => boolean; props?: (params: { field?: ControllerRenderProps; formState: FormState; @@ -88,7 +95,6 @@ export interface FieldMeta { event: React.ChangeEvent, formOnChange: (event: React.ChangeEvent) => void ) => void; - showWhen?: [keyof FormConfig, any]; // show field when another field equals an arbitrary value validation?: (dependencies: unknown[]) => Parameters[1]; error?: React.ReactNode; dependencies?: Array; // fields that another field may depend for or validation. Values are passed to the validation function @@ -123,16 +129,19 @@ export interface FieldMap { [ConfigKey.USERNAME]: FieldMeta; [ConfigKey.PASSWORD]: FieldMeta; [ConfigKey.PROXY_URL]: FieldMeta; + [ConfigKey.PROXY_HEADERS]: FieldMeta; ['proxy_url__tcp']: FieldMeta; [ConfigKey.REQUEST_METHOD_CHECK]: FieldMeta; [ConfigKey.REQUEST_HEADERS_CHECK]: FieldMeta; [ConfigKey.REQUEST_BODY_CHECK]: FieldMeta; [ConfigKey.RESPONSE_HEADERS_INDEX]: FieldMeta; [ConfigKey.RESPONSE_BODY_INDEX]: FieldMeta; + [ConfigKey.RESPONSE_BODY_MAX_BYTES]: FieldMeta; [ConfigKey.RESPONSE_STATUS_CHECK]: FieldMeta; [ConfigKey.RESPONSE_HEADERS_CHECK]: FieldMeta; [ConfigKey.RESPONSE_BODY_CHECK_POSITIVE]: FieldMeta; [ConfigKey.RESPONSE_BODY_CHECK_NEGATIVE]: FieldMeta; + [ConfigKey.RESPONSE_JSON_CHECK]: FieldMeta; [ConfigKey.RESPONSE_RECEIVE_CHECK]: FieldMeta; [ConfigKey.REQUEST_SEND_CHECK]: FieldMeta; ['source.inline']: FieldMeta; @@ -142,4 +151,6 @@ export interface FieldMap { [ConfigKey.PLAYWRIGHT_OPTIONS]: FieldMeta; [ConfigKey.SYNTHETICS_ARGS]: FieldMeta; [ConfigKey.IGNORE_HTTPS_ERRORS]: FieldMeta; + [ConfigKey.MODE]: FieldMeta; + [ConfigKey.IPV4]: FieldMeta; } diff --git a/x-pack/plugins/synthetics/server/legacy_uptime/lib/saved_objects/migrations/monitors/8.8.0.ts b/x-pack/plugins/synthetics/server/legacy_uptime/lib/saved_objects/migrations/monitors/8.8.0.ts index 426984969c191..9aec3104fc2bf 100644 --- a/x-pack/plugins/synthetics/server/legacy_uptime/lib/saved_objects/migrations/monitors/8.8.0.ts +++ b/x-pack/plugins/synthetics/server/legacy_uptime/lib/saved_objects/migrations/monitors/8.8.0.ts @@ -105,7 +105,7 @@ const getNearestSupportedSchedule = (currentSchedule: string): string => { return closest; } catch { - return ALLOWED_SCHEDULES_IN_MINUTES[0]; + return '10'; } }; diff --git a/x-pack/plugins/synthetics/server/routes/monitor_cruds/monitor_validation.test.ts b/x-pack/plugins/synthetics/server/routes/monitor_cruds/monitor_validation.test.ts index 12fe8306d1731..7c2498242b35c 100644 --- a/x-pack/plugins/synthetics/server/routes/monitor_cruds/monitor_validation.test.ts +++ b/x-pack/plugins/synthetics/server/routes/monitor_cruds/monitor_validation.test.ts @@ -9,6 +9,7 @@ import { BrowserAdvancedFields, BrowserFields, BrowserSimpleFields, + CodeEditorMode, CommonFields, ConfigKey, DataStream, @@ -18,7 +19,6 @@ import { HTTPSimpleFields, ICMPSimpleFields, Metadata, - Mode, MonitorFields, ResponseBodyIndexPolicy, ScheduleUnit, @@ -142,7 +142,7 @@ describe('validateMonitor', () => { [ConfigKey.RESPONSE_HEADERS_CHECK]: {}, [ConfigKey.RESPONSE_HEADERS_INDEX]: true, [ConfigKey.RESPONSE_STATUS_CHECK]: ['200', '201'], - [ConfigKey.REQUEST_BODY_CHECK]: { value: 'testValue', type: Mode.JSON }, + [ConfigKey.REQUEST_BODY_CHECK]: { value: 'testValue', type: CodeEditorMode.JSON }, [ConfigKey.REQUEST_HEADERS_CHECK]: {}, [ConfigKey.REQUEST_METHOD_CHECK]: '', [ConfigKey.USERNAME]: 'test-username', diff --git a/x-pack/plugins/synthetics/server/synthetics_service/formatters/format_configs.test.ts b/x-pack/plugins/synthetics/server/synthetics_service/formatters/format_configs.test.ts index e5ff19ddaf1c0..921fd737e5d3e 100644 --- a/x-pack/plugins/synthetics/server/synthetics_service/formatters/format_configs.test.ts +++ b/x-pack/plugins/synthetics/server/synthetics_service/formatters/format_configs.test.ts @@ -14,7 +14,7 @@ import { import { ConfigKey, DataStream, - Mode, + CodeEditorMode, MonitorFields, ResponseBodyIndexPolicy, ScheduleUnit, @@ -39,11 +39,19 @@ const testHTTPConfig: Partial = { proxy_url: '${proxyUrl}', 'check.response.body.negative': [], 'check.response.body.positive': [], + 'check.response.json': [ + { + description: 'test description', + expression: 'foo.bar == "myValue"', + }, + ], 'response.include_body': 'on_error' as ResponseBodyIndexPolicy, - 'check.response.headers': {}, + 'check.response.headers': { + 'test-header': 'test-value', + }, 'response.include_headers': true, 'check.response.status': [], - 'check.request.body': { type: 'text' as Mode, value: '' }, + 'check.request.body': { type: 'text' as CodeEditorMode, value: '' }, 'check.request.headers': {}, 'check.request.method': 'GET', 'ssl.verification_mode': VerificationMode.NONE, @@ -99,6 +107,15 @@ describe('formatMonitorConfig', () => { expect(yamlConfig).toEqual({ 'check.request.method': 'GET', + 'check.response.headers': { + 'test-header': 'test-value', + }, + 'check.response.json': [ + { + description: 'test description', + expression: 'foo.bar == "myValue"', + }, + ], enabled: true, locations: [], max_redirects: '0', @@ -129,6 +146,15 @@ describe('formatMonitorConfig', () => { expect(yamlConfig).toEqual({ 'check.request.method': 'GET', + 'check.response.headers': { + 'test-header': 'test-value', + }, + 'check.response.json': [ + { + description: 'test description', + expression: 'foo.bar == "myValue"', + }, + ], enabled: true, locations: [], max_redirects: '0', diff --git a/x-pack/plugins/synthetics/server/synthetics_service/formatters/http.ts b/x-pack/plugins/synthetics/server/synthetics_service/formatters/http.ts index 43c1a5e76ea70..4f65e7546d966 100644 --- a/x-pack/plugins/synthetics/server/synthetics_service/formatters/http.ts +++ b/x-pack/plugins/synthetics/server/synthetics_service/formatters/http.ts @@ -17,10 +17,12 @@ export const httpFormatters: HTTPFormatMap = { [ConfigKey.METADATA]: objectFormatter, [ConfigKey.RESPONSE_BODY_CHECK_NEGATIVE]: arrayFormatter, [ConfigKey.RESPONSE_BODY_CHECK_POSITIVE]: arrayFormatter, + [ConfigKey.RESPONSE_JSON_CHECK]: arrayFormatter, [ConfigKey.RESPONSE_HEADERS_CHECK]: objectFormatter, [ConfigKey.RESPONSE_STATUS_CHECK]: arrayFormatter, [ConfigKey.REQUEST_HEADERS_CHECK]: objectFormatter, [ConfigKey.REQUEST_BODY_CHECK]: (fields) => fields[ConfigKey.REQUEST_BODY_CHECK]?.value || null, + [ConfigKey.PROXY_HEADERS]: objectFormatter, ...tlsFormatters, ...commonFormatters, }; diff --git a/x-pack/plugins/synthetics/server/synthetics_service/project_monitor/normalizers/http_monitor.ts b/x-pack/plugins/synthetics/server/synthetics_service/project_monitor/normalizers/http_monitor.ts index cd7ac7a26f6dd..0045cc711f9bc 100644 --- a/x-pack/plugins/synthetics/server/synthetics_service/project_monitor/normalizers/http_monitor.ts +++ b/x-pack/plugins/synthetics/server/synthetics_service/project_monitor/normalizers/http_monitor.ts @@ -7,11 +7,11 @@ import { get } from 'lodash'; import { DEFAULT_FIELDS } from '../../../../common/constants/monitor_defaults'; import { + CodeEditorMode, ConfigKey, DataStream, FormMonitorType, HTTPFields, - Mode, TLSVersion, } from '../../../../common/runtime_types/monitor_management'; import { @@ -70,6 +70,11 @@ export const getNormalizeHTTPFields = ({ (yamlConfig as Record)[ConfigKey.REQUEST_BODY_CHECK] as string, defaultFields[ConfigKey.REQUEST_BODY_CHECK] ), + [ConfigKey.RESPONSE_BODY_MAX_BYTES]: `${get( + yamlConfig, + ConfigKey.RESPONSE_BODY_MAX_BYTES, + defaultFields[ConfigKey.RESPONSE_BODY_MAX_BYTES] + )}`, [ConfigKey.TLS_VERSION]: get(monitor, ConfigKey.TLS_VERSION) ? (getOptionalListField(get(monitor, ConfigKey.TLS_VERSION)) as TLSVersion[]) : defaultFields[ConfigKey.TLS_VERSION], @@ -94,14 +99,14 @@ export const getRequestBodyField = ( defaultValue: HTTPFields[ConfigKey.REQUEST_BODY_CHECK] ): HTTPFields[ConfigKey.REQUEST_BODY_CHECK] => { let parsedValue: string; - let type: Mode; + let type: CodeEditorMode; if (typeof value === 'object') { parsedValue = JSON.stringify(value); - type = Mode.JSON; + type = CodeEditorMode.JSON; } else { parsedValue = value; - type = Mode.PLAINTEXT; + type = CodeEditorMode.PLAINTEXT; } return { type, diff --git a/x-pack/test/api_integration/apis/synthetics/add_monitor_private_location.ts b/x-pack/test/api_integration/apis/synthetics/add_monitor_private_location.ts index d680e99d13405..b48cf2c9a56ca 100644 --- a/x-pack/test/api_integration/apis/synthetics/add_monitor_private_location.ts +++ b/x-pack/test/api_integration/apis/synthetics/add_monitor_private_location.ts @@ -36,7 +36,7 @@ export default function ({ getService }: FtrProviderContext) { before(async () => { await supertestAPI.post('/api/fleet/setup').set('kbn-xsrf', 'true').send().expect(200); await supertestAPI - .post('/api/fleet/epm/packages/synthetics/0.11.4') + .post('/api/fleet/epm/packages/synthetics/0.12.0') .set('kbn-xsrf', 'true') .send({ force: true }) .expect(200); @@ -455,7 +455,7 @@ export default function ({ getService }: FtrProviderContext) { pkgPolicy.id === monitorId + '-' + testFleetPolicyID + `-default` ); - expect(packagePolicy.package.version).eql('0.11.4'); + expect(packagePolicy.package.version).eql('0.12.0'); await supertestAPI.post('/api/fleet/setup').set('kbn-xsrf', 'true').send().expect(200); const policyResponseAfterUpgrade = await supertestAPI.get( @@ -465,7 +465,7 @@ export default function ({ getService }: FtrProviderContext) { (pkgPolicy: PackagePolicy) => pkgPolicy.id === monitorId + '-' + testFleetPolicyID + `-default` ); - expect(semver.gte(packagePolicyAfterUpgrade.package.version, '0.11.4')).eql(true); + expect(semver.gte(packagePolicyAfterUpgrade.package.version, '0.12.0')).eql(true); } finally { await supertestAPI .delete(API_URLS.SYNTHETICS_MONITORS + '/' + monitorId) diff --git a/x-pack/test/api_integration/apis/synthetics/add_monitor_project.ts b/x-pack/test/api_integration/apis/synthetics/add_monitor_project.ts index e6a8ae14cda1a..7674d2fbc534f 100644 --- a/x-pack/test/api_integration/apis/synthetics/add_monitor_project.ts +++ b/x-pack/test/api_integration/apis/synthetics/add_monitor_project.ts @@ -77,7 +77,7 @@ export default function ({ getService }: FtrProviderContext) { await supertest.put(API_URLS.SYNTHETICS_ENABLEMENT).set('kbn-xsrf', 'true').expect(200); await supertest.post('/api/fleet/setup').set('kbn-xsrf', 'true').send().expect(200); await supertest - .post('/api/fleet/epm/packages/synthetics/0.11.4') + .post('/api/fleet/epm/packages/synthetics/0.12.0') .set('kbn-xsrf', 'true') .send({ force: true }) .expect(200); @@ -321,6 +321,9 @@ export default function ({ getService }: FtrProviderContext) { custom_heartbeat_id: `${journeyId}-${project}-default`, 'check.response.body.negative': [], 'check.response.body.positive': ['Saved', 'saved'], + 'check.response.json': [ + { description: 'check status', expression: 'foo.bar == "myValue"' }, + ], 'check.response.headers': {}, 'check.request.body': { type: 'text', @@ -357,8 +360,10 @@ export default function ({ getService }: FtrProviderContext) { username: '', password: '', proxy_url: '', + proxy_headers: {}, 'response.include_body': 'always', 'response.include_headers': false, + 'response.include_body_max_bytes': '900', revision: 1, schedule: { number: '60', @@ -378,6 +383,9 @@ export default function ({ getService }: FtrProviderContext) { 'url.port': null, id: `${journeyId}-${project}-default`, hash: 'ekrjelkjrelkjre', + mode: 'any', + ipv6: true, + ipv4: true, }); } } finally { @@ -486,6 +494,9 @@ export default function ({ getService }: FtrProviderContext) { urls: '', id: `${journeyId}-${project}-default`, hash: 'ekrjelkjrelkjre', + mode: 'any', + ipv6: true, + ipv4: true, }); } } finally { @@ -590,6 +601,9 @@ export default function ({ getService }: FtrProviderContext) { : `${parseInt(monitor.wait?.slice(0, -1) || '1', 10) * 60}`, id: `${journeyId}-${project}-default`, hash: 'ekrjelkjrelkjre', + mode: 'any', + ipv4: true, + ipv6: true, }); } } finally { @@ -1784,6 +1798,7 @@ export default function ({ getService }: FtrProviderContext) { positive: ['Saved', 'saved'], }, status: [200], + json: [{ description: 'check status', expression: 'foo.bar == "myValue"' }], }, enabled: false, hash: 'ekrjelkjrelkjre', @@ -1792,6 +1807,7 @@ export default function ({ getService }: FtrProviderContext) { name: 'My Monitor 3', response: { include_body: 'always', + include_body_max_bytes: 900, }, 'response.include_headers': false, schedule: 60, @@ -1885,6 +1901,12 @@ export default function ({ getService }: FtrProviderContext) { positive: ['Saved', 'saved'], }, status: [200], + json: [ + { + description: 'check status', + expression: 'foo.bar == "myValue"', + }, + ], }, enabled: false, hash: 'ekrjelkjrelkjre', @@ -1893,6 +1915,7 @@ export default function ({ getService }: FtrProviderContext) { name: 'My Monitor 3', response: { include_body: 'always', + include_body_max_bytes: 900, }, 'response.include_headers': false, schedule: 60, @@ -1946,6 +1969,12 @@ export default function ({ getService }: FtrProviderContext) { positive: ['Saved', 'saved'], }, status: [200], + json: [ + { + description: 'check status', + expression: 'foo.bar == "myValue"', + }, + ], }, enabled: false, hash: 'ekrjelkjrelkjre', @@ -1954,6 +1983,7 @@ export default function ({ getService }: FtrProviderContext) { name: 'My Monitor 3', response: { include_body: 'always', + include_body_max_bytes: 900, }, 'response.include_headers': false, schedule: 60, @@ -2008,6 +2038,12 @@ export default function ({ getService }: FtrProviderContext) { positive: ['Saved', 'saved'], }, status: [200], + json: [ + { + description: 'check status', + expression: 'foo.bar == "myValue"', + }, + ], }, enabled: false, hash: 'ekrjelkjrelkjre', @@ -2016,6 +2052,7 @@ export default function ({ getService }: FtrProviderContext) { name: 'My Monitor 3', response: { include_body: 'always', + include_body_max_bytes: 900, }, 'response.include_headers': false, schedule: 60, diff --git a/x-pack/test/api_integration/apis/synthetics/add_monitor_project_legacy.ts b/x-pack/test/api_integration/apis/synthetics/add_monitor_project_legacy.ts index 65768d80a53f3..0e2cec6c3a5c3 100644 --- a/x-pack/test/api_integration/apis/synthetics/add_monitor_project_legacy.ts +++ b/x-pack/test/api_integration/apis/synthetics/add_monitor_project_legacy.ts @@ -86,7 +86,7 @@ export default function ({ getService }: FtrProviderContext) { before(async () => { await supertest.post('/api/fleet/setup').set('kbn-xsrf', 'true').send().expect(200); await supertest - .post('/api/fleet/epm/packages/synthetics/0.11.4') + .post('/api/fleet/epm/packages/synthetics/0.12.0') .set('kbn-xsrf', 'true') .send({ force: true }) .expect(200); @@ -256,6 +256,9 @@ export default function ({ getService }: FtrProviderContext) { custom_heartbeat_id: `${journeyId}-test-suite-default`, 'check.response.body.negative': [], 'check.response.body.positive': ['Saved', 'saved'], + 'check.response.json': [ + { description: 'check status', expression: 'foo.bar == "myValue"' }, + ], 'check.response.headers': {}, 'check.request.body': { type: 'text', @@ -292,8 +295,10 @@ export default function ({ getService }: FtrProviderContext) { username: '', password: '', proxy_url: '', + proxy_headers: {}, 'response.include_body': 'always', 'response.include_headers': false, + 'response.include_body_max_bytes': '900', revision: 1, schedule: { number: '60', @@ -313,6 +318,9 @@ export default function ({ getService }: FtrProviderContext) { 'url.port': null, id: `${journeyId}-test-suite-default`, hash: 'ekrjelkjrelkjre', + ipv6: true, + ipv4: true, + mode: 'any', }); } } finally { @@ -418,6 +426,9 @@ export default function ({ getService }: FtrProviderContext) { urls: '', id: `${journeyId}-test-suite-default`, hash: 'ekrjelkjrelkjre', + ipv6: true, + ipv4: true, + mode: 'any', }); } } finally { @@ -520,6 +531,9 @@ export default function ({ getService }: FtrProviderContext) { : `${parseInt(monitor.wait?.slice(0, -1) || '1', 10) * 60}`, id: `${journeyId}-test-suite-default`, hash: 'ekrjelkjrelkjre', + ipv6: true, + ipv4: true, + mode: 'any', }); } } finally { @@ -1802,11 +1816,13 @@ export default function ({ getService }: FtrProviderContext) { timeout: { value: '80s', type: 'text' }, max_redirects: { value: '0', type: 'integer' }, proxy_url: { value: '', type: 'text' }, + proxy_headers: { value: null, type: 'yaml' }, tags: { value: '["tag2","tag2"]', type: 'yaml' }, username: { value: '', type: 'text' }, password: { value: '', type: 'password' }, 'response.include_headers': { value: false, type: 'bool' }, 'response.include_body': { value: 'always', type: 'text' }, + 'response.include_body_max_bytes': { value: '900', type: 'text' }, 'check.request.method': { value: 'POST', type: 'text' }, 'check.request.headers': { value: '{"Content-Type":"application/x-www-form-urlencoded"}', @@ -1817,6 +1833,10 @@ export default function ({ getService }: FtrProviderContext) { 'check.response.headers': { value: null, type: 'yaml' }, 'check.response.body.positive': { value: '["Saved","saved"]', type: 'yaml' }, 'check.response.body.negative': { value: null, type: 'yaml' }, + 'check.response.json': { + value: '[{"description":"check status","expression":"foo.bar == \\"myValue\\""}]', + type: 'yaml', + }, 'ssl.certificate_authorities': { value: null, type: 'yaml' }, 'ssl.certificate': { value: null, type: 'yaml' }, 'ssl.key': { value: null, type: 'yaml' }, @@ -1842,6 +1862,9 @@ export default function ({ getService }: FtrProviderContext) { type: 'text', value: 'test-suite', }, + ipv4: { type: 'bool', value: true }, + ipv6: { type: 'bool', value: true }, + mode: { type: 'text', value: 'any' }, }, id: `synthetics/http-http-${id}-${testPolicyId}`, compiled_stream: { @@ -1866,6 +1889,15 @@ export default function ({ getService }: FtrProviderContext) { 'ssl.supported_protocols': ['TLSv1.1', 'TLSv1.2', 'TLSv1.3'], 'run_from.geo.name': 'Test private location 0', 'run_from.id': 'Test private location 0', + 'check.response.json': [ + { + description: 'check status', + expression: 'foo.bar == "myValue"', + }, + ], + ipv4: true, + ipv6: true, + mode: 'any', processors: [ { add_fields: { diff --git a/x-pack/test/api_integration/apis/synthetics/delete_monitor.ts b/x-pack/test/api_integration/apis/synthetics/delete_monitor.ts index 87d033be74743..9ab6063775844 100644 --- a/x-pack/test/api_integration/apis/synthetics/delete_monitor.ts +++ b/x-pack/test/api_integration/apis/synthetics/delete_monitor.ts @@ -41,7 +41,7 @@ export default function ({ getService }: FtrProviderContext) { _httpMonitorJson = getFixtureJson('http_monitor'); await supertest.post('/api/fleet/setup').set('kbn-xsrf', 'true').send().expect(200); await supertest - .post('/api/fleet/epm/packages/synthetics/0.11.4') + .post('/api/fleet/epm/packages/synthetics/0.12.0') .set('kbn-xsrf', 'true') .send({ force: true }) .expect(200); diff --git a/x-pack/test/api_integration/apis/synthetics/delete_monitor_project.ts b/x-pack/test/api_integration/apis/synthetics/delete_monitor_project.ts index 8ba507beb7e4c..13bcab980d248 100644 --- a/x-pack/test/api_integration/apis/synthetics/delete_monitor_project.ts +++ b/x-pack/test/api_integration/apis/synthetics/delete_monitor_project.ts @@ -45,7 +45,7 @@ export default function ({ getService }: FtrProviderContext) { before(async () => { await supertest.post('/api/fleet/setup').set('kbn-xsrf', 'true').send().expect(200); await supertest - .post('/api/fleet/epm/packages/synthetics/0.11.4') + .post('/api/fleet/epm/packages/synthetics/0.12.0') .set('kbn-xsrf', 'true') .send({ force: true }) .expect(200); diff --git a/x-pack/test/api_integration/apis/synthetics/get_monitor_project.ts b/x-pack/test/api_integration/apis/synthetics/get_monitor_project.ts index d24a3775bbf44..beefd6c561829 100644 --- a/x-pack/test/api_integration/apis/synthetics/get_monitor_project.ts +++ b/x-pack/test/api_integration/apis/synthetics/get_monitor_project.ts @@ -46,7 +46,7 @@ export default function ({ getService }: FtrProviderContext) { before(async () => { await supertest.post('/api/fleet/setup').set('kbn-xsrf', 'true').send().expect(200); await supertest - .post('/api/fleet/epm/packages/synthetics/0.11.4') + .post('/api/fleet/epm/packages/synthetics/0.12.0') .set('kbn-xsrf', 'true') .send({ force: true }) .expect(200); diff --git a/x-pack/test/api_integration/apis/synthetics/sample_data/test_browser_policy.ts b/x-pack/test/api_integration/apis/synthetics/sample_data/test_browser_policy.ts index eff1026cb4486..5eec726cdb328 100644 --- a/x-pack/test/api_integration/apis/synthetics/sample_data/test_browser_policy.ts +++ b/x-pack/test/api_integration/apis/synthetics/sample_data/test_browser_policy.ts @@ -21,7 +21,7 @@ export const getTestBrowserSyntheticsPolicy = ({ version: 'WzEzNzYsMV0=', name: 'Test HTTP Monitor 03-Test private location 0-default', namespace: 'testnamespace', - package: { name: 'synthetics', title: 'Elastic Synthetics', version: '0.11.4' }, + package: { name: 'synthetics', title: 'Elastic Synthetics', version: '0.12.0' }, enabled: true, policy_id: 'fe621d20-7b01-11ed-803f-475d82e1f9ca', inputs: [ @@ -44,11 +44,13 @@ export const getTestBrowserSyntheticsPolicy = ({ timeout: { type: 'text' }, max_redirects: { type: 'integer' }, proxy_url: { type: 'text' }, + proxy_headers: { type: 'yaml' }, tags: { type: 'yaml' }, username: { type: 'text' }, password: { type: 'password' }, 'response.include_headers': { type: 'bool' }, 'response.include_body': { type: 'text' }, + 'response.include_body_max_bytes': { type: 'text' }, 'check.request.method': { type: 'text' }, 'check.request.headers': { type: 'yaml' }, 'check.request.body': { type: 'yaml' }, @@ -56,6 +58,7 @@ export const getTestBrowserSyntheticsPolicy = ({ 'check.response.headers': { type: 'yaml' }, 'check.response.body.positive': { type: 'yaml' }, 'check.response.body.negative': { type: 'yaml' }, + 'check.response.json': { type: 'yaml' }, 'ssl.certificate_authorities': { type: 'yaml' }, 'ssl.certificate': { type: 'yaml' }, 'ssl.key': { type: 'yaml' }, @@ -69,6 +72,9 @@ export const getTestBrowserSyntheticsPolicy = ({ origin: { type: 'text' }, 'monitor.project.id': { type: 'text' }, 'monitor.project.name': { type: 'text' }, + ipv4: { type: 'bool', value: true }, + ipv6: { type: 'bool', value: true }, + mode: { type: 'text' }, }, id: 'synthetics/http-http-abf904a4-cb9a-4b29-8c11-4d183cca289b-fe621d20-7b01-11ed-803f-475d82e1f9ca-default', }, @@ -109,6 +115,9 @@ export const getTestBrowserSyntheticsPolicy = ({ origin: { type: 'text' }, 'monitor.project.id': { type: 'text' }, 'monitor.project.name': { type: 'text' }, + ipv4: { type: 'bool', value: true }, + ipv6: { type: 'bool', value: true }, + mode: { type: 'text' }, }, id: 'synthetics/tcp-tcp-abf904a4-cb9a-4b29-8c11-4d183cca289b-fe621d20-7b01-11ed-803f-475d82e1f9ca-default', }, @@ -140,6 +149,9 @@ export const getTestBrowserSyntheticsPolicy = ({ origin: { type: 'text' }, 'monitor.project.id': { type: 'text' }, 'monitor.project.name': { type: 'text' }, + ipv4: { type: 'bool', value: true }, + ipv6: { type: 'bool', value: true }, + mode: { type: 'text' }, }, id: 'synthetics/icmp-icmp-abf904a4-cb9a-4b29-8c11-4d183cca289b-fe621d20-7b01-11ed-803f-475d82e1f9ca-default', }, @@ -249,10 +261,7 @@ export const getTestBrowserSyntheticsPolicy = ({ }, id: 'synthetics/browser-browser.network-abf904a4-cb9a-4b29-8c11-4d183cca289b-fe621d20-7b01-11ed-803f-475d82e1f9ca-default', compiled_stream: { - processors: [ - { add_observer_metadata: { geo: { name: 'Fleet managed' } } }, - { add_fields: { target: '', fields: { 'monitor.fleet_managed': true } } }, - ], + processors: [{ add_fields: { target: '', fields: { 'monitor.fleet_managed': true } } }], }, }, { @@ -264,10 +273,7 @@ export const getTestBrowserSyntheticsPolicy = ({ }, id: 'synthetics/browser-browser.screenshot-abf904a4-cb9a-4b29-8c11-4d183cca289b-fe621d20-7b01-11ed-803f-475d82e1f9ca-default', compiled_stream: { - processors: [ - { add_observer_metadata: { geo: { name: 'Fleet managed' } } }, - { add_fields: { target: '', fields: { 'monitor.fleet_managed': true } } }, - ], + processors: [{ add_fields: { target: '', fields: { 'monitor.fleet_managed': true } } }], }, }, ], diff --git a/x-pack/test/api_integration/apis/synthetics/sample_data/test_policy.ts b/x-pack/test/api_integration/apis/synthetics/sample_data/test_policy.ts index 82e19948779bb..b0962e4d285e6 100644 --- a/x-pack/test/api_integration/apis/synthetics/sample_data/test_policy.ts +++ b/x-pack/test/api_integration/apis/synthetics/sample_data/test_policy.ts @@ -21,7 +21,7 @@ export const getTestSyntheticsPolicy = ( version: 'WzE2MjYsMV0=', name: 'test-monitor-name-Test private location 0-default', namespace: namespace || 'testnamespace', - package: { name: 'synthetics', title: 'Elastic Synthetics', version: '0.11.4' }, + package: { name: 'synthetics', title: 'Elastic Synthetics', version: '0.12.0' }, enabled: true, policy_id: '5347cd10-0368-11ed-8df7-a7424c6f5167', inputs: [ @@ -55,11 +55,13 @@ export const getTestSyntheticsPolicy = ( timeout: { value: '3ms', type: 'text' }, max_redirects: { value: '3', type: 'integer' }, proxy_url: { value: proxyUrl ?? 'http://proxy.com', type: 'text' }, + proxy_headers: { value: null, type: 'yaml' }, tags: { value: '["tag1","tag2"]', type: 'yaml' }, username: { value: 'test-username', type: 'text' }, password: { value: 'test', type: 'password' }, 'response.include_headers': { value: true, type: 'bool' }, 'response.include_body': { value: 'never', type: 'text' }, + 'response.include_body_max_bytes': { value: '1024', type: 'text' }, 'check.request.method': { value: '', type: 'text' }, 'check.request.headers': { value: '{"sampleHeader":"sampleHeaderValue"}', @@ -70,6 +72,7 @@ export const getTestSyntheticsPolicy = ( 'check.response.headers': { value: null, type: 'yaml' }, 'check.response.body.positive': { value: null, type: 'yaml' }, 'check.response.body.negative': { value: null, type: 'yaml' }, + 'check.response.json': { value: null, type: 'yaml' }, 'ssl.certificate_authorities': { value: isTLSEnabled ? '"t.string"' : null, type: 'yaml', @@ -89,6 +92,9 @@ export const getTestSyntheticsPolicy = ( origin: { value: 'ui', type: 'text' }, 'monitor.project.id': { type: 'text', value: null }, 'monitor.project.name': { type: 'text', value: null }, + ipv4: { type: 'bool', value: true }, + ipv6: { type: 'bool', value: true }, + mode: { type: 'text', value: 'any' }, }, id: 'synthetics/http-http-2bfd7da0-22ed-11ed-8c6b-09a2d21dfbc3-27337270-22ed-11ed-8c6b-09a2d21dfbc3-default', compiled_stream: { @@ -116,6 +122,9 @@ export const getTestSyntheticsPolicy = ( 'check.request.headers': { sampleHeader: 'sampleHeaderValue' }, 'check.request.body': 'testValue', 'check.response.status': ['200', '201'], + ipv4: true, + ipv6: true, + mode: 'any', ...(isTLSEnabled ? { 'ssl.certificate': 't.string', @@ -179,6 +188,9 @@ export const getTestSyntheticsPolicy = ( origin: { type: 'text' }, 'monitor.project.id': { type: 'text' }, 'monitor.project.name': { type: 'text' }, + ipv4: { type: 'bool', value: true }, + ipv6: { type: 'bool', value: true }, + mode: { type: 'text' }, }, id: 'synthetics/tcp-tcp-2bfd7da0-22ed-11ed-8c6b-09a2d21dfbc3-27337270-22ed-11ed-8c6b-09a2d21dfbc3-default', }, @@ -213,6 +225,9 @@ export const getTestSyntheticsPolicy = ( origin: { type: 'text' }, 'monitor.project.id': { type: 'text' }, 'monitor.project.name': { type: 'text' }, + ipv4: { type: 'bool', value: true }, + ipv6: { type: 'bool', value: true }, + mode: { type: 'text' }, }, id: 'synthetics/icmp-icmp-2bfd7da0-22ed-11ed-8c6b-09a2d21dfbc3-27337270-22ed-11ed-8c6b-09a2d21dfbc3-default', }, @@ -299,10 +314,7 @@ export const getTestSyntheticsPolicy = ( }, id: 'synthetics/browser-browser.network-2bfd7da0-22ed-11ed-8c6b-09a2d21dfbc3-27337270-22ed-11ed-8c6b-09a2d21dfbc3-default', compiled_stream: { - processors: [ - { add_observer_metadata: { geo: { name: 'Fleet managed' } } }, - { add_fields: { target: '', fields: { 'monitor.fleet_managed': true } } }, - ], + processors: [{ add_fields: { target: '', fields: { 'monitor.fleet_managed': true } } }], }, }, { @@ -318,10 +330,7 @@ export const getTestSyntheticsPolicy = ( }, id: 'synthetics/browser-browser.screenshot-2bfd7da0-22ed-11ed-8c6b-09a2d21dfbc3-27337270-22ed-11ed-8c6b-09a2d21dfbc3-default', compiled_stream: { - processors: [ - { add_observer_metadata: { geo: { name: 'Fleet managed' } } }, - { add_fields: { target: '', fields: { 'monitor.fleet_managed': true } } }, - ], + processors: [{ add_fields: { target: '', fields: { 'monitor.fleet_managed': true } } }], }, }, ], diff --git a/x-pack/test/api_integration/apis/synthetics/sample_data/test_project_monitor_policy.ts b/x-pack/test/api_integration/apis/synthetics/sample_data/test_project_monitor_policy.ts index ba48a26ff50fe..bc015cb4bd77c 100644 --- a/x-pack/test/api_integration/apis/synthetics/sample_data/test_project_monitor_policy.ts +++ b/x-pack/test/api_integration/apis/synthetics/sample_data/test_project_monitor_policy.ts @@ -34,7 +34,7 @@ export const getTestProjectSyntheticsPolicy = ( version: 'WzEzMDksMV0=', name: `4b6abc6c-118b-4d93-a489-1135500d09f1-${projectId}-default-Test private location 0`, namespace: 'default', - package: { name: 'synthetics', title: 'Elastic Synthetics', version: '0.11.4' }, + package: { name: 'synthetics', title: 'Elastic Synthetics', version: '0.12.0' }, enabled: true, policy_id: '46034710-0ba6-11ed-ba04-5f123b9faa8b', inputs: [ @@ -60,11 +60,13 @@ export const getTestProjectSyntheticsPolicy = ( timeout: { type: 'text' }, max_redirects: { type: 'integer' }, proxy_url: { type: 'text' }, + proxy_headers: { type: 'yaml' }, tags: { type: 'yaml' }, username: { type: 'text' }, password: { type: 'password' }, 'response.include_headers': { type: 'bool' }, 'response.include_body': { type: 'text' }, + 'response.include_body_max_bytes': { type: 'text' }, 'check.request.method': { type: 'text' }, 'check.request.headers': { type: 'yaml' }, 'check.request.body': { type: 'yaml' }, @@ -72,6 +74,7 @@ export const getTestProjectSyntheticsPolicy = ( 'check.response.headers': { type: 'yaml' }, 'check.response.body.positive': { type: 'yaml' }, 'check.response.body.negative': { type: 'yaml' }, + 'check.response.json': { type: 'yaml' }, 'ssl.certificate_authorities': { type: 'yaml' }, 'ssl.certificate': { type: 'yaml' }, 'ssl.key': { type: 'yaml' }, @@ -85,6 +88,9 @@ export const getTestProjectSyntheticsPolicy = ( origin: { type: 'text' }, 'monitor.project.id': { type: 'text' }, 'monitor.project.name': { type: 'text' }, + ipv4: { type: 'bool', value: true }, + ipv6: { type: 'bool', value: true }, + mode: { type: 'text' }, }, id: `synthetics/http-http-4b6abc6c-118b-4d93-a489-1135500d09f1-${projectId}-default-d70a46e0-22ea-11ed-8c6b-09a2d21dfbc3`, }, @@ -128,6 +134,9 @@ export const getTestProjectSyntheticsPolicy = ( origin: { type: 'text' }, 'monitor.project.id': { type: 'text' }, 'monitor.project.name': { type: 'text' }, + ipv4: { type: 'bool', value: true }, + ipv6: { type: 'bool', value: true }, + mode: { type: 'text' }, }, id: `synthetics/tcp-tcp-4b6abc6c-118b-4d93-a489-1135500d09f1-${projectId}-default-d70a46e0-22ea-11ed-8c6b-09a2d21dfbc3`, }, @@ -162,6 +171,9 @@ export const getTestProjectSyntheticsPolicy = ( origin: { type: 'text' }, 'monitor.project.id': { type: 'text' }, 'monitor.project.name': { type: 'text' }, + ipv4: { type: 'bool', value: true }, + ipv6: { type: 'bool', value: true }, + mode: { type: 'text' }, }, id: `synthetics/icmp-icmp-4b6abc6c-118b-4d93-a489-1135500d09f1-${projectId}-default-d70a46e0-22ea-11ed-8c6b-09a2d21dfbc3`, }, @@ -287,10 +299,7 @@ export const getTestProjectSyntheticsPolicy = ( }, id: `synthetics/browser-browser.network-4b6abc6c-118b-4d93-a489-1135500d09f1-${projectId}-default-d70a46e0-22ea-11ed-8c6b-09a2d21dfbc3`, compiled_stream: { - processors: [ - { add_observer_metadata: { geo: { name: 'Fleet managed' } } }, - { add_fields: { target: '', fields: { 'monitor.fleet_managed': true } } }, - ], + processors: [{ add_fields: { target: '', fields: { 'monitor.fleet_managed': true } } }], }, }, { @@ -306,10 +315,7 @@ export const getTestProjectSyntheticsPolicy = ( }, id: `synthetics/browser-browser.screenshot-4b6abc6c-118b-4d93-a489-1135500d09f1-${projectId}-default-d70a46e0-22ea-11ed-8c6b-09a2d21dfbc3`, compiled_stream: { - processors: [ - { add_observer_metadata: { geo: { name: 'Fleet managed' } } }, - { add_fields: { target: '', fields: { 'monitor.fleet_managed': true } } }, - ], + processors: [{ add_fields: { target: '', fields: { 'monitor.fleet_managed': true } } }], }, }, ], diff --git a/x-pack/test/api_integration/apis/synthetics/sync_global_params.ts b/x-pack/test/api_integration/apis/synthetics/sync_global_params.ts index acb6b4250628d..f5ff56d2ee506 100644 --- a/x-pack/test/api_integration/apis/synthetics/sync_global_params.ts +++ b/x-pack/test/api_integration/apis/synthetics/sync_global_params.ts @@ -44,7 +44,7 @@ export default function ({ getService }: FtrProviderContext) { before(async () => { await supertestAPI.post('/api/fleet/setup').set('kbn-xsrf', 'true').send().expect(200); await supertestAPI - .post('/api/fleet/epm/packages/synthetics/0.11.4') + .post('/api/fleet/epm/packages/synthetics/0.12.0') .set('kbn-xsrf', 'true') .send({ force: true }) .expect(200); diff --git a/x-pack/test/api_integration/apis/uptime/rest/fixtures/http_monitor.json b/x-pack/test/api_integration/apis/uptime/rest/fixtures/http_monitor.json index 091f18e6afe57..b2961eb0660d6 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/fixtures/http_monitor.json +++ b/x-pack/test/api_integration/apis/uptime/rest/fixtures/http_monitor.json @@ -25,9 +25,12 @@ "urls": "https://nextjs-test-synthetics.vercel.app/api/users", "url.port": null, "proxy_url": "http://proxy.com", + "proxy_headers": {}, "check.response.body.negative": [], "check.response.body.positive": [], + "check.response.json": [], "response.include_body": "never", + "response.include_body_max_bytes": "1024", "check.request.headers": { "sampleHeader": "sampleHeaderValue" }, @@ -81,5 +84,8 @@ "form_monitor_type": "http", "journey_id": "", "id": "", - "hash": "" + "hash": "", + "mode": "any", + "ipv4": true, + "ipv6": true } diff --git a/x-pack/test/api_integration/apis/uptime/rest/fixtures/icmp_monitor.json b/x-pack/test/api_integration/apis/uptime/rest/fixtures/icmp_monitor.json index ab172c3fe8ca5..ef94fcc067a98 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/fixtures/icmp_monitor.json +++ b/x-pack/test/api_integration/apis/uptime/rest/fixtures/icmp_monitor.json @@ -26,5 +26,8 @@ "origin": "ui", "form_monitor_type": "icmp", "id": "", - "hash": "" + "hash": "", + "mode": "any", + "ipv4": true, + "ipv6": true } diff --git a/x-pack/test/api_integration/apis/uptime/rest/fixtures/project_http_monitor.json b/x-pack/test/api_integration/apis/uptime/rest/fixtures/project_http_monitor.json index c8c2c27ce0f17..dd6d6eefecfcd 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/fixtures/project_http_monitor.json +++ b/x-pack/test/api_integration/apis/uptime/rest/fixtures/project_http_monitor.json @@ -56,7 +56,8 @@ } }, "response": { - "include_body": "always" + "include_body": "always", + "include_body_max_bytes": 900 }, "tags": "tag2,tag2", "response.include_headers": false, @@ -69,7 +70,8 @@ "Saved", "saved" ] - } + }, + "json": [{"description":"check status","expression":"foo.bar == \"myValue\""}] }, "hash": "ekrjelkjrelkjre", "ssl.verification_mode": "strict" diff --git a/x-pack/test/api_integration/apis/uptime/rest/fixtures/tcp_monitor.json b/x-pack/test/api_integration/apis/uptime/rest/fixtures/tcp_monitor.json index c3664c5646b16..a0390c0bc173e 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/fixtures/tcp_monitor.json +++ b/x-pack/test/api_integration/apis/uptime/rest/fixtures/tcp_monitor.json @@ -34,5 +34,8 @@ "origin": "ui", "form_monitor_type": "tcp", "id": "", - "hash": "" + "hash": "", + "mode": "any", + "ipv4": true, + "ipv6": true } From a04ad04dd5db128daf34b7dc0f77428a46ebaab4 Mon Sep 17 00:00:00 2001 From: Ignacio Rivas Date: Tue, 25 Apr 2023 14:26:18 +0200 Subject: [PATCH 10/29] [Grok] Migrate code editors to monaco (#155492) --- x-pack/plugins/grokdebugger/kibana.jsonc | 3 +- .../custom_patterns_input.js | 24 +++++----- .../components/event_input/event_input.js | 27 ++++++----- .../components/event_output/event_output.js | 25 ++++------- .../components/grok_debugger/brace_imports.ts | 10 ----- .../components/grok_debugger/grok_debugger.js | 1 - .../components/pattern_input/pattern_input.js | 28 ++++++------ .../public/lib/ace/grok_highlight_rules.js | 45 ------------------- .../grokdebugger/public/lib/ace/grok_mode.js | 18 -------- .../grokdebugger/public/lib/ace/index.js | 8 ---- .../grokdebugger/public/shared_imports.ts | 2 - .../page_objects/grok_debugger_page.ts | 21 +++------ .../test/functional/services/grok_debugger.js | 14 +++--- 13 files changed, 58 insertions(+), 168 deletions(-) delete mode 100644 x-pack/plugins/grokdebugger/public/components/grok_debugger/brace_imports.ts delete mode 100644 x-pack/plugins/grokdebugger/public/lib/ace/grok_highlight_rules.js delete mode 100644 x-pack/plugins/grokdebugger/public/lib/ace/grok_mode.js delete mode 100644 x-pack/plugins/grokdebugger/public/lib/ace/index.js diff --git a/x-pack/plugins/grokdebugger/kibana.jsonc b/x-pack/plugins/grokdebugger/kibana.jsonc index 340088efc772f..aa0bdc864142f 100644 --- a/x-pack/plugins/grokdebugger/kibana.jsonc +++ b/x-pack/plugins/grokdebugger/kibana.jsonc @@ -16,8 +16,7 @@ "devTools" ], "requiredBundles": [ - "kibanaReact", - "esUiShared" + "kibanaReact" ] } } diff --git a/x-pack/plugins/grokdebugger/public/components/custom_patterns_input/custom_patterns_input.js b/x-pack/plugins/grokdebugger/public/components/custom_patterns_input/custom_patterns_input.js index cac942fa44694..def839c3f9b09 100644 --- a/x-pack/plugins/grokdebugger/public/components/custom_patterns_input/custom_patterns_input.js +++ b/x-pack/plugins/grokdebugger/public/components/custom_patterns_input/custom_patterns_input.js @@ -6,11 +6,11 @@ */ import React from 'react'; +import { i18n } from '@kbn/i18n'; import { EuiAccordion, EuiCallOut, EuiCodeBlock, EuiFormRow, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { EDITOR } from '../../../common/constants'; -import { EuiCodeEditor } from '../../shared_imports'; +import { CodeEditor } from '@kbn/kibana-react-plugin/public'; export function CustomPatternsInput({ value, onChange }) { const sampleCustomPatterns = `POSTFIX_QUEUEID [0-9A-F]{10,11} @@ -43,18 +43,18 @@ MSG message-id=<%{GREEDYDATA}>`; - diff --git a/x-pack/plugins/grokdebugger/public/components/event_input/event_input.js b/x-pack/plugins/grokdebugger/public/components/event_input/event_input.js index 35b3be399fdce..2bfdce4f0a893 100644 --- a/x-pack/plugins/grokdebugger/public/components/event_input/event_input.js +++ b/x-pack/plugins/grokdebugger/public/components/event_input/event_input.js @@ -6,11 +6,10 @@ */ import React from 'react'; +import { i18n } from '@kbn/i18n'; import { EuiFormRow } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; - -import { EDITOR } from '../../../common/constants'; -import { EuiCodeEditor } from '../../shared_imports'; +import { CodeEditor } from '@kbn/kibana-react-plugin/public'; export function EventInput({ value, onChange }) { return ( @@ -19,20 +18,20 @@ export function EventInput({ value, onChange }) { } fullWidth - data-test-subj="aceEventInput" + data-test-subj="eventInput" > - ); diff --git a/x-pack/plugins/grokdebugger/public/components/event_output/event_output.js b/x-pack/plugins/grokdebugger/public/components/event_output/event_output.js index a2a02259c3fdf..e26672e467c3e 100644 --- a/x-pack/plugins/grokdebugger/public/components/event_output/event_output.js +++ b/x-pack/plugins/grokdebugger/public/components/event_output/event_output.js @@ -6,11 +6,9 @@ */ import React from 'react'; -import { EuiFormRow } from '@elastic/eui'; +import { EuiFormRow, EuiCodeBlock } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiCodeEditor } from '../../shared_imports'; - export function EventOutput({ value }) { return ( } fullWidth - data-test-subj="aceEventOutput" > - + + {JSON.stringify(value, null, 2)} + ); } diff --git a/x-pack/plugins/grokdebugger/public/components/grok_debugger/brace_imports.ts b/x-pack/plugins/grokdebugger/public/components/grok_debugger/brace_imports.ts deleted file mode 100644 index 4f81fd1744795..0000000000000 --- a/x-pack/plugins/grokdebugger/public/components/grok_debugger/brace_imports.ts +++ /dev/null @@ -1,10 +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 'brace/mode/json'; -import 'brace/mode/text'; -import 'brace/theme/textmate'; diff --git a/x-pack/plugins/grokdebugger/public/components/grok_debugger/grok_debugger.js b/x-pack/plugins/grokdebugger/public/components/grok_debugger/grok_debugger.js index f40948730872b..7eb4e9412edaa 100644 --- a/x-pack/plugins/grokdebugger/public/components/grok_debugger/grok_debugger.js +++ b/x-pack/plugins/grokdebugger/public/components/grok_debugger/grok_debugger.js @@ -11,7 +11,6 @@ import { i18n } from '@kbn/i18n'; // eslint-disable-next-line no-restricted-imports import isEmpty from 'lodash/isEmpty'; -import './brace_imports'; import { EuiForm, EuiButton, diff --git a/x-pack/plugins/grokdebugger/public/components/pattern_input/pattern_input.js b/x-pack/plugins/grokdebugger/public/components/pattern_input/pattern_input.js index 75af1453c6b40..096537bd8a6bf 100644 --- a/x-pack/plugins/grokdebugger/public/components/pattern_input/pattern_input.js +++ b/x-pack/plugins/grokdebugger/public/components/pattern_input/pattern_input.js @@ -6,12 +6,10 @@ */ import React from 'react'; +import { i18n } from '@kbn/i18n'; import { EuiFormRow } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; - -import { EDITOR } from '../../../common/constants'; -import { EuiCodeEditor } from '../../shared_imports'; -import { GrokMode } from '../../lib/ace'; +import { CodeEditor } from '@kbn/kibana-react-plugin/public'; export function PatternInput({ value, onChange }) { return ( @@ -20,20 +18,20 @@ export function PatternInput({ value, onChange }) { } fullWidth - data-test-subj="acePatternInput" + data-test-subj="patternInput" > - ); diff --git a/x-pack/plugins/grokdebugger/public/lib/ace/grok_highlight_rules.js b/x-pack/plugins/grokdebugger/public/lib/ace/grok_highlight_rules.js deleted file mode 100644 index 5987cd672ec05..0000000000000 --- a/x-pack/plugins/grokdebugger/public/lib/ace/grok_highlight_rules.js +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import ace from 'brace'; - -const { TextHighlightRules } = ace.acequire('ace/mode/text_highlight_rules'); - -export class GrokHighlightRules extends TextHighlightRules { - constructor() { - super(); - this.$rules = { - start: [ - { - token: ['grokStart', 'grokPatternName', 'grokSeparator', 'grokFieldName', 'grokEnd'], - regex: '(%{)([^:]+)(:)([^:]+)(})', - }, - { - token: [ - 'grokStart', - 'grokPatternName', - 'grokSeparator', - 'grokFieldName', - 'grokSeparator', - 'grokFieldType', - 'grokEnd', - ], - regex: '(%{)([^:]+)(:)([^:]+)(:)([^:]+)(})', - }, - { - token: (escapeToken /* regexToken */) => { - if (escapeToken) { - return ['grokEscape', 'grokEscaped']; - } - return 'grokRegex'; - }, - regex: '(\\\\)?([\\[\\]\\(\\)\\?\\:\\|])', - }, - ], - }; - } -} diff --git a/x-pack/plugins/grokdebugger/public/lib/ace/grok_mode.js b/x-pack/plugins/grokdebugger/public/lib/ace/grok_mode.js deleted file mode 100644 index a3f97de6dd934..0000000000000 --- a/x-pack/plugins/grokdebugger/public/lib/ace/grok_mode.js +++ /dev/null @@ -1,18 +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 ace from 'brace'; -import { GrokHighlightRules } from './grok_highlight_rules'; - -const TextMode = ace.acequire('ace/mode/text').Mode; - -export class GrokMode extends TextMode { - constructor() { - super(); - this.HighlightRules = GrokHighlightRules; - } -} diff --git a/x-pack/plugins/grokdebugger/public/lib/ace/index.js b/x-pack/plugins/grokdebugger/public/lib/ace/index.js deleted file mode 100644 index 3152baa94f0ec..0000000000000 --- a/x-pack/plugins/grokdebugger/public/lib/ace/index.js +++ /dev/null @@ -1,8 +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. - */ - -export { GrokMode } from './grok_mode'; diff --git a/x-pack/plugins/grokdebugger/public/shared_imports.ts b/x-pack/plugins/grokdebugger/public/shared_imports.ts index b2e82bce637f1..0bfbb3e05f933 100644 --- a/x-pack/plugins/grokdebugger/public/shared_imports.ts +++ b/x-pack/plugins/grokdebugger/public/shared_imports.ts @@ -5,6 +5,4 @@ * 2.0. */ -export { EuiCodeEditor } from '@kbn/es-ui-shared-plugin/public'; - export { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; diff --git a/x-pack/test/functional/page_objects/grok_debugger_page.ts b/x-pack/test/functional/page_objects/grok_debugger_page.ts index 06848c6b9ed3b..b7bad6ed57b9d 100644 --- a/x-pack/test/functional/page_objects/grok_debugger_page.ts +++ b/x-pack/test/functional/page_objects/grok_debugger_page.ts @@ -9,7 +9,7 @@ import { FtrService } from '../ftr_provider_context'; export class GrokDebuggerPageObject extends FtrService { private readonly testSubjects = this.ctx.getService('testSubjects'); - private readonly aceEditor = this.ctx.getService('aceEditor'); + private readonly monacoEditor = this.ctx.getService('monacoEditor'); private readonly retry = this.ctx.getService('retry'); async simulateButton() { @@ -17,30 +17,19 @@ export class GrokDebuggerPageObject extends FtrService { } async getEventOutput() { - return await this.aceEditor.getValue( - 'grokDebuggerContainer > aceEventOutput > codeEditorContainer' - ); + return await this.testSubjects.getVisibleText('eventOutputCodeBlock'); } async setEventInput(value: string) { - await this.aceEditor.setValue( - 'grokDebuggerContainer > aceEventInput > codeEditorContainer', - value - ); + await this.monacoEditor.setCodeEditorValue(value, 0); } async setPatternInput(pattern: string) { - await this.aceEditor.setValue( - 'grokDebuggerContainer > acePatternInput > codeEditorContainer', - pattern - ); + await this.monacoEditor.setCodeEditorValue(pattern, 1); } async setCustomPatternInput(customPattern: string) { - await this.aceEditor.setValue( - 'grokDebuggerContainer > aceCustomPatternsInput > codeEditorContainer', - customPattern - ); + await this.monacoEditor.setCodeEditorValue(customPattern, 2); } async toggleSetCustomPattern() { diff --git a/x-pack/test/functional/services/grok_debugger.js b/x-pack/test/functional/services/grok_debugger.js index 42a80edd70c85..618353130c20e 100644 --- a/x-pack/test/functional/services/grok_debugger.js +++ b/x-pack/test/functional/services/grok_debugger.js @@ -8,18 +8,14 @@ import expect from '@kbn/expect'; export function GrokDebuggerProvider({ getService }) { - const aceEditor = getService('aceEditor'); const testSubjects = getService('testSubjects'); const retry = getService('retry'); + const monacoEditor = getService('monacoEditor'); // test subject selectors const SUBJ_CONTAINER = 'grokDebuggerContainer'; - const SUBJ_UI_ACE_EVENT_INPUT = `${SUBJ_CONTAINER} > aceEventInput > codeEditorContainer`; const SUBJ_UI_ACE_PATTERN_INPUT = `${SUBJ_CONTAINER} > acePatternInput > codeEditorContainer`; - const SUBJ_UI_ACE_CUSTOM_PATTERNS_INPUT = `${SUBJ_CONTAINER} > aceCustomPatternsInput > codeEditorContainer`; - const SUBJ_UI_ACE_EVENT_OUTPUT = `${SUBJ_CONTAINER} > aceEventOutput > codeEditorContainer`; - const SUBJ_BTN_TOGGLE_CUSTOM_PATTERNS_INPUT = `${SUBJ_CONTAINER} > btnToggleCustomPatternsInput`; const SUBJ_BTN_SIMULATE = `${SUBJ_CONTAINER} > btnSimulate`; @@ -29,11 +25,11 @@ export function GrokDebuggerProvider({ getService }) { } async setEventInput(value) { - await aceEditor.setValue(SUBJ_UI_ACE_EVENT_INPUT, value); + await monacoEditor.setCodeEditorValue(value, 0); } async setPatternInput(value) { - await aceEditor.setValue(SUBJ_UI_ACE_PATTERN_INPUT, value); + await monacoEditor.setCodeEditorValue(value, 1); } async toggleCustomPatternsInput() { @@ -41,11 +37,11 @@ export function GrokDebuggerProvider({ getService }) { } async setCustomPatternsInput(value) { - await aceEditor.setValue(SUBJ_UI_ACE_CUSTOM_PATTERNS_INPUT, value); + await monacoEditor.setCodeEditorValue(value, 2); } async getEventOutput() { - return await aceEditor.getValue(SUBJ_UI_ACE_EVENT_OUTPUT); + return await testSubjects.getVisibleText('eventOutputCodeBlock'); } async assertExists() { From 8de71325a3016bdf138954ebdefe4f99b83f9990 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Tue, 25 Apr 2023 08:40:35 -0400 Subject: [PATCH 11/29] [Fleet] Add more tests for preconfigured outputs (#155656) --- .../output_preconfiguration.test.ts | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 x-pack/plugins/fleet/server/integration_tests/output_preconfiguration.test.ts diff --git a/x-pack/plugins/fleet/server/integration_tests/output_preconfiguration.test.ts b/x-pack/plugins/fleet/server/integration_tests/output_preconfiguration.test.ts new file mode 100644 index 0000000000000..81a14f6ddec40 --- /dev/null +++ b/x-pack/plugins/fleet/server/integration_tests/output_preconfiguration.test.ts @@ -0,0 +1,144 @@ +/* + * 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 Path from 'path'; + +import { + type TestElasticsearchUtils, + type TestKibanaUtils, + createRootWithCorePlugins, + createTestServers, +} from '@kbn/core-test-helpers-kbn-server'; + +import type { OutputSOAttributes } from '../types'; + +import { useDockerRegistry, waitForFleetSetup } from './helpers'; + +const logFilePath = Path.join(__dirname, 'logs.log'); + +describe('Fleet preconfigured outputs', () => { + let esServer: TestElasticsearchUtils; + let kbnServer: TestKibanaUtils; + + const registryUrl = useDockerRegistry(); + + const startServers = async (outputs: any) => { + const { startES } = createTestServers({ + adjustTimeout: (t) => jest.setTimeout(t), + settings: { + es: { + license: 'trial', + }, + kbn: {}, + }, + }); + + esServer = await startES(); + if (kbnServer) { + await kbnServer.stop(); + } + + const root = createRootWithCorePlugins( + { + xpack: { + fleet: { + outputs, + registryUrl, + }, + }, + logging: { + appenders: { + file: { + type: 'file', + fileName: logFilePath, + layout: { + type: 'json', + }, + }, + }, + loggers: [ + { + name: 'root', + appenders: ['file'], + }, + { + name: 'plugins.fleet', + level: 'all', + }, + ], + }, + }, + { oss: false } + ); + + await root.preboot(); + const coreSetup = await root.setup(); + const coreStart = await root.start(); + + kbnServer = { + root, + coreSetup, + coreStart, + stop: async () => await root.shutdown(), + }; + await waitForFleetSetup(kbnServer.root); + }; + + const stopServers = async () => { + if (kbnServer) { + await kbnServer.stop(); + } + + if (esServer) { + await esServer.stop(); + } + + await new Promise((res) => setTimeout(res, 10000)); + }; + + describe('Preconfigured outputs', () => { + describe('With a preconfigured monitoring output', () => { + beforeAll(async () => { + await startServers([ + { + name: 'Test output', + is_default_monitoring: true, + type: 'elasticsearch', + id: 'output-default-monitoring', + hosts: ['http://elasticsearch-alternative-url:9200'], + }, + ]); + }); + + afterAll(async () => { + await stopServers(); + }); + + it('Should create a default output and the default preconfigured output', async () => { + const outputs = await kbnServer.coreStart.savedObjects + .createInternalRepository() + .find({ + type: 'ingest-outputs', + perPage: 10000, + }); + + expect(outputs.total).toBe(2); + expect(outputs.saved_objects.filter((so) => so.attributes.is_default)).toHaveLength(1); + expect( + outputs.saved_objects.filter((so) => so.attributes.is_default_monitoring) + ).toHaveLength(1); + + const defaultDataOutput = outputs.saved_objects.find((so) => so.attributes.is_default); + const defaultMonitoringOutput = outputs.saved_objects.find( + (so) => so.attributes.is_default_monitoring + ); + expect(defaultDataOutput!.id).not.toBe(defaultMonitoringOutput!.id); + expect(defaultDataOutput!.attributes.is_default_monitoring).toBeFalsy(); + }); + }); + }); +}); From ab039e63f386a77bc9ab06da8820838496350788 Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Tue, 25 Apr 2023 13:56:19 +0100 Subject: [PATCH 12/29] [ML] Display info when no datafeed preview results are found (#155650) When an empty list is returned from the datafeed preview endpoint, it now displays a callout informing the user that there were no results. Closes https://github.com/elastic/kibana/issues/153306 ![image](https://user-images.githubusercontent.com/22172091/234065738-2c4f3d52-978b-4dec-9129-d755397eaa6f.png) Also rewrites the datafeed preview component in typescript. --- .../job_details/datafeed_preview_tab.js | 106 ------------------ .../job_details/datafeed_preview_tab.tsx | 102 +++++++++++++++++ .../services/ml_api_service/jobs.ts | 8 +- 3 files changed, 104 insertions(+), 112 deletions(-) delete mode 100644 x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/datafeed_preview_tab.js create mode 100644 x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/datafeed_preview_tab.tsx diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/datafeed_preview_tab.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/datafeed_preview_tab.js deleted file mode 100644 index f63511d857277..0000000000000 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/datafeed_preview_tab.js +++ /dev/null @@ -1,106 +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 PropTypes from 'prop-types'; -import React, { Component } from 'react'; - -import { EuiSpacer, EuiCallOut, EuiLoadingSpinner } from '@elastic/eui'; - -import { ml } from '../../../../services/ml_api_service'; -import { checkPermission } from '../../../../capabilities/check_capabilities'; -import { ML_DATA_PREVIEW_COUNT } from '../../../../../../common/util/job_utils'; -import { MLJobEditor } from '../ml_job_editor'; -import { FormattedMessage } from '@kbn/i18n-react'; - -export class DatafeedPreviewPane extends Component { - constructor(props) { - super(props); - - this.state = { - previewJson: '', - loading: true, - canPreviewDatafeed: true, - }; - } - - renderContent() { - const { previewJson, loading, canPreviewDatafeed } = this.state; - - if (canPreviewDatafeed === false) { - return ( - - } - color="warning" - iconType="warning" - > -

- -

-
- ); - } else if (loading === true) { - return ; - } else { - return ; - } - } - - componentDidMount() { - const canPreviewDatafeed = - checkPermission('canPreviewDatafeed') && this.props.job.datafeed_config !== undefined; - this.setState({ canPreviewDatafeed }); - - updateDatafeedPreview(this.props.job, canPreviewDatafeed) - .then((previewJson) => { - this.setState({ previewJson, loading: false }); - }) - .catch((error) => { - console.log('Datafeed preview could not be loaded', error); - this.setState({ loading: false }); - }); - } - - render() { - return ( - - - {this.renderContent()} - - ); - } -} -DatafeedPreviewPane.propTypes = { - job: PropTypes.object.isRequired, -}; - -function updateDatafeedPreview(job, canPreviewDatafeed) { - return new Promise((resolve, reject) => { - if (canPreviewDatafeed) { - ml.jobs - .datafeedPreview(job.datafeed_config.datafeed_id) - .then((resp) => { - if (Array.isArray(resp)) { - resolve(JSON.stringify(resp.slice(0, ML_DATA_PREVIEW_COUNT), null, 2)); - } else { - resolve(''); - console.log('Datafeed preview could not be loaded', resp); - } - }) - .catch((error) => { - reject(error); - }); - } - }); -} diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/datafeed_preview_tab.tsx b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/datafeed_preview_tab.tsx new file mode 100644 index 0000000000000..1ad46ce7bfce5 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_details/datafeed_preview_tab.tsx @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useEffect, useState } from 'react'; +import { EuiCallOut, EuiLoadingSpinner } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { ML_DATA_PREVIEW_COUNT } from '../../../../../../common/util/job_utils'; +import { useMlApiContext } from '../../../../contexts/kibana'; +import { usePermissionCheck } from '../../../../capabilities/check_capabilities'; +import { CombinedJob } from '../../../../../shared'; +import { MLJobEditor } from '../ml_job_editor'; + +interface Props { + job: CombinedJob; +} + +export const DatafeedPreviewPane: FC = ({ job }) => { + const { + jobs: { datafeedPreview }, + } = useMlApiContext(); + + const canPreviewDatafeed = usePermissionCheck('canPreviewDatafeed'); + const [loading, setLoading] = useState(false); + const [previewJson, setPreviewJson] = useState(''); + + useEffect(() => { + setLoading(true); + datafeedPreview(job.datafeed_config.datafeed_id).then((resp) => { + if (Array.isArray(resp)) { + if (resp.length === 0) { + setPreviewJson(null); + } else { + setPreviewJson(JSON.stringify(resp.slice(0, ML_DATA_PREVIEW_COUNT), null, 2)); + } + } else { + setPreviewJson(''); + } + + setLoading(false); + }); + }, [datafeedPreview, job]); + + if (canPreviewDatafeed === false) { + return ; + } + + return loading ? ( + + ) : ( + <> + {previewJson === null ? ( + + ) : ( + + )} + + ); +}; + +const InsufficientPermissions: FC = () => ( + + } + color="warning" + iconType="warning" + > +

+ +

+
+); + +const EmptyResults: FC = () => ( + + } + color="warning" + iconType="warning" + > +

+ +

+
+); diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts index 0e209c01ddb6a..1ab3a659442b1 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts @@ -368,8 +368,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({ ) { const body = JSON.stringify({ jobId, snapshotId, replay, end, calendarEvents }); return httpService.http<{ - total: number; - categories: Array<{ count?: number; category: Category }>; + success: boolean; }>({ path: `${ML_BASE_PATH}/jobs/revert_model_snapshot`, method: 'POST', @@ -379,10 +378,7 @@ export const jobsApiProvider = (httpService: HttpService) => ({ datafeedPreview(datafeedId?: string, job?: Job, datafeed?: Datafeed) { const body = JSON.stringify({ datafeedId, job, datafeed }); - return httpService.http<{ - total: number; - categories: Array<{ count?: number; category: Category }>; - }>({ + return httpService.http({ path: `${ML_BASE_PATH}/jobs/datafeed_preview`, method: 'POST', body, From e495581618571569cc6d20e91f5952e6b1894edd Mon Sep 17 00:00:00 2001 From: Shahzad Date: Tue, 25 Apr 2023 15:13:54 +0200 Subject: [PATCH 13/29] [Synthetics] Fixes exp view no data state (#155591) --- .../observability_data_views.test.ts | 1 + .../observability_data_views/observability_data_views.ts | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/x-pack/plugins/exploratory_view/public/utils/observability_data_views/observability_data_views.test.ts b/x-pack/plugins/exploratory_view/public/utils/observability_data_views/observability_data_views.test.ts index 4b9b904b73fc4..4bd40762a98fb 100644 --- a/x-pack/plugins/exploratory_view/public/utils/observability_data_views/observability_data_views.test.ts +++ b/x-pack/plugins/exploratory_view/public/utils/observability_data_views/observability_data_views.test.ts @@ -101,6 +101,7 @@ describe('ObservabilityDataViews', function () { expect(indexP).toEqual({ id: dataViewList.ux }); expect(dataViews?.createAndSave).toHaveBeenCalledWith({ + allowNoIndex: true, fieldFormats, id: 'rum_static_index_pattern_id_trace_apm_', timeFieldName: '@timestamp', diff --git a/x-pack/plugins/exploratory_view/public/utils/observability_data_views/observability_data_views.ts b/x-pack/plugins/exploratory_view/public/utils/observability_data_views/observability_data_views.ts index c22a2ae6d1712..ae3a60e7fe65b 100644 --- a/x-pack/plugins/exploratory_view/public/utils/observability_data_views/observability_data_views.ts +++ b/x-pack/plugins/exploratory_view/public/utils/observability_data_views/observability_data_views.ts @@ -140,11 +140,16 @@ export class ObservabilityDataViews { timeFieldName: '@timestamp', fieldFormats: this.getFieldFormats(app), name: DataTypesLabels[app], + allowNoIndex: true, }, false, false ); + if (dataView.matchedIndices.length === 0) { + throw new DataViewMissingIndices('No indices match pattern'); + } + if (runtimeFields !== null) { runtimeFields.forEach(({ name, field }) => { dataView.addRuntimeField(name, field); @@ -170,6 +175,7 @@ export class ObservabilityDataViews { timeFieldName: '@timestamp', fieldFormats: this.getFieldFormats(app), name: DataTypesLabels[app], + allowNoIndex: true, }); } // we want to make sure field formats remain same From 9dec953894ec928ccbd653329759da11ba46d84a Mon Sep 17 00:00:00 2001 From: Navarone Feekery <13634519+navarone-feekery@users.noreply.github.com> Date: Tue, 25 Apr 2023 15:14:24 +0200 Subject: [PATCH 14/29] [Enterprise Search] Update native connector config fields (#155606) ## Summary For native connectors: - Update configurable field labels - Add missing fields and field properties --- .../common/connectors/native_connectors.ts | 80 +++++++++++++++++-- 1 file changed, 74 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/enterprise_search/common/connectors/native_connectors.ts b/x-pack/plugins/enterprise_search/common/connectors/native_connectors.ts index 78a720304d996..bccfe15835a63 100644 --- a/x-pack/plugins/enterprise_search/common/connectors/native_connectors.ts +++ b/x-pack/plugins/enterprise_search/common/connectors/native_connectors.ts @@ -19,7 +19,7 @@ export const NATIVE_CONNECTOR_DEFINITIONS: Record Date: Tue, 25 Apr 2023 09:16:09 -0400 Subject: [PATCH 15/29] [Security Solution][Endpoint] Cypress test to validate that Endpoint can stream alerts to ES/Kibana (#155455) ## Summary - Adds cypress test that stands up a real endpoint and validates that it can trigger alerts and send those to ES/Kbn and that they show up on the Alerts list --- .../endpoint_agent_status.test.tsx | 2 +- .../endpoint_agent_status.tsx | 8 +- .../public/management/cypress/cypress.d.ts | 21 ++ .../e2e/endpoint/endpoint_alerts.cy.ts | 107 +++++++++ .../management/cypress/screens/alerts.ts | 41 ++++ .../management/cypress/screens/endpoints.ts | 6 +- .../cypress/support/data_loaders.ts | 53 +++++ .../public/management/cypress/support/e2e.ts | 38 ++++ .../public/management/cypress/tasks/alerts.ts | 170 +++++++++++++++ .../cypress/tasks/delete_all_endpoint_data.ts | 14 ++ .../cypress/tasks/endpoint_policy.ts | 63 ++++++ .../cypress/tasks/response_actions.ts | 41 ++++ .../management/cypress_endpoint.config.ts | 6 +- .../common/delete_all_endpoint_data.ts | 77 +++++++ .../endpoint/common/endpoint_host_services.ts | 205 ++++++++++++++++++ .../common/endpoint_metadata_services.ts | 43 ++++ .../scripts/endpoint/common/fleet_services.ts | 142 +++++++++++- .../endpoint/common/security_user_services.ts | 31 ++- .../scripts/endpoint/common/stack_services.ts | 6 +- .../endpoint_config.ts | 4 + 20 files changed, 1067 insertions(+), 11 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/cypress/e2e/endpoint/endpoint_alerts.cy.ts create mode 100644 x-pack/plugins/security_solution/public/management/cypress/screens/alerts.ts create mode 100644 x-pack/plugins/security_solution/public/management/cypress/tasks/alerts.ts create mode 100644 x-pack/plugins/security_solution/public/management/cypress/tasks/delete_all_endpoint_data.ts create mode 100644 x-pack/plugins/security_solution/public/management/cypress/tasks/endpoint_policy.ts create mode 100644 x-pack/plugins/security_solution/scripts/endpoint/common/delete_all_endpoint_data.ts create mode 100644 x-pack/plugins/security_solution/scripts/endpoint/common/endpoint_host_services.ts diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/endpoint_agent_status/endpoint_agent_status.test.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/endpoint_agent_status/endpoint_agent_status.test.tsx index bf6f85712b6c7..7fa169b32d348 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/endpoint_agent_status/endpoint_agent_status.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/endpoint_agent_status/endpoint_agent_status.test.tsx @@ -344,7 +344,7 @@ describe('When showing Endpoint Agent Status', () => { }); it('should keep agent status up to date when autoRefresh is true', async () => { - renderProps.autoFresh = true; + renderProps.autoRefresh = true; apiMocks.responseProvider.metadataDetails.mockReturnValueOnce(endpointDetails); const { getByTestId } = render(); diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/endpoint_agent_status/endpoint_agent_status.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/endpoint_agent_status/endpoint_agent_status.tsx index e74cdcf41fd57..1b2d021634a2f 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/endpoint_agent_status/endpoint_agent_status.tsx +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/endpoint_agent_status/endpoint_agent_status.tsx @@ -138,7 +138,7 @@ export interface EndpointAgentStatusByIdProps { * If set to `true` (Default), then the endpoint status and isolation/action counts will * be kept up to date by querying the API periodically */ - autoFresh?: boolean; + autoRefresh?: boolean; 'data-test-subj'?: string; } @@ -150,9 +150,9 @@ export interface EndpointAgentStatusByIdProps { * instead in order to avoid duplicate API calls. */ export const EndpointAgentStatusById = memo( - ({ endpointAgentId, autoFresh, 'data-test-subj': dataTestSubj }) => { + ({ endpointAgentId, autoRefresh, 'data-test-subj': dataTestSubj }) => { const { data } = useGetEndpointDetails(endpointAgentId, { - refetchInterval: autoFresh ? DEFAULT_POLL_INTERVAL : false, + refetchInterval: autoRefresh ? DEFAULT_POLL_INTERVAL : false, }); const emptyValue = ( @@ -169,7 +169,7 @@ export const EndpointAgentStatusById = memo( ); } diff --git a/x-pack/plugins/security_solution/public/management/cypress/cypress.d.ts b/x-pack/plugins/security_solution/public/management/cypress/cypress.d.ts index c461f712b75b5..51cd74de62f67 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/cypress.d.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/cypress.d.ts @@ -10,6 +10,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { CasePostRequest } from '@kbn/cases-plugin/common/api'; +import type { DeleteAllEndpointDataResponse } from '../../../scripts/endpoint/common/delete_all_endpoint_data'; import type { IndexedEndpointPolicyResponse } from '../../../common/endpoint/data_loaders/index_endpoint_policy_response'; import type { HostPolicyResponse, @@ -56,6 +57,20 @@ declare global { ...args: Parameters['find']> ): Chainable>; + /** + * Continuously call provided callback function until it either return `true` + * or fail if `timeout` is reached. + * @param fn + * @param options + */ + waitUntil( + fn: (subject?: any) => boolean | Promise | Chainable, + options?: Partial<{ + interval: number; + timeout: number; + }> + ): Chainable; + task( name: 'indexFleetEndpointPolicy', arg: { @@ -124,6 +139,12 @@ declare global { arg: HostActionResponse, options?: Partial ): Chainable; + + task( + name: 'deleteAllEndpointData', + arg: { endpointAgentIds: string[] }, + options?: Partial + ): Chainable; } } } diff --git a/x-pack/plugins/security_solution/public/management/cypress/e2e/endpoint/endpoint_alerts.cy.ts b/x-pack/plugins/security_solution/public/management/cypress/e2e/endpoint/endpoint_alerts.cy.ts new file mode 100644 index 0000000000000..8163e74db17b1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/cypress/e2e/endpoint/endpoint_alerts.cy.ts @@ -0,0 +1,107 @@ +/* + * 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 { deleteAllLoadedEndpointData } from '../../tasks/delete_all_endpoint_data'; +import { getAlertsTableRows, navigateToAlertsList } from '../../screens/alerts'; +import { waitForEndpointAlerts } from '../../tasks/alerts'; +import { request } from '../../tasks/common'; +import { getEndpointIntegrationVersion } from '../../tasks/fleet'; +import type { IndexedFleetEndpointPolicyResponse } from '../../../../../common/endpoint/data_loaders/index_fleet_endpoint_policy'; +import { enableAllPolicyProtections } from '../../tasks/endpoint_policy'; +import type { PolicyData, ResponseActionApiResponse } from '../../../../../common/endpoint/types'; +import type { CreateAndEnrollEndpointHostResponse } from '../../../../../scripts/endpoint/common/endpoint_host_services'; +import { login } from '../../tasks/login'; +import { EXECUTE_ROUTE } from '../../../../../common/endpoint/constants'; +import { waitForActionToComplete } from '../../tasks/response_actions'; + +describe('Endpoint generated alerts', () => { + let indexedPolicy: IndexedFleetEndpointPolicyResponse; + let policy: PolicyData; + let createdHost: CreateAndEnrollEndpointHostResponse; + + before(() => { + getEndpointIntegrationVersion().then((version) => { + const policyName = `alerts test ${Math.random().toString(36).substring(2, 7)}`; + + cy.task('indexFleetEndpointPolicy', { + policyName, + endpointPackageVersion: version, + agentPolicyName: policyName, + }).then((data) => { + indexedPolicy = data; + policy = indexedPolicy.integrationPolicies[0]; + + return enableAllPolicyProtections(policy.id).then(() => { + // Create and enroll a new Endpoint host + return cy + .task( + 'createEndpointHost', + { + agentPolicyId: policy.policy_id, + }, + { timeout: 180000 } + ) + .then((host) => { + createdHost = host as CreateAndEnrollEndpointHostResponse; + }); + }); + }); + }); + }); + + after(() => { + if (createdHost) { + cy.task('destroyEndpointHost', createdHost).then(() => {}); + } + + if (indexedPolicy) { + cy.task('deleteIndexedFleetEndpointPolicies', indexedPolicy); + } + + if (createdHost) { + deleteAllLoadedEndpointData({ endpointAgentIds: [createdHost.agentId] }); + } + }); + + beforeEach(() => { + login(); + }); + + it('should create a Detection Engine alert from an endpoint alert', () => { + // Triggers a Malicious Behaviour alert on Linux system (`grep *` was added only to identify this specific alert) + const executeMaliciousCommand = `bash -c cat /dev/tcp/foo | grep ${Math.random() + .toString(16) + .substring(2)}`; + + // Send `execute` command that triggers malicious behaviour using the `execute` response action + request({ + method: 'POST', + url: EXECUTE_ROUTE, + body: { + endpoint_ids: [createdHost.agentId], + parameters: { + command: executeMaliciousCommand, + }, + }, + }) + .then((response) => waitForActionToComplete(response.body.data.id)) + .then(() => { + return waitForEndpointAlerts(createdHost.agentId, [ + { + term: { 'process.group_leader.args': executeMaliciousCommand }, + }, + ]); + }) + .then(() => { + return navigateToAlertsList( + `query=(language:kuery,query:'agent.id: "${createdHost.agentId}" ')` + ); + }); + + getAlertsTableRows().should('have.length.greaterThan', 0); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/cypress/screens/alerts.ts b/x-pack/plugins/security_solution/public/management/cypress/screens/alerts.ts new file mode 100644 index 0000000000000..48f0747464bf8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/cypress/screens/alerts.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { APP_ALERTS_PATH } from '../../../../common/constants'; + +export const navigateToAlertsList = (urlQueryParams: string = '') => { + cy.visit(`${APP_ALERTS_PATH}${urlQueryParams ? `?${urlQueryParams}` : ''}`); +}; + +export const clickAlertListRefreshButton = (): Cypress.Chainable => { + return cy.getByTestSubj('querySubmitButton').click().should('be.enabled'); +}; + +/** + * Waits until the Alerts list has alerts data and return the number of rows that are currently displayed + * @param timeout + */ +export const getAlertsTableRows = (timeout?: number): Cypress.Chainable> => { + let $rows: JQuery = Cypress.$(); + + return cy + .waitUntil( + () => { + clickAlertListRefreshButton(); + + return cy + .getByTestSubj('alertsTable') + .find('.euiDataGridRow') + .then(($rowsFound) => { + $rows = $rowsFound; + return Boolean($rows); + }); + }, + { timeout } + ) + .then(() => $rows); +}; diff --git a/x-pack/plugins/security_solution/public/management/cypress/screens/endpoints.ts b/x-pack/plugins/security_solution/public/management/cypress/screens/endpoints.ts index 32a12168aadb0..da2c278b8e30d 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/screens/endpoints.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/screens/endpoints.ts @@ -6,7 +6,7 @@ */ import { APP_PATH } from '../../../../common/constants'; -import { getEndpointDetailsPath } from '../../common/routing'; +import { getEndpointDetailsPath, getEndpointListPath } from '../../common/routing'; export const AGENT_HOSTNAME_CELL = 'hostnameCellLink'; export const AGENT_POLICY_CELL = 'policyNameCellLink'; @@ -21,3 +21,7 @@ export const navigateToEndpointPolicyResponse = ( getEndpointDetailsPath({ name: 'endpointPolicyResponse', selected_endpoint: endpointAgentId }) ); }; + +export const navigateToEndpointList = (): Cypress.Chainable => { + return cy.visit(APP_PATH + getEndpointListPath({ name: 'endpointList' })); +}; diff --git a/x-pack/plugins/security_solution/public/management/cypress/support/data_loaders.ts b/x-pack/plugins/security_solution/public/management/cypress/support/data_loaders.ts index 6dd4bedaa8937..ffcca01a6f1e9 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/support/data_loaders.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/support/data_loaders.ts @@ -9,6 +9,13 @@ import type { CasePostRequest } from '@kbn/cases-plugin/common/api'; import { sendEndpointActionResponse } from '../../../../scripts/endpoint/agent_emulator/services/endpoint_response_actions'; +import type { DeleteAllEndpointDataResponse } from '../../../../scripts/endpoint/common/delete_all_endpoint_data'; +import { deleteAllEndpointData } from '../../../../scripts/endpoint/common/delete_all_endpoint_data'; +import { waitForEndpointToStreamData } from '../../../../scripts/endpoint/common/endpoint_metadata_services'; +import type { + CreateAndEnrollEndpointHostOptions, + CreateAndEnrollEndpointHostResponse, +} from '../../../../scripts/endpoint/common/endpoint_host_services'; import type { IndexedEndpointPolicyResponse } from '../../../../common/endpoint/data_loaders/index_endpoint_policy_response'; import { deleteIndexedEndpointPolicyResponse, @@ -39,6 +46,10 @@ import { deleteIndexedEndpointRuleAlerts, indexEndpointRuleAlerts, } from '../../../../common/endpoint/data_loaders/index_endpoint_rule_alerts'; +import { + createAndEnrollEndpointHost, + destroyEndpointHost, +} from '../../../../scripts/endpoint/common/endpoint_host_services'; /** * Cypress plugin for adding data loading related `task`s @@ -155,5 +166,47 @@ export const dataLoaders = ( const { esClient } = await stackServicesPromise; return sendEndpointActionResponse(esClient, data.action, { state: data.state.state }); }, + + deleteAllEndpointData: async ({ + endpointAgentIds, + }: { + endpointAgentIds: string[]; + }): Promise => { + const { esClient } = await stackServicesPromise; + return deleteAllEndpointData(esClient, endpointAgentIds); + }, + }); +}; + +export const dataLoadersForRealEndpoints = ( + on: Cypress.PluginEvents, + config: Cypress.PluginConfigOptions +): void => { + const stackServicesPromise = createRuntimeServices({ + kibanaUrl: config.env.KIBANA_URL, + elasticsearchUrl: config.env.ELASTICSEARCH_URL, + username: config.env.ELASTICSEARCH_USERNAME, + password: config.env.ELASTICSEARCH_PASSWORD, + asSuperuser: true, + }); + + on('task', { + createEndpointHost: async ( + options: Omit + ): Promise => { + const { kbnClient, log } = await stackServicesPromise; + return createAndEnrollEndpointHost({ ...options, log, kbnClient }).then((newHost) => { + return waitForEndpointToStreamData(kbnClient, newHost.agentId, 120000).then(() => { + return newHost; + }); + }); + }, + + destroyEndpointHost: async ( + createdHost: CreateAndEnrollEndpointHostResponse + ): Promise => { + const { kbnClient } = await stackServicesPromise; + return destroyEndpointHost(kbnClient, createdHost).then(() => null); + }, }); }; diff --git a/x-pack/plugins/security_solution/public/management/cypress/support/e2e.ts b/x-pack/plugins/security_solution/public/management/cypress/support/e2e.ts index 0ccb00e8d5e63..12c236f481791 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/support/e2e.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/support/e2e.ts @@ -50,4 +50,42 @@ Cypress.Commands.addQuery<'findByTestSubj'>( } ); +Cypress.Commands.add( + 'waitUntil', + { prevSubject: 'optional' }, + (subject, fn, { interval = 500, timeout = 30000 } = {}) => { + let attempts = Math.floor(timeout / interval); + + const completeOrRetry = (result: boolean) => { + if (result) { + return result; + } + if (attempts < 1) { + throw new Error(`Timed out while retrying, last result was: {${result}}`); + } + cy.wait(interval, { log: false }).then(() => { + attempts--; + return evaluate(); + }); + }; + + const evaluate = () => { + const result = fn(subject); + + if (typeof result === 'boolean') { + return completeOrRetry(result); + } else if ('then' in result) { + // @ts-expect-error + return result.then(completeOrRetry); + } else { + throw new Error( + `Unknown return type from callback: ${Object.prototype.toString.call(result)}` + ); + } + }; + + return evaluate(); + } +); + Cypress.on('uncaught:exception', () => false); diff --git a/x-pack/plugins/security_solution/public/management/cypress/tasks/alerts.ts b/x-pack/plugins/security_solution/public/management/cypress/tasks/alerts.ts new file mode 100644 index 0000000000000..a78b1c6742afa --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/cypress/tasks/alerts.ts @@ -0,0 +1,170 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { estypes } from '@elastic/elasticsearch'; +import type { Rule } from '../../../detection_engine/rule_management/logic'; +import { + DETECTION_ENGINE_QUERY_SIGNALS_URL, + DETECTION_ENGINE_RULES_BULK_ACTION, + DETECTION_ENGINE_RULES_URL, +} from '../../../../common/constants'; +import { ELASTIC_SECURITY_RULE_ID } from '../../../../common'; +import { request } from './common'; +import { ENDPOINT_ALERTS_INDEX } from '../../../../scripts/endpoint/common/constants'; +const ES_URL = Cypress.env('ELASTICSEARCH_URL'); + +/** + * Continuously check for any alert to have been received by the given endpoint. + * + * NOTE: This is tno the same as the alerts that populate the Alerts list. To check for + * those types of alerts, use `waitForDetectionAlerts()` + */ +export const waitForEndpointAlerts = ( + endpointAgentId: string, + additionalFilters?: object[], + timeout = 120000 +): Cypress.Chainable => { + return cy + .waitUntil( + () => { + return request({ + method: 'GET', + url: `${ES_URL}/${ENDPOINT_ALERTS_INDEX}/_search`, + body: { + query: { + match: { + 'agent.id': endpointAgentId, + }, + }, + size: 1, + _source: false, + }, + }).then(({ body: streamedAlerts }) => { + return (streamedAlerts.hits.total as estypes.SearchTotalHits).value > 0; + }); + }, + { timeout } + ) + .then(() => { + // Stop/start Endpoint rule so that it can pickup and create Detection alerts + cy.log( + `Received endpoint alerts for agent [${endpointAgentId}] in index [${ENDPOINT_ALERTS_INDEX}]` + ); + + return stopStartEndpointDetectionsRule(); + }) + .then(() => { + // wait until the Detection alert shows up in the API + return waitForDetectionAlerts(getEndpointDetectionAlertsQueryForAgentId(endpointAgentId)); + }); +}; + +export const fetchEndpointSecurityDetectionRule = (): Cypress.Chainable => { + return request({ + method: 'GET', + url: DETECTION_ENGINE_RULES_URL, + qs: { + rule_id: ELASTIC_SECURITY_RULE_ID, + }, + }).then(({ body }) => { + return body; + }); +}; + +export const stopStartEndpointDetectionsRule = (): Cypress.Chainable => { + return fetchEndpointSecurityDetectionRule() + .then((endpointRule) => { + // Disabled it + return request({ + method: 'POST', + url: DETECTION_ENGINE_RULES_BULK_ACTION, + body: { + action: 'disable', + ids: [endpointRule.id], + }, + }).then(() => { + return endpointRule; + }); + }) + .then((endpointRule) => { + cy.log(`Endpoint rule id [${endpointRule.id}] has been disabled`); + + // Re-enable it + return request({ + method: 'POST', + url: DETECTION_ENGINE_RULES_BULK_ACTION, + body: { + action: 'enable', + ids: [endpointRule.id], + }, + }).then(() => endpointRule); + }) + .then((endpointRule) => { + cy.log(`Endpoint rule id [${endpointRule.id}] has been re-enabled`); + return cy.wrap(endpointRule); + }); +}; + +/** + * Waits for alerts to have been loaded by continuously calling the detections engine alerts + * api until data shows up + * @param query + * @param timeout + */ +export const waitForDetectionAlerts = ( + /** The ES query. Defaults to `{ match_all: {} }` */ + query: object = { match_all: {} }, + timeout?: number +): Cypress.Chainable => { + return cy.waitUntil( + () => { + return request({ + method: 'POST', + url: DETECTION_ENGINE_QUERY_SIGNALS_URL, + body: { + query, + size: 1, + }, + }).then(({ body: alertsResponse }) => { + return Boolean((alertsResponse.hits.total as estypes.SearchTotalHits)?.value ?? 0); + }); + }, + { timeout } + ); +}; + +/** + * Builds and returns the ES `query` object for use in querying for Endpoint Detection Engine + * alerts. Can be used in ES searches or with the Detection Engine query signals (alerts) url. + * @param endpointAgentId + */ +export const getEndpointDetectionAlertsQueryForAgentId = (endpointAgentId: string) => { + return { + bool: { + filter: [ + { + bool: { + should: [{ match_phrase: { 'agent.type': 'endpoint' } }], + minimum_should_match: 1, + }, + }, + { + bool: { + should: [{ match_phrase: { 'agent.id': endpointAgentId } }], + minimum_should_match: 1, + }, + }, + { + bool: { + should: [{ exists: { field: 'kibana.alert.rule.uuid' } }], + minimum_should_match: 1, + }, + }, + ], + }, + }; +}; diff --git a/x-pack/plugins/security_solution/public/management/cypress/tasks/delete_all_endpoint_data.ts b/x-pack/plugins/security_solution/public/management/cypress/tasks/delete_all_endpoint_data.ts new file mode 100644 index 0000000000000..761cde513ad52 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/cypress/tasks/delete_all_endpoint_data.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { DeleteAllEndpointDataResponse } from '../../../../scripts/endpoint/common/delete_all_endpoint_data'; + +export const deleteAllLoadedEndpointData = (options: { + endpointAgentIds: string[]; +}): Cypress.Chainable => { + return cy.task('deleteAllEndpointData', options); +}; diff --git a/x-pack/plugins/security_solution/public/management/cypress/tasks/endpoint_policy.ts b/x-pack/plugins/security_solution/public/management/cypress/tasks/endpoint_policy.ts new file mode 100644 index 0000000000000..134fc470b412b --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/cypress/tasks/endpoint_policy.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + GetOnePackagePolicyResponse, + UpdatePackagePolicy, + UpdatePackagePolicyResponse, +} from '@kbn/fleet-plugin/common'; +import { packagePolicyRouteService } from '@kbn/fleet-plugin/common'; +import { request } from './common'; +import { ProtectionModes } from '../../../../common/endpoint/types'; + +/** + * Updates the given Endpoint policy and enables all of the policy protections + * @param endpointPolicyId + */ +export const enableAllPolicyProtections = ( + endpointPolicyId: string +): Cypress.Chainable> => { + return request({ + method: 'GET', + url: packagePolicyRouteService.getInfoPath(endpointPolicyId), + }).then(({ body: { item: endpointPolicy } }) => { + const { + created_by: _createdBy, + created_at: _createdAt, + updated_at: _updatedAt, + updated_by: _updatedBy, + id, + version, + revision, + ...restOfPolicy + } = endpointPolicy; + + const updatedEndpointPolicy: UpdatePackagePolicy = restOfPolicy; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const policy = updatedEndpointPolicy!.inputs[0]!.config!.policy.value; + + policy.mac.malware.mode = ProtectionModes.prevent; + policy.windows.malware.mode = ProtectionModes.prevent; + policy.linux.malware.mode = ProtectionModes.prevent; + + policy.mac.memory_protection.mode = ProtectionModes.prevent; + policy.windows.memory_protection.mode = ProtectionModes.prevent; + policy.linux.memory_protection.mode = ProtectionModes.prevent; + + policy.mac.behavior_protection.mode = ProtectionModes.prevent; + policy.windows.behavior_protection.mode = ProtectionModes.prevent; + policy.linux.behavior_protection.mode = ProtectionModes.prevent; + + policy.windows.ransomware.mode = ProtectionModes.prevent; + + return request({ + method: 'PUT', + url: packagePolicyRouteService.getUpdatePath(endpointPolicyId), + body: updatedEndpointPolicy, + }); + }); +}; diff --git a/x-pack/plugins/security_solution/public/management/cypress/tasks/response_actions.ts b/x-pack/plugins/security_solution/public/management/cypress/tasks/response_actions.ts index c888e7dce1254..13829f8d3378c 100644 --- a/x-pack/plugins/security_solution/public/management/cypress/tasks/response_actions.ts +++ b/x-pack/plugins/security_solution/public/management/cypress/tasks/response_actions.ts @@ -5,6 +5,10 @@ * 2.0. */ +import { request } from './common'; +import { resolvePathVariables } from '../../../common/utils/resolve_path_variables'; +import { ACTION_DETAILS_ROUTE } from '../../../../common/endpoint/constants'; +import type { ActionDetails, ActionDetailsApiResponse } from '../../../../common/endpoint/types'; import { ENABLED_AUTOMATED_RESPONSE_ACTION_COMMANDS } from '../../../../common/endpoint/service/response_actions/constants'; export const validateAvailableCommands = () => { @@ -59,3 +63,40 @@ export const tryAddingDisabledResponseAction = (itemNumber = 0) => { }); cy.getByTestSubj(`response-actions-list-item-${itemNumber}`).should('not.exist'); }; + +/** + * Continuously checks an Response Action until it completes (or timeout is reached) + * @param actionId + * @param timeout + */ +export const waitForActionToComplete = ( + actionId: string, + timeout = 60000 +): Cypress.Chainable => { + let action: ActionDetails | undefined; + + return cy + .waitUntil( + () => { + return request({ + method: 'GET', + url: resolvePathVariables(ACTION_DETAILS_ROUTE, { action_id: actionId || 'undefined' }), + }).then((response) => { + if (response.body.data.isCompleted) { + action = response.body.data; + return true; + } + + return false; + }); + }, + { timeout } + ) + .then(() => { + if (!action) { + throw new Error(`Failed to retrieve completed action`); + } + + return action; + }); +}; diff --git a/x-pack/plugins/security_solution/public/management/cypress_endpoint.config.ts b/x-pack/plugins/security_solution/public/management/cypress_endpoint.config.ts index 8975599350fe2..50a9d8f1f5356 100644 --- a/x-pack/plugins/security_solution/public/management/cypress_endpoint.config.ts +++ b/x-pack/plugins/security_solution/public/management/cypress_endpoint.config.ts @@ -7,7 +7,7 @@ import { defineCypressConfig } from '@kbn/cypress-config'; // eslint-disable-next-line @kbn/imports/no_boundary_crossing -import { dataLoaders } from './cypress/support/data_loaders'; +import { dataLoaders, dataLoadersForRealEndpoints } from './cypress/support/data_loaders'; // eslint-disable-next-line import/no-default-export export default defineCypressConfig({ @@ -40,7 +40,9 @@ export default defineCypressConfig({ specPattern: 'public/management/cypress/e2e/endpoint/*.cy.{js,jsx,ts,tsx}', experimentalRunAllSpecs: true, setupNodeEvents: (on: Cypress.PluginEvents, config: Cypress.PluginConfigOptions) => { - return dataLoaders(on, config); + dataLoaders(on, config); + // Data loaders specific to "real" Endpoint testing + dataLoadersForRealEndpoints(on, config); }, }, }); diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/delete_all_endpoint_data.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/delete_all_endpoint_data.ts new file mode 100644 index 0000000000000..6382964fda643 --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/delete_all_endpoint_data.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Client, estypes } from '@elastic/elasticsearch'; +import assert from 'assert'; +import { createEsClient } from './stack_services'; +import { createSecuritySuperuser } from './security_user_services'; + +export interface DeleteAllEndpointDataResponse { + count: number; + query: string; + response: estypes.DeleteByQueryResponse; +} + +/** + * Attempts to delete all data associated with the provided endpoint agent IDs. + * + * **NOTE:** This utility will create a new role and user that has elevated privileges and access to system indexes. + * + * @param esClient + * @param endpointAgentIds + */ +export const deleteAllEndpointData = async ( + esClient: Client, + endpointAgentIds: string[] +): Promise => { + assert(endpointAgentIds.length > 0, 'At least one endpoint agent id must be defined'); + + const unrestrictedUser = await createSecuritySuperuser(esClient, 'super_superuser'); + const esUrl = getEsUrlFromClient(esClient); + const esClientUnrestricted = createEsClient({ + url: esUrl, + username: unrestrictedUser.username, + password: unrestrictedUser.password, + }); + + const queryString = endpointAgentIds.map((id) => `(${id})`).join(' OR '); + + const deleteResponse = await esClientUnrestricted.deleteByQuery({ + index: '*,.*', + body: { + query: { + query_string: { + query: queryString, + }, + }, + }, + ignore_unavailable: true, + conflicts: 'proceed', + }); + + return { + count: deleteResponse.deleted ?? 0, + query: queryString, + response: deleteResponse, + }; +}; + +const getEsUrlFromClient = (esClient: Client) => { + const connection = esClient.connectionPool.connections.find((entry) => entry.status === 'alive'); + + if (!connection) { + throw new Error( + 'Unable to get esClient connection information. No connection found with status `alive`' + ); + } + + const url = new URL(connection.url.href); + url.username = ''; + url.password = ''; + + return url.href; +}; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/endpoint_host_services.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/endpoint_host_services.ts new file mode 100644 index 0000000000000..4bb03324f172e --- /dev/null +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/endpoint_host_services.ts @@ -0,0 +1,205 @@ +/* + * 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 { kibanaPackageJson } from '@kbn/repo-info'; +import type { KbnClient } from '@kbn/test'; +import type { ToolingLog } from '@kbn/tooling-log'; +import execa from 'execa'; +import assert from 'assert'; +import { + fetchAgentPolicyEnrollmentKey, + fetchFleetServerUrl, + getAgentDownloadUrl, + unEnrollFleetAgent, + waitForHostToEnroll, +} from './fleet_services'; + +export interface CreateAndEnrollEndpointHostOptions + extends Pick { + kbnClient: KbnClient; + log: ToolingLog; + /** The fleet Agent Policy ID to use for enrolling the agent */ + agentPolicyId: string; + /** version of the Agent to install. Defaults to stack version */ + version?: string; + /** The name for the host. Will also be the name of the VM */ + hostname?: string; +} + +export interface CreateAndEnrollEndpointHostResponse { + hostname: string; + agentId: string; +} + +/** + * Creates a new virtual machine (host) and enrolls that with Fleet + */ +export const createAndEnrollEndpointHost = async ({ + kbnClient, + log, + agentPolicyId, + cpus, + disk, + memory, + hostname, + version = kibanaPackageJson.version, +}: CreateAndEnrollEndpointHostOptions): Promise => { + const [vm, agentDownloadUrl, fleetServerUrl, enrollmentToken] = await Promise.all([ + createMultipassVm({ + vmName: hostname ?? `test-host-${Math.random().toString().substring(2, 6)}`, + disk, + cpus, + memory, + }), + + getAgentDownloadUrl(version, true, log), + + fetchFleetServerUrl(kbnClient), + + fetchAgentPolicyEnrollmentKey(kbnClient, agentPolicyId), + ]); + + // Some validations before we proceed + assert(agentDownloadUrl, 'Missing agent download URL'); + assert(fleetServerUrl, 'Fleet server URL not set'); + assert(enrollmentToken, `No enrollment token for agent policy id [${agentPolicyId}]`); + + log.verbose(`Enrolling host [${vm.vmName}] + with fleet-server [${fleetServerUrl}] + using enrollment token [${enrollmentToken}]`); + + const { agentId } = await enrollHostWithFleet({ + kbnClient, + log, + fleetServerUrl, + agentDownloadUrl, + enrollmentToken, + vmName: vm.vmName, + }); + + return { + hostname: vm.vmName, + agentId, + }; +}; + +/** + * Destroys the Endpoint Host VM and un-enrolls the Fleet agent + * @param kbnClient + * @param createdHost + */ +export const destroyEndpointHost = async ( + kbnClient: KbnClient, + createdHost: CreateAndEnrollEndpointHostResponse +): Promise => { + await Promise.all([ + deleteMultipassVm(createdHost.hostname), + unEnrollFleetAgent(kbnClient, createdHost.agentId, true), + ]); +}; + +interface CreateMultipassVmOptions { + vmName: string; + /** Number of CPUs */ + cpus?: number; + /** Disk size */ + disk?: string; + /** Amount of memory */ + memory?: string; +} + +interface CreateMultipassVmResponse { + vmName: string; +} + +/** + * Creates a new VM using `multipass` + */ +const createMultipassVm = async ({ + vmName, + disk = '8G', + cpus = 1, + memory = '1G', +}: CreateMultipassVmOptions): Promise => { + await execa.command( + `multipass launch --name ${vmName} --disk ${disk} --cpus ${cpus} --memory ${memory}` + ); + + return { + vmName, + }; +}; + +const deleteMultipassVm = async (vmName: string): Promise => { + await execa.command(`multipass delete -p ${vmName}`); +}; + +interface EnrollHostWithFleetOptions { + kbnClient: KbnClient; + log: ToolingLog; + vmName: string; + agentDownloadUrl: string; + fleetServerUrl: string; + enrollmentToken: string; +} + +const enrollHostWithFleet = async ({ + kbnClient, + log, + vmName, + fleetServerUrl, + agentDownloadUrl, + enrollmentToken, +}: EnrollHostWithFleetOptions): Promise<{ agentId: string }> => { + const agentDownloadedFile = agentDownloadUrl.substring(agentDownloadUrl.lastIndexOf('/') + 1); + const vmDirName = agentDownloadedFile.replace(/\.tar\.gz$/, ''); + + await execa.command( + `multipass exec ${vmName} -- curl -L ${agentDownloadUrl} -o ${agentDownloadedFile}` + ); + await execa.command(`multipass exec ${vmName} -- tar -zxf ${agentDownloadedFile}`); + await execa.command(`multipass exec ${vmName} -- rm -f ${agentDownloadedFile}`); + + const agentInstallArguments = [ + 'exec', + + vmName, + + '--working-directory', + `/home/ubuntu/${vmDirName}`, + + '--', + + 'sudo', + + './elastic-agent', + + 'install', + + '--insecure', + + '--force', + + '--url', + fleetServerUrl, + + '--enrollment-token', + enrollmentToken, + ]; + + log.info(`Enrolling elastic agent with Fleet`); + log.verbose(`Command: multipass ${agentInstallArguments.join(' ')}`); + + await execa(`multipass`, agentInstallArguments); + + log.info(`Waiting for Agent to check-in with Fleet`); + const agent = await waitForHostToEnroll(kbnClient, vmName, 120000); + + return { + agentId: agent.id, + }; +}; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/endpoint_metadata_services.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/endpoint_metadata_services.ts index a1f21b80567d6..7823309aa0059 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/common/endpoint_metadata_services.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/endpoint_metadata_services.ts @@ -131,3 +131,46 @@ const fetchLastStreamedEndpointUpdate = async ( return queryResult.hits?.hits[0]?._source; }; + +/** + * Waits for an endpoint to have streamed data to ES and for that data to have made it to the + * Endpoint Details API (transform destination index) + * @param kbnClient + * @param endpointAgentId + * @param timeoutMs + */ +export const waitForEndpointToStreamData = async ( + kbnClient: KbnClient, + endpointAgentId: string, + timeoutMs: number = 60000 +): Promise => { + const started = new Date(); + const hasTimedOut = (): boolean => { + const elapsedTime = Date.now() - started.getTime(); + return elapsedTime > timeoutMs; + }; + let found: HostInfo | undefined; + + while (!found && !hasTimedOut()) { + found = await fetchEndpointMetadata(kbnClient, 'invalid-id-test').catch((error) => { + // Ignore `not found` (404) responses. Endpoint could be new and thus documents might not have + // been streamed yet. + if (error?.response?.status === 404) { + return undefined; + } + + throw error; + }); + + if (!found) { + // sleep and check again + await new Promise((r) => setTimeout(r, 2000)); + } + } + + if (!found) { + throw new Error(`Timed out waiting for Endpoint id [${endpointAgentId}] to stream data to ES`); + } + + return found; +}; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/fleet_services.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/fleet_services.ts index 1f4d28cecc569..fca93d1848f4a 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/common/fleet_services.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/fleet_services.ts @@ -14,7 +14,12 @@ import type { GetAgentPoliciesResponse, GetAgentsResponse, } from '@kbn/fleet-plugin/common'; -import { AGENT_API_ROUTES, agentPolicyRouteService, AGENTS_INDEX } from '@kbn/fleet-plugin/common'; +import { + AGENT_API_ROUTES, + agentPolicyRouteService, + agentRouteService, + AGENTS_INDEX, +} from '@kbn/fleet-plugin/common'; import { ToolingLog } from '@kbn/tooling-log'; import type { KbnClient } from '@kbn/test'; import type { GetFleetServerHostsResponse } from '@kbn/fleet-plugin/common/types/rest_spec/fleet_server_hosts'; @@ -26,7 +31,10 @@ import type { EnrollmentAPIKey, GetAgentsRequest, GetEnrollmentAPIKeysResponse, + PostAgentUnenrollResponse, } from '@kbn/fleet-plugin/common/types'; +import nodeFetch from 'node-fetch'; +import semver from 'semver'; import { FleetAgentGenerator } from '../../../common/endpoint/data_generators/fleet_agent_generator'; const fleetGenerator = new FleetAgentGenerator(); @@ -236,3 +244,135 @@ export const getAgentVersionMatchingCurrentStack = async ( return version; }; + +interface ElasticArtifactSearchResponse { + manifest: { + 'last-update-time': string; + 'seconds-since-last-update': number; + }; + packages: { + [packageFileName: string]: { + architecture: string; + os: string[]; + type: string; + asc_url: string; + sha_url: string; + url: string; + }; + }; +} + +/** + * Retrieves the download URL to the Linux installation package for a given version of the Elastic Agent + * @param version + * @param closestMatch + * @param log + */ +export const getAgentDownloadUrl = async ( + version: string, + /** + * When set to true a check will be done to determine the latest version of the agent that + * is less than or equal to the `version` provided + */ + closestMatch: boolean = false, + log?: ToolingLog +): Promise => { + const agentVersion = closestMatch ? await getLatestAgentDownloadVersion(version, log) : version; + const downloadArch = + { arm64: 'arm64', x64: 'x86_64' }[process.arch] ?? `UNSUPPORTED_ARCHITECTURE_${process.arch}`; + const agentFile = `elastic-agent-${agentVersion}-linux-${downloadArch}.tar.gz`; + const artifactSearchUrl = `https://artifacts-api.elastic.co/v1/search/${agentVersion}/${agentFile}`; + + log?.verbose(`Retrieving elastic agent download URL from:\n ${artifactSearchUrl}`); + + const searchResult: ElasticArtifactSearchResponse = await nodeFetch(artifactSearchUrl).then( + (response) => { + if (!response.ok) { + throw new Error( + `Failed to search elastic's artifact repository: ${response.statusText} (HTTP ${response.status}) {URL: ${artifactSearchUrl})` + ); + } + + return response.json(); + } + ); + + log?.verbose(searchResult); + + if (!searchResult.packages[agentFile]) { + throw new Error(`Unable to find an Agent download URL for version [${agentVersion}]`); + } + + return searchResult.packages[agentFile].url; +}; + +/** + * Given a stack version number, function will return the closest Agent download version available + * for download. THis could be the actual version passed in or lower. + * @param version + */ +export const getLatestAgentDownloadVersion = async ( + version: string, + log?: ToolingLog +): Promise => { + const artifactsUrl = 'https://artifacts-api.elastic.co/v1/versions'; + const semverMatch = `<=${version}`; + const artifactVersionsResponse: { versions: string[] } = await nodeFetch(artifactsUrl).then( + (response) => { + if (!response.ok) { + throw new Error( + `Failed to retrieve list of versions from elastic's artifact repository: ${response.statusText} (HTTP ${response.status}) {URL: ${artifactsUrl})` + ); + } + + return response.json(); + } + ); + + const stackVersionToArtifactVersion: Record = + artifactVersionsResponse.versions.reduce((acc, artifactVersion) => { + const stackVersion = artifactVersion.split('-SNAPSHOT')[0]; + acc[stackVersion] = artifactVersion; + return acc; + }, {} as Record); + + log?.verbose( + `Versions found from [${artifactsUrl}]:\n${JSON.stringify( + stackVersionToArtifactVersion, + null, + 2 + )}` + ); + + const matchedVersion = semver.maxSatisfying( + Object.keys(stackVersionToArtifactVersion), + semverMatch + ); + + if (!matchedVersion) { + throw new Error(`Unable to find a semver version that meets ${semverMatch}`); + } + + return stackVersionToArtifactVersion[matchedVersion]; +}; + +/** + * Un-enrolls a Fleet agent + * + * @param kbnClient + * @param agentId + * @param force + */ +export const unEnrollFleetAgent = async ( + kbnClient: KbnClient, + agentId: string, + force = false +): Promise => { + const { data } = await kbnClient.request({ + method: 'POST', + path: agentRouteService.getUnenrollPath(agentId), + body: { revoke: force }, + }); + + return data; +}; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/security_user_services.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/security_user_services.ts index dab9e2b6abd27..f17bf7b514f21 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/common/security_user_services.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/security_user_services.ts @@ -17,12 +17,41 @@ export const createSecuritySuperuser = async ( throw new Error(`username and password require values.`); } + // Create a role which has full access to restricted indexes + await esClient.transport.request({ + method: 'POST', + path: '_security/role/superuser_restricted_indices', + body: { + cluster: ['all'], + indices: [ + { + names: ['*'], + privileges: ['all'], + allow_restricted_indices: true, + }, + { + names: ['*'], + privileges: ['monitor', 'read', 'view_index_metadata', 'read_cross_cluster'], + allow_restricted_indices: true, + }, + ], + applications: [ + { + application: '*', + privileges: ['*'], + resources: ['*'], + }, + ], + run_as: ['*'], + }, + }); + const addedUser = await esClient.transport.request>({ method: 'POST', path: `_security/user/${username}`, body: { password, - roles: ['superuser', 'kibana_system'], + roles: ['superuser', 'kibana_system', 'superuser_restricted_indices'], full_name: username, }, }); diff --git a/x-pack/plugins/security_solution/scripts/endpoint/common/stack_services.ts b/x-pack/plugins/security_solution/scripts/endpoint/common/stack_services.ts index 424f451c3fdc6..f7ba4c1a5b514 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/common/stack_services.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/common/stack_services.ts @@ -99,7 +99,11 @@ export const createRuntimeServices = async ({ }; }; -const buildUrlWithCredentials = (url: string, username: string, password: string): string => { +export const buildUrlWithCredentials = ( + url: string, + username: string, + password: string +): string => { const newUrl = new URL(url); newUrl.username = username; diff --git a/x-pack/test/defend_workflows_cypress/endpoint_config.ts b/x-pack/test/defend_workflows_cypress/endpoint_config.ts index f1ea9a9c81a12..e6191cea7074b 100644 --- a/x-pack/test/defend_workflows_cypress/endpoint_config.ts +++ b/x-pack/test/defend_workflows_cypress/endpoint_config.ts @@ -8,6 +8,7 @@ import { getLocalhostRealIp } from '@kbn/security-solution-plugin/scripts/endpoint/common/localhost_services'; import { FtrConfigProviderContext } from '@kbn/test'; +import { ExperimentalFeatures } from '@kbn/security-solution-plugin/common/experimental_features'; import { DefendWorkflowsCypressEndpointTestRunner } from './runner'; export default async function ({ readConfigFile }: FtrConfigProviderContext) { @@ -15,6 +16,8 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { const config = defendWorkflowsCypressConfig.getAll(); const hostIp = getLocalhostRealIp(); + const enabledFeatureFlags: Array = ['responseActionExecuteEnabled']; + return { ...config, kbnTestServer: { @@ -27,6 +30,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { )}`, // set the packagerTaskInterval to 5s in order to speed up test executions when checking fleet artifacts '--xpack.securitySolution.packagerTaskInterval=5s', + `--xpack.securitySolution.enableExperimental=${JSON.stringify(enabledFeatureFlags)}`, ], }, testRunner: DefendWorkflowsCypressEndpointTestRunner, From aa67e22b0ee7d94610db8464bec346751edda7b9 Mon Sep 17 00:00:00 2001 From: Julia Date: Tue, 25 Apr 2023 15:20:27 +0200 Subject: [PATCH 16/29] [RAM] Create slack web api connector (#154359) ## Summary Create separate Slack Web API connector ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --------- Co-authored-by: Xavier Mouligneau Co-authored-by: Julian Gernun <17549662+jcger@users.noreply.github.com> --- .../common/slack_api/constants.ts | 9 + .../stack_connectors/common/slack_api/lib.ts | 79 +++++ .../common/slack_api/schema.ts | 30 ++ .../common/slack_api/types.ts | 67 ++++ .../public/connector_types/index.ts | 6 +- .../public/connector_types/slack/index.ts | 2 +- .../public/connector_types/slack/slack.tsx | 30 ++ .../slack/slack_connectors.test.tsx | 2 +- .../connector_types/slack_api/index.tsx | 8 + .../slack_api/slack_api.test.tsx | 88 +++++ .../connector_types/slack_api/slack_api.tsx | 76 +++++ .../slack_api/slack_connectors.test.tsx | 77 +++++ .../slack_api/slack_connectors.tsx | 36 +++ .../slack_api/slack_params.test.tsx | 201 ++++++++++++ .../slack_api/slack_params.tsx | 209 ++++++++++++ .../connector_types/slack_api/translations.ts | 42 +++ .../server/connector_types/index.ts | 12 +- .../connector_types/slack_api/api.test.ts | 101 ++++++ .../server/connector_types/slack_api/api.ts | 24 ++ .../connector_types/slack_api/index.test.ts | 303 ++++++++++++++++++ .../server/connector_types/slack_api/index.ts | 124 +++++++ .../connector_types/slack_api/service.test.ts | 181 +++++++++++ .../connector_types/slack_api/service.ts | 162 ++++++++++ .../connector_types/slack_api/translations.ts | 12 + .../stack_connectors/server/plugin.test.ts | 20 +- .../plugins/stack_connectors/server/types.ts | 6 +- .../synthetics/common/rules/alert_actions.ts | 4 +- .../plugins/synthetics/common/rules/types.ts | 4 +- .../action_connector_form/action_form.tsx | 66 +++- .../action_type_form.tsx | 2 + .../action_type_menu.test.tsx | 20 +- .../action_type_menu.tsx | 18 +- .../connector_add_inline.tsx | 9 +- .../connector_add_modal.tsx | 147 ++++++++- .../action_connector_form/connector_form.tsx | 21 +- .../connectors_selection.tsx | 27 +- .../create_connector_flyout/index.tsx | 75 ++++- .../application/sections/common/connectors.ts | 6 +- .../triggers_actions_ui/public/types.ts | 4 + .../alerting_api_integration/common/config.ts | 2 + .../actions/connector_types/slack_api.ts | 76 +++++ .../{slack.ts => slack_webhook.ts} | 2 +- .../group2/tests/actions/index.ts | 5 +- .../check_registered_connector_types.ts | 1 + .../test/functional/services/actions/index.ts | 2 + .../test/functional/services/actions/slack.ts | 53 +++ .../triggers_actions_ui/connectors/index.ts | 1 + .../triggers_actions_ui/connectors/slack.ts | 208 ++++++++++++ .../functional_with_es_ssl/config.base.ts | 1 + .../check_registered_task_types.ts | 1 + 50 files changed, 2588 insertions(+), 74 deletions(-) create mode 100644 x-pack/plugins/stack_connectors/common/slack_api/constants.ts create mode 100644 x-pack/plugins/stack_connectors/common/slack_api/lib.ts create mode 100644 x-pack/plugins/stack_connectors/common/slack_api/schema.ts create mode 100644 x-pack/plugins/stack_connectors/common/slack_api/types.ts create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/slack_api/index.tsx create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_api.test.tsx create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_api.tsx create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.test.tsx create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.tsx create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.test.tsx create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.tsx create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/slack_api/translations.ts create mode 100644 x-pack/plugins/stack_connectors/server/connector_types/slack_api/api.test.ts create mode 100644 x-pack/plugins/stack_connectors/server/connector_types/slack_api/api.ts create mode 100644 x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.test.ts create mode 100644 x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.ts create mode 100644 x-pack/plugins/stack_connectors/server/connector_types/slack_api/service.test.ts create mode 100644 x-pack/plugins/stack_connectors/server/connector_types/slack_api/service.ts create mode 100644 x-pack/plugins/stack_connectors/server/connector_types/slack_api/translations.ts create mode 100644 x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/slack_api.ts rename x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/{slack.ts => slack_webhook.ts} (99%) create mode 100644 x-pack/test/functional/services/actions/slack.ts create mode 100644 x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/slack.ts diff --git a/x-pack/plugins/stack_connectors/common/slack_api/constants.ts b/x-pack/plugins/stack_connectors/common/slack_api/constants.ts new file mode 100644 index 0000000000000..3c107e1c05342 --- /dev/null +++ b/x-pack/plugins/stack_connectors/common/slack_api/constants.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const SLACK_API_CONNECTOR_ID = '.slack_api'; +export const SLACK_URL = 'https://slack.com/api/'; diff --git a/x-pack/plugins/stack_connectors/common/slack_api/lib.ts b/x-pack/plugins/stack_connectors/common/slack_api/lib.ts new file mode 100644 index 0000000000000..449b1aef56b14 --- /dev/null +++ b/x-pack/plugins/stack_connectors/common/slack_api/lib.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ActionTypeExecutorResult as ConnectorTypeExecutorResult } from '@kbn/actions-plugin/server/types'; +import { i18n } from '@kbn/i18n'; + +export function successResult( + actionId: string, + data: unknown +): ConnectorTypeExecutorResult { + return { status: 'ok', data, actionId }; +} + +export function errorResult(actionId: string, message: string): ConnectorTypeExecutorResult { + return { + status: 'error', + message, + actionId, + }; +} +export function serviceErrorResult( + actionId: string, + serviceMessage?: string +): ConnectorTypeExecutorResult { + const errMessage = i18n.translate('xpack.stackConnectors.slack.errorPostingErrorMessage', { + defaultMessage: 'error posting slack message', + }); + return { + status: 'error', + message: errMessage, + actionId, + serviceMessage, + }; +} + +export function retryResult(actionId: string, message: string): ConnectorTypeExecutorResult { + const errMessage = i18n.translate( + 'xpack.stackConnectors.slack.errorPostingRetryLaterErrorMessage', + { + defaultMessage: 'error posting a slack message, retry later', + } + ); + return { + status: 'error', + message: errMessage, + retry: true, + actionId, + }; +} + +export function retryResultSeconds( + actionId: string, + message: string, + retryAfter: number +): ConnectorTypeExecutorResult { + const retryEpoch = Date.now() + retryAfter * 1000; + const retry = new Date(retryEpoch); + const retryString = retry.toISOString(); + const errMessage = i18n.translate( + 'xpack.stackConnectors.slack.errorPostingRetryDateErrorMessage', + { + defaultMessage: 'error posting a slack message, retry at {retryString}', + values: { + retryString, + }, + } + ); + return { + status: 'error', + message: errMessage, + retry, + actionId, + serviceMessage: message, + }; +} diff --git a/x-pack/plugins/stack_connectors/common/slack_api/schema.ts b/x-pack/plugins/stack_connectors/common/slack_api/schema.ts new file mode 100644 index 0000000000000..a1060f3290b28 --- /dev/null +++ b/x-pack/plugins/stack_connectors/common/slack_api/schema.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +export const SlackApiSecretsSchema = schema.object({ + token: schema.string({ minLength: 1 }), +}); + +export const GetChannelsParamsSchema = schema.object({ + subAction: schema.literal('getChannels'), +}); + +export const PostMessageSubActionParamsSchema = schema.object({ + channels: schema.arrayOf(schema.string()), + text: schema.string(), +}); +export const PostMessageParamsSchema = schema.object({ + subAction: schema.literal('postMessage'), + subActionParams: PostMessageSubActionParamsSchema, +}); + +export const SlackApiParamsSchema = schema.oneOf([ + GetChannelsParamsSchema, + PostMessageParamsSchema, +]); diff --git a/x-pack/plugins/stack_connectors/common/slack_api/types.ts b/x-pack/plugins/stack_connectors/common/slack_api/types.ts new file mode 100644 index 0000000000000..1098d40eded19 --- /dev/null +++ b/x-pack/plugins/stack_connectors/common/slack_api/types.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ActionType as ConnectorType } from '@kbn/actions-plugin/server/types'; +import { TypeOf } from '@kbn/config-schema'; +import type { ActionTypeExecutorOptions as ConnectorTypeExecutorOptions } from '@kbn/actions-plugin/server/types'; +import type { ActionTypeExecutorResult as ConnectorTypeExecutorResult } from '@kbn/actions-plugin/server/types'; +import { + PostMessageParamsSchema, + PostMessageSubActionParamsSchema, + SlackApiSecretsSchema, + SlackApiParamsSchema, +} from './schema'; + +export type SlackApiSecrets = TypeOf; + +export type PostMessageParams = TypeOf; +export type PostMessageSubActionParams = TypeOf; +export type SlackApiParams = TypeOf; +export type SlackApiConnectorType = ConnectorType<{}, SlackApiSecrets, SlackApiParams, unknown>; + +export type SlackApiExecutorOptions = ConnectorTypeExecutorOptions< + {}, + SlackApiSecrets, + SlackApiParams +>; + +export type SlackExecutorOptions = ConnectorTypeExecutorOptions< + {}, + SlackApiSecrets, + SlackApiParams +>; + +export type SlackApiActionParams = TypeOf; + +export interface GetChannelsResponse { + ok: true; + error?: string; + channels?: Array<{ + id: string; + name: string; + is_channel: boolean; + is_archived: boolean; + is_private: boolean; + }>; +} + +export interface PostMessageResponse { + ok: boolean; + channel?: string; + error?: string; + message?: { + text: string; + }; +} + +export interface SlackApiService { + getChannels: () => Promise>; + postMessage: ({ + channels, + text, + }: PostMessageSubActionParams) => Promise>; +} diff --git a/x-pack/plugins/stack_connectors/public/connector_types/index.ts b/x-pack/plugins/stack_connectors/public/connector_types/index.ts index 55b2a31d2ca80..2cedad5996a8b 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/index.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/index.ts @@ -18,7 +18,8 @@ import { getServerLogConnectorType } from './server_log'; import { getServiceNowITOMConnectorType } from './servicenow_itom'; import { getServiceNowITSMConnectorType } from './servicenow_itsm'; import { getServiceNowSIRConnectorType } from './servicenow_sir'; -import { getSlackConnectorType } from './slack'; +import { getSlackWebhookConnectorType } from './slack'; +import { getSlackApiConnectorType } from './slack_api'; import { getSwimlaneConnectorType } from './swimlane'; import { getTeamsConnectorType } from './teams'; import { getTinesConnectorType } from './tines'; @@ -41,7 +42,8 @@ export function registerConnectorTypes({ services: RegistrationServices; }) { connectorTypeRegistry.register(getServerLogConnectorType()); - connectorTypeRegistry.register(getSlackConnectorType()); + connectorTypeRegistry.register(getSlackWebhookConnectorType()); + connectorTypeRegistry.register(getSlackApiConnectorType()); connectorTypeRegistry.register(getEmailConnectorType(services)); connectorTypeRegistry.register(getIndexConnectorType()); connectorTypeRegistry.register(getPagerDutyConnectorType()); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack/index.ts b/x-pack/plugins/stack_connectors/public/connector_types/slack/index.ts index 05d27afff76fb..74a96853ab149 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/slack/index.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { getConnectorType as getSlackConnectorType } from './slack'; +export { getConnectorType as getSlackWebhookConnectorType } from './slack'; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack/slack.tsx b/x-pack/plugins/stack_connectors/public/connector_types/slack/slack.tsx index fabfe46a4db12..c1b4b72182fa9 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/slack/slack.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack/slack.tsx @@ -12,10 +12,28 @@ import type { GenericValidationResult, } from '@kbn/triggers-actions-ui-plugin/public/types'; import { SlackActionParams, SlackSecrets } from '../types'; +import { PostMessageParams } from '../../../common/slack_api/types'; + +export const subtype = [ + { + id: '.slack', + name: i18n.translate('xpack.stackConnectors.components.slack.webhook', { + defaultMessage: 'Webhook', + }), + }, + { + id: '.slack_api', + name: i18n.translate('xpack.stackConnectors.components.slack.webApi', { + defaultMessage: 'Web API', + }), + }, +]; export function getConnectorType(): ConnectorTypeModel { return { id: '.slack', + subtype, + modalWidth: 675, iconClass: 'logoSlack', selectMessage: i18n.translate('xpack.stackConnectors.components.slack.selectMessageText', { defaultMessage: 'Send a message to a Slack channel or user.', @@ -38,5 +56,17 @@ export function getConnectorType(): ConnectorTypeModel import('./slack_connectors')), actionParamsFields: lazy(() => import('./slack_params')), + convertParamsBetweenGroups: ( + params: PostMessageParams | SlackActionParams + ): PostMessageParams | SlackActionParams | {} => { + if ('message' in params) { + return params; + } else if ('subAction' in params) { + return { + message: (params as PostMessageParams).subActionParams.text, + }; + } + return {}; + }, }; } diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack/slack_connectors.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/slack/slack_connectors.test.tsx index 0910565276216..e81dec2d662c2 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/slack/slack_connectors.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack/slack_connectors.test.tsx @@ -91,7 +91,7 @@ describe('SlackActionFields renders', () => { }); }); - it('validates teh web hook url field correctly', async () => { + it('validates the web hook url field correctly', async () => { const actionConnector = { secrets: { webhookUrl: 'http://test.com', diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/index.tsx b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/index.tsx new file mode 100644 index 0000000000000..258473accdd68 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/index.tsx @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { getConnectorType as getSlackApiConnectorType } from './slack_api'; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_api.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_api.test.tsx new file mode 100644 index 0000000000000..17ed4f9380e09 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_api.test.tsx @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TypeRegistry } from '@kbn/triggers-actions-ui-plugin/public/application/type_registry'; +import type { ActionTypeModel as ConnectorTypeModel } from '@kbn/triggers-actions-ui-plugin/public/types'; +import { registerConnectorTypes } from '..'; +import { registrationServicesMock } from '../../mocks'; +import { SLACK_API_CONNECTOR_ID } from '../../../common/slack_api/constants'; + +let connectorTypeModel: ConnectorTypeModel; + +beforeAll(async () => { + const connectorTypeRegistry = new TypeRegistry(); + registerConnectorTypes({ connectorTypeRegistry, services: registrationServicesMock }); + const getResult = connectorTypeRegistry.get(SLACK_API_CONNECTOR_ID); + if (getResult !== null) { + connectorTypeModel = getResult; + } +}); + +describe('connectorTypeRegistry.get works', () => { + test('connector type static data is as expected', () => { + expect(connectorTypeModel.id).toEqual(SLACK_API_CONNECTOR_ID); + expect(connectorTypeModel.iconClass).toEqual('logoSlack'); + }); +}); + +describe('Slack action params validation', () => { + test('should succeed when action params include valid message and channels list', async () => { + const actionParams = { + subAction: 'postMessage', + subActionParams: { channels: ['general'], text: 'some text' }, + }; + + expect(await connectorTypeModel.validateParams(actionParams)).toEqual({ + errors: { + text: [], + channels: [], + }, + }); + }); + + test('should fail when channels field is missing in action params', async () => { + const actionParams = { + subAction: 'postMessage', + subActionParams: { text: 'some text' }, + }; + + expect(await connectorTypeModel.validateParams(actionParams)).toEqual({ + errors: { + text: [], + channels: ['Selected channel is required.'], + }, + }); + }); + + test('should fail when field text does not exist', async () => { + const actionParams = { + subAction: 'postMessage', + subActionParams: { channels: ['general'] }, + }; + + expect(await connectorTypeModel.validateParams(actionParams)).toEqual({ + errors: { + text: ['Message is required.'], + channels: [], + }, + }); + }); + + test('should fail when text is empty string', async () => { + const actionParams = { + subAction: 'postMessage', + subActionParams: { channels: ['general'], text: '' }, + }; + + expect(await connectorTypeModel.validateParams(actionParams)).toEqual({ + errors: { + text: ['Message is required.'], + channels: [], + }, + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_api.tsx b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_api.tsx new file mode 100644 index 0000000000000..6b985dbb90e34 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_api.tsx @@ -0,0 +1,76 @@ +/* + * 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 { lazy } from 'react'; +import type { + ActionTypeModel as ConnectorTypeModel, + GenericValidationResult, +} from '@kbn/triggers-actions-ui-plugin/public/types'; +import { + ACTION_TYPE_TITLE, + CHANNEL_REQUIRED, + MESSAGE_REQUIRED, + SELECT_MESSAGE, +} from './translations'; +import type { + SlackApiActionParams, + SlackApiSecrets, + PostMessageParams, +} from '../../../common/slack_api/types'; +import { SLACK_API_CONNECTOR_ID } from '../../../common/slack_api/constants'; +import { SlackActionParams } from '../types'; +import { subtype } from '../slack/slack'; + +export const getConnectorType = (): ConnectorTypeModel< + unknown, + SlackApiSecrets, + PostMessageParams +> => ({ + id: SLACK_API_CONNECTOR_ID, + subtype, + hideInUi: true, + modalWidth: 675, + iconClass: 'logoSlack', + selectMessage: SELECT_MESSAGE, + actionTypeTitle: ACTION_TYPE_TITLE, + validateParams: async ( + actionParams: SlackApiActionParams + ): Promise> => { + const errors = { + text: new Array(), + channels: new Array(), + }; + const validationResult = { errors }; + if (actionParams.subAction === 'postMessage') { + if (!actionParams.subActionParams.text) { + errors.text.push(MESSAGE_REQUIRED); + } + if (!actionParams.subActionParams.channels?.length) { + errors.channels.push(CHANNEL_REQUIRED); + } + } + return validationResult; + }, + actionConnectorFields: lazy(() => import('./slack_connectors')), + actionParamsFields: lazy(() => import('./slack_params')), + convertParamsBetweenGroups: ( + params: SlackActionParams | PostMessageParams + ): SlackActionParams | PostMessageParams | {} => { + if ('message' in params) { + return { + subAction: 'postMessage', + subActionParams: { + channels: [], + text: params.message, + }, + }; + } else if ('subAction' in params) { + return params; + } + return {}; + }, +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.test.tsx new file mode 100644 index 0000000000000..ef9877c5a8772 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.test.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { act, render, fireEvent, screen } from '@testing-library/react'; +import SlackActionFields from './slack_connectors'; +import { ConnectorFormTestProvider, waitForComponentToUpdate } from '../lib/test_utils'; + +jest.mock('@kbn/triggers-actions-ui-plugin/public/common/lib/kibana'); + +describe('SlackActionFields renders', () => { + const onSubmit = jest.fn(); + beforeEach(() => { + jest.clearAllMocks(); + }); + it('all connector fields is rendered for web_api type', async () => { + const actionConnector = { + secrets: { + token: 'some token', + }, + id: 'test', + actionTypeId: '.slack', + name: 'slack', + config: {}, + isDeprecated: false, + }; + + render( + + {}} /> + + ); + + expect(screen.getByTestId('secrets.token-input')).toBeInTheDocument(); + expect(screen.getByTestId('secrets.token-input')).toHaveValue('some token'); + }); + + it('connector validation succeeds when connector config is valid for Web API type', async () => { + const actionConnector = { + secrets: { + token: 'some token', + }, + id: 'test', + actionTypeId: '.slack', + name: 'slack', + config: {}, + isDeprecated: false, + }; + + render( + + {}} /> + + ); + await waitForComponentToUpdate(); + await act(async () => { + fireEvent.click(screen.getByTestId('form-test-provide-submit')); + }); + expect(onSubmit).toBeCalledTimes(1); + expect(onSubmit).toBeCalledWith({ + data: { + secrets: { + token: 'some token', + }, + id: 'test', + actionTypeId: '.slack', + name: 'slack', + isDeprecated: false, + }, + isValid: true, + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.tsx b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.tsx new file mode 100644 index 0000000000000..4d36cc851ce69 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { + ActionConnectorFieldsProps, + SecretsFieldSchema, + SimpleConnectorForm, +} from '@kbn/triggers-actions-ui-plugin/public'; +import * as i18n from './translations'; + +const secretsFormSchema: SecretsFieldSchema[] = [ + { + id: 'token', + label: i18n.TOKEN_LABEL, + isPasswordField: true, + }, +]; + +const SlackActionFields: React.FC = ({ readOnly, isEdit }) => { + return ( + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { SlackActionFields as default }; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.test.tsx new file mode 100644 index 0000000000000..e8353a3bbabf3 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.test.tsx @@ -0,0 +1,201 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import SlackParamsFields from './slack_params'; +import type { UseSubActionParams } from '@kbn/triggers-actions-ui-plugin/public/application/hooks/use_sub_action'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; + +interface Result { + isLoading: boolean; + response: Record; + error: null | Error; +} + +const triggersActionsPath = '@kbn/triggers-actions-ui-plugin/public'; + +const mockUseSubAction = jest.fn]>( + jest.fn]>(() => ({ + isLoading: false, + response: { + channels: [ + { + id: 'id', + name: 'general', + is_channel: true, + is_archived: false, + is_private: true, + }, + ], + }, + error: null, + })) +); + +const mockToasts = { danger: jest.fn(), warning: jest.fn() }; +jest.mock(triggersActionsPath, () => { + const original = jest.requireActual(triggersActionsPath); + return { + ...original, + useSubAction: (params: UseSubActionParams) => mockUseSubAction(params), + useKibana: () => ({ + ...original.useKibana(), + notifications: { toasts: mockToasts }, + }), + }; +}); + +describe('SlackParamsFields renders', () => { + test('when useDefaultMessage is set to true and the default message changes, the underlying message is replaced with the default message', () => { + const editAction = jest.fn(); + const { rerender } = render( + + + + ); + expect(screen.getByTestId('webApiTextArea')).toBeInTheDocument(); + expect(screen.getByTestId('webApiTextArea')).toHaveValue('some text'); + rerender( + + + + ); + expect(editAction).toHaveBeenCalledWith( + 'subActionParams', + { channels: ['general'], text: 'some different default message' }, + 0 + ); + }); + + test('when useDefaultMessage is set to false and the default message changes, the underlying message is not changed, Web API', () => { + const editAction = jest.fn(); + const { rerender } = render( + + + + ); + expect(screen.getByTestId('webApiTextArea')).toBeInTheDocument(); + expect(screen.getByTestId('webApiTextArea')).toHaveValue('some text'); + + rerender( + + + + ); + expect(editAction).not.toHaveBeenCalled(); + }); + + test('all params fields is rendered for postMessage call', async () => { + render( + + {}} + index={0} + defaultMessage="default message" + messageVariables={[]} + /> + + ); + + expect(screen.getByTestId('webApiTextArea')).toBeInTheDocument(); + expect(screen.getByTestId('webApiTextArea')).toHaveValue('some text'); + }); + + test('all params fields is rendered for getChannels call', async () => { + render( + + {}} + index={0} + defaultMessage="default message" + messageVariables={[]} + /> + + ); + + expect(screen.getByTestId('slackChannelsButton')).toHaveTextContent('Channels'); + fireEvent.click(screen.getByTestId('slackChannelsButton')); + expect(screen.getByTestId('slackChannelsSelectableList')).toBeInTheDocument(); + expect(screen.getByTestId('slackChannelsSelectableList')).toHaveTextContent('general'); + fireEvent.click(screen.getByText('general')); + expect(screen.getByTitle('general').getAttribute('aria-checked')).toEqual('true'); + }); + + test('show error message when no channel is selected', async () => { + render( + + {}} + index={0} + defaultMessage="default message" + messageVariables={[]} + /> + + ); + expect(screen.getByText('my error message')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.tsx b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.tsx new file mode 100644 index 0000000000000..6d5f284e764b5 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.tsx @@ -0,0 +1,209 @@ +/* + * 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, useEffect, useMemo, useCallback } from 'react'; +import type { ActionParamsProps } from '@kbn/triggers-actions-ui-plugin/public'; +import { i18n } from '@kbn/i18n'; +import { TextAreaWithMessageVariables } from '@kbn/triggers-actions-ui-plugin/public'; +import { + EuiSpacer, + EuiFilterGroup, + EuiPopover, + EuiFilterButton, + EuiSelectable, + EuiSelectableOption, + EuiFormRow, +} from '@elastic/eui'; +import { useSubAction, useKibana } from '@kbn/triggers-actions-ui-plugin/public'; +import { FormattedMessage } from '@kbn/i18n-react'; +import type { GetChannelsResponse, PostMessageParams } from '../../../common/slack_api/types'; + +interface ChannelsStatus { + label: string; + checked?: 'on'; +} + +const SlackParamsFields: React.FunctionComponent> = ({ + actionConnector, + actionParams, + editAction, + index, + errors, + messageVariables, + defaultMessage, + useDefaultMessage, +}) => { + const { subAction, subActionParams } = actionParams; + const { channels = [], text } = subActionParams ?? {}; + const { toasts } = useKibana().notifications; + + useEffect(() => { + if (useDefaultMessage || !text) { + editAction('subActionParams', { channels, text: defaultMessage }, index); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [defaultMessage, useDefaultMessage]); + + if (!subAction) { + editAction('subAction', 'postMessage', index); + } + if (!subActionParams) { + editAction( + 'subActionParams', + { + channels, + text, + }, + index + ); + } + + const { + response: { channels: channelsInfo } = {}, + isLoading: isLoadingChannels, + error: channelsError, + } = useSubAction({ + connectorId: actionConnector?.id, + subAction: 'getChannels', + }); + + useEffect(() => { + if (channelsError) { + toasts.danger({ + title: i18n.translate( + 'xpack.stackConnectors.slack.params.componentError.getChannelsRequestFailed', + { + defaultMessage: 'Failed to retrieve Slack channels list', + } + ), + body: channelsError.message, + }); + } + }, [toasts, channelsError]); + + const slackChannels = useMemo( + () => + channelsInfo + ?.filter((slackChannel) => slackChannel.is_channel) + .map((slackChannel) => ({ label: slackChannel.name })) ?? [], + [channelsInfo] + ); + + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [selectedChannels, setSelectedChannels] = useState(channels ?? []); + + const button = ( + setIsPopoverOpen(!isPopoverOpen)} + numFilters={selectedChannels.length} + hasActiveFilters={selectedChannels.length > 0} + numActiveFilters={selectedChannels.length} + data-test-subj="slackChannelsButton" + > + + + ); + + const options: ChannelsStatus[] = useMemo( + () => + slackChannels.map((slackChannel) => ({ + label: slackChannel.label, + ...(selectedChannels.includes(slackChannel.label) ? { checked: 'on' } : {}), + })), + [slackChannels, selectedChannels] + ); + + const onChange = useCallback( + (newOptions: EuiSelectableOption[]) => { + const newSelectedChannels = newOptions.reduce((result, option) => { + if (option.checked === 'on') { + result = [...result, option.label]; + } + return result; + }, []); + + setSelectedChannels(newSelectedChannels); + editAction('subActionParams', { channels: newSelectedChannels, text }, index); + }, + [editAction, index, text] + ); + + return ( + <> + 0 && channels.length === 0} + > + + setIsPopoverOpen(false)} + > + + {(list, search) => ( + <> + {search} + + {list} + + )} + + + + + + + editAction('subActionParams', { channels, text: value }, index) + } + messageVariables={messageVariables} + paramsProperty="webApi" + inputTargetValue={text} + label={i18n.translate('xpack.stackConnectors.components.slack.messageTextAreaFieldLabel', { + defaultMessage: 'Message', + })} + errors={(errors.text ?? []) as string[]} + /> + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { SlackParamsFields as default }; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/translations.ts b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/translations.ts new file mode 100644 index 0000000000000..2c3ea5276ab92 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/translations.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const MESSAGE_REQUIRED = i18n.translate( + 'xpack.stackConnectors.components.slack.error.requiredSlackMessageText', + { + defaultMessage: 'Message is required.', + } +); +export const CHANNEL_REQUIRED = i18n.translate( + 'xpack.stackConnectors.components.slack.error.requiredSlackChannel', + { + defaultMessage: 'Selected channel is required.', + } +); +export const TOKEN_LABEL = i18n.translate( + 'xpack.stackConnectors.components.slack.tokenTextFieldLabel', + { + defaultMessage: 'API Token', + } +); +export const WEB_API = i18n.translate('xpack.stackConnectors.components.slack.webApi', { + defaultMessage: 'Web API', +}); +export const SELECT_MESSAGE = i18n.translate( + 'xpack.stackConnectors.components.slack.selectMessageText', + { + defaultMessage: 'Send a message to a Slack channel or user.', + } +); +export const ACTION_TYPE_TITLE = i18n.translate( + 'xpack.stackConnectors.components.slack.connectorTypeTitle', + { + defaultMessage: 'Send to Slack', + } +); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/index.ts index 648695bc7cbbd..0cd9a3b5a7194 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/index.ts @@ -20,7 +20,8 @@ import { getConnectorType as getIndexConnectorType } from './es_index'; import { getConnectorType as getPagerDutyConnectorType } from './pagerduty'; import { getConnectorType as getSwimlaneConnectorType } from './swimlane'; import { getConnectorType as getServerLogConnectorType } from './server_log'; -import { getConnectorType as getSlackConnectorType } from './slack'; +import { getConnectorType as getSlackWebhookConnectorType } from './slack'; +import { getConnectorType as getSlackApiConnectorType } from './slack_api'; import { getConnectorType as getWebhookConnectorType } from './webhook'; import { getConnectorType as getXmattersConnectorType } from './xmatters'; import { getConnectorType as getTeamsConnectorType } from './teams'; @@ -45,8 +46,10 @@ export type { ActionParamsType as PagerDutyActionParams } from './pagerduty'; export { ConnectorTypeId as ServerLogConnectorTypeId } from './server_log'; export type { ActionParamsType as ServerLogActionParams } from './server_log'; export { ServiceNowITOMConnectorTypeId } from './servicenow_itom'; -export { ConnectorTypeId as SlackConnectorTypeId } from './slack'; -export type { ActionParamsType as SlackActionParams } from './slack'; +export { ConnectorTypeId as SlackWebhookConnectorTypeId } from './slack'; +export type { ActionParamsType as SlackWebhookActionParams } from './slack'; +export { SLACK_API_CONNECTOR_ID as SlackApiConnectorTypeId } from '../../common/slack_api/constants'; +export type { SlackApiActionParams as SlackApiActionParams } from '../../common/slack_api/types'; export { ConnectorTypeId as TeamsConnectorTypeId } from './teams'; export type { ActionParamsType as TeamsActionParams } from './teams'; export { ConnectorTypeId as WebhookConnectorTypeId } from './webhook'; @@ -80,7 +83,8 @@ export function registerConnectorTypes({ actions.registerType(getPagerDutyConnectorType()); actions.registerType(getSwimlaneConnectorType()); actions.registerType(getServerLogConnectorType()); - actions.registerType(getSlackConnectorType({})); + actions.registerType(getSlackWebhookConnectorType({})); + actions.registerType(getSlackApiConnectorType()); actions.registerType(getWebhookConnectorType()); actions.registerType(getCasesWebhookConnectorType()); actions.registerType(getXmattersConnectorType()); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/api.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/api.test.ts new file mode 100644 index 0000000000000..2ae4a998b261a --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/api.test.ts @@ -0,0 +1,101 @@ +/* + * 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 { SlackApiService } from '../../../common/slack_api/types'; +import { api } from './api'; + +const createMock = (): jest.Mocked => { + const service = { + postMessage: jest.fn().mockImplementation(() => ({ + ok: true, + channel: 'general', + message: { + text: 'a message', + type: 'message', + }, + })), + getChannels: jest.fn().mockImplementation(() => [ + { + ok: true, + channels: [ + { + id: 'channel_id_1', + name: 'general', + is_channel: true, + is_archived: false, + is_private: true, + }, + { + id: 'channel_id_2', + name: 'privat', + is_channel: true, + is_archived: false, + is_private: false, + }, + ], + }, + ]), + }; + + return service; +}; + +const slackServiceMock = { + create: createMock, +}; + +describe('api', () => { + let externalService: jest.Mocked; + + beforeEach(() => { + externalService = slackServiceMock.create(); + }); + + test('getChannels', async () => { + const res = await api.getChannels({ + externalService, + }); + + expect(res).toEqual([ + { + channels: [ + { + id: 'channel_id_1', + is_archived: false, + is_channel: true, + is_private: true, + name: 'general', + }, + { + id: 'channel_id_2', + is_archived: false, + is_channel: true, + is_private: false, + name: 'privat', + }, + ], + ok: true, + }, + ]); + }); + + test('postMessage', async () => { + const res = await api.postMessage({ + externalService, + params: { channels: ['general'], text: 'a message' }, + }); + + expect(res).toEqual({ + channel: 'general', + message: { + text: 'a message', + type: 'message', + }, + ok: true, + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/api.ts b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/api.ts new file mode 100644 index 0000000000000..b0445b7c26e41 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/api.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { PostMessageSubActionParams, SlackApiService } from '../../../common/slack_api/types'; + +const getChannelsHandler = async ({ externalService }: { externalService: SlackApiService }) => + await externalService.getChannels(); + +const postMessageHandler = async ({ + externalService, + params: { channels, text }, +}: { + externalService: SlackApiService; + params: PostMessageSubActionParams; +}) => await externalService.postMessage({ channels, text }); + +export const api = { + getChannels: getChannelsHandler, + postMessage: postMessageHandler, +}; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.test.ts new file mode 100644 index 0000000000000..66bc3fba1219c --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.test.ts @@ -0,0 +1,303 @@ +/* + * 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 axios from 'axios'; +import { Logger } from '@kbn/core/server'; +import { Services } from '@kbn/actions-plugin/server/types'; +import { validateParams, validateSecrets } from '@kbn/actions-plugin/server/lib'; +import { getConnectorType } from '.'; +import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock'; +import { actionsMock } from '@kbn/actions-plugin/server/mocks'; +import { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config'; +import { loggerMock } from '@kbn/logging-mocks'; +import * as utils from '@kbn/actions-plugin/server/lib/axios_utils'; +import type { PostMessageParams, SlackApiConnectorType } from '../../../common/slack_api/types'; +import { SLACK_API_CONNECTOR_ID } from '../../../common/slack_api/constants'; +import { SLACK_CONNECTOR_NAME } from './translations'; + +jest.mock('axios'); +jest.mock('@kbn/actions-plugin/server/lib/axios_utils', () => { + const originalUtils = jest.requireActual('@kbn/actions-plugin/server/lib/axios_utils'); + return { + ...originalUtils, + request: jest.fn(), + }; +}); + +const requestMock = utils.request as jest.Mock; + +const services: Services = actionsMock.createServices(); +const mockedLogger: jest.Mocked = loggerMock.create(); + +let connectorType: SlackApiConnectorType; +let configurationUtilities: jest.Mocked; + +beforeEach(() => { + configurationUtilities = actionsConfigMock.create(); + connectorType = getConnectorType(); +}); + +describe('connector registration', () => { + test('returns connector type', () => { + expect(connectorType.id).toEqual(SLACK_API_CONNECTOR_ID); + expect(connectorType.name).toEqual(SLACK_CONNECTOR_NAME); + }); +}); + +describe('validate params', () => { + test('should validate and throw error when params are invalid', () => { + expect(() => { + validateParams(connectorType, {}, { configurationUtilities }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action params: Cannot destructure property 'Symbol(Symbol.iterator)' of 'undefined' as it is undefined."` + ); + + expect(() => { + validateParams(connectorType, { message: 1 }, { configurationUtilities }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action params: Cannot destructure property 'Symbol(Symbol.iterator)' of 'undefined' as it is undefined."` + ); + }); + + test('should validate and pass when params are valid for post message', () => { + expect( + validateParams( + connectorType, + { subAction: 'postMessage', subActionParams: { channels: ['general'], text: 'a text' } }, + { configurationUtilities } + ) + ).toEqual({ + subAction: 'postMessage', + subActionParams: { channels: ['general'], text: 'a text' }, + }); + }); + + test('should validate and pass when params are valid for get channels', () => { + expect( + validateParams(connectorType, { subAction: 'getChannels' }, { configurationUtilities }) + ).toEqual({ + subAction: 'getChannels', + }); + }); +}); + +describe('validate secrets', () => { + test('should validate and throw error when secrets is empty', () => { + expect(() => { + validateSecrets(connectorType, {}, { configurationUtilities }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type secrets: [token]: expected value of type [string] but got [undefined]"` + ); + }); + + test('should validate and pass when secrets is valid', () => { + validateSecrets( + connectorType, + { + token: 'token', + }, + { configurationUtilities } + ); + }); + + test('should validate and throw error when secrets is invalid', () => { + expect(() => { + validateSecrets(connectorType, { token: 1 }, { configurationUtilities }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type secrets: [token]: expected value of type [string] but got [number]"` + ); + }); + + test('config validation returns an error if the specified URL isnt added to allowedHosts', () => { + const configUtils = { + ...actionsConfigMock.create(), + ensureUriAllowed: () => { + throw new Error(`target hostname is not added to allowedHosts`); + }, + }; + + expect(() => { + validateSecrets( + connectorType, + { token: 'fake token' }, + { configurationUtilities: configUtils } + ); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type secrets: error configuring slack action: target hostname is not added to allowedHosts"` + ); + }); +}); + +describe('execute', () => { + beforeEach(() => { + jest.resetAllMocks(); + axios.create = jest.fn().mockImplementation(() => axios); + connectorType = getConnectorType(); + }); + + test('should fail if params does not include subAction', async () => { + requestMock.mockImplementation(() => ({ + data: { + ok: true, + message: { text: 'some text' }, + channel: 'general', + }, + })); + + await expect( + connectorType.executor({ + actionId: SLACK_API_CONNECTOR_ID, + config: {}, + services, + secrets: { token: 'some token' }, + params: {} as PostMessageParams, + configurationUtilities, + logger: mockedLogger, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[Action][ExternalService] -> [Slack API] Unsupported subAction type undefined."` + ); + }); + + test('should fail if subAction is not postMessage/getChannels', async () => { + requestMock.mockImplementation(() => ({ + data: { + ok: true, + message: { text: 'some text' }, + channel: 'general', + }, + })); + + await expect( + connectorType.executor({ + actionId: SLACK_API_CONNECTOR_ID, + services, + config: {}, + secrets: { token: 'some token' }, + params: { + subAction: 'getMessage' as 'getChannels', + }, + configurationUtilities, + logger: mockedLogger, + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"[Action][ExternalService] -> [Slack API] Unsupported subAction type getMessage."` + ); + }); + + test('renders parameter templates as expected', async () => { + expect(connectorType.renderParameterTemplates).toBeTruthy(); + const paramsWithTemplates = { + subAction: 'postMessage' as const, + subActionParams: { text: 'some text', channels: ['general'] }, + }; + const variables = { rogue: '*bold*' }; + const params = connectorType.renderParameterTemplates!( + paramsWithTemplates, + variables + ) as PostMessageParams; + expect(params.subActionParams.text).toBe('some text'); + }); + + test('should execute with success for post message', async () => { + requestMock.mockImplementation(() => ({ + data: { + ok: true, + message: { text: 'some text' }, + channel: 'general', + }, + })); + + const response = await connectorType.executor({ + actionId: SLACK_API_CONNECTOR_ID, + services, + config: {}, + secrets: { token: 'some token' }, + params: { + subAction: 'postMessage', + subActionParams: { channels: ['general'], text: 'some text' }, + }, + configurationUtilities, + logger: mockedLogger, + }); + + expect(requestMock).toHaveBeenCalledWith({ + axios, + configurationUtilities, + logger: mockedLogger, + method: 'post', + url: 'chat.postMessage', + data: { channel: 'general', text: 'some text' }, + }); + + expect(response).toEqual({ + actionId: SLACK_API_CONNECTOR_ID, + data: { + channel: 'general', + message: { + text: 'some text', + }, + ok: true, + }, + + status: 'ok', + }); + }); + + test('should execute with success for get channels', async () => { + requestMock.mockImplementation(() => ({ + data: { + ok: true, + channels: [ + { + id: 'id', + name: 'general', + is_channel: true, + is_archived: false, + is_private: true, + }, + ], + }, + })); + const response = await connectorType.executor({ + actionId: SLACK_API_CONNECTOR_ID, + services, + config: {}, + secrets: { token: 'some token' }, + params: { + subAction: 'getChannels', + }, + configurationUtilities, + logger: mockedLogger, + }); + + expect(requestMock).toHaveBeenCalledWith({ + axios, + configurationUtilities, + logger: mockedLogger, + method: 'get', + url: 'conversations.list?types=public_channel,private_channel', + }); + + expect(response).toEqual({ + actionId: SLACK_API_CONNECTOR_ID, + data: { + channels: [ + { + id: 'id', + is_archived: false, + is_channel: true, + is_private: true, + name: 'general', + }, + ], + ok: true, + }, + status: 'ok', + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.ts new file mode 100644 index 0000000000000..ee467dad3d8a2 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.ts @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ActionTypeExecutorResult } from '@kbn/actions-plugin/server/types'; +import { + AlertingConnectorFeatureId, + SecurityConnectorFeatureId, +} from '@kbn/actions-plugin/common/types'; +import { renderMustacheString } from '@kbn/actions-plugin/server/lib/mustache_renderer'; +import type { ValidatorServices } from '@kbn/actions-plugin/server/types'; +import { i18n } from '@kbn/i18n'; +import { schema } from '@kbn/config-schema'; +import type { + SlackApiExecutorOptions, + SlackApiConnectorType, + SlackApiParams, + SlackApiSecrets, +} from '../../../common/slack_api/types'; +import { SlackApiSecretsSchema, SlackApiParamsSchema } from '../../../common/slack_api/schema'; +import { SLACK_API_CONNECTOR_ID, SLACK_URL } from '../../../common/slack_api/constants'; +import { SLACK_CONNECTOR_NAME } from './translations'; +import { api } from './api'; +import { createExternalService } from './service'; + +const supportedSubActions = ['getChannels', 'postMessage']; + +export const getConnectorType = (): SlackApiConnectorType => { + return { + id: SLACK_API_CONNECTOR_ID, + minimumLicenseRequired: 'gold', + name: SLACK_CONNECTOR_NAME, + supportedFeatureIds: [AlertingConnectorFeatureId, SecurityConnectorFeatureId], + validate: { + config: { schema: schema.object({}, { defaultValue: {} }) }, + secrets: { + schema: SlackApiSecretsSchema, + customValidator: validateSlackUrl, + }, + params: { + schema: SlackApiParamsSchema, + }, + }, + renderParameterTemplates, + executor: async (execOptions: SlackApiExecutorOptions) => await slackApiExecutor(execOptions), + }; +}; + +const validateSlackUrl = (secretsObject: SlackApiSecrets, validatorServices: ValidatorServices) => { + const { configurationUtilities } = validatorServices; + + try { + configurationUtilities.ensureUriAllowed(SLACK_URL); + } catch (allowedListError) { + throw new Error( + i18n.translate('xpack.stackConnectors.slack_api.configurationError', { + defaultMessage: 'error configuring slack action: {message}', + values: { + message: allowedListError.message, + }, + }) + ); + } +}; + +const renderParameterTemplates = (params: SlackApiParams, variables: Record) => { + if (params.subAction === 'postMessage') + return { + subAction: params.subAction, + subActionParams: { + ...params.subActionParams, + text: renderMustacheString(params.subActionParams.text, variables, 'slack'), + }, + }; + return params; +}; + +const slackApiExecutor = async ({ + actionId, + params, + secrets, + configurationUtilities, + logger, +}: SlackApiExecutorOptions): Promise> => { + const subAction = params.subAction; + + if (!api[subAction]) { + const errorMessage = `[Action][ExternalService] -> [Slack API] Unsupported subAction type ${subAction}.`; + logger.error(errorMessage); + throw new Error(errorMessage); + } + + if (!supportedSubActions.includes(subAction)) { + const errorMessage = `[Action][ExternalService] -> [Slack API] subAction ${subAction} not implemented.`; + logger.error(errorMessage); + throw new Error(errorMessage); + } + + const externalService = createExternalService( + { + secrets, + }, + logger, + configurationUtilities + ); + + if (subAction === 'getChannels') { + return await api.getChannels({ + externalService, + }); + } + + if (subAction === 'postMessage') { + return await api.postMessage({ + externalService, + params: params.subActionParams, + }); + } + + return { status: 'ok', data: {}, actionId }; +}; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/service.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/service.test.ts new file mode 100644 index 0000000000000..350c6fc103fb4 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/service.test.ts @@ -0,0 +1,181 @@ +/* + * 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 axios from 'axios'; +import { request, createAxiosResponse } from '@kbn/actions-plugin/server/lib/axios_utils'; +import { Logger } from '@kbn/core/server'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock'; +import { createExternalService } from './service'; +import { SlackApiService } from '../../../common/slack_api/types'; +import { SLACK_API_CONNECTOR_ID } from '../../../common/slack_api/constants'; + +const logger = loggingSystemMock.create().get() as jest.Mocked; + +jest.mock('axios'); +jest.mock('@kbn/actions-plugin/server/lib/axios_utils', () => { + const originalUtils = jest.requireActual('@kbn/actions-plugin/server/lib/axios_utils'); + return { + ...originalUtils, + request: jest.fn(), + }; +}); + +axios.create = jest.fn(() => axios); +const requestMock = request as jest.Mock; +const configurationUtilities = actionsConfigMock.create(); + +const channels = [ + { + id: 'channel_id_1', + name: 'general', + is_channel: true, + is_archived: false, + is_private: true, + }, + { + id: 'channel_id_2', + name: 'privat', + is_channel: true, + is_archived: false, + is_private: false, + }, +]; + +const getChannelsResponse = createAxiosResponse({ + data: { + ok: true, + channels, + }, +}); + +const postMessageResponse = createAxiosResponse({ + data: [ + { + ok: true, + channel: 'general', + message: { + text: 'a message', + type: 'message', + }, + }, + { + ok: true, + channel: 'privat', + message: { + text: 'a message', + type: 'message', + }, + }, + ], +}); + +describe('Slack API service', () => { + let service: SlackApiService; + + beforeAll(() => { + service = createExternalService( + { + secrets: { token: 'token' }, + }, + logger, + configurationUtilities + ); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Secrets validation', () => { + test('throws without token', () => { + expect(() => + createExternalService( + { + secrets: { token: '' }, + }, + logger, + configurationUtilities + ) + ).toThrowErrorMatchingInlineSnapshot(`"[Action][Slack API]: Wrong configuration."`); + }); + }); + + describe('getChannels', () => { + test('should get slack channels', async () => { + requestMock.mockImplementation(() => getChannelsResponse); + const res = await service.getChannels(); + expect(res).toEqual({ + actionId: SLACK_API_CONNECTOR_ID, + data: { + ok: true, + channels, + }, + status: 'ok', + }); + }); + + test('should call request with correct arguments', async () => { + requestMock.mockImplementation(() => getChannelsResponse); + + await service.getChannels(); + expect(requestMock).toHaveBeenCalledWith({ + axios, + logger, + configurationUtilities, + method: 'get', + url: 'conversations.list?types=public_channel,private_channel', + }); + }); + + test('should throw an error if request to slack fail', async () => { + requestMock.mockImplementation(() => { + throw new Error('request fail'); + }); + + expect(await service.getChannels()).toEqual({ + actionId: SLACK_API_CONNECTOR_ID, + message: 'error posting slack message', + serviceMessage: 'request fail', + status: 'error', + }); + }); + }); + + describe('postMessage', () => { + test('should call request with correct arguments', async () => { + requestMock.mockImplementation(() => postMessageResponse); + + await service.postMessage({ channels: ['general', 'privat'], text: 'a message' }); + + expect(requestMock).toHaveBeenCalledTimes(1); + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + method: 'post', + url: 'chat.postMessage', + data: { channel: 'general', text: 'a message' }, + }); + }); + + test('should throw an error if request to slack fail', async () => { + requestMock.mockImplementation(() => { + throw new Error('request fail'); + }); + + expect( + await service.postMessage({ channels: ['general', 'privat'], text: 'a message' }) + ).toEqual({ + actionId: SLACK_API_CONNECTOR_ID, + message: 'error posting slack message', + serviceMessage: 'request fail', + status: 'error', + }); + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/service.ts b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/service.ts new file mode 100644 index 0000000000000..723d629a74418 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/service.ts @@ -0,0 +1,162 @@ +/* + * 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 axios, { AxiosResponse } from 'axios'; +import { Logger } from '@kbn/core/server'; +import { i18n } from '@kbn/i18n'; +import { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config'; +import { request } from '@kbn/actions-plugin/server/lib/axios_utils'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { map, getOrElse } from 'fp-ts/lib/Option'; +import type { ActionTypeExecutorResult as ConnectorTypeExecutorResult } from '@kbn/actions-plugin/server/types'; +import { SLACK_CONNECTOR_NAME } from './translations'; +import type { + PostMessageSubActionParams, + SlackApiService, + PostMessageResponse, +} from '../../../common/slack_api/types'; +import { + retryResultSeconds, + retryResult, + serviceErrorResult, + errorResult, + successResult, +} from '../../../common/slack_api/lib'; +import { SLACK_API_CONNECTOR_ID, SLACK_URL } from '../../../common/slack_api/constants'; +import { getRetryAfterIntervalFromHeaders } from '../lib/http_response_retry_header'; + +const buildSlackExecutorErrorResponse = ({ + slackApiError, + logger, +}: { + slackApiError: { + message: string; + response: { + status: number; + statusText: string; + headers: Record; + }; + }; + logger: Logger; +}) => { + if (!slackApiError.response) { + return serviceErrorResult(SLACK_API_CONNECTOR_ID, slackApiError.message); + } + + const { status, statusText, headers } = slackApiError.response; + + // special handling for 5xx + if (status >= 500) { + return retryResult(SLACK_API_CONNECTOR_ID, slackApiError.message); + } + + // special handling for rate limiting + if (status === 429) { + return pipe( + getRetryAfterIntervalFromHeaders(headers), + map((retry) => retryResultSeconds(SLACK_API_CONNECTOR_ID, slackApiError.message, retry)), + getOrElse(() => retryResult(SLACK_API_CONNECTOR_ID, slackApiError.message)) + ); + } + + const errorMessage = i18n.translate( + 'xpack.stackConnectors.slack.unexpectedHttpResponseErrorMessage', + { + defaultMessage: 'unexpected http response from slack: {httpStatus} {httpStatusText}', + values: { + httpStatus: status, + httpStatusText: statusText, + }, + } + ); + logger.error(`error on ${SLACK_API_CONNECTOR_ID} slack action: ${errorMessage}`); + + return errorResult(SLACK_API_CONNECTOR_ID, errorMessage); +}; + +const buildSlackExecutorSuccessResponse = ({ + slackApiResponseData, +}: { + slackApiResponseData: PostMessageResponse; +}) => { + if (!slackApiResponseData) { + const errMessage = i18n.translate( + 'xpack.stackConnectors.slack.unexpectedNullResponseErrorMessage', + { + defaultMessage: 'unexpected null response from slack', + } + ); + return errorResult(SLACK_API_CONNECTOR_ID, errMessage); + } + + if (!slackApiResponseData.ok) { + return serviceErrorResult(SLACK_API_CONNECTOR_ID, slackApiResponseData.error); + } + + return successResult(SLACK_API_CONNECTOR_ID, slackApiResponseData); +}; + +export const createExternalService = ( + { secrets }: { secrets: { token: string } }, + logger: Logger, + configurationUtilities: ActionsConfigurationUtilities +): SlackApiService => { + const { token } = secrets; + + if (!token) { + throw Error(`[Action][${SLACK_CONNECTOR_NAME}]: Wrong configuration.`); + } + + const axiosInstance = axios.create({ + baseURL: SLACK_URL, + headers: { + Authorization: `Bearer ${token}`, + 'Content-type': 'application/json; charset=UTF-8', + }, + }); + + const getChannels = async (): Promise> => { + try { + const result = await request({ + axios: axiosInstance, + configurationUtilities, + logger, + method: 'get', + url: 'conversations.list?types=public_channel,private_channel', + }); + + return buildSlackExecutorSuccessResponse({ slackApiResponseData: result.data }); + } catch (error) { + return buildSlackExecutorErrorResponse({ slackApiError: error, logger }); + } + }; + + const postMessage = async ({ + channels, + text, + }: PostMessageSubActionParams): Promise> => { + try { + const result: AxiosResponse = await request({ + axios: axiosInstance, + method: 'post', + url: 'chat.postMessage', + logger, + data: { channel: channels[0], text }, + configurationUtilities, + }); + + return buildSlackExecutorSuccessResponse({ slackApiResponseData: result.data }); + } catch (error) { + return buildSlackExecutorErrorResponse({ slackApiError: error, logger }); + } + }; + + return { + getChannels, + postMessage, + }; +}; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/translations.ts b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/translations.ts new file mode 100644 index 0000000000000..03157b6c6d53f --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/translations.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const SLACK_CONNECTOR_NAME = i18n.translate('xpack.stackConnectors.slackApi.title', { + defaultMessage: 'Slack API', +}); diff --git a/x-pack/plugins/stack_connectors/server/plugin.test.ts b/x-pack/plugins/stack_connectors/server/plugin.test.ts index bfc2bb9fd4197..a572970e0be15 100644 --- a/x-pack/plugins/stack_connectors/server/plugin.test.ts +++ b/x-pack/plugins/stack_connectors/server/plugin.test.ts @@ -25,7 +25,7 @@ describe('Stack Connectors Plugin', () => { it('should register built in connector types', () => { const actionsSetup = actionsMock.createSetup(); plugin.setup(coreSetup, { actions: actionsSetup }); - expect(actionsSetup.registerType).toHaveBeenCalledTimes(16); + expect(actionsSetup.registerType).toHaveBeenCalledTimes(17); expect(actionsSetup.registerType).toHaveBeenNthCalledWith( 1, expect.objectContaining({ @@ -69,63 +69,63 @@ describe('Stack Connectors Plugin', () => { }) ); expect(actionsSetup.registerType).toHaveBeenNthCalledWith( - 7, + 8, expect.objectContaining({ id: '.webhook', name: 'Webhook', }) ); expect(actionsSetup.registerType).toHaveBeenNthCalledWith( - 8, + 9, expect.objectContaining({ id: '.cases-webhook', name: 'Webhook - Case Management', }) ); expect(actionsSetup.registerType).toHaveBeenNthCalledWith( - 9, + 10, expect.objectContaining({ id: '.xmatters', name: 'xMatters', }) ); expect(actionsSetup.registerType).toHaveBeenNthCalledWith( - 10, + 11, expect.objectContaining({ id: '.servicenow', name: 'ServiceNow ITSM', }) ); expect(actionsSetup.registerType).toHaveBeenNthCalledWith( - 11, + 12, expect.objectContaining({ id: '.servicenow-sir', name: 'ServiceNow SecOps', }) ); expect(actionsSetup.registerType).toHaveBeenNthCalledWith( - 12, + 13, expect.objectContaining({ id: '.servicenow-itom', name: 'ServiceNow ITOM', }) ); expect(actionsSetup.registerType).toHaveBeenNthCalledWith( - 13, + 14, expect.objectContaining({ id: '.jira', name: 'Jira', }) ); expect(actionsSetup.registerType).toHaveBeenNthCalledWith( - 14, + 15, expect.objectContaining({ id: '.resilient', name: 'IBM Resilient', }) ); expect(actionsSetup.registerType).toHaveBeenNthCalledWith( - 15, + 16, expect.objectContaining({ id: '.teams', name: 'Microsoft Teams', diff --git a/x-pack/plugins/stack_connectors/server/types.ts b/x-pack/plugins/stack_connectors/server/types.ts index 697c6a358fbe0..d9cd9f9b99cad 100644 --- a/x-pack/plugins/stack_connectors/server/types.ts +++ b/x-pack/plugins/stack_connectors/server/types.ts @@ -21,8 +21,10 @@ export type { PagerDutyActionParams, ServerLogConnectorTypeId, ServerLogActionParams, - SlackConnectorTypeId, - SlackActionParams, + SlackApiConnectorTypeId, + SlackApiActionParams, + SlackWebhookConnectorTypeId, + SlackWebhookActionParams, WebhookConnectorTypeId, WebhookActionParams, ServiceNowITSMConnectorTypeId, diff --git a/x-pack/plugins/synthetics/common/rules/alert_actions.ts b/x-pack/plugins/synthetics/common/rules/alert_actions.ts index 0c9782108743f..2dc1990e26b45 100644 --- a/x-pack/plugins/synthetics/common/rules/alert_actions.ts +++ b/x-pack/plugins/synthetics/common/rules/alert_actions.ts @@ -20,7 +20,7 @@ import { v4 as uuidv4 } from 'uuid'; import { ActionConnector, ActionTypeId } from './types'; import { DefaultEmail } from '../runtime_types'; -export const SLACK_ACTION_ID: ActionTypeId = '.slack'; +export const SLACK_WEBHOOK_ACTION_ID: ActionTypeId = '.slack'; export const PAGER_DUTY_ACTION_ID: ActionTypeId = '.pagerduty'; export const SERVER_LOG_ACTION_ID: ActionTypeId = '.server-log'; export const INDEX_ACTION_ID: ActionTypeId = '.index'; @@ -98,7 +98,7 @@ export function populateAlertActions({ recoveredAction.params = getWebhookActionParams(translations, true); actions.push(recoveredAction); break; - case SLACK_ACTION_ID: + case SLACK_WEBHOOK_ACTION_ID: case TEAMS_ACTION_ID: action.params = { message: translations.defaultActionMessage, diff --git a/x-pack/plugins/synthetics/common/rules/types.ts b/x-pack/plugins/synthetics/common/rules/types.ts index 101ce9c1418c6..c398d66e376a2 100644 --- a/x-pack/plugins/synthetics/common/rules/types.ts +++ b/x-pack/plugins/synthetics/common/rules/types.ts @@ -11,7 +11,7 @@ import type { PagerDutyConnectorTypeId, ServerLogConnectorTypeId, ServiceNowITSMConnectorTypeId as ServiceNowConnectorTypeId, - SlackConnectorTypeId, + SlackWebhookConnectorTypeId, TeamsConnectorTypeId, WebhookConnectorTypeId, EmailConnectorTypeId, @@ -20,7 +20,7 @@ import type { import type { ActionConnector as RawActionConnector } from '@kbn/triggers-actions-ui-plugin/public'; export type ActionTypeId = - | typeof SlackConnectorTypeId + | typeof SlackWebhookConnectorTypeId | typeof PagerDutyConnectorTypeId | typeof ServerLogConnectorTypeId | typeof IndexConnectorTypeId diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx index 6b7ede2f01747..25feead9c518e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx @@ -228,7 +228,8 @@ export const ActionForm = ({ return; } setIsAddActionPanelOpen(false); - const actionTypeConnectors = connectors.filter( + const allowGroupConnector = (actionTypeModel?.subtype ?? []).map((atm) => atm.id); + let actionTypeConnectors = connectors.filter( (field) => field.actionTypeId === actionTypeModel.id ); @@ -241,7 +242,22 @@ export const ActionForm = ({ frequency: defaultRuleFrequency, }); setActionIdByIndex(actionTypeConnectors[0].id, actions.length - 1); + } else { + actionTypeConnectors = connectors.filter((field) => + allowGroupConnector.includes(field.actionTypeId) + ); + if (actionTypeConnectors.length > 0) { + actions.push({ + id: '', + actionTypeId: actionTypeConnectors[0].actionTypeId, + group: defaultActionGroupId, + params: {}, + frequency: DEFAULT_FREQUENCY, + }); + setActionIdByIndex(actionTypeConnectors[0].id, actions.length - 1); + } } + if (actionTypeConnectors.length === 0) { // if no connectors exists or all connectors is already assigned an action under current alert // set actionType as id to be able to create new connector within the alert form @@ -263,7 +279,7 @@ export const ActionForm = ({ const preconfiguredConnectors = connectors.filter((connector) => connector.isPreconfigured); actionTypeNodes = actionTypeRegistry .list() - .filter((item) => actionTypesIndex[item.id]) + .filter((item) => actionTypesIndex[item.id] && !item.hideInUi) .filter((item) => !!item.actionParamsFields) .sort((a, b) => actionTypeCompare(actionTypesIndex[a.id], actionTypesIndex[b.id], preconfiguredConnectors) @@ -378,6 +394,27 @@ export const ActionForm = ({ }} onSelectConnector={(connectorId: string) => { setActionIdByIndex(connectorId, index); + const newConnector = connectors.find((connector) => connector.id === connectorId); + if (newConnector && newConnector.actionTypeId) { + const actionTypeRegistered = actionTypeRegistry.get(newConnector.actionTypeId); + if (actionTypeRegistered.convertParamsBetweenGroups) { + const updatedActions = actions.map((_item: RuleAction, i: number) => { + if (i === index) { + return { + ..._item, + actionTypeId: newConnector.actionTypeId, + id: connectorId, + params: + actionTypeRegistered.convertParamsBetweenGroups != null + ? actionTypeRegistered.convertParamsBetweenGroups(_item.params) + : {}, + }; + } + return _item; + }); + setActions(updatedActions); + } + } }} /> ); @@ -407,6 +444,31 @@ export const ActionForm = ({ }} onConnectorSelected={(id: string) => { setActionIdByIndex(id, index); + const newConnector = connectors.find((connector) => connector.id === id); + if ( + newConnector && + actionConnector && + newConnector.actionTypeId !== actionConnector.actionTypeId + ) { + const actionTypeRegistered = actionTypeRegistry.get(newConnector.actionTypeId); + if (actionTypeRegistered.convertParamsBetweenGroups) { + const updatedActions = actions.map((_item: RuleAction, i: number) => { + if (i === index) { + return { + ..._item, + actionTypeId: newConnector.actionTypeId, + id, + params: + actionTypeRegistered.convertParamsBetweenGroups != null + ? actionTypeRegistered.convertParamsBetweenGroups(_item.params) + : {}, + }; + } + return _item; + }); + setActions(updatedActions); + } + } }} actionTypeRegistry={actionTypeRegistry} onDeleteAction={() => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx index 1743d435b722f..a5222ac091800 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx @@ -308,6 +308,7 @@ export const ActionTypeForm = ({ const actionTypeRegistered = actionTypeRegistry.get(actionConnector.actionTypeId); if (!actionTypeRegistered) return null; + const allowGroupConnector = (actionTypeRegistered?.subtype ?? []).map((atr) => atr.id); const showActionGroupErrorIcon = (): boolean => { return !isOpen && some(actionParamsErrors.errors, (error) => !isEmpty(error)); @@ -362,6 +363,7 @@ export const ActionTypeForm = ({ } > { }, actionConnectorFields: null, }); - actionTypeRegistry.get.mockReturnValueOnce(actionType); - loadActionTypes.mockResolvedValueOnce([ + actionTypeRegistry.get.mockReturnValue(actionType); + loadActionTypes.mockResolvedValue([ { id: actionType.id, enabled: true, @@ -137,8 +137,8 @@ describe('connector_add_flyout', () => { }, actionConnectorFields: null, }); - actionTypeRegistry.get.mockReturnValueOnce(actionType); - loadActionTypes.mockResolvedValueOnce([ + actionTypeRegistry.get.mockReturnValue(actionType); + loadActionTypes.mockResolvedValue([ { id: actionType.id, enabled: false, @@ -180,8 +180,8 @@ describe('connector_add_flyout', () => { }, actionConnectorFields: null, }); - actionTypeRegistry.get.mockReturnValueOnce(actionType); - loadActionTypes.mockResolvedValueOnce([ + actionTypeRegistry.get.mockReturnValue(actionType); + loadActionTypes.mockResolvedValue([ { id: actionType.id, enabled: false, @@ -221,8 +221,8 @@ describe('connector_add_flyout', () => { actionConnectorFields: null, isExperimental: false, }); - actionTypeRegistry.get.mockReturnValueOnce(actionType); - loadActionTypes.mockResolvedValueOnce([ + actionTypeRegistry.get.mockReturnValue(actionType); + loadActionTypes.mockResolvedValue([ { id: actionType.id, enabled: false, @@ -263,8 +263,8 @@ describe('connector_add_flyout', () => { actionConnectorFields: null, isExperimental: true, }); - actionTypeRegistry.get.mockReturnValueOnce(actionType); - loadActionTypes.mockResolvedValueOnce([ + actionTypeRegistry.get.mockReturnValue(actionType); + loadActionTypes.mockResolvedValue([ { id: actionType.id, enabled: false, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx index 60b153badffe6..f4717bb512a0c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_menu.tsx @@ -22,6 +22,7 @@ interface Props { onActionTypeChange: (actionType: ActionType) => void; featureId?: string; setHasActionsUpgradeableByTrial?: (value: boolean) => void; + setAllActionTypes?: (actionsType: ActionTypeIndex) => void; actionTypeRegistry: ActionTypeRegistryContract; } @@ -29,6 +30,7 @@ export const ActionTypeMenu = ({ onActionTypeChange, featureId, setHasActionsUpgradeableByTrial, + setAllActionTypes, actionTypeRegistry, }: Props) => { const { @@ -37,7 +39,6 @@ export const ActionTypeMenu = ({ } = useKibana().services; const [loadingActionTypes, setLoadingActionTypes] = useState(false); const [actionTypesIndex, setActionTypesIndex] = useState(undefined); - useEffect(() => { (async () => { try { @@ -50,6 +51,9 @@ export const ActionTypeMenu = ({ index[actionTypeItem.id] = actionTypeItem; } setActionTypesIndex(index); + if (setAllActionTypes) { + setAllActionTypes(index); + } // determine if there are actions disabled by license that that // would be enabled by upgrading to gold or trial if (setHasActionsUpgradeableByTrial) { @@ -73,9 +77,13 @@ export const ActionTypeMenu = ({ })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const registeredActionTypes = Object.entries(actionTypesIndex ?? []) - .filter(([id, details]) => actionTypeRegistry.has(id) && details.enabledInConfig === true) + .filter( + ([id, details]) => + actionTypeRegistry.has(id) && + details.enabledInConfig === true && + !actionTypeRegistry.get(id).hideInUi + ) .map(([id, actionType]) => { const actionTypeModel = actionTypeRegistry.get(id); return { @@ -100,7 +108,9 @@ export const ActionTypeMenu = ({ title={item.name} description={item.selectMessage} isDisabled={!checkEnabledResult.isEnabled} - onClick={() => onActionTypeChange(item.actionType)} + onClick={() => { + onActionTypeChange(item.actionType); + }} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_inline.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_inline.tsx index 530d41a4c27d1..ec5e4ef01ceec 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_inline.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_inline.tsx @@ -65,6 +65,7 @@ export const AddConnectorInline = ({ ? actionTypesIndex[actionItem.actionTypeId].name : actionItem.actionTypeId; const actionTypeRegistered = actionTypeRegistry.get(actionItem.actionTypeId); + const allowGroupConnector = (actionTypeRegistered?.subtype ?? []).map((subtype) => subtype.id); const connectorDropdownErrors = useMemo( () => [`Unable to load ${actionTypeRegistered.actionTypeTitle} connector`], [actionTypeRegistered.actionTypeTitle] @@ -90,7 +91,12 @@ export const AddConnectorInline = ({ ); useEffect(() => { - const filteredConnectors = getValidConnectors(connectors, actionItem, actionTypesIndex); + const filteredConnectors = getValidConnectors( + connectors, + actionItem, + actionTypesIndex, + allowGroupConnector + ); if (filteredConnectors.length > 0) { setHasConnectors(true); @@ -134,6 +140,7 @@ export const AddConnectorInline = ({ actionTypeRegistered={actionTypeRegistered} connectors={connectors} onConnectorSelected={onSelectConnector} + allowGroupConnector={allowGroupConnector} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx index 4f704af65827d..1c2e1ae1e7318 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx @@ -19,16 +19,26 @@ import { EuiIcon, EuiFlexGroup, EuiBetaBadge, + EuiButtonGroup, + EuiSpacer, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import './connector_add_modal.scss'; import { betaBadgeProps } from './beta_badge_props'; import { hasSaveActionsCapability } from '../../lib/capabilities'; -import { ActionType, ActionConnector, ActionTypeRegistryContract } from '../../../types'; +import { + ActionType, + ActionConnector, + ActionTypeRegistryContract, + ActionTypeModel, + ActionTypeIndex, +} from '../../../types'; import { useKibana } from '../../../common/lib/kibana'; import { useCreateConnector } from '../../hooks/use_create_connector'; -import { ConnectorForm, ConnectorFormState } from './connector_form'; +import { ConnectorForm, ConnectorFormState, ResetForm } from './connector_form'; import { ConnectorFormSchema } from './types'; +import { loadActionTypes } from '../../lib/action_connector_api'; +import { SectionLoading } from '../../components'; export interface ConnectorAddModalProps { actionType: ActionType; @@ -38,27 +48,74 @@ export interface ConnectorAddModalProps { } const ConnectorAddModal = ({ - actionType, + actionType: tempActionType, onClose, postSaveEventHandler, actionTypeRegistry, }: ConnectorAddModalProps) => { const { application: { capabilities }, + http, + notifications: { toasts }, } = useKibana().services; - + const [actionType, setActionType] = useState(tempActionType); + const [loadingActionTypes, setLoadingActionTypes] = useState(false); + const [allActionTypes, setAllActionTypes] = useState(undefined); const { isLoading: isSavingConnector, createConnector } = useCreateConnector(); const isMounted = useRef(false); - const initialConnector = { - actionTypeId: actionType.id, + const [initialConnector, setInitialConnector] = useState({ + actionTypeId: actionType?.id ?? '', isDeprecated: false, - isMissingSecrets: false, config: {}, secrets: {}, - }; + isMissingSecrets: false, + }); const canSave = hasSaveActionsCapability(capabilities); const actionTypeModel = actionTypeRegistry.get(actionType.id); + const groupActionTypeModel: Array = + actionTypeModel && actionTypeModel.subtype + ? (actionTypeModel?.subtype ?? []).map((subtypeAction) => ({ + ...actionTypeRegistry.get(subtypeAction.id), + name: subtypeAction.name, + })) + : []; + + const groupActionButtons = groupActionTypeModel.map((gAction) => ({ + id: gAction.id, + label: gAction.name, + 'data-test-subj': `${gAction.id}Button`, + })); + + const resetConnectorForm = useRef(); + + const setResetForm = (reset: ResetForm) => { + resetConnectorForm.current = reset; + }; + + const onChangeGroupAction = (id: string) => { + if (allActionTypes && allActionTypes[id]) { + setActionType(allActionTypes[id]); + setInitialConnector({ + actionTypeId: id, + isDeprecated: false, + config: {}, + secrets: {}, + isMissingSecrets: false, + }); + if (resetConnectorForm.current) { + resetConnectorForm.current({ + resetValues: true, + defaultValue: { + actionTypeId: id, + isDeprecated: false, + config: {}, + secrets: {}, + }, + }); + } + } + }; const [preSubmitValidationErrorMessage, setPreSubmitValidationErrorMessage] = useState(null); @@ -131,8 +188,39 @@ const ConnectorAddModal = ({ }; }, []); + useEffect(() => { + (async () => { + try { + setLoadingActionTypes(true); + const availableActionTypes = await loadActionTypes({ http }); + setLoadingActionTypes(false); + + const index: ActionTypeIndex = {}; + for (const actionTypeItem of availableActionTypes) { + index[actionTypeItem.id] = actionTypeItem; + } + setAllActionTypes(index); + } catch (e) { + if (toasts) { + toasts.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.actionsConnectorsList.unableToLoadConnectorTypesMessage', + { defaultMessage: 'Unable to load connector types' } + ), + }); + } + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( - + {actionTypeModel && actionTypeModel.iconClass ? ( @@ -167,13 +255,40 @@ const ConnectorAddModal = ({ - - {preSubmitValidationErrorMessage} + {loadingActionTypes ? ( + + + + ) : ( + <> + {groupActionTypeModel && ( + <> + + + + )} + + {preSubmitValidationErrorMessage} + + )} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_form.tsx index b0d8072dae652..d9e59ac19db94 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_form.tsx @@ -27,6 +27,16 @@ export interface ConnectorFormState { preSubmitValidator: ConnectorValidationFunc | null; } +export type ResetForm = ( + options?: + | { + resetValues?: boolean | undefined; + defaultValue?: + | Partial, Record>> + | undefined; + } + | undefined +) => void; interface Props { actionTypeModel: ActionTypeModel | null; connector: ConnectorFormSchema & { isMissingSecrets: boolean }; @@ -35,6 +45,7 @@ interface Props { onChange?: (state: ConnectorFormState) => void; /** Handler to receive update on the form "isModified" state */ onFormModifiedChange?: (isModified: boolean) => void; + setResetForm?: (value: ResetForm) => void; } /** * The serializer and deserializer are needed to transform the headers of @@ -101,13 +112,14 @@ const ConnectorFormComponent: React.FC = ({ isEdit, onChange, onFormModifiedChange, + setResetForm, }) => { const { form } = useForm({ defaultValue: connector, serializer: formSerializer, deserializer: formDeserializer, }); - const { submit, isValid: isFormValid, isSubmitted, isSubmitting } = form; + const { submit, isValid: isFormValid, isSubmitted, isSubmitting, reset } = form; const [preSubmitValidator, setPreSubmitValidator] = useState( null ); @@ -133,6 +145,13 @@ const ConnectorFormComponent: React.FC = ({ } }, [isFormModified, onFormModifiedChange]); + useEffect(() => { + if (setResetForm) { + setResetForm(reset); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [reset]); + return (
getValidConnectors(connectors, actionItem, actionTypesIndex), - [actionItem, actionTypesIndex, connectors] + () => getValidConnectors(connectors, actionItem, actionTypesIndex, allowGroupConnector), + [actionItem, actionTypesIndex, allowGroupConnector, connectors] ); const selectedConnectors = useMemo( - () => getValueOfSelectedConnector(actionItem.id, validConnectors, actionTypeRegistered), - [actionItem.id, validConnectors, actionTypeRegistered] + () => + getValueOfSelectedConnector( + actionItem.id, + validConnectors, + actionTypeRegistered, + allowGroupConnector + ), + [actionItem.id, validConnectors, actionTypeRegistered, allowGroupConnector] ); const options = useMemo( @@ -83,10 +91,15 @@ function ConnectorsSelectionComponent({ const getValueOfSelectedConnector = ( actionItemId: string, connectors: ActionConnector[], - actionTypeRegistered: ActionTypeModel + actionTypeRegistered: ActionTypeModel, + allowGroupConnector: string[] = [] ): Array> => { - const selectedConnector = connectors.find((connector) => connector.id === actionItemId); - + let selectedConnector = connectors.find((connector) => connector.id === actionItemId); + if (allowGroupConnector.length > 0 && !selectedConnector) { + selectedConnector = connectors.find((connector) => + allowGroupConnector.includes(connector.actionTypeId) + ); + } if (!selectedConnector) { return []; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/create_connector_flyout/index.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/create_connector_flyout/index.tsx index 32bd7931aecc8..5cf6f6f8de69b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/create_connector_flyout/index.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/create_connector_flyout/index.tsx @@ -6,21 +6,30 @@ */ import React, { memo, ReactNode, useCallback, useEffect, useRef, useState } from 'react'; -import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiFlyout, EuiFlyoutBody } from '@elastic/eui'; +import { + EuiButton, + EuiButtonGroup, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiSpacer, +} from '@elastic/eui'; -import { getConnectorCompatibility } from '@kbn/actions-plugin/common'; +import { getConnectorCompatibility, UptimeConnectorFeatureId } from '@kbn/actions-plugin/common'; import { FormattedMessage } from '@kbn/i18n-react'; import { ActionConnector, ActionType, ActionTypeModel, + ActionTypeIndex, ActionTypeRegistryContract, } from '../../../../types'; import { hasSaveActionsCapability } from '../../../lib/capabilities'; import { useKibana } from '../../../../common/lib/kibana'; import { ActionTypeMenu } from '../action_type_menu'; import { useCreateConnector } from '../../../hooks/use_create_connector'; -import { ConnectorForm, ConnectorFormState } from '../connector_form'; +import { ConnectorForm, ConnectorFormState, ResetForm } from '../connector_form'; import { ConnectorFormSchema } from '../types'; import { FlyoutHeader } from './header'; import { FlyoutFooter } from './footer'; @@ -47,6 +56,7 @@ const CreateConnectorFlyoutComponent: React.FC = ({ const { isLoading: isSavingConnector, createConnector } = useCreateConnector(); const isMounted = useRef(false); + const [allActionTypes, setAllActionTypes] = useState(undefined); const [actionType, setActionType] = useState(null); const [hasActionsUpgradeableByTrial, setHasActionsUpgradeableByTrial] = useState(false); const canSave = hasSaveActionsCapability(capabilities); @@ -78,6 +88,47 @@ const CreateConnectorFlyoutComponent: React.FC = ({ const actionTypeModel: ActionTypeModel | null = actionType != null ? actionTypeRegistry.get(actionType.id) : null; + /* Future Developer + * We are excluding `UptimeConnectorFeatureId` because as this time Synthetics won't work + * with slack API on their UI, We need to add an ISSUE here so they can fix it + */ + const groupActionTypeModel: Array = + actionTypeModel && actionTypeModel.subtype && featureId !== UptimeConnectorFeatureId + ? (actionTypeModel?.subtype ?? []).map((subtypeAction) => ({ + ...actionTypeRegistry.get(subtypeAction.id), + name: subtypeAction.name, + })) + : []; + + const groupActionButtons = groupActionTypeModel.map((gAction) => ({ + id: gAction.id, + label: gAction.name, + 'data-test-subj': `${gAction.id}Button`, + })); + + const resetConnectorForm = useRef(); + + const setResetForm = (reset: ResetForm) => { + resetConnectorForm.current = reset; + }; + + const onChangeGroupAction = (id: string) => { + if (allActionTypes && allActionTypes[id]) { + setActionType(allActionTypes[id]); + if (resetConnectorForm.current) { + resetConnectorForm.current({ + resetValues: true, + defaultValue: { + actionTypeId: id, + isDeprecated: false, + config: {}, + secrets: {}, + }, + }); + } + } + }; + const validateAndCreateConnector = useCallback(async () => { setPreSubmitValidationErrorMessage(null); @@ -166,14 +217,29 @@ const CreateConnectorFlyoutComponent: React.FC = ({ > {hasConnectorTypeSelected ? ( <> + {groupActionTypeModel && ( + <> + + + + )} {!!preSubmitValidationErrorMessage &&

{preSubmitValidationErrorMessage}

} - <> @@ -220,6 +286,7 @@ const CreateConnectorFlyoutComponent: React.FC = ({ featureId={featureId} onActionTypeChange={setActionType} setHasActionsUpgradeableByTrial={setHasActionsUpgradeableByTrial} + setAllActionTypes={setAllActionTypes} actionTypeRegistry={actionTypeRegistry} /> )} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/connectors.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/connectors.ts index 6543d74ecd7a2..af09d984f9417 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/connectors.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/connectors.ts @@ -10,13 +10,15 @@ import { ActionConnector, ActionTypeIndex, RuleAction } from '../../../types'; export const getValidConnectors = ( connectors: ActionConnector[], actionItem: RuleAction, - actionTypesIndex: ActionTypeIndex + actionTypesIndex: ActionTypeIndex, + allowGroupConnector: string[] = [] ): ActionConnector[] => { const actionType = actionTypesIndex[actionItem.actionTypeId]; return connectors.filter( (connector) => - connector.actionTypeId === actionItem.actionTypeId && + (allowGroupConnector.includes(connector.actionTypeId) || + connector.actionTypeId === actionItem.actionTypeId) && // include only enabled by config connectors or preconfigured (actionType?.enabledInConfig || connector.isPreconfigured) ); diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index b5278bde4e175..756ea14488fad 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -254,6 +254,10 @@ export interface ActionTypeModel; customConnectorSelectItem?: CustomConnectorSelectionItem; isExperimental?: boolean; + subtype?: Array<{ id: string; name: string }>; + convertParamsBetweenGroups?: (params: ActionParams) => ActionParams | {}; + hideInUi?: boolean; + modalWidth?: number; } export interface GenericValidationResult { diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index 4e8f8c45abb5b..d418b268f69ce 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -45,6 +45,7 @@ const enabledActionTypes = [ '.jira', '.resilient', '.slack', + '.slack_api', '.tines', '.webhook', '.xmatters', @@ -174,6 +175,7 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions) 'localhost', 'some.non.existent.com', 'smtp.live.com', + 'slack.com', ])}`, `--xpack.actions.enableFooterInEmail=${enableFooterInEmail}`, '--xpack.encryptedSavedObjects.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"', diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/slack_api.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/slack_api.ts new file mode 100644 index 0000000000000..12c17d2a7a4f9 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/slack_api.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function slackTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('Slack API action', () => { + it('should return 200 when creating a slack action successfully', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A slack api action', + connector_type_id: '.slack_api', + secrets: { + token: 'some token', + }, + }) + .expect(200); + + expect(createdAction).to.eql({ + id: createdAction.id, + is_preconfigured: false, + is_deprecated: false, + is_missing_secrets: false, + name: 'A slack api action', + connector_type_id: '.slack_api', + config: {}, + }); + + expect(typeof createdAction.id).to.be('string'); + + const { body: fetchedAction } = await supertest + .get(`/api/actions/connector/${createdAction.id}`) + .expect(200); + + expect(fetchedAction).to.eql({ + id: fetchedAction.id, + is_preconfigured: false, + is_deprecated: false, + is_missing_secrets: false, + name: 'A slack api action', + connector_type_id: '.slack_api', + config: {}, + }); + }); + + it('should respond with a 400 Bad Request when creating a slack action with no token', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A slack api action', + connector_type_id: '.slack_api', + secrets: {}, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type secrets: [token]: expected value of type [string] but got [undefined]', + }); + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/slack.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/slack_webhook.ts similarity index 99% rename from x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/slack.ts rename to x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/slack_webhook.ts index 305afa0fdcaf9..d9c32ccd643b0 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/slack.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/slack_webhook.ts @@ -18,7 +18,7 @@ export default function slackTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const configService = getService('config'); - describe('slack action', () => { + describe('Slack webhook action', () => { let simulatedActionId = ''; let slackSimulatorURL: string = ''; let slackServer: http.Server; diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts index 66f83daff0429..05bd4da72c19c 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts @@ -32,7 +32,8 @@ export default function connectorsTests({ loadTestFile, getService }: FtrProvide loadTestFile(require.resolve('./connector_types/opsgenie')); loadTestFile(require.resolve('./connector_types/pagerduty')); loadTestFile(require.resolve('./connector_types/server_log')); - loadTestFile(require.resolve('./connector_types/slack')); + loadTestFile(require.resolve('./connector_types/slack_webhook')); + loadTestFile(require.resolve('./connector_types/slack_api')); loadTestFile(require.resolve('./connector_types/webhook')); loadTestFile(require.resolve('./connector_types/xmatters')); loadTestFile(require.resolve('./connector_types/tines')); @@ -48,6 +49,6 @@ export default function connectorsTests({ loadTestFile, getService }: FtrProvide /** * Sub action framework */ - loadTestFile(require.resolve('./sub_action_framework')); + // loadTestFile(require.resolve('./sub_action_framework')); }); } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/check_registered_connector_types.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/check_registered_connector_types.ts index 830f5e6f8d96d..f0578f6dbd7ce 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/check_registered_connector_types.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/check_registered_connector_types.ts @@ -34,6 +34,7 @@ export default function createRegisteredConnectorTypeTests({ getService }: FtrPr '.swimlane', '.server-log', '.slack', + '.slack_api', '.webhook', '.cases-webhook', '.xmatters', diff --git a/x-pack/test/functional/services/actions/index.ts b/x-pack/test/functional/services/actions/index.ts index c7539c37f2c32..7218f3079aafb 100644 --- a/x-pack/test/functional/services/actions/index.ts +++ b/x-pack/test/functional/services/actions/index.ts @@ -10,6 +10,7 @@ import { ActionsCommonServiceProvider } from './common'; import { ActionsOpsgenieServiceProvider } from './opsgenie'; import { ActionsTinesServiceProvider } from './tines'; import { ActionsAPIServiceProvider } from './api'; +import { ActionsSlackServiceProvider } from './slack'; export function ActionsServiceProvider(context: FtrProviderContext) { const common = ActionsCommonServiceProvider(context); @@ -19,5 +20,6 @@ export function ActionsServiceProvider(context: FtrProviderContext) { common: ActionsCommonServiceProvider(context), opsgenie: ActionsOpsgenieServiceProvider(context, common), tines: ActionsTinesServiceProvider(context, common), + slack: ActionsSlackServiceProvider(context, common), }; } diff --git a/x-pack/test/functional/services/actions/slack.ts b/x-pack/test/functional/services/actions/slack.ts new file mode 100644 index 0000000000000..b4297644c7c93 --- /dev/null +++ b/x-pack/test/functional/services/actions/slack.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import type { ActionsCommon } from './common'; + +export interface WebhookConnectorFormFields { + name: string; + url: string; +} + +export interface WebApiConnectorFormFields { + name: string; + token: string; +} + +export function ActionsSlackServiceProvider( + { getService }: FtrProviderContext, + common: ActionsCommon +) { + const testSubjects = getService('testSubjects'); + + return { + async createNewWebhook({ name, url }: WebhookConnectorFormFields) { + await common.openNewConnectorForm('slack'); + + await testSubjects.setValue('nameInput', name); + await testSubjects.setValue('slackWebhookUrlInput', url); + + const flyOutSaveButton = await testSubjects.find('create-connector-flyout-save-btn'); + expect(await flyOutSaveButton.isEnabled()).to.be(true); + await flyOutSaveButton.click(); + }, + async createNewWebAPI({ name, token }: WebApiConnectorFormFields) { + await common.openNewConnectorForm('slack'); + + const webApiTab = await testSubjects.find('.slack_apiButton'); + await webApiTab.click(); + + await testSubjects.setValue('nameInput', name); + await testSubjects.setValue('secrets.token-input', token); + + const flyOutSaveButton = await testSubjects.find('create-connector-flyout-save-btn'); + expect(await flyOutSaveButton.isEnabled()).to.be(true); + await flyOutSaveButton.click(); + }, + }; +} diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/index.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/index.ts index 1d6420004c0cd..c246f92309d2d 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/index.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/index.ts @@ -12,5 +12,6 @@ export default ({ loadTestFile }: FtrProviderContext) => { loadTestFile(require.resolve('./general')); loadTestFile(require.resolve('./opsgenie')); loadTestFile(require.resolve('./tines')); + loadTestFile(require.resolve('./slack')); }); }; diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/slack.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/slack.ts new file mode 100644 index 0000000000000..f975bed9f965e --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/slack.ts @@ -0,0 +1,208 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { ObjectRemover } from '../../../lib/object_remover'; +import { generateUniqueKey } from '../../../lib/get_test_data'; +import { createSlackConnectorAndObjectRemover, getConnectorByName } from './utils'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const testSubjects = getService('testSubjects'); + const pageObjects = getPageObjects(['common', 'triggersActionsUI', 'header']); + const retry = getService('retry'); + const supertest = getService('supertest'); + const actions = getService('actions'); + const rules = getService('rules'); + let objectRemover: ObjectRemover; + + describe('Slack', () => { + before(async () => { + objectRemover = await createSlackConnectorAndObjectRemover({ getService }); + }); + + after(async () => { + await objectRemover.removeAll(); + }); + + describe('connector page', () => { + beforeEach(async () => { + await pageObjects.common.navigateToApp('triggersActionsConnectors'); + }); + + it('should only show one slack connector', async () => { + if (await testSubjects.exists('createActionButton')) { + await testSubjects.click('createActionButton'); + } else { + await testSubjects.click('createFirstActionButton'); + } + await testSubjects.existOrFail('.slack-card'); + const slackApiCardExists = await testSubjects.exists('.slack_api-card'); + expect(slackApiCardExists).to.be(false); + }); + + it('should create the webhook connector', async () => { + const connectorName = generateUniqueKey(); + await actions.slack.createNewWebhook({ + name: connectorName, + url: 'https://test.com', + }); + + const toastTitle = await pageObjects.common.closeToast(); + expect(toastTitle).to.eql(`Created '${connectorName}'`); + + await pageObjects.triggersActionsUI.searchConnectors(connectorName); + + const searchResults = await pageObjects.triggersActionsUI.getConnectorsList(); + expect(searchResults).to.eql([ + { + name: connectorName, + actionType: 'Slack', + }, + ]); + const connector = await getConnectorByName(connectorName, supertest); + objectRemover.add(connector.id, 'action', 'actions'); + }); + + it('should create the web api connector', async () => { + const connectorName = generateUniqueKey(); + await actions.slack.createNewWebAPI({ + name: connectorName, + token: 'supersecrettoken', + }); + + const toastTitle = await pageObjects.common.closeToast(); + expect(toastTitle).to.eql(`Created '${connectorName}'`); + + await pageObjects.triggersActionsUI.searchConnectors(connectorName); + + const searchResults = await pageObjects.triggersActionsUI.getConnectorsList(); + expect(searchResults).to.eql([ + { + name: connectorName, + actionType: 'Slack API', + }, + ]); + const connector = await getConnectorByName(connectorName, supertest); + objectRemover.add(connector.id, 'action', 'actions'); + }); + }); + + describe('rule creation', async () => { + const webhookConnectorName = generateUniqueKey(); + const webApiConnectorName = generateUniqueKey(); + let webApiAction: { id: string }; + let webhookAction: { id: string }; + + const setupRule = async () => { + const ruleName = generateUniqueKey(); + await retry.try(async () => { + await rules.common.defineIndexThresholdAlert(ruleName); + }); + return ruleName; + }; + + const getRuleIdByName = async (name: string) => { + const response = await supertest + .get(`/api/alerts/_find?search=${name}&search_fields=name`) + .expect(200); + return response.body.data[0].id; + }; + + const selectSlackConnectorInRuleAction = async ({ connectorId }: { connectorId: string }) => { + await testSubjects.click('.slack-alerting-ActionTypeSelectOption'); // "Slack" in connector list + await testSubjects.click('selectActionConnector-.slack-0'); + await testSubjects.click(`dropdown-connector-${connectorId}`); + }; + + before(async () => { + webApiAction = await actions.api.createConnector({ + name: webApiConnectorName, + config: {}, + secrets: { token: 'supersecrettoken' }, + connectorTypeId: '.slack_api', + }); + + webhookAction = await actions.api.createConnector({ + name: webhookConnectorName, + config: {}, + secrets: { webhookUrl: 'https://test.com' }, + connectorTypeId: '.slack', + }); + + objectRemover.add(webhookAction.id, 'action', 'actions'); + objectRemover.add(webApiAction.id, 'action', 'actions'); + await pageObjects.common.navigateToApp('triggersActions'); + }); + + it('should save webhook type slack connectors', async () => { + const ruleName = await setupRule(); + + await selectSlackConnectorInRuleAction({ + connectorId: webhookAction.id, + }); + await testSubjects.click('saveRuleButton'); + await pageObjects.triggersActionsUI.searchAlerts(ruleName); + + const ruleId = await getRuleIdByName(ruleName); + objectRemover.add(ruleId, 'rule', 'alerting'); + + const searchResults = await pageObjects.triggersActionsUI.getAlertsList(); + expect(searchResults).to.eql([ + { + duration: '00:00', + interval: '1 min', + name: `${ruleName}Index threshold`, + tags: '', + }, + ]); + + const toastTitle = await pageObjects.common.closeToast(); + expect(toastTitle).to.eql(`Created rule "${ruleName}"`); + }); + + it('should save webapi type slack connectors', async () => { + await setupRule(); + await selectSlackConnectorInRuleAction({ + connectorId: webApiAction.id, + }); + + await testSubjects.click('saveRuleButton'); + + const toastTitle = await pageObjects.common.closeToast(); + expect(toastTitle).to.eql('Failed to retrieve Slack channels list'); + + // We are not saving the rule yet as we currently have no way + // to mock the internal request that loads the channels list + // uncomment once we have a way to mock the request + + // const ruleName = await setupRule(); + // await selectSlackConnectorInRuleAction({ + // connectorId: webApiAction.id, + // }); + + // await testSubjects.click('saveRuleButton'); + // await pageObjects.triggersActionsUI.searchAlerts(ruleName); + + // const ruleId = await getRuleIdByName(ruleName); + // objectRemover.add(ruleId, 'rule', 'alerting'); + + // const searchResults = await pageObjects.triggersActionsUI.getAlertsList(); + // expect(searchResults).to.eql([ + // { + // duration: '00:00', + // interval: '1 min', + // name: `${ruleName}Index threshold`, + // tags: '', + // }, + // ]); + // const toastTitle = await pageObjects.common.closeToast(); + // expect(toastTitle).to.eql(`Created rule "${ruleName}"`); + }); + }); + }); +}; diff --git a/x-pack/test/functional_with_es_ssl/config.base.ts b/x-pack/test/functional_with_es_ssl/config.base.ts index 71039b211f5db..533fec1944b67 100644 --- a/x-pack/test/functional_with_es_ssl/config.base.ts +++ b/x-pack/test/functional_with_es_ssl/config.base.ts @@ -24,6 +24,7 @@ const enabledActionTypes = [ '.servicenow', '.servicenow-sir', '.slack', + '.slack_api', '.tines', '.webhook', 'test.authorization', diff --git a/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts b/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts index 67478db2c8c00..6bc6b02013ed0 100644 --- a/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts @@ -57,6 +57,7 @@ export default function ({ getService }: FtrProviderContext) { 'actions:.servicenow-itom', 'actions:.servicenow-sir', 'actions:.slack', + 'actions:.slack_api', 'actions:.swimlane', 'actions:.teams', 'actions:.tines', From 2fad86a6c578408700473200ae8173ca50fb0a5e Mon Sep 17 00:00:00 2001 From: Kevin Logan <56395104+kevinlog@users.noreply.github.com> Date: Tue, 25 Apr 2023 09:31:57 -0400 Subject: [PATCH 17/29] [Security Solution] Remove index false from artifact saved objects mappings (#155204) Updates the mappings for the artifact saved objects This effort is part of https://github.com/elastic/security-team/issues/6268 and https://github.com/elastic/dev/issues/2189 --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Gerard Soldevila --- ...grations_state_action_machine.test.ts.snap | 30 ++++ .../src/core/unused_types.ts | 2 + .../src/initial_state.test.ts | 5 + .../group2/check_registered_types.test.ts | 3 +- .../group3/split_kibana_index.test.ts | 1 - .../delete_unknown_types/mappings.json | 2 - .../deprecations_service/mappings.json | 1 - .../export_transform/mappings.json | 1 - .../hidden_saved_objects/mappings.json | 1 - .../nested_export_transform/mappings.json | 1 - .../visible_in_management/mappings.json | 2 - .../migrate_artifacts_to_fleet.test.ts | 144 ------------------ .../artifacts/migrate_artifacts_to_fleet.ts | 96 ------------ .../lib/artifacts/saved_object_mappings.ts | 65 +------- .../security_solution/server/plugin.ts | 21 ++- .../security_solution/server/saved_objects.ts | 6 +- .../saved_objects/spaces/mappings.json | 2 - .../saved_objects/spaces/mappings.json | 2 - 18 files changed, 49 insertions(+), 336 deletions(-) delete mode 100644 x-pack/plugins/security_solution/server/endpoint/lib/artifacts/migrate_artifacts_to_fleet.test.ts delete mode 100644 x-pack/plugins/security_solution/server/endpoint/lib/artifacts/migrate_artifacts_to_fleet.ts diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/__snapshots__/migrations_state_action_machine.test.ts.snap b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/__snapshots__/migrations_state_action_machine.test.ts.snap index 387dbd87bbafe..0e05eed1e99b7 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/__snapshots__/migrations_state_action_machine.test.ts.snap +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/__snapshots__/migrations_state_action_machine.test.ts.snap @@ -54,6 +54,11 @@ Object { "type": "csp_rule", }, }, + Object { + "term": Object { + "type": "endpoint:user-artifact", + }, + }, Object { "term": Object { "type": "file-upload-telemetry", @@ -272,6 +277,11 @@ Object { "type": "csp_rule", }, }, + Object { + "term": Object { + "type": "endpoint:user-artifact", + }, + }, Object { "term": Object { "type": "file-upload-telemetry", @@ -494,6 +504,11 @@ Object { "type": "csp_rule", }, }, + Object { + "term": Object { + "type": "endpoint:user-artifact", + }, + }, Object { "term": Object { "type": "file-upload-telemetry", @@ -720,6 +735,11 @@ Object { "type": "csp_rule", }, }, + Object { + "term": Object { + "type": "endpoint:user-artifact", + }, + }, Object { "term": Object { "type": "file-upload-telemetry", @@ -994,6 +1014,11 @@ Object { "type": "csp_rule", }, }, + Object { + "term": Object { + "type": "endpoint:user-artifact", + }, + }, Object { "term": Object { "type": "file-upload-telemetry", @@ -1223,6 +1248,11 @@ Object { "type": "csp_rule", }, }, + Object { + "term": Object { + "type": "endpoint:user-artifact", + }, + }, Object { "term": Object { "type": "file-upload-telemetry", diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/core/unused_types.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/core/unused_types.ts index 46ddd23251217..2f9dd57afa294 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/core/unused_types.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/core/unused_types.ts @@ -47,6 +47,8 @@ export const REMOVED_TYPES: string[] = [ 'csp_rule', // Removed in 8.8 https://github.com/elastic/kibana/pull/151116 'upgrade-assistant-telemetry', + // Removed in 8.8 https://github.com/elastic/kibana/pull/155204 + 'endpoint:user-artifact', ].sort(); export const excludeUnusedTypesQuery: QueryDslQueryContainer = { diff --git a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/initial_state.test.ts b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/initial_state.test.ts index d49e784f77633..3ee201605f1ac 100644 --- a/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/initial_state.test.ts +++ b/packages/core/saved-objects/core-saved-objects-migration-server-internal/src/initial_state.test.ts @@ -107,6 +107,11 @@ describe('createInitialState', () => { "type": "csp_rule", }, }, + Object { + "term": Object { + "type": "endpoint:user-artifact", + }, + }, Object { "term": Object { "type": "file-upload-telemetry", diff --git a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts index 6c7e00b1822b7..05124ffad8140 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group2/check_registered_types.test.ts @@ -81,8 +81,7 @@ describe('checking migration metadata changes on all registered SO types', () => "core-usage-stats": "b3c04da317c957741ebcdedfea4524049fdc79ff", "csp-rule-template": "099c229bf97578d9ca72b3a672d397559b84ee0b", "dashboard": "71e3f8dfcffeb5fbd410dec81ce46f5691763c43", - "endpoint:user-artifact": "a5b154962fb6cdf5d9e7452e58690054c95cc72a", - "endpoint:user-artifact-manifest": "5989989c0f84dd2d02da1eb46b6254e334bd2ccd", + "endpoint:user-artifact-manifest": "8ad9bd235dcfdc18b567aef0dc36ac686193dc89", "enterprise_search_telemetry": "4b41830e3b28a16eb92dee0736b44ae6276ced9b", "epm-packages": "8755f947a00613f994b1bc5d5580e104043e27f6", "epm-packages-assets": "00c8b5e5bf059627ffc9fbde920e1ac75926c5f6", diff --git a/src/core/server/integration_tests/saved_objects/migrations/group3/split_kibana_index.test.ts b/src/core/server/integration_tests/saved_objects/migrations/group3/split_kibana_index.test.ts index 8c5f78a574db1..06b8c169cc396 100644 --- a/src/core/server/integration_tests/saved_objects/migrations/group3/split_kibana_index.test.ts +++ b/src/core/server/integration_tests/saved_objects/migrations/group3/split_kibana_index.test.ts @@ -188,7 +188,6 @@ describe('split .kibana index into multiple system indices', () => { "connector_token", "core-usage-stats", "csp-rule-template", - "endpoint:user-artifact", "endpoint:user-artifact-manifest", "enterprise_search_telemetry", "epm-packages", diff --git a/test/api_integration/fixtures/es_archiver/saved_objects/delete_unknown_types/mappings.json b/test/api_integration/fixtures/es_archiver/saved_objects/delete_unknown_types/mappings.json index 4468d6849f6ca..885a64189a059 100644 --- a/test/api_integration/fixtures/es_archiver/saved_objects/delete_unknown_types/mappings.json +++ b/test/api_integration/fixtures/es_archiver/saved_objects/delete_unknown_types/mappings.json @@ -102,7 +102,6 @@ ], ".kibana_security_solution": [ "csp-rule-template", - "endpoint:user-artifact", "endpoint:user-artifact-manifest", "exception-list", "exception-list-agnostic", @@ -750,7 +749,6 @@ ], ".kibana_security_solution": [ "csp-rule-template", - "endpoint:user-artifact", "endpoint:user-artifact-manifest", "exception-list", "exception-list-agnostic", diff --git a/test/functional/fixtures/es_archiver/deprecations_service/mappings.json b/test/functional/fixtures/es_archiver/deprecations_service/mappings.json index f9bec7fdca4d5..33968127fe42c 100644 --- a/test/functional/fixtures/es_archiver/deprecations_service/mappings.json +++ b/test/functional/fixtures/es_archiver/deprecations_service/mappings.json @@ -101,7 +101,6 @@ ], ".kibana_security_solution": [ "csp-rule-template", - "endpoint:user-artifact", "endpoint:user-artifact-manifest", "exception-list", "exception-list-agnostic", diff --git a/test/functional/fixtures/es_archiver/saved_objects_management/export_transform/mappings.json b/test/functional/fixtures/es_archiver/saved_objects_management/export_transform/mappings.json index 963eaa9d47892..63842481820ee 100644 --- a/test/functional/fixtures/es_archiver/saved_objects_management/export_transform/mappings.json +++ b/test/functional/fixtures/es_archiver/saved_objects_management/export_transform/mappings.json @@ -101,7 +101,6 @@ ], ".kibana_security_solution": [ "csp-rule-template", - "endpoint:user-artifact", "endpoint:user-artifact-manifest", "exception-list", "exception-list-agnostic", diff --git a/test/functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects/mappings.json b/test/functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects/mappings.json index 4cb716253a8a3..dbb70af6b6233 100644 --- a/test/functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects/mappings.json +++ b/test/functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects/mappings.json @@ -101,7 +101,6 @@ ], ".kibana_security_solution": [ "csp-rule-template", - "endpoint:user-artifact", "endpoint:user-artifact-manifest", "exception-list", "exception-list-agnostic", diff --git a/test/functional/fixtures/es_archiver/saved_objects_management/nested_export_transform/mappings.json b/test/functional/fixtures/es_archiver/saved_objects_management/nested_export_transform/mappings.json index 963eaa9d47892..63842481820ee 100644 --- a/test/functional/fixtures/es_archiver/saved_objects_management/nested_export_transform/mappings.json +++ b/test/functional/fixtures/es_archiver/saved_objects_management/nested_export_transform/mappings.json @@ -101,7 +101,6 @@ ], ".kibana_security_solution": [ "csp-rule-template", - "endpoint:user-artifact", "endpoint:user-artifact-manifest", "exception-list", "exception-list-agnostic", diff --git a/test/functional/fixtures/es_archiver/saved_objects_management/visible_in_management/mappings.json b/test/functional/fixtures/es_archiver/saved_objects_management/visible_in_management/mappings.json index 924b2b070ff4d..6ccba0243793d 100644 --- a/test/functional/fixtures/es_archiver/saved_objects_management/visible_in_management/mappings.json +++ b/test/functional/fixtures/es_archiver/saved_objects_management/visible_in_management/mappings.json @@ -101,7 +101,6 @@ ], ".kibana_security_solution": [ "csp-rule-template", - "endpoint:user-artifact", "endpoint:user-artifact-manifest", "exception-list", "exception-list-agnostic", @@ -696,7 +695,6 @@ ], ".kibana_security_solution": [ "csp-rule-template", - "endpoint:user-artifact", "endpoint:user-artifact-manifest", "exception-list", "exception-list-agnostic", diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/migrate_artifacts_to_fleet.test.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/migrate_artifacts_to_fleet.test.ts deleted file mode 100644 index 277772253b92e..0000000000000 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/migrate_artifacts_to_fleet.test.ts +++ /dev/null @@ -1,144 +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 { - loggingSystemMock, - savedObjectsClientMock, - elasticsearchServiceMock, -} from '@kbn/core/server/mocks'; -import type { - SavedObjectsClient, - Logger, - SavedObjectsFindResponse, - SavedObjectsFindResult, -} from '@kbn/core/server'; -import { migrateArtifactsToFleet } from './migrate_artifacts_to_fleet'; -import { createEndpointArtifactClientMock } from '../../services/artifacts/mocks'; -import type { InternalArtifactCompleteSchema } from '../../schemas'; -import { generateArtifactEsGetSingleHitMock } from '@kbn/fleet-plugin/server/services/artifacts/mocks'; -import type { NewArtifact } from '@kbn/fleet-plugin/server/services'; -import type { CreateRequest } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; - -describe('When migrating artifacts to fleet', () => { - let soClient: jest.Mocked; - let logger: jest.Mocked; - let artifactClient: ReturnType; - /** An artifact that was created prior to 7.14 */ - let soArtifactEntry: InternalArtifactCompleteSchema; - - const createSoFindResult = ( - soHits: SavedObjectsFindResult[] = [], - total: number = 15, - page: number = 1 - ): SavedObjectsFindResponse => { - return { - total, - page, - per_page: 10, - saved_objects: soHits, - }; - }; - - beforeEach(async () => { - soClient = savedObjectsClientMock.create() as unknown as jest.Mocked; - logger = loggingSystemMock.create().get() as jest.Mocked; - artifactClient = createEndpointArtifactClientMock(); - // pre-v7.14 artifact, which is compressed - soArtifactEntry = { - identifier: 'endpoint-exceptionlist-macos-v1', - compressionAlgorithm: 'zlib', - encryptionAlgorithm: 'none', - decodedSha256: 'd801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658', - encodedSha256: 'f8e6afa1d5662f5b37f83337af774b5785b5b7f1daee08b7b00c2d6813874cda', - decodedSize: 14, - encodedSize: 22, - body: 'eJyrVkrNKynKTC1WsoqOrQUAJxkFKQ==', - }; - - // Mock the esClient create response to include the artifact properties that were provide - // to it by fleet artifact client - artifactClient._esClient.create.mockImplementation((props: CreateRequest) => { - return elasticsearchServiceMock.createSuccessTransportRequestPromise({ - ...generateArtifactEsGetSingleHitMock({ - ...((props?.body ?? {}) as NewArtifact), - }), - _index: '.fleet-artifacts-7', - _id: `endpoint:endpoint-exceptionlist-macos-v1-${ - // @ts-expect-error TS2339 - props?.body?.decodedSha256 ?? 'UNKNOWN?' - }`, - _version: 1, - result: 'created', - _shards: { - total: 1, - successful: 1, - failed: 0, - }, - _seq_no: 0, - _primary_term: 1, - }); - }); - - soClient.find.mockResolvedValue(createSoFindResult([], 0)).mockResolvedValueOnce( - createSoFindResult([ - { - score: 1, - type: '', - id: 'abc123', - references: [], - attributes: soArtifactEntry, - }, - ]) - ); - }); - - it('should do nothing if there are no artifacts', async () => { - soClient.find.mockReset(); - soClient.find.mockResolvedValue(createSoFindResult([], 0)); - await migrateArtifactsToFleet(soClient, artifactClient, logger); - expect(soClient.find).toHaveBeenCalled(); - expect(artifactClient.createArtifact).not.toHaveBeenCalled(); - expect(soClient.delete).not.toHaveBeenCalled(); - }); - - it('should create new artifact via fleet client and delete prior SO one', async () => { - await migrateArtifactsToFleet(soClient, artifactClient, logger); - expect(artifactClient.createArtifact).toHaveBeenCalled(); - expect(soClient.delete).toHaveBeenCalled(); - }); - - it('should create artifact in fleet with attributes that match the SO version', async () => { - await migrateArtifactsToFleet(soClient, artifactClient, logger); - - await expect(artifactClient.createArtifact.mock.results[0].value).resolves.toEqual( - expect.objectContaining({ - ...soArtifactEntry, - compressionAlgorithm: 'zlib', - }) - ); - }); - - it('should ignore 404 responses for SO delete (multi-node kibana setup)', async () => { - const notFoundError: Error & { output?: { statusCode: number } } = new Error('not found'); - notFoundError.output = { statusCode: 404 }; - soClient.delete.mockRejectedValue(notFoundError); - await expect(migrateArtifactsToFleet(soClient, artifactClient, logger)).resolves.toEqual( - undefined - ); - expect(logger.debug).toHaveBeenCalledWith( - 'Artifact Migration: Attempt to delete Artifact SO [abc123] returned 404' - ); - }); - - it('should Throw() and log error if migration fails', async () => { - const error = new Error('test: delete failed'); - soClient.delete.mockRejectedValue(error); - await expect(migrateArtifactsToFleet(soClient, artifactClient, logger)).rejects.toThrow( - 'Artifact SO migration failed' - ); - }); -}); diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/migrate_artifacts_to_fleet.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/migrate_artifacts_to_fleet.ts deleted file mode 100644 index e015019fa8a5d..0000000000000 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/migrate_artifacts_to_fleet.ts +++ /dev/null @@ -1,96 +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 { inflate as _inflate } from 'zlib'; -import { promisify } from 'util'; -import type { SavedObjectsClient, Logger } from '@kbn/core/server'; -import type { EndpointArtifactClientInterface } from '../../services'; -import type { InternalArtifactCompleteSchema, InternalArtifactSchema } from '../../schemas'; -import { ArtifactConstants } from './common'; - -class ArtifactMigrationError extends Error { - constructor(message: string, public readonly meta?: unknown) { - super(message); - } -} - -const inflateAsync = promisify(_inflate); - -function isCompressed(artifact: InternalArtifactSchema) { - return artifact.compressionAlgorithm === 'zlib'; -} - -/** - * With v7.13, artifact storage was moved from a security_solution saved object to a fleet index - * in order to support Fleet Server. - */ -export const migrateArtifactsToFleet = async ( - soClient: SavedObjectsClient, - endpointArtifactClient: EndpointArtifactClientInterface, - logger: Logger -): Promise => { - let totalArtifactsMigrated = -1; - let hasMore = true; - - try { - while (hasMore) { - // Retrieve list of artifact records - const { saved_objects: artifactList, total } = - await soClient.find({ - type: ArtifactConstants.SAVED_OBJECT_TYPE, - page: 1, - perPage: 10, - }); - - if (totalArtifactsMigrated === -1) { - totalArtifactsMigrated = total; - if (total > 0) { - logger.info(`Migrating artifacts from SavedObject`); - } - } - - // If nothing else to process, then exit out - if (total === 0) { - hasMore = false; - if (totalArtifactsMigrated > 0) { - logger.info(`Total Artifacts migrated: ${totalArtifactsMigrated}`); - } - return; - } - - for (const artifact of artifactList) { - if (isCompressed(artifact.attributes)) { - artifact.attributes = { - ...artifact.attributes, - body: (await inflateAsync(Buffer.from(artifact.attributes.body, 'base64'))).toString( - 'base64' - ), - }; - } - - // Create new artifact in fleet index - await endpointArtifactClient.createArtifact(artifact.attributes); - // Delete old artifact from SO and if there are errors here, then ignore 404's - // since multiple kibana instances could be going at this - try { - await soClient.delete(ArtifactConstants.SAVED_OBJECT_TYPE, artifact.id); - } catch (e) { - if (e?.output?.statusCode !== 404) { - throw e; - } - logger.debug( - `Artifact Migration: Attempt to delete Artifact SO [${artifact.id}] returned 404` - ); - } - } - } - } catch (e) { - const error = new ArtifactMigrationError('Artifact SO migration failed', e); - logger.error(error); - throw error; - } -}; diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/saved_object_mappings.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/saved_object_mappings.ts index 826002f5970cd..dba96c2c084de 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/saved_object_mappings.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/saved_object_mappings.ts @@ -14,81 +14,18 @@ import { migrations } from './migrations'; export const exceptionsArtifactSavedObjectType = ArtifactConstants.SAVED_OBJECT_TYPE; export const manifestSavedObjectType = ManifestConstants.SAVED_OBJECT_TYPE; -export const exceptionsArtifactSavedObjectMappings: SavedObjectsType['mappings'] = { - properties: { - identifier: { - type: 'keyword', - }, - compressionAlgorithm: { - type: 'keyword', - index: false, - }, - encryptionAlgorithm: { - type: 'keyword', - index: false, - }, - encodedSha256: { - type: 'keyword', - }, - encodedSize: { - type: 'long', - index: false, - }, - decodedSha256: { - type: 'keyword', - index: false, - }, - decodedSize: { - type: 'long', - index: false, - }, - created: { - type: 'date', - index: false, - }, - body: { - type: 'binary', - }, - }, -}; - export const manifestSavedObjectMappings: SavedObjectsType['mappings'] = { + dynamic: false, properties: { - created: { - type: 'date', - index: false, - }, schemaVersion: { type: 'keyword', }, - semanticVersion: { - type: 'keyword', - index: false, - }, artifacts: { type: 'nested', - properties: { - policyId: { - type: 'keyword', - index: false, - }, - artifactId: { - type: 'keyword', - index: false, - }, - }, }, }, }; -export const exceptionsArtifactType: SavedObjectsType = { - name: exceptionsArtifactSavedObjectType, - indexPattern: SECURITY_SOLUTION_SAVED_OBJECT_INDEX, - hidden: false, - namespaceType: 'agnostic', - mappings: exceptionsArtifactSavedObjectMappings, -}; - export const manifestType: SavedObjectsType = { name: manifestSavedObjectType, indexPattern: SECURITY_SOLUTION_SAVED_OBJECT_INDEX, diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index e74138f61e195..48b963b788bd2 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -69,7 +69,6 @@ import type { ITelemetryReceiver } from './lib/telemetry/receiver'; import { TelemetryReceiver } from './lib/telemetry/receiver'; import { licenseService } from './lib/license'; import { PolicyWatcher } from './endpoint/lib/policy/license_watch'; -import { migrateArtifactsToFleet } from './endpoint/lib/artifacts/migrate_artifacts_to_fleet'; import previewPolicy from './lib/detection_engine/routes/index/preview_policy.json'; import { createRuleExecutionLogService } from './lib/detection_engine/rule_monitoring'; import { getKibanaPrivilegesFeaturePrivileges, getCasesKibanaFeature } from './features'; @@ -450,17 +449,15 @@ export class Plugin implements ISecuritySolutionPlugin { // Migrate artifacts to fleet and then start the minifest task after that is done plugins.fleet.fleetSetupCompleted().then(() => { - migrateArtifactsToFleet(savedObjectsClient, artifactClient, logger).finally(() => { - logger.info('Dependent plugin setup complete - Starting ManifestTask'); - - if (this.manifestTask) { - this.manifestTask.start({ - taskManager, - }); - } else { - logger.error(new Error('User artifacts task not available.')); - } - }); + logger.info('Dependent plugin setup complete - Starting ManifestTask'); + + if (this.manifestTask) { + this.manifestTask.start({ + taskManager, + }); + } else { + logger.error(new Error('User artifacts task not available.')); + } }); // License related start diff --git a/x-pack/plugins/security_solution/server/saved_objects.ts b/x-pack/plugins/security_solution/server/saved_objects.ts index 394cab7f52455..bd6c21a4d489a 100644 --- a/x-pack/plugins/security_solution/server/saved_objects.ts +++ b/x-pack/plugins/security_solution/server/saved_objects.ts @@ -12,10 +12,7 @@ import { noteType, pinnedEventType, timelineType } from './lib/timeline/saved_ob import { legacyType as legacyRuleActionsType } from './lib/detection_engine/rule_actions_legacy'; import { prebuiltRuleAssetType } from './lib/detection_engine/prebuilt_rules'; import { type as signalsMigrationType } from './lib/detection_engine/migrations/saved_objects'; -import { - exceptionsArtifactType, - manifestType, -} from './endpoint/lib/artifacts/saved_object_mappings'; +import { manifestType } from './endpoint/lib/artifacts/saved_object_mappings'; const types = [ noteType, @@ -23,7 +20,6 @@ const types = [ legacyRuleActionsType, prebuiltRuleAssetType, timelineType, - exceptionsArtifactType, manifestType, signalsMigrationType, ]; diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json index f1722d291fc1b..407f0fd182fe7 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json +++ b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json @@ -73,7 +73,6 @@ ], ".kibana_security_solution": [ "csp-rule-template", - "endpoint:user-artifact", "endpoint:user-artifact-manifest", "exception-list", "exception-list-agnostic", @@ -226,7 +225,6 @@ ], ".kibana_security_solution": [ "csp-rule-template", - "endpoint:user-artifact", "endpoint:user-artifact-manifest", "exception-list", "exception-list-agnostic", diff --git a/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json b/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json index a787729a16c35..340a1003d50a9 100644 --- a/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json +++ b/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json @@ -73,7 +73,6 @@ ], ".kibana_security_solution": [ "csp-rule-template", - "endpoint:user-artifact", "endpoint:user-artifact-manifest", "exception-list", "exception-list-agnostic", @@ -213,7 +212,6 @@ ], ".kibana_security_solution": [ "csp-rule-template", - "endpoint:user-artifact", "endpoint:user-artifact-manifest", "exception-list", "exception-list-agnostic", From ddd09ac271414730b9c5b2e4f30367e20215da98 Mon Sep 17 00:00:00 2001 From: Bena Kansara <69037875+benakansara@users.noreply.github.com> Date: Tue, 25 Apr 2023 15:37:03 +0200 Subject: [PATCH 18/29] Add group-by feature in APM rules (#155001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds group-by dropdown in the following APM rules. - APM Latency threshold (Preselected fields: `service.name`, `service.environment`, `transaction.type`) - APM Failed transaction rate (Preselected fields: `service.name`, `service.environment`, `transaction.type`) - APM Error count threshold (Preselected fields: `service.name`, `service.environment`) Screenshot 2023-04-17 at 13 44 34 The preselected fields cannot be removed by user. The `transaction.name` field is selectable by user from the group-by dropdown. - https://github.com/elastic/kibana/issues/154535 - https://github.com/elastic/kibana/issues/154536 - https://github.com/elastic/kibana/issues/154537 Reason message is updated to include group key instead of only service name: - https://github.com/elastic/kibana/issues/155011 The `transaction.name` is added to the alert document: - https://github.com/elastic/kibana/issues/154543 The `transaction.name` action variable is added in UI: - https://github.com/elastic/kibana/issues/154545 The `transaction.name` is added to the context of active alert notifications: - https://github.com/elastic/kibana/issues/154547 There are additional fields in group-by dropdown for Error count threshold rule: https://github.com/elastic/kibana/issues/155633 - error.grouping_key - error.grouping_name ## Fixes - https://github.com/elastic/kibana/issues/154818 ### Update on Alert Id The alert Id is updated for all 3 rules. The new Id is generated from the group key. This is to avoid issues similar to #154818 where alerts are scheduled with same ID. Example of the new alert Ids - `opbeans-java_development_request_GET /flaky`, `opbeans-java_development_GET /fail` ## Out of scope of this PR - Updating the preview chart based on selected group by fields ## Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios ## Release note As the alert Id is updated for the APM Latency threshold rule, APM Failed transaction rate rule and APM Error count rule, the existing alerts, if any, will be recovered, and new alerts will be fired in place of them. --------- Co-authored-by: Katerina Patticha Co-authored-by: Søren Louv-Jansen --- .../__snapshots__/es_fields.test.ts.snap | 12 +- .../apm/common/rules/apm_rule_types.ts | 69 ++- x-pack/plugins/apm/common/rules/schema.ts | 3 + .../error_count_rule_type/index.tsx | 50 ++- .../transaction_duration_rule_type/index.tsx | 54 ++- .../index.tsx | 55 ++- .../ui_components/apm_rule_group_by.tsx | 74 ++++ .../apm_rule_params_container/index.tsx | 3 + .../routes/alerts/register_apm_rule_types.ts | 5 + .../register_error_count_rule_type.test.ts | 415 +++++++++++++++++- .../register_error_count_rule_type.ts | 70 +-- ...ter_transaction_duration_rule_type.test.ts | 214 ++++++++- ...register_transaction_duration_rule_type.ts | 77 ++-- ...r_transaction_error_rate_rule_type.test.ts | 300 ++++++++++++- ...gister_transaction_error_rate_rule_type.ts | 85 ++-- .../get_groupby_action_variables.test.ts | 46 ++ .../utils/get_groupby_action_variables.ts | 47 ++ .../utils/get_groupby_terms.test.ts | 34 ++ .../rule_types/utils/get_groupby_terms.ts | 21 + .../translations/translations/fr-FR.json | 3 - .../translations/translations/ja-JP.json | 3 - .../translations/translations/zh-CN.json | 2 - .../alerts/error_count_threshold.spec.ts | 63 ++- .../tests/alerts/transaction_duration.spec.ts | 183 ++++++++ .../alerts/transaction_error_rate.spec.ts | 187 ++++++++ .../tests/alerts/wait_for_rule_status.ts | 30 ++ .../trial/__snapshots__/create_rule.snap | 14 +- 27 files changed, 1943 insertions(+), 176 deletions(-) create mode 100644 x-pack/plugins/apm/public/components/alerting/ui_components/apm_rule_group_by.tsx create mode 100644 x-pack/plugins/apm/server/routes/alerts/rule_types/utils/get_groupby_action_variables.test.ts create mode 100644 x-pack/plugins/apm/server/routes/alerts/rule_types/utils/get_groupby_action_variables.ts create mode 100644 x-pack/plugins/apm/server/routes/alerts/rule_types/utils/get_groupby_terms.test.ts create mode 100644 x-pack/plugins/apm/server/routes/alerts/rule_types/utils/get_groupby_terms.ts create mode 100644 x-pack/test/apm_api_integration/tests/alerts/transaction_duration.spec.ts create mode 100644 x-pack/test/apm_api_integration/tests/alerts/transaction_error_rate.spec.ts diff --git a/x-pack/plugins/apm/common/es_fields/__snapshots__/es_fields.test.ts.snap b/x-pack/plugins/apm/common/es_fields/__snapshots__/es_fields.test.ts.snap index deb363ab5a21b..3ddc94bde0255 100644 --- a/x-pack/plugins/apm/common/es_fields/__snapshots__/es_fields.test.ts.snap +++ b/x-pack/plugins/apm/common/es_fields/__snapshots__/es_fields.test.ts.snap @@ -156,10 +156,10 @@ exports[`Error LABEL_GC 1`] = `undefined`; exports[`Error LABEL_NAME 1`] = `undefined`; -exports[`Error LABEL_TYPE 1`] = `undefined`; - exports[`Error LABEL_TELEMETRY_AUTO_VERSION 1`] = `undefined`; +exports[`Error LABEL_TYPE 1`] = `undefined`; + exports[`Error METRIC_CGROUP_MEMORY_LIMIT_BYTES 1`] = `undefined`; exports[`Error METRIC_CGROUP_MEMORY_USAGE_BYTES 1`] = `undefined`; @@ -479,10 +479,10 @@ exports[`Span LABEL_GC 1`] = `undefined`; exports[`Span LABEL_NAME 1`] = `undefined`; -exports[`Span LABEL_TYPE 1`] = `undefined`; - exports[`Span LABEL_TELEMETRY_AUTO_VERSION 1`] = `undefined`; +exports[`Span LABEL_TYPE 1`] = `undefined`; + exports[`Span METRIC_CGROUP_MEMORY_LIMIT_BYTES 1`] = `undefined`; exports[`Span METRIC_CGROUP_MEMORY_USAGE_BYTES 1`] = `undefined`; @@ -812,10 +812,10 @@ exports[`Transaction LABEL_GC 1`] = `undefined`; exports[`Transaction LABEL_NAME 1`] = `undefined`; -exports[`Transaction LABEL_TYPE 1`] = `undefined`; - exports[`Transaction LABEL_TELEMETRY_AUTO_VERSION 1`] = `undefined`; +exports[`Transaction LABEL_TYPE 1`] = `undefined`; + exports[`Transaction METRIC_CGROUP_MEMORY_LIMIT_BYTES 1`] = `undefined`; exports[`Transaction METRIC_CGROUP_MEMORY_USAGE_BYTES 1`] = `undefined`; diff --git a/x-pack/plugins/apm/common/rules/apm_rule_types.ts b/x-pack/plugins/apm/common/rules/apm_rule_types.ts index b5a640b2d72af..d1009cebc70da 100644 --- a/x-pack/plugins/apm/common/rules/apm_rule_types.ts +++ b/x-pack/plugins/apm/common/rules/apm_rule_types.ts @@ -15,6 +15,14 @@ import type { import type { ActionGroup } from '@kbn/alerting-plugin/common'; import { formatDurationFromTimeUnitChar } from '@kbn/observability-plugin/common'; import { ANOMALY_SEVERITY, ANOMALY_THRESHOLD } from '../ml_constants'; +import { + ERROR_GROUP_ID, + SERVICE_ENVIRONMENT, + SERVICE_NAME, + TRANSACTION_NAME, + TRANSACTION_TYPE, +} from '../es_fields/apm'; +import { getEnvironmentLabel } from '../environment_filter_values'; export const APM_SERVER_FEATURE_ID = 'apm'; @@ -40,29 +48,66 @@ const THRESHOLD_MET_GROUP: ActionGroup = { }), }; +const getFieldNameLabel = (field: string): string => { + switch (field) { + case SERVICE_NAME: + return 'service'; + case SERVICE_ENVIRONMENT: + return 'env'; + case TRANSACTION_TYPE: + return 'type'; + case TRANSACTION_NAME: + return 'name'; + case ERROR_GROUP_ID: + return 'error key'; + default: + return field; + } +}; + +export const getFieldValueLabel = ( + field: string, + fieldValue: string +): string => { + return field === SERVICE_ENVIRONMENT + ? getEnvironmentLabel(fieldValue) + : fieldValue; +}; + +const formatGroupByFields = (groupByFields: Record): string => { + const groupByFieldLabels = Object.keys(groupByFields).map( + (field) => + `${getFieldNameLabel(field)}: ${getFieldValueLabel( + field, + groupByFields[field] + )}` + ); + return groupByFieldLabels.join(', '); +}; + export function formatErrorCountReason({ threshold, measured, - serviceName, windowSize, windowUnit, + groupByFields, }: { threshold: number; measured: number; - serviceName: string; windowSize: number; windowUnit: string; + groupByFields: Record; }) { return i18n.translate('xpack.apm.alertTypes.errorCount.reason', { - defaultMessage: `Error count is {measured} in the last {interval} for {serviceName}. Alert when > {threshold}.`, + defaultMessage: `Error count is {measured} in the last {interval} for {group}. Alert when > {threshold}.`, values: { threshold, measured, - serviceName, interval: formatDurationFromTimeUnitChar( windowSize, windowUnit as TimeUnitChar ), + group: formatGroupByFields(groupByFields), }, }); } @@ -70,19 +115,19 @@ export function formatErrorCountReason({ export function formatTransactionDurationReason({ threshold, measured, - serviceName, asDuration, aggregationType, windowSize, windowUnit, + groupByFields, }: { threshold: number; measured: number; - serviceName: string; asDuration: AsDuration; aggregationType: string; windowSize: number; windowUnit: string; + groupByFields: Record; }) { let aggregationTypeFormatted = aggregationType.charAt(0).toUpperCase() + aggregationType.slice(1); @@ -90,16 +135,16 @@ export function formatTransactionDurationReason({ aggregationTypeFormatted = aggregationTypeFormatted + '.'; return i18n.translate('xpack.apm.alertTypes.transactionDuration.reason', { - defaultMessage: `{aggregationType} latency is {measured} in the last {interval} for {serviceName}. Alert when > {threshold}.`, + defaultMessage: `{aggregationType} latency is {measured} in the last {interval} for {group}. Alert when > {threshold}.`, values: { threshold: asDuration(threshold), measured: asDuration(measured), - serviceName, aggregationType: aggregationTypeFormatted, interval: formatDurationFromTimeUnitChar( windowSize, windowUnit as TimeUnitChar ), + group: formatGroupByFields(groupByFields), }, }); } @@ -107,28 +152,28 @@ export function formatTransactionDurationReason({ export function formatTransactionErrorRateReason({ threshold, measured, - serviceName, asPercent, windowSize, windowUnit, + groupByFields, }: { threshold: number; measured: number; - serviceName: string; asPercent: AsPercent; windowSize: number; windowUnit: string; + groupByFields: Record; }) { return i18n.translate('xpack.apm.alertTypes.transactionErrorRate.reason', { - defaultMessage: `Failed transactions is {measured} in the last {interval} for {serviceName}. Alert when > {threshold}.`, + defaultMessage: `Failed transactions is {measured} in the last {interval} for {group}. Alert when > {threshold}.`, values: { threshold: asPercent(threshold, 100), measured: asPercent(measured, 100), - serviceName, interval: formatDurationFromTimeUnitChar( windowSize, windowUnit as TimeUnitChar ), + group: formatGroupByFields(groupByFields), }, }); } diff --git a/x-pack/plugins/apm/common/rules/schema.ts b/x-pack/plugins/apm/common/rules/schema.ts index ca77e76f6f156..656f6efbe4b25 100644 --- a/x-pack/plugins/apm/common/rules/schema.ts +++ b/x-pack/plugins/apm/common/rules/schema.ts @@ -15,6 +15,7 @@ export const errorCountParamsSchema = schema.object({ threshold: schema.number(), serviceName: schema.maybe(schema.string()), environment: schema.string(), + groupBy: schema.maybe(schema.arrayOf(schema.string())), errorGroupingKey: schema.maybe(schema.string()), }); @@ -31,6 +32,7 @@ export const transactionDurationParamsSchema = schema.object({ schema.literal(AggregationType.P99), ]), environment: schema.string(), + groupBy: schema.maybe(schema.arrayOf(schema.string())), }); export const anomalyParamsSchema = schema.object({ @@ -55,6 +57,7 @@ export const transactionErrorRateParamsSchema = schema.object({ transactionName: schema.maybe(schema.string()), serviceName: schema.maybe(schema.string()), environment: schema.string(), + groupBy: schema.maybe(schema.arrayOf(schema.string())), }); type ErrorCountParamsType = TypeOf; diff --git a/x-pack/plugins/apm/public/components/alerting/rule_types/error_count_rule_type/index.tsx b/x-pack/plugins/apm/public/components/alerting/rule_types/error_count_rule_type/index.tsx index f23dc6f4fb362..f95f117cedc5b 100644 --- a/x-pack/plugins/apm/public/components/alerting/rule_types/error_count_rule_type/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/rule_types/error_count_rule_type/index.tsx @@ -7,13 +7,15 @@ import { i18n } from '@kbn/i18n'; import { defaults, omit } from 'lodash'; -import React, { useEffect } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { CoreStart } from '@kbn/core/public'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { ForLastExpression, TIME_UNITS, } from '@kbn/triggers-actions-ui-plugin/public'; +import { EuiFormRow } from '@elastic/eui'; +import { EuiSpacer } from '@elastic/eui'; import { ENVIRONMENT_ALL } from '../../../../../common/environment_filter_values'; import { asInteger } from '../../../../../common/utils/formatters'; import { useFetcher } from '../../../../hooks/use_fetcher'; @@ -27,6 +29,13 @@ import { } from '../../utils/fields'; import { AlertMetadata, getIntervalAndTimeRange } from '../../utils/helper'; import { ApmRuleParamsContainer } from '../../ui_components/apm_rule_params_container'; +import { APMRuleGroupBy } from '../../ui_components/apm_rule_group_by'; +import { + SERVICE_ENVIRONMENT, + SERVICE_NAME, + TRANSACTION_NAME, + ERROR_GROUP_ID, +} from '../../../../../common/es_fields/apm'; export interface RuleParams { windowSize?: number; @@ -34,6 +43,7 @@ export interface RuleParams { threshold?: number; serviceName?: string; environment?: string; + groupBy?: string[] | undefined; errorGroupingKey?: string; } @@ -95,6 +105,13 @@ export function ErrorCountRuleType(props: Props) { ] ); + const onGroupByChange = useCallback( + (group: string[] | null) => { + setRuleParams('groupBy', group ?? []); + }, + [setRuleParams] + ); + const fields = [ ); + const groupAlertsBy = ( + <> + + + + + + ); + return ( = { @@ -146,6 +156,13 @@ export function TransactionDurationRuleType(props: Props) { /> ); + const onGroupByChange = useCallback( + (group: string[] | null) => { + setRuleParams('groupBy', group ?? []); + }, + [setRuleParams] + ); + const fields = [ , ]; + const groupAlertsBy = ( + <> + + + + + + ); + return ( diff --git a/x-pack/plugins/apm/public/components/alerting/rule_types/transaction_error_rate_rule_type/index.tsx b/x-pack/plugins/apm/public/components/alerting/rule_types/transaction_error_rate_rule_type/index.tsx index f161ef085b3ea..6f4f36b84778d 100644 --- a/x-pack/plugins/apm/public/components/alerting/rule_types/transaction_error_rate_rule_type/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/rule_types/transaction_error_rate_rule_type/index.tsx @@ -6,13 +6,16 @@ */ import { defaults, omit } from 'lodash'; -import React, { useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import React, { useCallback, useEffect } from 'react'; import { CoreStart } from '@kbn/core/public'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { ForLastExpression, TIME_UNITS, } from '@kbn/triggers-actions-ui-plugin/public'; +import { EuiFormRow } from '@elastic/eui'; +import { EuiSpacer } from '@elastic/eui'; import { ENVIRONMENT_ALL } from '../../../../../common/environment_filter_values'; import { asPercent } from '../../../../../common/utils/formatters'; import { useFetcher } from '../../../../hooks/use_fetcher'; @@ -27,6 +30,13 @@ import { } from '../../utils/fields'; import { AlertMetadata, getIntervalAndTimeRange } from '../../utils/helper'; import { ApmRuleParamsContainer } from '../../ui_components/apm_rule_params_container'; +import { APMRuleGroupBy } from '../../ui_components/apm_rule_group_by'; +import { + SERVICE_NAME, + SERVICE_ENVIRONMENT, + TRANSACTION_TYPE, + TRANSACTION_NAME, +} from '../../../../../common/es_fields/apm'; export interface RuleParams { windowSize?: number; @@ -36,6 +46,7 @@ export interface RuleParams { transactionType?: string; transactionName?: string; environment?: string; + groupBy?: string[] | undefined; } export interface Props { @@ -100,6 +111,13 @@ export function TransactionErrorRateRuleType(props: Props) { ] ); + const onGroupByChange = useCallback( + (group: string[] | null) => { + setRuleParams('groupBy', group ?? []); + }, + [setRuleParams] + ); + const fields = [ ); + const groupAlertsBy = ( + <> + + + + + + ); + return ( void; + errorOptions?: string[]; +} + +export function APMRuleGroupBy({ + options, + fields, + preSelectedOptions, + onChange, + errorOptions, +}: Props) { + const handleChange = useCallback( + (selectedOptions: Array<{ label: string }>) => { + const groupByOption = selectedOptions.map((option) => option.label); + onChange([...new Set(preSelectedOptions.concat(groupByOption))]); + }, + [onChange, preSelectedOptions] + ); + + const getPreSelectedOptions = () => { + return preSelectedOptions.map((field) => ({ + label: field, + color: 'lightgray', + disabled: true, + })); + }; + + const getUserSelectedOptions = (groupBy: string[] | undefined) => { + return (groupBy ?? []) + .filter((group) => !preSelectedOptions.includes(group)) + .map((field) => ({ + label: field, + color: errorOptions?.includes(field) ? 'danger' : undefined, + })); + }; + + const selectedOptions = [ + ...getPreSelectedOptions(), + ...getUserSelectedOptions(options.groupBy), + ]; + + return ( + ({ label: field }))} + onChange={handleChange} + isClearable={false} + /> + ); +} diff --git a/x-pack/plugins/apm/public/components/alerting/ui_components/apm_rule_params_container/index.tsx b/x-pack/plugins/apm/public/components/alerting/ui_components/apm_rule_params_container/index.tsx index 57d27c6cb6e31..b651fb29a824f 100644 --- a/x-pack/plugins/apm/public/components/alerting/ui_components/apm_rule_params_container/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/ui_components/apm_rule_params_container/index.tsx @@ -24,6 +24,7 @@ interface Props { setRuleProperty: (key: string, value: any) => void; defaultParams: Record; fields: React.ReactNode[]; + groupAlertsBy?: React.ReactNode; chartPreview?: React.ReactNode; minimumWindowSize?: MinimumWindowSize; } @@ -31,6 +32,7 @@ interface Props { export function ApmRuleParamsContainer(props: Props) { const { fields, + groupAlertsBy, setRuleParams, defaultParams, chartPreview, @@ -72,6 +74,7 @@ export function ApmRuleParamsContainer(props: Props) { {chartPreview} + {groupAlertsBy} ); } diff --git a/x-pack/plugins/apm/server/routes/alerts/register_apm_rule_types.ts b/x-pack/plugins/apm/server/routes/alerts/register_apm_rule_types.ts index ccfd76b760d66..e34957083d3e4 100644 --- a/x-pack/plugins/apm/server/routes/alerts/register_apm_rule_types.ts +++ b/x-pack/plugins/apm/server/routes/alerts/register_apm_rule_types.ts @@ -17,6 +17,7 @@ import { MlPluginSetup } from '@kbn/ml-plugin/server'; import { legacyExperimentalFieldMap } from '@kbn/alerts-as-data-utils'; import { AGENT_NAME, + ERROR_GROUP_ID, PROCESSOR_EVENT, SERVICE_ENVIRONMENT, SERVICE_LANGUAGE_NAME, @@ -50,6 +51,10 @@ export const apmRuleTypeAlertFieldMap = { type: 'keyword', required: false, }, + [ERROR_GROUP_ID]: { + type: 'keyword', + required: false, + }, [PROCESSOR_EVENT]: { type: 'keyword', required: false, diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/error_count/register_error_count_rule_type.test.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/error_count/register_error_count_rule_type.test.ts index 4513db455cf6d..781094ca55257 100644 --- a/x-pack/plugins/apm/server/routes/alerts/rule_types/error_count/register_error_count_rule_type.test.ts +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/error_count/register_error_count_rule_type.test.ts @@ -44,7 +44,11 @@ describe('Error count alert', () => { registerErrorCountRuleType(dependencies); - const params = { threshold: 2, windowSize: 5, windowUnit: 'm' }; + const params = { + threshold: 2, + windowSize: 5, + windowUnit: 'm', + }; services.scopedClusterClient.asCurrentUser.search.mockResponse({ hits: { @@ -126,11 +130,208 @@ describe('Error count alert', () => { }, }); + await executor({ params }); + ['foo_env-foo', 'foo_env-foo-2', 'bar_env-bar'].forEach((instanceName) => + expect(services.alertFactory.create).toHaveBeenCalledWith(instanceName) + ); + + expect(scheduleActions).toHaveBeenCalledTimes(3); + + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'foo', + environment: 'env-foo', + threshold: 2, + triggerValue: 5, + reason: + 'Error count is 5 in the last 5 mins for service: foo, env: env-foo. Alert when > 2.', + interval: '5 mins', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/foo/errors?environment=env-foo', + }); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'foo', + environment: 'env-foo-2', + threshold: 2, + triggerValue: 4, + reason: + 'Error count is 4 in the last 5 mins for service: foo, env: env-foo-2. Alert when > 2.', + interval: '5 mins', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/foo/errors?environment=env-foo-2', + }); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'bar', + environment: 'env-bar', + reason: + 'Error count is 3 in the last 5 mins for service: bar, env: env-bar. Alert when > 2.', + threshold: 2, + triggerValue: 3, + interval: '5 mins', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/bar/errors?environment=env-bar', + }); + }); + + it('sends alert when rule is configured with group by on transaction.name', async () => { + const { services, dependencies, executor, scheduleActions } = + createRuleTypeMocks(); + + registerErrorCountRuleType(dependencies); + + const params = { + threshold: 2, + windowSize: 5, + windowUnit: 'm', + groupBy: ['service.name', 'service.environment', 'transaction.name'], + }; + + services.scopedClusterClient.asCurrentUser.search.mockResponse({ + hits: { + hits: [], + total: { + relation: 'eq', + value: 2, + }, + }, + aggregations: { + error_counts: { + buckets: [ + { + key: ['foo', 'env-foo', 'tx-name-foo'], + doc_count: 5, + }, + { + key: ['foo', 'env-foo-2', 'tx-name-foo-2'], + doc_count: 4, + }, + { + key: ['bar', 'env-bar', 'tx-name-bar'], + doc_count: 3, + }, + { + key: ['bar', 'env-bar-2', 'tx-name-bar-2'], + doc_count: 1, + }, + ], + }, + }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + skipped: 0, + successful: 1, + total: 1, + }, + }); + + await executor({ params }); + [ + 'foo_env-foo_tx-name-foo', + 'foo_env-foo-2_tx-name-foo-2', + 'bar_env-bar_tx-name-bar', + ].forEach((instanceName) => + expect(services.alertFactory.create).toHaveBeenCalledWith(instanceName) + ); + + expect(scheduleActions).toHaveBeenCalledTimes(3); + + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'foo', + environment: 'env-foo', + threshold: 2, + triggerValue: 5, + reason: + 'Error count is 5 in the last 5 mins for service: foo, env: env-foo, name: tx-name-foo. Alert when > 2.', + interval: '5 mins', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/foo/errors?environment=env-foo', + transactionName: 'tx-name-foo', + }); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'foo', + environment: 'env-foo-2', + threshold: 2, + triggerValue: 4, + reason: + 'Error count is 4 in the last 5 mins for service: foo, env: env-foo-2, name: tx-name-foo-2. Alert when > 2.', + interval: '5 mins', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/foo/errors?environment=env-foo-2', + transactionName: 'tx-name-foo-2', + }); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'bar', + environment: 'env-bar', + reason: + 'Error count is 3 in the last 5 mins for service: bar, env: env-bar, name: tx-name-bar. Alert when > 2.', + threshold: 2, + triggerValue: 3, + interval: '5 mins', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/bar/errors?environment=env-bar', + transactionName: 'tx-name-bar', + }); + }); + + it('sends alert when rule is configured with group by on error.grouping_key', async () => { + const { services, dependencies, executor, scheduleActions } = + createRuleTypeMocks(); + + registerErrorCountRuleType(dependencies); + + const params = { + threshold: 2, + windowSize: 5, + windowUnit: 'm', + groupBy: ['service.name', 'service.environment', 'error.grouping_key'], + }; + + services.scopedClusterClient.asCurrentUser.search.mockResponse({ + hits: { + hits: [], + total: { + relation: 'eq', + value: 2, + }, + }, + aggregations: { + error_counts: { + buckets: [ + { + key: ['foo', 'env-foo', 'error-key-foo'], + doc_count: 5, + }, + { + key: ['foo', 'env-foo-2', 'error-key-foo-2'], + doc_count: 4, + }, + { + key: ['bar', 'env-bar', 'error-key-bar'], + doc_count: 3, + }, + { + key: ['bar', 'env-bar-2', 'error-key-bar-2'], + doc_count: 1, + }, + ], + }, + }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + skipped: 0, + successful: 1, + total: 1, + }, + }); + await executor({ params }); [ - 'apm.error_rate_foo_env-foo', - 'apm.error_rate_foo_env-foo-2', - 'apm.error_rate_bar_env-bar', + 'foo_env-foo_error-key-foo', + 'foo_env-foo-2_error-key-foo-2', + 'bar_env-bar_error-key-bar', ].forEach((instanceName) => expect(services.alertFactory.create).toHaveBeenCalledWith(instanceName) ); @@ -142,25 +343,225 @@ describe('Error count alert', () => { environment: 'env-foo', threshold: 2, triggerValue: 5, - reason: 'Error count is 5 in the last 5 mins for foo. Alert when > 2.', + reason: + 'Error count is 5 in the last 5 mins for service: foo, env: env-foo, error key: error-key-foo. Alert when > 2.', interval: '5 mins', viewInAppUrl: 'http://localhost:5601/eyr/app/apm/services/foo/errors?environment=env-foo', + errorGroupingKey: 'error-key-foo', }); expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { serviceName: 'foo', environment: 'env-foo-2', threshold: 2, triggerValue: 4, - reason: 'Error count is 4 in the last 5 mins for foo. Alert when > 2.', + reason: + 'Error count is 4 in the last 5 mins for service: foo, env: env-foo-2, error key: error-key-foo-2. Alert when > 2.', interval: '5 mins', viewInAppUrl: 'http://localhost:5601/eyr/app/apm/services/foo/errors?environment=env-foo-2', + errorGroupingKey: 'error-key-foo-2', + }); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'bar', + environment: 'env-bar', + reason: + 'Error count is 3 in the last 5 mins for service: bar, env: env-bar, error key: error-key-bar. Alert when > 2.', + threshold: 2, + triggerValue: 3, + interval: '5 mins', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/bar/errors?environment=env-bar', + errorGroupingKey: 'error-key-bar', + }); + }); + + it('sends alert when rule is configured with preselected group by', async () => { + const { services, dependencies, executor, scheduleActions } = + createRuleTypeMocks(); + + registerErrorCountRuleType(dependencies); + + const params = { + threshold: 2, + windowSize: 5, + windowUnit: 'm', + groupBy: ['service.name', 'service.environment'], + }; + + services.scopedClusterClient.asCurrentUser.search.mockResponse({ + hits: { + hits: [], + total: { + relation: 'eq', + value: 2, + }, + }, + aggregations: { + error_counts: { + buckets: [ + { + key: ['foo', 'env-foo'], + doc_count: 5, + }, + { + key: ['foo', 'env-foo-2'], + doc_count: 4, + }, + { + key: ['bar', 'env-bar'], + doc_count: 3, + }, + { + key: ['bar', 'env-bar-2'], + doc_count: 1, + }, + ], + }, + }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + skipped: 0, + successful: 1, + total: 1, + }, + }); + + await executor({ params }); + ['foo_env-foo', 'foo_env-foo-2', 'bar_env-bar'].forEach((instanceName) => + expect(services.alertFactory.create).toHaveBeenCalledWith(instanceName) + ); + + expect(scheduleActions).toHaveBeenCalledTimes(3); + + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'foo', + environment: 'env-foo', + threshold: 2, + triggerValue: 5, + reason: + 'Error count is 5 in the last 5 mins for service: foo, env: env-foo. Alert when > 2.', + interval: '5 mins', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/foo/errors?environment=env-foo', + }); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'foo', + environment: 'env-foo-2', + threshold: 2, + triggerValue: 4, + reason: + 'Error count is 4 in the last 5 mins for service: foo, env: env-foo-2. Alert when > 2.', + interval: '5 mins', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/foo/errors?environment=env-foo-2', + }); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'bar', + environment: 'env-bar', + reason: + 'Error count is 3 in the last 5 mins for service: bar, env: env-bar. Alert when > 2.', + threshold: 2, + triggerValue: 3, + interval: '5 mins', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/bar/errors?environment=env-bar', + }); + }); + + it('sends alert when service.environment field does not exist in the source', async () => { + const { services, dependencies, executor, scheduleActions } = + createRuleTypeMocks(); + + registerErrorCountRuleType(dependencies); + + const params = { + threshold: 2, + windowSize: 5, + windowUnit: 'm', + groupBy: ['service.name', 'service.environment'], + }; + + services.scopedClusterClient.asCurrentUser.search.mockResponse({ + hits: { + hits: [], + total: { + relation: 'eq', + value: 2, + }, + }, + aggregations: { + error_counts: { + buckets: [ + { + key: ['foo', 'ENVIRONMENT_NOT_DEFINED'], + doc_count: 5, + }, + { + key: ['foo', 'ENVIRONMENT_NOT_DEFINED'], + doc_count: 4, + }, + { + key: ['bar', 'env-bar'], + doc_count: 3, + }, + { + key: ['bar', 'env-bar-2'], + doc_count: 1, + }, + ], + }, + }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + skipped: 0, + successful: 1, + total: 1, + }, + }); + + await executor({ params }); + [ + 'foo_ENVIRONMENT_NOT_DEFINED', + 'foo_ENVIRONMENT_NOT_DEFINED', + 'bar_env-bar', + ].forEach((instanceName) => + expect(services.alertFactory.create).toHaveBeenCalledWith(instanceName) + ); + + expect(scheduleActions).toHaveBeenCalledTimes(3); + + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'foo', + environment: 'Not defined', + threshold: 2, + triggerValue: 5, + reason: + 'Error count is 5 in the last 5 mins for service: foo, env: Not defined. Alert when > 2.', + interval: '5 mins', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/foo/errors?environment=ENVIRONMENT_ALL', + }); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'foo', + environment: 'Not defined', + threshold: 2, + triggerValue: 4, + reason: + 'Error count is 4 in the last 5 mins for service: foo, env: Not defined. Alert when > 2.', + interval: '5 mins', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/foo/errors?environment=ENVIRONMENT_ALL', }); expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { serviceName: 'bar', environment: 'env-bar', - reason: 'Error count is 3 in the last 5 mins for bar. Alert when > 2.', + reason: + 'Error count is 3 in the last 5 mins for service: bar, env: env-bar. Alert when > 2.', threshold: 2, triggerValue: 3, interval: '5 mins', diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/error_count/register_error_count_rule_type.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/error_count/register_error_count_rule_type.ts index 695a9a579a35d..6ac9a87789136 100644 --- a/x-pack/plugins/apm/server/routes/alerts/rule_types/error_count/register_error_count_rule_type.ts +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/error_count/register_error_count_rule_type.ts @@ -19,11 +19,7 @@ import { createLifecycleRuleTypeFactory } from '@kbn/rule-registry-plugin/server import { termQuery } from '@kbn/observability-plugin/server'; import { addSpaceIdToPath } from '@kbn/spaces-plugin/common'; import { firstValueFrom } from 'rxjs'; -import { - ENVIRONMENT_NOT_DEFINED, - getEnvironmentEsField, - getEnvironmentLabel, -} from '../../../../../common/environment_filter_values'; +import { getEnvironmentEsField } from '../../../../../common/environment_filter_values'; import { ERROR_GROUP_ID, PROCESSOR_EVENT, @@ -50,6 +46,8 @@ import { getServiceGroupFields, getServiceGroupFieldsAgg, } from '../get_service_group_fields'; +import { getGroupByTerms } from '../utils/get_groupby_terms'; +import { getGroupByActionVariables } from '../utils/get_groupby_action_variables'; const ruleTypeConfig = RULE_TYPES_CONFIG[ApmRuleType.ErrorCount]; @@ -78,6 +76,7 @@ export function registerErrorCountRuleType({ apmActionVariables.interval, apmActionVariables.reason, apmActionVariables.serviceName, + apmActionVariables.transactionName, apmActionVariables.errorGroupingKey, apmActionVariables.threshold, apmActionVariables.triggerValue, @@ -88,6 +87,12 @@ export function registerErrorCountRuleType({ minimumLicenseRequired: 'basic', isExportable: true, executor: async ({ params: ruleParams, services, spaceId }) => { + const predefinedGroupby = [SERVICE_NAME, SERVICE_ENVIRONMENT]; + + const allGroupbyFields = Array.from( + new Set([...predefinedGroupby, ...(ruleParams.groupBy ?? [])]) + ); + const config = await firstValueFrom(config$); const { savedObjectsClient, scopedClusterClient } = services; @@ -126,13 +131,7 @@ export function registerErrorCountRuleType({ aggs: { error_counts: { multi_terms: { - terms: [ - { field: SERVICE_NAME }, - { - field: SERVICE_ENVIRONMENT, - missing: ENVIRONMENT_NOT_DEFINED.value, - }, - ], + terms: getGroupByTerms(allGroupbyFields), size: 1000, order: { _count: 'desc' as const }, }, @@ -149,40 +148,42 @@ export function registerErrorCountRuleType({ const errorCountResults = response.aggregations?.error_counts.buckets.map((bucket) => { - const [serviceName, environment] = bucket.key; + const groupByFields = bucket.key.reduce( + (obj, bucketKey, bucketIndex) => { + obj[allGroupbyFields[bucketIndex]] = bucketKey; + return obj; + }, + {} as Record + ); + + const bucketKey = bucket.key; + return { - serviceName, - environment, errorCount: bucket.doc_count, sourceFields: getServiceGroupFields(bucket), + groupByFields, + bucketKey, }; }) ?? []; errorCountResults .filter((result) => result.errorCount >= ruleParams.threshold) .forEach((result) => { - const { serviceName, environment, errorCount, sourceFields } = + const { errorCount, sourceFields, groupByFields, bucketKey } = result; const alertReason = formatErrorCountReason({ - serviceName, threshold: ruleParams.threshold, measured: errorCount, windowSize: ruleParams.windowSize, windowUnit: ruleParams.windowUnit, + groupByFields, }); - const id = [ - ApmRuleType.ErrorCount, - serviceName, - environment, - ruleParams.errorGroupingKey, - ] - .filter((name) => name) - .join('_'); - const relativeViewInAppUrl = getAlertUrlErrorCount( - serviceName, - getEnvironmentEsField(environment)?.[SERVICE_ENVIRONMENT] + groupByFields[SERVICE_NAME], + getEnvironmentEsField(groupByFields[SERVICE_ENVIRONMENT])?.[ + SERVICE_ENVIRONMENT + ] ); const viewInAppUrl = addSpaceIdToPath( @@ -191,32 +192,33 @@ export function registerErrorCountRuleType({ relativeViewInAppUrl ); + const groupByActionVariables = + getGroupByActionVariables(groupByFields); + services .alertWithLifecycle({ - id, + id: bucketKey.join('_'), fields: { - [SERVICE_NAME]: serviceName, - ...getEnvironmentEsField(environment), [PROCESSOR_EVENT]: ProcessorEvent.error, [ALERT_EVALUATION_VALUE]: errorCount, [ALERT_EVALUATION_THRESHOLD]: ruleParams.threshold, [ERROR_GROUP_ID]: ruleParams.errorGroupingKey, [ALERT_REASON]: alertReason, ...sourceFields, + ...groupByFields, }, }) .scheduleActions(ruleTypeConfig.defaultActionGroupId, { - environment: getEnvironmentLabel(environment), interval: formatDurationFromTimeUnitChar( ruleParams.windowSize, ruleParams.windowUnit as TimeUnitChar ), reason: alertReason, - serviceName, threshold: ruleParams.threshold, - errorGroupingKey: ruleParams.errorGroupingKey, + errorGroupingKey: ruleParams.errorGroupingKey, // When group by doesn't include error.grouping_key, the context.error.grouping_key action variable will contain value of the Error Grouping Key filter triggerValue: errorCount, viewInAppUrl, + ...groupByActionVariables, }); }); diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.test.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.test.ts index 1f91cfc548469..f3993dfed7009 100644 --- a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.test.ts +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.test.ts @@ -27,7 +27,7 @@ describe('registerTransactionDurationRuleType', () => { series: { buckets: [ { - key: ['opbeans-java', 'ENVIRONMENT_NOT_DEFINED', 'request'], + key: ['opbeans-java', 'development', 'request'], avgLatency: { value: 5500000, }, @@ -61,16 +61,226 @@ describe('registerTransactionDurationRuleType', () => { 'http://localhost:5601/eyr/app/observability/alerts/' ), transactionName: 'GET /orders', + environment: 'development', + interval: `5 mins`, + reason: + 'Avg. latency is 5,500 ms in the last 5 mins for service: opbeans-java, env: development, type: request. Alert when > 3,000 ms.', + transactionType: 'request', + serviceName: 'opbeans-java', + threshold: 3000, + triggerValue: '5,500 ms', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/opbeans-java?transactionType=request&environment=development', + }); + }); + + it('sends alert when rule is configured with group by on transaction.name', async () => { + const { services, dependencies, executor, scheduleActions } = + createRuleTypeMocks(); + + registerTransactionDurationRuleType(dependencies); + + services.scopedClusterClient.asCurrentUser.search.mockResponse({ + hits: { + hits: [], + total: { + relation: 'eq', + value: 2, + }, + }, + aggregations: { + series: { + buckets: [ + { + key: ['opbeans-java', 'development', 'request', 'GET /products'], + avgLatency: { + value: 5500000, + }, + }, + ], + }, + }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + skipped: 0, + successful: 1, + total: 1, + }, + }); + + const params = { + threshold: 3000, + windowSize: 5, + windowUnit: 'm', + transactionType: 'request', + serviceName: 'opbeans-java', + aggregationType: 'avg', + groupBy: [ + 'service.name', + 'service.environment', + 'transaction.type', + 'transaction.name', + ], + }; + await executor({ params }); + expect(scheduleActions).toHaveBeenCalledTimes(1); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + alertDetailsUrl: expect.stringContaining( + 'http://localhost:5601/eyr/app/observability/alerts/' + ), + environment: 'development', + interval: `5 mins`, + reason: + 'Avg. latency is 5,500 ms in the last 5 mins for service: opbeans-java, env: development, type: request, name: GET /products. Alert when > 3,000 ms.', + transactionType: 'request', + serviceName: 'opbeans-java', + threshold: 3000, + triggerValue: '5,500 ms', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/opbeans-java?transactionType=request&environment=development', + transactionName: 'GET /products', + }); + }); + + it('sends alert when rule is configured with preselected group by', async () => { + const { services, dependencies, executor, scheduleActions } = + createRuleTypeMocks(); + + registerTransactionDurationRuleType(dependencies); + + services.scopedClusterClient.asCurrentUser.search.mockResponse({ + hits: { + hits: [], + total: { + relation: 'eq', + value: 2, + }, + }, + aggregations: { + series: { + buckets: [ + { + key: ['opbeans-java', 'development', 'request'], + avgLatency: { + value: 5500000, + }, + }, + ], + }, + }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + skipped: 0, + successful: 1, + total: 1, + }, + }); + + const params = { + threshold: 3000, + windowSize: 5, + windowUnit: 'm', + transactionType: 'request', + serviceName: 'opbeans-java', + aggregationType: 'avg', + groupBy: ['service.name', 'service.environment', 'transaction.type'], + }; + + await executor({ params }); + expect(scheduleActions).toHaveBeenCalledTimes(1); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + alertDetailsUrl: expect.stringContaining( + 'http://localhost:5601/eyr/app/observability/alerts/' + ), + environment: 'development', + interval: `5 mins`, + reason: + 'Avg. latency is 5,500 ms in the last 5 mins for service: opbeans-java, env: development, type: request. Alert when > 3,000 ms.', + transactionType: 'request', + serviceName: 'opbeans-java', + threshold: 3000, + triggerValue: '5,500 ms', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/opbeans-java?transactionType=request&environment=development', + }); + }); + + it('sends alert when service.environment field does not exist in the source', async () => { + const { services, dependencies, executor, scheduleActions } = + createRuleTypeMocks(); + + registerTransactionDurationRuleType(dependencies); + + services.scopedClusterClient.asCurrentUser.search.mockResponse({ + hits: { + hits: [], + total: { + relation: 'eq', + value: 2, + }, + }, + aggregations: { + series: { + buckets: [ + { + key: [ + 'opbeans-java', + 'ENVIRONMENT_NOT_DEFINED', + 'request', + 'tx-java', + ], + avgLatency: { + value: 5500000, + }, + }, + ], + }, + }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + skipped: 0, + successful: 1, + total: 1, + }, + }); + + const params = { + threshold: 3000, + windowSize: 5, + windowUnit: 'm', + transactionType: 'request', + serviceName: 'opbeans-java', + aggregationType: 'avg', + groupBy: [ + 'service.name', + 'service.environment', + 'transaction.type', + 'transaction.name', + ], + }; + await executor({ params }); + expect(scheduleActions).toHaveBeenCalledTimes(1); + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + alertDetailsUrl: expect.stringContaining( + 'http://localhost:5601/eyr/app/observability/alerts/' + ), environment: 'Not defined', interval: `5 mins`, reason: - 'Avg. latency is 5,500 ms in the last 5 mins for opbeans-java. Alert when > 3,000 ms.', + 'Avg. latency is 5,500 ms in the last 5 mins for service: opbeans-java, env: Not defined, type: request, name: tx-java. Alert when > 3,000 ms.', transactionType: 'request', serviceName: 'opbeans-java', threshold: 3000, triggerValue: '5,500 ms', viewInAppUrl: 'http://localhost:5601/eyr/app/apm/services/opbeans-java?transactionType=request&environment=ENVIRONMENT_ALL', + transactionName: 'tx-java', }); }); }); diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.ts index 9bb21324c22b7..0ac465261c954 100644 --- a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.ts +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_duration/register_transaction_duration_rule_type.ts @@ -22,12 +22,9 @@ import { import { createLifecycleRuleTypeFactory } from '@kbn/rule-registry-plugin/server'; import { addSpaceIdToPath } from '@kbn/spaces-plugin/common'; import { firstValueFrom } from 'rxjs'; +import { getGroupByTerms } from '../utils/get_groupby_terms'; import { SearchAggregatedTransactionSetting } from '../../../../../common/aggregated_transactions'; -import { - ENVIRONMENT_NOT_DEFINED, - getEnvironmentEsField, - getEnvironmentLabel, -} from '../../../../../common/environment_filter_values'; +import { getEnvironmentEsField } from '../../../../../common/environment_filter_values'; import { PROCESSOR_EVENT, SERVICE_ENVIRONMENT, @@ -66,6 +63,7 @@ import { averageOrPercentileAgg, getMultiTermsSortOrder, } from './average_or_percentile_agg'; +import { getGroupByActionVariables } from '../utils/get_groupby_action_variables'; const ruleTypeConfig = RULE_TYPES_CONFIG[ApmRuleType.TransactionDuration]; @@ -105,6 +103,16 @@ export function registerTransactionDurationRuleType({ minimumLicenseRequired: 'basic', isExportable: true, executor: async ({ params: ruleParams, services, spaceId }) => { + const predefinedGroupby = [ + SERVICE_NAME, + SERVICE_ENVIRONMENT, + TRANSACTION_TYPE, + ]; + + const allGroupbyFields = Array.from( + new Set([...predefinedGroupby, ...(ruleParams.groupBy ?? [])]) + ); + const config = await firstValueFrom(config$); const { getAlertUuid, savedObjectsClient, scopedClusterClient } = @@ -164,14 +172,7 @@ export function registerTransactionDurationRuleType({ aggs: { series: { multi_terms: { - terms: [ - { field: SERVICE_NAME }, - { - field: SERVICE_ENVIRONMENT, - missing: ENVIRONMENT_NOT_DEFINED.value, - }, - { field: TRANSACTION_TYPE }, - ], + terms: [...getGroupByTerms(allGroupbyFields)], size: 1000, ...getMultiTermsSortOrder(ruleParams.aggregationType), }, @@ -202,7 +203,15 @@ export function registerTransactionDurationRuleType({ const triggeredBuckets = []; for (const bucket of response.aggregations.series.buckets) { - const [serviceName, environment, transactionType] = bucket.key; + const groupByFields = bucket.key.reduce( + (obj, bucketKey, bucketIndex) => { + obj[allGroupbyFields[bucketIndex]] = bucketKey; + return obj; + }, + {} as Record + ); + + const bucketKey = bucket.key; const transactionDuration = 'avgLatency' in bucket // only true if ruleParams.aggregationType === 'avg' @@ -214,24 +223,20 @@ export function registerTransactionDurationRuleType({ transactionDuration > thresholdMicroseconds ) { triggeredBuckets.push({ - environment, - serviceName, sourceFields: getServiceGroupFields(bucket), - transactionType, transactionDuration, + groupByFields, + bucketKey, }); } } for (const { - serviceName, - environment, - transactionType, transactionDuration, sourceFields, + groupByFields, + bucketKey, } of triggeredBuckets) { - const environmentLabel = getEnvironmentLabel(environment); - const durationFormatter = getDurationFormatter(transactionDuration); const transactionDurationFormatted = durationFormatter(transactionDuration).formatted; @@ -240,15 +245,13 @@ export function registerTransactionDurationRuleType({ aggregationType: String(ruleParams.aggregationType), asDuration, measured: transactionDuration, - serviceName, threshold: thresholdMicroseconds, windowSize: ruleParams.windowSize, windowUnit: ruleParams.windowUnit, + groupByFields, }); - const id = `${ApmRuleType.TransactionDuration}_${environmentLabel}`; - - const alertUuid = getAlertUuid(id); + const alertUuid = getAlertUuid(bucketKey.join('_')); const alertDetailsUrl = getAlertDetailsUrl( basePath, @@ -260,41 +263,41 @@ export function registerTransactionDurationRuleType({ basePath.publicBaseUrl, spaceId, getAlertUrlTransaction( - serviceName, - getEnvironmentEsField(environment)?.[SERVICE_ENVIRONMENT], - transactionType + groupByFields[SERVICE_NAME], + getEnvironmentEsField(groupByFields[SERVICE_ENVIRONMENT])?.[ + SERVICE_ENVIRONMENT + ], + groupByFields[TRANSACTION_TYPE] ) ); + const groupByActionVariables = getGroupByActionVariables(groupByFields); + services .alertWithLifecycle({ - id, + id: bucketKey.join('_'), fields: { - [SERVICE_NAME]: serviceName, - ...getEnvironmentEsField(environment), - [TRANSACTION_TYPE]: transactionType, [TRANSACTION_NAME]: ruleParams.transactionName, [PROCESSOR_EVENT]: ProcessorEvent.transaction, [ALERT_EVALUATION_VALUE]: transactionDuration, [ALERT_EVALUATION_THRESHOLD]: ruleParams.threshold, [ALERT_REASON]: reason, ...sourceFields, + ...groupByFields, }, }) .scheduleActions(ruleTypeConfig.defaultActionGroupId, { alertDetailsUrl, - environment: environmentLabel, interval: formatDurationFromTimeUnitChar( ruleParams.windowSize, ruleParams.windowUnit as TimeUnitChar ), reason, - serviceName, - transactionName: ruleParams.transactionName, // #Note once we group by transactionName, use the transactionName key from the bucket + transactionName: ruleParams.transactionName, // When group by doesn't include transaction.name, the context.transaction.name action variable will contain value of the Transaction Name filter threshold: ruleParams.threshold, - transactionType, triggerValue: transactionDurationFormatted, viewInAppUrl, + ...groupByActionVariables, }); } diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_error_rate/register_transaction_error_rate_rule_type.test.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_error_rate/register_transaction_error_rate_rule_type.test.ts index a1c28f5dd77e4..708d5c533ba6b 100644 --- a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_error_rate/register_transaction_error_rate_rule_type.test.ts +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_error_rate/register_transaction_error_rate_rule_type.test.ts @@ -107,17 +107,120 @@ describe('Transaction error rate alert', () => { }, }); - const params = { threshold: 10, windowSize: 5, windowUnit: 'm' }; + const params = { + threshold: 10, + windowSize: 5, + windowUnit: 'm', + }; + + await executor({ params }); + + expect(services.alertFactory.create).toHaveBeenCalledTimes(1); + + expect(services.alertFactory.create).toHaveBeenCalledWith( + 'foo_env-foo_type-foo' + ); + expect(services.alertFactory.create).not.toHaveBeenCalledWith( + 'bar_env-bar_type-bar' + ); + + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'foo', + transactionType: 'type-foo', + environment: 'env-foo', + reason: + 'Failed transactions is 10% in the last 5 mins for service: foo, env: env-foo, type: type-foo. Alert when > 10%.', + threshold: 10, + triggerValue: '10', + interval: '5 mins', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/foo?transactionType=type-foo&environment=env-foo', + }); + }); + + it('sends alert when rule is configured with group by on transaction.name', async () => { + const { services, dependencies, executor, scheduleActions } = + createRuleTypeMocks(); + + registerTransactionErrorRateRuleType({ + ...dependencies, + }); + + services.scopedClusterClient.asCurrentUser.search.mockResponse({ + hits: { + hits: [], + total: { + relation: 'eq', + value: 2, + }, + }, + aggregations: { + series: { + buckets: [ + { + key: ['foo', 'env-foo', 'type-foo', 'tx-name-foo'], + outcomes: { + buckets: [ + { + key: 'success', + doc_count: 90, + }, + { + key: 'failure', + doc_count: 10, + }, + ], + }, + }, + { + key: ['bar', 'env-bar', 'type-bar', 'tx-name-bar'], + outcomes: { + buckets: [ + { + key: 'success', + doc_count: 90, + }, + { + key: 'failure', + doc_count: 1, + }, + ], + }, + }, + ], + }, + }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + skipped: 0, + successful: 1, + total: 1, + }, + }); + + const params = { + threshold: 10, + windowSize: 5, + windowUnit: 'm', + groupBy: [ + 'service.name', + 'service.environment', + 'transaction.type', + 'transaction.name', + ], + }; await executor({ params }); expect(services.alertFactory.create).toHaveBeenCalledTimes(1); expect(services.alertFactory.create).toHaveBeenCalledWith( - 'apm.transaction_error_rate_foo_type-foo_env-foo' + 'foo_env-foo_type-foo_tx-name-foo' ); expect(services.alertFactory.create).not.toHaveBeenCalledWith( - 'apm.transaction_error_rate_bar_type-bar_env-bar' + 'bar_env-bar_type-bar_tx-name-bar' ); expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { @@ -125,12 +228,201 @@ describe('Transaction error rate alert', () => { transactionType: 'type-foo', environment: 'env-foo', reason: - 'Failed transactions is 10% in the last 5 mins for foo. Alert when > 10%.', + 'Failed transactions is 10% in the last 5 mins for service: foo, env: env-foo, type: type-foo, name: tx-name-foo. Alert when > 10%.', threshold: 10, triggerValue: '10', interval: '5 mins', viewInAppUrl: 'http://localhost:5601/eyr/app/apm/services/foo?transactionType=type-foo&environment=env-foo', + transactionName: 'tx-name-foo', + }); + }); + + it('sends alert when rule is configured with preselected group by', async () => { + const { services, dependencies, executor, scheduleActions } = + createRuleTypeMocks(); + + registerTransactionErrorRateRuleType({ + ...dependencies, + }); + + services.scopedClusterClient.asCurrentUser.search.mockResponse({ + hits: { + hits: [], + total: { + relation: 'eq', + value: 2, + }, + }, + aggregations: { + series: { + buckets: [ + { + key: ['foo', 'env-foo', 'type-foo'], + outcomes: { + buckets: [ + { + key: 'success', + doc_count: 90, + }, + { + key: 'failure', + doc_count: 10, + }, + ], + }, + }, + { + key: ['bar', 'env-bar', 'type-bar'], + outcomes: { + buckets: [ + { + key: 'success', + doc_count: 90, + }, + { + key: 'failure', + doc_count: 1, + }, + ], + }, + }, + ], + }, + }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + skipped: 0, + successful: 1, + total: 1, + }, + }); + + const params = { + threshold: 10, + windowSize: 5, + windowUnit: 'm', + groupBy: ['service.name', 'service.environment', 'transaction.type'], + }; + + await executor({ params }); + + expect(services.alertFactory.create).toHaveBeenCalledTimes(1); + + expect(services.alertFactory.create).toHaveBeenCalledWith( + 'foo_env-foo_type-foo' + ); + expect(services.alertFactory.create).not.toHaveBeenCalledWith( + 'bar_env-bar_type-bar' + ); + + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'foo', + transactionType: 'type-foo', + environment: 'env-foo', + reason: + 'Failed transactions is 10% in the last 5 mins for service: foo, env: env-foo, type: type-foo. Alert when > 10%.', + threshold: 10, + triggerValue: '10', + interval: '5 mins', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/foo?transactionType=type-foo&environment=env-foo', + }); + }); + + it('sends alert when service.environment field does not exist in the source', async () => { + const { services, dependencies, executor, scheduleActions } = + createRuleTypeMocks(); + + registerTransactionErrorRateRuleType({ + ...dependencies, + }); + + services.scopedClusterClient.asCurrentUser.search.mockResponse({ + hits: { + hits: [], + total: { + relation: 'eq', + value: 2, + }, + }, + aggregations: { + series: { + buckets: [ + { + key: ['foo', 'ENVIRONMENT_NOT_DEFINED', 'type-foo'], + outcomes: { + buckets: [ + { + key: 'success', + doc_count: 90, + }, + { + key: 'failure', + doc_count: 10, + }, + ], + }, + }, + { + key: ['bar', 'ENVIRONMENT_NOT_DEFINED', 'type-bar'], + outcomes: { + buckets: [ + { + key: 'success', + doc_count: 90, + }, + { + key: 'failure', + doc_count: 1, + }, + ], + }, + }, + ], + }, + }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + skipped: 0, + successful: 1, + total: 1, + }, + }); + + const params = { + threshold: 10, + windowSize: 5, + windowUnit: 'm', + groupBy: ['service.name', 'service.environment', 'transaction.type'], + }; + + await executor({ params }); + + expect(services.alertFactory.create).toHaveBeenCalledTimes(1); + + expect(services.alertFactory.create).toHaveBeenCalledWith( + 'foo_ENVIRONMENT_NOT_DEFINED_type-foo' + ); + expect(services.alertFactory.create).not.toHaveBeenCalledWith( + 'bar_ENVIRONMENT_NOT_DEFINED_type-bar' + ); + + expect(scheduleActions).toHaveBeenCalledWith('threshold_met', { + serviceName: 'foo', + transactionType: 'type-foo', + environment: 'Not defined', + reason: + 'Failed transactions is 10% in the last 5 mins for service: foo, env: Not defined, type: type-foo. Alert when > 10%.', + threshold: 10, + triggerValue: '10', + interval: '5 mins', + viewInAppUrl: + 'http://localhost:5601/eyr/app/apm/services/foo?transactionType=type-foo&environment=ENVIRONMENT_ALL', }); }); }); diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_error_rate/register_transaction_error_rate_rule_type.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_error_rate/register_transaction_error_rate_rule_type.ts index 26b5847a205f1..6fa9319b71753 100644 --- a/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_error_rate/register_transaction_error_rate_rule_type.ts +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/transaction_error_rate/register_transaction_error_rate_rule_type.ts @@ -21,11 +21,7 @@ import { createLifecycleRuleTypeFactory } from '@kbn/rule-registry-plugin/server import { addSpaceIdToPath } from '@kbn/spaces-plugin/common'; import { firstValueFrom } from 'rxjs'; import { SearchAggregatedTransactionSetting } from '../../../../../common/aggregated_transactions'; -import { - ENVIRONMENT_NOT_DEFINED, - getEnvironmentEsField, - getEnvironmentLabel, -} from '../../../../../common/environment_filter_values'; +import { getEnvironmentEsField } from '../../../../../common/environment_filter_values'; import { EVENT_OUTCOME, PROCESSOR_EVENT, @@ -59,6 +55,8 @@ import { getServiceGroupFields, getServiceGroupFieldsAgg, } from '../get_service_group_fields'; +import { getGroupByTerms } from '../utils/get_groupby_terms'; +import { getGroupByActionVariables } from '../utils/get_groupby_action_variables'; const ruleTypeConfig = RULE_TYPES_CONFIG[ApmRuleType.TransactionErrorRate]; @@ -90,6 +88,7 @@ export function registerTransactionErrorRateRuleType({ apmActionVariables.transactionName, apmActionVariables.threshold, apmActionVariables.transactionType, + apmActionVariables.transactionName, apmActionVariables.triggerValue, apmActionVariables.viewInAppUrl, ], @@ -98,6 +97,16 @@ export function registerTransactionErrorRateRuleType({ minimumLicenseRequired: 'basic', isExportable: true, executor: async ({ services, spaceId, params: ruleParams }) => { + const predefinedGroupby = [ + SERVICE_NAME, + SERVICE_ENVIRONMENT, + TRANSACTION_TYPE, + ]; + + const allGroupbyFields = Array.from( + new Set([...predefinedGroupby, ...(ruleParams.groupBy ?? [])]) + ); + const config = await firstValueFrom(config$); const { savedObjectsClient, scopedClusterClient } = services; @@ -160,14 +169,7 @@ export function registerTransactionErrorRateRuleType({ aggs: { series: { multi_terms: { - terms: [ - { field: SERVICE_NAME }, - { - field: SERVICE_ENVIRONMENT, - missing: ENVIRONMENT_NOT_DEFINED.value, - }, - { field: TRANSACTION_TYPE }, - ], + terms: [...getGroupByTerms(allGroupbyFields)], size: 1000, order: { _count: 'desc' as const }, }, @@ -194,8 +196,17 @@ export function registerTransactionErrorRateRuleType({ } const results = []; + for (const bucket of response.aggregations.series.buckets) { - const [serviceName, environment, transactionType] = bucket.key; + const groupByFields = bucket.key.reduce( + (obj, bucketKey, bucketIndex) => { + obj[allGroupbyFields[bucketIndex]] = bucketKey; + return obj; + }, + {} as Record + ); + + const bucketKey = bucket.key; const failedOutcomeBucket = bucket.outcomes.buckets.find( (outcomeBucket) => outcomeBucket.key === EventOutcome.failure @@ -209,47 +220,32 @@ export function registerTransactionErrorRateRuleType({ if (errorRate >= ruleParams.threshold) { results.push({ - serviceName, - environment, - transactionType, errorRate, sourceFields: getServiceGroupFields(failedOutcomeBucket), + groupByFields, + bucketKey, }); } } results.forEach((result) => { - const { - serviceName, - environment, - transactionType, - errorRate, - sourceFields, - } = result; + const { errorRate, sourceFields, groupByFields, bucketKey } = result; const reasonMessage = formatTransactionErrorRateReason({ threshold: ruleParams.threshold, measured: errorRate, asPercent, - serviceName, windowSize: ruleParams.windowSize, windowUnit: ruleParams.windowUnit, + groupByFields, }); - const id = [ - ApmRuleType.TransactionErrorRate, - serviceName, - transactionType, - environment, - ruleParams.transactionName, - ] - .filter((name) => name) - .join('_'); - const relativeViewInAppUrl = getAlertUrlTransaction( - serviceName, - getEnvironmentEsField(environment)?.[SERVICE_ENVIRONMENT], - transactionType + groupByFields[SERVICE_NAME], + getEnvironmentEsField(groupByFields[SERVICE_ENVIRONMENT])?.[ + SERVICE_ENVIRONMENT + ], + groupByFields[TRANSACTION_TYPE] ); const viewInAppUrl = addSpaceIdToPath( @@ -258,34 +254,33 @@ export function registerTransactionErrorRateRuleType({ relativeViewInAppUrl ); + const groupByActionVariables = + getGroupByActionVariables(groupByFields); + services .alertWithLifecycle({ - id, + id: bucketKey.join('_'), fields: { - [SERVICE_NAME]: serviceName, - ...getEnvironmentEsField(environment), - [TRANSACTION_TYPE]: transactionType, [TRANSACTION_NAME]: ruleParams.transactionName, [PROCESSOR_EVENT]: ProcessorEvent.transaction, [ALERT_EVALUATION_VALUE]: errorRate, [ALERT_EVALUATION_THRESHOLD]: ruleParams.threshold, [ALERT_REASON]: reasonMessage, ...sourceFields, + ...groupByFields, }, }) .scheduleActions(ruleTypeConfig.defaultActionGroupId, { - environment: getEnvironmentLabel(environment), interval: formatDurationFromTimeUnitChar( ruleParams.windowSize, ruleParams.windowUnit as TimeUnitChar ), reason: reasonMessage, - serviceName, threshold: ruleParams.threshold, - transactionType, transactionName: ruleParams.transactionName, triggerValue: asDecimalOrInteger(errorRate), viewInAppUrl, + ...groupByActionVariables, }); }); diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/utils/get_groupby_action_variables.test.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/utils/get_groupby_action_variables.test.ts new file mode 100644 index 0000000000000..96e0cbd7f09c1 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/utils/get_groupby_action_variables.test.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 { getGroupByActionVariables } from './get_groupby_action_variables'; + +describe('getGroupByActionVariables', () => { + it('should rename action variables', () => { + const result = getGroupByActionVariables({ + 'service.name': 'opbeans-java', + 'service.environment': 'development', + 'transaction.type': 'request', + 'transaction.name': 'tx-java', + 'error.grouping_key': 'error-key-0', + }); + expect(result).toMatchInlineSnapshot(` + Object { + "environment": "development", + "errorGroupingKey": "error-key-0", + "serviceName": "opbeans-java", + "transactionName": "tx-java", + "transactionType": "request", + } + `); + }); + + it('environment action variable should have value "Not defined"', () => { + const result = getGroupByActionVariables({ + 'service.name': 'opbeans-java', + 'service.environment': 'ENVIRONMENT_NOT_DEFINED', + 'transaction.type': 'request', + 'transaction.name': 'tx-java', + }); + expect(result).toMatchInlineSnapshot(` + Object { + "environment": "Not defined", + "serviceName": "opbeans-java", + "transactionName": "tx-java", + "transactionType": "request", + } + `); + }); +}); diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/utils/get_groupby_action_variables.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/utils/get_groupby_action_variables.ts new file mode 100644 index 0000000000000..fd245bb3a1d0c --- /dev/null +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/utils/get_groupby_action_variables.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getFieldValueLabel } from '../../../../../common/rules/apm_rule_types'; +import { + ERROR_GROUP_ID, + SERVICE_ENVIRONMENT, + SERVICE_NAME, + TRANSACTION_NAME, + TRANSACTION_TYPE, +} from '../../../../../common/es_fields/apm'; + +const renameActionVariable = (field: string): string => { + switch (field) { + case SERVICE_NAME: + return 'serviceName'; + case SERVICE_ENVIRONMENT: + return 'environment'; + case TRANSACTION_TYPE: + return 'transactionType'; + case TRANSACTION_NAME: + return 'transactionName'; + case ERROR_GROUP_ID: + return 'errorGroupingKey'; + default: + return field; + } +}; + +export const getGroupByActionVariables = ( + groupByFields: Record +): Record => { + return Object.keys(groupByFields).reduce>( + (acc, cur) => { + acc[renameActionVariable(cur)] = getFieldValueLabel( + cur, + groupByFields[cur] + ); + return acc; + }, + {} + ); +}; diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/utils/get_groupby_terms.test.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/utils/get_groupby_terms.test.ts new file mode 100644 index 0000000000000..01d96f787bd9b --- /dev/null +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/utils/get_groupby_terms.test.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getGroupByTerms } from './get_groupby_terms'; + +describe('get terms fields for multi-terms aggregation', () => { + it('returns terms array based on the group-by fields', () => { + const ruleParams = { + groupBy: [ + 'service.name', + 'service.environment', + 'transaction.type', + 'transaction.name', + ], + }; + const terms = getGroupByTerms(ruleParams.groupBy); + expect(terms).toEqual([ + { field: 'service.name' }, + { field: 'service.environment', missing: 'ENVIRONMENT_NOT_DEFINED' }, + { field: 'transaction.type' }, + { field: 'transaction.name' }, + ]); + }); + + it('returns an empty terms array when group-by is undefined', () => { + const ruleParams = { groupBy: undefined }; + const terms = getGroupByTerms(ruleParams.groupBy); + expect(terms).toEqual([]); + }); +}); diff --git a/x-pack/plugins/apm/server/routes/alerts/rule_types/utils/get_groupby_terms.ts b/x-pack/plugins/apm/server/routes/alerts/rule_types/utils/get_groupby_terms.ts new file mode 100644 index 0000000000000..22b52fa6a3116 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/alerts/rule_types/utils/get_groupby_terms.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ENVIRONMENT_NOT_DEFINED } from '../../../../../common/environment_filter_values'; +import { SERVICE_ENVIRONMENT } from '../../../../../common/es_fields/apm'; + +export const getGroupByTerms = (groupByFields: string[] | undefined = []) => { + return groupByFields.map((groupByField) => { + return { + field: groupByField, + missing: + groupByField === SERVICE_ENVIRONMENT + ? ENVIRONMENT_NOT_DEFINED.value + : undefined, + }; + }); +}; diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index d08257e729f6a..d3a86b78b9551 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -7094,11 +7094,8 @@ "xpack.apm.agentExplorerInstanceTable.noServiceNodeName.tooltip.linkToDocs": "Vous pouvez configurer le nom du nœud de service via {seeDocs}.", "xpack.apm.agentExplorerTable.agentVersionColumnLabel.multipleVersions": "{versionsCount, plural, one {1 version} other {# versions}}", "xpack.apm.alerts.anomalySeverity.scoreDetailsDescription": "score {value} {value, select, critical {} other {et supérieur}}", - "xpack.apm.alertTypes.errorCount.reason": "Le nombre d'erreurs est {measured} dans le dernier {interval} pour {serviceName}. Alerte lorsque > {threshold}.", "xpack.apm.alertTypes.minimumWindowSize.description": "La valeur minimale recommandée est {sizeValue} {sizeUnit}. Elle permet de s'assurer que l'alerte comporte suffisamment de données à évaluer. Si vous choisissez une valeur trop basse, l'alerte ne se déclenchera peut-être pas comme prévu.", - "xpack.apm.alertTypes.transactionDuration.reason": "La latence de {aggregationType} est {measured} dans le dernier {interval} pour {serviceName}. Alerte lorsque > {threshold}.", "xpack.apm.alertTypes.transactionDurationAnomaly.reason": "Une anomalie {severityLevel} avec un score de {measured} a été détectée dans le dernier {interval} pour {serviceName}.", - "xpack.apm.alertTypes.transactionErrorRate.reason": "L'échec des transactions est {measured} dans le dernier {interval} pour {serviceName}. Alerte lorsque > {threshold}.", "xpack.apm.anomalyDetection.createJobs.failed.text": "Une erreur est survenue lors de la création d'une ou de plusieurs tâches de détection des anomalies pour les environnements de service APM [{environments}]. Erreur : \"{errorMessage}\"", "xpack.apm.anomalyDetection.createJobs.succeeded.text": "Tâches de détection des anomalies créées avec succès pour les environnements de service APM [{environments}]. Le démarrage de l'analyse du trafic à la recherche d'anomalies par le Machine Learning va prendre un certain temps.", "xpack.apm.anomalyDetectionSetup.notEnabledForEnvironmentText": "La détection des anomalies n'est pas encore activée pour l'environnement \"{currentEnvironment}\". Cliquez pour continuer la configuration.", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 1ebc1aeae602e..723902ad29739 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7095,11 +7095,8 @@ "xpack.apm.agentExplorerInstanceTable.noServiceNodeName.tooltip.linkToDocs": "{seeDocs}を使用してサービスノード名を構成できます。", "xpack.apm.agentExplorerTable.agentVersionColumnLabel.multipleVersions": "{versionsCount, plural, other {#個のバージョン}}", "xpack.apm.alerts.anomalySeverity.scoreDetailsDescription": "スコア{value}{value, select, critical {} other {以上}}", - "xpack.apm.alertTypes.errorCount.reason": "エラーカウントは{serviceName}の最後の{interval}で{measured}です。> {threshold}のときにアラートを通知します。", "xpack.apm.alertTypes.minimumWindowSize.description": "推奨される最小値は{sizeValue} {sizeUnit}です。これにより、アラートに評価する十分なデータがあることが保証されます。低すぎる値を選択した場合、アラートが想定通りに実行されない可能性があります。", - "xpack.apm.alertTypes.transactionDuration.reason": "{serviceName}の{aggregationType}のレイテンシは{measured}で最後の{interval}で測定されます。> {threshold}のときにアラートを通知します。", "xpack.apm.alertTypes.transactionDurationAnomaly.reason": "{severityLevel}の異常が{measured}のスコアで{serviceName}の最後の{interval}で検出されました。", - "xpack.apm.alertTypes.transactionErrorRate.reason": "{serviceName}の失敗したトランザクションは最後の{interval}で{measured}されます。> {threshold}のときにアラートを通知します。", "xpack.apm.anomalyDetection.createJobs.failed.text": "APMサービス環境[{environments}]用に1つ以上の異常検知ジョブを作成しているときに問題が発生しました。エラー「{errorMessage}」", "xpack.apm.anomalyDetection.createJobs.succeeded.text": "APMサービス環境[{environments}]の異常検知ジョブが正常に作成されました。機械学習がトラフィック異常値の分析を開始するには、少し時間がかかります。", "xpack.apm.anomalyDetectionSetup.notEnabledForEnvironmentText": "「{currentEnvironment}」環境では、まだ異常検知が有効ではありません。クリックすると、セットアップを続行します。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 0c2b495e1e3fa..469bb9a2cf748 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7095,10 +7095,8 @@ "xpack.apm.agentExplorerInstanceTable.noServiceNodeName.tooltip.linkToDocs": "您可以通过 {seeDocs} 配置服务节点名称。", "xpack.apm.agentExplorerTable.agentVersionColumnLabel.multipleVersions": "{versionsCount, plural, other {# 个版本}}", "xpack.apm.alerts.anomalySeverity.scoreDetailsDescription": "分数 {value} {value, select, critical {} other {及以上}}", - "xpack.apm.alertTypes.errorCount.reason": "对于 {serviceName},过去 {interval}的错误计数为 {measured}。大于 {threshold} 时告警。", "xpack.apm.alertTypes.minimumWindowSize.description": "建议的最小值为 {sizeValue} {sizeUnit}这是为了确保告警具有足够的待评估数据。如果选择的值太小,可能无法按预期触发告警。", "xpack.apm.alertTypes.transactionDurationAnomaly.reason": "对于 {serviceName},过去 {interval}检测到分数为 {measured} 的 {severityLevel} 异常。", - "xpack.apm.alertTypes.transactionErrorRate.reason": "对于 {serviceName},过去 {interval}的失败事务数为 {measured}。大于 {threshold} 时告警。", "xpack.apm.anomalyDetection.createJobs.failed.text": "为 APM 服务环境 [{environments}] 创建一个或多个异常检测作业时出现问题。错误:“{errorMessage}”", "xpack.apm.anomalyDetection.createJobs.succeeded.text": "APM 服务环境 [{environments}] 的异常检测作业已成功创建。Machine Learning 要过一些时间才会开始分析流量以发现异常。", "xpack.apm.anomalyDetectionSetup.notEnabledForEnvironmentText": "尚未针对环境“{currentEnvironment}”启用异常检测。单击可继续设置。", diff --git a/x-pack/test/apm_api_integration/tests/alerts/error_count_threshold.spec.ts b/x-pack/test/apm_api_integration/tests/alerts/error_count_threshold.spec.ts index 3edbabcf023f9..64ced57167ada 100644 --- a/x-pack/test/apm_api_integration/tests/alerts/error_count_threshold.spec.ts +++ b/x-pack/test/apm_api_integration/tests/alerts/error_count_threshold.spec.ts @@ -8,6 +8,7 @@ import { ApmRuleType } from '@kbn/apm-plugin/common/rules/apm_rule_types'; import { errorCountMessage } from '@kbn/apm-plugin/common/rules/default_action_message'; import { apm, timerange } from '@kbn/apm-synthtrace-client'; +import { getErrorGroupingKey } from '@kbn/apm-synthtrace-client/src/lib/apm/instance'; import expect from '@kbn/expect'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import { @@ -16,7 +17,11 @@ import { fetchServiceInventoryAlertCounts, fetchServiceTabAlertCount, } from './alerting_api_helper'; -import { waitForRuleStatus, waitForDocumentInIndex } from './wait_for_rule_status'; +import { + waitForRuleStatus, + waitForDocumentInIndex, + waitForAlertInIndex, +} from './wait_for_rule_status'; export default function ApiTest({ getService }: FtrProviderContext) { const registry = getService('registry'); @@ -32,7 +37,11 @@ export default function ApiTest({ getService }: FtrProviderContext) { let ruleId: string; let actionId: string | undefined; - const INDEX_NAME = 'error-count'; + const APM_ALERTS_INDEX = '.alerts-observability.apm.alerts-default'; + const ALERT_ACTION_INDEX_NAME = 'alert-action-error-count'; + + const errorMessage = '[ResponseError] index_not_found_exception'; + const errorGroupingKey = getErrorGroupingKey(errorMessage); before(async () => { const opbeansJava = apm @@ -50,11 +59,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { .timestamp(timestamp) .duration(100) .failure() - .errors( - opbeansJava - .error({ message: '[ResponseError] index_not_found_exception' }) - .timestamp(timestamp + 50) - ), + .errors(opbeansJava.error({ message: errorMessage }).timestamp(timestamp + 50)), opbeansNode .transaction({ transactionName: 'tx-node' }) .timestamp(timestamp) @@ -69,8 +74,11 @@ export default function ApiTest({ getService }: FtrProviderContext) { await synthtraceEsClient.clean(); await supertest.delete(`/api/alerting/rule/${ruleId}`).set('kbn-xsrf', 'foo'); await supertest.delete(`/api/actions/connector/${actionId}`).set('kbn-xsrf', 'foo'); - await esDeleteAllIndices(INDEX_NAME); - await es.deleteByQuery({ index: '.alerts*', query: { match_all: {} } }); + await esDeleteAllIndices(ALERT_ACTION_INDEX_NAME); + await es.deleteByQuery({ + index: APM_ALERTS_INDEX, + query: { term: { 'kibana.alert.rule.uuid': ruleId } }, + }); await es.deleteByQuery({ index: '.kibana-event-log-*', query: { term: { 'kibana.alert.rule.consumer': 'apm' } }, @@ -82,7 +90,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { actionId = await createIndexConnector({ supertest, name: 'Error count API test', - indexName: INDEX_NAME, + indexName: ALERT_ACTION_INDEX_NAME, }); const createdRule = await createApmRule({ supertest, @@ -93,13 +101,25 @@ export default function ApiTest({ getService }: FtrProviderContext) { threshold: 1, windowSize: 1, windowUnit: 'h', + groupBy: [ + 'service.name', + 'service.environment', + 'transaction.name', + 'error.grouping_key', + ], }, actions: [ { group: 'threshold_met', id: actionId, params: { - documents: [{ message: errorCountMessage }], + documents: [ + { + message: `${errorCountMessage} +- Transaction name: {{context.transactionName}} +- Error grouping key: {{context.errorGroupingKey}}`, + }, + ], }, frequency: { notify_when: 'onActionGroupChange', @@ -113,7 +133,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { ruleId = createdRule.id; }); - it('checks if alert is active', async () => { + it('checks if rule is active', async () => { const executionStatus = await waitForRuleStatus({ id: ruleId, expectedStatus: 'active', @@ -125,7 +145,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { it('returns correct message', async () => { const resp = await waitForDocumentInIndex<{ message: string }>({ es, - indexName: INDEX_NAME, + indexName: ALERT_ACTION_INDEX_NAME, }); expect(resp.hits.hits[0]._source?.message).eql( @@ -134,10 +154,25 @@ export default function ApiTest({ getService }: FtrProviderContext) { - Service name: opbeans-java - Environment: production - Threshold: 1 -- Triggered value: 15 errors over the last 1 hr` +- Triggered value: 15 errors over the last 1 hr +- Transaction name: tx-java +- Error grouping key: ${errorGroupingKey}` ); }); + it('indexes alert document with all group-by fields', async () => { + const resp = await waitForAlertInIndex({ + es, + indexName: APM_ALERTS_INDEX, + ruleId, + }); + + expect(resp.hits.hits[0]._source).property('service.name', 'opbeans-java'); + expect(resp.hits.hits[0]._source).property('service.environment', 'production'); + expect(resp.hits.hits[0]._source).property('transaction.name', 'tx-java'); + expect(resp.hits.hits[0]._source).property('error.grouping_key', errorGroupingKey); + }); + it('shows the correct alert count for each service on service inventory', async () => { const serviceInventoryAlertCounts = await fetchServiceInventoryAlertCounts(apmApiClient); expect(serviceInventoryAlertCounts).to.eql({ diff --git a/x-pack/test/apm_api_integration/tests/alerts/transaction_duration.spec.ts b/x-pack/test/apm_api_integration/tests/alerts/transaction_duration.spec.ts new file mode 100644 index 0000000000000..4fa40d566552f --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/alerts/transaction_duration.spec.ts @@ -0,0 +1,183 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AggregationType, ApmRuleType } from '@kbn/apm-plugin/common/rules/apm_rule_types'; +import { apm, timerange } from '@kbn/apm-synthtrace-client'; +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + createApmRule, + createIndexConnector, + fetchServiceInventoryAlertCounts, + fetchServiceTabAlertCount, +} from './alerting_api_helper'; +import { + waitForRuleStatus, + waitForDocumentInIndex, + waitForAlertInIndex, +} from './wait_for_rule_status'; + +export default function ApiTest({ getService }: FtrProviderContext) { + const registry = getService('registry'); + + const supertest = getService('supertest'); + const es = getService('es'); + const apmApiClient = getService('apmApiClient'); + const esDeleteAllIndices = getService('esDeleteAllIndices'); + + const synthtraceEsClient = getService('synthtraceEsClient'); + + registry.when('transaction duration alert', { config: 'basic', archives: [] }, () => { + let ruleId: string; + let actionId: string | undefined; + + const APM_ALERTS_INDEX = '.alerts-observability.apm.alerts-default'; + const ALERT_ACTION_INDEX_NAME = 'alert-action-transaction-duration'; + + before(async () => { + const opbeansJava = apm + .service({ name: 'opbeans-java', environment: 'production', agentName: 'java' }) + .instance('instance'); + const opbeansNode = apm + .service({ name: 'opbeans-node', environment: 'production', agentName: 'node' }) + .instance('instance'); + const events = timerange('now-15m', 'now') + .ratePerMinute(1) + .generator((timestamp) => { + return [ + opbeansJava + .transaction({ transactionName: 'tx-java' }) + .timestamp(timestamp) + .duration(5000) + .success(), + opbeansNode + .transaction({ transactionName: 'tx-node' }) + .timestamp(timestamp) + .duration(4000) + .success(), + ]; + }); + await synthtraceEsClient.index(events); + }); + + after(async () => { + await synthtraceEsClient.clean(); + await supertest.delete(`/api/alerting/rule/${ruleId}`).set('kbn-xsrf', 'foo'); + await supertest.delete(`/api/actions/connector/${actionId}`).set('kbn-xsrf', 'foo'); + await esDeleteAllIndices([ALERT_ACTION_INDEX_NAME]); + await es.deleteByQuery({ + index: APM_ALERTS_INDEX, + query: { term: { 'kibana.alert.rule.uuid': ruleId } }, + }); + await es.deleteByQuery({ + index: '.kibana-event-log-*', + query: { term: { 'kibana.alert.rule.consumer': 'apm' } }, + }); + }); + + describe('create alert with transaction.name group by', () => { + before(async () => { + actionId = await createIndexConnector({ + supertest, + name: 'Transation duration API test', + indexName: ALERT_ACTION_INDEX_NAME, + }); + const createdRule = await createApmRule({ + supertest, + ruleTypeId: ApmRuleType.TransactionDuration, + name: 'Apm transaction duration', + params: { + threshold: 3000, + windowSize: 5, + windowUnit: 'm', + transactionType: 'request', + serviceName: 'opbeans-java', + environment: 'production', + aggregationType: AggregationType.Avg, + groupBy: [ + 'service.name', + 'service.environment', + 'transaction.type', + 'transaction.name', + ], + }, + actions: [ + { + group: 'threshold_met', + id: actionId, + params: { + documents: [{ message: 'Transaction Name: {{context.transactionName}}' }], + }, + frequency: { + notify_when: 'onActionGroupChange', + throttle: null, + summary: false, + }, + }, + ], + }); + expect(createdRule.id).to.not.eql(undefined); + ruleId = createdRule.id; + }); + + it('checks if rule is active', async () => { + const executionStatus = await waitForRuleStatus({ + id: ruleId, + expectedStatus: 'active', + supertest, + }); + expect(executionStatus.status).to.be('active'); + }); + + it('returns correct message', async () => { + const resp = await waitForDocumentInIndex<{ message: string }>({ + es, + indexName: ALERT_ACTION_INDEX_NAME, + }); + + expect(resp.hits.hits[0]._source?.message).eql(`Transaction Name: tx-java`); + }); + + it('indexes alert document with all group-by fields', async () => { + const resp = await waitForAlertInIndex({ + es, + indexName: APM_ALERTS_INDEX, + ruleId, + }); + + expect(resp.hits.hits[0]._source).property('service.name', 'opbeans-java'); + expect(resp.hits.hits[0]._source).property('service.environment', 'production'); + expect(resp.hits.hits[0]._source).property('transaction.type', 'request'); + expect(resp.hits.hits[0]._source).property('transaction.name', 'tx-java'); + }); + + it('shows the correct alert count for each service on service inventory', async () => { + const serviceInventoryAlertCounts = await fetchServiceInventoryAlertCounts(apmApiClient); + expect(serviceInventoryAlertCounts).to.eql({ + 'opbeans-node': 0, + 'opbeans-java': 1, + }); + }); + + it('shows the correct alert count in opbeans-java service', async () => { + const serviceTabAlertCount = await fetchServiceTabAlertCount({ + apmApiClient, + serviceName: 'opbeans-java', + }); + expect(serviceTabAlertCount).to.be(1); + }); + + it('shows the correct alert count in opbeans-node service', async () => { + const serviceTabAlertCount = await fetchServiceTabAlertCount({ + apmApiClient, + serviceName: 'opbeans-node', + }); + expect(serviceTabAlertCount).to.be(0); + }); + }); + }); +} diff --git a/x-pack/test/apm_api_integration/tests/alerts/transaction_error_rate.spec.ts b/x-pack/test/apm_api_integration/tests/alerts/transaction_error_rate.spec.ts new file mode 100644 index 0000000000000..d4d6a0cf3585d --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/alerts/transaction_error_rate.spec.ts @@ -0,0 +1,187 @@ +/* + * 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 { ApmRuleType } from '@kbn/apm-plugin/common/rules/apm_rule_types'; +import { apm, timerange } from '@kbn/apm-synthtrace-client'; +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + createApmRule, + createIndexConnector, + fetchServiceInventoryAlertCounts, + fetchServiceTabAlertCount, +} from './alerting_api_helper'; +import { + waitForRuleStatus, + waitForDocumentInIndex, + waitForAlertInIndex, +} from './wait_for_rule_status'; + +export default function ApiTest({ getService }: FtrProviderContext) { + const registry = getService('registry'); + + const supertest = getService('supertest'); + const es = getService('es'); + const apmApiClient = getService('apmApiClient'); + const esDeleteAllIndices = getService('esDeleteAllIndices'); + + const synthtraceEsClient = getService('synthtraceEsClient'); + + registry.when('transaction error rate alert', { config: 'basic', archives: [] }, () => { + let ruleId: string; + let actionId: string | undefined; + + const APM_ALERTS_INDEX = '.alerts-observability.apm.alerts-default'; + const ALERT_ACTION_INDEX_NAME = 'alert-action-transaction-error-rate'; + + before(async () => { + const opbeansJava = apm + .service({ name: 'opbeans-java', environment: 'production', agentName: 'java' }) + .instance('instance'); + const opbeansNode = apm + .service({ name: 'opbeans-node', environment: 'production', agentName: 'node' }) + .instance('instance'); + const events = timerange('now-15m', 'now') + .ratePerMinute(1) + .generator((timestamp) => { + return [ + opbeansJava + .transaction({ transactionName: 'tx-java' }) + .timestamp(timestamp) + .duration(100) + .failure(), + opbeansJava + .transaction({ transactionName: 'tx-java' }) + .timestamp(timestamp) + .duration(200) + .success(), + opbeansNode + .transaction({ transactionName: 'tx-node' }) + .timestamp(timestamp) + .duration(400) + .success(), + ]; + }); + await synthtraceEsClient.index(events); + }); + + after(async () => { + await synthtraceEsClient.clean(); + await supertest.delete(`/api/alerting/rule/${ruleId}`).set('kbn-xsrf', 'foo'); + await supertest.delete(`/api/actions/connector/${actionId}`).set('kbn-xsrf', 'foo'); + await esDeleteAllIndices([ALERT_ACTION_INDEX_NAME]); + await es.deleteByQuery({ + index: APM_ALERTS_INDEX, + query: { term: { 'kibana.alert.rule.uuid': ruleId } }, + }); + await es.deleteByQuery({ + index: '.kibana-event-log-*', + query: { term: { 'kibana.alert.rule.consumer': 'apm' } }, + }); + }); + + describe('create alert with transaction.name group by', () => { + before(async () => { + actionId = await createIndexConnector({ + supertest, + name: 'Transation error rate API test', + indexName: ALERT_ACTION_INDEX_NAME, + }); + const createdRule = await createApmRule({ + supertest, + ruleTypeId: ApmRuleType.TransactionErrorRate, + name: 'Apm error rate duration', + params: { + threshold: 50, + windowSize: 5, + windowUnit: 'm', + transactionType: 'request', + serviceName: 'opbeans-java', + environment: 'production', + groupBy: [ + 'service.name', + 'service.environment', + 'transaction.type', + 'transaction.name', + ], + }, + actions: [ + { + group: 'threshold_met', + id: actionId, + params: { + documents: [{ message: 'Transaction Name: {{context.transactionName}}' }], + }, + frequency: { + notify_when: 'onActionGroupChange', + throttle: null, + summary: false, + }, + }, + ], + }); + expect(createdRule.id).to.not.eql(undefined); + ruleId = createdRule.id; + }); + + it('checks if rule is active', async () => { + const executionStatus = await waitForRuleStatus({ + id: ruleId, + expectedStatus: 'active', + supertest, + }); + expect(executionStatus.status).to.be('active'); + }); + + it('returns correct message', async () => { + const resp = await waitForDocumentInIndex<{ message: string }>({ + es, + indexName: ALERT_ACTION_INDEX_NAME, + }); + + expect(resp.hits.hits[0]._source?.message).eql(`Transaction Name: tx-java`); + }); + + it('indexes alert document with all group-by fields', async () => { + const resp = await waitForAlertInIndex({ + es, + indexName: APM_ALERTS_INDEX, + ruleId, + }); + + expect(resp.hits.hits[0]._source).property('service.name', 'opbeans-java'); + expect(resp.hits.hits[0]._source).property('service.environment', 'production'); + expect(resp.hits.hits[0]._source).property('transaction.type', 'request'); + expect(resp.hits.hits[0]._source).property('transaction.name', 'tx-java'); + }); + + it('shows the correct alert count for each service on service inventory', async () => { + const serviceInventoryAlertCounts = await fetchServiceInventoryAlertCounts(apmApiClient); + expect(serviceInventoryAlertCounts).to.eql({ + 'opbeans-node': 0, + 'opbeans-java': 1, + }); + }); + + it('shows the correct alert count in opbeans-java service', async () => { + const serviceTabAlertCount = await fetchServiceTabAlertCount({ + apmApiClient, + serviceName: 'opbeans-java', + }); + expect(serviceTabAlertCount).to.be(1); + }); + + it('shows the correct alert count in opbeans-node service', async () => { + const serviceTabAlertCount = await fetchServiceTabAlertCount({ + apmApiClient, + serviceName: 'opbeans-node', + }); + expect(serviceTabAlertCount).to.be(0); + }); + }); + }); +} diff --git a/x-pack/test/apm_api_integration/tests/alerts/wait_for_rule_status.ts b/x-pack/test/apm_api_integration/tests/alerts/wait_for_rule_status.ts index 44da2bea36bf2..f02285d5056eb 100644 --- a/x-pack/test/apm_api_integration/tests/alerts/wait_for_rule_status.ts +++ b/x-pack/test/apm_api_integration/tests/alerts/wait_for_rule_status.ts @@ -53,3 +53,33 @@ export async function waitForDocumentInIndex({ { retries: 10 } ); } + +export async function waitForAlertInIndex({ + es, + indexName, + ruleId, +}: { + es: Client; + indexName: string; + ruleId: string; +}): Promise>> { + return pRetry( + async () => { + const response = await es.search({ + index: indexName, + body: { + query: { + term: { + 'kibana.alert.rule.uuid': ruleId, + }, + }, + }, + }); + if (response.hits.hits.length === 0) { + throw new Error('No hits found'); + } + return response; + }, + { retries: 10 } + ); +} diff --git a/x-pack/test/rule_registry/spaces_only/tests/trial/__snapshots__/create_rule.snap b/x-pack/test/rule_registry/spaces_only/tests/trial/__snapshots__/create_rule.snap index e76a791652f93..a66368fe5a8ec 100644 --- a/x-pack/test/rule_registry/spaces_only/tests/trial/__snapshots__/create_rule.snap +++ b/x-pack/test/rule_registry/spaces_only/tests/trial/__snapshots__/create_rule.snap @@ -21,10 +21,10 @@ Object { false, ], "kibana.alert.instance.id": Array [ - "apm.transaction_error_rate_opbeans-go_request_ENVIRONMENT_NOT_DEFINED", + "opbeans-go_ENVIRONMENT_NOT_DEFINED_request", ], "kibana.alert.reason": Array [ - "Failed transactions is 50% in the last 5 mins for opbeans-go. Alert when > 30%.", + "Failed transactions is 50% in the last 5 mins for service: opbeans-go, env: Not defined, type: request. Alert when > 30%.", ], "kibana.alert.rule.category": Array [ "Failed transaction rate threshold", @@ -70,6 +70,9 @@ Object { "processor.event": Array [ "transaction", ], + "service.environment": Array [ + "ENVIRONMENT_NOT_DEFINED", + ], "service.name": Array [ "opbeans-go", ], @@ -101,10 +104,10 @@ Object { false, ], "kibana.alert.instance.id": Array [ - "apm.transaction_error_rate_opbeans-go_request_ENVIRONMENT_NOT_DEFINED", + "opbeans-go_ENVIRONMENT_NOT_DEFINED_request", ], "kibana.alert.reason": Array [ - "Failed transactions is 50% in the last 5 mins for opbeans-go. Alert when > 30%.", + "Failed transactions is 50% in the last 5 mins for service: opbeans-go, env: Not defined, type: request. Alert when > 30%.", ], "kibana.alert.rule.category": Array [ "Failed transaction rate threshold", @@ -150,6 +153,9 @@ Object { "processor.event": Array [ "transaction", ], + "service.environment": Array [ + "ENVIRONMENT_NOT_DEFINED", + ], "service.name": Array [ "opbeans-go", ], From 6e612b5622f0c19dfcddac1749b07f6558da1595 Mon Sep 17 00:00:00 2001 From: Sander Philipse <94373878+sphilipse@users.noreply.github.com> Date: Tue, 25 Apr 2023 16:03:17 +0200 Subject: [PATCH 19/29] [Enterprise Search] Create search results provider (#155641) ## Summary This adds a global search provider for Enterprise Search results, returning the web crawler and connectors. --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../common/connectors/connectors.ts | 120 +++++++++++ x-pack/plugins/enterprise_search/kibana.jsonc | 1 + .../components/new_index/utils.ts | 4 +- .../search_index/connector/constants.ts | 126 ++++------- .../search_index/connector/types.ts | 10 +- .../shared/icons/connector_icons.ts | 30 +++ .../connector.svg | 0 .../crawler.svg | 0 .../{microsoft_sql.svg => mssql.svg} | 0 .../source_icons/native_connector_icons.ts | 30 --- .../source_icons/{amazon_s3.svg => s3.svg} | 0 .../enterprise_search/server/integrations.ts | 6 +- .../enterprise_search/server/plugin.ts | 29 ++- .../utils/search_result_provider.test.ts | 196 ++++++++++++++++++ .../server/utils/search_result_provider.ts | 103 +++++++++ .../plugins/enterprise_search/tsconfig.json | 1 + 16 files changed, 517 insertions(+), 139 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/common/connectors/connectors.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/icons/connector_icons.ts rename x-pack/plugins/enterprise_search/public/assets/{enterprise_search_features => source_icons}/connector.svg (100%) rename x-pack/plugins/enterprise_search/public/assets/{enterprise_search_features => source_icons}/crawler.svg (100%) rename x-pack/plugins/enterprise_search/public/assets/source_icons/{microsoft_sql.svg => mssql.svg} (100%) delete mode 100644 x-pack/plugins/enterprise_search/public/assets/source_icons/native_connector_icons.ts rename x-pack/plugins/enterprise_search/public/assets/source_icons/{amazon_s3.svg => s3.svg} (100%) create mode 100644 x-pack/plugins/enterprise_search/server/utils/search_result_provider.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/utils/search_result_provider.ts diff --git a/x-pack/plugins/enterprise_search/common/connectors/connectors.ts b/x-pack/plugins/enterprise_search/common/connectors/connectors.ts new file mode 100644 index 0000000000000..953c49b493b37 --- /dev/null +++ b/x-pack/plugins/enterprise_search/common/connectors/connectors.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 { i18n } from '@kbn/i18n'; + +export interface ConnectorServerSideDefinition { + iconPath: string; + isBeta: boolean; + isNative: boolean; + keywords: string[]; + name: string; + serviceType: string; +} + +export const CONNECTOR_DEFINITIONS: ConnectorServerSideDefinition[] = [ + { + iconPath: 'mongodb.svg', + isBeta: false, + isNative: true, + keywords: ['mongo', 'mongodb', 'database', 'nosql', 'connector'], + name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.mongodb.name', { + defaultMessage: 'MongoDB', + }), + serviceType: 'mongodb', + }, + { + iconPath: 'mysql.svg', + isBeta: false, + isNative: true, + keywords: ['mysql', 'sql', 'database', 'connector'], + name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.mysql.name', { + defaultMessage: 'MySQL', + }), + serviceType: 'mysql', + }, + { + iconPath: 'azure_blob_storage.svg', + isBeta: true, + isNative: false, + keywords: ['cloud', 'azure', 'blob', 's3', 'connector'], + name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.azureBlob.name', { + defaultMessage: 'Azure Blob Storage', + }), + serviceType: 'azure_blob_storage', + }, + { + iconPath: 'google_cloud_storage.svg', + isBeta: true, + isNative: false, + keywords: ['google', 'cloud', 'blob', 's3', 'connector'], + name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.googleCloud.name', { + defaultMessage: 'Google Cloud Storage', + }), + serviceType: 'google_cloud_storage', + }, + { + iconPath: 'mssql.svg', + isBeta: true, + isNative: false, + keywords: ['mssql', 'microsoft', 'sql', 'database', 'connector'], + name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.microsoftSQL.name', { + defaultMessage: 'Microsoft SQL', + }), + serviceType: 'mssql', + }, + { + iconPath: 'network_drive.svg', + isBeta: true, + isNative: false, + keywords: ['network', 'drive', 'file', 'directory', 'connector'], + name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.networkDrive.name', { + defaultMessage: 'Network drive', + }), + serviceType: 'network_drive', + }, + { + iconPath: 'oracle.svg', + isBeta: true, + isNative: false, + keywords: ['oracle', 'sql', 'database', 'connector'], + name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.oracle.name', { + defaultMessage: 'Oracle', + }), + serviceType: 'oracle', + }, + { + iconPath: 'postgresql.svg', + isBeta: true, + isNative: false, + keywords: ['postgresql', 'sql', 'database', 'connector'], + name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.postgresql.name', { + defaultMessage: 'Postgresql', + }), + serviceType: 'postgresql', + }, + { + iconPath: 's3.svg', + isBeta: true, + isNative: false, + keywords: ['s3', 'cloud', 'amazon', 'connector'], + name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.s3.name', { + defaultMessage: 'S3', + }), + serviceType: 's3', + }, + { + iconPath: 'custom.svg', + isBeta: true, + isNative: false, + keywords: ['custom', 'connector', 'code'], + name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.customConnector.name', { + defaultMessage: 'Customized connector', + }), + serviceType: '', + }, +]; diff --git a/x-pack/plugins/enterprise_search/kibana.jsonc b/x-pack/plugins/enterprise_search/kibana.jsonc index 5886cf02bfd47..5593addaeca35 100644 --- a/x-pack/plugins/enterprise_search/kibana.jsonc +++ b/x-pack/plugins/enterprise_search/kibana.jsonc @@ -26,6 +26,7 @@ ], "optionalPlugins": [ "customIntegrations", + "globalSearch", "home", "ml", "spaces", diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/new_index/utils.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/new_index/utils.ts index a1d182434f3f1..e4bdceb5dcbdf 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/new_index/utils.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/new_index/utils.ts @@ -7,9 +7,9 @@ import { INGESTION_METHOD_IDS } from '../../../../../common/constants'; -import connectorLogo from '../../../../assets/enterprise_search_features/connector.svg'; +import connectorLogo from '../../../../assets/source_icons/connector.svg'; -import crawlerLogo from '../../../../assets/enterprise_search_features/crawler.svg'; +import crawlerLogo from '../../../../assets/source_icons/crawler.svg'; import { UNIVERSAL_LANGUAGE_VALUE } from './constants'; import { LanguageForOptimization } from './types'; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/constants.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/constants.ts index 9aaea14fde6db..b6269bb91aa87 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/constants.ts @@ -5,137 +5,83 @@ * 2.0. */ -import { i18n } from '@kbn/i18n'; - -import { CONNECTOR_ICONS } from '../../../../../assets/source_icons/native_connector_icons'; +import { CONNECTOR_DEFINITIONS } from '../../../../../../common/connectors/connectors'; import { docLinks } from '../../../../shared/doc_links'; +import { CONNECTOR_ICONS } from '../../../../shared/icons/connector_icons'; -import { ConnectorDefinition } from './types'; +import { ConnectorClientSideDefinition } from './types'; -export const CONNECTORS: ConnectorDefinition[] = [ - { - docsUrl: docLinks.connectorsMongoDB, - externalAuthDocsUrl: 'https://www.mongodb.com/docs/atlas/app-services/authentication/', - externalDocsUrl: 'https://www.mongodb.com/docs/', - icon: CONNECTOR_ICONS.mongodb, - isBeta: false, - isNative: true, - name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.mongodb.name', { - defaultMessage: 'MongoDB', - }), - serviceType: 'mongodb', - }, - { - docsUrl: docLinks.connectorsMySQL, - externalDocsUrl: 'https://dev.mysql.com/doc/', - icon: CONNECTOR_ICONS.mysql, - isBeta: false, - isNative: true, - name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.mysql.name', { - defaultMessage: 'MySQL', - }), - serviceType: 'mysql', - }, - { +export const CONNECTORS_DICT: Record = { + azure_blob_storage: { docsUrl: docLinks.connectorsAzureBlobStorage, externalAuthDocsUrl: 'https://learn.microsoft.com/azure/storage/common/authorize-data-access', externalDocsUrl: 'https://learn.microsoft.com/azure/storage/blobs/', icon: CONNECTOR_ICONS.azure_blob_storage, - isBeta: true, - isNative: false, - name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.azureBlob.name', { - defaultMessage: 'Azure Blob Storage', - }), - serviceType: 'azure_blob_storage', }, - { + custom: { + docsUrl: docLinks.connectors, + externalAuthDocsUrl: '', + externalDocsUrl: '', + icon: CONNECTOR_ICONS.custom, + }, + google_cloud_storage: { docsUrl: docLinks.connectorsGoogleCloudStorage, externalAuthDocsUrl: 'https://cloud.google.com/storage/docs/authentication', externalDocsUrl: 'https://cloud.google.com/storage/docs', icon: CONNECTOR_ICONS.google_cloud_storage, - isBeta: true, - isNative: false, - name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.googleCloud.name', { - defaultMessage: 'Google Cloud Storage', - }), - serviceType: 'google_cloud_storage', }, - { + mongodb: { + docsUrl: docLinks.connectorsMongoDB, + externalAuthDocsUrl: 'https://www.mongodb.com/docs/atlas/app-services/authentication/', + externalDocsUrl: 'https://www.mongodb.com/docs/', + icon: CONNECTOR_ICONS.mongodb, + }, + mssql: { docsUrl: docLinks.connectorsMicrosoftSQL, externalAuthDocsUrl: 'https://learn.microsoft.com/sql/relational-databases/security/authentication-access/getting-started-with-database-engine-permissions', externalDocsUrl: 'https://learn.microsoft.com/sql/', icon: CONNECTOR_ICONS.microsoft_sql, - isBeta: true, - isNative: false, - name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.microsoftSQL.name', { - defaultMessage: 'Microsoft SQL', - }), - serviceType: 'mssql', }, - { + mysql: { + docsUrl: docLinks.connectorsMySQL, + externalDocsUrl: 'https://dev.mysql.com/doc/', + icon: CONNECTOR_ICONS.mysql, + }, + network_drive: { docsUrl: docLinks.connectorsNetworkDrive, externalAuthDocsUrl: '', externalDocsUrl: '', icon: CONNECTOR_ICONS.network_drive, - isBeta: true, - isNative: false, - name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.networkDrive.name', { - defaultMessage: 'Network drive', - }), - serviceType: 'network_drive', }, - { + oracle: { docsUrl: docLinks.connectorsOracle, externalAuthDocsUrl: 'https://docs.oracle.com/en/database/oracle/oracle-database/19/dbseg/index.html', externalDocsUrl: 'https://docs.oracle.com/database/oracle/oracle-database/', icon: CONNECTOR_ICONS.oracle, - isBeta: true, - isNative: false, - name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.oracle.name', { - defaultMessage: 'Oracle', - }), - serviceType: 'oracle', }, - { + postgresql: { docsUrl: docLinks.connectorsPostgreSQL, externalAuthDocsUrl: 'https://www.postgresql.org/docs/15/auth-methods.html', externalDocsUrl: 'https://www.postgresql.org/docs/', icon: CONNECTOR_ICONS.postgresql, - isBeta: true, - isNative: false, - name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.postgresql.name', { - defaultMessage: 'Postgresql', - }), - serviceType: 'postgresql', }, - { + s3: { docsUrl: docLinks.connectorsS3, externalAuthDocsUrl: 'https://docs.aws.amazon.com/s3/index.html', externalDocsUrl: '', icon: CONNECTOR_ICONS.amazon_s3, - isBeta: true, - isNative: false, - name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.s3.name', { - defaultMessage: 'S3', - }), - serviceType: 's3', }, - { - docsUrl: docLinks.connectors, - externalAuthDocsUrl: '', - externalDocsUrl: '', - icon: CONNECTOR_ICONS.custom, - isBeta: true, - isNative: false, - name: i18n.translate('xpack.enterpriseSearch.content.nativeConnectors.customConnector.name', { - defaultMessage: 'Custom connector', - }), - serviceType: '', - }, -]; +}; + +export const CONNECTORS = CONNECTOR_DEFINITIONS.map((connector) => ({ + ...connector, + ...(connector.serviceType && CONNECTORS_DICT[connector.serviceType] + ? CONNECTORS_DICT[connector.serviceType] + : CONNECTORS_DICT.custom), +})); export const CUSTOM_CONNECTORS = CONNECTORS.filter(({ isNative }) => !isNative); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/types.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/types.ts index 0b061d1aa3241..68d990650a175 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/types.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/search_index/connector/types.ts @@ -5,13 +5,13 @@ * 2.0. */ -export interface ConnectorDefinition { +import { ConnectorServerSideDefinition } from '../../../../../../common/connectors/connectors'; + +export interface ConnectorClientSideDefinition { docsUrl?: string; externalAuthDocsUrl?: string; externalDocsUrl: string; icon: string; - isBeta: boolean; - isNative: boolean; - name: string; - serviceType: string; } + +export type ConnectorDefinition = ConnectorClientSideDefinition & ConnectorServerSideDefinition; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/icons/connector_icons.ts b/x-pack/plugins/enterprise_search/public/applications/shared/icons/connector_icons.ts new file mode 100644 index 0000000000000..f9d9eb5df9799 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/icons/connector_icons.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import azure_blob_storage from '../../../assets/source_icons/azure_blob_storage.svg'; +import custom from '../../../assets/source_icons/custom.svg'; +import google_cloud_storage from '../../../assets/source_icons/google_cloud_storage.svg'; +import mongodb from '../../../assets/source_icons/mongodb.svg'; +import microsoft_sql from '../../../assets/source_icons/mssql.svg'; +import mysql from '../../../assets/source_icons/mysql.svg'; +import network_drive from '../../../assets/source_icons/network_drive.svg'; +import oracle from '../../../assets/source_icons/oracle.svg'; +import postgresql from '../../../assets/source_icons/postgresql.svg'; +import amazon_s3 from '../../../assets/source_icons/s3.svg'; + +export const CONNECTOR_ICONS = { + amazon_s3, + azure_blob_storage, + custom, + google_cloud_storage, + microsoft_sql, + mongodb, + mysql, + network_drive, + oracle, + postgresql, +}; diff --git a/x-pack/plugins/enterprise_search/public/assets/enterprise_search_features/connector.svg b/x-pack/plugins/enterprise_search/public/assets/source_icons/connector.svg similarity index 100% rename from x-pack/plugins/enterprise_search/public/assets/enterprise_search_features/connector.svg rename to x-pack/plugins/enterprise_search/public/assets/source_icons/connector.svg diff --git a/x-pack/plugins/enterprise_search/public/assets/enterprise_search_features/crawler.svg b/x-pack/plugins/enterprise_search/public/assets/source_icons/crawler.svg similarity index 100% rename from x-pack/plugins/enterprise_search/public/assets/enterprise_search_features/crawler.svg rename to x-pack/plugins/enterprise_search/public/assets/source_icons/crawler.svg diff --git a/x-pack/plugins/enterprise_search/public/assets/source_icons/microsoft_sql.svg b/x-pack/plugins/enterprise_search/public/assets/source_icons/mssql.svg similarity index 100% rename from x-pack/plugins/enterprise_search/public/assets/source_icons/microsoft_sql.svg rename to x-pack/plugins/enterprise_search/public/assets/source_icons/mssql.svg diff --git a/x-pack/plugins/enterprise_search/public/assets/source_icons/native_connector_icons.ts b/x-pack/plugins/enterprise_search/public/assets/source_icons/native_connector_icons.ts deleted file mode 100644 index 49767bb497f8b..0000000000000 --- a/x-pack/plugins/enterprise_search/public/assets/source_icons/native_connector_icons.ts +++ /dev/null @@ -1,30 +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 amazon_s3 from './amazon_s3.svg'; -import azure_blob_storage from './azure_blob_storage.svg'; -import custom from './custom.svg'; -import google_cloud_storage from './google_cloud_storage.svg'; -import microsoft_sql from './microsoft_sql.svg'; -import mongodb from './mongodb.svg'; -import mysql from './mysql.svg'; -import network_drive from './network_drive.svg'; -import oracle from './oracle.svg'; -import postgresql from './postgresql.svg'; - -export const CONNECTOR_ICONS = { - amazon_s3, - azure_blob_storage, - custom, - google_cloud_storage, - microsoft_sql, - mongodb, - mysql, - network_drive, - oracle, - postgresql, -}; diff --git a/x-pack/plugins/enterprise_search/public/assets/source_icons/amazon_s3.svg b/x-pack/plugins/enterprise_search/public/assets/source_icons/s3.svg similarity index 100% rename from x-pack/plugins/enterprise_search/public/assets/source_icons/amazon_s3.svg rename to x-pack/plugins/enterprise_search/public/assets/source_icons/s3.svg diff --git a/x-pack/plugins/enterprise_search/server/integrations.ts b/x-pack/plugins/enterprise_search/server/integrations.ts index ae5eb87aad733..8d68f1c3e1c58 100644 --- a/x-pack/plugins/enterprise_search/server/integrations.ts +++ b/x-pack/plugins/enterprise_search/server/integrations.ts @@ -588,9 +588,7 @@ export const registerEnterpriseSearchIntegrations = ( icons: [ { type: 'svg', - src: http.basePath.prepend( - '/plugins/enterpriseSearch/assets/source_icons/microsoft_sql.svg' - ), + src: http.basePath.prepend('/plugins/enterpriseSearch/assets/source_icons/mssql.svg'), }, ], shipper: 'enterprise_search', @@ -651,7 +649,7 @@ export const registerEnterpriseSearchIntegrations = ( icons: [ { type: 'svg', - src: http.basePath.prepend('/plugins/enterpriseSearch/assets/source_icons/amazon_s3.svg'), + src: http.basePath.prepend('/plugins/enterpriseSearch/assets/source_icons/s3.svg'), }, ], shipper: 'enterprise_search', diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index 729258fe4e97c..77dc6d4c839f5 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -18,6 +18,7 @@ import { import { CustomIntegrationsPluginSetup } from '@kbn/custom-integrations-plugin/server'; import { DataPluginStart } from '@kbn/data-plugin/server/plugin'; import { PluginSetupContract as FeaturesPluginSetup } from '@kbn/features-plugin/server'; +import { GlobalSearchPluginSetup } from '@kbn/global-search-plugin/server'; import type { GuidedOnboardingPluginSetup } from '@kbn/guided-onboarding-plugin/server'; import { InfraPluginSetup } from '@kbn/infra-plugin/server'; import type { MlPluginSetup } from '@kbn/ml-plugin/server'; @@ -76,31 +77,34 @@ import { workplaceSearchTelemetryType } from './saved_objects/workplace_search/t import { uiSettings as enterpriseSearchUISettings } from './ui_settings'; +import { getSearchResultProvider } from './utils/search_result_provider'; + import { ConfigType } from '.'; interface PluginsSetup { - usageCollection?: UsageCollectionSetup; - security: SecurityPluginSetup; + customIntegrations?: CustomIntegrationsPluginSetup; features: FeaturesPluginSetup; + globalSearch: GlobalSearchPluginSetup; + guidedOnboarding: GuidedOnboardingPluginSetup; infra: InfraPluginSetup; - customIntegrations?: CustomIntegrationsPluginSetup; ml?: MlPluginSetup; - guidedOnboarding: GuidedOnboardingPluginSetup; + security: SecurityPluginSetup; + usageCollection?: UsageCollectionSetup; } interface PluginsStart { - spaces?: SpacesPluginStart; - security: SecurityPluginStart; data: DataPluginStart; + security: SecurityPluginStart; + spaces?: SpacesPluginStart; } export interface RouteDependencies { - router: IRouter; config: ConfigType; - log: Logger; enterpriseSearchRequestHandler: IEnterpriseSearchRequestHandler; getSavedObjectsService?(): SavedObjectsServiceStart; + log: Logger; ml?: MlPluginSetup; + router: IRouter; } export class EnterpriseSearchPlugin implements Plugin { @@ -118,6 +122,7 @@ export class EnterpriseSearchPlugin implements Plugin { usageCollection, security, features, + globalSearch, infra, customIntegrations, ml, @@ -284,6 +289,14 @@ export class EnterpriseSearchPlugin implements Plugin { if (config.hasNativeConnectors) { guidedOnboarding.registerGuideConfig(databaseSearchGuideId, databaseSearchGuideConfig); } + + /** + * Register our integrations in the global search bar + */ + + if (globalSearch) { + globalSearch.registerResultProvider(getSearchResultProvider(http.basePath, config)); + } } public start() {} diff --git a/x-pack/plugins/enterprise_search/server/utils/search_result_provider.test.ts b/x-pack/plugins/enterprise_search/server/utils/search_result_provider.test.ts new file mode 100644 index 0000000000000..d5ecee88f80cf --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/utils/search_result_provider.test.ts @@ -0,0 +1,196 @@ +/* + * 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 { NEVER } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { ENTERPRISE_SEARCH_CONTENT_PLUGIN } from '../../common/constants'; + +import { getSearchResultProvider } from './search_result_provider'; + +const getTestScheduler = () => { + return new TestScheduler((actual, expected) => { + return expect(actual).toEqual(expected); + }); +}; + +describe('Enterprise Search search provider', () => { + const basePathMock = { + prepend: (input: string) => `/kbn${input}`, + } as any; + + const crawlerResult = { + icon: '/kbn/plugins/enterpriseSearch/assets/source_icons/crawler.svg', + id: 'elastic-crawler', + score: 75, + title: 'Elastic Web Crawler', + type: 'Enterprise Search', + url: { + path: `${ENTERPRISE_SEARCH_CONTENT_PLUGIN.URL}/search_indices/new_index/crawler`, + prependBasePath: true, + }, + }; + + const mongoResult = { + icon: '/kbn/plugins/enterpriseSearch/assets/source_icons/mongodb.svg', + id: 'mongodb', + score: 75, + title: 'MongoDB', + type: 'Enterprise Search', + url: { + path: `${ENTERPRISE_SEARCH_CONTENT_PLUGIN.URL}/search_indices/new_index/connector?service_type=mongodb`, + prependBasePath: true, + }, + }; + + const searchResultProvider = getSearchResultProvider(basePathMock, { + hasConnectors: true, + hasWebCrawler: true, + } as any); + + beforeEach(() => {}); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('find', () => { + it('returns formatted results', () => { + getTestScheduler().run(({ expectObservable }) => { + expectObservable( + searchResultProvider.find( + { term: 'crawler' }, + { aborted$: NEVER, maxResults: 100, preference: '' }, + {} as any + ) + ).toBe('(a|)', { + a: [crawlerResult], + }); + }); + }); + + it('returns everything on empty string', () => { + getTestScheduler().run(({ expectObservable }) => { + expectObservable( + searchResultProvider.find( + { term: '' }, + { aborted$: NEVER, maxResults: 100, preference: '' }, + {} as any + ) + ).toBe('(a|)', { + a: expect.arrayContaining([ + { ...crawlerResult, score: 80 }, + { ...mongoResult, score: 80 }, + ]), + }); + }); + }); + + it('respect maximum results', () => { + getTestScheduler().run(({ expectObservable }) => { + expectObservable( + searchResultProvider.find( + { term: '' }, + { aborted$: NEVER, maxResults: 1, preference: '' }, + {} as any + ) + ).toBe('(a|)', { + a: [{ ...crawlerResult, score: 80 }], + }); + }); + }); + + it('omits crawler if config has crawler disabled', () => { + const searchProvider = getSearchResultProvider(basePathMock, { + hasConnectors: true, + hasWebCrawler: false, + } as any); + getTestScheduler().run(({ expectObservable }) => { + expectObservable( + searchProvider.find( + { term: '' }, + { aborted$: NEVER, maxResults: 100, preference: '' }, + {} as any + ) + ).toBe('(a|)', { + a: expect.not.arrayContaining([{ ...crawlerResult, score: 80 }]), + }); + }); + }); + + it('omits connectors if config has connectors disabled', () => { + const searchProvider = getSearchResultProvider(basePathMock, { + hasConnectors: false, + hasWebCrawler: true, + } as any); + getTestScheduler().run(({ expectObservable }) => { + expectObservable( + searchProvider.find( + { term: '' }, + { aborted$: NEVER, maxResults: 100, preference: '' }, + {} as any + ) + ).toBe('(a|)', { + a: expect.not.arrayContaining([{ mongoResult, score: 80 }]), + }); + }); + }); + + it('returns nothing if tag is specified', () => { + getTestScheduler().run(({ expectObservable }) => { + expectObservable( + searchResultProvider.find( + { tags: ['tag'], term: '' }, + { aborted$: NEVER, maxResults: 1, preference: '' }, + {} as any + ) + ).toBe('(a|)', { + a: [], + }); + }); + }); + it('returns nothing if unknown type is specified', () => { + getTestScheduler().run(({ expectObservable }) => { + expectObservable( + searchResultProvider.find( + { term: '', types: ['tag'] }, + { aborted$: NEVER, maxResults: 1, preference: '' }, + {} as any + ) + ).toBe('(a|)', { + a: [], + }); + }); + }); + it('returns results for integrations tag', () => { + getTestScheduler().run(({ expectObservable }) => { + expectObservable( + searchResultProvider.find( + { term: 'crawler', types: ['integration'] }, + { aborted$: NEVER, maxResults: 1, preference: '' }, + {} as any + ) + ).toBe('(a|)', { + a: [crawlerResult], + }); + }); + }); + it('returns results for enterprise search tag', () => { + getTestScheduler().run(({ expectObservable }) => { + expectObservable( + searchResultProvider.find( + { term: 'crawler', types: ['enterprise search'] }, + { aborted$: NEVER, maxResults: 1, preference: '' }, + {} as any + ) + ).toBe('(a|)', { + a: [crawlerResult], + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/utils/search_result_provider.ts b/x-pack/plugins/enterprise_search/server/utils/search_result_provider.ts new file mode 100644 index 0000000000000..29f791dfd5ca1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/utils/search_result_provider.ts @@ -0,0 +1,103 @@ +/* + * 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 { from, takeUntil } from 'rxjs'; + +import { IBasePath } from '@kbn/core-http-server'; +import { GlobalSearchResultProvider } from '@kbn/global-search-plugin/server'; + +import { ConfigType } from '..'; +import { CONNECTOR_DEFINITIONS } from '../../common/connectors/connectors'; +import { + ENTERPRISE_SEARCH_CONNECTOR_CRAWLER_SERVICE_TYPE, + ENTERPRISE_SEARCH_CONTENT_PLUGIN, +} from '../../common/constants'; + +export function toSearchResult({ + basePath, + iconPath, + name, + score, + serviceType, +}: { + basePath: IBasePath; + iconPath: string; + name: string; + score: number; + serviceType: string; +}) { + return { + icon: iconPath + ? basePath.prepend(`/plugins/enterpriseSearch/assets/source_icons/${iconPath}`) + : 'logoEnterpriseSearch', + id: serviceType, + score, + title: name, + type: 'Enterprise Search', + url: { + path: `${ENTERPRISE_SEARCH_CONTENT_PLUGIN.URL}/search_indices/new_index/${ + serviceType === ENTERPRISE_SEARCH_CONNECTOR_CRAWLER_SERVICE_TYPE + ? 'crawler' + : `connector?service_type=${serviceType}` + }`, + prependBasePath: true, + }, + }; +} + +export function getSearchResultProvider( + basePath: IBasePath, + config: ConfigType +): GlobalSearchResultProvider { + return { + find: ({ term, types, tags }, { aborted$, maxResults }) => { + if ( + tags || + (types && !(types.includes('integration') || types.includes('enterprise search'))) + ) { + return from([[]]); + } + const result = [ + ...(config.hasWebCrawler + ? [ + { + iconPath: 'crawler.svg', + keywords: ['crawler', 'web', 'website', 'internet', 'google'], + name: 'Elastic Web Crawler', + serviceType: ENTERPRISE_SEARCH_CONNECTOR_CRAWLER_SERVICE_TYPE, + }, + ] + : []), + ...(config.hasConnectors ? CONNECTOR_DEFINITIONS : []), + ] + .map(({ iconPath, keywords, name, serviceType }) => { + let score = 0; + const searchTerm = (term || '').toLowerCase(); + const searchName = name.toLowerCase(); + if (!searchTerm) { + score = 80; + } else if (searchName === searchTerm) { + score = 100; + } else if (searchName.startsWith(searchTerm)) { + score = 90; + } else if (searchName.includes(searchTerm)) { + score = 75; + } else if (serviceType === searchTerm) { + score = 65; + } else if (keywords.some((keyword) => keyword.includes(searchTerm))) { + score = 50; + } + return toSearchResult({ basePath, iconPath, name, score, serviceType }); + }) + .filter(({ score }) => score > 0) + .slice(0, maxResults); + return from([result]).pipe(takeUntil(aborted$)); + }, + getSearchableTypes: () => ['enterprise search', 'integration'], + id: 'enterpriseSearch', + }; +} diff --git a/x-pack/plugins/enterprise_search/tsconfig.json b/x-pack/plugins/enterprise_search/tsconfig.json index 4ba8f2b48d814..3bd55202cfe52 100644 --- a/x-pack/plugins/enterprise_search/tsconfig.json +++ b/x-pack/plugins/enterprise_search/tsconfig.json @@ -59,5 +59,6 @@ "@kbn/field-types", "@kbn/core-elasticsearch-server-mocks", "@kbn/shared-ux-link-redirect-app", + "@kbn/global-search-plugin", ] } From 84a8957d6ea136fc018dd082176f71033e876c94 Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Tue, 25 Apr 2023 16:08:17 +0200 Subject: [PATCH 20/29] [Cases] Description markdown redesign (#155151) ## Summary This PR redesigns the description markdown editor on the case view page. https://user-images.githubusercontent.com/117571355/233022102-d9540765-7961-4bd7-9758-bf32194ca6b0.mov ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [x] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### For maintainers - [x] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../case_view/case_view_page.test.tsx | 17 +- .../components/case_view_activity.test.tsx | 27 +- .../components/case_view_activity.tsx | 7 +- .../components/case_view/translations.ts | 8 + .../description/description_wrapper.test.tsx | 88 ------ .../description/description_wrapper.tsx | 106 ------- .../components/description/index.test.tsx | 155 ++++++++++ .../public/components/description/index.tsx | 203 +++++++++++++ .../public/components/description/schema.ts | 26 ++ .../editable_markdown_footer.tsx} | 12 +- .../editable_markdown_renderer.test.tsx | 220 ++++++++++++++ .../editable_markdown_renderer.tsx | 105 +++++++ .../components/markdown_editor/index.tsx | 2 + .../scrollable_markdown_renderer.test.tsx | 29 ++ .../scrollable_markdown_renderer.tsx | 31 ++ .../user_actions/comment/actions.tsx | 7 +- .../user_actions/description.test.tsx | 46 +-- .../components/user_actions/description.tsx | 100 +------ .../components/user_actions/index.test.tsx | 12 +- .../user_actions/markdown_form.test.tsx | 274 ++---------------- .../components/user_actions/markdown_form.tsx | 91 +----- .../cypress/e2e/cases/creation.cy.ts | 4 +- .../cypress/screens/case_details.ts | 11 +- .../services/cases/single_case_view.ts | 2 +- .../apps/cases/group1/create_case_form.ts | 2 +- .../apps/cases/group1/view_case.ts | 16 +- .../apps/cases/group2/upgrade.ts | 10 +- 27 files changed, 892 insertions(+), 719 deletions(-) delete mode 100644 x-pack/plugins/cases/public/components/description/description_wrapper.test.tsx delete mode 100644 x-pack/plugins/cases/public/components/description/description_wrapper.tsx create mode 100644 x-pack/plugins/cases/public/components/description/index.test.tsx create mode 100644 x-pack/plugins/cases/public/components/description/index.tsx create mode 100644 x-pack/plugins/cases/public/components/description/schema.ts rename x-pack/plugins/cases/public/components/{user_actions/markdown_form_footer.tsx => markdown_editor/editable_markdown_footer.tsx} (76%) create mode 100644 x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_renderer.test.tsx create mode 100644 x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_renderer.tsx create mode 100644 x-pack/plugins/cases/public/components/markdown_editor/scrollable_markdown_renderer.test.tsx create mode 100644 x-pack/plugins/cases/public/components/markdown_editor/scrollable_markdown_renderer.tsx diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx index f247945c7c700..d33cf6efd4fbf 100644 --- a/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx @@ -189,7 +189,7 @@ describe('CaseViewPage', () => { expect(result.getAllByText(data.createdBy.fullName!)[0]).toBeInTheDocument(); expect( - within(result.getByTestId('description-action')).getByTestId('user-action-markdown') + within(result.getByTestId('description')).getByTestId('scrollable-markdown') ).toHaveTextContent(data.description); expect(result.getByTestId('case-view-status-action-button')).toHaveTextContent( @@ -604,15 +604,15 @@ describe('CaseViewPage', () => { }); describe('description', () => { - it('renders the descriptions user correctly', async () => { + it('renders the description correctly', async () => { appMockRenderer.render(); - const description = within(screen.getByTestId('description-action')); + const description = within(screen.getByTestId('description')); - expect(await description.findByText('Leslie Knope')).toBeInTheDocument(); + expect(await description.findByText(caseData.description)).toBeInTheDocument(); }); - it('should display description isLoading', async () => { + it('should display description when case is loading', async () => { useUpdateCaseMock.mockImplementation(() => ({ ...defaultUpdateCaseState, isLoading: true, @@ -622,8 +622,7 @@ describe('CaseViewPage', () => { appMockRenderer.render(); await waitFor(() => { - expect(screen.getByTestId('description-loading')).toBeInTheDocument(); - expect(screen.queryByTestId('description-action')).not.toBeInTheDocument(); + expect(screen.getByTestId('description')).toBeInTheDocument(); }); }); @@ -636,11 +635,11 @@ describe('CaseViewPage', () => { userEvent.type(await screen.findByTestId('euiMarkdownEditorTextArea'), newComment); - userEvent.click(await screen.findByTestId('editable-description-edit-icon')); + userEvent.click(await screen.findByTestId('description-edit-icon')); userEvent.type(screen.getAllByTestId('euiMarkdownEditorTextArea')[0], 'Edited!'); - userEvent.click(screen.getByTestId('user-action-save-markdown')); + userEvent.click(screen.getByTestId('editable-save-markdown')); expect(await screen.findByTestId('euiMarkdownEditorTextArea')).toHaveTextContent( newComment diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.test.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.test.tsx index aa28340c55308..7386d8bcd7ea1 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.test.tsx @@ -493,17 +493,38 @@ describe.skip('Case View Page activity tab', () => { }); describe('User actions', () => { - it('renders the descriptions user correctly', async () => { + it('renders the description correctly', async () => { appMockRender = createAppMockRenderer(); const result = appMockRender.render(); - const description = within(result.getByTestId('description-action')); + const description = within(result.getByTestId('description')); await waitFor(() => { - expect(description.getByText('Leslie Knope')).toBeInTheDocument(); + expect(description.getByText(caseData.description)).toBeInTheDocument(); }); }); + it('renders edit description user action correctly', async () => { + useFindCaseUserActionsMock.mockReturnValue({ + ...defaultUseFindCaseUserActions, + data: { + userActions: [ + getUserAction('description', 'create'), + getUserAction('description', 'update'), + ], + }, + }); + + appMockRender = createAppMockRenderer(); + const result = appMockRender.render(); + + const userActions = within(result.getAllByTestId('user-actions-list')[1]); + + expect( + userActions.getByTestId('description-update-action-description-update') + ).toBeInTheDocument(); + }); + it('renders the unassigned users correctly', async () => { useFindCaseUserActionsMock.mockReturnValue({ ...defaultUseFindCaseUserActions, diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx index b178fe37bb450..c4f387c617417 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.tsx @@ -37,7 +37,7 @@ import { convertToCaseUserWithProfileInfo } from '../../user_profiles/user_conve import type { UserActivityParams } from '../../user_actions_activity_bar/types'; import { CASE_VIEW_PAGE_TABS } from '../../../../common/types'; import { CaseViewTabs } from '../case_view_tabs'; -import { DescriptionWrapper } from '../../description/description_wrapper'; +import { Description } from '../../description'; const buildUserProfilesMap = (users?: CaseUsers): Map => { const userProfiles = new Map(); @@ -210,10 +210,9 @@ export const CaseViewActivity = ({ <> - diff --git a/x-pack/plugins/cases/public/components/case_view/translations.ts b/x-pack/plugins/cases/public/components/case_view/translations.ts index 8fc80c1a0aba3..d0be88f729a69 100644 --- a/x-pack/plugins/cases/public/components/case_view/translations.ts +++ b/x-pack/plugins/cases/public/components/case_view/translations.ts @@ -76,6 +76,14 @@ export const EDIT_DESCRIPTION = i18n.translate('xpack.cases.caseView.edit.descri defaultMessage: 'Edit description', }); +export const COLLAPSE_DESCRIPTION = i18n.translate('xpack.cases.caseView.description.collapse', { + defaultMessage: 'Collapse description', +}); + +export const EXPAND_DESCRIPTION = i18n.translate('xpack.cases.caseView.description.expand', { + defaultMessage: 'Expand description', +}); + export const QUOTE = i18n.translate('xpack.cases.caseView.edit.quote', { defaultMessage: 'Quote', }); diff --git a/x-pack/plugins/cases/public/components/description/description_wrapper.test.tsx b/x-pack/plugins/cases/public/components/description/description_wrapper.test.tsx deleted file mode 100644 index 40fcf91ffe1fa..0000000000000 --- a/x-pack/plugins/cases/public/components/description/description_wrapper.test.tsx +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { waitFor, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -// eslint-disable-next-line @kbn/eslint/module_migration -import routeData from 'react-router'; - -import { useUpdateComment } from '../../containers/use_update_comment'; -import { basicCase } from '../../containers/mock'; -import type { AppMockRenderer } from '../../common/mock'; -import { createAppMockRenderer } from '../../common/mock'; -import { DescriptionWrapper } from './description_wrapper'; -import { waitForComponentToUpdate } from '../../common/test_utils'; - -const onUpdateField = jest.fn(); - -const defaultProps = { - data: basicCase, - onUpdateField, - isLoadingDescription: false, - userProfiles: new Map(), -}; - -jest.mock('../../containers/use_update_comment'); -jest.mock('../../common/lib/kibana'); - -const useUpdateCommentMock = useUpdateComment as jest.Mock; -const patchComment = jest.fn(); - -// FLAKY: -describe.skip(`DescriptionWrapper`, () => { - const sampleData = { - content: 'what a great comment update', - }; - let appMockRender: AppMockRenderer; - - beforeEach(() => { - jest.clearAllMocks(); - useUpdateCommentMock.mockReturnValue({ - isLoadingIds: [], - patchComment, - }); - - jest.spyOn(routeData, 'useParams').mockReturnValue({ detailName: 'case-id' }); - appMockRender = createAppMockRenderer(); - }); - - it('renders correctly', () => { - appMockRender.render(); - - expect(screen.getByTestId('description-action')).toBeInTheDocument(); - }); - - it('renders loading state', () => { - appMockRender.render(); - - expect(screen.getByTestId('description-loading')).toBeInTheDocument(); - }); - - it('calls update description when description markdown is saved', async () => { - const newData = { - content: 'what a great comment update', - }; - - appMockRender.render(); - - userEvent.click(screen.getByTestId('editable-description-edit-icon')); - - userEvent.clear(screen.getAllByTestId('euiMarkdownEditorTextArea')[0]); - - userEvent.type(screen.getAllByTestId('euiMarkdownEditorTextArea')[0], newData.content); - - userEvent.click(screen.getByTestId('user-action-save-markdown')); - - await waitForComponentToUpdate(); - - await waitFor(() => { - expect(screen.queryByTestId('user-action-markdown-form')).not.toBeInTheDocument(); - expect(onUpdateField).toBeCalledWith({ key: 'description', value: sampleData.content }); - }); - }); -}); diff --git a/x-pack/plugins/cases/public/components/description/description_wrapper.tsx b/x-pack/plugins/cases/public/components/description/description_wrapper.tsx deleted file mode 100644 index 77b29abfa0c0d..0000000000000 --- a/x-pack/plugins/cases/public/components/description/description_wrapper.tsx +++ /dev/null @@ -1,106 +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 type { EuiCommentProps } from '@elastic/eui'; -import type { UserProfileWithAvatar } from '@kbn/user-profile-components'; -import { EuiCommentList, EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; - -import React, { useMemo } from 'react'; -import styled from 'styled-components'; - -import type { Case } from '../../containers/types'; -import type { OnUpdateFields } from '../case_view/types'; -import { getDescriptionUserAction } from '../user_actions/description'; -import { useUserActionsHandler } from '../user_actions/use_user_actions_handler'; -import { useCasesContext } from '../cases_context/use_cases_context'; - -interface DescriptionWrapperProps { - data: Case; - isLoadingDescription: boolean; - userProfiles: Map; - onUpdateField: ({ key, value, onSuccess, onError }: OnUpdateFields) => void; -} - -const MyEuiCommentList = styled(EuiCommentList)` - & .euiComment > [class*='euiTimelineItemIcon-top'] { - display: none; - } - - & .draftFooter { - & .euiCommentEvent__body { - padding: 0; - } - } - - & .euiComment.isEdit { - & .euiCommentEvent { - border: none; - box-shadow: none; - } - - & .euiCommentEvent__body { - padding: 0; - } - - & .euiCommentEvent__header { - display: none; - } - } -`; - -export const DescriptionWrapper = React.memo( - ({ - data: caseData, - isLoadingDescription, - userProfiles, - onUpdateField, - }: DescriptionWrapperProps) => { - const { appId } = useCasesContext(); - - const { commentRefs, manageMarkdownEditIds, handleManageMarkdownEditId } = - useUserActionsHandler(); - - const descriptionCommentListObj: EuiCommentProps = useMemo( - () => - getDescriptionUserAction({ - appId, - caseData, - commentRefs, - userProfiles, - manageMarkdownEditIds, - isLoadingDescription, - onUpdateField, - handleManageMarkdownEditId, - }), - [ - appId, - caseData, - commentRefs, - manageMarkdownEditIds, - isLoadingDescription, - userProfiles, - onUpdateField, - handleManageMarkdownEditId, - ] - ); - - return isLoadingDescription ? ( - - - - - - ) : ( - - ); - } -); - -DescriptionWrapper.displayName = 'DescriptionWrapper'; diff --git a/x-pack/plugins/cases/public/components/description/index.test.tsx b/x-pack/plugins/cases/public/components/description/index.test.tsx new file mode 100644 index 0000000000000..bf92733b4526a --- /dev/null +++ b/x-pack/plugins/cases/public/components/description/index.test.tsx @@ -0,0 +1,155 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { basicCase } from '../../containers/mock'; + +import { Description } from '.'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer, noUpdateCasesPermissions, TestProviders } from '../../common/mock'; + +jest.mock('../../common/lib/kibana'); +jest.mock('../../common/navigation/hooks'); + +const defaultProps = { + appId: 'testAppId', + caseData: { + ...basicCase, + }, + isLoadingDescription: false, +}; + +describe('Description', () => { + const onUpdateField = jest.fn(); + let appMockRender: AppMockRenderer; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders description correctly', async () => { + appMockRender.render(); + + expect(screen.getByTestId('description')).toBeInTheDocument(); + expect(screen.getByText('Security banana Issue')).toBeInTheDocument(); + }); + + it('hides and shows the description correctly when collapse button clicked', async () => { + const res = appMockRender.render( + + ); + + userEvent.click(res.getByTestId('description-collapse-icon')); + + await waitFor(() => { + expect(screen.queryByText('Security banana Issue')).not.toBeInTheDocument(); + }); + + userEvent.click(res.getByTestId('description-collapse-icon')); + + await waitFor(() => { + expect(screen.getByText('Security banana Issue')).toBeInTheDocument(); + }); + }); + + it('shows textarea on edit click', async () => { + const res = appMockRender.render( + + ); + + userEvent.click(res.getByTestId('description-edit-icon')); + + await waitFor(() => { + expect(screen.getByTestId('euiMarkdownEditorTextArea')).toBeInTheDocument(); + }); + }); + + it('edits the description correctly when saved', async () => { + const editedDescription = 'New updated description'; + const res = appMockRender.render( + + ); + + userEvent.click(res.getByTestId('description-edit-icon')); + + userEvent.clear(screen.getByTestId('euiMarkdownEditorTextArea')); + userEvent.type(screen.getByTestId('euiMarkdownEditorTextArea'), editedDescription); + + userEvent.click(screen.getByTestId('editable-save-markdown')); + + await waitFor(() => { + expect(onUpdateField).toHaveBeenCalledWith({ key: 'description', value: editedDescription }); + }); + }); + + it('keeps the old description correctly when canceled', async () => { + const editedDescription = 'New updated description'; + const res = appMockRender.render( + + ); + + userEvent.click(res.getByTestId('description-edit-icon')); + + userEvent.clear(screen.getByTestId('euiMarkdownEditorTextArea')); + userEvent.type(screen.getByTestId('euiMarkdownEditorTextArea'), editedDescription); + + userEvent.click(screen.getByTestId('editable-cancel-markdown')); + + await waitFor(() => { + expect(onUpdateField).not.toHaveBeenCalled(); + expect(screen.getByText('Security banana Issue')).toBeInTheDocument(); + }); + }); + + it('should hide the edit button when the user does not have update permissions', () => { + appMockRender.render( + + + + ); + + expect(screen.getByText('Security banana Issue')).toBeInTheDocument(); + expect(screen.queryByTestId('description-edit-icon')).not.toBeInTheDocument(); + }); + + describe('draft message', () => { + const draftStorageKey = `cases.testAppId.basic-case-id.description.markdownEditor`; + + beforeEach(() => { + sessionStorage.setItem(draftStorageKey, 'value set in storage'); + }); + + it('should show unsaved draft message correctly', async () => { + appMockRender.render(); + + expect(screen.getByTestId('description-unsaved-draft')).toBeInTheDocument(); + }); + + it('should not show unsaved draft message when loading', async () => { + appMockRender.render( + + ); + + expect(screen.queryByTestId('description-unsaved-draft')).not.toBeInTheDocument(); + }); + + it('should not show unsaved draft message when description and storage value are same', async () => { + const props = { + ...defaultProps, + caseData: { ...defaultProps.caseData, description: 'value set in storage' }, + }; + + appMockRender.render(); + + expect(screen.queryByTestId('description-unsaved-draft')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/description/index.tsx b/x-pack/plugins/cases/public/components/description/index.tsx new file mode 100644 index 0000000000000..6ebbf8edd6f45 --- /dev/null +++ b/x-pack/plugins/cases/public/components/description/index.tsx @@ -0,0 +1,203 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useRef, useState } from 'react'; +import styled from 'styled-components'; +import { css } from '@emotion/react'; +import { + EuiButtonIcon, + EuiPanel, + EuiFlexGroup, + EuiFlexItem, + EuiText, + useEuiTheme, +} from '@elastic/eui'; + +import { getMarkdownEditorStorageKey } from '../markdown_editor/utils'; +import * as i18n from '../user_actions/translations'; +import { useCasesContext } from '../cases_context/use_cases_context'; +import { useLensDraftComment } from '../markdown_editor/plugins/lens/use_lens_draft_comment'; +import { EditableMarkdown, ScrollableMarkdown } from '../markdown_editor'; +import type { Case } from '../../containers/types'; +import type { OnUpdateFields } from '../case_view/types'; +import { schema } from './schema'; + +const DESCRIPTION_ID = 'description'; +export interface DescriptionProps { + caseData: Case; + isLoadingDescription: boolean; + onUpdateField: ({ key, value, onSuccess, onError }: OnUpdateFields) => void; +} + +const DescriptionFooter = styled(EuiFlexItem)` + ${({ theme }) => ` + border-top: ${theme.eui.euiBorderThin}; + padding: ${theme.eui.euiSizeS}; + `} +`; + +const Panel = styled(EuiPanel)` + padding: 0; +`; + +const Header = styled(EuiFlexGroup)` + ${({ theme }) => ` + display: flex; + padding: ${theme.eui.euiSizeS}; + align-items: center; + `} +`; + +const Body = styled(EuiFlexItem)` + ${({ theme }) => ` + padding: ${theme.eui.euiSize}; + padding-top: 0; + + > div { + padding: 0; + } + `} +`; + +const getDraftDescription = ( + applicationId = '', + caseId: string, + commentId: string +): string | null => { + const draftStorageKey = getMarkdownEditorStorageKey(applicationId, caseId, commentId); + + return sessionStorage.getItem(draftStorageKey); +}; + +export const Description = ({ + caseData, + onUpdateField, + isLoadingDescription, +}: DescriptionProps) => { + const [isCollapsed, setIsCollapsed] = useState(false); + const [isEditable, setIsEditable] = useState(false); + + const descriptionRef = useRef(null); + const { euiTheme } = useEuiTheme(); + const { appId, permissions } = useCasesContext(); + + const { + clearDraftComment: clearLensDraftComment, + draftComment: lensDraftComment, + hasIncomingLensState, + } = useLensDraftComment(); + + const handleOnChangeEditable = useCallback(() => { + clearLensDraftComment(); + setIsEditable(false); + }, [setIsEditable, clearLensDraftComment]); + + const handleOnSave = useCallback( + (content: string) => { + onUpdateField({ key: DESCRIPTION_ID, value: content }); + setIsEditable(false); + }, + [onUpdateField, setIsEditable] + ); + + const toggleCollapse = () => setIsCollapsed((oldValue: boolean) => !oldValue); + + const draftDescription = getDraftDescription(appId, caseData.id, DESCRIPTION_ID); + + if ( + hasIncomingLensState && + lensDraftComment !== null && + lensDraftComment?.commentId === DESCRIPTION_ID && + !isEditable + ) { + setIsEditable(true); + } + + const hasUnsavedChanges = + draftDescription && draftDescription !== caseData.description && !isLoadingDescription; + + return isEditable ? ( + + ) : ( + + + +
+ + + {i18n.DESCRIPTION} + + + + + {permissions.update ? ( + setIsEditable(true)} + data-test-subj="description-edit-icon" + /> + ) : null} + + + + + +
+
+ {!isCollapsed ? ( + + + + ) : null} + {hasUnsavedChanges ? ( + + + {i18n.UNSAVED_DRAFT_DESCRIPTION} + + + ) : null} +
+
+ ); +}; + +Description.displayName = 'Description'; diff --git a/x-pack/plugins/cases/public/components/description/schema.ts b/x-pack/plugins/cases/public/components/description/schema.ts new file mode 100644 index 0000000000000..8c47b700adeb5 --- /dev/null +++ b/x-pack/plugins/cases/public/components/description/schema.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FormSchema } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { FIELD_TYPES } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; +import * as i18n from '../../common/translations'; + +const { emptyField } = fieldValidators; +export interface Content { + content: string; +} +export const schema: FormSchema = { + content: { + type: FIELD_TYPES.TEXTAREA, + validations: [ + { + validator: emptyField(i18n.REQUIRED_FIELD), + }, + ], + }, +}; diff --git a/x-pack/plugins/cases/public/components/user_actions/markdown_form_footer.tsx b/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_footer.tsx similarity index 76% rename from x-pack/plugins/cases/public/components/user_actions/markdown_form_footer.tsx rename to x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_footer.tsx index d2c5f692d396b..86772136c18d0 100644 --- a/x-pack/plugins/cases/public/components/user_actions/markdown_form_footer.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_footer.tsx @@ -12,12 +12,12 @@ import { useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib import * as i18n from '../case_view/translations'; -interface UserActionMarkdownFooterProps { +interface EditableMarkdownFooterProps { handleSaveAction: () => Promise; handleCancelAction: () => void; } -const UserActionMarkdownFooterComponent: React.FC = ({ +const EditableMarkdownFooterComponent: React.FC = ({ handleSaveAction, handleCancelAction, }) => { @@ -27,7 +27,7 @@ const UserActionMarkdownFooterComponent: React.FC ); }; -UserActionMarkdownFooterComponent.displayName = 'UserActionMarkdownFooterComponent'; +EditableMarkdownFooterComponent.displayName = 'EditableMarkdownFooterComponent'; -export const UserActionMarkdownFooter = React.memo(UserActionMarkdownFooterComponent); +export const EditableMarkdownFooter = React.memo(EditableMarkdownFooterComponent); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_renderer.test.tsx b/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_renderer.test.tsx new file mode 100644 index 0000000000000..59d424fdd82c8 --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_renderer.test.tsx @@ -0,0 +1,220 @@ +/* + * 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 { useForm, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { waitFor, fireEvent, screen, render, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { EditableMarkdown } from '.'; +import { TestProviders } from '../../common/mock'; +import type { Content } from '../user_actions/schema'; +import { schema } from '../user_actions/schema'; + +jest.mock('../../common/lib/kibana'); + +const onChangeEditable = jest.fn(); +const onSaveContent = jest.fn(); + +const newValue = 'Hello from Tehas'; +const emptyValue = ''; +const hyperlink = `[hyperlink](http://elastic.co)`; +const draftStorageKey = `cases.testAppId.caseId.markdown-id.markdownEditor`; +const content = `A link to a timeline ${hyperlink}`; + +const editorRef: React.MutableRefObject = { current: null }; +const defaultProps = { + content, + id: 'markdown-id', + caseId: 'caseId', + isEditable: true, + draftStorageKey, + onChangeEditable, + onSaveContent, + fieldName: 'content', + formSchema: schema, + editorRef, +}; + +describe('EditableMarkdown', () => { + const MockHookWrapperComponent: React.FC<{ testProviderProps?: unknown }> = ({ + children, + testProviderProps = {}, + }) => { + const { form } = useForm({ + defaultValue: { content }, + options: { stripEmptyFields: false }, + schema, + }); + + return ( + + {children} + + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + sessionStorage.removeItem(draftStorageKey); + }); + + it('Save button click calls onSaveContent and onChangeEditable when text area value changed', async () => { + render( + + + + ); + + fireEvent.change(screen.getByTestId('euiMarkdownEditorTextArea'), { + target: { value: newValue }, + }); + + userEvent.click(screen.getByTestId('editable-save-markdown')); + + await waitFor(() => { + expect(onSaveContent).toHaveBeenCalledWith(newValue); + expect(onChangeEditable).toHaveBeenCalledWith(defaultProps.id); + }); + }); + + it('Does not call onSaveContent if no change from current text', async () => { + render( + + + + ); + + userEvent.click(screen.getByTestId('editable-save-markdown')); + + await waitFor(() => { + expect(onChangeEditable).toHaveBeenCalledWith(defaultProps.id); + }); + expect(onSaveContent).not.toHaveBeenCalled(); + }); + + it('Save button disabled if current text is empty', async () => { + render( + + + + ); + + fireEvent.change(screen.getByTestId('euiMarkdownEditorTextArea'), { value: emptyValue }); + + await waitFor(() => { + expect(screen.getByTestId('editable-save-markdown')).toHaveProperty('disabled'); + }); + }); + + it('Cancel button click calls only onChangeEditable', async () => { + render( + + + + ); + + userEvent.click(screen.getByTestId('editable-cancel-markdown')); + + await waitFor(() => { + expect(onSaveContent).not.toHaveBeenCalled(); + expect(onChangeEditable).toHaveBeenCalledWith(defaultProps.id); + }); + }); + + describe('draft comment ', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + sessionStorage.removeItem(draftStorageKey); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('Save button click clears session storage', async () => { + const result = render( + + + + ); + + fireEvent.change(result.getByTestId('euiMarkdownEditorTextArea'), { + target: { value: newValue }, + }); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + expect(sessionStorage.getItem(draftStorageKey)).toBe(newValue); + + fireEvent.click(result.getByTestId(`editable-save-markdown`)); + + await waitFor(() => { + expect(onSaveContent).toHaveBeenCalledWith(newValue); + expect(onChangeEditable).toHaveBeenCalledWith(defaultProps.id); + expect(sessionStorage.getItem(draftStorageKey)).toBe(null); + }); + }); + + it('Cancel button click clears session storage', async () => { + const result = render( + + + + ); + + expect(sessionStorage.getItem(draftStorageKey)).toBe(''); + + fireEvent.change(result.getByTestId('euiMarkdownEditorTextArea'), { + target: { value: newValue }, + }); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + await waitFor(() => { + expect(sessionStorage.getItem(draftStorageKey)).toBe(newValue); + }); + + fireEvent.click(result.getByTestId('editable-cancel-markdown')); + + await waitFor(() => { + expect(sessionStorage.getItem(draftStorageKey)).toBe(null); + }); + }); + + describe('existing storage key', () => { + beforeEach(() => { + sessionStorage.setItem(draftStorageKey, 'value set in storage'); + }); + + it('should have session storage value same as draft comment', async () => { + const result = render( + + + + ); + + expect(result.getByText('value set in storage')).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_renderer.tsx b/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_renderer.tsx new file mode 100644 index 0000000000000..1706e30fcc8f4 --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/editable_markdown_renderer.tsx @@ -0,0 +1,105 @@ +/* + * 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, { forwardRef, useCallback, useImperativeHandle } from 'react'; + +import type { FormSchema } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { Form, UseField, useForm } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { MarkdownEditorForm } from '.'; +import { removeItemFromSessionStorage } from '../utils'; +import { useCasesContext } from '../cases_context/use_cases_context'; +import { getMarkdownEditorStorageKey } from './utils'; +import { EditableMarkdownFooter } from './editable_markdown_footer'; + +export interface EditableMarkdownRefObject { + setComment: (newComment: string) => void; +} +interface EditableMarkdownRendererProps { + content: string; + id: string; + caseId: string; + fieldName: string; + onChangeEditable: (id: string) => void; + onSaveContent: (content: string) => void; + editorRef: React.MutableRefObject; + formSchema: FormSchema<{ content: string }> | undefined; +} + +const EditableMarkDownRenderer = forwardRef< + EditableMarkdownRefObject, + EditableMarkdownRendererProps +>( + ( + { id, content, caseId, fieldName, onChangeEditable, onSaveContent, editorRef, formSchema }, + ref + ) => { + const { appId } = useCasesContext(); + const draftStorageKey = getMarkdownEditorStorageKey(appId, caseId, id); + const initialState = { content }; + + const { form } = useForm({ + defaultValue: initialState, + options: { stripEmptyFields: false }, + schema: formSchema, + }); + const { submit, setFieldValue } = form; + + const setComment = useCallback( + (newComment) => { + setFieldValue(fieldName, newComment); + }, + [setFieldValue, fieldName] + ); + + useImperativeHandle(ref, () => ({ + setComment, + editor: editorRef.current, + })); + + const handleCancelAction = useCallback(() => { + onChangeEditable(id); + removeItemFromSessionStorage(draftStorageKey); + }, [id, onChangeEditable, draftStorageKey]); + + const handleSaveAction = useCallback(async () => { + const { isValid, data } = await submit(); + + if (isValid && data.content !== content) { + onSaveContent(data.content); + } + onChangeEditable(id); + removeItemFromSessionStorage(draftStorageKey); + }, [content, id, onChangeEditable, onSaveContent, submit, draftStorageKey]); + + return ( +
+ + ), + initialValue: content, + }} + /> + + ); + } +); + +EditableMarkDownRenderer.displayName = 'EditableMarkDownRenderer'; + +export const EditableMarkdown = React.memo(EditableMarkDownRenderer); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/index.tsx b/x-pack/plugins/cases/public/components/markdown_editor/index.tsx index e77a36d48f7d9..214779263f773 100644 --- a/x-pack/plugins/cases/public/components/markdown_editor/index.tsx +++ b/x-pack/plugins/cases/public/components/markdown_editor/index.tsx @@ -9,3 +9,5 @@ export * from './types'; export * from './renderer'; export * from './editor'; export * from './eui_form'; +export * from './scrollable_markdown_renderer'; +export * from './editable_markdown_renderer'; diff --git a/x-pack/plugins/cases/public/components/markdown_editor/scrollable_markdown_renderer.test.tsx b/x-pack/plugins/cases/public/components/markdown_editor/scrollable_markdown_renderer.test.tsx new file mode 100644 index 0000000000000..05ea034e776a3 --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/scrollable_markdown_renderer.test.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen } from '@testing-library/react'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; + +import { ScrollableMarkdown } from '.'; + +const content = 'This is sample content'; + +describe('ScrollableMarkdown', () => { + let appMockRenderer: AppMockRenderer; + beforeEach(() => { + jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); + }); + + it('renders correctly', () => { + appMockRenderer.render(); + expect(screen.getByTestId('scrollable-markdown')).toBeInTheDocument(); + expect(screen.getByText(content)).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/markdown_editor/scrollable_markdown_renderer.tsx b/x-pack/plugins/cases/public/components/markdown_editor/scrollable_markdown_renderer.tsx new file mode 100644 index 0000000000000..dd5ab2ec8241a --- /dev/null +++ b/x-pack/plugins/cases/public/components/markdown_editor/scrollable_markdown_renderer.tsx @@ -0,0 +1,31 @@ +/* + * 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 styled from 'styled-components'; + +import { MarkdownRenderer } from './renderer'; + +export const ContentWrapper = styled.div` + padding: ${({ theme }) => `${theme.eui.euiSizeM} ${theme.eui.euiSizeL}`}; + text-overflow: ellipsis; + word-break: break-word; + display: -webkit-box; + -webkit-box-orient: vertical; +`; + +const ScrollableMarkdownRenderer = ({ content }: { content: string }) => { + return ( + + {content} + + ); +}; + +ScrollableMarkdownRenderer.displayName = 'ScrollableMarkdownRenderer'; + +export const ScrollableMarkdown = React.memo(ScrollableMarkdownRenderer); diff --git a/x-pack/plugins/cases/public/components/user_actions/comment/actions.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/actions.tsx index dccf5ae0b91a2..d4086805d7cd9 100644 --- a/x-pack/plugins/cases/public/components/user_actions/comment/actions.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/comment/actions.tsx @@ -13,8 +13,7 @@ import type { UserActionBuilder, UserActionBuilderArgs } from '../types'; import { UserActionTimestamp } from '../timestamp'; import type { SnakeToCamelCase } from '../../../../common/types'; import { UserActionCopyLink } from '../copy_link'; -import { MarkdownRenderer } from '../../markdown_editor'; -import { ContentWrapper } from '../markdown_form'; +import { ScrollableMarkdown } from '../../markdown_editor'; import { HostIsolationCommentEvent } from './host_isolation_event'; import { HoverableUserWithAvatarResolver } from '../../user_profiles/hoverable_user_with_avatar_resolver'; @@ -57,9 +56,7 @@ export const createActionAttachmentUserActionBuilder = ({ timelineAvatarAriaLabel: actionIconName, actions: , children: comment.comment.trim().length > 0 && ( - - {comment.comment} - + ), }, ]; diff --git a/x-pack/plugins/cases/public/components/user_actions/description.test.tsx b/x-pack/plugins/cases/public/components/user_actions/description.test.tsx index 0f0d284130519..d7f393f987b92 100644 --- a/x-pack/plugins/cases/public/components/user_actions/description.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/description.test.tsx @@ -7,65 +7,27 @@ import React from 'react'; import { EuiCommentList } from '@elastic/eui'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { Actions } from '../../../common/api'; import { getUserAction } from '../../containers/mock'; import { TestProviders } from '../../common/mock'; -import { createDescriptionUserActionBuilder, getDescriptionUserAction } from './description'; +import { createDescriptionUserActionBuilder } from './description'; import { getMockBuilderArgs } from './mock'; -import userEvent from '@testing-library/user-event'; jest.mock('../../common/lib/kibana'); jest.mock('../../common/navigation/hooks'); describe('createDescriptionUserActionBuilder ', () => { - const onUpdateField = jest.fn(); const builderArgs = getMockBuilderArgs(); beforeEach(() => { jest.clearAllMocks(); }); - it('renders correctly description', async () => { - const descriptionUserAction = getDescriptionUserAction({ - ...builderArgs, - onUpdateField, - isLoadingDescription: false, - }); - - render( - - - - ); - - expect(screen.getByText('added description')).toBeInTheDocument(); - expect(screen.getByText('Security banana Issue')).toBeInTheDocument(); - }); - - it('edits the description correctly', async () => { - const descriptionUserAction = getDescriptionUserAction({ - ...builderArgs, - onUpdateField, - isLoadingDescription: false, - }); - - const res = render( - - - - ); - - userEvent.click(res.getByTestId('editable-description-edit-icon')); - - await waitFor(() => { - expect(builderArgs.handleManageMarkdownEditId).toHaveBeenCalledWith('description'); - }); - }); - - it('renders correctly when editing a description', async () => { + it('renders correctly', async () => { const userAction = getUserAction('description', Actions.update); + // @ts-ignore no need to pass all the arguments const builder = createDescriptionUserActionBuilder({ ...builderArgs, userAction, diff --git a/x-pack/plugins/cases/public/components/user_actions/description.tsx b/x-pack/plugins/cases/public/components/user_actions/description.tsx index 236252927ad6e..42deabb9f1346 100644 --- a/x-pack/plugins/cases/public/components/user_actions/description.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/description.tsx @@ -5,108 +5,12 @@ * 2.0. */ -import React from 'react'; -import classNames from 'classnames'; -import type { EuiCommentProps } from '@elastic/eui'; -import styled from 'styled-components'; -import { EuiText, EuiButtonIcon } from '@elastic/eui'; - -import type { UserActionBuilder, UserActionBuilderArgs, UserActionTreeProps } from './types'; -import { createCommonUpdateUserActionBuilder } from './common'; -import { UserActionTimestamp } from './timestamp'; -import { UserActionMarkdown } from './markdown_form'; -import { getMarkdownEditorStorageKey } from '../markdown_editor/utils'; +import type { UserActionBuilder } from './types'; import * as i18n from './translations'; -import { HoverableUsernameResolver } from '../user_profiles/hoverable_username_resolver'; - -const DESCRIPTION_ID = 'description'; +import { createCommonUpdateUserActionBuilder } from './common'; const getLabelTitle = () => `${i18n.EDITED_FIELD} ${i18n.DESCRIPTION.toLowerCase()}`; -type GetDescriptionUserActionArgs = Pick< - UserActionBuilderArgs, - | 'caseData' - | 'commentRefs' - | 'userProfiles' - | 'manageMarkdownEditIds' - | 'handleManageMarkdownEditId' - | 'appId' -> & - Pick & { isLoadingDescription: boolean }; - -const MyEuiCommentFooter = styled(EuiText)` - ${({ theme }) => ` - border-top: ${theme.eui.euiBorderThin}; - padding: ${theme.eui.euiSizeS}; - `} -`; - -const hasDraftComment = (appId = '', caseId: string, commentId: string): boolean => { - const draftStorageKey = getMarkdownEditorStorageKey(appId, caseId, commentId); - - return Boolean(sessionStorage.getItem(draftStorageKey)); -}; - -export const getDescriptionUserAction = ({ - appId, - caseData, - commentRefs, - manageMarkdownEditIds, - isLoadingDescription, - userProfiles, - onUpdateField, - handleManageMarkdownEditId, -}: GetDescriptionUserActionArgs): EuiCommentProps => { - const isEditable = manageMarkdownEditIds.includes(DESCRIPTION_ID); - return { - username: , - event: i18n.ADDED_DESCRIPTION, - 'data-test-subj': 'description-action', - timestamp: , - children: ( - <> - (commentRefs.current[DESCRIPTION_ID] = element)} - caseId={caseData.id} - id={DESCRIPTION_ID} - content={caseData.description} - isEditable={isEditable} - onSaveContent={(content: string) => { - onUpdateField({ key: DESCRIPTION_ID, value: content }); - }} - onChangeEditable={handleManageMarkdownEditId} - /> - {!isEditable && - !isLoadingDescription && - hasDraftComment(appId, caseData.id, DESCRIPTION_ID) ? ( - - - {i18n.UNSAVED_DRAFT_DESCRIPTION} - - - ) : ( - '' - )} - - ), - timelineAvatar: null, - className: classNames({ - isEdit: manageMarkdownEditIds.includes(DESCRIPTION_ID), - draftFooter: - !isEditable && !isLoadingDescription && hasDraftComment(appId, caseData.id, DESCRIPTION_ID), - }), - actions: ( - handleManageMarkdownEditId(DESCRIPTION_ID)} - data-test-subj="editable-description-edit-icon" - /> - ), - }; -}; - export const createDescriptionUserActionBuilder: UserActionBuilder = ({ userAction, userProfiles, diff --git a/x-pack/plugins/cases/public/components/user_actions/index.test.tsx b/x-pack/plugins/cases/public/components/user_actions/index.test.tsx index 5008fc99c0f01..47f0bd6ab4939 100644 --- a/x-pack/plugins/cases/public/components/user_actions/index.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/index.test.tsx @@ -171,14 +171,14 @@ describe(`UserActions`, () => { userEvent.click( within( screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1] - ).getByTestId('user-action-cancel-markdown') + ).getByTestId('editable-cancel-markdown') ); await waitFor(() => { expect( within( screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1] - ).queryByTestId('user-action-markdown-form') + ).queryByTestId('editable-markdown-form') ).not.toBeInTheDocument(); }); }); @@ -212,14 +212,14 @@ describe(`UserActions`, () => { userEvent.click( within( screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1] - ).getByTestId('user-action-save-markdown') + ).getByTestId('editable-save-markdown') ); await waitFor(() => { expect( within( screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1] - ).queryByTestId('user-action-markdown-form') + ).queryByTestId('editable-markdown-form') ).not.toBeInTheDocument(); expect(patchComment).toBeCalledWith({ @@ -306,14 +306,14 @@ describe(`UserActions`, () => { userEvent.click( within( screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1] - ).getByTestId('user-action-save-markdown') + ).getByTestId('editable-save-markdown') ); await waitFor(() => { expect( within( screen.getAllByTestId(`comment-create-action-${defaultProps.data.comments[0].id}`)[1] - ).queryByTestId('user-action-markdown-form') + ).queryByTestId('editable-markdown-form') ).not.toBeInTheDocument(); }); diff --git a/x-pack/plugins/cases/public/components/user_actions/markdown_form.test.tsx b/x-pack/plugins/cases/public/components/user_actions/markdown_form.test.tsx index 972a520d2085c..a52d1bf221119 100644 --- a/x-pack/plugins/cases/public/components/user_actions/markdown_form.test.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/markdown_form.test.tsx @@ -6,20 +6,19 @@ */ import React from 'react'; -import { mount } from 'enzyme'; -import { useForm, Form } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import type { Content } from './schema'; -import { schema } from './schema'; -import { UserActionMarkdown } from './markdown_form'; -import type { AppMockRenderer } from '../../common/mock'; -import { createAppMockRenderer, TestProviders } from '../../common/mock'; -import { waitFor, fireEvent, render, act } from '@testing-library/react'; +import { waitFor, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { UserActionMarkdown } from './markdown_form'; + +jest.mock('../../common/lib/kibana'); +jest.mock('../../common/navigation/hooks'); + const onChangeEditable = jest.fn(); const onSaveContent = jest.fn(); -const newValue = 'Hello from Tehas'; -const emptyValue = ''; const hyperlink = `[hyperlink](http://elastic.co)`; const draftStorageKey = `cases.testAppId.caseId.markdown-id.markdownEditor`; const defaultProps = { @@ -33,8 +32,10 @@ const defaultProps = { }; describe('UserActionMarkdown ', () => { + let appMockRenderer: AppMockRenderer; beforeEach(() => { jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); }); afterEach(() => { @@ -42,147 +43,26 @@ describe('UserActionMarkdown ', () => { }); it('Renders markdown correctly when not in edit mode', async () => { - const wrapper = mount( - - - - ); + appMockRenderer.render(); - expect(wrapper.find(`[data-test-subj="markdown-link"]`).first().text()).toContain('hyperlink'); + expect(screen.getByTestId('scrollable-markdown')).toBeInTheDocument(); + expect(screen.getByTestId('markdown-link')).toBeInTheDocument(); + expect(screen.queryByTestId('editable-save-markdown')).not.toBeInTheDocument(); + expect(screen.queryByTestId('editable-cancel-markdown')).not.toBeInTheDocument(); }); - it('Save button click calls onSaveContent and onChangeEditable when text area value changed', async () => { - const wrapper = mount( - - - - ); + it('Renders markdown correctly when in edit mode', async () => { + appMockRenderer.render(); - wrapper - .find(`.euiMarkdownEditorTextArea`) - .first() - .simulate('change', { - target: { value: newValue }, - }); - - wrapper.find(`button[data-test-subj="user-action-save-markdown"]`).first().simulate('click'); - - await waitFor(() => { - expect(onSaveContent).toHaveBeenCalledWith(newValue); - expect(onChangeEditable).toHaveBeenCalledWith(defaultProps.id); - }); - }); - - it('Does not call onSaveContent if no change from current text', async () => { - const wrapper = mount( - - - - ); - - wrapper.find(`button[data-test-subj="user-action-save-markdown"]`).first().simulate('click'); - - await waitFor(() => { - expect(onChangeEditable).toHaveBeenCalledWith(defaultProps.id); - }); - expect(onSaveContent).not.toHaveBeenCalled(); - }); - - it('Save button disabled if current text is empty', async () => { - const wrapper = mount( - - - - ); - - wrapper - .find(`.euiMarkdownEditorTextArea`) - .first() - .simulate('change', { - target: { value: emptyValue }, - }); - - await waitFor(() => { - expect( - wrapper.find(`button[data-test-subj="user-action-save-markdown"]`).first().prop('disabled') - ).toBeTruthy(); - }); - }); - - it('Cancel button click calls only onChangeEditable', async () => { - const wrapper = mount( - - - - ); - wrapper.find(`[data-test-subj="user-action-cancel-markdown"]`).first().simulate('click'); - - await waitFor(() => { - expect(onSaveContent).not.toHaveBeenCalled(); - expect(onChangeEditable).toHaveBeenCalledWith(defaultProps.id); - }); + expect(screen.getByTestId('editable-save-markdown')).toBeInTheDocument(); + expect(screen.getByTestId('editable-cancel-markdown')).toBeInTheDocument(); }); describe('useForm stale state bug', () => { - let appMockRenderer: AppMockRenderer; const oldContent = defaultProps.content; const appendContent = ' appended content'; const newContent = defaultProps.content + appendContent; - beforeEach(() => { - appMockRenderer = createAppMockRenderer(); - }); - - it('creates a stale state if a key is not passed to the component', async () => { - const TestComponent = () => { - const [isEditable, setIsEditable] = React.useState(true); - const [saveContent, setSaveContent] = React.useState(defaultProps.content); - return ( -
- -
- ); - }; - - const result = appMockRenderer.render(); - - expect(result.getByTestId('user-action-markdown-form')).toBeTruthy(); - - // append some content and save - userEvent.type(result.container.querySelector('textarea')!, appendContent); - userEvent.click(result.getByTestId('user-action-save-markdown')); - - // wait for the state to update - await waitFor(() => { - expect(onChangeEditable).toHaveBeenCalledWith(defaultProps.id); - }); - - // toggle to non-edit state - userEvent.click(result.getByTestId('test-button')); - expect(result.getByTestId('user-action-markdown')).toBeTruthy(); - - // toggle to edit state again - userEvent.click(result.getByTestId('test-button')); - - // the text area holds a stale value - // this is the wrong behaviour. The textarea holds the old content - expect(result.container.querySelector('textarea')!.value).toEqual(oldContent); - expect(result.container.querySelector('textarea')!.value).not.toEqual(newContent); - }); - it("doesn't create a stale state if a key is passed to the component", async () => { const TestComponent = () => { const [isEditable, setIsEditable] = React.useState(true); @@ -208,11 +88,11 @@ describe('UserActionMarkdown ', () => { ); }; const result = appMockRenderer.render(); - expect(result.getByTestId('user-action-markdown-form')).toBeTruthy(); + expect(result.getByTestId('editable-markdown-form')).toBeTruthy(); // append content and save userEvent.type(result.container.querySelector('textarea')!, appendContent); - userEvent.click(result.getByTestId('user-action-save-markdown')); + userEvent.click(result.getByTestId('editable-save-markdown')); // wait for the state to update await waitFor(() => { @@ -221,7 +101,7 @@ describe('UserActionMarkdown ', () => { // toggle to non-edit state userEvent.click(result.getByTestId('test-button')); - expect(result.getByTestId('user-action-markdown')).toBeTruthy(); + expect(result.getByTestId('scrollable-markdown')).toBeTruthy(); // toggle to edit state again userEvent.click(result.getByTestId('test-button')); @@ -231,112 +111,4 @@ describe('UserActionMarkdown ', () => { expect(result.container.querySelector('textarea')!.value).not.toEqual(oldContent); }); }); - - describe('draft comment ', () => { - const content = 'test content'; - const initialState = { content }; - const MockHookWrapperComponent: React.FC<{ testProviderProps?: unknown }> = ({ - children, - testProviderProps = {}, - }) => { - const { form } = useForm({ - defaultValue: initialState, - options: { stripEmptyFields: false }, - schema, - }); - - return ( - -
{children}
-
- ); - }; - - beforeAll(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.clearAllTimers(); - }); - - afterAll(() => { - jest.useRealTimers(); - sessionStorage.removeItem(draftStorageKey); - }); - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('Save button click clears session storage', async () => { - const result = render( - - - - ); - - fireEvent.change(result.getByTestId('euiMarkdownEditorTextArea'), { - target: { value: newValue }, - }); - - act(() => { - jest.advanceTimersByTime(1000); - }); - - expect(sessionStorage.getItem(draftStorageKey)).toBe(newValue); - - fireEvent.click(result.getByTestId(`user-action-save-markdown`)); - - await waitFor(() => { - expect(onSaveContent).toHaveBeenCalledWith(newValue); - expect(onChangeEditable).toHaveBeenCalledWith(defaultProps.id); - expect(sessionStorage.getItem(draftStorageKey)).toBe(null); - }); - }); - - it('Cancel button click clears session storage', async () => { - const result = render( - - - - ); - - expect(sessionStorage.getItem(draftStorageKey)).toBe(''); - - fireEvent.change(result.getByTestId('euiMarkdownEditorTextArea'), { - target: { value: newValue }, - }); - - act(() => { - jest.advanceTimersByTime(1000); - }); - - await waitFor(() => { - expect(sessionStorage.getItem(draftStorageKey)).toBe(newValue); - }); - - fireEvent.click(result.getByTestId('user-action-cancel-markdown')); - - await waitFor(() => { - expect(sessionStorage.getItem(draftStorageKey)).toBe(null); - }); - }); - - describe('existing storage key', () => { - beforeEach(() => { - sessionStorage.setItem(draftStorageKey, 'value set in storage'); - }); - - it('should have session storage value same as draft comment', async () => { - const result = render( - - - - ); - - expect(result.getByText('value set in storage')).toBeInTheDocument(); - }); - }); - }); }); diff --git a/x-pack/plugins/cases/public/components/user_actions/markdown_form.tsx b/x-pack/plugins/cases/public/components/user_actions/markdown_form.tsx index 3ddb62eb4fb7a..3866fe774ec14 100644 --- a/x-pack/plugins/cases/public/components/user_actions/markdown_form.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/markdown_form.tsx @@ -5,25 +5,10 @@ * 2.0. */ -import React, { forwardRef, useCallback, useImperativeHandle, useRef } from 'react'; -import styled from 'styled-components'; +import React, { forwardRef, useRef } from 'react'; -import { Form, useForm, UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; -import type { Content } from './schema'; import { schema } from './schema'; -import { MarkdownRenderer, MarkdownEditorForm } from '../markdown_editor'; -import { removeItemFromSessionStorage } from '../utils'; -import { useCasesContext } from '../cases_context/use_cases_context'; -import { getMarkdownEditorStorageKey } from '../markdown_editor/utils'; -import { UserActionMarkdownFooter } from './markdown_form_footer'; - -export const ContentWrapper = styled.div` - padding: ${({ theme }) => `${theme.eui.euiSizeM} ${theme.eui.euiSizeL}`}; - text-overflow: ellipsis; - word-break: break-word; - display: -webkit-box; - -webkit-box-orient: vertical; -`; +import { ScrollableMarkdown, EditableMarkdown } from '../markdown_editor'; interface UserActionMarkdownProps { content: string; @@ -43,70 +28,22 @@ const UserActionMarkdownComponent = forwardRef< UserActionMarkdownProps >(({ id, content, caseId, isEditable, onChangeEditable, onSaveContent }, ref) => { const editorRef = useRef(); - const initialState = { content }; - const { form } = useForm({ - defaultValue: initialState, - options: { stripEmptyFields: false }, - schema, - }); - const fieldName = 'content'; - const { appId } = useCasesContext(); - const draftStorageKey = getMarkdownEditorStorageKey(appId, caseId, id); - const { setFieldValue, submit } = form; - - const handleCancelAction = useCallback(() => { - onChangeEditable(id); - removeItemFromSessionStorage(draftStorageKey); - }, [id, onChangeEditable, draftStorageKey]); - - const handleSaveAction = useCallback(async () => { - const { isValid, data } = await submit(); - - if (isValid && data.content !== content) { - onSaveContent(data.content); - } - onChangeEditable(id); - removeItemFromSessionStorage(draftStorageKey); - }, [content, id, onChangeEditable, onSaveContent, submit, draftStorageKey]); - - const setComment = useCallback( - (newComment) => { - setFieldValue(fieldName, newComment); - }, - [setFieldValue] - ); - - useImperativeHandle(ref, () => ({ - setComment, - editor: editorRef.current, - })); return isEditable ? ( -
- - ), - initialValue: content, - }} - /> - + ) : ( - - {content} - + ); }); diff --git a/x-pack/plugins/security_solution/cypress/e2e/cases/creation.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/cases/creation.cy.ts index 46bfd1f388ea6..260ef5a393b3e 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/cases/creation.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/cases/creation.cy.ts @@ -26,7 +26,6 @@ import { CASE_DETAILS_PAGE_TITLE, CASE_DETAILS_STATUS, CASE_DETAILS_TAGS, - CASE_DETAILS_USER_ACTION_DESCRIPTION_USERNAME, CASE_DETAILS_USER_ACTION_DESCRIPTION_EVENT, CASE_DETAILS_USERNAMES, PARTICIPANTS, @@ -99,8 +98,7 @@ describe('Cases', () => { const expectedTags = this.mycase.tags.join(''); cy.get(CASE_DETAILS_PAGE_TITLE).should('have.text', this.mycase.name); cy.get(CASE_DETAILS_STATUS).should('have.text', 'Open'); - cy.get(CASE_DETAILS_USER_ACTION_DESCRIPTION_USERNAME).should('have.text', this.mycase.reporter); - cy.get(CASE_DETAILS_USER_ACTION_DESCRIPTION_EVENT).should('have.text', 'added description'); + cy.get(CASE_DETAILS_USER_ACTION_DESCRIPTION_EVENT).should('have.text', 'Description'); cy.get(CASE_DETAILS_DESCRIPTION).should( 'have.text', `${this.mycase.description} ${this.mycase.timeline.title}` diff --git a/x-pack/plugins/security_solution/cypress/screens/case_details.ts b/x-pack/plugins/security_solution/cypress/screens/case_details.ts index fcd8b60557fc1..271ef54922d5d 100644 --- a/x-pack/plugins/security_solution/cypress/screens/case_details.ts +++ b/x-pack/plugins/security_solution/cypress/screens/case_details.ts @@ -12,7 +12,7 @@ export const CASE_CONNECTOR = '[data-test-subj="connector-fields"] .euiCard__tit export const CASE_DELETE = '[data-test-subj="property-actions-trash"]'; export const CASE_DETAILS_DESCRIPTION = - '[data-test-subj="description-action"] [data-test-subj="user-action-markdown"]'; + '[data-test-subj="description"] [data-test-subj="scrollable-markdown"]'; export const CASE_DETAILS_PAGE_TITLE = '[data-test-subj="header-page-title"]'; @@ -21,13 +21,10 @@ export const CASE_DETAILS_STATUS = '[data-test-subj="case-view-status-dropdown"] export const CASE_DETAILS_TAGS = '[data-test-subj="case-tags"]'; export const CASE_DETAILS_TIMELINE_LINK_MARKDOWN = - '[data-test-subj="description-action"] [data-test-subj="user-action-markdown"] button'; + '[data-test-subj="description"] [data-test-subj="scrollable-markdown"] button'; export const CASE_DETAILS_USER_ACTION_DESCRIPTION_EVENT = - '[data-test-subj="description-action"] .euiCommentEvent__headerEvent'; - -export const CASE_DETAILS_USER_ACTION_DESCRIPTION_USERNAME = - '[data-test-subj="description-action"] .euiCommentEvent__headerUsername'; + '[data-test-subj="description"] [data-test-subj="description-title"]'; export const CASE_DETAILS_USERNAMES = '[data-test-subj="user-profile-username"]'; @@ -41,7 +38,7 @@ export const CASES_TAGS = (tagName: string) => { return `[data-test-subj="tag-${tagName}"]`; }; -export const CASE_USER_ACTION = '[data-test-subj="user-action-markdown"]'; +export const CASE_USER_ACTION = '[data-test-subj="scrollable-markdown"]'; export const CONNECTOR_CARD_DETAILS = '[data-test-subj="connector-card-details"]'; diff --git a/x-pack/test/functional/services/cases/single_case_view.ts b/x-pack/test/functional/services/cases/single_case_view.ts index f5c34feedf811..237881f2af78b 100644 --- a/x-pack/test/functional/services/cases/single_case_view.ts +++ b/x-pack/test/functional/services/cases/single_case_view.ts @@ -101,7 +101,7 @@ export function CasesSingleViewServiceProvider({ getService, getPageObject }: Ft async assertCaseDescription(expectedDescription: string) { const desc = await find.byCssSelector( - '[data-test-subj="description-action"] [data-test-subj="user-action-markdown"]' + '[data-test-subj="description"] [data-test-subj="scrollable-markdown"]' ); const actualDescription = await desc.getVisibleText(); diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/group1/create_case_form.ts b/x-pack/test/functional_with_es_ssl/apps/cases/group1/create_case_form.ts index 40cbbc53c5596..8823f5144a0fc 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/group1/create_case_form.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/group1/create_case_form.ts @@ -51,7 +51,7 @@ export default ({ getService, getPageObject }: FtrProviderContext) => { expect(await title.getVisibleText()).equal(caseTitle); // validate description - const description = await testSubjects.find('user-action-markdown'); + const description = await testSubjects.find('scrollable-markdown'); expect(await description.getVisibleText()).equal('test description'); // validate tag exists diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts b/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts index d0df92ac4f7e7..233157e57e518 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/group1/view_case.ts @@ -58,7 +58,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { // validate user action const newComment = await find.byCssSelector( - '[data-test-subj*="comment-create-action"] [data-test-subj="user-action-markdown"]' + '[data-test-subj*="comment-create-action"] [data-test-subj="scrollable-markdown"]' ); expect(await newComment.getVisibleText()).equal('Test comment from automation'); }); @@ -207,7 +207,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { // validate user action const newComment = await find.byCssSelector( - '[data-test-subj*="comment-create-action"] [data-test-subj="user-action-markdown"]' + '[data-test-subj*="comment-create-action"] [data-test-subj="scrollable-markdown"]' ); expect(await newComment.getVisibleText()).equal('Test comment from automation'); }); @@ -244,7 +244,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { // validate user action const newComment = await find.byCssSelector( - '[data-test-subj*="comment-create-action"] [data-test-subj="user-action-markdown"]' + '[data-test-subj*="comment-create-action"] [data-test-subj="scrollable-markdown"]' ); expect(await newComment.getVisibleText()).equal('Test comment from automation'); }); @@ -266,7 +266,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { // validate user action const newComment = await find.byCssSelector( - '[data-test-subj*="comment-create-action"] [data-test-subj="user-action-markdown"]' + '[data-test-subj*="comment-create-action"] [data-test-subj="scrollable-markdown"]' ); expect(await newComment.getVisibleText()).equal('Test comment from automation'); }); @@ -291,7 +291,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await header.waitUntilLoadingHasFinished(); const editCommentTextArea = await find.byCssSelector( - '[data-test-subj*="user-action-markdown-form"] textarea.euiMarkdownEditorTextArea' + '[data-test-subj*="editable-markdown-form"] textarea.euiMarkdownEditorTextArea' ); await header.waitUntilLoadingHasFinished(); @@ -307,12 +307,12 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { }); it('shows unsaved description message when page is refreshed', async () => { - await testSubjects.click('editable-description-edit-icon'); + await testSubjects.click('description-edit-icon'); await header.waitUntilLoadingHasFinished(); const editCommentTextArea = await find.byCssSelector( - '[data-test-subj*="user-action-markdown-form"] textarea.euiMarkdownEditorTextArea' + '[data-test-subj*="editable-markdown-form"] textarea.euiMarkdownEditorTextArea' ); await header.waitUntilLoadingHasFinished(); @@ -320,6 +320,8 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { await editCommentTextArea.focus(); await editCommentTextArea.type('Edited description'); + await header.waitUntilLoadingHasFinished(); + await browser.refresh(); await header.waitUntilLoadingHasFinished(); diff --git a/x-pack/test/functional_with_es_ssl/apps/cases/group2/upgrade.ts b/x-pack/test/functional_with_es_ssl/apps/cases/group2/upgrade.ts index 561bbae70bcca..93d12b5d908da 100644 --- a/x-pack/test/functional_with_es_ssl/apps/cases/group2/upgrade.ts +++ b/x-pack/test/functional_with_es_ssl/apps/cases/group2/upgrade.ts @@ -86,7 +86,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { it('shows the description correctly', async () => { const desc = await find.byCssSelector( - '[data-test-subj="description-action"] [data-test-subj="user-action-markdown"]' + '[data-test-subj="description"] [data-test-subj="scrollable-markdown"]' ); expect(await desc.getVisibleText()).equal(`Testing upgrade! Let's see how it goes.`); @@ -112,7 +112,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { it('shows the first comment correctly', async () => { const comment = await find.byCssSelector( - '[data-test-subj^="comment-create-action"] [data-test-subj="user-action-markdown"]' + '[data-test-subj^="comment-create-action"] [data-test-subj="scrollable-markdown"]' ); expect(await comment.getVisibleText()).equal(`This is interesting. I am curious also.`); @@ -127,7 +127,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { it('shows the second comment correctly', async () => { const comments = await find.allByCssSelector( - '[data-test-subj^="comment-create-action"] [data-test-subj="user-action-markdown"]' + '[data-test-subj^="comment-create-action"] [data-test-subj="scrollable-markdown"]' ); const secondComment = comments[1]; @@ -140,7 +140,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { it('shows the third comment correctly', async () => { const comments = await find.allByCssSelector( - '[data-test-subj^="comment-create-action"] [data-test-subj="user-action-markdown"]' + '[data-test-subj^="comment-create-action"] [data-test-subj="scrollable-markdown"]' ); const thirdComment = comments[2]; @@ -200,7 +200,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { it('shows the fourth comment correctly', async () => { const comments = await find.allByCssSelector( - '[data-test-subj^="comment-create-action"] [data-test-subj="user-action-markdown"]' + '[data-test-subj^="comment-create-action"] [data-test-subj="scrollable-markdown"]' ); const thirdComment = comments[3]; From 5ed5fd4555c378778bc8fc06e5aa3c35899ee64e Mon Sep 17 00:00:00 2001 From: Ignacio Rivas Date: Tue, 25 Apr 2023 16:11:32 +0200 Subject: [PATCH 21/29] [Rollup Jobs] Migrate EuiCodeEditor to EuiCodeBlock (#155494) --- .../components/job_details/tabs/tab_json.js | 16 ++++------------ .../job_list/detail_panel/detail_panel.test.js | 5 ++--- x-pack/plugins/rollup/public/shared_imports.ts | 7 +------ 3 files changed, 7 insertions(+), 21 deletions(-) diff --git a/x-pack/plugins/rollup/public/crud_app/sections/components/job_details/tabs/tab_json.js b/x-pack/plugins/rollup/public/crud_app/sections/components/job_details/tabs/tab_json.js index adfb9aafc444e..daba32a3b9520 100644 --- a/x-pack/plugins/rollup/public/crud_app/sections/components/job_details/tabs/tab_json.js +++ b/x-pack/plugins/rollup/public/crud_app/sections/components/job_details/tabs/tab_json.js @@ -6,22 +6,14 @@ */ import React from 'react'; - -import { EuiCodeEditor } from '../../../../../shared_imports'; +import { EuiCodeBlock } from '@elastic/eui'; export const TabJson = ({ json }) => { const jsonString = JSON.stringify(json, null, 2); return ( - + + {jsonString} + ); }; diff --git a/x-pack/plugins/rollup/public/crud_app/sections/job_list/detail_panel/detail_panel.test.js b/x-pack/plugins/rollup/public/crud_app/sections/job_list/detail_panel/detail_panel.test.js index fa63639ef4d06..dc38bd3af9afd 100644 --- a/x-pack/plugins/rollup/public/crud_app/sections/job_list/detail_panel/detail_panel.test.js +++ b/x-pack/plugins/rollup/public/crud_app/sections/job_list/detail_panel/detail_panel.test.js @@ -322,9 +322,8 @@ describe('', () => { const tabContent = find('rollupJobDetailTabContent'); it('should render the "EuiCodeEditor" with the job "json" data', () => { - const euiCodeEditor = tabContent.find('EuiCodeEditor'); - expect(euiCodeEditor.length).toBeTruthy(); - expect(JSON.parse(euiCodeEditor.props().value)).toEqual(defaultJob.json); + const euiCodeEditor = tabContent.find('[data-test-subj="jsonCodeBlock"]').at(0); + expect(JSON.parse(euiCodeEditor.text())).toEqual(defaultJob.json); }); }); }); diff --git a/x-pack/plugins/rollup/public/shared_imports.ts b/x-pack/plugins/rollup/public/shared_imports.ts index cee1fe39bc16a..ed0d444ccb160 100644 --- a/x-pack/plugins/rollup/public/shared_imports.ts +++ b/x-pack/plugins/rollup/public/shared_imports.ts @@ -5,12 +5,7 @@ * 2.0. */ -export { - extractQueryParams, - indices, - SectionLoading, - EuiCodeEditor, -} from '@kbn/es-ui-shared-plugin/public'; +export { extractQueryParams, indices, SectionLoading } from '@kbn/es-ui-shared-plugin/public'; export { KibanaContextProvider, From ae76801a2e310c5bc67067c32123350588f8d6dd Mon Sep 17 00:00:00 2001 From: Ignacio Rivas Date: Tue, 25 Apr 2023 16:11:57 +0200 Subject: [PATCH 22/29] [Index Management] Migrate code editors to Monaco (#155598) --- .../template_create.test.tsx | 17 +++++++++++++ .../template_edit.test.tsx | 17 +++++++++++++ .../template_form.helpers.ts | 22 +++++----------- .../component_template_create.test.tsx | 17 +++++++++++++ .../component_template_edit.test.tsx | 17 +++++++++++++ .../component_template_form.helpers.ts | 24 +++++++++--------- .../components/wizard_steps/step_aliases.tsx | 25 ++++++++----------- .../components/wizard_steps/step_settings.tsx | 25 ++++++++----------- 8 files changed, 106 insertions(+), 58 deletions(-) diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx index 1727caa9eaff0..2eb7403177257 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx @@ -21,6 +21,23 @@ import { import { setup } from './template_create.helpers'; import { TemplateFormTestBed } from './template_form.helpers'; +jest.mock('@kbn/kibana-react-plugin/public', () => { + const original = jest.requireActual('@kbn/kibana-react-plugin/public'); + return { + ...original, + // Mocking CodeEditor, which uses React Monaco under the hood + CodeEditor: (props: any) => ( + ) => { + props.onChange(e.currentTarget.getAttribute('data-currentvalue')); + }} + /> + ), + }; +}); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx index 99565222ffa7b..6d1224abf3529 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_edit.test.tsx @@ -28,6 +28,23 @@ const MAPPING = { }, }; +jest.mock('@kbn/kibana-react-plugin/public', () => { + const original = jest.requireActual('@kbn/kibana-react-plugin/public'); + return { + ...original, + // Mocking CodeEditor, which uses React Monaco under the hood + CodeEditor: (props: any) => ( + ) => { + props.onChange(e.currentTarget.getAttribute('data-currentvalue')); + }} + /> + ), + }; +}); + jest.mock('@elastic/eui', () => { const origial = jest.requireActual('@elastic/eui'); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts index 64ce73ee8161b..bf16e8e5e803d 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/index_template_wizard/template_form.helpers.ts @@ -219,18 +219,13 @@ export const formSetup = async (initTestBed: SetupFunc) => { const completeStepThree = async (settings?: string) => { const { find, component } = testBed; - await act(async () => { - if (settings) { - find('settingsEditor').simulate('change', { - jsonString: settings, - }); // Using mocked EuiCodeEditor - jest.advanceTimersByTime(0); - } - }); + if (settings) { + find('settingsEditor').getDOMNode().setAttribute('data-currentvalue', settings); + find('settingsEditor').simulate('change'); + } await act(async () => { clickNextButton(); - jest.advanceTimersByTime(0); }); component.update(); @@ -258,13 +253,8 @@ export const formSetup = async (initTestBed: SetupFunc) => { const { find, component } = testBed; if (aliases) { - await act(async () => { - find('aliasesEditor').simulate('change', { - jsonString: aliases, - }); // Using mocked EuiCodeEditor - jest.advanceTimersByTime(0); // advance timers to allow the form to validate - }); - component.update(); + find('aliasesEditor').getDOMNode().setAttribute('data-currentvalue', aliases); + find('aliasesEditor').simulate('change'); } await act(async () => { diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx index 5e915c526dc44..8309db0699fc6 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx @@ -14,6 +14,23 @@ import { setupEnvironment } from './helpers'; import { API_BASE_PATH } from './helpers/constants'; import { setup, ComponentTemplateCreateTestBed } from './helpers/component_template_create.helpers'; +jest.mock('@kbn/kibana-react-plugin/public', () => { + const original = jest.requireActual('@kbn/kibana-react-plugin/public'); + return { + ...original, + // Mocking CodeEditor, which uses React Monaco under the hood + CodeEditor: (props: any) => ( + ) => { + props.onChange(e.currentTarget.getAttribute('data-currentvalue')); + }} + /> + ), + }; +}); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx index 94beecf441b07..1da8027c59497 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx @@ -13,6 +13,23 @@ import { setupEnvironment } from './helpers'; import { API_BASE_PATH } from './helpers/constants'; import { setup, ComponentTemplateEditTestBed } from './helpers/component_template_edit.helpers'; +jest.mock('@kbn/kibana-react-plugin/public', () => { + const original = jest.requireActual('@kbn/kibana-react-plugin/public'); + return { + ...original, + // Mocking CodeEditor, which uses React Monaco under the hood + CodeEditor: (props: any) => ( + ) => { + props.onChange(e.currentTarget.getAttribute('data-currentvalue')); + }} + /> + ), + }; +}); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_form.helpers.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_form.helpers.ts index 2aaadf3c06f11..0db29dffff510 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_form.helpers.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_form.helpers.ts @@ -66,14 +66,14 @@ export const getFormActions = (testBed: TestBed) => { const completeStepSettings = async (settings?: { [key: string]: any }) => { const { find, component } = testBed; + const settingsValue = JSON.stringify(settings); - await act(async () => { - if (settings) { - find('settingsEditor').simulate('change', { - jsonString: JSON.stringify(settings), - }); // Using mocked EuiCodeEditor - } + if (settingsValue) { + find('settingsEditor').getDOMNode().setAttribute('data-currentvalue', settingsValue); + find('settingsEditor').simulate('change'); + } + await act(async () => { clickNextButton(); }); @@ -119,14 +119,14 @@ export const getFormActions = (testBed: TestBed) => { const completeStepAliases = async (aliases?: { [key: string]: any }) => { const { find, component } = testBed; + const aliasesValue = JSON.stringify(aliases); - await act(async () => { - if (aliases) { - find('aliasesEditor').simulate('change', { - jsonString: JSON.stringify(aliases), - }); // Using mocked EuiCodeEditor - } + if (aliasesValue) { + find('aliasesEditor').getDOMNode().setAttribute('data-currentvalue', aliasesValue); + find('aliasesEditor').simulate('change'); + } + await act(async () => { clickNextButton(); }); diff --git a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_aliases.tsx b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_aliases.tsx index b22030be2d30b..a948b9a999fa8 100644 --- a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_aliases.tsx +++ b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_aliases.tsx @@ -18,8 +18,9 @@ import { EuiCode, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; +import { CodeEditor } from '@kbn/kibana-react-plugin/public'; -import { EuiCodeEditor, Forms } from '../../../../../shared_imports'; +import { Forms } from '../../../../../shared_imports'; import { useJsonStep } from './use_json_step'; interface Props { @@ -105,29 +106,23 @@ export const StepAliases: React.FunctionComponent = React.memo( error={error} fullWidth > - diff --git a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_settings.tsx b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_settings.tsx index f370ea3642491..0fd889de03921 100644 --- a/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_settings.tsx +++ b/x-pack/plugins/index_management/public/application/components/shared/components/wizard_steps/step_settings.tsx @@ -18,8 +18,9 @@ import { EuiCode, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; +import { CodeEditor } from '@kbn/kibana-react-plugin/public'; -import { EuiCodeEditor, Forms } from '../../../../../shared_imports'; +import { Forms } from '../../../../../shared_imports'; import { useJsonStep } from './use_json_step'; interface Props { @@ -99,29 +100,23 @@ export const StepSettings: React.FunctionComponent = React.memo( error={error} fullWidth > - From bd35be27cafb03f7df4a43f203780e4374e409e2 Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger Date: Tue, 25 Apr 2023 16:17:49 +0200 Subject: [PATCH 23/29] [ML] Transforms: Replace `es_search` API endpoint with `data.search` (#154898) Replaces the custom `es_search` API endpoint with using `data.search`. --- .../public/app/__mocks__/app_dependencies.tsx | 39 ++- .../public/app/hooks/__mocks__/use_api.ts | 28 -- .../transform/public/app/hooks/use_api.ts | 9 - .../public/app/hooks/use_data_search.ts | 41 +++ .../public/app/hooks/use_index_data.ts | 262 +++++++++--------- .../components/filter_term_form.tsx | 85 +++--- .../transform/server/routes/api/transforms.ts | 28 -- 7 files changed, 261 insertions(+), 231 deletions(-) create mode 100644 x-pack/plugins/transform/public/app/hooks/use_data_search.ts diff --git a/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx b/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx index 62a88a51b7b27..3922c32df2181 100644 --- a/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx +++ b/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx @@ -6,18 +6,21 @@ */ import { useContext } from 'react'; +import { of } from 'rxjs'; +import type { + IKibanaSearchResponse, + IKibanaSearchRequest, + ISearchGeneric, +} from '@kbn/data-plugin/public'; import type { ScopedHistory } from '@kbn/core/public'; - import { coreMock, themeServiceMock } from '@kbn/core/public/mocks'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; import { savedObjectsPluginMock } from '@kbn/saved-objects-plugin/public/mocks'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; import { fieldFormatsServiceMock } from '@kbn/field-formats-plugin/public/mocks'; - import { SharePluginStart } from '@kbn/share-plugin/public'; - import type { Storage } from '@kbn/kibana-utils-plugin/public'; import type { TriggersAndActionsUIPublicPluginStart } from '@kbn/triggers-actions-ui-plugin/public'; import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; @@ -35,6 +38,36 @@ const dataViewsStart = dataViewPluginMocks.createStartContract(); // Replace mock to support syntax using `.then()` as used in transform code. coreStart.savedObjects.client.find = jest.fn().mockResolvedValue({ savedObjects: [] }); +// Replace mock to support tests for `use_index_data`. +dataStart.search.search = jest.fn(({ params }: IKibanaSearchRequest) => { + const hits = []; + + // simulate a cross cluster search result + // against a cluster that doesn't support fields + if (params.index.includes(':')) { + hits.push({ + _id: 'the-doc', + _index: 'the-index', + }); + } + + return of({ + rawResponse: { + hits: { + hits, + total: { + value: 0, + relation: 'eq', + }, + max_score: 0, + }, + timed_out: false, + took: 10, + _shards: { total: 1, successful: 1, failed: 0, skipped: 0 }, + }, + }); +}) as ISearchGeneric; + const appDependencies: AppDependencies = { application: coreStart.application, charts: chartPluginMock.createStartContract(), diff --git a/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts b/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts index 61e6baf5c250e..41564d393913c 100644 --- a/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts +++ b/x-pack/plugins/transform/public/app/hooks/__mocks__/use_api.ts @@ -5,8 +5,6 @@ * 2.0. */ -import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; - import type { IHttpFetchError } from '@kbn/core-http-browser'; import { KBN_FIELD_TYPES } from '@kbn/field-types'; @@ -135,32 +133,6 @@ const apiFactory = () => ({ ): Promise { return Promise.resolve({ messages: [], total: 0 }); }, - async esSearch(payload: any): Promise { - const hits = []; - - // simulate a cross cluster search result - // against a cluster that doesn't support fields - if (payload.index.includes(':')) { - hits.push({ - _id: 'the-doc', - _index: 'the-index', - }); - } - - return Promise.resolve({ - hits: { - hits, - total: { - value: 0, - relation: 'eq', - }, - max_score: 0, - }, - timed_out: false, - took: 10, - _shards: { total: 1, successful: 1, failed: 0, skipped: 0 }, - }); - }, async getEsIndices(): Promise { return Promise.resolve([]); diff --git a/x-pack/plugins/transform/public/app/hooks/use_api.ts b/x-pack/plugins/transform/public/app/hooks/use_api.ts index 3364ed58d5af6..3b39d39a3a7bc 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_api.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_api.ts @@ -7,8 +7,6 @@ import { useMemo } from 'react'; -import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; - import type { IHttpFetchError } from '@kbn/core-http-browser'; import { KBN_FIELD_TYPES } from '@kbn/field-types'; @@ -244,13 +242,6 @@ export const useApi = () => { return e; } }, - async esSearch(payload: any): Promise { - try { - return await http.post(`${API_BASE_PATH}es_search`, { body: JSON.stringify(payload) }); - } catch (e) { - return e; - } - }, async getEsIndices(): Promise { try { return await http.get(`/api/index_management/indices`); diff --git a/x-pack/plugins/transform/public/app/hooks/use_data_search.ts b/x-pack/plugins/transform/public/app/hooks/use_data_search.ts new file mode 100644 index 0000000000000..af4bb440f9e24 --- /dev/null +++ b/x-pack/plugins/transform/public/app/hooks/use_data_search.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback } from 'react'; +import { lastValueFrom } from 'rxjs'; + +import type { IKibanaSearchRequest } from '@kbn/data-plugin/common'; + +import { useAppDependencies } from '../app_dependencies'; + +export const useDataSearch = () => { + const { data } = useAppDependencies(); + + return useCallback( + async (esSearchRequestParams: IKibanaSearchRequest['params'], abortSignal?: AbortSignal) => { + try { + const { rawResponse: resp } = await lastValueFrom( + data.search.search( + { + params: esSearchRequestParams, + }, + { abortSignal } + ) + ); + + return resp; + } catch (error) { + if (error.name === 'AbortError') { + // ignore abort errors + } else { + return error; + } + } + }, + [data] + ); +}; diff --git a/x-pack/plugins/transform/public/app/hooks/use_index_data.ts b/x-pack/plugins/transform/public/app/hooks/use_index_data.ts index f4d10b5562001..97005c11c3661 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_index_data.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_index_data.ts @@ -33,6 +33,7 @@ import type { StepDefineExposedState } from '../sections/create_transform/compon import { SearchItems } from './use_search_items'; import { useApi } from './use_api'; +import { useDataSearch } from './use_data_search'; export const useIndexData = ( dataView: SearchItems['dataView'], @@ -43,6 +44,7 @@ export const useIndexData = ( const indexPattern = useMemo(() => dataView.getIndexPattern(), [dataView]); const api = useApi(); + const dataSearch = useDataSearch(); const toastNotifications = useToastNotifications(); const { ml: { @@ -78,56 +80,62 @@ export const useIndexData = ( }, }; - // Fetch 500 random documents to determine populated fields. - // This is a workaround to avoid passing potentially thousands of unpopulated fields - // (for example, as part of filebeat/metricbeat/ECS based indices) - // to the data grid component which would significantly slow down the page. - const fetchDataGridSampleDocuments = async function () { - setErrorMessage(''); - setStatus(INDEX_STATUS.LOADING); - - const esSearchRequest = { - index: indexPattern, - body: { - fields: ['*'], - _source: false, - query: { - function_score: { - query: defaultQuery, - random_score: {}, + useEffect(() => { + const abortController = new AbortController(); + + // Fetch 500 random documents to determine populated fields. + // This is a workaround to avoid passing potentially thousands of unpopulated fields + // (for example, as part of filebeat/metricbeat/ECS based indices) + // to the data grid component which would significantly slow down the page. + const fetchDataGridSampleDocuments = async function () { + setErrorMessage(''); + setStatus(INDEX_STATUS.LOADING); + + const esSearchRequest = { + index: indexPattern, + body: { + fields: ['*'], + _source: false, + query: { + function_score: { + query: defaultQuery, + random_score: {}, + }, }, + size: 500, }, - size: 500, - }, - }; + }; - const resp = await api.esSearch(esSearchRequest); + const resp = await dataSearch(esSearchRequest, abortController.signal); - if (!isEsSearchResponse(resp)) { - setErrorMessage(getErrorMessage(resp)); - setStatus(INDEX_STATUS.ERROR); - return; - } + if (!isEsSearchResponse(resp)) { + setErrorMessage(getErrorMessage(resp)); + setStatus(INDEX_STATUS.ERROR); + return; + } - const isCrossClusterSearch = indexPattern.includes(':'); - const isMissingFields = resp.hits.hits.every((d) => typeof d.fields === 'undefined'); + const isCrossClusterSearch = indexPattern.includes(':'); + const isMissingFields = resp.hits.hits.every((d) => typeof d.fields === 'undefined'); - const docs = resp.hits.hits.map((d) => getProcessedFields(d.fields ?? {})); + const docs = resp.hits.hits.map((d) => getProcessedFields(d.fields ?? {})); - // Get all field names for each returned doc and flatten it - // to a list of unique field names used across all docs. - const allDataViewFields = getFieldsFromKibanaIndexPattern(dataView); - const populatedFields = [...new Set(docs.map(Object.keys).flat(1))] - .filter((d) => allDataViewFields.includes(d)) - .sort(); + // Get all field names for each returned doc and flatten it + // to a list of unique field names used across all docs. + const allDataViewFields = getFieldsFromKibanaIndexPattern(dataView); + const populatedFields = [...new Set(docs.map(Object.keys).flat(1))] + .filter((d) => allDataViewFields.includes(d)) + .sort(); - setCcsWarning(isCrossClusterSearch && isMissingFields); - setStatus(INDEX_STATUS.LOADED); - setDataViewFields(populatedFields); - }; + setCcsWarning(isCrossClusterSearch && isMissingFields); + setStatus(INDEX_STATUS.LOADED); + setDataViewFields(populatedFields); + }; - useEffect(() => { fetchDataGridSampleDocuments(); + + return () => { + abortController.abort(); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [timeRangeMs]); @@ -190,96 +198,62 @@ export const useIndexData = ( // eslint-disable-next-line react-hooks/exhaustive-deps }, [JSON.stringify([query, timeRangeMs])]); - const fetchDataGridData = async function () { - setErrorMessage(''); - setStatus(INDEX_STATUS.LOADING); - - const sort: EsSorting = sortingColumns.reduce((s, column) => { - s[column.id] = { order: column.direction }; - return s; - }, {} as EsSorting); - - const esSearchRequest = { - index: indexPattern, - body: { - fields: ['*'], - _source: false, - query: isDefaultQuery(query) ? defaultQuery : queryWithBaseFilterCriteria, - from: pagination.pageIndex * pagination.pageSize, - size: pagination.pageSize, - ...(Object.keys(sort).length > 0 ? { sort } : {}), - ...(isRuntimeMappings(combinedRuntimeMappings) - ? { runtime_mappings: combinedRuntimeMappings } - : {}), - }, - }; - const resp = await api.esSearch(esSearchRequest); - - if (!isEsSearchResponse(resp)) { - setErrorMessage(getErrorMessage(resp)); - setStatus(INDEX_STATUS.ERROR); - return; - } - - const isCrossClusterSearch = indexPattern.includes(':'); - const isMissingFields = resp.hits.hits.every((d) => typeof d.fields === 'undefined'); + useEffect(() => { + const abortController = new AbortController(); + + const fetchDataGridData = async function () { + setErrorMessage(''); + setStatus(INDEX_STATUS.LOADING); + + const sort: EsSorting = sortingColumns.reduce((s, column) => { + s[column.id] = { order: column.direction }; + return s; + }, {} as EsSorting); + + const esSearchRequest = { + index: indexPattern, + body: { + fields: ['*'], + _source: false, + query: isDefaultQuery(query) ? defaultQuery : queryWithBaseFilterCriteria, + from: pagination.pageIndex * pagination.pageSize, + size: pagination.pageSize, + ...(Object.keys(sort).length > 0 ? { sort } : {}), + ...(isRuntimeMappings(combinedRuntimeMappings) + ? { runtime_mappings: combinedRuntimeMappings } + : {}), + }, + }; + const resp = await dataSearch(esSearchRequest, abortController.signal); - const docs = resp.hits.hits.map((d) => getProcessedFields(d.fields ?? {})); + if (!isEsSearchResponse(resp)) { + setErrorMessage(getErrorMessage(resp)); + setStatus(INDEX_STATUS.ERROR); + return; + } - setCcsWarning(isCrossClusterSearch && isMissingFields); - setRowCountInfo({ - rowCount: typeof resp.hits.total === 'number' ? resp.hits.total : resp.hits.total!.value, - rowCountRelation: - typeof resp.hits.total === 'number' - ? ('eq' as estypes.SearchTotalHitsRelation) - : resp.hits.total!.relation, - }); - setTableItems(docs); - setStatus(INDEX_STATUS.LOADED); - }; + const isCrossClusterSearch = indexPattern.includes(':'); + const isMissingFields = resp.hits.hits.every((d) => typeof d.fields === 'undefined'); - const fetchColumnChartsData = async function () { - const allDataViewFieldNames = new Set(dataView.fields.map((f) => f.name)); - const columnChartsData = await api.getHistogramsForFields( - indexPattern, - columns - .filter((cT) => dataGrid.visibleColumns.includes(cT.id)) - .map((cT) => { - // If a column field name has a corresponding keyword field, - // fetch the keyword field instead to be able to do aggregations. - const fieldName = cT.id; - return hasKeywordDuplicate(fieldName, allDataViewFieldNames) - ? { - fieldName: `${fieldName}.keyword`, - type: getFieldType(undefined), - } - : { - fieldName, - type: getFieldType(cT.schema), - }; - }), - isDefaultQuery(query) ? defaultQuery : queryWithBaseFilterCriteria, - combinedRuntimeMappings - ); - - if (!isFieldHistogramsResponseSchema(columnChartsData)) { - showDataGridColumnChartErrorMessageToast(columnChartsData, toastNotifications); - return; - } + const docs = resp.hits.hits.map((d) => getProcessedFields(d.fields ?? {})); - setColumnCharts( - // revert field names with `.keyword` used to do aggregations to their original column name - columnChartsData.map((d) => ({ - ...d, - ...(isKeywordDuplicate(d.id, allDataViewFieldNames) - ? { id: removeKeywordPostfix(d.id) } - : {}), - })) - ); - }; + setCcsWarning(isCrossClusterSearch && isMissingFields); + setRowCountInfo({ + rowCount: typeof resp.hits.total === 'number' ? resp.hits.total : resp.hits.total!.value, + rowCountRelation: + typeof resp.hits.total === 'number' + ? ('eq' as estypes.SearchTotalHitsRelation) + : resp.hits.total!.relation, + }); + setTableItems(docs); + setStatus(INDEX_STATUS.LOADED); + }; - useEffect(() => { fetchDataGridData(); + + return () => { + abortController.abort(); + }; // custom comparison // eslint-disable-next-line react-hooks/exhaustive-deps }, [ @@ -296,6 +270,46 @@ export const useIndexData = ( ]); useEffect(() => { + const fetchColumnChartsData = async function () { + const allDataViewFieldNames = new Set(dataView.fields.map((f) => f.name)); + const columnChartsData = await api.getHistogramsForFields( + indexPattern, + columns + .filter((cT) => dataGrid.visibleColumns.includes(cT.id)) + .map((cT) => { + // If a column field name has a corresponding keyword field, + // fetch the keyword field instead to be able to do aggregations. + const fieldName = cT.id; + return hasKeywordDuplicate(fieldName, allDataViewFieldNames) + ? { + fieldName: `${fieldName}.keyword`, + type: getFieldType(undefined), + } + : { + fieldName, + type: getFieldType(cT.schema), + }; + }), + isDefaultQuery(query) ? defaultQuery : queryWithBaseFilterCriteria, + combinedRuntimeMappings + ); + + if (!isFieldHistogramsResponseSchema(columnChartsData)) { + showDataGridColumnChartErrorMessageToast(columnChartsData, toastNotifications); + return; + } + + setColumnCharts( + // revert field names with `.keyword` used to do aggregations to their original column name + columnChartsData.map((d) => ({ + ...d, + ...(isKeywordDuplicate(d.id, allDataViewFieldNames) + ? { id: removeKeywordPostfix(d.id) } + : {}), + })) + ); + }; + if (chartsVisible) { fetchColumnChartsData(); } diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_term_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_term_form.tsx index 11f9dadbb359c..6749786865083 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_term_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_define/common/filter_agg/components/filter_term_form.tsx @@ -5,22 +5,27 @@ * 2.0. */ +import { debounce } from 'lodash'; import React, { useCallback, useContext, useEffect, useState } from 'react'; +import useUpdateEffect from 'react-use/lib/useUpdateEffect'; + import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; + import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui'; + import { FormattedMessage } from '@kbn/i18n-react'; -import { debounce } from 'lodash'; -import useUpdateEffect from 'react-use/lib/useUpdateEffect'; import { i18n } from '@kbn/i18n'; + +import { useDataSearch } from '../../../../../../../hooks/use_data_search'; import { isEsSearchResponseWithAggregations, isMultiBucketAggregate, } from '../../../../../../../../../common/api_schemas/type_guards'; -import { useApi } from '../../../../../../../hooks'; import { CreateTransformWizardContext } from '../../../../wizard/wizard'; -import { FilterAggConfigTerm } from '../types'; import { useToastNotifications } from '../../../../../../../app_dependencies'; +import { FilterAggConfigTerm } from '../types'; + /** * Form component for the term filter aggregation. */ @@ -29,16 +34,39 @@ export const FilterTermForm: FilterAggConfigTerm['aggTypeConfig']['FilterAggForm onChange, selectedField, }) => { - const api = useApi(); const { dataView, runtimeMappings } = useContext(CreateTransformWizardContext); + const dataSearch = useDataSearch(); const toastNotifications = useToastNotifications(); const [options, setOptions] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [searchValue, setSearchValue] = useState(''); + + const onSearchChange = (newSearchValue: string) => { + setSearchValue(newSearchValue); + }; + + const updateConfig = useCallback( + (update) => { + onChange({ + config: { + ...config, + ...update, + }, + }); + }, + [config, onChange] + ); + + useEffect(() => { + const abortController = new AbortController(); + + const fetchOptions = debounce(async () => { + if (selectedField === undefined) return; + + setIsLoading(true); + setOptions([]); - /* eslint-disable-next-line react-hooks/exhaustive-deps */ - const fetchOptions = useCallback( - debounce(async (searchValue: string) => { const esSearchRequest = { index: dataView!.title, body: { @@ -62,7 +90,7 @@ export const FilterTermForm: FilterAggConfigTerm['aggTypeConfig']['FilterAggForm }, }; - const response = await api.esSearch(esSearchRequest); + const response = await dataSearch(esSearchRequest, abortController.signal); setIsLoading(false); @@ -88,42 +116,21 @@ export const FilterTermForm: FilterAggConfigTerm['aggTypeConfig']['FilterAggForm .buckets as estypes.AggregationsSignificantLongTermsBucket[] ).map((value) => ({ label: value.key + '' })) ); - }, 600), - [selectedField] - ); - - const onSearchChange = useCallback( - async (searchValue) => { - if (selectedField === undefined) return; + }, 600); - setIsLoading(true); - setOptions([]); + fetchOptions(); - await fetchOptions(searchValue); - }, - [fetchOptions, selectedField] - ); - - const updateConfig = useCallback( - (update) => { - onChange({ - config: { - ...config, - ...update, - }, - }); - }, - [config, onChange] - ); - - useEffect(() => { - // Simulate initial load. - onSearchChange(''); return () => { // make sure the ongoing request is canceled fetchOptions.cancel(); + abortController.abort(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + }, [selectedField]); + + useEffect(() => { + // Simulate initial load. + onSearchChange(''); }, []); useUpdateEffect(() => { diff --git a/x-pack/plugins/transform/server/routes/api/transforms.ts b/x-pack/plugins/transform/server/routes/api/transforms.ts index d19d9eb1a2963..36459711a7b34 100644 --- a/x-pack/plugins/transform/server/routes/api/transforms.ts +++ b/x-pack/plugins/transform/server/routes/api/transforms.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { schema } from '@kbn/config-schema'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { @@ -529,33 +528,6 @@ export function registerTransformsRoutes(routeDependencies: RouteDependencies) { ) ); - /** - * @apiGroup Transforms - * - * @api {post} /api/transform/es_search Transform ES Search Proxy - * @apiName PostTransformEsSearchProxy - * @apiDescription ES Search Proxy - * - * @apiSchema (body) any - */ - router.post( - { - path: addBasePath('es_search'), - validate: { - body: schema.maybe(schema.any()), - }, - }, - license.guardApiRoute(async (ctx, req, res) => { - try { - const esClient = (await ctx.core).elasticsearch.client; - const body = await esClient.asCurrentUser.search(req.body, { maxRetries: 0 }); - return res.ok({ body }); - } catch (e) { - return res.customError(wrapError(wrapEsError(e))); - } - }) - ); - registerTransformsAuditMessagesRoutes(routeDependencies); registerTransformNodesRoutes(routeDependencies); } From bd01a35555c148bea44abf0181789feb4fed72b7 Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Tue, 25 Apr 2023 15:27:55 +0100 Subject: [PATCH 24/29] [ML] Adding ignore unavailable indices option to anomaly detection job wizards (#155527) Adds a switch to set the `indices_options` property `ignore_unavailable` to true. If switched off, the property is removed from the datafeed config. **Single metric wizard (all non-advanced wizards)** ![image](https://user-images.githubusercontent.com/22172091/233674155-4ef74d94-9938-4228-8b67-2eff3eff89f2.png) **Advanced wizard** ![image](https://user-images.githubusercontent.com/22172091/233674357-bcd3a83f-6476-4cbf-8064-f3e9f3e4b495.png) --------- Co-authored-by: Dima Arnautov --- .../new_job/common/job_creator/job_creator.ts | 30 +++++++++++-- .../advanced_section/advanced_section.tsx | 43 ++++++++++++++++-- .../annotations/annotations_switch.tsx | 3 +- .../ignore_unavailable/description.tsx | 42 ++++++++++++++++++ .../ignore_unavailable_switch.tsx | 44 +++++++++++++++++++ .../components/ignore_unavailable/index.ts | 8 ++++ .../model_plot/model_plot_switch.tsx | 3 +- 7 files changed, 162 insertions(+), 11 deletions(-) create mode 100644 x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/ignore_unavailable/description.tsx create mode 100644 x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/ignore_unavailable/ignore_unavailable_switch.tsx create mode 100644 x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/ignore_unavailable/index.ts diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts index f445afe634b90..2d28256336757 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/job_creator/job_creator.ts @@ -20,7 +20,7 @@ import { mlOnlyAggregations, } from '../../../../../../common/constants/aggregation_types'; import { getQueryFromSavedSearchObject } from '../../../../util/index_utils'; -import { +import type { Job, Datafeed, Detector, @@ -29,11 +29,11 @@ import { BucketSpan, CustomSettings, } from '../../../../../../common/types/anomaly_detection_jobs'; -import { Aggregation, Field, RuntimeMappings } from '../../../../../../common/types/fields'; +import type { Aggregation, Field, RuntimeMappings } from '../../../../../../common/types/fields'; import { combineFieldsAndAggs } from '../../../../../../common/util/fields_utils'; import { createEmptyJob, createEmptyDatafeed } from './util/default_configs'; import { mlJobService } from '../../../../services/job_service'; -import { JobRunner, ProgressSubscriber } from '../job_runner'; +import { JobRunner, type ProgressSubscriber } from '../job_runner'; import { JOB_TYPE, CREATED_BY_LABEL, @@ -42,7 +42,7 @@ import { import { collectAggs } from './util/general'; import { filterRuntimeMappings } from './util/filter_runtime_mappings'; import { parseInterval } from '../../../../../../common/util/parse_interval'; -import { Calendar } from '../../../../../../common/types/calendars'; +import type { Calendar } from '../../../../../../common/types/calendars'; import { mlCalendarService } from '../../../../services/calendar_service'; import { getDatafeedAggregations } from '../../../../../../common/util/datafeed_utils'; import { getFirstKeyInObject } from '../../../../../../common/util/object_utils'; @@ -542,6 +542,28 @@ export class JobCreator { this._datafeed_config.indices = indics; } + public get ignoreUnavailable(): boolean { + return !!this._datafeed_config.indices_options?.ignore_unavailable; + } + + public set ignoreUnavailable(ignore: boolean) { + if (ignore === true) { + if (this._datafeed_config.indices_options === undefined) { + this._datafeed_config.indices_options = {}; + } + this._datafeed_config.indices_options.ignore_unavailable = true; + } else { + if (this._datafeed_config.indices_options !== undefined) { + delete this._datafeed_config.indices_options.ignore_unavailable; + + // if no other properties are set, remove indices_options + if (Object.keys(this._datafeed_config.indices_options).length === 0) { + delete this._datafeed_config.indices_options; + } + } + } + } + public get scriptFields(): Field[] { return this._scriptFields; } diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/advanced_section.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/advanced_section.tsx index 6774db9225fd4..e0ad13e1d1c00 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/advanced_section.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/advanced_section.tsx @@ -20,6 +20,7 @@ import { DedicatedIndexSwitch } from './components/dedicated_index'; import { ModelMemoryLimitInput } from '../../../common/model_memory_limit'; import { JobCreatorContext } from '../../../job_creator_context'; import { JOB_TYPE } from '../../../../../../../../../common/constants/new_job'; +import { IgnoreUnavailableSwitch } from './components/ignore_unavailable'; const buttonContent = i18n.translate( 'xpack.ml.newJob.wizard.jobDetailsStep.advancedSectionButton', @@ -43,12 +44,22 @@ export const AdvancedSection: FC = ({ advancedExpanded, setAdvancedExpand - + + + + + + + + + + + ); } @@ -71,13 +82,39 @@ export const AdvancedSection: FC = ({ advancedExpanded, setAdvancedExpand > - -
+ + + + + + + + + + + + + + + + + + + + ); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/annotations/annotations_switch.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/annotations/annotations_switch.tsx index 12294a2e3fed4..154d0785e244e 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/annotations/annotations_switch.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/annotations/annotations_switch.tsx @@ -7,7 +7,7 @@ import React, { FC, useState, useContext, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiCallOut, EuiSpacer, EuiSwitch } from '@elastic/eui'; +import { EuiCallOut, EuiSwitch } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { JobCreatorContext } from '../../../../../job_creator_context'; import { Description } from './description'; @@ -62,7 +62,6 @@ export const AnnotationsSwitch: FC = () => { iconType="help" /> )} - ); }; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/ignore_unavailable/description.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/ignore_unavailable/description.tsx new file mode 100644 index 0000000000000..5e40856084246 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/ignore_unavailable/description.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, FC } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiCode, EuiDescribedFormGroup, EuiFormRow } from '@elastic/eui'; + +export const Description: FC = memo(({ children }) => { + const title = i18n.translate( + 'xpack.ml.newJob.wizard.jobDetailsStep.advancedSection.ignoreUnavailable.title', + { + defaultMessage: 'Ignore unavailable indices', + } + ); + return ( + {title}} + description={ + + ignore_unavailable + + ), + }} + /> + } + > + + <>{children} + + + ); +}); diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/ignore_unavailable/ignore_unavailable_switch.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/ignore_unavailable/ignore_unavailable_switch.tsx new file mode 100644 index 0000000000000..aa6be80329c76 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/ignore_unavailable/ignore_unavailable_switch.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useState, useContext, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiSwitch } from '@elastic/eui'; +import { JobCreatorContext } from '../../../../../job_creator_context'; +import { Description } from './description'; + +export const IgnoreUnavailableSwitch: FC = () => { + const { jobCreator, jobCreatorUpdate } = useContext(JobCreatorContext); + const [ignoreUnavailable, setIgnoreUnavailable] = useState(jobCreator.ignoreUnavailable); + + useEffect(() => { + jobCreator.ignoreUnavailable = ignoreUnavailable; + jobCreatorUpdate(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ignoreUnavailable]); + + function toggleIgnoreUnavailable() { + setIgnoreUnavailable(!ignoreUnavailable); + } + + return ( + + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/ignore_unavailable/index.ts b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/ignore_unavailable/index.ts new file mode 100644 index 0000000000000..997ee4f81a175 --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/ignore_unavailable/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { IgnoreUnavailableSwitch } from './ignore_unavailable_switch'; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/model_plot/model_plot_switch.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/model_plot/model_plot_switch.tsx index 631922a388faa..167335bacab77 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/model_plot/model_plot_switch.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/pages/components/job_details_step/components/advanced_section/components/model_plot/model_plot_switch.tsx @@ -7,7 +7,7 @@ import React, { FC, useState, useContext, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiSpacer, EuiSwitch } from '@elastic/eui'; +import { EuiSwitch } from '@elastic/eui'; import { JobCreatorContext } from '../../../../../job_creator_context'; import { Description } from './description'; import { MMLCallout } from '../mml_callout'; @@ -58,7 +58,6 @@ export const ModelPlotSwitch: FC = () => { />
- ); }; From 7c69a34b7bd557796390b18d4c77c78ce368ed5d Mon Sep 17 00:00:00 2001 From: Saarika Bhasi <55930906+saarikabhasi@users.noreply.github.com> Date: Tue, 25 Apr 2023 10:28:28 -0400 Subject: [PATCH 25/29] [Search Application] [Preview] Add configuration button with Nav items (#155568) ## Summary * Add configuration Button and comment out changes for View this API call - see [thread](https://elastic.slack.com/archives/C02U50QNEAG/p1682089775653529?thread_ts=1681758090.657279&cid=C02U50QNEAG) ### Screen Recording https://user-images.githubusercontent.com/55930906/234107309-40542d5f-253e-4e5b-8c2c-418530446e72.mov --- .../engine_search_preview.tsx | 246 ++++++++++++++++-- 1 file changed, 222 insertions(+), 24 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_search_preview/engine_search_preview.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_search_preview/engine_search_preview.tsx index 2dfaa672459e8..5cd4895c3aa35 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_search_preview/engine_search_preview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/engine/engine_search_preview/engine_search_preview.tsx @@ -7,9 +7,23 @@ import React, { useState, useMemo } from 'react'; -import { useValues } from 'kea'; +import { useActions, useValues } from 'kea'; -import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiLink, EuiSpacer } from '@elastic/eui'; +import { + EuiButtonEmpty, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiIcon, + EuiLink, + EuiPanel, + EuiPopover, + EuiSpacer, + EuiTextColor, + EuiTitle, +} from '@elastic/eui'; import { PagingInfo, Results, @@ -24,18 +38,27 @@ import EnginesAPIConnector, { SearchResponse, } from '@elastic/search-ui-engines-connector'; import { HttpSetup } from '@kbn/core-http-browser'; + import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { docLinks } from '../../../../shared/doc_links'; +import { generateEncodedPath } from '../../../../shared/encode_path_params'; import { HttpLogic } from '../../../../shared/http'; -import { EngineViewTabs } from '../../../routes'; +import { KibanaLogic } from '../../../../shared/kibana'; +import { TelemetryLogic } from '../../../../shared/telemetry'; +import { + EngineViewTabs, + SearchApplicationConnectTabs, + SearchApplicationContentTabs, + SEARCH_APPLICATION_CONNECT_PATH, + SEARCH_APPLICATION_CONTENT_PATH, +} from '../../../routes'; import { EnterpriseSearchEnginesPageTemplate } from '../../layout/engines_page_template'; import { EngineIndicesLogic } from '../engine_indices_logic'; import { EngineViewLogic } from '../engine_view_logic'; -import { APICallFlyout, APICallData } from './api_call_flyout'; import { DocumentProvider } from './document_context'; import { DocumentFlyout } from './document_flyout'; import { EngineSearchPreviewLogic } from './engine_search_preview_logic'; @@ -53,8 +76,7 @@ import { class InternalEngineTransporter implements Transporter { constructor( private http: HttpSetup, - private engineName: string, - private setLastAPICall: (apiCallData: APICallData) => void + private engineName: string // uncomment and add setLastAPICall to constructor when view this API call is needed // private setLastAPICall?: (apiCallData: APICallData) => void ) {} async performRequest(request: SearchRequest) { @@ -64,7 +86,7 @@ class InternalEngineTransporter implements Transporter { body: JSON.stringify(request), }); - this.setLastAPICall({ request, response }); + // this.setLastAPICall({ request, response }); Uncomment when view this API call is needed const withUniqueIds = { ...response, @@ -87,20 +109,184 @@ class InternalEngineTransporter implements Transporter { } } -const pageTitle = i18n.translate('xpack.enterpriseSearch.content.engine.searchPreview.pageTitle', { - defaultMessage: 'Search Preview', -}); +interface ConfigurationPopOverProps { + engineName: string; + setCloseConfiguration: () => void; + showConfiguration: boolean; +} + +const ConfigurationPopover: React.FC = ({ + engineName, + showConfiguration, + setCloseConfiguration, +}) => { + const { navigateToUrl } = useValues(KibanaLogic); + const { engineData } = useValues(EngineViewLogic); + const { openDeleteEngineModal } = useActions(EngineViewLogic); + const { sendEnterpriseSearchTelemetry } = useActions(TelemetryLogic); + return ( + <> + + {i18n.translate( + 'xpack.enterpriseSearch.content.engine.searchPreview.configuration.buttonTitle', + { + defaultMessage: 'Configuration', + } + )} +
+ } + > + + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.content.engine.searchPreview.configuration.contentTitle', + { + defaultMessage: 'Content', + } + )} +

+
+
+ + + + navigateToUrl( + generateEncodedPath(SEARCH_APPLICATION_CONTENT_PATH, { + contentTabId: SearchApplicationContentTabs.INDICES, + engineName, + }) + ) + } + > + {i18n.translate( + 'xpack.enterpriseSearch.content.engine.searchPreview.configuration.content.Indices', + { + defaultMessage: 'Indices', + } + )} + + + navigateToUrl( + generateEncodedPath(SEARCH_APPLICATION_CONTENT_PATH, { + contentTabId: SearchApplicationContentTabs.SCHEMA, + engineName, + }) + ) + } + > + {i18n.translate( + 'xpack.enterpriseSearch.content.engine.searchPreview.configuration.content.Schema', + { + defaultMessage: 'Schema', + } + )} + + + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.content.engine.searchPreview.configuration.connectTitle', + { + defaultMessage: 'Connect', + } + )} +

+
+
+ + + navigateToUrl( + generateEncodedPath(SEARCH_APPLICATION_CONNECT_PATH, { + connectTabId: SearchApplicationConnectTabs.API, + engineName, + }) + ) + } + > + {i18n.translate( + 'xpack.enterpriseSearch.content.engine.searchPreview.configuration.connect.Api', + { + defaultMessage: 'API', + } + )} + + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.content.engine.searchPreview.configuration.settingsTitle', + { + defaultMessage: 'Settings', + } + )} +

+
+
+ + } + onClick={() => { + if (engineData) { + openDeleteEngineModal(); + sendEnterpriseSearchTelemetry({ + action: 'clicked', + metric: 'entSearchContent-engines-engineView-deleteEngine', + }); + } + }} + > + +

+ {i18n.translate( + 'xpack.enterpriseSearch.content.engine.searchPreview.configuration.settings.delete', + { + defaultMessage: 'Delete this app', + } + )} +

+
+
+
+ + + ); +}; export const EngineSearchPreview: React.FC = () => { const { http } = useValues(HttpLogic); - const [showAPICallFlyout, setShowAPICallFlyout] = useState(false); - const [lastAPICall, setLastAPICall] = useState(null); + // const [showAPICallFlyout, setShowAPICallFlyout] = useState(false); Uncomment when view this API call is needed + const [showConfigurationPopover, setShowConfigurationPopover] = useState(false); + // const [lastAPICall, setLastAPICall] = useState(null); Uncomment when view this API call is needed const { engineName, isLoadingEngine } = useValues(EngineViewLogic); const { resultFields, searchableFields, sortableFields } = useValues(EngineSearchPreviewLogic); const { engineData } = useValues(EngineIndicesLogic); const config: SearchDriverOptions = useMemo(() => { - const transporter = new InternalEngineTransporter(http, engineName, setLastAPICall); + const transporter = new InternalEngineTransporter(http, engineName); const connector = new EnginesAPIConnector(transporter); return { @@ -112,27 +298,35 @@ export const EngineSearchPreview: React.FC = () => { search_fields: searchableFields, }, }; - }, [http, engineName, setLastAPICall, resultFields, searchableFields]); + }, [http, engineName, resultFields, searchableFields]); if (!engineData) return null; return ( + ), rightSideItems: [ <> - setShowAPICallFlyout(true)} - isLoading={lastAPICall == null} - > - View this API call - + setShowConfigurationPopover(!showConfigurationPopover)} + /> , ], }} @@ -167,6 +361,9 @@ export const EngineSearchPreview: React.FC = () => { + {/* + Uncomment when view this API call needed + {showAPICallFlyout && lastAPICall && ( setShowAPICallFlyout(false)} @@ -174,6 +371,7 @@ export const EngineSearchPreview: React.FC = () => { engineName={engineName} /> )} + */} ); From 3864554b3661fd65106e0d9c5c4d50cfa167dd68 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Tue, 25 Apr 2023 16:30:11 +0200 Subject: [PATCH 26/29] [Synthetics] Monitor overview item display median duration (#155694) ## Summary Display median duration instead of avg on monitor overview item image --- .../monitor_summary/duration_panel.tsx | 8 +-- .../monitor_summary/duration_sparklines.tsx | 4 +- .../overview/overview/metric_item.tsx | 51 +++++++++++++++---- .../overview/overview/overview_grid.test.tsx | 22 +++++--- .../overview/overview/overview_grid_item.tsx | 12 ++++- .../hooks/use_last_50_duration_chart.test.ts | 10 +++- .../hooks/use_last_50_duration_chart.ts | 29 ++++++++--- .../synthetics/utils/formatting/format.ts | 15 +++++- .../translations/translations/fr-FR.json | 3 +- .../translations/translations/ja-JP.json | 3 +- .../translations/translations/zh-CN.json | 3 +- 11 files changed, 121 insertions(+), 39 deletions(-) diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_panel.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_panel.tsx index 85ab6773033be..7ca1d1e003cc4 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_panel.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_panel.tsx @@ -42,7 +42,7 @@ export const DurationPanel = (props: DurationPanelProps) => { attributes={[ { time: props, - name: AVG_DURATION_LABEL, + name: MEDIAN_DURATION_LABEL, dataType: 'synthetics', selectedMetricField: 'monitor_duration', reportDefinitions: { @@ -55,9 +55,9 @@ export const DurationPanel = (props: DurationPanelProps) => { ); }; -export const AVG_DURATION_LABEL = i18n.translate( - 'xpack.synthetics.monitorDetails.summary.avgDuration', +export const MEDIAN_DURATION_LABEL = i18n.translate( + 'xpack.synthetics.monitorDetails.summary.medianDuration', { - defaultMessage: 'Avg. duration', + defaultMessage: 'Median duration', } ); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_sparklines.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_sparklines.tsx index 1c1370d4da3ab..5851d1c47cdf5 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_sparklines.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/duration_sparklines.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { ReportTypes } from '@kbn/exploratory-view-plugin/public'; import { useTheme } from '@kbn/observability-plugin/public'; -import { AVG_DURATION_LABEL } from './duration_panel'; +import { MEDIAN_DURATION_LABEL } from './duration_panel'; import { useMonitorQueryId } from '../hooks/use_monitor_query_id'; import { ClientPluginsStart } from '../../../../../plugin'; import { useSelectedLocation } from '../hooks/use_selected_location'; @@ -47,7 +47,7 @@ export const DurationSparklines = (props: DurationSparklinesProps) => { { seriesType: 'area', time: props, - name: AVG_DURATION_LABEL, + name: MEDIAN_DURATION_LABEL, dataType: 'synthetics', selectedMetricField: 'monitor.duration.us', reportDefinitions: { diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/metric_item.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/metric_item.tsx index 88cfaf75e061c..369010408917c 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/metric_item.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/metric_item.tsx @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; import React, { useState } from 'react'; import { Chart, Settings, Metric, MetricTrendShape } from '@elastic/charts'; -import { EuiPanel } from '@elastic/eui'; +import { EuiPanel, EuiIconTip, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { DARK_THEME } from '@elastic/charts'; import { useTheme } from '@kbn/observability-plugin/public'; import { useDispatch, useSelector } from 'react-redux'; @@ -46,13 +46,19 @@ export const getColor = ( export const MetricItem = ({ monitor, - averageDuration, + medianDuration, + maxDuration, + minDuration, + avgDuration, data, onClick, }: { monitor: MonitorOverviewItem; data: Array<{ x: number; y: number }>; - averageDuration: number; + medianDuration: number; + avgDuration: number; + minDuration: number; + maxDuration: number; onClick: (params: { id: string; configId: string; location: string; locationId: string }) => void; }) => { const [isMouseOver, setIsMouseOver] = useState(false); @@ -119,15 +125,42 @@ export const MetricItem = ({ { title: monitor.name, subtitle: locationName, - value: averageDuration, + value: medianDuration, trendShape: MetricTrendShape.Area, trend: data, extra: ( - - {i18n.translate('xpack.synthetics.overview.duration.label', { - defaultMessage: 'Duration Avg.', - })} - + + + {i18n.translate('xpack.synthetics.overview.duration.label', { + defaultMessage: 'Duration', + })} + + + + + ), valueFormatter: (d: number) => formatDuration(d), color: getColor(theme, monitor.isEnabled, status), diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid.test.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid.test.tsx index 8238d7b26b62a..ed470f6f24bce 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid.test.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid.test.tsx @@ -64,9 +64,14 @@ describe('Overview Grid', () => { const perPage = 20; it('renders correctly', async () => { - jest - .spyOn(hooks, 'useLast50DurationChart') - .mockReturnValue({ data: getMockChart(), averageDuration: 30000, loading: false }); + jest.spyOn(hooks, 'useLast50DurationChart').mockReturnValue({ + data: getMockChart(), + avgDuration: 30000, + minDuration: 0, + maxDuration: 50000, + medianDuration: 15000, + loading: false, + }); const { getByText, getAllByTestId, queryByText } = render(, { state: { @@ -124,9 +129,14 @@ describe('Overview Grid', () => { }); it('displays showing all monitors label when reaching the end of the list', async () => { - jest - .spyOn(hooks, 'useLast50DurationChart') - .mockReturnValue({ data: getMockChart(), averageDuration: 30000, loading: false }); + jest.spyOn(hooks, 'useLast50DurationChart').mockReturnValue({ + data: getMockChart(), + avgDuration: 30000, + minDuration: 0, + maxDuration: 50000, + medianDuration: 15000, + loading: false, + }); const { getByText } = render(, { state: { diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid_item.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid_item.tsx index de153cf01eca7..952a48d424733 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid_item.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_grid_item.tsx @@ -32,12 +32,20 @@ export const OverviewGridItem = ({ const { timestamp } = useStatusByLocationOverview(monitor.configId, locationName); - const { data, averageDuration } = useLast50DurationChart({ + const { data, medianDuration, maxDuration, avgDuration, minDuration } = useLast50DurationChart({ locationId: monitor.location?.id, monitorId: monitor.id, timestamp, }); return ( - + ); }; diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_last_50_duration_chart.test.ts b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_last_50_duration_chart.test.ts index bb2e74712322f..47cb97793bab1 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_last_50_duration_chart.test.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_last_50_duration_chart.test.ts @@ -33,7 +33,10 @@ describe('useLast50DurationChart', () => { { wrapper: WrappedHelper } ); expect(result.current).toEqual({ - averageDuration: 4.5, + medianDuration: 5, + maxDuration: 9, + minDuration: 0, + avgDuration: 4.5, data: [ { x: 0, @@ -132,7 +135,10 @@ describe('useLast50DurationChart', () => { ]; expect(result.current).toEqual({ - averageDuration: data.reduce((acc, datum) => (acc += datum.y), 0) / 9, + medianDuration: [...data].sort((a, b) => a.y - b.y)[Math.floor(data.length / 2)].y, + maxDuration: 9, + minDuration: 0, + avgDuration: 4.4, data, loading: false, }); diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_last_50_duration_chart.ts b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_last_50_duration_chart.ts index 78e4cc6cecbbf..8cb7d524635c7 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_last_50_duration_chart.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/hooks/use_last_50_duration_chart.ts @@ -28,20 +28,23 @@ export function useLast50DurationChart({ size: 50, timestamp, }); - const { data, averageDuration } = useMemo(() => { + const { data, median, min, max, avg } = useMemo(() => { if (loading) { return { data: [], - averageDuration: 0, + median: 0, + avg: 0, + min: 0, + max: 0, }; } - let totalDuration = 0; + + // calculate min, max, average duration and median const coords = hits .reverse() // results are returned in desc order by timestamp. Reverse to ensure the data is in asc order by timestamp .map((hit, index) => { const duration = hit?.['monitor.duration.us']?.[0]; - totalDuration += duration || 0; if (duration === undefined) { return null; } @@ -52,18 +55,30 @@ export function useLast50DurationChart({ }) .filter((item) => item !== null); + const sortedByDuration = [...hits].sort( + (a, b) => (a?.['monitor.duration.us']?.[0] || 0) - (b?.['monitor.duration.us']?.[0] || 0) + ); + return { data: coords as Array<{ x: number; y: number }>, - averageDuration: totalDuration / coords.length, + median: sortedByDuration[Math.floor(hits.length / 2)]?.['monitor.duration.us']?.[0] || 0, + avg: + sortedByDuration.reduce((acc, curr) => acc + (curr?.['monitor.duration.us']?.[0] || 0), 0) / + hits.length, + min: sortedByDuration[0]?.['monitor.duration.us']?.[0] || 0, + max: sortedByDuration[sortedByDuration.length - 1]?.['monitor.duration.us']?.[0] || 0, }; }, [hits, loading]); return useMemo( () => ({ data, - averageDuration, + medianDuration: median, + avgDuration: avg, + minDuration: min, + maxDuration: max, loading, }), - [loading, data, averageDuration] + [data, median, avg, min, max, loading] ); } diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/utils/formatting/format.ts b/x-pack/plugins/synthetics/public/apps/synthetics/utils/formatting/format.ts index 5dab17f55ad68..433d4a9c8cfec 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/utils/formatting/format.ts +++ b/x-pack/plugins/synthetics/public/apps/synthetics/utils/formatting/format.ts @@ -25,8 +25,14 @@ export const microsToMillis = (microseconds: number | null): number | null => { return Math.floor(microseconds / NUM_MICROSECONDS_IN_MILLISECOND); }; -export const formatDuration = (durationMicros: number) => { +export const formatDuration = (durationMicros: number, { noSpace }: { noSpace?: true } = {}) => { if (durationMicros < MILLIS_LIMIT) { + if (noSpace) { + return i18n.translate('xpack.synthetics.overview.durationMsFormattingNoSpace', { + values: { millis: microsToMillis(durationMicros) }, + defaultMessage: '{millis}ms', + }); + } return i18n.translate('xpack.synthetics.overview.durationMsFormatting', { values: { millis: microsToMillis(durationMicros) }, defaultMessage: '{millis} ms', @@ -34,6 +40,13 @@ export const formatDuration = (durationMicros: number) => { } const seconds = (durationMicros / ONE_SECOND_AS_MICROS).toFixed(0); + if (noSpace) { + return i18n.translate('xpack.synthetics.overview.durationSecondsFormattingNoSpace', { + values: { seconds }, + defaultMessage: '{seconds}s', + }); + } + return i18n.translate('xpack.synthetics.overview.durationSecondsFormatting', { values: { seconds }, defaultMessage: '{seconds} s', diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index d3a86b78b9551..191f6310ace50 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -34806,7 +34806,6 @@ "xpack.synthetics.monitorDetails.statusBar.pingType.icmp": "ICMP", "xpack.synthetics.monitorDetails.statusBar.pingType.tcp": "TCP", "xpack.synthetics.monitorDetails.summary.availability": "Disponibilité", - "xpack.synthetics.monitorDetails.summary.avgDuration": "Durée moy.", "xpack.synthetics.monitorDetails.summary.brushArea": "Brosser une zone pour une plus haute fidélité", "xpack.synthetics.monitorDetails.summary.complete": "Terminé", "xpack.synthetics.monitorDetails.summary.duration": "Durée", @@ -37947,4 +37946,4 @@ "xpack.painlessLab.title": "Painless Lab", "xpack.painlessLab.walkthroughButtonLabel": "Présentation" } -} \ No newline at end of file +} diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 723902ad29739..48521b3e3b558 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -34785,7 +34785,6 @@ "xpack.synthetics.monitorDetails.statusBar.pingType.icmp": "ICMP", "xpack.synthetics.monitorDetails.statusBar.pingType.tcp": "TCP", "xpack.synthetics.monitorDetails.summary.availability": "可用性", - "xpack.synthetics.monitorDetails.summary.avgDuration": "平均期間", "xpack.synthetics.monitorDetails.summary.brushArea": "信頼度を高めるためにエリアを精査", "xpack.synthetics.monitorDetails.summary.complete": "完了", "xpack.synthetics.monitorDetails.summary.duration": "期間", @@ -37915,4 +37914,4 @@ "xpack.painlessLab.title": "Painless Lab", "xpack.painlessLab.walkthroughButtonLabel": "実地検証" } -} \ No newline at end of file +} diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 469bb9a2cf748..d3b2c66d7d642 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -34802,7 +34802,6 @@ "xpack.synthetics.monitorDetails.statusBar.pingType.icmp": "ICMP", "xpack.synthetics.monitorDetails.statusBar.pingType.tcp": "TCP", "xpack.synthetics.monitorDetails.summary.availability": "可用性", - "xpack.synthetics.monitorDetails.summary.avgDuration": "平均持续时间", "xpack.synthetics.monitorDetails.summary.brushArea": "轻刷某个区域以提高保真度", "xpack.synthetics.monitorDetails.summary.complete": "已完成", "xpack.synthetics.monitorDetails.summary.duration": "持续时间", @@ -37943,4 +37942,4 @@ "xpack.painlessLab.title": "Painless 实验室", "xpack.painlessLab.walkthroughButtonLabel": "指导" } -} \ No newline at end of file +} From b6a91f318ecc8398a166803fcef039b7e8424c1c Mon Sep 17 00:00:00 2001 From: Miriam <31922082+MiriamAparicio@users.noreply.github.com> Date: Tue, 25 Apr 2023 15:43:21 +0100 Subject: [PATCH 27/29] [APM] Add table tabs showing summary of metrics (#153044) Closes https://github.com/elastic/kibana/issues/146877 Notes: - Crash rate is calculated per sessions, the same session ID is kept after a crash, so a session can have more than one crash. - App number of launches is not available - Error rate stays out for now - Http requests, `host.os.version` and `service.version` is only available on transactions, metrics and errors. Not spans for now, there are two issues opened to fix this for the apm mobile agents team - Instead of the View Load (not available), we show Throughput - The filters (+ -) will be added in a follow-up PR Pending: - [x] API tests - [x] e2e tests https://user-images.githubusercontent.com/31922082/234267965-e5e1e411-87c6-40b8-9e94-31d792f9d806.mov --------- Co-authored-by: Yngrid Coello --- .../src/lib/apm/apm_fields.ts | 2 + .../src/lib/apm/mobile_device.ts | 22 +- .../src/scenarios/mobile.ts | 88 ++++--- x-pack/plugins/apm/common/data_source.ts | 3 +- x-pack/plugins/apm/common/document_type.ts | 1 + .../__snapshots__/es_fields.test.ts.snap | 6 + x-pack/plugins/apm/common/es_fields/apm.ts | 1 + .../read_only_user/mobile/generate_data.ts | 221 ++++++++++++++++++ .../mobile/mobile_transactions.cy.ts | 68 ++++++ .../app/mobile/transaction_overview/index.tsx | 12 +- .../app_version_tab.tsx | 61 +++++ .../transaction_overview_tabs/devices_tab.tsx | 58 +++++ .../transaction_overview_tabs/index.tsx | 77 ++++++ .../os_version_tab.tsx | 61 +++++ .../stats_list/get_columns.tsx | 148 ++++++++++++ .../stats_list/index.tsx | 67 ++++++ .../transactions_tab.tsx | 40 ++++ .../use_mobile_statistics_fetcher.ts | 120 ++++++++++ .../routing/mobile_service_detail/index.tsx | 1 + .../shared/transactions_table/index.tsx | 53 +++-- .../helpers/create_es_client/document_type.ts | 4 + ...get_mobile_detailed_statistics_by_field.ts | 214 +++++++++++++++++ .../get_mobile_main_statistics_by_field.ts | 186 +++++++++++++++ .../plugins/apm/server/routes/mobile/route.ts | 90 ++++++- .../tests/mobile/generate_mobile_data.ts | 11 +- ...obile_detailed_statistics_by_field.spec.ts | 132 +++++++++++ .../mobile_main_statistics_by_field.spec.ts | 143 ++++++++++++ 27 files changed, 1826 insertions(+), 64 deletions(-) create mode 100644 x-pack/plugins/apm/ftr_e2e/cypress/e2e/read_only_user/mobile/generate_data.ts create mode 100644 x-pack/plugins/apm/ftr_e2e/cypress/e2e/read_only_user/mobile/mobile_transactions.cy.ts create mode 100644 x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/app_version_tab.tsx create mode 100644 x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/devices_tab.tsx create mode 100644 x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/index.tsx create mode 100644 x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/os_version_tab.tsx create mode 100644 x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/stats_list/get_columns.tsx create mode 100644 x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/stats_list/index.tsx create mode 100644 x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/transactions_tab.tsx create mode 100644 x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/use_mobile_statistics_fetcher.ts create mode 100644 x-pack/plugins/apm/server/routes/mobile/get_mobile_detailed_statistics_by_field.ts create mode 100644 x-pack/plugins/apm/server/routes/mobile/get_mobile_main_statistics_by_field.ts create mode 100644 x-pack/test/apm_api_integration/tests/mobile/mobile_detailed_statistics_by_field.spec.ts create mode 100644 x-pack/test/apm_api_integration/tests/mobile/mobile_main_statistics_by_field.spec.ts diff --git a/packages/kbn-apm-synthtrace-client/src/lib/apm/apm_fields.ts b/packages/kbn-apm-synthtrace-client/src/lib/apm/apm_fields.ts index 808894dab55ef..d5efb1dbc2c97 100644 --- a/packages/kbn-apm-synthtrace-client/src/lib/apm/apm_fields.ts +++ b/packages/kbn-apm-synthtrace-client/src/lib/apm/apm_fields.ts @@ -22,6 +22,7 @@ export type ApmApplicationMetricFields = Partial<{ 'faas.timeout': number; 'faas.coldstart_duration': number; 'faas.duration': number; + 'application.launch.time': number; }>; export type ApmUserAgentFields = Partial<{ @@ -88,6 +89,7 @@ export type ApmFields = Fields<{ 'error.grouping_key': string; 'error.grouping_name': string; 'error.id': string; + 'error.type': string; 'event.ingested': number; 'event.name': string; 'event.outcome': string; diff --git a/packages/kbn-apm-synthtrace-client/src/lib/apm/mobile_device.ts b/packages/kbn-apm-synthtrace-client/src/lib/apm/mobile_device.ts index eddb7d6c99d18..252590104e7a2 100644 --- a/packages/kbn-apm-synthtrace-client/src/lib/apm/mobile_device.ts +++ b/packages/kbn-apm-synthtrace-client/src/lib/apm/mobile_device.ts @@ -9,8 +9,10 @@ import { Entity } from '../entity'; import { Span } from './span'; import { Transaction } from './transaction'; -import { ApmFields, SpanParams, GeoLocation } from './apm_fields'; +import { ApmFields, SpanParams, GeoLocation, ApmApplicationMetricFields } from './apm_fields'; import { generateLongId } from '../utils/generate_id'; +import { Metricset } from './metricset'; +import { ApmError } from './apm_error'; export interface DeviceInfo { manufacturer: string; @@ -115,6 +117,7 @@ export class MobileDevice extends Entity { return this; } + // FIXME synthtrace shouldn't have side-effects like this. We should use an API like .session() which returns a session startNewSession() { this.fields['session.id'] = generateLongId(); return this; @@ -238,4 +241,21 @@ export class MobileDevice extends Entity { return this.span(spanParameters); } + + appMetrics(metrics: ApmApplicationMetricFields) { + return new Metricset({ + ...this.fields, + 'metricset.name': 'app', + ...metrics, + }); + } + + crash({ message, groupingName }: { message: string; groupingName?: string }) { + return new ApmError({ + ...this.fields, + 'error.type': 'crash', + 'error.exception': [{ message, ...{ type: 'crash' } }], + 'error.grouping_name': groupingName || message, + }); + } } diff --git a/packages/kbn-apm-synthtrace/src/scenarios/mobile.ts b/packages/kbn-apm-synthtrace/src/scenarios/mobile.ts index 6db2d17b624f9..0ca4abf07bf91 100644 --- a/packages/kbn-apm-synthtrace/src/scenarios/mobile.ts +++ b/packages/kbn-apm-synthtrace/src/scenarios/mobile.ts @@ -20,6 +20,16 @@ const ENVIRONMENT = getSynthtraceEnvironment(__filename); type DeviceMetadata = DeviceInfo & OSInfo; +const modelIdentifiersWithCrashes = [ + 'SM-G930F', + 'HUAWEI P2-0000', + 'Pixel 3a', + 'LG K10', + 'iPhone11,8', + 'Watch6,8', + 'iPad12,2', +]; + const ANDROID_DEVICES: DeviceMetadata[] = [ { manufacturer: 'Samsung', @@ -354,34 +364,40 @@ const scenario: Scenario = async ({ scenarioOpts, logger }) => { device.startNewSession(); const framework = device.fields['device.manufacturer'] === 'Apple' ? 'iOS' : 'Android Activity'; + const couldCrash = modelIdentifiersWithCrashes.includes( + device.fields['device.model.identifier'] ?? '' + ); + const startTx = device + .transaction('Start View - View Appearing', framework) + .timestamp(timestamp) + .duration(500) + .success() + .children( + device + .span({ + spanName: 'onCreate', + spanType: 'app', + spanSubtype: 'external', + 'service.target.type': 'http', + 'span.destination.service.resource': 'external', + }) + .duration(50) + .success() + .timestamp(timestamp + 20), + device + .httpSpan({ + spanName: 'GET backend:1234', + httpMethod: 'GET', + httpUrl: 'https://backend:1234/api/start', + }) + .duration(800) + .failure() + .timestamp(timestamp + 400) + ); return [ - device - .transaction('Start View - View Appearing', framework) - .timestamp(timestamp) - .duration(500) - .success() - .children( - device - .span({ - spanName: 'onCreate', - spanType: 'app', - spanSubtype: 'external', - 'service.target.type': 'http', - 'span.destination.service.resource': 'external', - }) - .duration(50) - .success() - .timestamp(timestamp + 20), - device - .httpSpan({ - spanName: 'GET backend:1234', - httpMethod: 'GET', - httpUrl: 'https://backend:1234/api/start', - }) - .duration(800) - .failure() - .timestamp(timestamp + 400) - ), + couldCrash && index % 2 === 0 + ? startTx.errors(device.crash({ message: 'error' }).timestamp(timestamp)) + : startTx, device .transaction('Second View - View Appearing', framework) .timestamp(10000 + timestamp) @@ -418,7 +434,23 @@ const scenario: Scenario = async ({ scenarioOpts, logger }) => { }); }; - return [...androidDevices, ...iOSDevices].map((device) => sessionTransactions(device)); + const appLaunchMetrics = (device: MobileDevice) => { + return clickRate.generator((timestamp, index) => + device + .appMetrics({ + 'application.launch.time': 100 * (index + 1), + }) + .timestamp(timestamp) + ); + }; + + return [ + ...androidDevices.flatMap((device) => [ + sessionTransactions(device), + appLaunchMetrics(device), + ]), + ...iOSDevices.map((device) => sessionTransactions(device)), + ]; }, }; }; diff --git a/x-pack/plugins/apm/common/data_source.ts b/x-pack/plugins/apm/common/data_source.ts index b951677a8cb65..9282fb372ac72 100644 --- a/x-pack/plugins/apm/common/data_source.ts +++ b/x-pack/plugins/apm/common/data_source.ts @@ -13,7 +13,8 @@ type AnyApmDocumentType = | ApmDocumentType.TransactionMetric | ApmDocumentType.TransactionEvent | ApmDocumentType.ServiceDestinationMetric - | ApmDocumentType.ServiceSummaryMetric; + | ApmDocumentType.ServiceSummaryMetric + | ApmDocumentType.ErrorEvent; export interface ApmDataSource< TDocumentType extends AnyApmDocumentType = AnyApmDocumentType diff --git a/x-pack/plugins/apm/common/document_type.ts b/x-pack/plugins/apm/common/document_type.ts index 333b9f69e0d0f..92a17c3125a96 100644 --- a/x-pack/plugins/apm/common/document_type.ts +++ b/x-pack/plugins/apm/common/document_type.ts @@ -11,6 +11,7 @@ export enum ApmDocumentType { TransactionEvent = 'transactionEvent', ServiceDestinationMetric = 'serviceDestinationMetric', ServiceSummaryMetric = 'serviceSummaryMetric', + ErrorEvent = 'error', } export type ApmServiceTransactionDocumentType = diff --git a/x-pack/plugins/apm/common/es_fields/__snapshots__/es_fields.test.ts.snap b/x-pack/plugins/apm/common/es_fields/__snapshots__/es_fields.test.ts.snap index 3ddc94bde0255..9dfb15ed9cb05 100644 --- a/x-pack/plugins/apm/common/es_fields/__snapshots__/es_fields.test.ts.snap +++ b/x-pack/plugins/apm/common/es_fields/__snapshots__/es_fields.test.ts.snap @@ -90,6 +90,8 @@ exports[`Error ERROR_LOG_MESSAGE 1`] = `undefined`; exports[`Error ERROR_PAGE_URL 1`] = `undefined`; +exports[`Error ERROR_TYPE 1`] = `undefined`; + exports[`Error EVENT_NAME 1`] = `undefined`; exports[`Error EVENT_OUTCOME 1`] = `undefined`; @@ -417,6 +419,8 @@ exports[`Span ERROR_LOG_MESSAGE 1`] = `undefined`; exports[`Span ERROR_PAGE_URL 1`] = `undefined`; +exports[`Span ERROR_TYPE 1`] = `undefined`; + exports[`Span EVENT_NAME 1`] = `undefined`; exports[`Span EVENT_OUTCOME 1`] = `"unknown"`; @@ -740,6 +744,8 @@ exports[`Transaction ERROR_LOG_MESSAGE 1`] = `undefined`; exports[`Transaction ERROR_PAGE_URL 1`] = `undefined`; +exports[`Transaction ERROR_TYPE 1`] = `undefined`; + exports[`Transaction EVENT_NAME 1`] = `undefined`; exports[`Transaction EVENT_OUTCOME 1`] = `"unknown"`; diff --git a/x-pack/plugins/apm/common/es_fields/apm.ts b/x-pack/plugins/apm/common/es_fields/apm.ts index 9a1dd15f94a75..141be32365956 100644 --- a/x-pack/plugins/apm/common/es_fields/apm.ts +++ b/x-pack/plugins/apm/common/es_fields/apm.ts @@ -109,6 +109,7 @@ export const ERROR_EXC_MESSAGE = 'error.exception.message'; // only to be used i export const ERROR_EXC_HANDLED = 'error.exception.handled'; // only to be used in es queries, since error.exception is now an array export const ERROR_EXC_TYPE = 'error.exception.type'; export const ERROR_PAGE_URL = 'error.page.url'; +export const ERROR_TYPE = 'error.type'; // METRICS export const METRIC_SYSTEM_FREE_MEMORY = 'system.memory.actual.free'; diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/e2e/read_only_user/mobile/generate_data.ts b/x-pack/plugins/apm/ftr_e2e/cypress/e2e/read_only_user/mobile/generate_data.ts new file mode 100644 index 0000000000000..5b12bd58b76be --- /dev/null +++ b/x-pack/plugins/apm/ftr_e2e/cypress/e2e/read_only_user/mobile/generate_data.ts @@ -0,0 +1,221 @@ +/* + * 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 { apm, timerange } from '@kbn/apm-synthtrace-client'; + +export const SERVICE_VERSIONS = ['2.3', '1.2', '1.1']; + +export function generateMobileData({ from, to }: { from: number; to: number }) { + const galaxy10 = apm + .mobileApp({ + name: 'synth-android', + environment: 'production', + agentName: 'android/java', + }) + .mobileDevice({ serviceVersion: SERVICE_VERSIONS[0] }) + .deviceInfo({ + manufacturer: 'Samsung', + modelIdentifier: 'SM-G973F', + modelName: 'Galaxy S10', + }) + .osInfo({ + osType: 'android', + osVersion: '10', + osFull: 'Android 10, API level 29, BUILD A022MUBU2AUD1', + runtimeVersion: '2.1.0', + }) + .setGeoInfo({ + clientIp: '223.72.43.22', + cityName: 'Beijing', + continentName: 'Asia', + countryIsoCode: 'CN', + countryName: 'China', + regionIsoCode: 'CN-BJ', + regionName: 'Beijing', + location: { coordinates: [116.3861, 39.9143], type: 'Point' }, + }) + .setNetworkConnection({ type: 'wifi' }); + + const galaxy7 = apm + .mobileApp({ + name: 'synth-android', + environment: 'production', + agentName: 'android/java', + }) + .mobileDevice({ serviceVersion: SERVICE_VERSIONS[1] }) + .deviceInfo({ + manufacturer: 'Samsung', + modelIdentifier: 'SM-G930F', + modelName: 'Galaxy S7', + }) + .osInfo({ + osType: 'android', + osVersion: '10', + osFull: 'Android 10, API level 29, BUILD A022MUBU2AUD1', + runtimeVersion: '2.1.0', + }) + .setGeoInfo({ + clientIp: '223.72.43.22', + cityName: 'Beijing', + continentName: 'Asia', + countryIsoCode: 'CN', + countryName: 'China', + regionIsoCode: 'CN-BJ', + regionName: 'Beijing', + location: { coordinates: [116.3861, 39.9143], type: 'Point' }, + }) + .setNetworkConnection({ + type: 'cell', + subType: 'edge', + carrierName: 'M1 Limited', + carrierMNC: '03', + carrierICC: 'SG', + carrierMCC: '525', + }); + + const huaweiP2 = apm + .mobileApp({ + name: 'synth-android', + environment: 'production', + agentName: 'android/java', + }) + .mobileDevice({ serviceVersion: SERVICE_VERSIONS[2] }) + .deviceInfo({ + manufacturer: 'Huawei', + modelIdentifier: 'HUAWEI P2-0000', + modelName: 'HuaweiP2', + }) + .osInfo({ + osType: 'android', + osVersion: '10', + osFull: 'Android 10, API level 29, BUILD A022MUBU2AUD1', + runtimeVersion: '2.1.0', + }) + .setGeoInfo({ + clientIp: '20.24.184.101', + cityName: 'Singapore', + continentName: 'Asia', + countryIsoCode: 'SG', + countryName: 'Singapore', + location: { coordinates: [103.8554, 1.3036], type: 'Point' }, + }) + .setNetworkConnection({ + type: 'cell', + subType: 'edge', + carrierName: 'Osaka Gas Business Create Co., Ltd.', + carrierMNC: '17', + carrierICC: 'JP', + carrierMCC: '440', + }); + + return timerange(from, to) + .interval('5m') + .rate(1) + .generator((timestamp) => { + galaxy10.startNewSession(); + galaxy7.startNewSession(); + huaweiP2.startNewSession(); + return [ + galaxy10 + .transaction('Start View - View Appearing', 'Android Activity') + .timestamp(timestamp) + .duration(500) + .success() + .children( + galaxy10 + .span({ + spanName: 'onCreate', + spanType: 'app', + spanSubtype: 'external', + 'service.target.type': 'http', + 'span.destination.service.resource': 'external', + }) + .duration(50) + .success() + .timestamp(timestamp + 20), + galaxy10 + .httpSpan({ + spanName: 'GET backend:1234', + httpMethod: 'GET', + httpUrl: 'https://backend:1234/api/start', + }) + .duration(800) + .success() + .timestamp(timestamp + 400) + ), + galaxy10 + .transaction('Second View - View Appearing', 'Android Activity') + .timestamp(10000 + timestamp) + .duration(300) + .failure() + .children( + galaxy10 + .httpSpan({ + spanName: 'GET backend:1234', + httpMethod: 'GET', + httpUrl: 'https://backend:1234/api/second', + }) + .duration(400) + .success() + .timestamp(10000 + timestamp + 250) + ), + huaweiP2 + .transaction('Start View - View Appearing', 'huaweiP2 Activity') + .timestamp(timestamp) + .duration(20) + .success() + .children( + huaweiP2 + .span({ + spanName: 'onCreate', + spanType: 'app', + spanSubtype: 'external', + 'service.target.type': 'http', + 'span.destination.service.resource': 'external', + }) + .duration(50) + .success() + .timestamp(timestamp + 20), + huaweiP2 + .httpSpan({ + spanName: 'GET backend:1234', + httpMethod: 'GET', + httpUrl: 'https://backend:1234/api/start', + }) + .duration(800) + .success() + .timestamp(timestamp + 400) + ), + galaxy7 + .transaction('Start View - View Appearing', 'Android Activity') + .timestamp(timestamp) + .duration(20) + .success() + .children( + galaxy7 + .span({ + spanName: 'onCreate', + spanType: 'app', + spanSubtype: 'external', + 'service.target.type': 'http', + 'span.destination.service.resource': 'external', + }) + .duration(50) + .success() + .timestamp(timestamp + 20), + galaxy7 + .httpSpan({ + spanName: 'GET backend:1234', + httpMethod: 'GET', + httpUrl: 'https://backend:1234/api/start', + }) + .duration(800) + .success() + .timestamp(timestamp + 400) + ), + ]; + }); +} diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/e2e/read_only_user/mobile/mobile_transactions.cy.ts b/x-pack/plugins/apm/ftr_e2e/cypress/e2e/read_only_user/mobile/mobile_transactions.cy.ts new file mode 100644 index 0000000000000..85cf055507f3b --- /dev/null +++ b/x-pack/plugins/apm/ftr_e2e/cypress/e2e/read_only_user/mobile/mobile_transactions.cy.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import url from 'url'; +import { synthtrace } from '../../../../synthtrace'; +import { generateMobileData } from './generate_data'; + +const start = '2021-10-10T00:00:00.000Z'; +const end = '2021-10-10T00:15:00.000Z'; + +const mobileTransactionsPageHref = url.format({ + pathname: '/app/apm/mobile-services/synth-android/transactions', + query: { + rangeFrom: start, + rangeTo: end, + }, +}); + +describe('Mobile transactions page', () => { + beforeEach(() => { + cy.loginAsViewerUser(); + }); + + describe('when data is loaded', () => { + before(() => { + synthtrace.index( + generateMobileData({ + from: new Date(start).getTime(), + to: new Date(end).getTime(), + }) + ); + }); + + after(() => { + synthtrace.clean(); + }); + + describe('when click on tab shows correct table', () => { + it('shows version tab', () => { + cy.visitKibana(mobileTransactionsPageHref); + cy.getByTestSubj('apmAppVersionTab') + .click() + .should('have.attr', 'aria-selected', 'true'); + cy.url().should('include', 'mobileSelectedTab=app_version_tab'); + }); + + it('shows OS version tab', () => { + cy.visitKibana(mobileTransactionsPageHref); + cy.getByTestSubj('apmOsVersionTab') + .click() + .should('have.attr', 'aria-selected', 'true'); + cy.url().should('include', 'mobileSelectedTab=os_version_tab'); + }); + + it('shows devices tab', () => { + cy.visitKibana(mobileTransactionsPageHref); + cy.getByTestSubj('apmDevicesTab') + .click() + .should('have.attr', 'aria-selected', 'true'); + cy.url().should('include', 'mobileSelectedTab=devices_tab'); + }); + }); + }); +}); diff --git a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/index.tsx b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/index.tsx index be6963994c639..ce06e04683af9 100644 --- a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/index.tsx @@ -16,11 +16,11 @@ import { useHistory } from 'react-router-dom'; import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; import { useApmParams } from '../../../../hooks/use_apm_params'; import { useTimeRange } from '../../../../hooks/use_time_range'; -import { TransactionsTable } from '../../../shared/transactions_table'; import { replace } from '../../../shared/links/url_helpers'; import { getKueryWithMobileFilters } from '../../../../../common/utils/get_kuery_with_mobile_filters'; import { MobileTransactionCharts } from './transaction_charts'; import { MobileTreemap } from '../charts/mobile_treemap'; +import { TransactionOverviewTabs } from './transaction_overview_tabs'; export function MobileTransactionOverview() { const { @@ -37,6 +37,7 @@ export function MobileTransactionOverview() { kuery, offset, comparisonEnabled, + mobileSelectedTab, }, } = useApmParams('/mobile-services/{serviceName}/transactions'); @@ -88,15 +89,14 @@ export function MobileTransactionOverview() { /> - diff --git a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/app_version_tab.tsx b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/app_version_tab.tsx new file mode 100644 index 0000000000000..deafdeb59d3c5 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/app_version_tab.tsx @@ -0,0 +1,61 @@ +/* + * 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 { TabContentProps } from '.'; +import { isPending } from '../../../../../hooks/use_fetcher'; +import { StatsList } from './stats_list'; +import { SERVICE_VERSION } from '../../../../../../common/es_fields/apm'; +import { useMobileStatisticsFetcher } from './use_mobile_statistics_fetcher'; + +function AppVersionTab({ + environment, + kuery, + start, + end, + comparisonEnabled, + offset, +}: TabContentProps) { + const { + mainStatistics, + mainStatisticsStatus, + detailedStatistics, + detailedStatisticsStatus, + } = useMobileStatisticsFetcher({ + field: SERVICE_VERSION, + environment, + kuery, + start, + end, + comparisonEnabled, + offset, + }); + + return ( + + ); +} + +export const appVersionTab = { + dataTestSubj: 'apmAppVersionTab', + key: 'app_version_tab', + label: i18n.translate( + 'xpack.apm.mobile.transactions.overview.tabs.app.version', + { + defaultMessage: 'App version', + } + ), + component: AppVersionTab, +}; diff --git a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/devices_tab.tsx b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/devices_tab.tsx new file mode 100644 index 0000000000000..4d2f18b046709 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/devices_tab.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { TabContentProps } from '.'; +import { isPending } from '../../../../../hooks/use_fetcher'; +import { StatsList } from './stats_list'; +import { useMobileStatisticsFetcher } from './use_mobile_statistics_fetcher'; +import { DEVICE_MODEL_IDENTIFIER } from '../../../../../../common/es_fields/apm'; + +function DevicesTab({ + environment, + kuery, + start, + end, + comparisonEnabled, + offset, +}: TabContentProps) { + const { + mainStatistics, + mainStatisticsStatus, + detailedStatistics, + detailedStatisticsStatus, + } = useMobileStatisticsFetcher({ + field: DEVICE_MODEL_IDENTIFIER, + environment, + kuery, + start, + end, + comparisonEnabled, + offset, + }); + + return ( + + ); +} + +export const devicesTab = { + dataTestSubj: 'apmDevicesTab', + key: 'devices_tab', + label: i18n.translate('xpack.apm.mobile.transactions.overview.tabs.devices', { + defaultMessage: 'Devices', + }), + component: DevicesTab, +}; diff --git a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/index.tsx b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/index.tsx new file mode 100644 index 0000000000000..c986f5903b7b5 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/index.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { useHistory } from 'react-router-dom'; +import { EuiSpacer, EuiTabs, EuiTab } from '@elastic/eui'; +import { push } from '../../../../shared/links/url_helpers'; +import { transactionsTab } from './transactions_tab'; +import { osVersionTab } from './os_version_tab'; +import { appVersionTab } from './app_version_tab'; +import { devicesTab } from './devices_tab'; + +export interface TabContentProps { + agentName?: string; + environment: string; + start: string; + end: string; + kuery: string; + comparisonEnabled: boolean; + offset?: string; + mobileSelectedTab?: string; +} + +const tabs = [transactionsTab, appVersionTab, osVersionTab, devicesTab]; + +export function TransactionOverviewTabs({ + agentName, + environment, + start, + end, + kuery, + comparisonEnabled, + offset, + mobileSelectedTab, +}: TabContentProps) { + const history = useHistory(); + + const { component: TabContent } = + tabs.find((tab) => tab.key === mobileSelectedTab) ?? transactionsTab; + return ( + <> + + {tabs.map(({ dataTestSubj, key, label }) => ( + { + push(history, { + query: { + mobileSelectedTab: key, + }, + }); + }} + > + {label} + + ))} + + + + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/os_version_tab.tsx b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/os_version_tab.tsx new file mode 100644 index 0000000000000..6eee1f01aae9f --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/os_version_tab.tsx @@ -0,0 +1,61 @@ +/* + * 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 { TabContentProps } from '.'; +import { isPending } from '../../../../../hooks/use_fetcher'; +import { StatsList } from './stats_list'; +import { useMobileStatisticsFetcher } from './use_mobile_statistics_fetcher'; +import { HOST_OS_VERSION } from '../../../../../../common/es_fields/apm'; + +function OSVersionTab({ + environment, + kuery, + start, + end, + comparisonEnabled, + offset, +}: TabContentProps) { + const { + mainStatistics, + mainStatisticsStatus, + detailedStatistics, + detailedStatisticsStatus, + } = useMobileStatisticsFetcher({ + field: HOST_OS_VERSION, + environment, + kuery, + start, + end, + comparisonEnabled, + offset, + }); + + return ( + + ); +} + +export const osVersionTab = { + dataTestSubj: 'apmOsVersionTab', + key: 'os_version_tab', + label: i18n.translate( + 'xpack.apm.mobile.transactions.overview.tabs.os.version', + { + defaultMessage: 'OS version', + } + ), + component: OSVersionTab, +}; diff --git a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/stats_list/get_columns.tsx b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/stats_list/get_columns.tsx new file mode 100644 index 0000000000000..18ac252011357 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/stats_list/get_columns.tsx @@ -0,0 +1,148 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { RIGHT_ALIGNMENT, EuiText } from '@elastic/eui'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { ValuesType } from 'utility-types'; +import { APIReturnType } from '../../../../../../services/rest/create_call_apm_api'; +import { + ChartType, + getTimeSeriesColor, +} from '../../../../../shared/charts/helper/get_timeseries_color'; +import { SparkPlot } from '../../../../../shared/charts/spark_plot'; +import { isTimeComparison } from '../../../../../shared/time_comparison/get_comparison_options'; +import { + asMillisecondDuration, + asPercent, + asTransactionRate, +} from '../../../../../../../common/utils/formatters'; +import { ITableColumn } from '../../../../../shared/managed_table'; + +type MobileMainStatisticsByField = + APIReturnType<'GET /internal/apm/mobile-services/{serviceName}/main_statistics'>; + +type MobileMainStatisticsByFieldItem = ValuesType< + MobileMainStatisticsByField['mainStatistics'] +>; + +type MobileDetailedStatisticsByField = + APIReturnType<'GET /internal/apm/mobile-services/{serviceName}/detailed_statistics'>; + +export function getColumns({ + agentName, + detailedStatisticsLoading, + detailedStatistics, + comparisonEnabled, + offset, +}: { + agentName?: string; + detailedStatisticsLoading: boolean; + detailedStatistics: MobileDetailedStatisticsByField; + comparisonEnabled?: boolean; + offset?: string; +}): Array> { + return [ + // version/device + { + field: 'name', + name: i18n.translate( + 'xpack.apm.mobile.transactions.overview.table.nameColumnLabel', + { + defaultMessage: 'Name', + } + ), + }, + // latency + { + field: 'latency', + name: i18n.translate( + 'xpack.apm.mobile.transactions.overview.table.latencyColumnAvgLabel', + { + defaultMessage: 'Latency (avg.)', + } + ), + align: RIGHT_ALIGNMENT, + render: (_, { latency, name }) => { + const currentPeriodTimeseries = + detailedStatistics?.currentPeriod?.[name]?.latency; + const previousPeriodTimeseries = + detailedStatistics?.previousPeriod?.[name]?.latency; + + const { currentPeriodColor, previousPeriodColor } = getTimeSeriesColor( + ChartType.LATENCY_AVG + ); + + return ( + + ); + }, + }, + // throughput + { + field: 'throughput', + name: i18n.translate( + 'xpack.apm.mobile.transactions.overview.table.throughputColumnAvgLabel', + { defaultMessage: 'Throughput' } + ), + align: RIGHT_ALIGNMENT, + render: (_, { throughput, name }) => { + const currentPeriodTimeseries = + detailedStatistics?.currentPeriod?.[name]?.throughput; + const previousPeriodTimeseries = + detailedStatistics?.previousPeriod?.[name]?.throughput; + + const { currentPeriodColor, previousPeriodColor } = getTimeSeriesColor( + ChartType.THROUGHPUT + ); + + return ( + + ); + }, + }, + // crash rate + { + field: 'crashRate', + name: i18n.translate( + 'xpack.apm.mobile.transactions.overview.table.crashRateColumnLabel', + { + defaultMessage: 'Crash rate', + } + ), + align: RIGHT_ALIGNMENT, + render: (_, { crashRate }) => { + return ( + + {asPercent(crashRate, 1)} + + ); + }, + }, + ]; +} diff --git a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/stats_list/index.tsx b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/stats_list/index.tsx new file mode 100644 index 0000000000000..ab71f49421ddd --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/stats_list/index.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { ManagedTable } from '../../../../../shared/managed_table'; +import { APIReturnType } from '../../../../../../services/rest/create_call_apm_api'; +import { getColumns } from './get_columns'; + +type MobileMainStatisticsByField = + APIReturnType<'GET /internal/apm/mobile-services/{serviceName}/main_statistics'>['mainStatistics']; + +type MobileDetailedStatisticsByField = + APIReturnType<'GET /internal/apm/mobile-services/{serviceName}/detailed_statistics'>; + +interface Props { + isLoading: boolean; + mainStatistics: MobileMainStatisticsByField; + detailedStatisticsLoading: boolean; + detailedStatistics: MobileDetailedStatisticsByField; + comparisonEnabled?: boolean; + offset?: string; +} +export function StatsList({ + isLoading, + mainStatistics, + detailedStatisticsLoading, + detailedStatistics, + comparisonEnabled, + offset, +}: Props) { + const columns = useMemo(() => { + return getColumns({ + detailedStatisticsLoading, + detailedStatistics, + comparisonEnabled, + offset, + }); + }, [ + detailedStatisticsLoading, + detailedStatistics, + comparisonEnabled, + offset, + ]); + return ( + + ); +} diff --git a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/transactions_tab.tsx b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/transactions_tab.tsx new file mode 100644 index 0000000000000..4fef8262e6305 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/transactions_tab.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; + +import { i18n } from '@kbn/i18n'; +import { TabContentProps } from '.'; +import { TransactionsTable } from '../../../../shared/transactions_table'; + +function TransactionsTab({ environment, kuery, start, end }: TabContentProps) { + return ( + + ); +} + +export const transactionsTab = { + dataTestSubj: 'apmTransactionsTab', + key: 'transactions', + label: i18n.translate( + 'xpack.apm.mobile.transactions.overview.tabs.transactions', + { + defaultMessage: 'Transactions', + } + ), + component: TransactionsTab, +}; diff --git a/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/use_mobile_statistics_fetcher.ts b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/use_mobile_statistics_fetcher.ts new file mode 100644 index 0000000000000..4c3bd48e5e089 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/mobile/transaction_overview/transaction_overview_tabs/use_mobile_statistics_fetcher.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 { v4 as uuidv4 } from 'uuid'; +import { useApmServiceContext } from '../../../../../context/apm_service/use_apm_service_context'; +import { useFetcher } from '../../../../../hooks/use_fetcher'; +import { isTimeComparison } from '../../../../shared/time_comparison/get_comparison_options'; + +const INITIAL_STATE_MAIN_STATISTICS = { + mainStatistics: [], + requestId: undefined, + totalItems: 0, +}; + +const INITIAL_STATE_DETAILED_STATISTICS = { + currentPeriod: {}, + previousPeriod: {}, +}; + +interface Props { + field: string; + environment: string; + start: string; + end: string; + kuery: string; + comparisonEnabled: boolean; + offset?: string; +} + +export function useMobileStatisticsFetcher({ + field, + environment, + start, + end, + kuery, + comparisonEnabled, + offset, +}: Props) { + const { serviceName } = useApmServiceContext(); + + const { data = INITIAL_STATE_MAIN_STATISTICS, status: mainStatisticsStatus } = + useFetcher( + (callApmApi) => { + if (start && end) { + return callApmApi( + 'GET /internal/apm/mobile-services/{serviceName}/main_statistics', + { + params: { + path: { serviceName }, + query: { + environment, + kuery, + start, + end, + field, + }, + }, + } + ).then((response) => { + return { + // Everytime the main statistics is refetched, updates the requestId making the comparison API to be refetched. + requestId: uuidv4(), + mainStatistics: response.mainStatistics, + totalItems: response.mainStatistics.length, + }; + }); + } + }, + [environment, start, end, kuery, serviceName, field] + ); + + const { mainStatistics, requestId, totalItems } = data; + + const { + data: detailedStatistics = INITIAL_STATE_DETAILED_STATISTICS, + status: detailedStatisticsStatus, + } = useFetcher( + (callApmApi) => { + if (totalItems && start && end) { + return callApmApi( + 'GET /internal/apm/mobile-services/{serviceName}/detailed_statistics', + { + params: { + path: { serviceName }, + query: { + environment, + kuery, + start, + end, + field, + fieldValues: JSON.stringify( + data?.mainStatistics.map(({ name }) => name).sort() + ), + offset: + comparisonEnabled && isTimeComparison(offset) + ? offset + : undefined, + }, + }, + } + ); + } + }, + // only fetches agg results when requestId changes + // eslint-disable-next-line react-hooks/exhaustive-deps + [requestId], + { preservePreviousData: false } + ); + + return { + mainStatistics, + mainStatisticsStatus, + detailedStatistics, + detailedStatisticsStatus, + }; +} diff --git a/x-pack/plugins/apm/public/components/routing/mobile_service_detail/index.tsx b/x-pack/plugins/apm/public/components/routing/mobile_service_detail/index.tsx index 52e127c63f805..7b8e4a120c298 100644 --- a/x-pack/plugins/apm/public/components/routing/mobile_service_detail/index.tsx +++ b/x-pack/plugins/apm/public/components/routing/mobile_service_detail/index.tsx @@ -146,6 +146,7 @@ export const mobileServiceDetail = { osVersion: t.string, appVersion: t.string, netConnectionType: t.string, + mobileSelectedTab: t.string, }), }), children: { diff --git a/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx b/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx index 42d03902bc313..c4df8a4f7e9a9 100644 --- a/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx @@ -65,6 +65,7 @@ const DEFAULT_SORT = { }; interface Props { + hideTitle?: boolean; hideViewTransactionsLink?: boolean; isSingleColumn?: boolean; numberOfTransactionsPerPage?: number; @@ -81,6 +82,7 @@ interface Props { export function TransactionsTable({ fixedHeight = false, hideViewTransactionsLink = false, + hideTitle = false, isSingleColumn = true, numberOfTransactionsPerPage = 5, showPerPageOptions = true, @@ -294,32 +296,35 @@ export function TransactionsTable({ gutterSize="s" data-test-subj="transactionsGroupTable" > - - - - -

- {i18n.translate('xpack.apm.transactionsTable.title', { - defaultMessage: 'Transactions', - })} -

-
-
- {!hideViewTransactionsLink && ( + {!hideTitle && ( + + - - {i18n.translate('xpack.apm.transactionsTable.linkText', { - defaultMessage: 'View transactions', - })} - + +

+ {i18n.translate('xpack.apm.transactionsTable.title', { + defaultMessage: 'Transactions', + })} +

+
- )} -
-
+ {!hideViewTransactionsLink && ( + + + {i18n.translate('xpack.apm.transactionsTable.linkText', { + defaultMessage: 'View transactions', + })} + + + )} +
+
+ )} + {showMaxTransactionGroupsExceededWarning && maxTransactionGroupsExceeded && ( = diff --git a/x-pack/plugins/apm/server/routes/mobile/get_mobile_detailed_statistics_by_field.ts b/x-pack/plugins/apm/server/routes/mobile/get_mobile_detailed_statistics_by_field.ts new file mode 100644 index 0000000000000..d511b22b13274 --- /dev/null +++ b/x-pack/plugins/apm/server/routes/mobile/get_mobile_detailed_statistics_by_field.ts @@ -0,0 +1,214 @@ +/* + * 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 { + kqlQuery, + rangeQuery, + termQuery, +} from '@kbn/observability-plugin/server'; +import { keyBy } from 'lodash'; +import { getBucketSize } from '../../../common/utils/get_bucket_size'; +import { getOffsetInMs } from '../../../common/utils/get_offset_in_ms'; +import { APMEventClient } from '../../lib/helpers/create_es_client/create_apm_event_client'; +import { environmentQuery } from '../../../common/utils/environment_query'; +import { + SERVICE_NAME, + TRANSACTION_DURATION, +} from '../../../common/es_fields/apm'; +import { getLatencyValue } from '../../lib/helpers/latency_aggregation_type'; +import { LatencyAggregationType } from '../../../common/latency_aggregation_types'; +import { offsetPreviousPeriodCoordinates } from '../../../common/utils/offset_previous_period_coordinate'; +import { Coordinate } from '../../../typings/timeseries'; +import { ApmDocumentType } from '../../../common/document_type'; +import { RollupInterval } from '../../../common/rollup'; + +interface MobileDetailedStatistics { + fieldName: string; + latency: Coordinate[]; + throughput: Coordinate[]; +} + +export interface MobileDetailedStatisticsResponse { + currentPeriod: Record; + previousPeriod: Record; +} + +interface Props { + kuery: string; + apmEventClient: APMEventClient; + serviceName: string; + environment: string; + start: number; + end: number; + field: string; + fieldValues: string[]; + offset?: string; +} + +async function getMobileDetailedStatisticsByField({ + environment, + kuery, + serviceName, + field, + fieldValues, + apmEventClient, + start, + end, + offset, +}: Props) { + const { startWithOffset, endWithOffset } = getOffsetInMs({ + start, + end, + offset, + }); + + const { intervalString } = getBucketSize({ + start: startWithOffset, + end: endWithOffset, + minBucketSize: 60, + }); + + const response = await apmEventClient.search( + `get_mobile_detailed_statistics_by_field`, + { + apm: { + sources: [ + { + documentType: ApmDocumentType.TransactionEvent, + rollupInterval: RollupInterval.None, + }, + ], + }, + body: { + track_total_hits: false, + size: 0, + query: { + bool: { + filter: [ + ...termQuery(SERVICE_NAME, serviceName), + ...rangeQuery(startWithOffset, endWithOffset), + ...environmentQuery(environment), + ...kqlQuery(kuery), + ], + }, + }, + aggs: { + detailed_statistics: { + terms: { + field, + include: fieldValues, + size: fieldValues.length, + }, + aggs: { + timeseries: { + date_histogram: { + field: '@timestamp', + fixed_interval: intervalString, + min_doc_count: 0, + extended_bounds: { + min: startWithOffset, + max: endWithOffset, + }, + }, + aggs: { + latency: { + avg: { + field: TRANSACTION_DURATION, + }, + }, + }, + }, + }, + }, + }, + }, + } + ); + + const buckets = response.aggregations?.detailed_statistics.buckets ?? []; + + return buckets.map((bucket) => { + const fieldName = bucket.key as string; + const latency = bucket.timeseries.buckets.map((timeseriesBucket) => ({ + x: timeseriesBucket.key, + y: getLatencyValue({ + latencyAggregationType: LatencyAggregationType.avg, + aggregation: timeseriesBucket.latency, + }), + })); + const throughput = bucket.timeseries.buckets.map((timeseriesBucket) => ({ + x: timeseriesBucket.key, + y: timeseriesBucket.doc_count, + })); + + return { + fieldName, + latency, + throughput, + }; + }); +} + +export async function getMobileDetailedStatisticsByFieldPeriods({ + environment, + kuery, + serviceName, + field, + fieldValues, + apmEventClient, + start, + end, + offset, +}: Props): Promise { + const commonProps = { + environment, + kuery, + serviceName, + field, + fieldValues, + apmEventClient, + start, + end, + }; + + const currentPeriodPromise = getMobileDetailedStatisticsByField({ + ...commonProps, + }); + + const previousPeriodPromise = offset + ? getMobileDetailedStatisticsByField({ + ...commonProps, + offset, + }) + : []; + + const [currentPeriod, previousPeriod] = await Promise.all([ + currentPeriodPromise, + previousPeriodPromise, + ]); + + const firstCurrentPeriod = currentPeriod?.[0]; + return { + currentPeriod: keyBy(currentPeriod, 'fieldName'), + previousPeriod: keyBy( + previousPeriod.map((data) => { + return { + ...data, + latency: offsetPreviousPeriodCoordinates({ + currentPeriodTimeseries: firstCurrentPeriod?.latency, + previousPeriodTimeseries: data.latency, + }), + throughput: offsetPreviousPeriodCoordinates({ + currentPeriodTimeseries: firstCurrentPeriod?.throughput, + previousPeriodTimeseries: data.throughput, + }), + }; + }), + 'fieldName' + ), + }; +} diff --git a/x-pack/plugins/apm/server/routes/mobile/get_mobile_main_statistics_by_field.ts b/x-pack/plugins/apm/server/routes/mobile/get_mobile_main_statistics_by_field.ts new file mode 100644 index 0000000000000..a5783997e391b --- /dev/null +++ b/x-pack/plugins/apm/server/routes/mobile/get_mobile_main_statistics_by_field.ts @@ -0,0 +1,186 @@ +/* + * 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 { + termQuery, + kqlQuery, + rangeQuery, +} from '@kbn/observability-plugin/server'; +import { merge } from 'lodash'; +import { + SERVICE_NAME, + SESSION_ID, + TRANSACTION_DURATION, + ERROR_TYPE, +} from '../../../common/es_fields/apm'; +import { environmentQuery } from '../../../common/utils/environment_query'; +import { APMEventClient } from '../../lib/helpers/create_es_client/create_apm_event_client'; +import { getLatencyValue } from '../../lib/helpers/latency_aggregation_type'; +import { LatencyAggregationType } from '../../../common/latency_aggregation_types'; +import { calculateThroughputWithRange } from '../../lib/helpers/calculate_throughput'; +import { ApmDocumentType } from '../../../common/document_type'; +import { RollupInterval } from '../../../common/rollup'; + +interface Props { + kuery: string; + apmEventClient: APMEventClient; + serviceName: string; + environment: string; + start: number; + end: number; + field: string; +} + +export interface MobileMainStatisticsResponse { + mainStatistics: Array<{ + name: string | number; + latency: number | null; + throughput: number; + crashRate?: number; + }>; +} + +export async function getMobileMainStatisticsByField({ + kuery, + apmEventClient, + serviceName, + environment, + start, + end, + field, +}: Props) { + async function getMobileTransactionEventStatistics() { + const response = await apmEventClient.search( + `get_mobile_main_statistics_by_field`, + { + apm: { + sources: [ + { + documentType: ApmDocumentType.TransactionEvent, + rollupInterval: RollupInterval.None, + }, + ], + }, + body: { + track_total_hits: false, + size: 0, + query: { + bool: { + filter: [ + ...termQuery(SERVICE_NAME, serviceName), + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...kqlQuery(kuery), + ], + }, + }, + aggs: { + main_statistics: { + terms: { + field, + size: 1000, + }, + aggs: { + latency: { + avg: { + field: TRANSACTION_DURATION, + }, + }, + }, + }, + }, + }, + } + ); + + return ( + response.aggregations?.main_statistics.buckets.map((bucket) => { + return { + name: bucket.key, + latency: getLatencyValue({ + latencyAggregationType: LatencyAggregationType.avg, + aggregation: bucket.latency, + }), + throughput: calculateThroughputWithRange({ + start, + end, + value: bucket.doc_count, + }), + }; + }) ?? [] + ); + } + + async function getMobileErrorEventStatistics() { + const response = await apmEventClient.search( + `get_mobile_transaction_events_main_statistics_by_field`, + { + apm: { + sources: [ + { + documentType: ApmDocumentType.ErrorEvent, + rollupInterval: RollupInterval.None, + }, + ], + }, + body: { + track_total_hits: false, + size: 0, + query: { + bool: { + filter: [ + ...termQuery(SERVICE_NAME, serviceName), + ...rangeQuery(start, end), + ...environmentQuery(environment), + ...kqlQuery(kuery), + ], + }, + }, + aggs: { + main_statistics: { + terms: { + field, + size: 1000, + }, + aggs: { + sessions: { + cardinality: { + field: SESSION_ID, + }, + }, + crashes: { + filter: { + term: { + [ERROR_TYPE]: 'crash', + }, + }, + }, + }, + }, + }, + }, + } + ); + return ( + response.aggregations?.main_statistics.buckets.map((bucket) => { + return { + name: bucket.key, + crashRate: bucket.crashes.doc_count / bucket.sessions.value ?? 0, + }; + }) ?? [] + ); + } + + const [transactioEventStatistics, errorEventStatistics] = await Promise.all([ + getMobileTransactionEventStatistics(), + getMobileErrorEventStatistics(), + ]); + + const mainStatistics = merge(transactioEventStatistics, errorEventStatistics); + + return { mainStatistics }; +} diff --git a/x-pack/plugins/apm/server/routes/mobile/route.ts b/x-pack/plugins/apm/server/routes/mobile/route.ts index 3f6de9de1b696..3323172a5e6d5 100644 --- a/x-pack/plugins/apm/server/routes/mobile/route.ts +++ b/x-pack/plugins/apm/server/routes/mobile/route.ts @@ -6,7 +6,7 @@ */ import * as t from 'io-ts'; -import { toNumberRt } from '@kbn/io-ts-utils'; +import { jsonRt, toNumberRt } from '@kbn/io-ts-utils'; import { getApmEventClient } from '../../lib/helpers/get_apm_event_client'; import { createApmServerRoute } from '../apm_routes/create_apm_server_route'; import { environmentRt, kueryRt, rangeRt } from '../default_api_types'; @@ -26,6 +26,14 @@ import { getMobileTermsByField, MobileTermsByFieldResponse, } from './get_mobile_terms_by_field'; +import { + getMobileMainStatisticsByField, + MobileMainStatisticsResponse, +} from './get_mobile_main_statistics_by_field'; +import { + getMobileDetailedStatisticsByFieldPeriods, + MobileDetailedStatisticsResponse, +} from './get_mobile_detailed_statistics_by_field'; import { getMobileMostUsedCharts, MobileMostUsedChartResponse, @@ -329,6 +337,84 @@ const mobileTermsByFieldRoute = createApmServerRoute({ }, }); +const mobileMainStatisticsByField = createApmServerRoute({ + endpoint: 'GET /internal/apm/mobile-services/{serviceName}/main_statistics', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + query: t.intersection([ + kueryRt, + rangeRt, + environmentRt, + t.type({ + field: t.string, + }), + ]), + }), + options: { + tags: ['access:apm'], + }, + handler: async (resources): Promise => { + const apmEventClient = await getApmEventClient(resources); + const { params } = resources; + const { serviceName } = params.path; + const { kuery, environment, start, end, field } = params.query; + + return await getMobileMainStatisticsByField({ + kuery, + environment, + start, + end, + serviceName, + apmEventClient, + field, + }); + }, +}); + +const mobileDetailedStatisticsByField = createApmServerRoute({ + endpoint: + 'GET /internal/apm/mobile-services/{serviceName}/detailed_statistics', + params: t.type({ + path: t.type({ + serviceName: t.string, + }), + query: t.intersection([ + kueryRt, + rangeRt, + offsetRt, + environmentRt, + t.type({ + field: t.string, + fieldValues: jsonRt.pipe(t.array(t.string)), + }), + ]), + }), + options: { + tags: ['access:apm'], + }, + handler: async (resources): Promise => { + const apmEventClient = await getApmEventClient(resources); + const { params } = resources; + const { serviceName } = params.path; + const { kuery, environment, start, end, field, offset, fieldValues } = + params.query; + + return await getMobileDetailedStatisticsByFieldPeriods({ + kuery, + environment, + start, + end, + serviceName, + apmEventClient, + field, + fieldValues, + offset, + }); + }, +}); + export const mobileRouteRepository = { ...mobileFiltersRoute, ...mobileChartsRoute, @@ -337,4 +423,6 @@ export const mobileRouteRepository = { ...mobileStatsRoute, ...mobileLocationStatsRoute, ...mobileTermsByFieldRoute, + ...mobileMainStatisticsByField, + ...mobileDetailedStatisticsByField, }; diff --git a/x-pack/test/apm_api_integration/tests/mobile/generate_mobile_data.ts b/x-pack/test/apm_api_integration/tests/mobile/generate_mobile_data.ts index cd3b4ed636272..91a8aac9bc3d3 100644 --- a/x-pack/test/apm_api_integration/tests/mobile/generate_mobile_data.ts +++ b/x-pack/test/apm_api_integration/tests/mobile/generate_mobile_data.ts @@ -7,6 +7,8 @@ import { apm, timerange } from '@kbn/apm-synthtrace-client'; import { ApmSynthtraceEsClient } from '@kbn/apm-synthtrace'; +export const SERVICE_VERSIONS = ['2.3', '1.2', '1.1']; + export async function generateMobileData({ start, end, @@ -22,7 +24,7 @@ export async function generateMobileData({ environment: 'production', agentName: 'android/java', }) - .mobileDevice({ serviceVersion: '2.3' }) + .mobileDevice({ serviceVersion: SERVICE_VERSIONS[0] }) .deviceInfo({ manufacturer: 'Samsung', modelIdentifier: 'SM-G973F', @@ -52,7 +54,7 @@ export async function generateMobileData({ environment: 'production', agentName: 'android/java', }) - .mobileDevice({ serviceVersion: '1.2' }) + .mobileDevice({ serviceVersion: SERVICE_VERSIONS[1] }) .deviceInfo({ manufacturer: 'Samsung', modelIdentifier: 'SM-G930F', @@ -89,7 +91,7 @@ export async function generateMobileData({ environment: 'production', agentName: 'android/java', }) - .mobileDevice({ serviceVersion: '1.1' }) + .mobileDevice({ serviceVersion: SERVICE_VERSIONS[2] }) .deviceInfo({ manufacturer: 'Huawei', modelIdentifier: 'HUAWEI P2-0000', @@ -222,6 +224,7 @@ export async function generateMobileData({ return [ galaxy10 .transaction('Start View - View Appearing', 'Android Activity') + .errors(galaxy10.crash({ message: 'error' }).timestamp(timestamp)) .timestamp(timestamp) .duration(500) .success() @@ -265,6 +268,7 @@ export async function generateMobileData({ ), huaweiP2 .transaction('Start View - View Appearing', 'huaweiP2 Activity') + .errors(huaweiP2.crash({ message: 'error' }).timestamp(timestamp)) .timestamp(timestamp) .duration(20) .success() @@ -292,6 +296,7 @@ export async function generateMobileData({ ), galaxy7 .transaction('Start View - View Appearing', 'Android Activity') + .errors(galaxy7.crash({ message: 'error' }).timestamp(timestamp)) .timestamp(timestamp) .duration(20) .success() diff --git a/x-pack/test/apm_api_integration/tests/mobile/mobile_detailed_statistics_by_field.spec.ts b/x-pack/test/apm_api_integration/tests/mobile/mobile_detailed_statistics_by_field.spec.ts new file mode 100644 index 0000000000000..601e3b81e6dad --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/mobile/mobile_detailed_statistics_by_field.spec.ts @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { ENVIRONMENT_ALL } from '@kbn/apm-plugin/common/environment_filter_values'; +import { isEmpty } from 'lodash'; +import moment from 'moment'; +import { APIReturnType } from '@kbn/apm-plugin/public/services/rest/create_call_apm_api'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { generateMobileData, SERVICE_VERSIONS } from './generate_mobile_data'; + +type MobileDetailedStatisticsResponse = + APIReturnType<'GET /internal/apm/mobile-services/{serviceName}/detailed_statistics'>; + +export default function ApiTest({ getService }: FtrProviderContext) { + const apmApiClient = getService('apmApiClient'); + const registry = getService('registry'); + const synthtraceEsClient = getService('synthtraceEsClient'); + + const start = new Date('2023-01-01T00:00:00.000Z').getTime(); + const end = new Date('2023-01-01T00:15:00.000Z').getTime() - 1; + + async function getMobileDetailedStatisticsByField({ + environment = ENVIRONMENT_ALL.value, + kuery = '', + serviceName, + field, + offset, + }: { + environment?: string; + kuery?: string; + serviceName: string; + field: string; + offset?: string; + }) { + return await apmApiClient + .readUser({ + endpoint: 'GET /internal/apm/mobile-services/{serviceName}/detailed_statistics', + params: { + path: { serviceName }, + query: { + environment, + start: moment(end).subtract(7, 'minutes').toISOString(), + end: new Date(end).toISOString(), + offset, + kuery, + field, + fieldValues: JSON.stringify(SERVICE_VERSIONS), + }, + }, + }) + .then(({ body }) => body); + } + + registry.when( + 'Mobile detailed statistics when data is not loaded', + { config: 'basic', archives: [] }, + () => { + describe('when no data', () => { + it('handles empty state', async () => { + const response = await getMobileDetailedStatisticsByField({ + serviceName: 'foo', + field: 'service.version', + }); + expect(response).to.be.eql({ currentPeriod: {}, previousPeriod: {} }); + }); + }); + } + ); + + registry.when( + 'Mobile detailed statistics when data is loaded', + { config: 'basic', archives: [] }, + () => { + before(async () => { + await generateMobileData({ + synthtraceEsClient, + start, + end, + }); + }); + + after(() => synthtraceEsClient.clean()); + + describe('when comparison is disable', () => { + it('returns current period data only', async () => { + const response = await getMobileDetailedStatisticsByField({ + serviceName: 'synth-android', + environment: 'production', + field: 'service.version', + }); + expect(isEmpty(response.currentPeriod)).to.be.equal(false); + expect(isEmpty(response.previousPeriod)).to.be.equal(true); + }); + }); + + describe('when comparison is enable', () => { + let mobiledetailedStatisticResponse: MobileDetailedStatisticsResponse; + + before(async () => { + mobiledetailedStatisticResponse = await getMobileDetailedStatisticsByField({ + serviceName: 'synth-android', + environment: 'production', + field: 'service.version', + offset: '8m', + }); + }); + it('returns some data for both periods', async () => { + expect(isEmpty(mobiledetailedStatisticResponse.currentPeriod)).to.be.equal(false); + expect(isEmpty(mobiledetailedStatisticResponse.previousPeriod)).to.be.equal(false); + }); + + it('returns same number of buckets for both periods', () => { + const currentPeriod = mobiledetailedStatisticResponse.currentPeriod[SERVICE_VERSIONS[0]]; + const previousPeriod = + mobiledetailedStatisticResponse.previousPeriod[SERVICE_VERSIONS[0]]; + + [ + [currentPeriod.latency, previousPeriod.latency], + [currentPeriod.throughput, previousPeriod.throughput], + ].forEach(([currentTimeseries, previousTimeseries]) => { + expect(currentTimeseries.length).to.equal(previousTimeseries.length); + }); + }); + }); + } + ); +} diff --git a/x-pack/test/apm_api_integration/tests/mobile/mobile_main_statistics_by_field.spec.ts b/x-pack/test/apm_api_integration/tests/mobile/mobile_main_statistics_by_field.spec.ts new file mode 100644 index 0000000000000..a58f6e58b99e6 --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/mobile/mobile_main_statistics_by_field.spec.ts @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { ENVIRONMENT_ALL } from '@kbn/apm-plugin/common/environment_filter_values'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { generateMobileData } from './generate_mobile_data'; + +export default function ApiTest({ getService }: FtrProviderContext) { + const apmApiClient = getService('apmApiClient'); + const registry = getService('registry'); + const synthtraceEsClient = getService('synthtraceEsClient'); + + const start = new Date('2023-01-01T00:00:00.000Z').getTime(); + const end = new Date('2023-01-01T00:15:00.000Z').getTime() - 1; + + async function getMobileMainStatisticsByField({ + environment = ENVIRONMENT_ALL.value, + kuery = '', + serviceName, + field, + }: { + environment?: string; + kuery?: string; + serviceName: string; + field: string; + }) { + return await apmApiClient + .readUser({ + endpoint: 'GET /internal/apm/mobile-services/{serviceName}/main_statistics', + params: { + path: { serviceName }, + query: { + environment, + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), + kuery, + field, + }, + }, + }) + .then(({ body }) => body); + } + + registry.when( + 'Mobile main statistics when data is not loaded', + { config: 'basic', archives: [] }, + () => { + describe('when no data', () => { + it('handles empty state', async () => { + const response = await getMobileMainStatisticsByField({ + serviceName: 'foo', + field: 'service.version', + }); + expect(response.mainStatistics.length).to.be(0); + }); + }); + } + ); + + registry.when('Mobile main statistics', { config: 'basic', archives: [] }, () => { + before(async () => { + await generateMobileData({ + synthtraceEsClient, + start, + end, + }); + }); + + after(() => synthtraceEsClient.clean()); + + describe('when data is loaded', () => { + it('returns the correct data for App version', async () => { + const response = await getMobileMainStatisticsByField({ + serviceName: 'synth-android', + environment: 'production', + field: 'service.version', + }); + const fieldValues = response.mainStatistics.map((item) => item.name); + + expect(fieldValues).to.be.eql(['1.1', '1.2', '2.3']); + + const latencyValues = response.mainStatistics.map((item) => item.latency); + + expect(latencyValues).to.be.eql([172000, 20000, 20000]); + + const throughputValues = response.mainStatistics.map((item) => item.throughput); + expect(throughputValues).to.be.eql([ + 1.0000011111123457, 0.20000022222246913, 0.20000022222246913, + ]); + }); + it('returns the correct data for Os version', async () => { + const response = await getMobileMainStatisticsByField({ + serviceName: 'synth-android', + environment: 'production', + field: 'host.os.version', + }); + + const fieldValues = response.mainStatistics.map((item) => item.name); + + expect(fieldValues).to.be.eql(['10']); + + const latencyValues = response.mainStatistics.map((item) => item.latency); + + expect(latencyValues).to.be.eql([128571.42857142857]); + + const throughputValues = response.mainStatistics.map((item) => item.throughput); + expect(throughputValues).to.be.eql([1.4000015555572838]); + }); + it('returns the correct data for Devices', async () => { + const response = await getMobileMainStatisticsByField({ + serviceName: 'synth-android', + environment: 'production', + field: 'device.model.identifier', + }); + const fieldValues = response.mainStatistics.map((item) => item.name); + + expect(fieldValues).to.be.eql([ + 'HUAWEI P2-0000', + 'SM-G930F', + 'SM-G973F', + 'Pixel 7 Pro', + 'Pixel 8', + 'SM-G930F', + ]); + + const latencyValues = response.mainStatistics.map((item) => item.latency); + + expect(latencyValues).to.be.eql([400000, 20000, 20000, 20000, 20000, 20000]); + + const throughputValues = response.mainStatistics.map((item) => item.throughput); + expect(throughputValues).to.be.eql([ + 0.40000044444493826, 0.20000022222246913, 0.20000022222246913, 0.20000022222246913, + 0.20000022222246913, 0.20000022222246913, + ]); + }); + }); + }); +} From 61b56ce15f6744b4add7bb1c345a00f171154c7c Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Tue, 25 Apr 2023 16:50:40 +0200 Subject: [PATCH 28/29] [Discover] Add a deprecation badge to legacy stats setting (#155688) ## Summary We are adding a deprecation badge in 8.8 and removing this setting in 8.9 https://github.com/elastic/kibana/pull/155503 Screenshot 2023-04-25 at 10 28 21 --- packages/kbn-doc-links/src/get_doc_links.ts | 1 + src/plugins/discover/server/ui_settings.ts | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/packages/kbn-doc-links/src/get_doc_links.ts b/packages/kbn-doc-links/src/get_doc_links.ts index dc75ebee9a5eb..29ac5472e37dd 100644 --- a/packages/kbn-doc-links/src/get_doc_links.ts +++ b/packages/kbn-doc-links/src/get_doc_links.ts @@ -419,6 +419,7 @@ export const getDocLinks = ({ kibanaBranch }: GetDocLinkOptions): DocLinks => { dashboardSettings: `${KIBANA_DOCS}advanced-options.html#kibana-dashboard-settings`, indexManagement: `${ELASTICSEARCH_DOCS}index-mgmt.html`, kibanaSearchSettings: `${KIBANA_DOCS}advanced-options.html#kibana-search-settings`, + discoverSettings: `${KIBANA_DOCS}advanced-options.html#kibana-discover-settings`, visualizationSettings: `${KIBANA_DOCS}advanced-options.html#kibana-visualization-settings`, timelionSettings: `${KIBANA_DOCS}advanced-options.html#kibana-timelion-settings`, savedObjectsApiList: `${KIBANA_DOCS}saved-objects-api.html#saved-objects-api`, diff --git a/src/plugins/discover/server/ui_settings.ts b/src/plugins/discover/server/ui_settings.ts index 3c0fe05348324..d6985033cdb1d 100644 --- a/src/plugins/discover/server/ui_settings.ts +++ b/src/plugins/discover/server/ui_settings.ts @@ -137,6 +137,12 @@ export const getUiSettings: (docLinks: DocLinksServiceSetup) => Record Date: Tue, 25 Apr 2023 10:51:59 -0400 Subject: [PATCH 29/29] [Security Solution][Investigations] - Add kibana.alert.url (#155069) ## Summary This PR introduces the field `kibana.alert.url` to the alerts generated by al alert rule types. Functionality was added in [this PR](https://github.com/elastic/kibana/pull/148800) for 8.8 to allow users to link directly to the alert flyout. To be able to provide users with this field via our connectors, we are adding the url under the field `kibana.alert.url`. To test, create an alert of any type and you should see this field set in the alert flyout: image The url provided is a redirect path that contains the necessary information (space, id, index, and timestamp) to be able to redirect the user to a filtered alert page for the given alert and the detail flyout opened. This allows us to retain flexibility in the future for any changes that may occur with the alert flyout or an alert page. More on that can be found in the earlier pr: https://github.com/elastic/kibana/pull/148800 ### Testing 1. The `kibana.alert.url` field makes use of the `publicBaseUrl` configuration which must be set in your kibana.dev.yml for this field to be generated. Add the following to your yaml file. Note that if you use a `basePath`, it will have to be appended to the end of your `publicBaseUrl` path. ``` server.publicBaseUrl: 'http://localhost:5601' ``` with basePath: ``` server.basePath: '/someBasePath' server.publicBaseUrl: 'http://localhost:5601/someBasePath' ``` 2. Generate data and enable any rule type to get alerts. 3. Go to the alert page, click expand detail, and search for `kibana.alert.url` in the table. 4. Visit that url and you should see a filtered alert page with the details flyout opened ***Caveat - when grouping is enabled, the details flyout will not open as the table that it is attached to is not actually loaded at that point in time. When the table is loaded by either disabling grouping or opening the group, the details flyout will open --- .../src/default_alerts_as_data.ts | 2 +- .../security_solution/common/constants.ts | 1 + .../schemas/alerts/8.8.0/index.ts | 56 ++++++++++++++++++ .../detection_engine/schemas/alerts/index.ts | 37 ++++++------ .../common/utils/alert_detail_path.test.ts | 56 ++++++++++++++++++ .../common/utils/alert_detail_path.ts | 39 +++++++++++++ .../e2e/detection_alerts/alerts_details.cy.ts | 2 +- x-pack/plugins/security_solution/kibana.jsonc | 2 +- .../store/data_table/epic_local_storage.ts | 2 + .../public/detections/pages/alerts/index.tsx | 8 ++- .../__snapshots__/index.test.tsx.snap | 37 ++++++++---- .../event_details/expandable_event.tsx | 42 ++++++++------ .../use_get_alert_details_flyout_link.ts | 14 +++-- .../rule_types/__mocks__/es_results.ts | 2 + .../rule_types/__mocks__/threshold.ts | 2 + .../create_security_rule_type_wrapper.ts | 14 ++++- .../build_alert_group_from_sequence.test.ts | 16 +++++- .../eql/build_alert_group_from_sequence.ts | 57 ++++++++++++++----- .../rule_types/eql/wrap_sequences_factory.ts | 5 +- .../factories/utils/build_alert.test.ts | 19 +++++-- .../rule_types/factories/utils/build_alert.ts | 21 ++++++- .../factories/utils/build_bulk_body.ts | 6 +- .../rule_types/factories/wrap_hits_factory.ts | 34 ++++++----- .../new_terms/create_new_terms_alert_type.ts | 2 + .../new_terms/wrap_new_terms_alerts.test.ts | 19 ++++++- .../new_terms/wrap_new_terms_alerts.ts | 9 ++- .../group_and_bulk_create.ts | 1 + .../wrap_suppressed_alerts.ts | 9 ++- .../query/create_query_alert_type.test.ts | 2 + .../lib/detection_engine/rule_types/types.ts | 2 + .../utils/enrichments/__mocks__/alerts.ts | 2 + .../utils/search_after_bulk_create.test.ts | 1 + .../security_solution/server/plugin.ts | 1 + 33 files changed, 418 insertions(+), 104 deletions(-) create mode 100644 x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/8.8.0/index.ts create mode 100644 x-pack/plugins/security_solution/common/utils/alert_detail_path.test.ts create mode 100644 x-pack/plugins/security_solution/common/utils/alert_detail_path.ts diff --git a/packages/kbn-rule-data-utils/src/default_alerts_as_data.ts b/packages/kbn-rule-data-utils/src/default_alerts_as_data.ts index d803d6ca503b8..c5ba5f59fb384 100644 --- a/packages/kbn-rule-data-utils/src/default_alerts_as_data.ts +++ b/packages/kbn-rule-data-utils/src/default_alerts_as_data.ts @@ -94,7 +94,7 @@ const ALERT_RULE_TAGS = `${ALERT_RULE_NAMESPACE}.tags` as const; // kibana.alert.rule_type_id - rule type id for rule that generated this alert const ALERT_RULE_TYPE_ID = `${ALERT_RULE_NAMESPACE}.rule_type_id` as const; -// kibana.alert.url - allow our user to go back to the details url in kibana +// kibana.alert.url - url which will redirect users to a page related to the given alert const ALERT_URL = `${ALERT_NAMESPACE}.url` as const; // kibana.alert.rule.uuid - rule ID for rule that generated this alert diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 2551bc34f7fa4..7da50bd569a47 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -156,6 +156,7 @@ export const DATA_QUALITY_PATH = '/data_quality' as const; export const DETECTION_RESPONSE_PATH = '/detection_response' as const; export const DETECTIONS_PATH = '/detections' as const; export const ALERTS_PATH = '/alerts' as const; +export const ALERT_DETAILS_REDIRECT_PATH = `${ALERTS_PATH}/redirect` as const; export const RULES_PATH = '/rules' as const; export const RULES_CREATE_PATH = `${RULES_PATH}/create` as const; export const EXCEPTIONS_PATH = '/exceptions' as const; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/8.8.0/index.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/8.8.0/index.ts new file mode 100644 index 0000000000000..66c06b8406d3c --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/8.8.0/index.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ALERT_URL, ALERT_UUID } from '@kbn/rule-data-utils'; +import type { AlertWithCommonFields800 } from '@kbn/rule-registry-plugin/common/schemas/8.0.0'; +import type { + Ancestor840, + BaseFields840, + EqlBuildingBlockFields840, + EqlShellFields840, + NewTermsFields840, +} from '../8.4.0'; + +/* DO NOT MODIFY THIS SCHEMA TO ADD NEW FIELDS. These types represent the alerts that shipped in 8.8.0. +Any changes to these types should be bug fixes so the types more accurately represent the alerts from 8.8.0. +If you are adding new fields for a new release of Kibana, create a new sibling folder to this one +for the version to be released and add the field(s) to the schema in that folder. +Then, update `../index.ts` to import from the new folder that has the latest schemas, add the +new schemas to the union of all alert schemas, and re-export the new schemas as the `*Latest` schemas. +*/ + +export type { Ancestor840 as Ancestor880 }; +export interface BaseFields880 extends BaseFields840 { + [ALERT_URL]: string | undefined; + [ALERT_UUID]: string; +} + +export interface WrappedFields880 { + _id: string; + _index: string; + _source: T; +} + +export type GenericAlert880 = AlertWithCommonFields800; + +export type EqlShellFields880 = EqlShellFields840 & BaseFields880; + +export type EqlBuildingBlockFields880 = EqlBuildingBlockFields840 & BaseFields880; + +export type NewTermsFields880 = NewTermsFields840 & BaseFields880; + +export type NewTermsAlert880 = NewTermsFields840 & BaseFields880; + +export type EqlBuildingBlockAlert880 = AlertWithCommonFields800; + +export type EqlShellAlert880 = AlertWithCommonFields800; + +export type DetectionAlert880 = + | GenericAlert880 + | EqlShellAlert880 + | EqlBuildingBlockAlert880 + | NewTermsAlert880; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/index.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/index.ts index 2fdf426f0aea0..1d3e3f0d35f4f 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/index.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/index.ts @@ -7,18 +7,18 @@ import type { DetectionAlert800 } from './8.0.0'; -import type { - Ancestor840, - BaseFields840, - DetectionAlert840, - WrappedFields840, - EqlBuildingBlockFields840, - EqlShellFields840, - NewTermsFields840, -} from './8.4.0'; - +import type { DetectionAlert840 } from './8.4.0'; import type { DetectionAlert860 } from './8.6.0'; import type { DetectionAlert870 } from './8.7.0'; +import type { + Ancestor880, + BaseFields880, + DetectionAlert880, + EqlBuildingBlockFields880, + EqlShellFields880, + NewTermsFields880, + WrappedFields880, +} from './8.8.0'; // When new Alert schemas are created for new Kibana versions, add the DetectionAlert type from the new version // here, e.g. `export type DetectionAlert = DetectionAlert800 | DetectionAlert820` if a new schema is created in 8.2.0 @@ -26,14 +26,15 @@ export type DetectionAlert = | DetectionAlert800 | DetectionAlert840 | DetectionAlert860 - | DetectionAlert870; + | DetectionAlert870 + | DetectionAlert880; export type { - Ancestor840 as AncestorLatest, - BaseFields840 as BaseFieldsLatest, - DetectionAlert860 as DetectionAlertLatest, - WrappedFields840 as WrappedFieldsLatest, - EqlBuildingBlockFields840 as EqlBuildingBlockFieldsLatest, - EqlShellFields840 as EqlShellFieldsLatest, - NewTermsFields840 as NewTermsFieldsLatest, + Ancestor880 as AncestorLatest, + BaseFields880 as BaseFieldsLatest, + DetectionAlert880 as DetectionAlertLatest, + WrappedFields880 as WrappedFieldsLatest, + EqlBuildingBlockFields880 as EqlBuildingBlockFieldsLatest, + EqlShellFields880 as EqlShellFieldsLatest, + NewTermsFields880 as NewTermsFieldsLatest, }; diff --git a/x-pack/plugins/security_solution/common/utils/alert_detail_path.test.ts b/x-pack/plugins/security_solution/common/utils/alert_detail_path.test.ts new file mode 100644 index 0000000000000..be827e082db14 --- /dev/null +++ b/x-pack/plugins/security_solution/common/utils/alert_detail_path.test.ts @@ -0,0 +1,56 @@ +/* + * 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 { buildAlertDetailPath, getAlertDetailsUrl } from './alert_detail_path'; + +describe('alert_detail_path', () => { + const defaultArguments = { + alertId: 'testId', + index: 'testIndex', + timestamp: '2023-04-18T00:00:00.000Z', + }; + describe('buildAlertDetailPath', () => { + it('builds the alert detail path as expected', () => { + expect(buildAlertDetailPath(defaultArguments)).toMatchInlineSnapshot( + `"/alerts/redirect/testId?index=testIndex×tamp=2023-04-18T00:00:00.000Z"` + ); + }); + }); + describe('getAlertDetailsUrl', () => { + it('builds the alert detail path without a space id', () => { + expect( + getAlertDetailsUrl({ + ...defaultArguments, + basePath: 'http://somebasepath.com', + }) + ).toMatchInlineSnapshot( + `"http://somebasepath.com/app/security/alerts/redirect/testId?index=testIndex×tamp=2023-04-18T00:00:00.000Z"` + ); + }); + + it('builds the alert detail path with a space id', () => { + expect( + getAlertDetailsUrl({ + ...defaultArguments, + basePath: 'http://somebasepath.com', + spaceId: 'test-space', + }) + ).toMatchInlineSnapshot( + `"http://somebasepath.com/s/test-space/app/security/alerts/redirect/testId?index=testIndex×tamp=2023-04-18T00:00:00.000Z"` + ); + }); + + it('does not build the alert detail path without a basePath', () => { + expect( + getAlertDetailsUrl({ + ...defaultArguments, + spaceId: 'test-space', + }) + ).toBe(undefined); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/common/utils/alert_detail_path.ts b/x-pack/plugins/security_solution/common/utils/alert_detail_path.ts new file mode 100644 index 0000000000000..2fcc1b6687b7d --- /dev/null +++ b/x-pack/plugins/security_solution/common/utils/alert_detail_path.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { addSpaceIdToPath } from '@kbn/spaces-plugin/common'; +import { ALERT_DETAILS_REDIRECT_PATH, APP_PATH } from '../constants'; + +export const buildAlertDetailPath = ({ + alertId, + index, + timestamp, +}: { + alertId: string; + index: string; + timestamp: string; +}) => `${ALERT_DETAILS_REDIRECT_PATH}/${alertId}?index=${index}×tamp=${timestamp}`; + +export const getAlertDetailsUrl = ({ + alertId, + index, + timestamp, + basePath, + spaceId, +}: { + alertId: string; + index: string; + timestamp: string; + basePath?: string; + spaceId?: string | null; +}) => { + const alertDetailPath = buildAlertDetailPath({ alertId, index, timestamp }); + const alertDetailPathWithAppPath = `${APP_PATH}${alertDetailPath}`; + return basePath + ? addSpaceIdToPath(basePath, spaceId ?? undefined, alertDetailPathWithAppPath) + : undefined; +}; diff --git a/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/alerts_details.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/alerts_details.cy.ts index 947f92d3ec4aa..a42f81481d576 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/alerts_details.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/alerts_details.cy.ts @@ -134,7 +134,7 @@ describe('Alert details flyout', () => { cy.get('[data-test-subj="formatted-field-_id"]') .invoke('text') .then((alertId) => { - cy.visit(`http://localhost:5620/app/security/alerts/${alertId}`); + cy.visit(`http://localhost:5620/app/security/alerts/redirect/${alertId}`); cy.get('[data-test-subj="unifiedQueryInput"]').should('have.text', `_id: ${alertId}`); cy.get(ALERTS_COUNT).should('have.text', '1 alert'); cy.get(OVERVIEW_RULE).should('be.visible'); diff --git a/x-pack/plugins/security_solution/kibana.jsonc b/x-pack/plugins/security_solution/kibana.jsonc index 9401b69d11657..a929c15b48641 100644 --- a/x-pack/plugins/security_solution/kibana.jsonc +++ b/x-pack/plugins/security_solution/kibana.jsonc @@ -32,6 +32,7 @@ "maps", "ruleRegistry", "sessionView", + "spaces", "taskManager", "threatIntelligence", "timelines", @@ -50,7 +51,6 @@ "ml", "newsfeed", "security", - "spaces", "usageCollection", "lists", "home", diff --git a/x-pack/plugins/security_solution/public/common/store/data_table/epic_local_storage.ts b/x-pack/plugins/security_solution/public/common/store/data_table/epic_local_storage.ts index 2afd961c7a9b7..4b0e5cdd2a0e3 100644 --- a/x-pack/plugins/security_solution/public/common/store/data_table/epic_local_storage.ts +++ b/x-pack/plugins/security_solution/public/common/store/data_table/epic_local_storage.ts @@ -21,6 +21,7 @@ const { applyDeltaToColumnWidth, changeViewMode, removeColumn, + toggleDetailPanel, updateColumnOrder, updateColumns, updateColumnWidth, @@ -46,6 +47,7 @@ const tableActionTypes = [ updateShowBuildingBlockAlertsFilter.type, updateTotalCount.type, updateIsLoading.type, + toggleDetailPanel.type, ]; export const createDataTableLocalStorageEpic = diff --git a/x-pack/plugins/security_solution/public/detections/pages/alerts/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/alerts/index.tsx index 2d3a55c6b0cd4..6a7bdc526e750 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/alerts/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/alerts/index.tsx @@ -10,7 +10,11 @@ import { Switch } from 'react-router-dom'; import { Route } from '@kbn/shared-ux-router'; import { TrackApplicationView } from '@kbn/usage-collection-plugin/public'; -import { ALERTS_PATH, SecurityPageName } from '../../../../common/constants'; +import { + ALERTS_PATH, + ALERT_DETAILS_REDIRECT_PATH, + SecurityPageName, +} from '../../../../common/constants'; import { NotFoundPage } from '../../../app/404'; import * as i18n from './translations'; import { DetectionEnginePage } from '../detection_engine/detection_engine'; @@ -31,7 +35,7 @@ const AlertsContainerComponent: React.FC = () => { {/* Redirect to the alerts page filtered for the given alert id */} - + ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap index 9717daac79baf..c5bc7821278b5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/__snapshots__/index.test.tsx.snap @@ -49,18 +49,26 @@ Array [
- +
+ +
+
,
+
+
+
( )} - {handleOnEventClosed && ( - - - - )} - {isAlert && ( - - {(copy) => ( - - {i18n.SHARE_ALERT} - + + + {handleOnEventClosed && ( + + + + )} + {isAlert && alertDetailsLink && ( + + {(copy) => ( + + {i18n.SHARE_ALERT} + + )} + )} - - )} + + ); } diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/use_get_alert_details_flyout_link.ts b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/use_get_alert_details_flyout_link.ts index 1d2d1b5ea6213..9a074e16dc0b3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/use_get_alert_details_flyout_link.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/use_get_alert_details_flyout_link.ts @@ -6,9 +6,10 @@ */ import { useMemo } from 'react'; +import { DEFAULT_PREVIEW_INDEX } from '../../../../../common/constants'; +import { buildAlertDetailPath } from '../../../../../common/utils/alert_detail_path'; import { useAppUrl } from '../../../../common/lib/kibana/hooks'; -import { ALERTS_PATH } from '../../../../../common/constants'; export const useGetAlertDetailsFlyoutLink = ({ _id, @@ -20,13 +21,16 @@ export const useGetAlertDetailsFlyoutLink = ({ timestamp: string; }) => { const { getAppUrl } = useAppUrl(); + const alertDetailPath = buildAlertDetailPath({ alertId: _id, index: _index, timestamp }); + const isPreviewAlert = _index.includes(DEFAULT_PREVIEW_INDEX); + // getAppUrl accounts for the users selected space const alertDetailsLink = useMemo(() => { - const url = getAppUrl({ - path: `${ALERTS_PATH}/${_id}?index=${_index}×tamp=${timestamp}`, - }); + if (isPreviewAlert) return null; + const url = getAppUrl({ path: alertDetailPath }); + // We use window.location.origin instead of http.basePath as the http.basePath has to be configured in config dev yml return `${window.location.origin}${url}`; - }, [_id, _index, getAppUrl, timestamp]); + }, [isPreviewAlert, getAppUrl, alertDetailPath]); return alertDetailsLink; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/es_results.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/es_results.ts index 9593dd5624c22..f852bfff48873 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/es_results.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/es_results.ts @@ -50,6 +50,7 @@ import { ALERT_SEVERITY, ALERT_STATUS, ALERT_STATUS_ACTIVE, + ALERT_URL, ALERT_UUID, ALERT_WORKFLOW_STATUS, EVENT_KIND, @@ -318,6 +319,7 @@ export const sampleAlertDocAADNoSortId = ( }, ], }, + [ALERT_URL]: 'http://example.com/docID', }, fields: { someKey: ['someValue'], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/threshold.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/threshold.ts index 15f82e84155cb..399a80f4b9101 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/threshold.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/threshold.ts @@ -13,6 +13,7 @@ import { ALERT_STATUS_ACTIVE, ALERT_WORKFLOW_STATUS, ALERT_RULE_NAMESPACE, + ALERT_URL, ALERT_UUID, ALERT_RULE_TYPE_ID, ALERT_RULE_PRODUCER, @@ -125,6 +126,7 @@ export const sampleThresholdAlert = { interval: '5m', exceptions_list: getListArrayMock(), }) as TypeOfFieldMap), + [ALERT_URL]: 'http://example.com/docID', 'kibana.alert.depth': 1, }, }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts index e2650f0a142f2..990523d6ad5f9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_wrapper.ts @@ -65,7 +65,16 @@ export const securityRuleTypeFieldMap = { /* eslint-disable complexity */ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = - ({ lists, logger, config, ruleDataClient, ruleExecutionLoggerFactory, version, isPreview }) => + ({ + lists, + logger, + config, + publicBaseUrl, + ruleDataClient, + ruleExecutionLoggerFactory, + version, + isPreview, + }) => (type) => { const { alertIgnoreFields: ignoreFields, alertMergeStrategy: mergeStrategy } = config; const persistenceRuleType = createPersistenceRuleTypeWrapper({ ruleDataClient, logger }); @@ -319,6 +328,7 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = spaceId, indicesToQuery: inputIndex, alertTimestampOverride, + publicBaseUrl, ruleExecutionLogger, }); @@ -328,6 +338,7 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = mergeStrategy, completeRule, spaceId, + publicBaseUrl, indicesToQuery: inputIndex, alertTimestampOverride, }); @@ -371,6 +382,7 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper = alertTimestampOverride, alertWithSuppression, refreshOnIndexingAlerts: refresh, + publicBaseUrl, }, }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_alert_group_from_sequence.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_alert_group_from_sequence.test.ts index 98c5637b59d7a..b3cf8f3ed1675 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_alert_group_from_sequence.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_alert_group_from_sequence.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ALERT_RULE_CONSUMER } from '@kbn/rule-data-utils'; +import { ALERT_RULE_CONSUMER, ALERT_URL } from '@kbn/rule-data-utils'; import { sampleDocNoSortId, sampleRuleGuid } from '../__mocks__/es_results'; import { @@ -25,6 +25,7 @@ import { } from '../../../../../common/field_maps/field_names'; const SPACE_ID = 'space'; +const PUBLIC_BASE_URL = 'http://testkibanabaseurl.com'; const ruleExecutionLoggerMock = ruleExecutionLogMock.forExecutors.create(); @@ -54,7 +55,8 @@ describe('buildAlert', () => { SPACE_ID, jest.fn(), completeRule.ruleParams.index as string[], - undefined + undefined, + PUBLIC_BASE_URL ); expect(alertGroup.length).toEqual(3); expect(alertGroup[0]).toEqual( @@ -74,6 +76,9 @@ describe('buildAlert', () => { }), }) ); + expect(alertGroup[0]._source[ALERT_URL]).toContain( + 'http://testkibanabaseurl.com/s/space/app/security/alerts/redirect/f2db3574eaf8450e3f4d1cf4f416d70b110b035ae0a7a00026242df07f0a6c90?index=.alerts-security.alerts-space' + ); expect(alertGroup[1]).toEqual( expect.objectContaining({ _source: expect.objectContaining({ @@ -91,6 +96,9 @@ describe('buildAlert', () => { }), }) ); + expect(alertGroup[1]._source[ALERT_URL]).toContain( + 'http://testkibanabaseurl.com/s/space/app/security/alerts/redirect/1dbc416333244efbda833832eb83f13ea5d980a33c2f981ca8d2b35d82a045da?index=.alerts-security.alerts-space' + ); expect(alertGroup[2]).toEqual( expect.objectContaining({ _source: expect.objectContaining({ @@ -128,7 +136,9 @@ describe('buildAlert', () => { }), }) ); - + expect(alertGroup[2]._source[ALERT_URL]).toContain( + 'http://testkibanabaseurl.com/s/space/app/security/alerts/redirect/1b7d06954e74257140f3bf73f139078483f9658fe829fd806cc307fc0388fb23?index=.alerts-security.alerts-space' + ); const groupIds = alertGroup.map((alert) => alert._source[ALERT_GROUP_ID]); for (const groupId of groupIds) { expect(groupId).toEqual(groupIds[0]); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_alert_group_from_sequence.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_alert_group_from_sequence.ts index f03d185c19996..92c8e4d749a7d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_alert_group_from_sequence.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_alert_group_from_sequence.ts @@ -5,8 +5,10 @@ * 2.0. */ -import { ALERT_UUID } from '@kbn/rule-data-utils'; +import { ALERT_URL, ALERT_UUID } from '@kbn/rule-data-utils'; +import { getAlertDetailsUrl } from '../../../../../common/utils/alert_detail_path'; +import { DEFAULT_ALERTS_INDEX } from '../../../../../common/constants'; import type { ConfigType } from '../../../../config'; import type { Ancestor, SignalSource, SignalSourceHit } from '../types'; import { buildAlert, buildAncestors, generateAlertId } from '../factories/utils/build_alert'; @@ -43,7 +45,8 @@ export const buildAlertGroupFromSequence = ( spaceId: string | null | undefined, buildReasonMessage: BuildReasonMessage, indicesToQuery: string[], - alertTimestampOverride: Date | undefined + alertTimestampOverride: Date | undefined, + publicBaseUrl?: string ): Array> => { const ancestors: Ancestor[] = sequence.events.flatMap((event) => buildAncestors(event)); if (ancestors.some((ancestor) => ancestor?.rule === completeRule.alertId)) { @@ -65,7 +68,9 @@ export const buildAlertGroupFromSequence = ( buildReasonMessage, indicesToQuery, alertTimestampOverride, - ruleExecutionLogger + ruleExecutionLogger, + 'placeholder-alert-uuid', // This is overriden below + publicBaseUrl ) ); } catch (error) { @@ -96,7 +101,8 @@ export const buildAlertGroupFromSequence = ( spaceId, buildReasonMessage, indicesToQuery, - alertTimestampOverride + alertTimestampOverride, + publicBaseUrl ); const sequenceAlert: WrappedFieldsLatest = { _id: shellAlert[ALERT_UUID], @@ -106,15 +112,26 @@ export const buildAlertGroupFromSequence = ( // Finally, we have the group id from the shell alert so we can convert the BaseFields into EqlBuildingBlocks const wrappedBuildingBlocks = wrappedBaseFields.map( - (block, i): WrappedFieldsLatest => ({ - ...block, - _source: { - ...block._source, - [ALERT_BUILDING_BLOCK_TYPE]: 'default', - [ALERT_GROUP_ID]: shellAlert[ALERT_GROUP_ID], - [ALERT_GROUP_INDEX]: i, - }, - }) + (block, i): WrappedFieldsLatest => { + const alertUrl = getAlertDetailsUrl({ + alertId: block._id, + index: `${DEFAULT_ALERTS_INDEX}-${spaceId}`, + timestamp: block._source['@timestamp'], + basePath: publicBaseUrl, + spaceId, + }); + + return { + ...block, + _source: { + ...block._source, + [ALERT_BUILDING_BLOCK_TYPE]: 'default', + [ALERT_GROUP_ID]: shellAlert[ALERT_GROUP_ID], + [ALERT_GROUP_INDEX]: i, + [ALERT_URL]: alertUrl, + }, + }; + } ); return [...wrappedBuildingBlocks, sequenceAlert]; @@ -126,7 +143,8 @@ export const buildAlertRoot = ( spaceId: string | null | undefined, buildReasonMessage: BuildReasonMessage, indicesToQuery: string[], - alertTimestampOverride: Date | undefined + alertTimestampOverride: Date | undefined, + publicBaseUrl?: string ): EqlShellFieldsLatest => { const mergedAlerts = objectArrayIntersection(wrappedBuildingBlocks.map((alert) => alert._source)); const reason = buildReasonMessage({ @@ -140,14 +158,25 @@ export const buildAlertRoot = ( spaceId, reason, indicesToQuery, + 'placeholder-uuid', // These will be overriden below + publicBaseUrl, // Not necessary now, but when the ID is created ahead of time this can be passed alertTimestampOverride ); const alertId = generateAlertId(doc); + const alertUrl = getAlertDetailsUrl({ + alertId, + index: `${DEFAULT_ALERTS_INDEX}-${spaceId}`, + timestamp: doc['@timestamp'], + basePath: publicBaseUrl, + spaceId, + }); + return { ...mergedAlerts, ...doc, [ALERT_UUID]: alertId, [ALERT_GROUP_ID]: alertId, + [ALERT_URL]: alertUrl, }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/wrap_sequences_factory.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/wrap_sequences_factory.ts index 650cfb21a71c9..6c608da5cb5cb 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/wrap_sequences_factory.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/wrap_sequences_factory.ts @@ -21,6 +21,7 @@ export const wrapSequencesFactory = completeRule, ignoreFields, mergeStrategy, + publicBaseUrl, spaceId, indicesToQuery, alertTimestampOverride, @@ -32,6 +33,7 @@ export const wrapSequencesFactory = spaceId: string | null | undefined; indicesToQuery: string[]; alertTimestampOverride: Date | undefined; + publicBaseUrl: string | undefined; }): WrapSequences => (sequences, buildReasonMessage) => sequences.reduce( @@ -45,7 +47,8 @@ export const wrapSequencesFactory = spaceId, buildReasonMessage, indicesToQuery, - alertTimestampOverride + alertTimestampOverride, + publicBaseUrl ), ], [] diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.test.ts index 2b6702f591ab4..8525c63ce8c87 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.test.ts @@ -17,6 +17,7 @@ import { ALERT_SEVERITY, ALERT_STATUS, ALERT_STATUS_ACTIVE, + ALERT_URL, ALERT_UUID, ALERT_WORKFLOW_STATUS, EVENT_ACTION, @@ -31,7 +32,7 @@ import { sampleDocNoSortIdWithTimestamp } from '../../__mocks__/es_results'; import { buildAlert, buildParent, buildAncestors, additionalAlertFields } from './build_alert'; import type { Ancestor, SignalSourceHit } from '../../types'; import { getListArrayMock } from '../../../../../../common/detection_engine/schemas/types/lists.mock'; -import { SERVER_APP_ID } from '../../../../../../common/constants'; +import { DEFAULT_ALERTS_INDEX, SERVER_APP_ID } from '../../../../../../common/constants'; import { EVENT_DATASET } from '../../../../../../common/cti/constants'; import { ALERT_ANCESTORS, @@ -48,6 +49,9 @@ type SignalDoc = SignalSourceHit & { }; const SPACE_ID = 'space'; +const reason = 'alert reasonable reason'; +const publicBaseUrl = 'testKibanaBasePath.com'; +const alertUuid = 'test-uuid'; describe('buildAlert', () => { beforeEach(() => { @@ -58,7 +62,6 @@ describe('buildAlert', () => { const doc = sampleDocNoSortIdWithTimestamp('d5e8eb51-a6a0-456d-8a15-4b79bfec3d71'); delete doc._source.event; const completeRule = getCompleteRuleMock(getQueryRuleParams()); - const reason = 'alert reasonable reason'; const alert = { ...buildAlert( [doc], @@ -66,11 +69,14 @@ describe('buildAlert', () => { SPACE_ID, reason, completeRule.ruleParams.index as string[], + alertUuid, + publicBaseUrl, undefined ), ...additionalAlertFields(doc), }; const timestamp = alert[TIMESTAMP]; + const expectedAlertUrl = `${publicBaseUrl}/s/${SPACE_ID}/app/security/alerts/redirect/${alertUuid}?index=${DEFAULT_ALERTS_INDEX}-${SPACE_ID}×tamp=${timestamp}`; const expected = { [TIMESTAMP]: timestamp, [EVENT_KIND]: 'signal', @@ -222,6 +228,8 @@ describe('buildAlert', () => { timeline_title: 'some-timeline-title', }), [ALERT_DEPTH]: 1, + [ALERT_URL]: expectedAlertUrl, + [ALERT_UUID]: alertUuid, }; expect(alert).toEqual(expected); }); @@ -239,7 +247,6 @@ describe('buildAlert', () => { }, }; const completeRule = getCompleteRuleMock(getQueryRuleParams()); - const reason = 'alert reasonable reason'; const alert = { ...buildAlert( [doc], @@ -247,12 +254,14 @@ describe('buildAlert', () => { SPACE_ID, reason, completeRule.ruleParams.index as string[], + alertUuid, + publicBaseUrl, undefined ), ...additionalAlertFields(doc), }; const timestamp = alert[TIMESTAMP]; - + const expectedAlertUrl = `${publicBaseUrl}/s/${SPACE_ID}/app/security/alerts/redirect/${alertUuid}?index=${DEFAULT_ALERTS_INDEX}-${SPACE_ID}×tamp=${timestamp}`; const expected = { [TIMESTAMP]: timestamp, [EVENT_KIND]: 'signal', @@ -410,6 +419,8 @@ describe('buildAlert', () => { timeline_title: 'some-timeline-title', }), [ALERT_DEPTH]: 1, + [ALERT_URL]: expectedAlertUrl, + [ALERT_UUID]: alertUuid, }; expect(alert).toEqual(expected); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.ts index d206fa06704f0..5d53380a736f5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert.ts @@ -34,6 +34,8 @@ import { ALERT_SEVERITY, ALERT_STATUS, ALERT_STATUS_ACTIVE, + ALERT_URL, + ALERT_UUID, ALERT_WORKFLOW_STATUS, EVENT_KIND, SPACE_IDS, @@ -43,6 +45,7 @@ import { flattenWithPrefix } from '@kbn/securitysolution-rules'; import { createHash } from 'crypto'; +import { getAlertDetailsUrl } from '../../../../../../common/utils/alert_detail_path'; import type { BaseSignalHit, SimpleHit } from '../../types'; import type { ThresholdResult } from '../../threshold/types'; import { @@ -51,7 +54,7 @@ import { isWrappedDetectionAlert, isWrappedSignalHit, } from '../../utils/utils'; -import { SERVER_APP_ID } from '../../../../../../common/constants'; +import { DEFAULT_ALERTS_INDEX, SERVER_APP_ID } from '../../../../../../common/constants'; import type { SearchTypes } from '../../../../telemetry/types'; import { ALERT_ANCESTORS, @@ -137,6 +140,8 @@ export const buildAlert = ( spaceId: string | null | undefined, reason: string, indicesToQuery: string[], + alertUuid: string, + publicBaseUrl: string | undefined, alertTimestampOverride: Date | undefined, overrides?: { nameOverride: string; @@ -180,8 +185,18 @@ export const buildAlert = ( primaryTimestamp: TIMESTAMP, }); + const timestamp = alertTimestampOverride?.toISOString() ?? new Date().toISOString(); + + const alertUrl = getAlertDetailsUrl({ + alertId: alertUuid, + index: `${DEFAULT_ALERTS_INDEX}-${spaceId}`, + timestamp, + basePath: publicBaseUrl, + spaceId, + }); + return { - [TIMESTAMP]: alertTimestampOverride?.toISOString() ?? new Date().toISOString(), + [TIMESTAMP]: timestamp, [SPACE_IDS]: spaceId != null ? [spaceId] : [], [EVENT_KIND]: 'signal', [ALERT_ORIGINAL_TIME]: originalTime?.toISOString(), @@ -229,6 +244,8 @@ export const buildAlert = ( [ALERT_RULE_UPDATED_BY]: updatedBy ?? '', [ALERT_RULE_UUID]: completeRule.alertId, [ALERT_RULE_VERSION]: params.version, + [ALERT_URL]: alertUrl, + [ALERT_UUID]: alertUuid, ...flattenWithPrefix(ALERT_RULE_META, params.meta), // These fields don't exist in the mappings, but leaving here for now to limit changes to the alert building logic 'kibana.alert.rule.risk_score': params.riskScore, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_bulk_body.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_bulk_body.ts index cd7351362db0b..1963837d64bc7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_bulk_body.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_bulk_body.ts @@ -55,7 +55,9 @@ export const buildBulkBody = ( buildReasonMessage: BuildReasonMessage, indicesToQuery: string[], alertTimestampOverride: Date | undefined, - ruleExecutionLogger: IRuleExecutionLogForExecutors + ruleExecutionLogger: IRuleExecutionLogForExecutors, + alertUuid: string, + publicBaseUrl?: string ): BaseFieldsLatest => { const mergedDoc = getMergeStrategy(mergeStrategy)({ doc, ignoreFields }); @@ -111,6 +113,8 @@ export const buildBulkBody = ( spaceId, reason, indicesToQuery, + alertUuid, + publicBaseUrl, alertTimestampOverride, overrides ), diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/wrap_hits_factory.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/wrap_hits_factory.ts index aae7501bf3798..a5b56303c603d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/wrap_hits_factory.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/wrap_hits_factory.ts @@ -6,8 +6,6 @@ */ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { ALERT_UUID } from '@kbn/rule-data-utils'; - import type { ConfigType } from '../../../../config'; import type { SignalSource, SimpleHit } from '../types'; import type { CompleteRule, RuleParams } from '../../rule_schema'; @@ -28,6 +26,7 @@ export const wrapHitsFactory = spaceId, indicesToQuery, alertTimestampOverride, + publicBaseUrl, ruleExecutionLogger, }: { completeRule: CompleteRule; @@ -36,6 +35,7 @@ export const wrapHitsFactory = spaceId: string | null | undefined; indicesToQuery: string[]; alertTimestampOverride: Date | undefined; + publicBaseUrl: string | undefined; ruleExecutionLogger: IRuleExecutionLogForExecutors; }) => ( @@ -49,23 +49,27 @@ export const wrapHitsFactory = String(event._version), `${spaceId}:${completeRule.alertId}` ); + + const baseAlert = buildBulkBody( + spaceId, + completeRule, + event as SimpleHit, + mergeStrategy, + ignoreFields, + true, + buildReasonMessage, + indicesToQuery, + alertTimestampOverride, + ruleExecutionLogger, + id, + publicBaseUrl + ); + return { _id: id, _index: '', _source: { - ...buildBulkBody( - spaceId, - completeRule, - event as SimpleHit, - mergeStrategy, - ignoreFields, - true, - buildReasonMessage, - indicesToQuery, - alertTimestampOverride, - ruleExecutionLogger - ), - [ALERT_UUID]: id, + ...baseAlert, }, }; }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/create_new_terms_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/create_new_terms_alert_type.ts index c4525de1b00b3..41c5420c748e9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/create_new_terms_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/create_new_terms_alert_type.ts @@ -107,6 +107,7 @@ export const createNewTermsAlertType = ( exceptionFilter, unprocessedExceptions, alertTimestampOverride, + publicBaseUrl, }, services, params, @@ -300,6 +301,7 @@ export const createNewTermsAlertType = ( indicesToQuery: inputIndex, alertTimestampOverride, ruleExecutionLogger, + publicBaseUrl, }); const bulkCreateResult = await bulkCreate( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/wrap_new_terms_alerts.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/wrap_new_terms_alerts.test.ts index 69d0b90b45c29..67a3c69af9850 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/wrap_new_terms_alerts.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/wrap_new_terms_alerts.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ALERT_UUID } from '@kbn/rule-data-utils'; +import { ALERT_URL, ALERT_UUID } from '@kbn/rule-data-utils'; import { ALERT_NEW_TERMS } from '../../../../../common/field_maps/field_names'; import { getCompleteRuleMock, getNewTermsRuleParams } from '../../rule_schema/mocks'; import { ruleExecutionLogMock } from '../../rule_monitoring/mocks'; @@ -15,6 +15,7 @@ import { wrapNewTermsAlerts } from './wrap_new_terms_alerts'; const ruleExecutionLogger = ruleExecutionLogMock.forExecutors.create(); const docId = 'd5e8eb51-a6a0-456d-8a15-4b79bfec3d71'; +const publicBaseUrl = 'http://somekibanabaseurl.com'; describe('wrapNewTermsAlerts', () => { test('should create an alert with the correct _id from a document', () => { const doc = sampleDocNoSortIdWithTimestamp(docId); @@ -27,11 +28,15 @@ describe('wrapNewTermsAlerts', () => { indicesToQuery: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], alertTimestampOverride: undefined, ruleExecutionLogger, + publicBaseUrl, }); expect(alerts[0]._id).toEqual('a36d9fe6fe4b2f65058fb1a487733275f811af58'); expect(alerts[0]._source[ALERT_UUID]).toEqual('a36d9fe6fe4b2f65058fb1a487733275f811af58'); expect(alerts[0]._source[ALERT_NEW_TERMS]).toEqual(['127.0.0.1']); + expect(alerts[0]._source[ALERT_URL]).toContain( + 'http://somekibanabaseurl.com/app/security/alerts/redirect/a36d9fe6fe4b2f65058fb1a487733275f811af58?index=.alerts-security.alerts-default' + ); }); test('should create an alert with a different _id if the space is different', () => { @@ -45,11 +50,15 @@ describe('wrapNewTermsAlerts', () => { indicesToQuery: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], alertTimestampOverride: undefined, ruleExecutionLogger, + publicBaseUrl, }); expect(alerts[0]._id).toEqual('f7877a31b1cc83373dbc9ba5939ebfab1db66545'); expect(alerts[0]._source[ALERT_UUID]).toEqual('f7877a31b1cc83373dbc9ba5939ebfab1db66545'); expect(alerts[0]._source[ALERT_NEW_TERMS]).toEqual(['127.0.0.1']); + expect(alerts[0]._source[ALERT_URL]).toContain( + 'http://somekibanabaseurl.com/s/otherSpace/app/security/alerts/redirect/f7877a31b1cc83373dbc9ba5939ebfab1db66545?index=.alerts-security.alerts-otherSpace' + ); }); test('should create an alert with a different _id if the newTerms array is different', () => { @@ -63,11 +72,15 @@ describe('wrapNewTermsAlerts', () => { indicesToQuery: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], alertTimestampOverride: undefined, ruleExecutionLogger, + publicBaseUrl, }); expect(alerts[0]._id).toEqual('75e5a507a4bc48bcd983820c7fd2d9621ff4e2ea'); expect(alerts[0]._source[ALERT_UUID]).toEqual('75e5a507a4bc48bcd983820c7fd2d9621ff4e2ea'); expect(alerts[0]._source[ALERT_NEW_TERMS]).toEqual(['127.0.0.2']); + expect(alerts[0]._source[ALERT_URL]).toContain( + 'http://somekibanabaseurl.com/s/otherSpace/app/security/alerts/redirect/75e5a507a4bc48bcd983820c7fd2d9621ff4e2ea?index=.alerts-security.alerts-otherSpace' + ); }); test('should create an alert with a different _id if the newTerms array contains multiple terms', () => { @@ -81,10 +94,14 @@ describe('wrapNewTermsAlerts', () => { indicesToQuery: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'], alertTimestampOverride: undefined, ruleExecutionLogger, + publicBaseUrl, }); expect(alerts[0]._id).toEqual('86a216cfa4884767d9bb26d2b8db911cb4aa85ce'); expect(alerts[0]._source[ALERT_UUID]).toEqual('86a216cfa4884767d9bb26d2b8db911cb4aa85ce'); expect(alerts[0]._source[ALERT_NEW_TERMS]).toEqual(['127.0.0.1', '127.0.0.2']); + expect(alerts[0]._source[ALERT_URL]).toContain( + 'http://somekibanabaseurl.com/s/otherSpace/app/security/alerts/redirect/86a216cfa4884767d9bb26d2b8db911cb4aa85ce?index=.alerts-security.alerts-otherSpace' + ); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/wrap_new_terms_alerts.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/wrap_new_terms_alerts.ts index 424c52273c30a..2a373edf7de6e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/wrap_new_terms_alerts.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/new_terms/wrap_new_terms_alerts.ts @@ -7,7 +7,6 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/types'; import objectHash from 'object-hash'; -import { ALERT_UUID } from '@kbn/rule-data-utils'; import type { BaseFieldsLatest, NewTermsFieldsLatest, @@ -34,6 +33,7 @@ export const wrapNewTermsAlerts = ({ indicesToQuery, alertTimestampOverride, ruleExecutionLogger, + publicBaseUrl, }: { eventsAndTerms: EventsAndTerms[]; spaceId: string | null | undefined; @@ -42,6 +42,7 @@ export const wrapNewTermsAlerts = ({ indicesToQuery: string[]; alertTimestampOverride: Date | undefined; ruleExecutionLogger: IRuleExecutionLogForExecutors; + publicBaseUrl: string | undefined; }): Array> => { return eventsAndTerms.map((eventAndTerms) => { const id = objectHash([ @@ -61,15 +62,17 @@ export const wrapNewTermsAlerts = ({ buildReasonMessageForNewTermsAlert, indicesToQuery, alertTimestampOverride, - ruleExecutionLogger + ruleExecutionLogger, + id, + publicBaseUrl ); + return { _id: id, _index: '', _source: { ...baseAlert, [ALERT_NEW_TERMS]: eventAndTerms.newTerms, - [ALERT_UUID]: id, }, }; }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/alert_suppression/group_and_bulk_create.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/alert_suppression/group_and_bulk_create.ts index 99f1901aad9f8..6338917eecbb9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/alert_suppression/group_and_bulk_create.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/alert_suppression/group_and_bulk_create.ts @@ -224,6 +224,7 @@ export const groupAndBulkCreate = async ({ completeRule: runOpts.completeRule, mergeStrategy: runOpts.mergeStrategy, indicesToQuery: runOpts.inputIndex, + publicBaseUrl: runOpts.publicBaseUrl, buildReasonMessage, alertTimestampOverride: runOpts.alertTimestampOverride, ruleExecutionLogger: runOpts.ruleExecutionLogger, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/alert_suppression/wrap_suppressed_alerts.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/alert_suppression/wrap_suppressed_alerts.ts index 42fe0954a1847..c793d24f9fa6b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/alert_suppression/wrap_suppressed_alerts.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/alert_suppression/wrap_suppressed_alerts.ts @@ -8,7 +8,6 @@ import objectHash from 'object-hash'; import type * as estypes from '@elastic/elasticsearch/lib/api/types'; import { - ALERT_UUID, ALERT_SUPPRESSION_TERMS, ALERT_SUPPRESSION_DOCS_COUNT, ALERT_SUPPRESSION_END, @@ -56,6 +55,7 @@ export const wrapSuppressedAlerts = ({ buildReasonMessage, alertTimestampOverride, ruleExecutionLogger, + publicBaseUrl, }: { suppressionBuckets: SuppressionBuckets[]; spaceId: string; @@ -65,6 +65,7 @@ export const wrapSuppressedAlerts = ({ buildReasonMessage: BuildReasonMessage; alertTimestampOverride: Date | undefined; ruleExecutionLogger: IRuleExecutionLogForExecutors; + publicBaseUrl: string | undefined; }): Array> => { return suppressionBuckets.map((bucket) => { const id = objectHash([ @@ -91,8 +92,11 @@ export const wrapSuppressedAlerts = ({ buildReasonMessage, indicesToQuery, alertTimestampOverride, - ruleExecutionLogger + ruleExecutionLogger, + id, + publicBaseUrl ); + return { _id: id, _index: '', @@ -102,7 +106,6 @@ export const wrapSuppressedAlerts = ({ [ALERT_SUPPRESSION_START]: bucket.start, [ALERT_SUPPRESSION_END]: bucket.end, [ALERT_SUPPRESSION_DOCS_COUNT]: bucket.count - 1, - [ALERT_UUID]: id, [ALERT_INSTANCE_ID]: instanceId, }, }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts index d3f8e8b42b44e..13c7e9e53df54 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts @@ -34,6 +34,7 @@ jest.mock('../utils/get_list_client', () => ({ describe('Custom Query Alerts', () => { const mocks = createRuleTypeMocks(); const licensing = licensingMock.createSetup(); + const publicBaseUrl = 'http://somekibanabaseurl.com'; const { dependencies, executor, services } = mocks; const { alerting, lists, logger, ruleDataClient } = dependencies; @@ -44,6 +45,7 @@ describe('Custom Query Alerts', () => { ruleDataClient, ruleExecutionLoggerFactory: () => Promise.resolve(ruleExecutionLogMock.forExecutors.create()), version: '8.3', + publicBaseUrl, }); const eventsTelemetry = createMockTelemetryEventsSender(true); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts index 0dee5eba79cc4..c58613344f724 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts @@ -100,6 +100,7 @@ export interface RunOpts { alertTimestampOverride: Date | undefined; alertWithSuppression: SuppressedAlertService; refreshOnIndexingAlerts: RefreshTypes; + publicBaseUrl: string | undefined; } export type SecurityAlertType< @@ -129,6 +130,7 @@ export interface CreateSecurityRuleTypeWrapperProps { lists: SetupPlugins['lists']; logger: Logger; config: ConfigType; + publicBaseUrl: string | undefined; ruleDataClient: IRuleDataClient; ruleExecutionLoggerFactory: IRuleExecutionLogService['createClientForExecutors']; version: string; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/enrichments/__mocks__/alerts.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/enrichments/__mocks__/alerts.ts index 43cd37aca396a..3d3dc8872f261 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/enrichments/__mocks__/alerts.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/enrichments/__mocks__/alerts.ts @@ -38,6 +38,7 @@ import { ALERT_SEVERITY, ALERT_STATUS, ALERT_STATUS_ACTIVE, + ALERT_URL, ALERT_UUID, ALERT_WORKFLOW_STATUS, EVENT_KIND, @@ -158,6 +159,7 @@ export const createAlert = ( [ALERT_RULE_UUID]: '2e051244-b3c6-4779-a241-e1b4f0beceb9', [ALERT_RULE_VERSION]: 1, [ALERT_UUID]: someUuid, + [ALERT_URL]: `http://kibanaurl.com/app/security/alerts/redirect/${someUuid}?index=myFakeSignalIndex×tamp=2020-04-20T21:27:45`, 'kibana.alert.rule.risk_score': 50, 'kibana.alert.rule.severity': 'high', 'kibana.alert.rule.building_block_type': undefined, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/search_after_bulk_create.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/search_after_bulk_create.test.ts index 8e71a4dce49aa..31c1e38b08f91 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/search_after_bulk_create.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/search_after_bulk_create.test.ts @@ -112,6 +112,7 @@ describe('searchAfterAndBulkCreate', () => { indicesToQuery: inputIndexPattern, alertTimestampOverride: undefined, ruleExecutionLogger, + publicBaseUrl: 'http://testkibanabaseurl.com', }); }); diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 48b963b788bd2..164969377c6d7 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -238,6 +238,7 @@ export class Plugin implements ISecuritySolutionPlugin { lists: plugins.lists, logger: this.logger, config: this.config, + publicBaseUrl: core.http.basePath.publicBaseUrl, ruleDataClient, ruleExecutionLoggerFactory: ruleExecutionLogService.createClientForExecutors, version: pluginContext.env.packageInfo.version,