From 3034dc86a778d8acdf0240fe00f0354132f03bd7 Mon Sep 17 00:00:00 2001 From: Jordan <51442161+JordanSh@users.noreply.github.com> Date: Tue, 15 Oct 2024 12:56:18 +0300 Subject: [PATCH 01/13] [Cloud Security] Temporarily disabled rule creation for 3P findings (#196185) --- .../components/detection_rule_counter.tsx | 32 ++++++++++++++----- .../create_detection_rule_from_benchmark.ts | 10 +++++- ..._detection_rule_from_vulnerability.test.ts | 2 +- ...reate_detection_rule_from_vulnerability.ts | 10 ++++++ 4 files changed, 44 insertions(+), 10 deletions(-) diff --git a/x-pack/plugins/cloud_security_posture/public/components/detection_rule_counter.tsx b/x-pack/plugins/cloud_security_posture/public/components/detection_rule_counter.tsx index 01309ce334d3c..8c75496e04c7d 100644 --- a/x-pack/plugins/cloud_security_posture/public/components/detection_rule_counter.tsx +++ b/x-pack/plugins/cloud_security_posture/public/components/detection_rule_counter.tsx @@ -17,6 +17,7 @@ import { METRIC_TYPE } from '@kbn/analytics'; import { useHistory } from 'react-router-dom'; import useSessionStorage from 'react-use/lib/useSessionStorage'; import { useQueryClient } from '@tanstack/react-query'; +import { i18n as kbnI18n } from '@kbn/i18n'; import { useFetchDetectionRulesAlertsStatus } from '../common/api/use_fetch_detection_rules_alerts_status'; import { useFetchDetectionRulesByTags } from '../common/api/use_fetch_detection_rules_by_tags'; import { RuleResponse } from '../common/types'; @@ -67,15 +68,30 @@ export const DetectionRuleCounter = ({ tags, createRuleFn }: DetectionRuleCounte }, [history]); const createDetectionRuleOnClick = useCallback(async () => { - uiMetricService.trackUiMetric(METRIC_TYPE.CLICK, CREATE_DETECTION_RULE_FROM_FLYOUT); const startServices = { analytics, notifications, i18n, theme }; - setIsCreateRuleLoading(true); - const ruleResponse = await createRuleFn(http); - setIsCreateRuleLoading(false); - showCreateDetectionRuleSuccessToast(startServices, http, ruleResponse); - // Triggering a refetch of rules and alerts to update the UI - queryClient.invalidateQueries([DETECTION_ENGINE_RULES_KEY]); - queryClient.invalidateQueries([DETECTION_ENGINE_ALERTS_KEY]); + + try { + setIsCreateRuleLoading(true); + uiMetricService.trackUiMetric(METRIC_TYPE.CLICK, CREATE_DETECTION_RULE_FROM_FLYOUT); + + const ruleResponse = await createRuleFn(http); + + setIsCreateRuleLoading(false); + showCreateDetectionRuleSuccessToast(startServices, http, ruleResponse); + + // Triggering a refetch of rules and alerts to update the UI + queryClient.invalidateQueries([DETECTION_ENGINE_RULES_KEY]); + queryClient.invalidateQueries([DETECTION_ENGINE_ALERTS_KEY]); + } catch (e) { + setIsCreateRuleLoading(false); + + notifications.toasts.addWarning({ + title: kbnI18n.translate('xpack.csp.detectionRuleCounter.alerts.createRuleErrorTitle', { + defaultMessage: 'Coming Soon', + }), + text: e.message, + }); + } }, [createRuleFn, http, analytics, notifications, i18n, theme, queryClient]); if (alertsIsError) return <>{'-'}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/configurations/utils/create_detection_rule_from_benchmark.ts b/x-pack/plugins/cloud_security_posture/public/pages/configurations/utils/create_detection_rule_from_benchmark.ts index 0ce1b7d09e897..cd09f275aaf22 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/configurations/utils/create_detection_rule_from_benchmark.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/configurations/utils/create_detection_rule_from_benchmark.ts @@ -8,8 +8,8 @@ import { HttpSetup } from '@kbn/core/public'; import { LATEST_FINDINGS_RETENTION_POLICY } from '@kbn/cloud-security-posture-common'; import type { CspBenchmarkRule } from '@kbn/cloud-security-posture-common/schema/rules/latest'; +import { i18n } from '@kbn/i18n'; import { FINDINGS_INDEX_PATTERN } from '../../../../common/constants'; - import { createDetectionRule } from '../../../common/api/create_detection_rule'; import { generateBenchmarkRuleTags } from '../../../../common/utils/detection_rules'; @@ -63,6 +63,14 @@ export const createDetectionRuleFromBenchmarkRule = async ( http: HttpSetup, benchmarkRule: CspBenchmarkRule['metadata'] ) => { + if (!benchmarkRule.benchmark?.posture_type) { + throw new Error( + i18n.translate('xpack.csp.createDetectionRuleFromBenchmarkRule.createRuleErrorMessage', { + defaultMessage: 'Rule creation is currently only available for Elastic findings', + }) + ); + } + return await createDetectionRule({ http, rule: { diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils/create_detection_rule_from_vulnerability.test.ts b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils/create_detection_rule_from_vulnerability.test.ts index 7dd0982cc58b5..4558d78fb8cf9 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils/create_detection_rule_from_vulnerability.test.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils/create_detection_rule_from_vulnerability.test.ts @@ -18,7 +18,7 @@ jest.mock('../../../common/utils/is_native_csp_finding', () => ({ isNativeCspFinding: jest.fn(), })); -describe('CreateDetectionRuleFromVulnerability', () => { +describe.skip('CreateDetectionRuleFromVulnerability', () => { describe('getVulnerabilityTags', () => { it('should return tags with CSP_RULE_TAG and vulnerability id', () => { const mockVulnerability = { diff --git a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils/create_detection_rule_from_vulnerability.ts b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils/create_detection_rule_from_vulnerability.ts index 804e89fad61d8..bf01180c38789 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils/create_detection_rule_from_vulnerability.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/vulnerabilities/utils/create_detection_rule_from_vulnerability.ts @@ -13,6 +13,7 @@ import { VULNERABILITIES_SEVERITY, } from '@kbn/cloud-security-posture-common'; import type { Vulnerability } from '@kbn/cloud-security-posture-common/schema/vulnerabilities/latest'; +import { CSP_VULN_DATASET } from '../../../common/utils/get_vendor_name'; import { isNativeCspFinding } from '../../../common/utils/is_native_csp_finding'; import { VULNERABILITIES_INDEX_PATTERN } from '../../../../common/constants'; import { createDetectionRule } from '../../../common/api/create_detection_rule'; @@ -87,6 +88,15 @@ export const createDetectionRuleFromVulnerabilityFinding = async ( http: HttpSetup, vulnerabilityFinding: CspVulnerabilityFinding ) => { + if (vulnerabilityFinding.data_stream?.dataset !== CSP_VULN_DATASET) { + throw new Error( + i18n.translate( + 'xpack.csp.createDetectionRuleFromVulnerabilityFinding.createRuleErrorMessage', + { defaultMessage: 'Rule creation is currently only available for Elastic findings' } + ) + ); + } + const tags = getVulnerabilityTags(vulnerabilityFinding); const vulnerability = vulnerabilityFinding.vulnerability; From 5c2df6347d779f577946634e972d30224299079a Mon Sep 17 00:00:00 2001 From: Jiawei Wu <74562234+JiaweiWu@users.noreply.github.com> Date: Tue, 15 Oct 2024 03:17:59 -0700 Subject: [PATCH 02/13] [Response Ops][Rules] Add New Rule Form to Stack Management (#194655) ## Summary Enables and adds the new rule form to stack management. We are only going to turn this on for stack management for now until we are confident that this is fairly bug free. ### To test: 1. Switch `USE_NEW_RULE_FORM_FEATURE_FLAG` to true 2. Navigate to stack management -> rules list 3. Click "Create rule" 4. Assert the user is navigated to the new form 5. Create rule 6. Assert the user is navigated to the rule details page 7. Click "Edit" 8. Edit rule 9. Assert the user is navigated to the rule details page 10. Try editing a rule in the rules list and assert everything works as expected We should also make sure this rule form is not enabled in other solutions. ### 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: Elastic Machine Co-authored-by: Christos Nasikas --- .../update_rule/transform_update_rule_body.ts | 2 +- .../src/common/constants/rule_flapping.ts | 2 +- .../src/common/hooks/use_create_rule.ts | 3 +- .../src/common/hooks/use_load_connectors.ts | 4 +- .../use_load_rule_type_aad_template_fields.ts | 4 +- .../src/common/hooks/use_resolve_rule.ts | 4 +- .../src/common/hooks/use_update_rule.ts | 3 +- .../src/common/types/rule_types.ts | 2 - .../src/rule_form/constants.ts | 3 +- .../src/rule_form/create_rule_form.tsx | 22 ++- .../src/rule_form/edit_rule_form.tsx | 28 ++- .../hooks/use_load_dependencies.test.tsx | 49 +---- .../rule_form/hooks/use_load_dependencies.ts | 39 ++-- .../rule_actions/rule_actions.test.tsx | 12 +- .../rule_form/rule_actions/rule_actions.tsx | 13 +- .../rule_actions_alerts_filter.tsx | 1 + .../rule_actions_connectors_modal.tsx | 5 +- .../rule_actions/rule_actions_item.test.tsx | 2 +- .../rule_actions/rule_actions_item.tsx | 91 ++++++--- .../rule_definition/rule_alert_delay.test.tsx | 7 +- .../rule_definition/rule_alert_delay.tsx | 14 +- .../rule_definition/rule_definition.test.tsx | 69 ++++++- .../rule_definition/rule_definition.tsx | 39 ++-- .../rule_definition/rule_schedule.tsx | 3 - .../src/rule_form/rule_form.tsx | 29 ++- .../rule_form_state_reducer.test.tsx | 2 + .../rule_form_state_reducer.ts | 49 +++-- .../rule_form/rule_page/rule_page.test.tsx | 15 +- .../src/rule_form/rule_page/rule_page.tsx | 178 +++++++++++++----- .../rule_page/rule_page_footer.test.tsx | 50 ++++- .../rule_form/rule_page/rule_page_footer.tsx | 6 +- .../src/rule_form/translations.ts | 37 +++- .../src/rule_form/types.ts | 4 +- .../utils/get_authorized_consumers.ts | 3 - .../src/rule_form/utils/get_default_params.ts | 25 +++ .../src/rule_form/utils/index.ts | 1 + .../src/rule_form/validation/validate_form.ts | 49 ++--- .../rule_settings_flapping_form.tsx | 12 +- .../rule_settings_flapping_title_tooltip.tsx | 1 + .../src/routes/stack_rule_paths.ts | 5 + .../public/application.tsx | 2 - .../.storybook/decorator.tsx | 2 +- .../common/experimental_features.ts | 2 +- .../public/application/constants/index.ts | 2 + .../public/application/home.tsx | 1 + .../public/application/lib/breadcrumb.ts | 12 ++ .../public/application/lib/doc_title.ts | 10 + .../public/application/rules_app.tsx | 22 ++- .../sections/rule_details/components/rule.tsx | 1 + .../components/rule_definition.test.tsx | 4 + .../components/rule_definition.tsx | 26 ++- .../components/rule_details.test.tsx | 4 + .../rule_details/components/rule_details.tsx | 30 ++- .../components/rule_details_route.test.tsx | 5 + .../sections/rule_form/rule_form_route.tsx | 100 ++++++++++ .../rules_list/components/rules_list.tsx | 33 +++- .../common/get_experimental_features.test.tsx | 4 +- .../triggers_actions_ui/public/types.ts | 1 + .../functional_with_es_ssl/config.base.ts | 1 + .../test_serverless/functional/config.base.ts | 5 + 60 files changed, 857 insertions(+), 297 deletions(-) create mode 100644 packages/kbn-alerts-ui-shared/src/rule_form/utils/get_default_params.ts create mode 100644 x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form_route.tsx diff --git a/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/transform_update_rule_body.ts b/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/transform_update_rule_body.ts index 8f4e59d80458b..9a719c24076f7 100644 --- a/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/transform_update_rule_body.ts +++ b/packages/kbn-alerts-ui-shared/src/common/apis/update_rule/transform_update_rule_body.ts @@ -54,6 +54,6 @@ export const transformUpdateRuleBody: RewriteResponseCase = ({ ...(uuid && { uuid }), }; }), - ...(alertDelay ? { alert_delay: alertDelay } : {}), + ...(alertDelay !== undefined ? { alert_delay: alertDelay } : {}), ...(flapping !== undefined ? { flapping: transformUpdateRuleFlapping(flapping) } : {}), }); diff --git a/packages/kbn-alerts-ui-shared/src/common/constants/rule_flapping.ts b/packages/kbn-alerts-ui-shared/src/common/constants/rule_flapping.ts index 49ea5a63b3fca..542bb055fd431 100644 --- a/packages/kbn-alerts-ui-shared/src/common/constants/rule_flapping.ts +++ b/packages/kbn-alerts-ui-shared/src/common/constants/rule_flapping.ts @@ -8,4 +8,4 @@ */ // Feature flag for frontend rule specific flapping in rule flyout -export const IS_RULE_SPECIFIC_FLAPPING_ENABLED = false; +export const IS_RULE_SPECIFIC_FLAPPING_ENABLED = true; diff --git a/packages/kbn-alerts-ui-shared/src/common/hooks/use_create_rule.ts b/packages/kbn-alerts-ui-shared/src/common/hooks/use_create_rule.ts index 4ee00a94b90ed..ebdfeeafbe2fd 100644 --- a/packages/kbn-alerts-ui-shared/src/common/hooks/use_create_rule.ts +++ b/packages/kbn-alerts-ui-shared/src/common/hooks/use_create_rule.ts @@ -10,10 +10,11 @@ import { useMutation } from '@tanstack/react-query'; import type { HttpStart, IHttpFetchError } from '@kbn/core-http-browser'; import { createRule, CreateRuleBody } from '../apis/create_rule'; +import { Rule } from '../types'; export interface UseCreateRuleProps { http: HttpStart; - onSuccess?: (formData: CreateRuleBody) => void; + onSuccess?: (rule: Rule) => void; onError?: (error: IHttpFetchError<{ message: string }>) => void; } diff --git a/packages/kbn-alerts-ui-shared/src/common/hooks/use_load_connectors.ts b/packages/kbn-alerts-ui-shared/src/common/hooks/use_load_connectors.ts index 9ae876d06278b..8c93881762b1a 100644 --- a/packages/kbn-alerts-ui-shared/src/common/hooks/use_load_connectors.ts +++ b/packages/kbn-alerts-ui-shared/src/common/hooks/use_load_connectors.ts @@ -15,10 +15,11 @@ export interface UseLoadConnectorsProps { http: HttpStart; includeSystemActions?: boolean; enabled?: boolean; + cacheTime?: number; } export const useLoadConnectors = (props: UseLoadConnectorsProps) => { - const { http, includeSystemActions = false, enabled = true } = props; + const { http, includeSystemActions = false, enabled = true, cacheTime } = props; const queryFn = () => { return fetchConnectors({ http, includeSystemActions }); @@ -27,6 +28,7 @@ export const useLoadConnectors = (props: UseLoadConnectorsProps) => { const { data, isLoading, isFetching, isInitialLoading } = useQuery({ queryKey: ['useLoadConnectors', includeSystemActions], queryFn, + cacheTime, refetchOnWindowFocus: false, enabled, }); diff --git a/packages/kbn-alerts-ui-shared/src/common/hooks/use_load_rule_type_aad_template_fields.ts b/packages/kbn-alerts-ui-shared/src/common/hooks/use_load_rule_type_aad_template_fields.ts index fab6fd3336f2e..c9dbc6c75ff35 100644 --- a/packages/kbn-alerts-ui-shared/src/common/hooks/use_load_rule_type_aad_template_fields.ts +++ b/packages/kbn-alerts-ui-shared/src/common/hooks/use_load_rule_type_aad_template_fields.ts @@ -17,10 +17,11 @@ export interface UseLoadRuleTypeAadTemplateFieldProps { http: HttpStart; ruleTypeId?: string; enabled: boolean; + cacheTime?: number; } export const useLoadRuleTypeAadTemplateField = (props: UseLoadRuleTypeAadTemplateFieldProps) => { - const { http, ruleTypeId, enabled } = props; + const { http, ruleTypeId, enabled, cacheTime } = props; const queryFn = () => { if (!ruleTypeId) { @@ -43,6 +44,7 @@ export const useLoadRuleTypeAadTemplateField = (props: UseLoadRuleTypeAadTemplat description: getDescription(d.name, EcsFlat), })); }, + cacheTime, refetchOnWindowFocus: false, enabled, }); diff --git a/packages/kbn-alerts-ui-shared/src/common/hooks/use_resolve_rule.ts b/packages/kbn-alerts-ui-shared/src/common/hooks/use_resolve_rule.ts index fafd372dc3640..95c3ca6baad02 100644 --- a/packages/kbn-alerts-ui-shared/src/common/hooks/use_resolve_rule.ts +++ b/packages/kbn-alerts-ui-shared/src/common/hooks/use_resolve_rule.ts @@ -15,10 +15,11 @@ import { RuleFormData } from '../../rule_form'; export interface UseResolveProps { http: HttpStart; id?: string; + cacheTime?: number; } export const useResolveRule = (props: UseResolveProps) => { - const { id, http } = props; + const { id, http, cacheTime } = props; const queryFn = () => { if (id) { @@ -30,6 +31,7 @@ export const useResolveRule = (props: UseResolveProps) => { queryKey: ['useResolveRule', id], queryFn, enabled: !!id, + cacheTime, select: (rule): RuleFormData | null => { if (!rule) { return null; diff --git a/packages/kbn-alerts-ui-shared/src/common/hooks/use_update_rule.ts b/packages/kbn-alerts-ui-shared/src/common/hooks/use_update_rule.ts index 0e8199fc1cca2..5764b8128ef42 100644 --- a/packages/kbn-alerts-ui-shared/src/common/hooks/use_update_rule.ts +++ b/packages/kbn-alerts-ui-shared/src/common/hooks/use_update_rule.ts @@ -10,10 +10,11 @@ import { useMutation } from '@tanstack/react-query'; import type { HttpStart, IHttpFetchError } from '@kbn/core-http-browser'; import { updateRule, UpdateRuleBody } from '../apis/update_rule'; +import { Rule } from '../types'; export interface UseUpdateRuleProps { http: HttpStart; - onSuccess?: (formData: UpdateRuleBody) => void; + onSuccess?: (rule: Rule) => void; onError?: (error: IHttpFetchError<{ message: string }>) => void; } diff --git a/packages/kbn-alerts-ui-shared/src/common/types/rule_types.ts b/packages/kbn-alerts-ui-shared/src/common/types/rule_types.ts index 40498f1a27886..29eaf17552a2b 100644 --- a/packages/kbn-alerts-ui-shared/src/common/types/rule_types.ts +++ b/packages/kbn-alerts-ui-shared/src/common/types/rule_types.ts @@ -27,8 +27,6 @@ import { TypeRegistry } from '../type_registry'; export type { SanitizedRuleAction as RuleAction } from '@kbn/alerting-types'; -export type { Flapping } from '@kbn/alerting-types'; - export type RuleTypeWithDescription = RuleType & { description?: string }; export type RuleTypeIndexWithDescriptions = Map; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/constants.ts b/packages/kbn-alerts-ui-shared/src/rule_form/constants.ts index f557dc5ebdb42..a3748eeabe697 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/constants.ts +++ b/packages/kbn-alerts-ui-shared/src/rule_form/constants.ts @@ -27,7 +27,7 @@ export const DEFAULT_FREQUENCY = { summary: false, }; -export const GET_DEFAULT_FORM_DATA = ({ +export const getDefaultFormData = ({ ruleTypeId, name, consumer, @@ -50,6 +50,7 @@ export const GET_DEFAULT_FORM_DATA = ({ ruleTypeId, name, actions, + alertDelay: { active: 1 }, }; }; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/create_rule_form.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/create_rule_form.tsx index fc96ae214a7a8..4399dc5239ec7 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/create_rule_form.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/create_rule_form.tsx @@ -12,7 +12,7 @@ import { EuiLoadingElastic } from '@elastic/eui'; import { toMountPoint } from '@kbn/react-kibana-mount'; import { type RuleCreationValidConsumer } from '@kbn/rule-data-utils'; import type { RuleFormData, RuleFormPlugins } from './types'; -import { DEFAULT_VALID_CONSUMERS, GET_DEFAULT_FORM_DATA } from './constants'; +import { DEFAULT_VALID_CONSUMERS, getDefaultFormData } from './constants'; import { RuleFormStateProvider } from './rule_form_state'; import { useCreateRule } from '../common/hooks'; import { RulePage } from './rule_page'; @@ -24,6 +24,7 @@ import { } from './rule_form_errors'; import { useLoadDependencies } from './hooks/use_load_dependencies'; import { + getAvailableRuleTypes, getInitialConsumer, getInitialMultiConsumer, getInitialSchedule, @@ -42,7 +43,8 @@ export interface CreateRuleFormProps { shouldUseRuleProducer?: boolean; canShowConsumerSelection?: boolean; showMustacheAutocompleteSwitch?: boolean; - returnUrl: string; + onCancel?: () => void; + onSubmit?: (ruleId: string) => void; } export const CreateRuleForm = (props: CreateRuleFormProps) => { @@ -56,7 +58,8 @@ export const CreateRuleForm = (props: CreateRuleFormProps) => { shouldUseRuleProducer = false, canShowConsumerSelection = true, showMustacheAutocompleteSwitch = false, - returnUrl, + onCancel, + onSubmit, } = props; const { http, docLinks, notifications, ruleTypeRegistry, i18n, theme } = plugins; @@ -64,8 +67,9 @@ export const CreateRuleForm = (props: CreateRuleFormProps) => { const { mutate, isLoading: isSaving } = useCreateRule({ http, - onSuccess: ({ name }) => { + onSuccess: ({ name, id }) => { toasts.addSuccess(RULE_CREATE_SUCCESS_TEXT(name)); + onSubmit?.(id); }, onError: (error) => { const message = parseRuleCircuitBreakerErrorMessage( @@ -86,6 +90,7 @@ export const CreateRuleForm = (props: CreateRuleFormProps) => { const { isInitialLoading, ruleType, + ruleTypes, ruleTypeModel, uiConfig, healthCheckError, @@ -153,7 +158,7 @@ export const CreateRuleForm = (props: CreateRuleFormProps) => {
{ minimumScheduleInterval: uiConfig?.minimumScheduleInterval, selectedRuleTypeModel: ruleTypeModel, selectedRuleType: ruleType, + availableRuleTypes: getAvailableRuleTypes({ + consumer, + ruleTypes, + ruleTypeRegistry, + }).map(({ ruleType: rt }) => rt), validConsumers, flappingSettings, canShowConsumerSelection, @@ -185,7 +195,7 @@ export const CreateRuleForm = (props: CreateRuleFormProps) => { }), }} > - +
); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/edit_rule_form.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/edit_rule_form.tsx index 6e92b94cc2e0d..917fc87420f9a 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/edit_rule_form.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/edit_rule_form.tsx @@ -24,17 +24,19 @@ import { RuleFormRuleTypeError, } from './rule_form_errors'; import { RULE_EDIT_ERROR_TEXT, RULE_EDIT_SUCCESS_TEXT } from './translations'; -import { parseRuleCircuitBreakerErrorMessage } from './utils'; +import { getAvailableRuleTypes, parseRuleCircuitBreakerErrorMessage } from './utils'; +import { DEFAULT_VALID_CONSUMERS, getDefaultFormData } from './constants'; export interface EditRuleFormProps { id: string; plugins: RuleFormPlugins; showMustacheAutocompleteSwitch?: boolean; - returnUrl: string; + onCancel?: () => void; + onSubmit?: (ruleId: string) => void; } export const EditRuleForm = (props: EditRuleFormProps) => { - const { id, plugins, returnUrl, showMustacheAutocompleteSwitch = false } = props; + const { id, plugins, showMustacheAutocompleteSwitch = false, onCancel, onSubmit } = props; const { http, notifications, docLinks, ruleTypeRegistry, i18n, theme, application } = plugins; const { toasts } = notifications; @@ -42,6 +44,7 @@ export const EditRuleForm = (props: EditRuleFormProps) => { http, onSuccess: ({ name }) => { toasts.addSuccess(RULE_EDIT_SUCCESS_TEXT(name)); + onSubmit?.(id); }, onError: (error) => { const message = parseRuleCircuitBreakerErrorMessage( @@ -62,6 +65,7 @@ export const EditRuleForm = (props: EditRuleFormProps) => { const { isInitialLoading, ruleType, + ruleTypes, ruleTypeModel, uiConfig, healthCheckError, @@ -156,17 +160,31 @@ export const EditRuleForm = (props: EditRuleFormProps) => { connectors, connectorTypes, aadTemplateFields, - formData: fetchedFormData, + formData: { + ...getDefaultFormData({ + ruleTypeId: fetchedFormData.ruleTypeId, + name: fetchedFormData.name, + consumer: fetchedFormData.consumer, + actions: fetchedFormData.actions, + }), + ...fetchedFormData, + }, id, plugins, minimumScheduleInterval: uiConfig?.minimumScheduleInterval, selectedRuleType: ruleType, selectedRuleTypeModel: ruleTypeModel, + availableRuleTypes: getAvailableRuleTypes({ + consumer: fetchedFormData.consumer, + ruleTypes, + ruleTypeRegistry, + }).map(({ ruleType: rt }) => rt), flappingSettings, + validConsumers: DEFAULT_VALID_CONSUMERS, showMustacheAutocompleteSwitch, }} > - + ); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/hooks/use_load_dependencies.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/hooks/use_load_dependencies.test.tsx index 9d2ce3b6f1211..f0a14ac82e4a6 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/hooks/use_load_dependencies.test.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/hooks/use_load_dependencies.test.tsx @@ -46,10 +46,6 @@ jest.mock('../../common/hooks/use_load_rule_type_aad_template_fields', () => ({ useLoadRuleTypeAadTemplateField: jest.fn(), })); -jest.mock('../utils/get_authorized_rule_types', () => ({ - getAvailableRuleTypes: jest.fn(), -})); - jest.mock('../../common/hooks/use_fetch_flapping_settings', () => ({ useFetchFlappingSettings: jest.fn(), })); @@ -63,7 +59,6 @@ const { useLoadRuleTypeAadTemplateField } = jest.requireMock( '../../common/hooks/use_load_rule_type_aad_template_fields' ); const { useLoadRuleTypesQuery } = jest.requireMock('../../common/hooks/use_load_rule_types_query'); -const { getAvailableRuleTypes } = jest.requireMock('../utils/get_authorized_rule_types'); const { useFetchFlappingSettings } = jest.requireMock( '../../common/hooks/use_fetch_flapping_settings' ); @@ -168,13 +163,6 @@ useLoadRuleTypesQuery.mockReturnValue({ }, }); -getAvailableRuleTypes.mockReturnValue([ - { - ruleType: indexThresholdRuleType, - ruleTypeModel: indexThresholdRuleTypeModel, - }, -]); - const mockConnector = { id: 'test-connector', name: 'Test', @@ -236,7 +224,7 @@ const toastsMock = jest.fn(); const ruleTypeRegistryMock: RuleTypeRegistryContract = { has: jest.fn(), register: jest.fn(), - get: jest.fn(), + get: jest.fn().mockReturnValue(indexThresholdRuleTypeModel), list: jest.fn(), }; @@ -272,6 +260,7 @@ describe('useLoadDependencies', () => { isLoading: false, isInitialLoading: false, ruleType: indexThresholdRuleType, + ruleTypes: [...ruleTypeIndex.values()], ruleTypeModel: indexThresholdRuleTypeModel, uiConfig: uiConfigMock, healthCheckError: null, @@ -317,39 +306,6 @@ describe('useLoadDependencies', () => { }); }); - test('should call getAvailableRuleTypes with the correct params', async () => { - const { result } = renderHook( - () => { - return useLoadDependencies({ - http: httpMock as unknown as HttpStart, - toasts: toastsMock as unknown as ToastsStart, - ruleTypeRegistry: ruleTypeRegistryMock, - validConsumers: ['stackAlerts', 'logs'], - consumer: 'logs', - capabilities: { - actions: { - show: true, - save: true, - execute: true, - }, - } as unknown as ApplicationStart['capabilities'], - }); - }, - { wrapper } - ); - - await waitFor(() => { - return expect(result.current.isInitialLoading).toEqual(false); - }); - - expect(getAvailableRuleTypes).toBeCalledWith({ - consumer: 'logs', - ruleTypeRegistry: ruleTypeRegistryMock, - ruleTypes: [indexThresholdRuleType], - validConsumers: ['stackAlerts', 'logs'], - }); - }); - test('should call resolve rule with the correct params', async () => { const { result } = renderHook( () => { @@ -377,6 +333,7 @@ describe('useLoadDependencies', () => { expect(useResolveRule).toBeCalledWith({ http: httpMock, id: 'test-rule-id', + cacheTime: 0, }); }); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/hooks/use_load_dependencies.ts b/packages/kbn-alerts-ui-shared/src/rule_form/hooks/use_load_dependencies.ts index 5e0c52b1089ba..9fb0f173b9d21 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/hooks/use_load_dependencies.ts +++ b/packages/kbn-alerts-ui-shared/src/rule_form/hooks/use_load_dependencies.ts @@ -20,7 +20,6 @@ import { useLoadUiConfig, useResolveRule, } from '../../common/hooks'; -import { getAvailableRuleTypes } from '../utils'; import { RuleTypeRegistryContract } from '../../common'; import { useFetchFlappingSettings } from '../../common/hooks/use_fetch_flapping_settings'; import { IS_RULE_SPECIFIC_FLAPPING_ENABLED } from '../../common/constants/rule_flapping'; @@ -43,8 +42,6 @@ export const useLoadDependencies = (props: UseLoadDependencies) => { http, toasts, ruleTypeRegistry, - consumer, - validConsumers, id, ruleTypeId, capabilities, @@ -69,7 +66,7 @@ export const useLoadDependencies = (props: UseLoadDependencies) => { data: fetchedFormData, isLoading: isLoadingRule, isInitialLoading: isInitialLoadingRule, - } = useResolveRule({ http, id }); + } = useResolveRule({ http, id, cacheTime: 0 }); const { ruleTypesState: { @@ -100,6 +97,7 @@ export const useLoadDependencies = (props: UseLoadDependencies) => { http, includeSystemActions: true, enabled: canReadConnectors, + cacheTime: 0, }); const computedRuleTypeId = useMemo(() => { @@ -125,28 +123,22 @@ export const useLoadDependencies = (props: UseLoadDependencies) => { http, ruleTypeId: computedRuleTypeId, enabled: !!computedRuleTypeId && canReadConnectors, + cacheTime: 0, }); - const authorizedRuleTypeItems = useMemo(() => { - const computedConsumer = consumer || fetchedFormData?.consumer; - if (!computedConsumer) { - return []; + const ruleType = useMemo(() => { + if (!computedRuleTypeId || !ruleTypeIndex) { + return null; } - return getAvailableRuleTypes({ - consumer: computedConsumer, - ruleTypes: [...ruleTypeIndex.values()], - ruleTypeRegistry, - validConsumers, - }); - }, [consumer, ruleTypeIndex, ruleTypeRegistry, validConsumers, fetchedFormData]); - - const [ruleType, ruleTypeModel] = useMemo(() => { - const item = authorizedRuleTypeItems.find(({ ruleType: rt }) => { - return rt.id === computedRuleTypeId; - }); - - return [item?.ruleType, item?.ruleTypeModel]; - }, [authorizedRuleTypeItems, computedRuleTypeId]); + return ruleTypeIndex.get(computedRuleTypeId); + }, [computedRuleTypeId, ruleTypeIndex]); + + const ruleTypeModel = useMemo(() => { + if (!computedRuleTypeId) { + return null; + } + return ruleTypeRegistry.get(computedRuleTypeId); + }, [computedRuleTypeId, ruleTypeRegistry]); const isLoading = useMemo(() => { // Create Mode @@ -227,6 +219,7 @@ export const useLoadDependencies = (props: UseLoadDependencies) => { isInitialLoading: !!isInitialLoading, ruleType, ruleTypeModel, + ruleTypes: [...ruleTypeIndex.values()], uiConfig, healthCheckError, fetchedFormData, diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions.test.tsx index 63846fb3628ce..9560d933060f6 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions.test.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions.test.tsx @@ -117,12 +117,18 @@ describe('ruleActions', () => { getActionTypeModel('1', { id: 'actionType-1', validateParams: mockValidate, + defaultActionParams: { + key: 'value', + }, }) ); actionTypeRegistry.register( getActionTypeModel('2', { id: 'actionType-2', validateParams: mockValidate, + defaultActionParams: { + key: 'value', + }, }) ); @@ -150,6 +156,10 @@ describe('ruleActions', () => { selectedRuleType: { id: 'selectedRuleTypeId', defaultActionGroupId: 'test', + recoveryActionGroup: { + id: 'test-recovery-group-id', + name: 'test-recovery-group', + }, producer: 'stackAlerts', }, connectors: mockConnectors, @@ -222,7 +232,7 @@ describe('ruleActions', () => { frequency: { notifyWhen: 'onActionGroupChange', summary: false, throttle: null }, group: 'test', id: 'connector-1', - params: {}, + params: { key: 'value' }, uuid: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', }, type: 'addAction', diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions.tsx index b9eb28025205c..47588b487be6d 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions.tsx @@ -18,6 +18,7 @@ import { ActionConnector, RuleAction, RuleFormParamsErrors } from '../../common/ import { DEFAULT_FREQUENCY, MULTI_CONSUMER_RULE_TYPE_IDS } from '../constants'; import { RuleActionsItem } from './rule_actions_item'; import { RuleActionsSystemActionsItem } from './rule_actions_system_actions_item'; +import { getDefaultParams } from '../utils'; export const RuleActions = () => { const [isConnectorModalOpen, setIsConnectorModalOpen] = useState(false); @@ -44,7 +45,15 @@ export const RuleActions = () => { async (connector: ActionConnector) => { const { id, actionTypeId } = connector; const uuid = uuidv4(); - const params = {}; + const group = selectedRuleType.defaultActionGroupId; + const actionTypeModel = actionTypeRegistry.get(actionTypeId); + + const params = + getDefaultParams({ + group, + ruleType: selectedRuleType, + actionTypeModel, + }) || {}; dispatch({ type: 'addAction', @@ -53,7 +62,7 @@ export const RuleActions = () => { actionTypeId, uuid, params, - group: selectedRuleType.defaultActionGroupId, + group, frequency: DEFAULT_FREQUENCY, }, }); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_alerts_filter.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_alerts_filter.tsx index 791c1ce0491f2..a5bbacc74d7a5 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_alerts_filter.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_alerts_filter.tsx @@ -68,6 +68,7 @@ export const RuleActionsAlertsFilter = ({ () => onChange(state ? undefined : query), [state, query, onChange] ); + const updateQuery = useCallback( (update: Partial) => { setQuery({ diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_connectors_modal.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_connectors_modal.tsx index 9c3dbcf15e364..82496d9578ff0 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_connectors_modal.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_connectors_modal.tsx @@ -163,7 +163,10 @@ export const RuleActionsConnectorsModal = (props: RuleActionsConnectorsModalProp const connectorFacetButtons = useMemo(() => { return ( - + { await userEvent.click(screen.getByText('onTimeframeChange')); - expect(mockOnChange).toHaveBeenCalledTimes(1); + expect(mockOnChange).toHaveBeenCalledTimes(2); expect(mockOnChange).toHaveBeenCalledWith({ payload: { diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_item.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_item.tsx index b80a79a69cfcf..9bf6cac970b19 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_item.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_actions/rule_actions_item.tsx @@ -40,17 +40,12 @@ import { isEmpty, some } from 'lodash'; import { css } from '@emotion/react'; import { SavedObjectAttribute } from '@kbn/core/types'; import { useRuleFormDispatch, useRuleFormState } from '../hooks'; -import { - ActionConnector, - ActionTypeModel, - RuleFormParamsErrors, - RuleTypeWithDescription, -} from '../../common/types'; +import { ActionConnector, RuleFormParamsErrors } from '../../common/types'; import { getAvailableActionVariables } from '../../action_variables'; import { validateAction, validateParamsForWarnings } from '../validation'; import { RuleActionsSettings } from './rule_actions_settings'; -import { getSelectedActionGroup } from '../utils'; +import { getDefaultParams, getSelectedActionGroup } from '../utils'; import { RuleActionsMessage } from './rule_actions_message'; import { ACTION_ERROR_TOOLTIP, @@ -60,6 +55,7 @@ import { TECH_PREVIEW_DESCRIPTION, TECH_PREVIEW_LABEL, } from '../translations'; +import { checkActionFormActionTypeEnabled } from '../utils/check_action_type_enabled'; const SUMMARY_GROUP_TITLE = i18n.translate('alertsUIShared.ruleActionsItem.summaryGroupTitle', { defaultMessage: 'Summary of alerts', @@ -83,22 +79,6 @@ const ACTION_TITLE = (connector: ActionConnector) => }, }); -const getDefaultParams = ({ - group, - ruleType, - actionTypeModel, -}: { - group: string; - actionTypeModel: ActionTypeModel; - ruleType: RuleTypeWithDescription; -}) => { - if (group === ruleType.recoveryActionGroup.id) { - return actionTypeModel.defaultRecoveredActionParams; - } else { - return actionTypeModel.defaultActionParams; - } -}; - export interface RuleActionsItemProps { action: RuleAction; index: number; @@ -178,6 +158,16 @@ export const RuleActionsItem = (props: RuleActionsItemProps) => { ? aadTemplateFields : availableActionVariables; + const checkEnabledResult = useMemo(() => { + if (!actionType) { + return null; + } + return checkActionFormActionTypeEnabled( + actionType, + connectors.filter((c) => c.isPreconfigured) + ); + }, [actionType, connectors]); + const onDelete = (id: string) => { dispatch({ type: 'removeAction', payload: { uuid: id } }); }; @@ -381,16 +371,24 @@ export const RuleActionsItem = (props: RuleActionsItemProps) => { ...action.alertsFilter, query, }; + + if (!newAlertsFilter.query) { + delete newAlertsFilter.query; + } + + const alertsFilter = isEmpty(newAlertsFilter) ? undefined : newAlertsFilter; + const newAction = { ...action, - alertsFilter: newAlertsFilter, + alertsFilter, }; + dispatch({ type: 'setActionProperty', payload: { uuid: action.uuid!, key: 'alertsFilter', - value: newAlertsFilter, + value: alertsFilter, }, }); validateActionBase(newAction); @@ -400,19 +398,33 @@ export const RuleActionsItem = (props: RuleActionsItemProps) => { const onTimeframeChange = useCallback( (timeframe?: AlertsFilterTimeframe) => { + const newAlertsFilter = { + ...action.alertsFilter, + timeframe, + }; + + if (!newAlertsFilter.timeframe) { + delete newAlertsFilter.timeframe; + } + + const alertsFilter = isEmpty(newAlertsFilter) ? undefined : newAlertsFilter; + + const newAction = { + ...action, + alertsFilter, + }; + dispatch({ type: 'setActionProperty', payload: { uuid: action.uuid!, key: 'alertsFilter', - value: { - ...action.alertsFilter, - timeframe, - }, + value: alertsFilter, }, }); + validateActionBase(newAction); }, - [action, dispatch] + [action, dispatch, validateActionBase] ); const onUseAadTemplateFieldsChange = useCallback(() => { @@ -443,9 +455,25 @@ export const RuleActionsItem = (props: RuleActionsItemProps) => { }, [action, storedActionParamsForAadToggle, dispatch]); const accordionContent = useMemo(() => { - if (!connector) { + if (!connector || !checkEnabledResult) { return null; } + + if (!checkEnabledResult.isEnabled) { + return ( + + {checkEnabledResult.messageCard} + + ); + } + return ( { templateFields, useDefaultMessage, warning, + checkEnabledResult, onNotifyWhenChange, onActionGroupChange, onAlertsFilterChange, diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_alert_delay.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_alert_delay.test.tsx index 7b12160c1dadd..327a0ba12634c 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_alert_delay.test.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_alert_delay.test.tsx @@ -74,17 +74,14 @@ describe('RuleAlertDelay', () => { expect(mockOnChange).not.toHaveBeenCalled(); }); - test('Should call onChange with null if empty string is typed', () => { + test('Should not call onChange if empty string is typed', () => { render(); fireEvent.change(screen.getByTestId('alertDelayInput'), { target: { value: '', }, }); - expect(mockOnChange).toHaveBeenCalledWith({ - type: 'setAlertDelay', - payload: null, - }); + expect(mockOnChange).not.toHaveBeenCalled(); }); test('Should display error when input is invalid', () => { diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_alert_delay.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_alert_delay.tsx index 5b26c38232ab4..a79f1f5efe447 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_alert_delay.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_alert_delay.tsx @@ -28,16 +28,8 @@ export const RuleAlertDelay = () => { const onAlertDelayChange = useCallback( (e: React.ChangeEvent) => { - if (!e.target.validity.valid) { - return; - } - const value = e.target.value; - if (value === '') { - dispatch({ - type: 'setAlertDelay', - payload: null, - }); - } else if (INTEGER_REGEX.test(value)) { + const value = e.target.value.trim(); + if (INTEGER_REGEX.test(value)) { const parsedValue = parseInt(value, 10); dispatch({ type: 'setAlertDelay', @@ -66,7 +58,7 @@ export const RuleAlertDelay = () => { { active: 5, }, notifyWhen: null, - consumer: 'stackAlerts', + consumer: 'alerts', + ruleTypeId: '.es-query', }, selectedRuleType: ruleType, selectedRuleTypeModel: ruleModel, canShowConsumerSelection: true, validConsumers: ['logs', 'stackAlerts'], + availableRuleTypes: [ruleType], }); render(); @@ -164,13 +167,16 @@ describe('Rule Definition', () => { active: 5, }, notifyWhen: null, - consumer: 'stackAlerts', + consumer: 'alerts', + ruleTypeId: '.es-query', }, selectedRuleType: ruleType, selectedRuleTypeModel: { ...ruleModel, documentationUrl: null, }, + availableRuleTypes: [ruleType], + validConsumers: ['logs', 'stackAlerts'], }); render(); @@ -191,6 +197,7 @@ describe('Rule Definition', () => { }, notifyWhen: null, consumer: 'stackAlerts', + ruleTypeId: '.es-query', }, selectedRuleType: ruleType, selectedRuleTypeModel: ruleModel, @@ -215,9 +222,11 @@ describe('Rule Definition', () => { }, notifyWhen: null, consumer: 'stackAlerts', + ruleTypeId: '.es-query', }, selectedRuleType: ruleType, selectedRuleTypeModel: ruleModel, + availableRuleTypes: [ruleType], canShowConsumerSelect: true, validConsumers: ['logs'], }); @@ -241,9 +250,11 @@ describe('Rule Definition', () => { }, notifyWhen: null, consumer: 'stackAlerts', + ruleTypeId: '.es-query', }, selectedRuleType: ruleType, selectedRuleTypeModel: ruleModel, + availableRuleTypes: [ruleType], canShowConsumerSelect: true, validConsumers: ['logs', 'observability'], }); @@ -267,9 +278,11 @@ describe('Rule Definition', () => { }, notifyWhen: null, consumer: 'stackAlerts', + ruleTypeId: '.es-query', }, selectedRuleType: ruleType, selectedRuleTypeModel: ruleModel, + availableRuleTypes: [ruleType], }); render(); @@ -292,9 +305,11 @@ describe('Rule Definition', () => { }, notifyWhen: null, consumer: 'stackAlerts', + ruleTypeId: '.es-query', }, selectedRuleType: ruleType, selectedRuleTypeModel: ruleModel, + availableRuleTypes: [ruleType], }); render(); @@ -326,9 +341,11 @@ describe('Rule Definition', () => { }, notifyWhen: null, consumer: 'stackAlerts', + ruleTypeId: '.es-query', }, selectedRuleType: ruleType, selectedRuleTypeModel: ruleModel, + availableRuleTypes: [ruleType], canShowConsumerSelection: true, validConsumers: ['logs', 'stackAlerts'], }); @@ -339,6 +356,48 @@ describe('Rule Definition', () => { expect(screen.getByTestId('ruleSettingsFlappingForm')).toBeInTheDocument(); }); + test('should hide flapping if the user does not have read access', async () => { + useRuleFormState.mockReturnValue({ + plugins: { + charts: {} as ChartsPluginSetup, + data: {} as DataPublicPluginStart, + dataViews: {} as DataViewsPublicPluginStart, + unifiedSearch: {} as UnifiedSearchPublicPluginStart, + docLinks: {} as DocLinksStart, + application: { + capabilities: { + rulesSettings: { + readFlappingSettingsUI: false, + writeFlappingSettingsUI: true, + }, + }, + }, + }, + formData: { + id: 'test-id', + params: {}, + schedule: { + interval: '1m', + }, + alertDelay: { + active: 5, + }, + notifyWhen: null, + consumer: 'stackAlerts', + ruleTypeId: '.es-query', + }, + selectedRuleType: ruleType, + selectedRuleTypeModel: ruleModel, + availableRuleTypes: [ruleType], + canShowConsumerSelection: true, + validConsumers: ['logs', 'stackAlerts'], + }); + + render(); + + expect(screen.queryByTestId('ruleDefinitionFlappingFormGroup')).not.toBeInTheDocument(); + }); + test('should allow flapping to be changed', async () => { useRuleFormState.mockReturnValue({ plugins, @@ -353,9 +412,11 @@ describe('Rule Definition', () => { }, notifyWhen: null, consumer: 'stackAlerts', + ruleTypeId: '.es-query', }, selectedRuleType: ruleType, selectedRuleTypeModel: ruleModel, + availableRuleTypes: [ruleType], canShowConsumerSelection: true, validConsumers: ['logs', 'stackAlerts'], }); @@ -389,9 +450,11 @@ describe('Rule Definition', () => { }, notifyWhen: null, consumer: 'stackAlerts', + ruleTypeId: '.es-query', }, selectedRuleType: ruleType, selectedRuleTypeModel: ruleModel, + availableRuleTypes: [ruleType], canShowConsumerSelection: true, validConsumers: ['logs', 'stackAlerts'], }); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_definition.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_definition.tsx index 3b404edc5d029..997e666e8340f 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_definition.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_definition.tsx @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { Suspense, useMemo, useState, useCallback } from 'react'; +import React, { Suspense, useMemo, useState, useCallback, useEffect } from 'react'; import { EuiEmptyPrompt, EuiLoadingSpinner, @@ -47,7 +47,7 @@ import { RuleAlertDelay } from './rule_alert_delay'; import { RuleConsumerSelection } from './rule_consumer_selection'; import { RuleSchedule } from './rule_schedule'; import { useRuleFormState, useRuleFormDispatch } from '../hooks'; -import { MULTI_CONSUMER_RULE_TYPE_IDS } from '../constants'; +import { ALERTING_FEATURE_ID, MULTI_CONSUMER_RULE_TYPE_IDS } from '../constants'; import { getAuthorizedConsumers } from '../utils'; import { RuleSettingsFlappingTitleTooltip } from '../../rule_settings/rule_settings_flapping_title_tooltip'; import { RuleSettingsFlappingForm } from '../../rule_settings/rule_settings_flapping_form'; @@ -62,6 +62,7 @@ export const RuleDefinition = () => { metadata, selectedRuleType, selectedRuleTypeModel, + availableRuleTypes, validConsumers, canShowConsumerSelection = false, flappingSettings, @@ -70,29 +71,44 @@ export const RuleDefinition = () => { const { colorMode } = useEuiTheme(); const dispatch = useRuleFormDispatch(); + useEffect(() => { + // Need to do a dry run validating the params because the Missing Monitor Data rule type + // does not properly initialize the params + if (selectedRuleType.id === 'monitoring_alert_missing_monitoring_data') { + dispatch({ type: 'runValidation' }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const { charts, data, dataViews, unifiedSearch, docLinks, application } = plugins; const { capabilities: { rulesSettings }, } = application; - const { writeFlappingSettingsUI } = rulesSettings || {}; + const { readFlappingSettingsUI, writeFlappingSettingsUI } = rulesSettings || {}; - const { params, schedule, notifyWhen, flapping } = formData; + const { params, schedule, notifyWhen, flapping, consumer, ruleTypeId } = formData; const [isAdvancedOptionsVisible, setIsAdvancedOptionsVisible] = useState(false); const [isFlappingPopoverOpen, setIsFlappingPopoverOpen] = useState(false); const authorizedConsumers = useMemo(() => { - if (!validConsumers?.length) { + if (consumer !== ALERTING_FEATURE_ID) { + return []; + } + const selectedAvailableRuleType = availableRuleTypes.find((ruleType) => { + return ruleType.id === selectedRuleType.id; + }); + if (!selectedAvailableRuleType?.authorizedConsumers) { return []; } return getAuthorizedConsumers({ - ruleType: selectedRuleType, + ruleType: selectedAvailableRuleType, validConsumers, }); - }, [selectedRuleType, validConsumers]); + }, [consumer, selectedRuleType, availableRuleTypes, validConsumers]); const shouldShowConsumerSelect = useMemo(() => { if (!canShowConsumerSelection) { @@ -107,10 +123,8 @@ export const RuleDefinition = () => { ) { return false; } - return ( - selectedRuleTypeModel.id && MULTI_CONSUMER_RULE_TYPE_IDS.includes(selectedRuleTypeModel.id) - ); - }, [authorizedConsumers, selectedRuleTypeModel, canShowConsumerSelection]); + return !!(ruleTypeId && MULTI_CONSUMER_RULE_TYPE_IDS.includes(ruleTypeId)); + }, [ruleTypeId, authorizedConsumers, canShowConsumerSelection]); const RuleParamsExpressionComponent = selectedRuleTypeModel.ruleParamsExpression ?? null; @@ -305,8 +319,9 @@ export const RuleDefinition = () => { > - {IS_RULE_SPECIFIC_FLAPPING_ENABLED && ( + {IS_RULE_SPECIFIC_FLAPPING_ENABLED && readFlappingSettingsUI && ( {ALERT_FLAPPING_DETECTION_TITLE}} description={ diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_schedule.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_schedule.tsx index 26342d99580a6..1768303c55223 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_schedule.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_definition/rule_schedule.tsx @@ -80,9 +80,6 @@ export const RuleSchedule = () => { const onIntervalNumberChange = useCallback( (e: React.ChangeEvent) => { - if (!e.target.validity.valid) { - return; - } const value = e.target.value.trim(); if (INTEGER_REGEX.test(value)) { const parsedValue = parseInt(value, 10); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_form.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form.tsx index d1a0f6a56fe2b..c09add5ae1c06 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_form.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form.tsx @@ -23,11 +23,12 @@ const queryClient = new QueryClient(); export interface RuleFormProps { plugins: RuleFormPlugins; - returnUrl: string; + onCancel?: () => void; + onSubmit?: (ruleId: string) => void; } export const RuleForm = (props: RuleFormProps) => { - const { plugins, returnUrl } = props; + const { plugins, onCancel, onSubmit } = props; const { id, ruleTypeId } = useParams<{ id?: string; ruleTypeId?: string; @@ -35,23 +36,31 @@ export const RuleForm = (props: RuleFormProps) => { const ruleFormComponent = useMemo(() => { if (id) { - return ; + return ; } if (ruleTypeId) { - return ; + return ( + + ); } return ( {RULE_FORM_ROUTE_PARAMS_ERROR_TITLE}} - > - -

{RULE_FORM_ROUTE_PARAMS_ERROR_TEXT}

-
-
+ body={ + +

{RULE_FORM_ROUTE_PARAMS_ERROR_TEXT}

+
+ } + /> ); - }, [id, ruleTypeId, plugins, returnUrl]); + }, [id, ruleTypeId, plugins, onCancel, onSubmit]); return {ruleFormComponent}; }; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_state/rule_form_state_reducer.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_state/rule_form_state_reducer.test.tsx index 81d1aab4b2c3f..d8e6380462f9b 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_state/rule_form_state_reducer.test.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_state/rule_form_state_reducer.test.tsx @@ -76,6 +76,8 @@ const initialState: RuleFormState = { selectedRuleType: indexThresholdRuleType, selectedRuleTypeModel: indexThresholdRuleTypeModel, multiConsumerSelection: 'stackAlerts', + availableRuleTypes: [], + validConsumers: [], connectors: [], connectorTypes: [], aadTemplateFields: [], diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_state/rule_form_state_reducer.ts b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_state/rule_form_state_reducer.ts index a65842125b6a8..d79ae00988875 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_state/rule_form_state_reducer.ts +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_form_state/rule_form_state_reducer.ts @@ -8,7 +8,7 @@ */ import { RuleActionParams } from '@kbn/alerting-types'; -import { omit } from 'lodash'; +import { isEmpty, omit } from 'lodash'; import { RuleFormActionsErrors, RuleFormParamsErrors, RuleUiAction } from '../../common'; import { RuleFormData, RuleFormState } from '../types'; import { validateRuleBase, validateRuleParams } from '../validation'; @@ -106,13 +106,20 @@ export type RuleFormStateReducerAction = uuid: string; errors: RuleFormParamsErrors; }; + } + | { + type: 'runValidation'; }; const getUpdateWithValidation = (ruleFormState: RuleFormState) => (updater: () => RuleFormData): RuleFormState => { - const { minimumScheduleInterval, selectedRuleTypeModel, multiConsumerSelection } = - ruleFormState; + const { + minimumScheduleInterval, + selectedRuleTypeModel, + multiConsumerSelection, + selectedRuleType, + } = ruleFormState; const formData = updater(); @@ -121,17 +128,33 @@ const getUpdateWithValidation = ...(multiConsumerSelection ? { consumer: multiConsumerSelection } : {}), }; + const baseErrors = validateRuleBase({ + formData: formDataWithMultiConsumer, + minimumScheduleInterval, + }); + + const paramsErrors = validateRuleParams({ + formData: formDataWithMultiConsumer, + ruleTypeModel: selectedRuleTypeModel, + }); + + // We need to do this because the Missing Monitor Data rule type + // for whatever reason does not initialize the params with any data, + // therefore the expression component renders as blank + if (selectedRuleType.id === 'monitoring_alert_missing_monitoring_data') { + if (isEmpty(formData.params) && !isEmpty(paramsErrors)) { + Object.keys(paramsErrors).forEach((key) => { + formData.params[key] = null; + }); + } + } + return { ...ruleFormState, formData, - baseErrors: validateRuleBase({ - formData: formDataWithMultiConsumer, - minimumScheduleInterval, - }), - paramsErrors: validateRuleParams({ - formData: formDataWithMultiConsumer, - ruleTypeModel: selectedRuleTypeModel, - }), + baseErrors, + paramsErrors, + touched: true, }; }; @@ -222,6 +245,7 @@ export const ruleFormStateReducer = ( return { ...ruleFormState, multiConsumerSelection: payload, + touched: true, }; } case 'setMetadata': { @@ -326,6 +350,9 @@ export const ruleFormStateReducer = ( }, }; } + case 'runValidation': { + return updateWithValidation(() => formData); + } default: { return ruleFormState; } diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page.test.tsx index ca80c0b77aae3..ac07c580fbd49 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page.test.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page.test.tsx @@ -61,6 +61,8 @@ const formDataMock: RuleFormData = { }, }; +const onCancel = jest.fn(); + useRuleFormState.mockReturnValue({ plugins: { application: { @@ -84,7 +86,6 @@ useRuleFormState.mockReturnValue({ }); const onSave = jest.fn(); -const returnUrl = 'management'; describe('rulePage', () => { afterEach(() => { @@ -92,7 +93,7 @@ describe('rulePage', () => { }); test('renders correctly', () => { - render(); + render(); expect(screen.getByText(RULE_FORM_PAGE_RULE_DEFINITION_TITLE)).toBeInTheDocument(); expect(screen.getByText(RULE_FORM_PAGE_RULE_ACTIONS_TITLE)).toBeInTheDocument(); @@ -100,7 +101,7 @@ describe('rulePage', () => { }); test('should call onSave when save button is pressed', () => { - render(); + render(); fireEvent.click(screen.getByTestId('rulePageFooterSaveButton')); fireEvent.click(screen.getByTestId('confirmModalConfirmButton')); @@ -112,16 +113,16 @@ describe('rulePage', () => { }); test('should call onCancel when the cancel button is clicked', () => { - render(); + render(); fireEvent.click(screen.getByTestId('rulePageFooterCancelButton')); - expect(navigateToUrl).toHaveBeenCalledWith('management'); + expect(onCancel).toHaveBeenCalled(); }); test('should call onCancel when the return button is clicked', () => { - render(); + render(); fireEvent.click(screen.getByTestId('rulePageReturnButton')); - expect(navigateToUrl).toHaveBeenCalledWith('management'); + expect(onCancel).toHaveBeenCalled(); }); }); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page.tsx index 4e2e019d41269..68ff6d5db6b19 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page.tsx @@ -7,7 +7,7 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { EuiPageTemplate, EuiHorizontalRule, @@ -18,6 +18,8 @@ import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, + EuiCallOut, + EuiConfirmModal, } from '@elastic/eui'; import { RuleDefinition, @@ -33,32 +35,45 @@ import { RULE_FORM_PAGE_RULE_ACTIONS_TITLE, RULE_FORM_PAGE_RULE_DETAILS_TITLE, RULE_FORM_RETURN_TITLE, + DISABLED_ACTIONS_WARNING_TITLE, + RULE_FORM_CANCEL_MODAL_TITLE, + RULE_FORM_CANCEL_MODAL_DESCRIPTION, + RULE_FORM_CANCEL_MODAL_CONFIRM, + RULE_FORM_CANCEL_MODAL_CANCEL, } from '../translations'; +import { hasActionsError, hasActionsParamsErrors, hasParamsErrors } from '../validation'; +import { checkActionFormActionTypeEnabled } from '../utils/check_action_type_enabled'; export interface RulePageProps { isEdit?: boolean; isSaving?: boolean; - returnUrl: string; + onCancel?: () => void; onSave: (formData: RuleFormData) => void; } export const RulePage = (props: RulePageProps) => { - const { isEdit = false, isSaving = false, returnUrl, onSave } = props; + const { isEdit = false, isSaving = false, onCancel = () => {}, onSave } = props; + const [isCancelModalOpen, setIsCancelModalOpen] = useState(false); const { plugins: { application }, + baseErrors = {}, + paramsErrors = {}, + actionsErrors = {}, + actionsParamsErrors = {}, formData, multiConsumerSelection, + connectorTypes, + connectors, + touched, } = useRuleFormState(); + const { actions } = formData; + const canReadConnectors = !!application.capabilities.actions?.show; const styles = useEuiBackgroundColorCSS().transparent; - const onCancel = useCallback(() => { - application.navigateToUrl(returnUrl); - }, [application, returnUrl]); - const onSaveInternal = useCallback(() => { onSave({ ...formData, @@ -66,11 +81,51 @@ export const RulePage = (props: RulePageProps) => { }); }, [onSave, formData, multiConsumerSelection]); - const actionComponent = useMemo(() => { + const onCancelInternal = useCallback(() => { + if (touched) { + setIsCancelModalOpen(true); + } else { + onCancel(); + } + }, [touched, onCancel]); + + const hasActionsDisabled = useMemo(() => { + const preconfiguredConnectors = connectors.filter((connector) => connector.isPreconfigured); + return actions.some((action) => { + const actionType = connectorTypes.find(({ id }) => id === action.actionTypeId); + if (!actionType) { + return false; + } + const checkEnabledResult = checkActionFormActionTypeEnabled( + actionType, + preconfiguredConnectors + ); + return !actionType.enabled && !checkEnabledResult.isEnabled; + }); + }, [actions, connectors, connectorTypes]); + + const hasRuleDefinitionErrors = useMemo(() => { + return !!( + hasParamsErrors(paramsErrors) || + baseErrors.interval?.length || + baseErrors.alertDelay?.length + ); + }, [paramsErrors, baseErrors]); + + const hasActionErrors = useMemo(() => { + return hasActionsError(actionsErrors) || hasActionsParamsErrors(actionsParamsErrors); + }, [actionsErrors, actionsParamsErrors]); + + const hasRuleDetailsError = useMemo(() => { + return baseErrors.name?.length || baseErrors.tags?.length; + }, [baseErrors]); + + const actionComponent: EuiStepsProps['steps'] = useMemo(() => { if (canReadConnectors) { return [ { title: RULE_FORM_PAGE_RULE_ACTIONS_TITLE, + status: hasActionErrors ? 'danger' : undefined, children: ( <> @@ -82,17 +137,19 @@ export const RulePage = (props: RulePageProps) => { ]; } return []; - }, [canReadConnectors]); + }, [hasActionErrors, canReadConnectors]); const steps: EuiStepsProps['steps'] = useMemo(() => { return [ { title: RULE_FORM_PAGE_RULE_DEFINITION_TITLE, + status: hasRuleDefinitionErrors ? 'danger' : undefined, children: , }, ...actionComponent, { title: RULE_FORM_PAGE_RULE_DETAILS_TITLE, + status: hasRuleDetailsError ? 'danger' : undefined, children: ( <> @@ -102,46 +159,73 @@ export const RulePage = (props: RulePageProps) => { ), }, ]; - }, [actionComponent]); + }, [hasRuleDefinitionErrors, hasRuleDetailsError, actionComponent]); return ( - - - + + + + + + {RULE_FORM_RETURN_TITLE} + + + + + + + + + + {hasActionsDisabled && ( + <> + + + + )} + + + + + + + {isCancelModalOpen && ( + setIsCancelModalOpen(false)} + onConfirm={onCancel} + buttonColor="danger" + defaultFocusedButton="confirm" + title={RULE_FORM_CANCEL_MODAL_TITLE} + confirmButtonText={RULE_FORM_CANCEL_MODAL_CONFIRM} + cancelButtonText={RULE_FORM_CANCEL_MODAL_CANCEL} > - - - {RULE_FORM_RETURN_TITLE} - - - - - - - - - - - - - - - +

{RULE_FORM_CANCEL_MODAL_DESCRIPTION}

+ + )} + ); }; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_footer.test.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_footer.test.tsx index 45e2008773583..d937c60aa3a52 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_footer.test.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_footer.test.tsx @@ -32,15 +32,27 @@ const onSave = jest.fn(); const onCancel = jest.fn(); hasRuleErrors.mockReturnValue(false); -useRuleFormState.mockReturnValue({ - baseErrors: {}, - paramsErrors: {}, - formData: { - actions: [], - }, -}); describe('rulePageFooter', () => { + beforeEach(() => { + useRuleFormState.mockReturnValue({ + plugins: { + application: { + capabilities: { + actions: { + show: true, + }, + }, + }, + }, + baseErrors: {}, + paramsErrors: {}, + formData: { + actions: [], + }, + }); + }); + afterEach(() => { jest.clearAllMocks(); }); @@ -75,6 +87,30 @@ describe('rulePageFooter', () => { expect(screen.getByTestId('rulePageConfirmCreateRule')).toBeInTheDocument(); }); + test('should not show creat rule confirmation if user cannot read actions', () => { + useRuleFormState.mockReturnValue({ + plugins: { + application: { + capabilities: { + actions: { + show: false, + }, + }, + }, + }, + baseErrors: {}, + paramsErrors: {}, + formData: { + actions: [], + }, + }); + + render(); + fireEvent.click(screen.getByTestId('rulePageFooterSaveButton')); + expect(screen.queryByTestId('rulePageConfirmCreateRule')).not.toBeInTheDocument(); + expect(onSave).toHaveBeenCalled(); + }); + test('should show call onSave if clicking rule confirmation', () => { render(); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_footer.tsx b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_footer.tsx index 09d2ac429fd50..62a0e4b64e4f1 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_footer.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_form/rule_page/rule_page_footer.tsx @@ -34,6 +34,7 @@ export const RulePageFooter = (props: RulePageFooterProps) => { const { isEdit = false, isSaving = false, onCancel, onSave } = props; const { + plugins: { application }, formData: { actions }, connectors, baseErrors = {}, @@ -78,11 +79,12 @@ export const RulePageFooter = (props: RulePageFooterProps) => { if (isEdit) { return onSave(); } - if (actions.length === 0) { + const canReadConnectors = !!application.capabilities.actions?.show; + if (actions.length === 0 && canReadConnectors) { return setShowCreateConfirmation(true); } onSave(); - }, [actions, isEdit, onSave]); + }, [actions, isEdit, application, onSave]); const onCreateConfirmClick = useCallback(() => { setShowCreateConfirmation(false); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/translations.ts b/packages/kbn-alerts-ui-shared/src/rule_form/translations.ts index 20e87c66f10f4..fca2e30b94434 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/translations.ts +++ b/packages/kbn-alerts-ui-shared/src/rule_form/translations.ts @@ -194,7 +194,7 @@ export const RULE_TYPE_REQUIRED_TEXT = i18n.translate( export const RULE_ALERT_DELAY_BELOW_MINIMUM_TEXT = i18n.translate( 'alertsUIShared.ruleForm.error.belowMinimumAlertDelayText', { - defaultMessage: 'Alert delay must be greater than 1.', + defaultMessage: 'Alert delay must be 1 or greater.', } ); @@ -498,6 +498,34 @@ export const RULE_FORM_RETURN_TITLE = i18n.translate('alertsUIShared.ruleForm.re defaultMessage: 'Return', }); +export const RULE_FORM_CANCEL_MODAL_TITLE = i18n.translate( + 'alertsUIShared.ruleForm.ruleFormCancelModalTitle', + { + defaultMessage: 'Discard unsaved changes to rule?', + } +); + +export const RULE_FORM_CANCEL_MODAL_DESCRIPTION = i18n.translate( + 'alertsUIShared.ruleForm.ruleFormCancelModalDescription', + { + defaultMessage: "You can't recover unsaved changes.", + } +); + +export const RULE_FORM_CANCEL_MODAL_CONFIRM = i18n.translate( + 'alertsUIShared.ruleForm.ruleFormCancelModalConfirm', + { + defaultMessage: 'Discard changes', + } +); + +export const RULE_FORM_CANCEL_MODAL_CANCEL = i18n.translate( + 'alertsUIShared.ruleForm.ruleFormCancelModalCancel', + { + defaultMessage: 'Cancel', + } +); + export const MODAL_SEARCH_PLACEHOLDER = i18n.translate( 'alertsUIShared.ruleForm.modalSearchPlaceholder', { @@ -586,3 +614,10 @@ export const TECH_PREVIEW_DESCRIPTION = i18n.translate( 'This functionality is in technical preview and may be changed or removed completely in a future release. Elastic will work to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.', } ); + +export const DISABLED_ACTIONS_WARNING_TITLE = i18n.translate( + 'alertsUIShared.disabledActionsWarningTitle', + { + defaultMessage: 'This rule has actions that are disabled', + } +); diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/types.ts b/packages/kbn-alerts-ui-shared/src/rule_form/types.ts index d33c74da528db..4b45f64d3ead4 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/types.ts +++ b/packages/kbn-alerts-ui-shared/src/rule_form/types.ts @@ -72,6 +72,7 @@ export interface RuleFormState { connectors: ActionConnector[]; connectorTypes: ActionType[]; aadTemplateFields: ActionVariable[]; + availableRuleTypes: RuleTypeWithDescription[]; baseErrors?: RuleFormBaseErrors; paramsErrors?: RuleFormParamsErrors; actionsErrors?: Record; @@ -83,8 +84,9 @@ export interface RuleFormState { metadata?: Record; minimumScheduleInterval?: MinimumScheduleInterval; canShowConsumerSelection?: boolean; - validConsumers?: RuleCreationValidConsumer[]; + validConsumers: RuleCreationValidConsumer[]; flappingSettings?: RulesSettingsFlapping; + touched?: boolean; } export type InitialRule = Partial & diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_authorized_consumers.ts b/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_authorized_consumers.ts index 217bb18328d0e..0b5234c669440 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_authorized_consumers.ts +++ b/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_authorized_consumers.ts @@ -17,9 +17,6 @@ export const getAuthorizedConsumers = ({ ruleType: RuleTypeWithDescription; validConsumers: RuleCreationValidConsumer[]; }) => { - if (!ruleType.authorizedConsumers) { - return []; - } return Object.entries(ruleType.authorizedConsumers).reduce( (result, [authorizedConsumer, privilege]) => { if ( diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_default_params.ts b/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_default_params.ts new file mode 100644 index 0000000000000..d2aab787d6eb5 --- /dev/null +++ b/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_default_params.ts @@ -0,0 +1,25 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ +import { ActionTypeModel, RuleTypeWithDescription } from '../../common/types'; + +export const getDefaultParams = ({ + group, + ruleType, + actionTypeModel, +}: { + group: string; + actionTypeModel: ActionTypeModel; + ruleType: RuleTypeWithDescription; +}) => { + if (group === ruleType.recoveryActionGroup.id) { + return actionTypeModel.defaultRecoveredActionParams; + } else { + return actionTypeModel.defaultActionParams; + } +}; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/utils/index.ts b/packages/kbn-alerts-ui-shared/src/rule_form/utils/index.ts index f5b583a1a9c63..53c9aedda7545 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/utils/index.ts +++ b/packages/kbn-alerts-ui-shared/src/rule_form/utils/index.ts @@ -17,3 +17,4 @@ export * from './get_initial_schedule'; export * from './has_fields_for_aad'; export * from './get_selected_action_group'; export * from './get_initial_consumer'; +export * from './get_default_params'; diff --git a/packages/kbn-alerts-ui-shared/src/rule_form/validation/validate_form.ts b/packages/kbn-alerts-ui-shared/src/rule_form/validation/validate_form.ts index d65e9c5893937..57afe66b53edf 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_form/validation/validate_form.ts +++ b/packages/kbn-alerts-ui-shared/src/rule_form/validation/validate_form.ts @@ -35,7 +35,10 @@ export const validateAction = ({ action }: { action: RuleUiAction }): RuleFormAc if ('alertsFilter' in action) { const query = action?.alertsFilter?.query; - if (query && !query.kql) { + if (!query) { + return errors; + } + if (!query.filters.length && !query.kql) { errors.filterQuery.push( i18n.translate('alertsUIShared.ruleForm.actionsForm.requiredFilterQuery', { defaultMessage: 'A custom query is required.', @@ -43,7 +46,6 @@ export const validateAction = ({ action }: { action: RuleUiAction }): RuleFormAc ); } } - return errors; }; @@ -88,11 +90,7 @@ export function validateRuleBase({ errors.ruleTypeId.push(RULE_TYPE_REQUIRED_TEXT); } - if ( - formData.alertDelay && - !isNaN(formData.alertDelay?.active) && - formData.alertDelay?.active < 1 - ) { + if (!formData.alertDelay || isNaN(formData.alertDelay.active) || formData.alertDelay.active < 1) { errors.alertDelay.push(RULE_ALERT_DELAY_BELOW_MINIMUM_TEXT); } @@ -111,34 +109,41 @@ export const validateRuleParams = ({ return ruleTypeModel.validate(formData.params, isServerless).errors; }; -const hasRuleBaseErrors = (errors: RuleFormBaseErrors) => { +export const hasRuleBaseErrors = (errors: RuleFormBaseErrors) => { return Object.values(errors).some((error: string[]) => error.length > 0); }; -const hasActionsError = (actionsErrors: Record) => { +export const hasActionsError = (actionsErrors: Record) => { return Object.values(actionsErrors).some((errors: RuleFormActionsErrors) => { return Object.values(errors).some((error: string[]) => error.length > 0); }); }; -const hasParamsErrors = (errors: RuleFormParamsErrors): boolean => { - const values = Object.values(errors); +export const hasParamsErrors = (errors: RuleFormParamsErrors | string | string[]): boolean => { let hasError = false; - for (const value of values) { - if (Array.isArray(value) && value.length > 0) { - return true; - } - if (typeof value === 'string' && value.trim() !== '') { - return true; - } - if (isObject(value)) { - hasError = hasParamsErrors(value as RuleFormParamsErrors); - } + + if (typeof errors === 'string' && errors.trim() !== '') { + hasError = true; } + + if (Array.isArray(errors)) { + errors.forEach((error) => { + hasError = hasError || hasParamsErrors(error); + }); + } + + if (isObject(errors)) { + Object.entries(errors).forEach(([_, value]) => { + hasError = hasError || hasParamsErrors(value); + }); + } + return hasError; }; -const hasActionsParamsErrors = (actionsParamsErrors: Record) => { +export const hasActionsParamsErrors = ( + actionsParamsErrors: Record +) => { return Object.values(actionsParamsErrors).some((errors: RuleFormParamsErrors) => { return hasParamsErrors(errors); }); diff --git a/packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_flapping_form.tsx b/packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_flapping_form.tsx index 99f64f0a3977f..030cde8127b0a 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_flapping_form.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_flapping_form.tsx @@ -218,15 +218,17 @@ export const RuleSettingsFlappingForm = (props: RuleSettingsFlappingFormProps) = direction={isDesktop ? 'row' : 'column'} alignItems={isDesktop ? 'center' : undefined} > - + {flappingLabel} - + {enabled ? flappingOnLabel : flappingOffLabel} {flappingSettings && enabled && ( - {flappingOverrideLabel} + + {flappingOverrideLabel} + )} @@ -236,6 +238,7 @@ export const RuleSettingsFlappingForm = (props: RuleSettingsFlappingFormProps) = compressed checked={!!flappingSettings} label={flappingOverrideConfiguration} + disabled={!canWriteFlappingSettingsUI} onChange={onFlappingToggle} /> )} @@ -256,6 +259,7 @@ export const RuleSettingsFlappingForm = (props: RuleSettingsFlappingFormProps) = spaceFlappingSettings, flappingSettings, flappingOffTooltip, + canWriteFlappingSettingsUI, onFlappingToggle, ]); @@ -273,12 +277,14 @@ export const RuleSettingsFlappingForm = (props: RuleSettingsFlappingFormProps) = statusChangeThreshold={flappingSettings.statusChangeThreshold} onLookBackWindowChange={onLookBackWindowChange} onStatusChangeThresholdChange={onStatusChangeThresholdChange} + isDisabled={!canWriteFlappingSettingsUI} /> ); }, [ flappingSettings, spaceFlappingSettings, + canWriteFlappingSettingsUI, onLookBackWindowChange, onStatusChangeThresholdChange, ]); diff --git a/packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_flapping_title_tooltip.tsx b/packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_flapping_title_tooltip.tsx index 2a5cc4186013d..149eb5b792c1b 100644 --- a/packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_flapping_title_tooltip.tsx +++ b/packages/kbn-alerts-ui-shared/src/rule_settings/rule_settings_flapping_title_tooltip.tsx @@ -80,6 +80,7 @@ export const RuleSettingsFlappingTitleTooltip = (props: RuleSettingsFlappingTitl panelStyle={{ width: 500, }} + closePopover={() => setIsPopoverOpen(false)} button={ ruleDetailsRoute.replace(':ruleId', ruleId); +export const getCreateRuleRoute = (ruleTypeId: string) => + createRuleRoute.replace(':ruleTypeId', ruleTypeId); +export const getEditRuleRoute = (ruleId: string) => editRuleRoute.replace(':id', ruleId); diff --git a/x-pack/examples/triggers_actions_ui_example/public/application.tsx b/x-pack/examples/triggers_actions_ui_example/public/application.tsx index 4a429fbfd58d7..b3c11beb5285c 100644 --- a/x-pack/examples/triggers_actions_ui_example/public/application.tsx +++ b/x-pack/examples/triggers_actions_ui_example/public/application.tsx @@ -203,7 +203,6 @@ const TriggersActionsUiExampleApp = ({ ruleTypeRegistry: triggersActionsUi.ruleTypeRegistry, actionTypeRegistry: triggersActionsUi.actionTypeRegistry, }} - returnUrl={application.getUrlForApp('triggersActionsUiExample')} /> )} @@ -229,7 +228,6 @@ const TriggersActionsUiExampleApp = ({ ruleTypeRegistry: triggersActionsUi.ruleTypeRegistry, actionTypeRegistry: triggersActionsUi.actionTypeRegistry, }} - returnUrl={application.getUrlForApp('triggersActionsUiExample')} /> )} diff --git a/x-pack/plugins/triggers_actions_ui/.storybook/decorator.tsx b/x-pack/plugins/triggers_actions_ui/.storybook/decorator.tsx index 183b1acd3ca53..233b673353929 100644 --- a/x-pack/plugins/triggers_actions_ui/.storybook/decorator.tsx +++ b/x-pack/plugins/triggers_actions_ui/.storybook/decorator.tsx @@ -69,7 +69,7 @@ export const StorybookContextDecorator: FC; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/constants/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/constants/index.ts index 8a6003960473b..8d39d7851d9bf 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/constants/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/constants/index.ts @@ -25,6 +25,8 @@ export const routeToConnectors = `/connectors`; export const routeToConnectorEdit = `/connectors/:connectorId`; export const routeToRules = `/rules`; export const routeToLogs = `/logs`; +export const routeToCreateRule = '/rules/create'; +export const routeToEditRule = '/rules/edit'; export const legacyRouteToAlerts = `/alerts`; export const legacyRouteToRuleDetails = `/alert/:alertId`; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/home.tsx b/x-pack/plugins/triggers_actions_ui/public/application/home.tsx index 045732830c891..a2a2187c75895 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/home.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/home.tsx @@ -72,6 +72,7 @@ export const TriggersActionsUIHome: React.FunctionComponent { defaultMessage: 'Rules', }); break; + case 'createRule': + updatedTitle = i18n.translate('xpack.triggersActionsUI.rules.createRule.breadcrumbTitle', { + defaultMessage: 'Create rule', + }); + break; + case 'editRule': + updatedTitle = i18n.translate('xpack.triggersActionsUI.rules.editRule.breadcrumbTitle', { + defaultMessage: 'Edit rule', + }); + break; case 'alerts': updatedTitle = i18n.translate('xpack.triggersActionsUI.alerts.breadcrumbTitle', { defaultMessage: 'Alerts', diff --git a/x-pack/plugins/triggers_actions_ui/public/application/rules_app.tsx b/x-pack/plugins/triggers_actions_ui/public/application/rules_app.tsx index 9f472c251a91b..8550518edb457 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/rules_app.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/rules_app.tsx @@ -31,7 +31,7 @@ import type { LensPublicStart } from '@kbn/lens-plugin/public'; import { Storage } from '@kbn/kibana-utils-plugin/public'; import { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public'; -import { ruleDetailsRoute } from '@kbn/rule-data-utils'; +import { ruleDetailsRoute, createRuleRoute, editRuleRoute } from '@kbn/rule-data-utils'; import { QueryClientProvider } from '@tanstack/react-query'; import { DashboardStart } from '@kbn/dashboard-plugin/public'; import { ExpressionsStart } from '@kbn/expressions-plugin/public'; @@ -54,11 +54,14 @@ import { KibanaContextProvider, useKibana } from '../common/lib/kibana'; import { ConnectorProvider } from './context/connector_context'; import { ALERTS_PAGE_ID, CONNECTORS_PLUGIN_ID } from '../common/constants'; import { queryClient } from './query_client'; +import { getIsExperimentalFeatureEnabled } from '../common/get_experimental_features'; const TriggersActionsUIHome = lazy(() => import('./home')); const RuleDetailsRoute = lazy( () => import('./sections/rule_details/components/rule_details_route') ); +const CreateRuleRoute = lazy(() => import('./sections/rule_form/rule_form_route')); +const EditRuleRoute = lazy(() => import('./sections/rule_form/rule_form_route')); export interface TriggersAndActionsUiServices extends CoreStart { actions: ActionsPublicPluginSetup; @@ -122,9 +125,25 @@ export const AppWithoutRouter = ({ sectionsRegex }: { sectionsRegex: string }) = application: { navigateToApp }, } = useKibana().services; + const isUsingRuleCreateFlyout = getIsExperimentalFeatureEnabled('isUsingRuleCreateFlyout'); + return ( + {!isUsingRuleCreateFlyout && ( + + )} + {!isUsingRuleCreateFlyout && ( + + )} - diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule.tsx index c6598becec313..ca4de13be903b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule.tsx @@ -199,6 +199,7 @@ export function RuleComponent({ actionTypeRegistry, ruleTypeRegistry, hideEditButton: true, + useNewRuleForm: true, onEditRule: requestRefresh, })}
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_definition.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_definition.test.tsx index 91a77d18009c5..bc5da8218d118 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_definition.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_definition.test.tsx @@ -21,6 +21,10 @@ jest.mock('./rule_actions', () => ({ }, })); +jest.mock('../../../../common/get_experimental_features', () => ({ + getIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true), +})); + jest.mock('../../../lib/capabilities', () => ({ hasAllPrivilege: jest.fn(() => true), hasSaveRulesCapability: jest.fn(() => true), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_definition.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_definition.tsx index e608a71af05a6..ed21bb88992a8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_definition.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_definition.tsx @@ -16,13 +16,14 @@ import { EuiLoadingSpinner, EuiDescriptionList, } from '@elastic/eui'; -import { AlertConsumers } from '@kbn/rule-data-utils'; +import { AlertConsumers, getEditRuleRoute, getRuleDetailsRoute } from '@kbn/rule-data-utils'; import { i18n } from '@kbn/i18n'; import { formatDuration } from '@kbn/alerting-plugin/common'; import { useLoadRuleTypesQuery } from '../../../hooks/use_load_rule_types_query'; import { RuleDefinitionProps } from '../../../../types'; import { RuleType } from '../../../..'; import { useKibana } from '../../../../common/lib/kibana'; +import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features'; import { hasAllPrivilege, hasExecuteActionsCapability, @@ -38,11 +39,14 @@ export const RuleDefinition: React.FunctionComponent = ({ onEditRule, hideEditButton = false, filteredRuleTypes = [], + useNewRuleForm = false, }) => { const { - application: { capabilities }, + application: { capabilities, navigateToApp }, } = useKibana().services; + const isUsingRuleCreateFlyout = getIsExperimentalFeatureEnabled('isUsingRuleCreateFlyout'); + const [editFlyoutVisible, setEditFlyoutVisible] = useState(false); const [ruleType, setRuleType] = useState(); const { @@ -103,6 +107,20 @@ export const RuleDefinition: React.FunctionComponent = ({ return ''; }, [rule, ruleTypeRegistry]); + const onEditRuleClick = () => { + if (!isUsingRuleCreateFlyout && useNewRuleForm) { + navigateToApp('management', { + path: `insightsAndAlerting/triggersActions/${getEditRuleRoute(rule.id)}`, + state: { + returnApp: 'management', + returnPath: `insightsAndAlerting/triggersActions/${getRuleDetailsRoute(rule.id)}`, + }, + }); + } else { + setEditFlyoutVisible(true); + } + }; + const ruleDefinitionList = [ { title: i18n.translate('xpack.triggersActionsUI.ruleDetails.ruleType', { @@ -153,7 +171,7 @@ export const RuleDefinition: React.FunctionComponent = ({ > {hasEditButton ? ( - setEditFlyoutVisible(true)} flush="left"> + {getRuleConditionsWording()} ) : ( @@ -206,7 +224,7 @@ export const RuleDefinition: React.FunctionComponent = ({ setEditFlyoutVisible(true)} + onClick={onEditRuleClick} /> ) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx index 615efb5ed74b6..ffde171117385 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.test.tsx @@ -23,6 +23,10 @@ import { ruleTypeRegistryMock } from '../../../rule_type_registry.mock'; jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../../common/get_experimental_features', () => ({ + getIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true), +})); + jest.mock('@kbn/alerts-ui-shared/src/common/apis/fetch_ui_config', () => ({ fetchUiConfig: jest .fn() diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx index 8b2ee15db87d1..9422abdba3ec0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_details/components/rule_details.tsx @@ -26,7 +26,7 @@ import { import { FormattedMessage } from '@kbn/i18n-react'; import { toMountPoint } from '@kbn/react-kibana-mount'; import { RuleExecutionStatusErrorReasons, parseDuration } from '@kbn/alerting-plugin/common'; -import { getRuleDetailsRoute } from '@kbn/rule-data-utils'; +import { getEditRuleRoute, getRuleDetailsRoute } from '@kbn/rule-data-utils'; import { fetchUiConfig as triggersActionsUiConfig } from '@kbn/alerts-ui-shared/src/common/apis/fetch_ui_config'; import { UpdateApiKeyModalConfirmation } from '../../../components/update_api_key_modal_confirmation'; import { bulkUpdateAPIKey } from '../../../lib/rule_api/update_api_key'; @@ -71,6 +71,7 @@ import { import { useBulkOperationToast } from '../../../hooks/use_bulk_operation_toast'; import { RefreshToken } from './types'; import { UntrackAlertsModal } from '../../common/components/untrack_alerts_modal'; +import { getIsExperimentalFeatureEnabled } from '../../../../common/get_experimental_features'; export type RuleDetailsProps = { rule: Rule; @@ -78,6 +79,7 @@ export type RuleDetailsProps = { actionTypes: ActionType[]; requestRefresh: () => Promise; refreshToken?: RefreshToken; + useNewRuleForm?: boolean; } & Pick< BulkOperationsComponentOpts, 'bulkDisableRules' | 'bulkEnableRules' | 'bulkDeleteRules' | 'snoozeRule' | 'unsnoozeRule' @@ -98,7 +100,7 @@ export const RuleDetails: React.FunctionComponent = ({ }) => { const history = useHistory(); const { - application: { capabilities }, + application: { capabilities, navigateToApp }, ruleTypeRegistry, actionTypeRegistry, setBreadcrumbs, @@ -108,6 +110,9 @@ export const RuleDetails: React.FunctionComponent = ({ theme, notifications: { toasts }, } = useKibana().services; + + const isUsingRuleCreateFlyout = getIsExperimentalFeatureEnabled('isUsingRuleCreateFlyout'); + const ruleReducer = useMemo(() => getRuleReducer(actionTypeRegistry), [actionTypeRegistry]); const [{}, dispatch] = useReducer(ruleReducer, { rule }); const setInitialRule = (value: Rule) => { @@ -206,7 +211,7 @@ export const RuleDetails: React.FunctionComponent = ({ data-test-subj="ruleIntervalToastEditButton" onClick={() => { toasts.remove(configurationToast); - setEditFlyoutVisibility(true); + onEditRuleClick(); }} > = ({ }); } } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ i18nStart, theme, @@ -256,12 +262,26 @@ export const RuleDetails: React.FunctionComponent = ({ } }; + const onEditRuleClick = () => { + if (!isUsingRuleCreateFlyout) { + navigateToApp('management', { + path: `insightsAndAlerting/triggersActions/${getEditRuleRoute(rule.id)}`, + state: { + returnApp: 'management', + returnPath: `insightsAndAlerting/triggersActions/${getRuleDetailsRoute(rule.id)}`, + }, + }); + } else { + setEditFlyoutVisibility(true); + } + }; + const editButton = hasEditButton ? ( <> setEditFlyoutVisibility(true)} + onClick={onEditRuleClick} name="edit" disabled={!ruleType.enabledInLicense} > @@ -529,7 +549,7 @@ export const RuleDetails: React.FunctionComponent = ({ setEditFlyoutVisibility(true)} + onClick={onEditRuleClick} > ({ .fn() .mockResolvedValue({ minimumScheduleInterval: { value: '1m', enforce: false } }), })); + +jest.mock('../../../../common/get_experimental_features', () => ({ + getIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true), +})); + describe('rule_details_route', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form_route.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form_route.tsx new file mode 100644 index 0000000000000..496b4d30873e6 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_form_route.tsx @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect } from 'react'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import { RuleForm } from '@kbn/alerts-ui-shared/src/rule_form/rule_form'; +import { getRuleDetailsRoute } from '@kbn/rule-data-utils'; +import { useLocation, useParams } from 'react-router-dom'; +import { useKibana } from '../../../common/lib/kibana'; +import { getAlertingSectionBreadcrumb } from '../../lib/breadcrumb'; +import { getCurrentDocTitle } from '../../lib/doc_title'; + +export const RuleFormRoute = () => { + const { + http, + i18n, + theme, + application, + notifications, + charts, + settings, + data, + dataViews, + unifiedSearch, + docLinks, + ruleTypeRegistry, + actionTypeRegistry, + chrome, + setBreadcrumbs, + } = useKibana().services; + + const location = useLocation<{ returnApp?: string; returnPath?: string }>(); + const { id, ruleTypeId } = useParams<{ + id?: string; + ruleTypeId?: string; + }>(); + const { returnApp, returnPath } = location.state || {}; + + // Set breadcrumb and page title + useEffect(() => { + if (id) { + setBreadcrumbs([ + getAlertingSectionBreadcrumb('rules', true), + getAlertingSectionBreadcrumb('editRule'), + ]); + chrome.docTitle.change(getCurrentDocTitle('editRule')); + } + if (ruleTypeId) { + setBreadcrumbs([ + getAlertingSectionBreadcrumb('rules', true), + getAlertingSectionBreadcrumb('createRule'), + ]); + chrome.docTitle.change(getCurrentDocTitle('createRule')); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + { + if (returnApp && returnPath) { + application.navigateToApp(returnApp, { path: returnPath }); + } else { + application.navigateToApp('management', { + path: `insightsAndAlerting/triggersActions/rules`, + }); + } + }} + onSubmit={(ruleId) => { + application.navigateToApp('management', { + path: `insightsAndAlerting/triggersActions/${getRuleDetailsRoute(ruleId)}`, + }); + }} + /> + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { RuleFormRoute as default }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx index a36068125a6a5..d98aa2c5dec67 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rules_list/components/rules_list.tsx @@ -45,6 +45,8 @@ import { RuleCreationValidConsumer, ruleDetailsRoute as commonRuleDetailsRoute, STACK_ALERTS_FEATURE_ID, + getCreateRuleRoute, + getEditRuleRoute, } from '@kbn/rule-data-utils'; import { MaintenanceWindowCallout } from '@kbn/alerts-ui-shared'; import { @@ -139,6 +141,7 @@ export interface RulesListProps { onRefresh?: (refresh: Date) => void; setHeaderActions?: (components?: React.ReactNode[]) => void; initialSelectedConsumer?: RuleCreationValidConsumer | null; + useNewRuleForm?: boolean; } export const percentileFields = { @@ -180,12 +183,13 @@ export const RulesList = ({ onRefresh, setHeaderActions, initialSelectedConsumer = STACK_ALERTS_FEATURE_ID, + useNewRuleForm = false, }: RulesListProps) => { const history = useHistory(); const kibanaServices = useKibana().services; const { actionTypeRegistry, - application: { capabilities }, + application: { capabilities, navigateToApp }, http, kibanaFeatures, notifications: { toasts }, @@ -211,6 +215,7 @@ export const RulesList = ({ const cloneRuleId = useRef(null); const isRuleStatusFilterEnabled = getIsExperimentalFeatureEnabled('ruleStatusFilter'); + const isUsingRuleCreateFlyout = getIsExperimentalFeatureEnabled('isUsingRuleCreateFlyout'); const [percentileOptions, setPercentileOptions] = useState(initialPercentileOptions); @@ -312,8 +317,18 @@ export const RulesList = ({ }); const onRuleEdit = (ruleItem: RuleTableItem) => { - setEditFlyoutVisibility(true); - setCurrentRuleToEdit(ruleItem); + if (!isUsingRuleCreateFlyout && useNewRuleForm) { + navigateToApp('management', { + path: `insightsAndAlerting/triggersActions/${getEditRuleRoute(ruleItem.id)}`, + state: { + returnApp: 'management', + returnPath: `insightsAndAlerting/triggersActions/rules`, + }, + }); + } else { + setEditFlyoutVisibility(true); + setCurrentRuleToEdit(ruleItem); + } }; const onRunRule = async (id: string) => { @@ -1006,9 +1021,15 @@ export const RulesList = ({ setRuleTypeModalVisibility(false)} onSelectRuleType={(ruleTypeId) => { - setRuleTypeIdToCreate(ruleTypeId); - setRuleTypeModalVisibility(false); - setRuleFlyoutVisibility(true); + if (!isUsingRuleCreateFlyout) { + navigateToApp('management', { + path: `insightsAndAlerting/triggersActions/${getCreateRuleRoute(ruleTypeId)}`, + }); + } else { + setRuleTypeIdToCreate(ruleTypeId); + setRuleTypeModalVisibility(false); + setRuleFlyoutVisibility(true); + } }} http={http} toasts={toasts} diff --git a/x-pack/plugins/triggers_actions_ui/public/common/get_experimental_features.test.tsx b/x-pack/plugins/triggers_actions_ui/public/common/get_experimental_features.test.tsx index b7ffa1aa48b18..b33622423b92d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/get_experimental_features.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/common/get_experimental_features.test.tsx @@ -24,7 +24,7 @@ describe('getIsExperimentalFeatureEnabled', () => { ruleKqlBar: true, isMustacheAutocompleteOn: false, showMustacheAutocompleteSwitch: false, - ruleFormV2: false, + isUsingRuleCreateFlyout: false, }, }); @@ -64,7 +64,7 @@ describe('getIsExperimentalFeatureEnabled', () => { expect(result).toEqual(false); - result = getIsExperimentalFeatureEnabled('ruleFormV2'); + result = getIsExperimentalFeatureEnabled('isUsingRuleCreateFlyout'); expect(result).toEqual(false); diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index 6ad86397606c8..a592b19ae8a79 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -395,6 +395,7 @@ export interface RuleDefinitionProps Promise; hideEditButton?: boolean; filteredRuleTypes?: string[]; + useNewRuleForm?: boolean; } export enum Percentiles { 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 2fdf49bc41fef..b4cc8a734a270 100644 --- a/x-pack/test/functional_with_es_ssl/config.base.ts +++ b/x-pack/test/functional_with_es_ssl/config.base.ts @@ -85,6 +85,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { 'stackAlertsPage', 'ruleTagFilter', 'ruleStatusFilter', + 'isUsingRuleCreateFlyout', ])}`, `--xpack.alerting.rules.minimumScheduleInterval.value="2s"`, `--xpack.actions.enabledActionTypes=${JSON.stringify(enabledActionTypes)}`, diff --git a/x-pack/test_serverless/functional/config.base.ts b/x-pack/test_serverless/functional/config.base.ts index b0cd556fe1d36..1a3cd2ffd6a5b 100644 --- a/x-pack/test_serverless/functional/config.base.ts +++ b/x-pack/test_serverless/functional/config.base.ts @@ -36,6 +36,11 @@ export function createTestConfig(options: CreateTestConfigOptions) { serverArgs: [ ...svlSharedConfig.get('kbnTestServer.serverArgs'), `--serverless=${options.serverlessProject}`, + // Ensures the existing E2E tests are backwards compatible with the old rule create flyout + // Remove this experiment once all of the migration has been completed + `--xpack.trigger_actions_ui.enableExperimental=${JSON.stringify([ + 'isUsingRuleCreateFlyout', + ])}`, // custom native roles are enabled only for search and security projects ...(options.serverlessProject !== 'oblt' ? ['--xpack.security.roleManagementEnabled=true'] From 0ccfb70c810b037c5aa02270e5a59da284d2b31c Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Tue, 15 Oct 2024 13:32:32 +0300 Subject: [PATCH 03/13] fix: [Stateful: Home page] Create an API key dialog information announcement duplication (#196133) Closes: #195754 Closes: #195252 ## Description Information about an element (in this case, a dialog) should be announced once to the user. If the user navigates to another element and then returns to the same dialog, they should hear the information about the dialog again (one time). ## What was changed?: 1. Added `aria-labelledby` for `EuiFlyout` based on the EUI recommendation. This will correctly pronounce the Flyout header without extra text. 2. Added `aria-labelledby` and `role="region"` for `EuiAccordion` for the same reason. ## Screen: image --- .../shared/api_key/create_api_key_flyout.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/api_key/create_api_key_flyout.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/api_key/create_api_key_flyout.tsx index fe298fbd98f4b..38217df269fd1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/api_key/create_api_key_flyout.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/api_key/create_api_key_flyout.tsx @@ -32,6 +32,7 @@ import { EuiSwitchEvent, EuiText, EuiTitle, + useGeneratedHtmlId, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -161,6 +162,8 @@ export const CreateApiKeyFlyout: React.FC = ({ onClose const apiKeyRef = useRef(null); + const uniqueId = useGeneratedHtmlId(); + useEffect(() => { if (createdApiKey && apiKeyRef) { apiKeyRef.current?.scrollIntoView(); @@ -178,10 +181,11 @@ export const CreateApiKeyFlyout: React.FC = ({ onClose css={css` max-width: calc(${euiTheme.size.xxxxl} * 10); `} + aria-labelledby={`${uniqueId}-header`} > -

+

{i18n.translate('xpack.enterpriseSearch.apiKey.flyoutTitle', { defaultMessage: 'Create an API key', })} @@ -239,6 +243,8 @@ export const CreateApiKeyFlyout: React.FC = ({ onClose id="apiKey.setup" paddingSize="l" initialIsOpen + aria-labelledby={`${uniqueId}-setupHeader`} + role="region" buttonContent={
@@ -247,7 +253,7 @@ export const CreateApiKeyFlyout: React.FC = ({ onClose -

+

{i18n.translate('xpack.enterpriseSearch.apiKey.setup.title', { defaultMessage: 'Setup', })} @@ -283,6 +289,8 @@ export const CreateApiKeyFlyout: React.FC = ({ onClose @@ -291,7 +299,7 @@ export const CreateApiKeyFlyout: React.FC = ({ onClose -

+

{i18n.translate('xpack.enterpriseSearch.apiKey.privileges.title', { defaultMessage: 'Security Privileges', })} @@ -338,6 +346,8 @@ export const CreateApiKeyFlyout: React.FC = ({ onClose @@ -346,7 +356,7 @@ export const CreateApiKeyFlyout: React.FC = ({ onClose -

+

{i18n.translate('xpack.enterpriseSearch.apiKey.metadata.title', { defaultMessage: 'Metadata', })} From 2c1d5ce08fa55275148e61012aa49061f01c3dd9 Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Tue, 15 Oct 2024 13:33:30 +0300 Subject: [PATCH 04/13] fix: [Stateful: Home page] Not checked radio button receive focus a first element in radio group. (#195745) Closes: #195190 ## Description According to ARIA Authoring Practices Guide, focus should be on the checked radio button when the user reaches radio group while navigating using only keyboard. As of now, because all the time first radio button in the group receives focus, even if it is not checked, it may cause confusion and could potentially lead users to unintentionally change their selection without checking all checkboxes which exist in the group. ## What was changed: 1. Added name attribute for `EuiRadioGroup`. ## Screen: https://github.com/user-attachments/assets/20db2394-b9db-4c40-9e72-53ee860cd066 --- .../public/applications/shared/api_key/basic_setup_form.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/api_key/basic_setup_form.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/api_key/basic_setup_form.tsx index 0964f2909d85d..42a20a44dd06e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/api_key/basic_setup_form.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/api_key/basic_setup_form.tsx @@ -117,6 +117,7 @@ export const BasicSetupForm: React.FC = ({ 'data-test-subj': 'create-api-key-expires-days-radio', }, ]} + name="create-api-key-expires-group" idSelected={expires === null ? 'never' : 'days'} onChange={(id) => onChangeExpires(id === 'never' ? null : DEFAULT_EXPIRES_VALUE)} data-test-subj="create-api-key-expires-radio" From 422cad5c2dca04ed121544079be255ac85f9e479 Mon Sep 17 00:00:00 2001 From: Joe McElroy Date: Tue, 15 Oct 2024 11:44:17 +0100 Subject: [PATCH 05/13] [Onboarding] Small fixes from QA (#196178) ## Summary - update the code examples to use the normal client, not the elasticsearch client. The devtools team wants us to use the elasticsearch client here - update the code samples highlighting component so you can see highlighting --- .../public/code_examples/javascript.ts | 6 +-- .../public/code_examples/python.ts | 43 ++++++++----------- .../public/components/shared/code_sample.tsx | 5 +++ 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/x-pack/plugins/search_indices/public/code_examples/javascript.ts b/x-pack/plugins/search_indices/public/code_examples/javascript.ts index 3e91cb99301a7..a819b973388f4 100644 --- a/x-pack/plugins/search_indices/public/code_examples/javascript.ts +++ b/x-pack/plugins/search_indices/public/code_examples/javascript.ts @@ -19,7 +19,7 @@ export const JAVASCRIPT_INFO: CodeLanguage = { codeBlockLanguage: 'javascript', }; -const SERVERLESS_INSTALL_CMD = `npm install @elastic/elasticsearch-serverless`; +const SERVERLESS_INSTALL_CMD = `npm install @elastic/elasticsearch`; export const JavascriptServerlessCreateIndexExamples: CreateIndexLanguageExamples = { default: { @@ -28,7 +28,7 @@ export const JavascriptServerlessCreateIndexExamples: CreateIndexLanguageExample elasticsearchURL, apiKey, indexName, - }) => `import { Client } from "@elastic/elasticsearch-serverless" + }) => `import { Client } from "@elastic/elasticsearch" const client = new Client({ node: '${elasticsearchURL}', @@ -47,7 +47,7 @@ client.indices.create({ elasticsearchURL, apiKey, indexName, - }) => `import { Client } from "@elastic/elasticsearch-serverless" + }) => `import { Client } from "@elastic/elasticsearch" const client = new Client({ node: '${elasticsearchURL}', diff --git a/x-pack/plugins/search_indices/public/code_examples/python.ts b/x-pack/plugins/search_indices/public/code_examples/python.ts index e41e542456e72..ac405cfecd1e9 100644 --- a/x-pack/plugins/search_indices/public/code_examples/python.ts +++ b/x-pack/plugins/search_indices/public/code_examples/python.ts @@ -23,7 +23,7 @@ export const PYTHON_INFO: CodeLanguage = { codeBlockLanguage: 'python', }; -const SERVERLESS_PYTHON_INSTALL_CMD = 'pip install elasticsearch-serverless'; +const SERVERLESS_PYTHON_INSTALL_CMD = 'pip install elasticsearch'; export const PythonServerlessCreateIndexExamples: CreateIndexLanguageExamples = { default: { @@ -32,7 +32,7 @@ export const PythonServerlessCreateIndexExamples: CreateIndexLanguageExamples = elasticsearchURL, apiKey, indexName, - }: CodeSnippetParameters) => `from elasticsearch-serverless import Elasticsearch + }: CodeSnippetParameters) => `from elasticsearch import Elasticsearch client = Elasticsearch( "${elasticsearchURL}", @@ -49,21 +49,21 @@ client.indices.create( elasticsearchURL, apiKey, indexName, - }: CodeSnippetParameters) => `from elasticsearch-serverless import Elasticsearch + }: CodeSnippetParameters) => `from elasticsearch import Elasticsearch client = Elasticsearch( - "${elasticsearchURL}", - api_key="${apiKey ?? API_KEY_PLACEHOLDER}" + "${elasticsearchURL}", + api_key="${apiKey ?? API_KEY_PLACEHOLDER}" ) client.indices.create( - index="${indexName ?? INDEX_PLACEHOLDER}" - mappings={ - "properties": { - "vector": {"type": "dense_vector", "dims": 3 }, - "text": {"type": "text"} - } - } + index="${indexName ?? INDEX_PLACEHOLDER}", + mappings={ + "properties": { + "vector": {"type": "dense_vector", "dims": 3 }, + "text": {"type": "text"} + } + } )`, }, }; @@ -72,7 +72,7 @@ const serverlessIngestionCommand: IngestCodeSnippetFunction = ({ apiKey, indexName, sampleDocument, -}) => `from elasticsearch-serverless import Elasticsearch, helpers +}) => `from elasticsearch import Elasticsearch, helpers client = Elasticsearch( "${elasticsearchURL}", @@ -93,25 +93,20 @@ const serverlessUpdateMappingsCommand: IngestCodeSnippetFunction = ({ apiKey, indexName, mappingProperties, -}) => `from elasticsearch-serverless import Elasticsearch +}) => `from elasticsearch import Elasticsearch client = Elasticsearch( -"${elasticsearchURL}", -api_key="${apiKey ?? API_KEY_PLACEHOLDER}" + "${elasticsearchURL}", + api_key="${apiKey ?? API_KEY_PLACEHOLDER}" ) index_name = "${indexName}" mappings = ${JSON.stringify({ properties: mappingProperties }, null, 4)} -update_mapping_response = client.indices.put_mapping(index=index_name, body=mappings) - -# Print the response -print(update_mapping_response) - -# Verify the mapping -mapping = client.indices.get_mapping(index=index_name) -print(mapping)`; +mapping_response = client.indices.put_mapping(index=index_name, body=mappings) +print(mapping_response) +`; export const PythonServerlessVectorsIngestDataExample: IngestDataCodeDefinition = { installCommand: SERVERLESS_PYTHON_INSTALL_CMD, diff --git a/x-pack/plugins/search_indices/public/components/shared/code_sample.tsx b/x-pack/plugins/search_indices/public/components/shared/code_sample.tsx index 4ddce94d685b0..fc233e498ea10 100644 --- a/x-pack/plugins/search_indices/public/components/shared/code_sample.tsx +++ b/x-pack/plugins/search_indices/public/components/shared/code_sample.tsx @@ -54,6 +54,11 @@ export const CodeSample = ({ id, title, language, code, onCodeCopyClick }: CodeS paddingSize="m" isCopyable transparentBackground + css={{ + '*::selection': { + backgroundColor: 'rgba(255, 255, 255, 0.2)', + }, + }} > {code} From 32413591c381953e86d96a52efe9253785d43abf Mon Sep 17 00:00:00 2001 From: Robert Oskamp Date: Tue, 15 Oct 2024 12:47:27 +0200 Subject: [PATCH 06/13] Skip serverless security agentless tests for MKI (#196250) ## Summary This PR skips the serverless security agentless test suite for MKI runs. Details in #196245 --- .../ftr/cloud_security_posture/agentless_api/create_agent.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/x-pack/test_serverless/functional/test_suites/security/ftr/cloud_security_posture/agentless_api/create_agent.ts b/x-pack/test_serverless/functional/test_suites/security/ftr/cloud_security_posture/agentless_api/create_agent.ts index b26581fb46dfd..8164fd39a81de 100644 --- a/x-pack/test_serverless/functional/test_suites/security/ftr/cloud_security_posture/agentless_api/create_agent.ts +++ b/x-pack/test_serverless/functional/test_suites/security/ftr/cloud_security_posture/agentless_api/create_agent.ts @@ -25,6 +25,9 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const AWS_SINGLE_ACCOUNT_TEST_ID = 'awsSingleTestId'; describe('Agentless API Serverless', function () { + // fails on MKI, see https://github.com/elastic/kibana/issues/196245 + this.tags(['failsOnMKI']); + let mockApiServer: http.Server; let cisIntegration: typeof pageObjects.cisAddIntegration; From 562bf21fbd805bf523748e692c177621fe93e133 Mon Sep 17 00:00:00 2001 From: Tre Date: Tue, 15 Oct 2024 12:40:10 +0100 Subject: [PATCH 07/13] [FTR][Ownership] Assign Ownership to "entity/*" ES Archives (#194436) ## Summary Modify code owner declarations for `x-pack/test/functional/es_archives/entity/**/*` in .github/CODEOWNERS ### For reviewers To verify this pr, you can use the `scripts/get_owners_for_file.js` script E.g: ``` node scripts/get_owners_for_file.js --file x-pack/test/functional/es_archives/entity//risks # Or any other file ``` Also, delete `x-pack/test/functional/es_archives/entity/user_risk` as `CMD+SHIFT+F` on my MAC in VS Code, resolved zero uses. #### Notes All of these are a best guess effort. The more help from the dev teams, the more accurate this will be for reporting in the future. Contributes to: https://github.com/elastic/kibana/issues/192979 --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Elastic Machine Co-authored-by: Vitalii Dmyterko <92328789+vitaliidm@users.noreply.github.com> --- .github/CODEOWNERS | 2 + .../es_archives/entity/user_risk/data.json | 39 ------------------- .../entity/user_risk/mappings.json | 35 ----------------- 3 files changed, 2 insertions(+), 74 deletions(-) delete mode 100644 x-pack/test/functional/es_archives/entity/user_risk/data.json delete mode 100644 x-pack/test/functional/es_archives/entity/user_risk/mappings.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 241593811f941..bd0fa1bc13104 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1750,6 +1750,8 @@ x-pack/test/security_solution_cypress/cypress/tasks/expandable_flyout @elastic/ /x-pack/plugins/security_solution/common/api/detection_engine/signals_migration @elastic/security-detection-engine /x-pack/plugins/security_solution/common/cti @elastic/security-detection-engine /x-pack/plugins/security_solution/common/field_maps @elastic/security-detection-engine +/x-pack/test/functional/es_archives/entity/risks @elastic/security-detection-engine +/x-pack/test/functional/es_archives/entity/host_risk @elastic/security-detection-engine /x-pack/plugins/security_solution/public/sourcerer @elastic/security-threat-hunting-investigations /x-pack/plugins/security_solution/public/detection_engine/rule_creation @elastic/security-detection-engine diff --git a/x-pack/test/functional/es_archives/entity/user_risk/data.json b/x-pack/test/functional/es_archives/entity/user_risk/data.json deleted file mode 100644 index 39b403deddc69..0000000000000 --- a/x-pack/test/functional/es_archives/entity/user_risk/data.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "type": "doc", - "value": { - "index": "ml_user_risk_score_latest_default", - "id": "1", - "source": { - "user": { - "name": "root", - "risk": { - "calculated_score_norm": 11, - "calculated_level": "Low" - } - }, - "ingest_timestamp": "2022-08-15T16:32:16.142561766Z", - "@timestamp": "2022-08-12T14:45:36.171Z" - }, - "type": "_doc" - } -} - -{ - "type": "doc", - "value": { - "id": "2", - "index": "ml_user_risk_score_latest_default", - "source": { - "host": { - "name": "User name 1", - "risk": { - "calculated_score_norm": 20, - "calculated_level": "Low" - } - }, - "ingest_timestamp": "2022-08-15T16:32:16.142561766Z", - "@timestamp": "2022-08-12T14:45:36.171Z" - }, - "type": "_doc" - } -} diff --git a/x-pack/test/functional/es_archives/entity/user_risk/mappings.json b/x-pack/test/functional/es_archives/entity/user_risk/mappings.json deleted file mode 100644 index 22518c9c455fc..0000000000000 --- a/x-pack/test/functional/es_archives/entity/user_risk/mappings.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - - "type": "index", - "value": { - "index": "ml_user_risk_score_latest_default", - "mappings": { - "properties": { - "user": { - "properties": { - "name": { - "type": "keyword" - }, - "risk": { - "properties": { - "calculated_level": { - "type": "keyword" - }, - "calculated_score_norm": { - "type": "float" - } - } - } - } - } - } - }, - "settings": { - "index": { - "auto_expand_replicas": "0-1", - "number_of_replicas": "0", - "number_of_shards": "1" - } - } - } -} From c0bd82b30ca7e0fec99321412a37a2e37bc20970 Mon Sep 17 00:00:00 2001 From: Katerina Date: Tue, 15 Oct 2024 14:51:34 +0300 Subject: [PATCH 08/13] [Inventory][ECO] Show alerts for entities (#195250) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Show alerts related to entities close https://github.com/elastic/kibana/issues/194381 ### Checklist - change default sorting from last seen to alertsCount - when alertsCount is not available server side sorting fallbacks to last seen - [Change app route from /app/observability/inventory to /app/inventory](https://github.com/elastic/kibana/pull/195250/commits/57598d05fbc27b5ef1c2654508719e4bd8069879) (causing issue when importing observability plugin - refactoring: move columns into seperate file https://github.com/user-attachments/assets/ea3abc5a-0581-41e7-a174-6655a39c1133 ### How to test - run any synthtrace scenario ex`node scripts/synthtrace infra_hosts_with_apm_hosts.ts` - create a rule (SLO or apm) - click on the alert count --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Cauê Marcondes <55978943+cauemarcondes@users.noreply.github.com> --- .../array/join_by_key.test.ts | 224 ++++++++++++++++++ .../observability_utils/array/join_by_key.ts | 60 +++++ .../inventory/common/entities.ts | 18 +- ...parse_identity_field_values_to_kql.test.ts | 90 +++++++ .../parse_identity_field_values_to_kql.ts | 34 +++ .../inventory/kibana.jsonc | 1 + .../alerts_badge/alerts_badge.test.tsx | 86 +++++++ .../components/alerts_badge/alerts_badge.tsx | 49 ++++ .../components/entities_grid/grid_columns.tsx | 113 +++++++++ .../public/components/entities_grid/index.tsx | 101 ++------ .../entities_grid/mock/entities_mock.ts | 6 + .../components/search_bar/discover_button.tsx | 3 +- .../public/pages/inventory_page/index.tsx | 4 +- .../inventory/public/plugin.ts | 2 +- .../inventory/public/routes/config.tsx | 7 +- .../create_alerts_client.ts | 47 ++++ .../entities/get_group_by_terms_agg.test.ts | 65 +++++ .../routes/entities/get_group_by_terms_agg.ts | 26 ++ .../entities/get_identify_fields.test.ts | 64 +++++ .../get_identity_fields_per_entity_type.ts | 21 ++ .../routes/entities/get_latest_entities.ts | 17 +- .../entities/get_latest_entities_alerts.ts | 65 +++++ .../inventory/server/routes/entities/route.ts | 48 +++- .../inventory/server/types.ts | 6 + .../inventory/tsconfig.json | 4 + 25 files changed, 1056 insertions(+), 105 deletions(-) create mode 100644 x-pack/packages/observability/observability_utils/array/join_by_key.test.ts create mode 100644 x-pack/packages/observability/observability_utils/array/join_by_key.ts create mode 100644 x-pack/plugins/observability_solution/inventory/common/utils/parse_identity_field_values_to_kql.test.ts create mode 100644 x-pack/plugins/observability_solution/inventory/common/utils/parse_identity_field_values_to_kql.ts create mode 100644 x-pack/plugins/observability_solution/inventory/public/components/alerts_badge/alerts_badge.test.tsx create mode 100644 x-pack/plugins/observability_solution/inventory/public/components/alerts_badge/alerts_badge.tsx create mode 100644 x-pack/plugins/observability_solution/inventory/public/components/entities_grid/grid_columns.tsx create mode 100644 x-pack/plugins/observability_solution/inventory/server/lib/create_alerts_client.ts/create_alerts_client.ts create mode 100644 x-pack/plugins/observability_solution/inventory/server/routes/entities/get_group_by_terms_agg.test.ts create mode 100644 x-pack/plugins/observability_solution/inventory/server/routes/entities/get_group_by_terms_agg.ts create mode 100644 x-pack/plugins/observability_solution/inventory/server/routes/entities/get_identify_fields.test.ts create mode 100644 x-pack/plugins/observability_solution/inventory/server/routes/entities/get_identity_fields_per_entity_type.ts create mode 100644 x-pack/plugins/observability_solution/inventory/server/routes/entities/get_latest_entities_alerts.ts diff --git a/x-pack/packages/observability/observability_utils/array/join_by_key.test.ts b/x-pack/packages/observability/observability_utils/array/join_by_key.test.ts new file mode 100644 index 0000000000000..8e0fc6ad09479 --- /dev/null +++ b/x-pack/packages/observability/observability_utils/array/join_by_key.test.ts @@ -0,0 +1,224 @@ +/* + * 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 { joinByKey } from './join_by_key'; + +describe('joinByKey', () => { + it('joins by a string key', () => { + const joined = joinByKey( + [ + { + serviceName: 'opbeans-node', + avg: 10, + }, + { + serviceName: 'opbeans-node', + count: 12, + }, + { + serviceName: 'opbeans-java', + avg: 11, + }, + { + serviceName: 'opbeans-java', + p95: 18, + }, + ], + 'serviceName' + ); + + expect(joined.length).toBe(2); + + expect(joined).toEqual([ + { + serviceName: 'opbeans-node', + avg: 10, + count: 12, + }, + { + serviceName: 'opbeans-java', + avg: 11, + p95: 18, + }, + ]); + }); + + it('joins by a record key', () => { + const joined = joinByKey( + [ + { + key: { + serviceName: 'opbeans-node', + transactionName: '/api/opbeans-node', + }, + avg: 10, + }, + { + key: { + serviceName: 'opbeans-node', + transactionName: '/api/opbeans-node', + }, + count: 12, + }, + { + key: { + serviceName: 'opbeans-java', + transactionName: '/api/opbeans-java', + }, + avg: 11, + }, + { + key: { + serviceName: 'opbeans-java', + transactionName: '/api/opbeans-java', + }, + p95: 18, + }, + ], + 'key' + ); + + expect(joined.length).toBe(2); + + expect(joined).toEqual([ + { + key: { + serviceName: 'opbeans-node', + transactionName: '/api/opbeans-node', + }, + avg: 10, + count: 12, + }, + { + key: { + serviceName: 'opbeans-java', + transactionName: '/api/opbeans-java', + }, + avg: 11, + p95: 18, + }, + ]); + }); + + it('joins by multiple keys', () => { + const data = [ + { + serviceName: 'opbeans-node', + environment: 'production', + type: 'service', + }, + { + serviceName: 'opbeans-node', + environment: 'stage', + type: 'service', + }, + { + serviceName: 'opbeans-node', + hostName: 'host-1', + }, + { + containerId: 'containerId', + }, + ]; + + const alerts = [ + { + serviceName: 'opbeans-node', + environment: 'production', + type: 'service', + alertCount: 10, + }, + { + containerId: 'containerId', + alertCount: 1, + }, + { + hostName: 'host-1', + environment: 'production', + alertCount: 5, + }, + ]; + + const joined = joinByKey( + [...data, ...alerts], + ['serviceName', 'environment', 'hostName', 'containerId'] + ); + + expect(joined.length).toBe(5); + + expect(joined).toEqual([ + { environment: 'stage', serviceName: 'opbeans-node', type: 'service' }, + { hostName: 'host-1', serviceName: 'opbeans-node' }, + { alertCount: 10, environment: 'production', serviceName: 'opbeans-node', type: 'service' }, + { alertCount: 1, containerId: 'containerId' }, + { alertCount: 5, environment: 'production', hostName: 'host-1' }, + ]); + }); + + it('uses the custom merge fn to replace items', () => { + const joined = joinByKey( + [ + { + serviceName: 'opbeans-java', + values: ['a'], + }, + { + serviceName: 'opbeans-node', + values: ['a'], + }, + { + serviceName: 'opbeans-node', + values: ['b'], + }, + { + serviceName: 'opbeans-node', + values: ['c'], + }, + ], + 'serviceName', + (a, b) => ({ + ...a, + ...b, + values: a.values.concat(b.values), + }) + ); + + expect(joined.find((item) => item.serviceName === 'opbeans-node')?.values).toEqual([ + 'a', + 'b', + 'c', + ]); + }); + + it('deeply merges objects', () => { + const joined = joinByKey( + [ + { + serviceName: 'opbeans-node', + properties: { + foo: '', + }, + }, + { + serviceName: 'opbeans-node', + properties: { + bar: '', + }, + }, + ], + 'serviceName' + ); + + expect(joined[0]).toEqual({ + serviceName: 'opbeans-node', + properties: { + foo: '', + bar: '', + }, + }); + }); +}); diff --git a/x-pack/packages/observability/observability_utils/array/join_by_key.ts b/x-pack/packages/observability/observability_utils/array/join_by_key.ts new file mode 100644 index 0000000000000..54e8ecdaf409b --- /dev/null +++ b/x-pack/packages/observability/observability_utils/array/join_by_key.ts @@ -0,0 +1,60 @@ +/* + * 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 { UnionToIntersection, ValuesType } from 'utility-types'; +import { merge, castArray } from 'lodash'; +import stableStringify from 'json-stable-stringify'; + +export type JoinedReturnType< + T extends Record, + U extends UnionToIntersection +> = Array< + Partial & { + [k in keyof T]: T[k]; + } +>; + +type ArrayOrSingle = T | T[]; + +export function joinByKey< + T extends Record, + U extends UnionToIntersection, + V extends ArrayOrSingle +>(items: T[], key: V): JoinedReturnType; + +export function joinByKey< + T extends Record, + U extends UnionToIntersection, + V extends ArrayOrSingle, + W extends JoinedReturnType, + X extends (a: T, b: T) => ValuesType +>(items: T[], key: V, mergeFn: X): W; + +export function joinByKey( + items: Array>, + key: string | string[], + mergeFn: Function = (a: Record, b: Record) => merge({}, a, b) +) { + const keys = castArray(key); + // Create a map to quickly query the key of group. + const map = new Map(); + items.forEach((current) => { + // The key of the map is a stable JSON string of the values from given keys. + // We need stable JSON string to support plain object values. + const stableKey = stableStringify(keys.map((k) => current[k])); + + if (map.has(stableKey)) { + const item = map.get(stableKey); + // delete and set the key to put it last + map.delete(stableKey); + map.set(stableKey, mergeFn(item, current)); + } else { + map.set(stableKey, { ...current }); + } + }); + return [...map.values()]; +} diff --git a/x-pack/plugins/observability_solution/inventory/common/entities.ts b/x-pack/plugins/observability_solution/inventory/common/entities.ts index 40fae48cb9dc3..7df71559aa97a 100644 --- a/x-pack/plugins/observability_solution/inventory/common/entities.ts +++ b/x-pack/plugins/observability_solution/inventory/common/entities.ts @@ -6,6 +6,9 @@ */ import { ENTITY_LATEST, entitiesAliasPattern } from '@kbn/entities-schema'; import { + HOST_NAME, + SERVICE_ENVIRONMENT, + SERVICE_NAME, AGENT_NAME, CLOUD_PROVIDER, CONTAINER_ID, @@ -15,9 +18,6 @@ import { ENTITY_IDENTITY_FIELDS, ENTITY_LAST_SEEN, ENTITY_TYPE, - HOST_NAME, - SERVICE_ENVIRONMENT, - SERVICE_NAME, } from '@kbn/observability-shared-plugin/common'; import { isRight } from 'fp-ts/lib/Either'; import * as t from 'io-ts'; @@ -28,8 +28,19 @@ export const entityTypeRt = t.union([ t.literal('container'), ]); +export const entityColumnIdsRt = t.union([ + t.literal(ENTITY_DISPLAY_NAME), + t.literal(ENTITY_LAST_SEEN), + t.literal(ENTITY_TYPE), + t.literal('alertsCount'), +]); + +export type EntityColumnIds = t.TypeOf; + export type EntityType = t.TypeOf; +export const defaultEntitySortField: EntityColumnIds = 'alertsCount'; + export const MAX_NUMBER_OF_ENTITIES = 500; export const ENTITIES_LATEST_ALIAS = entitiesAliasPattern({ @@ -79,6 +90,7 @@ interface BaseEntity { [ENTITY_DISPLAY_NAME]: string; [ENTITY_DEFINITION_ID]: string; [ENTITY_IDENTITY_FIELDS]: string | string[]; + alertsCount?: number; [key: string]: any; } diff --git a/x-pack/plugins/observability_solution/inventory/common/utils/parse_identity_field_values_to_kql.test.ts b/x-pack/plugins/observability_solution/inventory/common/utils/parse_identity_field_values_to_kql.test.ts new file mode 100644 index 0000000000000..c4b48410456f8 --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/common/utils/parse_identity_field_values_to_kql.test.ts @@ -0,0 +1,90 @@ +/* + * 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 { + ENTITY_DEFINITION_ID, + ENTITY_DISPLAY_NAME, + ENTITY_ID, + ENTITY_LAST_SEEN, +} from '@kbn/observability-shared-plugin/common'; +import { HostEntity, ServiceEntity } from '../entities'; +import { parseIdentityFieldValuesToKql } from './parse_identity_field_values_to_kql'; + +const commonEntityFields = { + [ENTITY_LAST_SEEN]: '2023-10-09T00:00:00Z', + [ENTITY_ID]: '1', + [ENTITY_DISPLAY_NAME]: 'entity_name', + [ENTITY_DEFINITION_ID]: 'entity_definition_id', + alertCount: 3, +}; + +describe('parseIdentityFieldValuesToKql', () => { + it('should return the value when identityFields is a single string', () => { + const entity: ServiceEntity = { + 'agent.name': 'node', + 'entity.identityFields': 'service.name', + 'service.name': 'my-service', + 'entity.type': 'service', + ...commonEntityFields, + }; + + const result = parseIdentityFieldValuesToKql({ entity }); + expect(result).toEqual('service.name: "my-service"'); + }); + + it('should return values when identityFields is an array of strings', () => { + const entity: ServiceEntity = { + 'agent.name': 'node', + 'entity.identityFields': ['service.name', 'service.environment'], + 'service.name': 'my-service', + 'entity.type': 'service', + 'service.environment': 'staging', + ...commonEntityFields, + }; + + const result = parseIdentityFieldValuesToKql({ entity }); + expect(result).toEqual('service.name: "my-service" AND service.environment: "staging"'); + }); + + it('should return an empty string if identityFields is empty string', () => { + const entity: ServiceEntity = { + 'agent.name': 'node', + 'entity.identityFields': '', + 'service.name': 'my-service', + 'entity.type': 'service', + ...commonEntityFields, + }; + + const result = parseIdentityFieldValuesToKql({ entity }); + expect(result).toEqual(''); + }); + it('should return an empty array if identityFields is empty array', () => { + const entity: ServiceEntity = { + 'agent.name': 'node', + 'entity.identityFields': [], + 'service.name': 'my-service', + 'entity.type': 'service', + ...commonEntityFields, + }; + + const result = parseIdentityFieldValuesToKql({ entity }); + expect(result).toEqual(''); + }); + + it('should ignore fields that are not present in the entity', () => { + const entity: HostEntity = { + 'entity.identityFields': ['host.name', 'foo.bar'], + 'host.name': 'my-host', + 'entity.type': 'host', + 'cloud.provider': null, + ...commonEntityFields, + }; + + const result = parseIdentityFieldValuesToKql({ entity }); + expect(result).toEqual('host.name: "my-host"'); + }); +}); diff --git a/x-pack/plugins/observability_solution/inventory/common/utils/parse_identity_field_values_to_kql.ts b/x-pack/plugins/observability_solution/inventory/common/utils/parse_identity_field_values_to_kql.ts new file mode 100644 index 0000000000000..2e3f3dadd4109 --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/common/utils/parse_identity_field_values_to_kql.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 { ENTITY_IDENTITY_FIELDS } from '@kbn/observability-shared-plugin/common'; +import { Entity } from '../entities'; + +type Operator = 'AND'; +export function parseIdentityFieldValuesToKql({ + entity, + operator = 'AND', +}: { + entity: Entity; + operator?: Operator; +}) { + const mapping: string[] = []; + + const identityFields = entity[ENTITY_IDENTITY_FIELDS]; + + if (identityFields) { + const fields = [identityFields].flat(); + + fields.forEach((field) => { + if (field in entity) { + mapping.push(`${[field]}: "${entity[field as keyof Entity]}"`); + } + }); + } + + return mapping.join(` ${operator} `); +} diff --git a/x-pack/plugins/observability_solution/inventory/kibana.jsonc b/x-pack/plugins/observability_solution/inventory/kibana.jsonc index 1467d294a4f49..fc77163ae3c5f 100644 --- a/x-pack/plugins/observability_solution/inventory/kibana.jsonc +++ b/x-pack/plugins/observability_solution/inventory/kibana.jsonc @@ -16,6 +16,7 @@ "features", "unifiedSearch", "data", + "ruleRegistry", "share" ], "requiredBundles": ["kibanaReact"], diff --git a/x-pack/plugins/observability_solution/inventory/public/components/alerts_badge/alerts_badge.test.tsx b/x-pack/plugins/observability_solution/inventory/public/components/alerts_badge/alerts_badge.test.tsx new file mode 100644 index 0000000000000..c60490c8a12b1 --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/components/alerts_badge/alerts_badge.test.tsx @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { type KibanaReactContextValue } from '@kbn/kibana-react-plugin/public'; +import { render, screen } from '@testing-library/react'; +import { AlertsBadge } from './alerts_badge'; +import * as useKibana from '../../hooks/use_kibana'; +import { HostEntity, ServiceEntity } from '../../../common/entities'; + +describe('AlertsBadge', () => { + jest.spyOn(useKibana, 'useKibana').mockReturnValue({ + services: { + http: { + basePath: { + prepend: (path: string) => path, + }, + }, + }, + } as unknown as KibanaReactContextValue); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('render alerts badge for a host entity', () => { + const entity: HostEntity = { + 'entity.lastSeenTimestamp': 'foo', + 'entity.id': '1', + 'entity.type': 'host', + 'entity.displayName': 'foo', + 'entity.identityFields': 'host.name', + 'host.name': 'foo', + 'entity.definitionId': 'host', + 'cloud.provider': null, + alertsCount: 1, + }; + render(); + expect(screen.queryByTestId('inventoryAlertsBadgeLink')?.getAttribute('href')).toEqual( + '/app/observability/alerts?_a=(kuery:\'host.name: "foo"\',status:active)' + ); + expect(screen.queryByTestId('inventoryAlertsBadgeLink')?.textContent).toEqual('1'); + }); + it('render alerts badge for a service entity', () => { + const entity: ServiceEntity = { + 'entity.lastSeenTimestamp': 'foo', + 'agent.name': 'node', + 'entity.id': '1', + 'entity.type': 'service', + 'entity.displayName': 'foo', + 'entity.identityFields': 'service.name', + 'service.name': 'bar', + 'entity.definitionId': 'host', + 'cloud.provider': null, + alertsCount: 5, + }; + render(); + expect(screen.queryByTestId('inventoryAlertsBadgeLink')?.getAttribute('href')).toEqual( + '/app/observability/alerts?_a=(kuery:\'service.name: "bar"\',status:active)' + ); + expect(screen.queryByTestId('inventoryAlertsBadgeLink')?.textContent).toEqual('5'); + }); + it('render alerts badge for a service entity with multiple identity fields', () => { + const entity: ServiceEntity = { + 'entity.lastSeenTimestamp': 'foo', + 'agent.name': 'node', + 'entity.id': '1', + 'entity.type': 'service', + 'entity.displayName': 'foo', + 'entity.identityFields': ['service.name', 'service.environment'], + 'service.name': 'bar', + 'service.environment': 'prod', + 'entity.definitionId': 'host', + 'cloud.provider': null, + alertsCount: 2, + }; + render(); + expect(screen.queryByTestId('inventoryAlertsBadgeLink')?.getAttribute('href')).toEqual( + '/app/observability/alerts?_a=(kuery:\'service.name: "bar" AND service.environment: "prod"\',status:active)' + ); + expect(screen.queryByTestId('inventoryAlertsBadgeLink')?.textContent).toEqual('2'); + }); +}); diff --git a/x-pack/plugins/observability_solution/inventory/public/components/alerts_badge/alerts_badge.tsx b/x-pack/plugins/observability_solution/inventory/public/components/alerts_badge/alerts_badge.tsx new file mode 100644 index 0000000000000..ba1b992ff62c1 --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/components/alerts_badge/alerts_badge.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import rison from '@kbn/rison'; +import { EuiBadge, EuiToolTip } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { Entity } from '../../../common/entities'; +import { useKibana } from '../../hooks/use_kibana'; +import { parseIdentityFieldValuesToKql } from '../../../common/utils/parse_identity_field_values_to_kql'; + +export function AlertsBadge({ entity }: { entity: Entity }) { + const { + services: { + http: { basePath }, + }, + } = useKibana(); + + const activeAlertsHref = basePath.prepend( + `/app/observability/alerts?_a=${rison.encode({ + kuery: parseIdentityFieldValuesToKql({ entity }), + status: 'active', + })}` + ); + return ( + + + {entity.alertsCount} + + + ); +} diff --git a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/grid_columns.tsx b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/grid_columns.tsx new file mode 100644 index 0000000000000..96fb8b3736ead --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/grid_columns.tsx @@ -0,0 +1,113 @@ +/* + * 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 { EuiButtonIcon, EuiDataGridColumn, EuiToolTip } from '@elastic/eui'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { + ENTITY_DISPLAY_NAME, + ENTITY_LAST_SEEN, + ENTITY_TYPE, +} from '@kbn/observability-shared-plugin/common'; + +const alertsLabel = i18n.translate('xpack.inventory.entitiesGrid.euiDataGrid.alertsLabel', { + defaultMessage: 'Alerts', +}); + +const alertsTooltip = i18n.translate('xpack.inventory.entitiesGrid.euiDataGrid.alertsTooltip', { + defaultMessage: 'The count of the active alerts', +}); + +const entityNameLabel = i18n.translate('xpack.inventory.entitiesGrid.euiDataGrid.entityNameLabel', { + defaultMessage: 'Entity name', +}); +const entityNameTooltip = i18n.translate( + 'xpack.inventory.entitiesGrid.euiDataGrid.entityNameTooltip', + { + defaultMessage: 'Name of the entity (entity.displayName)', + } +); + +const entityTypeLabel = i18n.translate('xpack.inventory.entitiesGrid.euiDataGrid.typeLabel', { + defaultMessage: 'Type', +}); +const entityTypeTooltip = i18n.translate('xpack.inventory.entitiesGrid.euiDataGrid.typeTooltip', { + defaultMessage: 'Type of entity (entity.type)', +}); + +const entityLastSeenLabel = i18n.translate( + 'xpack.inventory.entitiesGrid.euiDataGrid.lastSeenLabel', + { + defaultMessage: 'Last seen', + } +); +const entityLastSeenToolip = i18n.translate( + 'xpack.inventory.entitiesGrid.euiDataGrid.lastSeenTooltip', + { + defaultMessage: 'Timestamp of last received data for entity (entity.lastSeenTimestamp)', + } +); + +const CustomHeaderCell = ({ title, tooltipContent }: { title: string; tooltipContent: string }) => ( + <> + {title} + + + + +); + +export const getColumns = ({ + showAlertsColumn, +}: { + showAlertsColumn: boolean; +}): EuiDataGridColumn[] => { + return [ + ...(showAlertsColumn + ? [ + { + id: 'alertsCount', + displayAsText: alertsLabel, + isSortable: true, + display: , + initialWidth: 100, + schema: 'numeric', + }, + ] + : []), + { + id: ENTITY_DISPLAY_NAME, + // keep it for accessibility purposes + displayAsText: entityNameLabel, + display: , + isSortable: true, + }, + { + id: ENTITY_TYPE, + // keep it for accessibility purposes + displayAsText: entityTypeLabel, + display: , + isSortable: true, + }, + { + id: ENTITY_LAST_SEEN, + // keep it for accessibility purposes + displayAsText: entityLastSeenLabel, + display: ( + + ), + defaultSortDirection: 'desc', + isSortable: true, + schema: 'datetime', + }, + ]; +}; diff --git a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx index 8bdfa0d46627c..697bc3304753e 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/index.tsx @@ -5,103 +5,32 @@ * 2.0. */ import { - EuiButtonIcon, EuiDataGrid, EuiDataGridCellValueElementProps, - EuiDataGridColumn, EuiDataGridSorting, EuiLoadingSpinner, EuiText, - EuiToolTip, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedDate, FormattedMessage, FormattedTime } from '@kbn/i18n-react'; import { last } from 'lodash'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { ENTITY_DISPLAY_NAME, ENTITY_LAST_SEEN, ENTITY_TYPE, } from '@kbn/observability-shared-plugin/common'; +import { EntityColumnIds, EntityType } from '../../../common/entities'; import { APIReturnType } from '../../api'; import { BadgeFilterWithPopover } from '../badge_filter_with_popover'; +import { getColumns } from './grid_columns'; +import { AlertsBadge } from '../alerts_badge/alerts_badge'; import { EntityName } from './entity_name'; -import { EntityType } from '../../../common/entities'; import { getEntityTypeLabel } from '../../utils/get_entity_type_label'; type InventoryEntitiesAPIReturnType = APIReturnType<'GET /internal/inventory/entities'>; type LatestEntities = InventoryEntitiesAPIReturnType['entities']; -export type EntityColumnIds = - | typeof ENTITY_DISPLAY_NAME - | typeof ENTITY_LAST_SEEN - | typeof ENTITY_TYPE; - -const CustomHeaderCell = ({ title, tooltipContent }: { title: string; tooltipContent: string }) => ( - <> - {title} - - - - -); - -const entityNameLabel = i18n.translate('xpack.inventory.entitiesGrid.euiDataGrid.entityNameLabel', { - defaultMessage: 'Entity name', -}); -const entityTypeLabel = i18n.translate('xpack.inventory.entitiesGrid.euiDataGrid.typeLabel', { - defaultMessage: 'Type', -}); -const entityLastSeenLabel = i18n.translate( - 'xpack.inventory.entitiesGrid.euiDataGrid.lastSeenLabel', - { - defaultMessage: 'Last seen', - } -); - -const columns: EuiDataGridColumn[] = [ - { - id: ENTITY_DISPLAY_NAME, - // keep it for accessibility purposes - displayAsText: entityNameLabel, - display: ( - - ), - isSortable: true, - }, - { - id: ENTITY_TYPE, - // keep it for accessibility purposes - displayAsText: entityTypeLabel, - display: ( - - ), - isSortable: true, - }, - { - id: ENTITY_LAST_SEEN, - // keep it for accessibility purposes - displayAsText: entityLastSeenLabel, - display: ( - - ), - defaultSortDirection: 'desc', - isSortable: true, - schema: 'datetime', - }, -]; - interface Props { loading: boolean; entities: LatestEntities; @@ -125,8 +54,6 @@ export function EntitiesGrid({ onChangeSort, onFilterByType, }: Props) { - const [visibleColumns, setVisibleColumns] = useState(columns.map(({ id }) => id)); - const onSort: EuiDataGridSorting['onSort'] = useCallback( (newSortingColumns) => { const lastItem = last(newSortingColumns); @@ -137,6 +64,19 @@ export function EntitiesGrid({ [onChangeSort] ); + const showAlertsColumn = useMemo( + () => entities?.some((entity) => entity?.alertsCount && entity?.alertsCount > 0), + [entities] + ); + + const columnVisibility = useMemo( + () => ({ + visibleColumns: getColumns({ showAlertsColumn }).map(({ id }) => id), + setVisibleColumns: () => {}, + }), + [showAlertsColumn] + ); + const renderCellValue = useCallback( ({ rowIndex, columnId }: EuiDataGridCellValueElementProps) => { const entity = entities[rowIndex]; @@ -146,6 +86,9 @@ export function EntitiesGrid({ const columnEntityTableId = columnId as EntityColumnIds; switch (columnEntityTableId) { + case 'alertsCount': + return entity?.alertsCount ? : null; + case ENTITY_TYPE: const entityType = entity[columnEntityTableId]; return ( @@ -203,8 +146,8 @@ export function EntitiesGrid({ 'xpack.inventory.entitiesGrid.euiDataGrid.inventoryEntitiesGridLabel', { defaultMessage: 'Inventory entities grid' } )} - columns={columns} - columnVisibility={{ visibleColumns, setVisibleColumns }} + columns={getColumns({ showAlertsColumn })} + columnVisibility={columnVisibility} rowCount={entities.length} renderCellValue={renderCellValue} gridStyle={{ border: 'horizontal', header: 'shade' }} diff --git a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/mock/entities_mock.ts b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/mock/entities_mock.ts index 10ba7fbe4119e..bf72d5d7832cf 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/mock/entities_mock.ts +++ b/x-pack/plugins/observability_solution/inventory/public/components/entities_grid/mock/entities_mock.ts @@ -15,24 +15,29 @@ export const entitiesMock = [ 'entity.type': 'host', 'entity.displayName': 'Spider-Man', 'entity.id': '0', + alertsCount: 3, }, { 'entity.lastSeenTimestamp': '2024-06-16T21:48:16.259Z', 'entity.type': 'service', 'entity.displayName': 'Iron Man', 'entity.id': '1', + alertsCount: 3, }, + { 'entity.lastSeenTimestamp': '2024-04-28T03:31:57.528Z', 'entity.type': 'host', 'entity.displayName': 'Captain America', 'entity.id': '2', + alertsCount: 10, }, { 'entity.lastSeenTimestamp': '2024-05-14T11:32:04.275Z', 'entity.type': 'host', 'entity.displayName': 'Hulk', 'entity.id': '3', + alertsCount: 1, }, { 'entity.lastSeenTimestamp': '2023-12-05T13:33:54.028Z', @@ -1630,6 +1635,7 @@ export const entitiesMock = [ 'entity.displayName': 'Sed dignissim libero a diam sagittis, in convallis leo pellentesque. Cras ut sapien sed lacus scelerisque vehicula. Pellentesque at purus pulvinar, mollis justo hendrerit, pharetra purus. Morbi dapibus, augue et volutpat ultricies, neque quam sollicitudin mauris, vitae luctus ex libero id erat. Suspendisse risus lectus, scelerisque vel odio sed.', 'entity.id': '269', + alertsCount: 4, }, { 'entity.lastSeenTimestamp': '2023-10-22T13:49:53.092Z', diff --git a/x-pack/plugins/observability_solution/inventory/public/components/search_bar/discover_button.tsx b/x-pack/plugins/observability_solution/inventory/public/components/search_bar/discover_button.tsx index 90b6213da84a4..ee3014e990b0b 100644 --- a/x-pack/plugins/observability_solution/inventory/public/components/search_bar/discover_button.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/components/search_bar/discover_button.tsx @@ -17,10 +17,9 @@ import { ENTITY_LAST_SEEN, ENTITY_TYPE, } from '@kbn/observability-shared-plugin/common'; -import { defaultEntityDefinitions } from '../../../common/entities'; +import { defaultEntityDefinitions, EntityColumnIds } from '../../../common/entities'; import { useInventoryParams } from '../../hooks/use_inventory_params'; import { useKibana } from '../../hooks/use_kibana'; -import { EntityColumnIds } from '../entities_grid'; const ACTIVE_COLUMNS: EntityColumnIds[] = [ENTITY_DISPLAY_NAME, ENTITY_TYPE, ENTITY_LAST_SEEN]; diff --git a/x-pack/plugins/observability_solution/inventory/public/pages/inventory_page/index.tsx b/x-pack/plugins/observability_solution/inventory/public/pages/inventory_page/index.tsx index 7af9a9fc21acc..965434eeac6d1 100644 --- a/x-pack/plugins/observability_solution/inventory/public/pages/inventory_page/index.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/pages/inventory_page/index.tsx @@ -7,7 +7,7 @@ import { EuiDataGridSorting } from '@elastic/eui'; import React from 'react'; import useEffectOnce from 'react-use/lib/useEffectOnce'; -import { EntityType } from '../../../common/entities'; +import { EntityColumnIds, EntityType } from '../../../common/entities'; import { EntitiesGrid } from '../../components/entities_grid'; import { useInventorySearchBarContext } from '../../context/inventory_search_bar_context_provider'; import { useInventoryAbortableAsync } from '../../hooks/use_inventory_abortable_async'; @@ -76,7 +76,7 @@ export function InventoryPage() { path: {}, query: { ...query, - sortField: sorting.id, + sortField: sorting.id as EntityColumnIds, sortDirection: sorting.direction, }, }); diff --git a/x-pack/plugins/observability_solution/inventory/public/plugin.ts b/x-pack/plugins/observability_solution/inventory/public/plugin.ts index 4567e8f34a94a..b6771d2f95550 100644 --- a/x-pack/plugins/observability_solution/inventory/public/plugin.ts +++ b/x-pack/plugins/observability_solution/inventory/public/plugin.ts @@ -117,7 +117,7 @@ export class InventoryPlugin defaultMessage: 'Inventory', }), euiIconType: 'logoObservability', - appRoute: '/app/observability/inventory', + appRoute: '/app/inventory', category: DEFAULT_APP_CATEGORIES.observability, visibleIn: ['sideNav', 'globalSearch'], order: 8200, diff --git a/x-pack/plugins/observability_solution/inventory/public/routes/config.tsx b/x-pack/plugins/observability_solution/inventory/public/routes/config.tsx index d67a7250f75a5..dc7ba13451e02 100644 --- a/x-pack/plugins/observability_solution/inventory/public/routes/config.tsx +++ b/x-pack/plugins/observability_solution/inventory/public/routes/config.tsx @@ -8,10 +8,9 @@ import { toNumberRt } from '@kbn/io-ts-utils'; import { Outlet, createRouter } from '@kbn/typed-react-router-config'; import * as t from 'io-ts'; import React from 'react'; -import { ENTITY_LAST_SEEN } from '@kbn/observability-shared-plugin/common'; import { InventoryPageTemplate } from '../components/inventory_page_template'; import { InventoryPage } from '../pages/inventory_page'; -import { entityTypesRt } from '../../common/entities'; +import { defaultEntitySortField, entityTypesRt, entityColumnIdsRt } from '../../common/entities'; /** * The array of route definitions to be used when the application @@ -27,7 +26,7 @@ const inventoryRoutes = { params: t.type({ query: t.intersection([ t.type({ - sortField: t.string, + sortField: entityColumnIdsRt, sortDirection: t.union([t.literal('asc'), t.literal('desc')]), pageIndex: toNumberRt, }), @@ -39,7 +38,7 @@ const inventoryRoutes = { }), defaults: { query: { - sortField: ENTITY_LAST_SEEN, + sortField: defaultEntitySortField, sortDirection: 'desc', pageIndex: '0', }, diff --git a/x-pack/plugins/observability_solution/inventory/server/lib/create_alerts_client.ts/create_alerts_client.ts b/x-pack/plugins/observability_solution/inventory/server/lib/create_alerts_client.ts/create_alerts_client.ts new file mode 100644 index 0000000000000..150e946fd98d6 --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/server/lib/create_alerts_client.ts/create_alerts_client.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 { isEmpty } from 'lodash'; +import { ESSearchRequest, InferSearchResponseOf } from '@kbn/es-types'; +import { ParsedTechnicalFields } from '@kbn/rule-registry-plugin/common'; +import { InventoryRouteHandlerResources } from '../../routes/types'; + +export type AlertsClient = Awaited>; + +export async function createAlertsClient({ + plugins, + request, +}: Pick) { + const ruleRegistryPluginStart = await plugins.ruleRegistry.start(); + const alertsClient = await ruleRegistryPluginStart.getRacClientWithRequest(request); + const alertsIndices = await alertsClient.getAuthorizedAlertsIndices([ + 'logs', + 'infrastructure', + 'apm', + 'slo', + 'observability', + ]); + + if (!alertsIndices || isEmpty(alertsIndices)) { + throw Error('No alert indices exist'); + } + type RequiredParams = ESSearchRequest & { + size: number; + track_total_hits: boolean | number; + }; + + return { + search( + searchParams: TParams + ): Promise> { + return alertsClient.find({ + ...searchParams, + index: alertsIndices.join(','), + }) as Promise; + }, + }; +} diff --git a/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_group_by_terms_agg.test.ts b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_group_by_terms_agg.test.ts new file mode 100644 index 0000000000000..03027430116e6 --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_group_by_terms_agg.test.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getGroupByTermsAgg } from './get_group_by_terms_agg'; +import { IdentityFieldsPerEntityType } from './get_identity_fields_per_entity_type'; + +describe('getGroupByTermsAgg', () => { + it('should return an empty object when fields is empty', () => { + const fields: IdentityFieldsPerEntityType = new Map(); + const result = getGroupByTermsAgg(fields); + expect(result).toEqual({}); + }); + + it('should correctly generate aggregation structure for service, host, and container entity types', () => { + const fields: IdentityFieldsPerEntityType = new Map([ + ['service', ['service.name', 'service.environment']], + ['host', ['host.name']], + ['container', ['container.id', 'foo.bar']], + ]); + + const result = getGroupByTermsAgg(fields); + + expect(result).toEqual({ + service: { + composite: { + size: 500, + sources: [ + { 'service.name': { terms: { field: 'service.name' } } }, + { 'service.environment': { terms: { field: 'service.environment' } } }, + ], + }, + }, + host: { + composite: { + size: 500, + sources: [{ 'host.name': { terms: { field: 'host.name' } } }], + }, + }, + container: { + composite: { + size: 500, + sources: [ + { + 'container.id': { + terms: { field: 'container.id' }, + }, + }, + { + 'foo.bar': { terms: { field: 'foo.bar' } }, + }, + ], + }, + }, + }); + }); + it('should override maxSize when provided', () => { + const fields: IdentityFieldsPerEntityType = new Map([['host', ['host.name']]]); + const result = getGroupByTermsAgg(fields, 10); + expect(result.host.composite.size).toBe(10); + }); +}); diff --git a/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_group_by_terms_agg.ts b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_group_by_terms_agg.ts new file mode 100644 index 0000000000000..96ab3eb24444a --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_group_by_terms_agg.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 { IdentityFieldsPerEntityType } from './get_identity_fields_per_entity_type'; + +export const getGroupByTermsAgg = (fields: IdentityFieldsPerEntityType, maxSize = 500) => { + return Array.from(fields).reduce((acc, [entityType, identityFields]) => { + acc[entityType] = { + composite: { + size: maxSize, + sources: identityFields.map((field) => ({ + [field]: { + terms: { + field, + }, + }, + })), + }, + }; + return acc; + }, {} as Record); +}; diff --git a/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_identify_fields.test.ts b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_identify_fields.test.ts new file mode 100644 index 0000000000000..90bf2967b894d --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_identify_fields.test.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ContainerEntity, HostEntity, ServiceEntity } from '../../../common/entities'; +import { + ENTITY_DEFINITION_ID, + ENTITY_DISPLAY_NAME, + ENTITY_ID, + ENTITY_LAST_SEEN, +} from '@kbn/observability-shared-plugin/common'; +import { getIdentityFieldsPerEntityType } from './get_identity_fields_per_entity_type'; + +const commonEntityFields = { + [ENTITY_LAST_SEEN]: '2023-10-09T00:00:00Z', + [ENTITY_ID]: '1', + [ENTITY_DISPLAY_NAME]: 'entity_name', + [ENTITY_DEFINITION_ID]: 'entity_definition_id', + alertCount: 3, +}; +describe('getIdentityFields', () => { + it('should return an empty Map when no entities are provided', () => { + const result = getIdentityFieldsPerEntityType([]); + expect(result.size).toBe(0); + }); + it('should return a Map with unique entity types and their respective identity fields', () => { + const serviceEntity: ServiceEntity = { + 'agent.name': 'node', + 'entity.identityFields': ['service.name', 'service.environment'], + 'service.name': 'my-service', + 'entity.type': 'service', + ...commonEntityFields, + }; + + const hostEntity: HostEntity = { + 'entity.identityFields': ['host.name'], + 'host.name': 'my-host', + 'entity.type': 'host', + 'cloud.provider': null, + ...commonEntityFields, + }; + + const containerEntity: ContainerEntity = { + 'entity.identityFields': 'container.id', + 'host.name': 'my-host', + 'entity.type': 'container', + 'cloud.provider': null, + 'container.id': '123', + ...commonEntityFields, + }; + + const mockEntities = [serviceEntity, hostEntity, containerEntity]; + const result = getIdentityFieldsPerEntityType(mockEntities); + + expect(result.size).toBe(3); + + expect(result.get('service')).toEqual(['service.name', 'service.environment']); + expect(result.get('host')).toEqual(['host.name']); + expect(result.get('container')).toEqual(['container.id']); + }); +}); diff --git a/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_identity_fields_per_entity_type.ts b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_identity_fields_per_entity_type.ts new file mode 100644 index 0000000000000..0ca4eb9d21239 --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_identity_fields_per_entity_type.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 { ENTITY_IDENTITY_FIELDS, ENTITY_TYPE } from '@kbn/observability-shared-plugin/common'; +import { Entity, EntityType } from '../../../common/entities'; + +export type IdentityFieldsPerEntityType = Map; + +export const getIdentityFieldsPerEntityType = (entities: Entity[]) => { + const identityFieldsPerEntityType: IdentityFieldsPerEntityType = new Map(); + + entities.forEach((entity) => + identityFieldsPerEntityType.set(entity[ENTITY_TYPE], [entity[ENTITY_IDENTITY_FIELDS]].flat()) + ); + + return identityFieldsPerEntityType; +}; diff --git a/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_latest_entities.ts b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_latest_entities.ts index 853d52d8401a9..e500ce32c3cef 100644 --- a/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_latest_entities.ts +++ b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_latest_entities.ts @@ -8,11 +8,13 @@ import { type ObservabilityElasticsearchClient } from '@kbn/observability-utils/es/client/create_observability_es_client'; import { kqlQuery } from '@kbn/observability-utils/es/queries/kql_query'; import { esqlResultToPlainObjects } from '@kbn/observability-utils/es/utils/esql_result_to_plain_objects'; +import { ENTITY_LAST_SEEN } from '@kbn/observability-shared-plugin/common'; import { ENTITIES_LATEST_ALIAS, MAX_NUMBER_OF_ENTITIES, type EntityType, - Entity, + type Entity, + type EntityColumnIds, } from '../../../common/entities'; import { getEntityDefinitionIdWhereClause, getEntityTypesWhereClause } from './query_helper'; @@ -25,15 +27,18 @@ export async function getLatestEntities({ }: { inventoryEsClient: ObservabilityElasticsearchClient; sortDirection: 'asc' | 'desc'; - sortField: string; + sortField: EntityColumnIds; entityTypes?: EntityType[]; kuery?: string; }) { - const latestEntitiesEsqlResponse = await inventoryEsClient.esql('get_latest_entities', { + // alertsCount doesn't exist in entities index. Ignore it and sort by entity.lastSeenTimestamp by default. + const entitiesSortField = sortField === 'alertsCount' ? ENTITY_LAST_SEEN : sortField; + + const request = { query: `FROM ${ENTITIES_LATEST_ALIAS} | ${getEntityTypesWhereClause(entityTypes)} | ${getEntityDefinitionIdWhereClause()} - | SORT ${sortField} ${sortDirection} + | SORT ${entitiesSortField} ${sortDirection} | LIMIT ${MAX_NUMBER_OF_ENTITIES} `, filter: { @@ -41,7 +46,9 @@ export async function getLatestEntities({ filter: [...kqlQuery(kuery)], }, }, - }); + }; + + const latestEntitiesEsqlResponse = await inventoryEsClient.esql('get_latest_entities', request); return esqlResultToPlainObjects(latestEntitiesEsqlResponse); } diff --git a/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_latest_entities_alerts.ts b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_latest_entities_alerts.ts new file mode 100644 index 0000000000000..4e6ce545a079e --- /dev/null +++ b/x-pack/plugins/observability_solution/inventory/server/routes/entities/get_latest_entities_alerts.ts @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { kqlQuery, termQuery } from '@kbn/observability-plugin/server'; +import { ALERT_STATUS, ALERT_STATUS_ACTIVE } from '@kbn/rule-data-utils'; +import { AlertsClient } from '../../lib/create_alerts_client.ts/create_alerts_client'; +import { getGroupByTermsAgg } from './get_group_by_terms_agg'; +import { IdentityFieldsPerEntityType } from './get_identity_fields_per_entity_type'; +import { EntityType } from '../../../common/entities'; + +interface Bucket { + key: Record; + doc_count: number; +} + +type EntityTypeBucketsAggregation = Record; + +export async function getLatestEntitiesAlerts({ + alertsClient, + kuery, + identityFieldsPerEntityType, +}: { + alertsClient: AlertsClient; + kuery?: string; + identityFieldsPerEntityType: IdentityFieldsPerEntityType; +}): Promise> { + if (identityFieldsPerEntityType.size === 0) { + return []; + } + + const filter = { + size: 0, + track_total_hits: false, + query: { + bool: { + filter: [...termQuery(ALERT_STATUS, ALERT_STATUS_ACTIVE), ...kqlQuery(kuery)], + }, + }, + }; + + const response = await alertsClient.search({ + ...filter, + aggs: getGroupByTermsAgg(identityFieldsPerEntityType), + }); + + const aggregations = response.aggregations as EntityTypeBucketsAggregation; + + const alerts = Array.from(identityFieldsPerEntityType).flatMap(([entityType]) => { + const entityAggregation = aggregations?.[entityType]; + + const buckets = entityAggregation.buckets ?? []; + + return buckets.map((bucket: Bucket) => ({ + alertsCount: bucket.doc_count, + type: entityType, + ...bucket.key, + })); + }); + + return alerts; +} diff --git a/x-pack/plugins/observability_solution/inventory/server/routes/entities/route.ts b/x-pack/plugins/observability_solution/inventory/server/routes/entities/route.ts index beef1b068ed15..eb80f80d02730 100644 --- a/x-pack/plugins/observability_solution/inventory/server/routes/entities/route.ts +++ b/x-pack/plugins/observability_solution/inventory/server/routes/entities/route.ts @@ -8,10 +8,15 @@ import { INVENTORY_APP_ID } from '@kbn/deeplinks-observability/constants'; import { jsonRt } from '@kbn/io-ts-utils'; import { createObservabilityEsClient } from '@kbn/observability-utils/es/client/create_observability_es_client'; import * as t from 'io-ts'; -import { entityTypeRt } from '../../../common/entities'; +import { orderBy } from 'lodash'; +import { joinByKey } from '@kbn/observability-utils/array/join_by_key'; +import { entityTypeRt, entityColumnIdsRt, Entity } from '../../../common/entities'; import { createInventoryServerRoute } from '../create_inventory_server_route'; import { getEntityTypes } from './get_entity_types'; import { getLatestEntities } from './get_latest_entities'; +import { createAlertsClient } from '../../lib/create_alerts_client.ts/create_alerts_client'; +import { getLatestEntitiesAlerts } from './get_latest_entities_alerts'; +import { getIdentityFieldsPerEntityType } from './get_identity_fields_per_entity_type'; export const getEntityTypesRoute = createInventoryServerRoute({ endpoint: 'GET /internal/inventory/entities/types', @@ -36,7 +41,7 @@ export const listLatestEntitiesRoute = createInventoryServerRoute({ params: t.type({ query: t.intersection([ t.type({ - sortField: t.string, + sortField: entityColumnIdsRt, sortDirection: t.union([t.literal('asc'), t.literal('desc')]), }), t.partial({ @@ -48,7 +53,7 @@ export const listLatestEntitiesRoute = createInventoryServerRoute({ options: { tags: ['access:inventory'], }, - handler: async ({ params, context, logger }) => { + handler: async ({ params, context, logger, plugins, request }) => { const coreContext = await context.core; const inventoryEsClient = createObservabilityEsClient({ client: coreContext.elasticsearch.client.asCurrentUser, @@ -58,15 +63,40 @@ export const listLatestEntitiesRoute = createInventoryServerRoute({ const { sortDirection, sortField, entityTypes, kuery } = params.query; - const latestEntities = await getLatestEntities({ - inventoryEsClient, - sortDirection, - sortField, - entityTypes, + const [alertsClient, latestEntities] = await Promise.all([ + createAlertsClient({ plugins, request }), + getLatestEntities({ + inventoryEsClient, + sortDirection, + sortField, + entityTypes, + kuery, + }), + ]); + + const identityFieldsPerEntityType = getIdentityFieldsPerEntityType(latestEntities); + + const alerts = await getLatestEntitiesAlerts({ + identityFieldsPerEntityType, + alertsClient, kuery, }); - return { entities: latestEntities }; + const joined = joinByKey( + [...latestEntities, ...alerts], + [...identityFieldsPerEntityType.values()].flat() + ).filter((entity) => entity['entity.id']); + + return { + entities: + sortField === 'alertsCount' + ? orderBy( + joined, + [(item: Entity) => item?.alertsCount === undefined, sortField], + ['asc', sortDirection] // push entities without alertsCount to the end + ) + : joined, + }; }, }); diff --git a/x-pack/plugins/observability_solution/inventory/server/types.ts b/x-pack/plugins/observability_solution/inventory/server/types.ts index 05f75561674c6..d3d5ef0fb7f60 100644 --- a/x-pack/plugins/observability_solution/inventory/server/types.ts +++ b/x-pack/plugins/observability_solution/inventory/server/types.ts @@ -14,6 +14,10 @@ import type { DataViewsServerPluginStart, } from '@kbn/data-views-plugin/server'; import { FeaturesPluginSetup } from '@kbn/features-plugin/server'; +import { + RuleRegistryPluginStartContract, + RuleRegistryPluginSetupContract, +} from '@kbn/rule-registry-plugin/server'; /* eslint-disable @typescript-eslint/no-empty-interface*/ export interface ConfigSchema {} @@ -23,12 +27,14 @@ export interface InventorySetupDependencies { inference: InferenceServerSetup; dataViews: DataViewsServerPluginSetup; features: FeaturesPluginSetup; + ruleRegistry: RuleRegistryPluginSetupContract; } export interface InventoryStartDependencies { entityManager: EntityManagerServerPluginStart; inference: InferenceServerStart; dataViews: DataViewsServerPluginStart; + ruleRegistry: RuleRegistryPluginStartContract; } export interface InventoryServerSetup {} diff --git a/x-pack/plugins/observability_solution/inventory/tsconfig.json b/x-pack/plugins/observability_solution/inventory/tsconfig.json index 6492cd51d067a..c4c8f5d3ac59d 100644 --- a/x-pack/plugins/observability_solution/inventory/tsconfig.json +++ b/x-pack/plugins/observability_solution/inventory/tsconfig.json @@ -46,6 +46,10 @@ "@kbn/elastic-agent-utils", "@kbn/custom-icons", "@kbn/ui-theme", + "@kbn/rison", + "@kbn/rule-registry-plugin", + "@kbn/observability-plugin", + "@kbn/rule-data-utils", "@kbn/spaces-plugin", "@kbn/cloud-plugin" ] From 1055120d0f4640af67881b4909d4881681d9575d Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Tue, 15 Oct 2024 13:55:53 +0200 Subject: [PATCH 09/13] fix `no-restricted-imports` (#195456) ## Summary I noticed that our `no-restricted-imports` rules were not working on some parts of the codebase. Turns our the rule was overriden by mistake. This PR fixes the rules and places that were not following them: - lodash set for safety - react-use for a bit smaller bundles - router for context annoncement (`useExecutionContext`) and hopefully easier upgrade to newer version --- .eslintrc.js | 13 ++++++------- .github/CODEOWNERS | 2 ++ .../impl/assistant/index.test.tsx | 6 ++++-- .../quick_prompts/quick_prompts.test.tsx | 15 ++++++--------- .../assistant/quick_prompts/quick_prompts.tsx | 2 +- .../impl/assistant_context/index.test.tsx | 8 +++----- .../impl/assistant_context/index.tsx | 3 ++- .../cases/common/types/domain/user/v1.test.ts | 2 +- .../visualizations/open_lens_button.test.tsx | 2 +- .../connectors/cases/cases_oracle_service.test.ts | 3 ++- .../server/connectors/cases/cases_service.test.ts | 3 ++- .../server/services/user_actions/index.test.ts | 3 ++- .../user_actions/operations/create.test.ts | 3 ++- .../public/common/hooks/use_availability.ts | 2 +- .../create_integration/create_integration.tsx | 8 ++++---- .../common/alerting_callout/alerting_callout.tsx | 2 +- .../monitor_add_edit/form/controlled_field.tsx | 2 +- .../monitor_status/use_monitor_status_data.ts | 2 +- .../monitors_page/hooks/use_monitor_list.ts | 2 +- .../overview/grid_by_group/grid_group_item.tsx | 2 +- .../settings/global_params/params_list.tsx | 2 +- .../contexts/synthetics_refresh_context.tsx | 2 +- .../synthetics/hooks/use_breadcrumbs.test.tsx | 2 +- .../apps/synthetics/hooks/use_monitor_name.ts | 2 +- .../synthetics/utils/formatting/test_helpers.ts | 1 + .../endpoint_metadata_generator.ts | 3 ++- .../endpoint/models/policy_config_helpers.test.ts | 3 ++- .../endpoint/models/policy_config_helpers.ts | 3 ++- .../common/utils/expand_dotted.ts | 3 ++- .../cell_action/add_to_timeline.test.ts | 2 +- .../top_values_popover.test.tsx | 5 +---- .../top_values_popover/top_values_popover.tsx | 2 +- .../public/assistant/provider.tsx | 2 +- .../public/attack_discovery/pages/index.test.tsx | 15 +++++---------- .../public/attack_discovery/pages/index.tsx | 2 +- .../use_security_solution_navigation.tsx | 2 +- .../components/visualization_actions/actions.tsx | 2 +- .../lib/endpoint/utils/get_host_platform.test.ts | 2 +- .../public/common/mock/router.tsx | 1 + .../utils/global_query_string/helpers.test.tsx | 1 + .../related_integrations_help_info.tsx | 2 +- .../required_fields/required_fields_help_info.tsx | 2 +- .../comparison_side/comparison_side_help_info.tsx | 2 +- .../final_edit/fields/kql_query.tsx | 2 +- .../final_side/final_side_help_info.tsx | 2 +- .../add_prebuilt_rules_header_buttons.tsx | 2 +- .../add_prebuilt_rules_install_button.tsx | 2 +- .../asset_criticality_selector.tsx | 2 +- .../public/entity_analytics/routes.tsx | 11 +++++------ .../explore/network/pages/details/index.test.tsx | 3 ++- .../privileged_route/privileged_route.test.tsx | 1 + .../policy/use_fetch_endpoint_policy.test.ts | 2 +- .../components/advanced_section.test.tsx | 2 +- .../cards/attack_surface_reduction_card.test.tsx | 3 ++- .../cards/behaviour_protection_card.test.tsx | 2 +- .../cards/linux_event_collection_card.test.tsx | 2 +- .../cards/mac_event_collection_card.test.tsx | 2 +- .../cards/malware_protections_card.test.tsx | 3 ++- .../cards/memory_protection_card.test.tsx | 2 +- .../cards/ransomware_protection_card.test.tsx | 2 +- .../cards/windows_event_collection_card.test.tsx | 2 +- .../detect_prevent_protection_level.test.tsx | 3 ++- .../components/event_collection_card.test.tsx | 3 ++- .../components/event_collection_card.tsx | 3 ++- .../components/notify_user_option.test.tsx | 3 ++- .../protection_setting_card_switch.test.tsx | 3 ++- .../policy/view/policy_settings_form/mocks.ts | 2 +- .../policy_settings_layout.test.tsx | 3 ++- .../security_solution/public/notes/routes.tsx | 7 +++---- .../callouts/integration_card_top_callout.tsx | 2 +- .../onboarding_body/hooks/use_body_config.test.ts | 4 ++-- .../onboarding_body/hooks/use_body_config.ts | 2 +- .../cards/teammates_card/teammates_card.tsx | 2 +- .../public/onboarding/hooks/use_stored_state.ts | 2 +- .../timelines/store/middlewares/timeline_save.ts | 3 ++- .../routes/actions/response_actions.test.ts | 3 ++- .../lib/base_response_actions_client.test.ts | 2 +- .../factories/utils/traverse_and_mutate_doc.ts | 3 ++- .../server/lib/telemetry/helpers.ts | 3 ++- 79 files changed, 131 insertions(+), 117 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index e46dde5a3c56f..006f39ce1026c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1014,6 +1014,7 @@ module.exports = { 'error', { patterns: ['**/legacy_uptime/*'], + paths: RESTRICTED_IMPORTS, }, ], }, @@ -1055,6 +1056,7 @@ module.exports = { { // prevents UI code from importing server side code and then webpack including it when doing builds patterns: ['**/server/*'], + paths: RESTRICTED_IMPORTS, }, ], }, @@ -1113,6 +1115,7 @@ module.exports = { { // prevents UI code from importing server side code and then webpack including it when doing builds patterns: ['**/server/*'], + paths: RESTRICTED_IMPORTS, }, ], }, @@ -1184,13 +1187,7 @@ module.exports = { // to help deprecation and prevent accidental re-use/continued use of code we plan on removing. If you are // finding yourself turning this off a lot for "new code" consider renaming the file and functions if it is has valid uses. patterns: ['*legacy*'], - paths: [ - { - name: 'react-router-dom', - importNames: ['Route'], - message: "import { Route } from '@kbn/kibana-react-plugin/public'", - }, - ], + paths: RESTRICTED_IMPORTS, }, ], }, @@ -1348,6 +1345,7 @@ module.exports = { { // prevents UI code from importing server side code and then webpack including it when doing builds patterns: ['**/server/*'], + paths: RESTRICTED_IMPORTS, }, ], }, @@ -1525,6 +1523,7 @@ module.exports = { // to help deprecation and prevent accidental re-use/continued use of code we plan on removing. If you are // finding yourself turning this off a lot for "new code" consider renaming the file and functions if it has valid uses. patterns: ['*legacy*'], + paths: RESTRICTED_IMPORTS, }, ], }, diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index bd0fa1bc13104..7c7634aab7231 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1327,6 +1327,8 @@ x-pack/test_serverless/**/test_suites/observability/ai_assistant @elastic/obs-ai /x-pack/dev-tools @elastic/kibana-operations /catalog-info.yaml @elastic/kibana-operations @elastic/kibana-tech-leads /.devcontainer/ @elastic/kibana-operations +/.eslintrc.js @elastic/kibana-operations +/.eslintignore @elastic/kibana-operations # Appex QA /x-pack/test_serverless/tsconfig.json @elastic/appex-qa diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.test.tsx index 4b1851834cdba..d042a4cfd96f5 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/index.test.tsx @@ -15,7 +15,8 @@ import { useLoadConnectors } from '../connectorland/use_load_connectors'; import { DefinedUseQueryResult, UseQueryResult } from '@tanstack/react-query'; -import { useLocalStorage, useSessionStorage } from 'react-use'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; +import useSessionStorage from 'react-use/lib/useSessionStorage'; import { QuickPrompts } from './quick_prompts/quick_prompts'; import { mockAssistantAvailability, TestProviders } from '../mock/test_providers/test_providers'; import { useFetchCurrentUserConversations } from './api'; @@ -27,7 +28,8 @@ import { omit } from 'lodash'; jest.mock('../connectorland/use_load_connectors'); jest.mock('../connectorland/connector_setup'); -jest.mock('react-use'); +jest.mock('react-use/lib/useLocalStorage'); +jest.mock('react-use/lib/useSessionStorage'); jest.mock('./quick_prompts/quick_prompts', () => ({ QuickPrompts: jest.fn() })); jest.mock('./api/conversations/use_fetch_current_user_conversations'); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompts.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompts.test.tsx index e46f54ddede40..c3927a939af92 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompts.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompts.test.tsx @@ -32,15 +32,12 @@ const testTitle = 'SPL_QUERY_CONVERSION_TITLE'; const testPrompt = 'SPL_QUERY_CONVERSION_PROMPT'; const customTitle = 'A_CUSTOM_OPTION'; -jest.mock('react-use', () => ({ - ...jest.requireActual('react-use'), - useMeasure: () => [ - () => {}, - { - width: 500, - }, - ], -})); +jest.mock('react-use/lib/useMeasure', () => () => [ + () => {}, + { + width: 500, + }, +]); jest.mock('../../assistant_context', () => ({ ...jest.requireActual('../../assistant_context'), diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompts.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompts.tsx index 036fb4fb4db3f..f2baf4528b52d 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompts.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant/quick_prompts/quick_prompts.tsx @@ -14,7 +14,7 @@ import { EuiButtonIcon, EuiButtonEmpty, } from '@elastic/eui'; -import { useMeasure } from 'react-use'; +import useMeasure from 'react-use/lib/useMeasure'; import { css } from '@emotion/react'; import { diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.test.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.test.tsx index 5bd49fec6c857..4e877e1886fb4 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.test.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.test.tsx @@ -8,13 +8,11 @@ import { renderHook } from '@testing-library/react-hooks'; import { useAssistantContext } from '.'; -import { useLocalStorage } from 'react-use'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; import { TestProviders } from '../mock/test_providers/test_providers'; -jest.mock('react-use', () => ({ - useLocalStorage: jest.fn().mockReturnValue(['456', jest.fn()]), - useSessionStorage: jest.fn().mockReturnValue(['456', jest.fn()]), -})); +jest.mock('react-use/lib/useLocalStorage', () => jest.fn().mockReturnValue(['456', jest.fn()])); +jest.mock('react-use/lib/useSessionStorage', () => jest.fn().mockReturnValue(['456', jest.fn()])); describe('AssistantContext', () => { beforeEach(() => jest.clearAllMocks()); diff --git a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx index 75516eaf907b2..c7b15f681a717 100644 --- a/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx +++ b/x-pack/packages/kbn-elastic-assistant/impl/assistant_context/index.tsx @@ -10,7 +10,8 @@ import { omit } from 'lodash/fp'; import React, { useCallback, useMemo, useState, useRef } from 'react'; import type { IToasts } from '@kbn/core-notifications-browser'; import { ActionTypeRegistryContract } from '@kbn/triggers-actions-ui-plugin/public'; -import { useLocalStorage, useSessionStorage } from 'react-use'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; +import useSessionStorage from 'react-use/lib/useSessionStorage'; import type { DocLinksStart } from '@kbn/core-doc-links-browser'; import { AssistantFeatures, defaultAssistantFeatures } from '@kbn/elastic-assistant-common'; import { NavigateToAppOptions } from '@kbn/core/public'; diff --git a/x-pack/plugins/cases/common/types/domain/user/v1.test.ts b/x-pack/plugins/cases/common/types/domain/user/v1.test.ts index 56d23fff6fc1a..3c90054857e93 100644 --- a/x-pack/plugins/cases/common/types/domain/user/v1.test.ts +++ b/x-pack/plugins/cases/common/types/domain/user/v1.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { set } from 'lodash'; +import { set } from '@kbn/safer-lodash-set'; import { UserRt, UserWithProfileInfoRt, UsersRt, CaseUserProfileRt, CaseAssigneesRt } from './v1'; describe('User', () => { diff --git a/x-pack/plugins/cases/public/components/visualizations/open_lens_button.test.tsx b/x-pack/plugins/cases/public/components/visualizations/open_lens_button.test.tsx index 7ac2ed8d45da4..752bdd2980987 100644 --- a/x-pack/plugins/cases/public/components/visualizations/open_lens_button.test.tsx +++ b/x-pack/plugins/cases/public/components/visualizations/open_lens_button.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { set } from 'lodash'; +import { set } from '@kbn/safer-lodash-set'; import React from 'react'; import { screen } from '@testing-library/react'; import type { AppMockRenderer } from '../../common/mock'; diff --git a/x-pack/plugins/cases/server/connectors/cases/cases_oracle_service.test.ts b/x-pack/plugins/cases/server/connectors/cases/cases_oracle_service.test.ts index ea64b20f2c1a2..4d5d167a58852 100644 --- a/x-pack/plugins/cases/server/connectors/cases/cases_oracle_service.test.ts +++ b/x-pack/plugins/cases/server/connectors/cases/cases_oracle_service.test.ts @@ -12,7 +12,8 @@ import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; import { CasesOracleService } from './cases_oracle_service'; import { CASE_RULES_SAVED_OBJECT } from '../../../common/constants'; -import { isEmpty, set } from 'lodash'; +import { isEmpty } from 'lodash'; +import { set } from '@kbn/safer-lodash-set'; describe('CasesOracleService', () => { const savedObjectsClient = savedObjectsClientMock.create(); diff --git a/x-pack/plugins/cases/server/connectors/cases/cases_service.test.ts b/x-pack/plugins/cases/server/connectors/cases/cases_service.test.ts index 848d3fa276236..183d628d7a742 100644 --- a/x-pack/plugins/cases/server/connectors/cases/cases_service.test.ts +++ b/x-pack/plugins/cases/server/connectors/cases/cases_service.test.ts @@ -8,7 +8,8 @@ import { createHash } from 'node:crypto'; import stringify from 'json-stable-stringify'; -import { isEmpty, set } from 'lodash'; +import { isEmpty } from 'lodash'; +import { set } from '@kbn/safer-lodash-set'; import { CasesService } from './cases_service'; describe('CasesService', () => { diff --git a/x-pack/plugins/cases/server/services/user_actions/index.test.ts b/x-pack/plugins/cases/server/services/user_actions/index.test.ts index 20c06f2701fed..9e5b7589f1626 100644 --- a/x-pack/plugins/cases/server/services/user_actions/index.test.ts +++ b/x-pack/plugins/cases/server/services/user_actions/index.test.ts @@ -5,7 +5,8 @@ * 2.0. */ -import { set, omit, unset } from 'lodash'; +import { omit, unset } from 'lodash'; +import { set } from '@kbn/safer-lodash-set'; import { loggerMock } from '@kbn/logging-mocks'; import { savedObjectsClientMock } from '@kbn/core/server/mocks'; import type { diff --git a/x-pack/plugins/cases/server/services/user_actions/operations/create.test.ts b/x-pack/plugins/cases/server/services/user_actions/operations/create.test.ts index 833e8676a2619..38fb3e4e746ec 100644 --- a/x-pack/plugins/cases/server/services/user_actions/operations/create.test.ts +++ b/x-pack/plugins/cases/server/services/user_actions/operations/create.test.ts @@ -11,7 +11,8 @@ import { createSavedObjectsSerializerMock } from '../../../client/mocks'; import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks'; import { loggerMock } from '@kbn/logging-mocks'; import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks'; -import { set, unset } from 'lodash'; +import { unset } from 'lodash'; +import { set } from '@kbn/safer-lodash-set'; import { createConnectorObject } from '../../test_utils'; import { UserActionPersister } from './create'; import { createUserActionSO } from '../test_utils'; diff --git a/x-pack/plugins/integration_assistant/public/common/hooks/use_availability.ts b/x-pack/plugins/integration_assistant/public/common/hooks/use_availability.ts index 02f523fcde226..3fdf37297ad65 100644 --- a/x-pack/plugins/integration_assistant/public/common/hooks/use_availability.ts +++ b/x-pack/plugins/integration_assistant/public/common/hooks/use_availability.ts @@ -6,7 +6,7 @@ */ import { useMemo } from 'react'; -import { useObservable } from 'react-use'; +import useObservable from 'react-use/lib/useObservable'; import { MINIMUM_LICENSE_TYPE } from '../../../common/constants'; import { useKibana } from './use_kibana'; import type { RenderUpselling } from '../../services'; diff --git a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration.tsx b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration.tsx index 494bc94d8c58c..6afacc8e417f3 100644 --- a/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration.tsx +++ b/x-pack/plugins/integration_assistant/public/components/create_integration/create_integration.tsx @@ -5,8 +5,8 @@ * 2.0. */ import React from 'react'; -import { Redirect, Switch } from 'react-router-dom'; -import { Route } from '@kbn/shared-ux-router'; +import { Redirect } from 'react-router-dom'; +import { Route, Routes } from '@kbn/shared-ux-router'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import type { Services } from '../../services'; import { TelemetryContextProvider } from './telemetry'; @@ -33,7 +33,7 @@ const CreateIntegrationRouter = React.memo(() => { const { canUseIntegrationAssistant, canUseIntegrationUpload } = useRoutesAuthorization(); const isAvailable = useIsAvailable(); return ( - + {isAvailable && canUseIntegrationAssistant && ( )} @@ -44,7 +44,7 @@ const CreateIntegrationRouter = React.memo(() => { } /> - + ); }); CreateIntegrationRouter.displayName = 'CreateIntegrationRouter'; diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/common/alerting_callout/alerting_callout.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/common/alerting_callout/alerting_callout.tsx index 397b1597107c4..a6353c674d7c0 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/common/alerting_callout/alerting_callout.tsx +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/common/alerting_callout/alerting_callout.tsx @@ -11,7 +11,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { EuiButton, EuiButtonEmpty, EuiCallOut, EuiMarkdownFormat, EuiSpacer } from '@elastic/eui'; import { syntheticsSettingsLocatorID } from '@kbn/observability-plugin/common'; import { useFetcher } from '@kbn/observability-shared-plugin/public'; -import { useSessionStorage } from 'react-use'; +import useSessionStorage from 'react-use/lib/useSessionStorage'; import { i18n } from '@kbn/i18n'; import { isEmpty } from 'lodash'; import { useKibana } from '@kbn/kibana-react-plugin/public'; diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitor_add_edit/form/controlled_field.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitor_add_edit/form/controlled_field.tsx index cc37a530087c4..ddf1db76d819f 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitor_add_edit/form/controlled_field.tsx +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitor_add_edit/form/controlled_field.tsx @@ -7,7 +7,7 @@ import React, { useCallback, useState } from 'react'; import { EuiFormRow, EuiFormRowProps } from '@elastic/eui'; import { useSelector } from 'react-redux'; -import { useDebounce } from 'react-use'; +import useDebounce from 'react-use/lib/useDebounce'; import { ControllerRenderProps, ControllerFieldState, useFormContext } from 'react-hook-form'; import { useKibanaSpace, useIsEditFlow } from '../hooks'; import { selectServiceLocationsState } from '../../../state'; diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/use_monitor_status_data.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/use_monitor_status_data.ts index 8eaa80fb44a53..710ff65de7c66 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/use_monitor_status_data.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitor_details/monitor_status/use_monitor_status_data.ts @@ -7,7 +7,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useSelector, useDispatch } from 'react-redux'; -import { useDebounce } from 'react-use'; +import useDebounce from 'react-use/lib/useDebounce'; import { useLocation } from 'react-router-dom'; import { useSyntheticsRefreshContext } from '../../../contexts/synthetics_refresh_context'; diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/hooks/use_monitor_list.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/hooks/use_monitor_list.ts index 29e1f550d43cf..df8be3c98b451 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/hooks/use_monitor_list.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/hooks/use_monitor_list.ts @@ -7,7 +7,7 @@ import { useCallback, useEffect, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { useDebounce } from 'react-use'; +import useDebounce from 'react-use/lib/useDebounce'; import { useMonitorFiltersState } from '../common/monitor_filters/use_filters'; import { fetchMonitorListAction, diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/grid_by_group/grid_group_item.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/grid_by_group/grid_group_item.tsx index f9f0a417e065e..6fcf90f631fad 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/grid_by_group/grid_group_item.tsx +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/grid_by_group/grid_group_item.tsx @@ -19,7 +19,7 @@ import { import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; import { useSelector } from 'react-redux'; -import { useKey } from 'react-use'; +import useKey from 'react-use/lib/useKey'; import { FlyoutParamProps } from '../types'; import { OverviewLoader } from '../overview_loader'; import { useFilteredGroupMonitors } from './use_filtered_group_monitors'; diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/settings/global_params/params_list.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/settings/global_params/params_list.tsx index d72d92156e42e..2ff3ea547ae9f 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/settings/global_params/params_list.tsx +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/components/settings/global_params/params_list.tsx @@ -21,7 +21,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import { EuiBasicTableColumn } from '@elastic/eui/src/components/basic_table/basic_table'; -import { useDebounce } from 'react-use'; +import useDebounce from 'react-use/lib/useDebounce'; import { TableTitle } from '../../common/components/table_title'; import { ParamsText } from './params_text'; import { SyntheticsParams } from '../../../../../../common/runtime_types'; diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/contexts/synthetics_refresh_context.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/contexts/synthetics_refresh_context.tsx index 9f3902b8ccaf2..68f6910b43b78 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/contexts/synthetics_refresh_context.tsx +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/contexts/synthetics_refresh_context.tsx @@ -15,7 +15,7 @@ import React, { FC, } from 'react'; import useLocalStorage from 'react-use/lib/useLocalStorage'; -import { useEvent } from 'react-use'; +import useEvent from 'react-use/lib/useEvent'; import moment from 'moment'; import { Subject } from 'rxjs'; import { i18n } from '@kbn/i18n'; diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/hooks/use_breadcrumbs.test.tsx b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/hooks/use_breadcrumbs.test.tsx index 6a07150070362..5e524eca31bda 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/hooks/use_breadcrumbs.test.tsx +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/hooks/use_breadcrumbs.test.tsx @@ -9,7 +9,7 @@ import { ChromeBreadcrumb } from '@kbn/core/public'; import { render } from '../utils/testing'; import React from 'react'; import { i18n } from '@kbn/i18n'; -import { Route } from 'react-router-dom'; +import { Route } from '@kbn/shared-ux-router'; import { OVERVIEW_ROUTE } from '../../../../common/constants'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/hooks/use_monitor_name.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/hooks/use_monitor_name.ts index 717399d94d1fc..b90044725d070 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/hooks/use_monitor_name.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/hooks/use_monitor_name.ts @@ -7,7 +7,7 @@ import { useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; -import { useDebounce } from 'react-use'; +import useDebounce from 'react-use/lib/useDebounce'; import { useFetcher } from '@kbn/observability-shared-plugin/public'; import { fetchMonitorManagementList, getMonitorListPageStateWithDefaults } from '../state'; diff --git a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/utils/formatting/test_helpers.ts b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/utils/formatting/test_helpers.ts index 0b32c4a2420e8..8ba26624f3f0c 100644 --- a/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/utils/formatting/test_helpers.ts +++ b/x-pack/plugins/observability_solution/synthetics/public/apps/synthetics/utils/formatting/test_helpers.ts @@ -8,6 +8,7 @@ import moment from 'moment'; import { Moment } from 'moment-timezone'; import * as redux from 'react-redux'; +// eslint-disable-next-line no-restricted-imports import * as reactRouterDom from 'react-router-dom'; export function mockMoment() { diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_metadata_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_metadata_generator.ts index 558a9b8371068..b14ddc1e8af9e 100644 --- a/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_metadata_generator.ts +++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/endpoint_metadata_generator.ts @@ -8,7 +8,8 @@ /* eslint-disable max-classes-per-file */ import type { DeepPartial } from 'utility-types'; -import { merge, set } from 'lodash'; +import { merge } from 'lodash'; +import { set } from '@kbn/safer-lodash-set'; import { gte } from 'semver'; import type { EndpointCapabilities } from '../service/response_actions/constants'; import { BaseDataGenerator } from './base_data_generator'; diff --git a/x-pack/plugins/security_solution/common/endpoint/models/policy_config_helpers.test.ts b/x-pack/plugins/security_solution/common/endpoint/models/policy_config_helpers.test.ts index 5d7cc61d1d7bd..603ec6b1ac6e3 100644 --- a/x-pack/plugins/security_solution/common/endpoint/models/policy_config_helpers.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/models/policy_config_helpers.test.ts @@ -17,7 +17,8 @@ import { checkIfPopupMessagesContainCustomNotifications, resetCustomNotifications, } from './policy_config_helpers'; -import { get, merge, set } from 'lodash'; +import { get, merge } from 'lodash'; +import { set } from '@kbn/safer-lodash-set'; describe('Policy Config helpers', () => { describe('disableProtections', () => { diff --git a/x-pack/plugins/security_solution/common/endpoint/models/policy_config_helpers.ts b/x-pack/plugins/security_solution/common/endpoint/models/policy_config_helpers.ts index 9b3906191b698..5079493724d78 100644 --- a/x-pack/plugins/security_solution/common/endpoint/models/policy_config_helpers.ts +++ b/x-pack/plugins/security_solution/common/endpoint/models/policy_config_helpers.ts @@ -5,7 +5,8 @@ * 2.0. */ -import { get, set } from 'lodash'; +import { get } from 'lodash'; +import { set } from '@kbn/safer-lodash-set'; import { DefaultPolicyNotificationMessage } from './policy_config'; import type { PolicyConfig } from '../types'; import { PolicyOperatingSystem, ProtectionModes, AntivirusRegistrationModes } from '../types'; diff --git a/x-pack/plugins/security_solution/common/utils/expand_dotted.ts b/x-pack/plugins/security_solution/common/utils/expand_dotted.ts index e919b71dcdcf4..d452ca4df9fb6 100644 --- a/x-pack/plugins/security_solution/common/utils/expand_dotted.ts +++ b/x-pack/plugins/security_solution/common/utils/expand_dotted.ts @@ -5,7 +5,8 @@ * 2.0. */ -import { merge, setWith } from 'lodash'; +import { merge } from 'lodash'; +import { setWith } from '@kbn/safer-lodash-set'; /* * Expands an object with "dotted" fields to a nested object with unflattened fields. diff --git a/x-pack/plugins/security_solution/public/app/actions/add_to_timeline/cell_action/add_to_timeline.test.ts b/x-pack/plugins/security_solution/public/app/actions/add_to_timeline/cell_action/add_to_timeline.test.ts index dfdc2a5ede83f..3d105c34515b4 100644 --- a/x-pack/plugins/security_solution/public/app/actions/add_to_timeline/cell_action/add_to_timeline.test.ts +++ b/x-pack/plugins/security_solution/public/app/actions/add_to_timeline/cell_action/add_to_timeline.test.ts @@ -12,7 +12,7 @@ import { createAddToTimelineCellActionFactory } from './add_to_timeline'; import type { CellActionExecutionContext } from '@kbn/cell-actions'; import { GEO_FIELD_TYPE } from '../../../../timelines/components/timeline/body/renderers/constants'; import { createStartServicesMock } from '../../../../common/lib/kibana/kibana_react.mock'; -import { set } from 'lodash/fp'; +import { set } from '@kbn/safer-lodash-set/fp'; import { KBN_FIELD_TYPES } from '@kbn/field-types'; const services = createStartServicesMock(); diff --git a/x-pack/plugins/security_solution/public/app/components/top_values_popover/top_values_popover.test.tsx b/x-pack/plugins/security_solution/public/app/components/top_values_popover/top_values_popover.test.tsx index 80b22c42b544e..ed65f8a12a02a 100644 --- a/x-pack/plugins/security_solution/public/app/components/top_values_popover/top_values_popover.test.tsx +++ b/x-pack/plugins/security_solution/public/app/components/top_values_popover/top_values_popover.test.tsx @@ -29,10 +29,7 @@ const data = { const mockUseObservable = jest.fn(); -jest.mock('react-use', () => ({ - ...jest.requireActual('react-use'), - useObservable: () => mockUseObservable(), -})); +jest.mock('react-use/lib/useObservable', () => () => mockUseObservable()); jest.mock('../../../common/lib/kibana', () => { const original = jest.requireActual('../../../common/lib/kibana'); diff --git a/x-pack/plugins/security_solution/public/app/components/top_values_popover/top_values_popover.tsx b/x-pack/plugins/security_solution/public/app/components/top_values_popover/top_values_popover.tsx index ad88362e9e861..f03be50f39660 100644 --- a/x-pack/plugins/security_solution/public/app/components/top_values_popover/top_values_popover.tsx +++ b/x-pack/plugins/security_solution/public/app/components/top_values_popover/top_values_popover.tsx @@ -8,7 +8,7 @@ import React, { useCallback } from 'react'; import { EuiWrappingPopover } from '@elastic/eui'; import { useLocation } from 'react-router-dom'; -import { useObservable } from 'react-use'; +import useObservable from 'react-use/lib/useObservable'; import { StatefulTopN } from '../../../common/components/top_n'; import { getScopeFromPath } from '../../../sourcerer/containers/sourcerer_paths'; import { useSourcererDataView } from '../../../sourcerer/containers'; diff --git a/x-pack/plugins/security_solution/public/assistant/provider.tsx b/x-pack/plugins/security_solution/public/assistant/provider.tsx index 54d4e47edb684..93c65bb463584 100644 --- a/x-pack/plugins/security_solution/public/assistant/provider.tsx +++ b/x-pack/plugins/security_solution/public/assistant/provider.tsx @@ -23,7 +23,7 @@ import { once } from 'lodash/fp'; import type { HttpSetup } from '@kbn/core-http-browser'; import type { Message } from '@kbn/elastic-assistant-common'; import { loadAllActions as loadConnectors } from '@kbn/triggers-actions-ui-plugin/public/common/constants'; -import { useObservable } from 'react-use'; +import useObservable from 'react-use/lib/useObservable'; import { APP_ID } from '../../common'; import { useBasePath, useKibana } from '../common/lib/kibana'; import { useAssistantTelemetry } from './use_assistant_telemetry'; diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/index.test.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/index.test.tsx index 97f98b81dc153..8a53cd81db96a 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/index.test.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/index.test.tsx @@ -13,7 +13,7 @@ import { UpsellingService } from '@kbn/security-solution-upselling/service'; import { Router } from '@kbn/shared-ux-router'; import { render, screen } from '@testing-library/react'; import React from 'react'; -import { useLocalStorage } from 'react-use'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; import { TestProviders } from '../../common/mock'; import { ATTACK_DISCOVERY_PATH } from '../../../common/constants'; @@ -38,15 +38,10 @@ const mockConnectors: unknown[] = [ }, ]; -jest.mock('react-use', () => { - const actual = jest.requireActual('react-use'); - - return { - ...actual, - useLocalStorage: jest.fn().mockReturnValue(['test-id', jest.fn()]), - useSessionStorage: jest.fn().mockReturnValue([undefined, jest.fn()]), - }; -}); +jest.mock('react-use/lib/useLocalStorage', () => jest.fn().mockReturnValue(['test-id', jest.fn()])); +jest.mock('react-use/lib/useSessionStorage', () => + jest.fn().mockReturnValue([undefined, jest.fn()]) +); jest.mock( '@kbn/elastic-assistant/impl/assistant/api/anonymization_fields/use_fetch_anonymization_fields', diff --git a/x-pack/plugins/security_solution/public/attack_discovery/pages/index.tsx b/x-pack/plugins/security_solution/public/attack_discovery/pages/index.tsx index f3981696b3e80..ea5c16fc3cbba 100644 --- a/x-pack/plugins/security_solution/public/attack_discovery/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/attack_discovery/pages/index.tsx @@ -16,7 +16,7 @@ import { import type { AttackDiscoveries, Replacements } from '@kbn/elastic-assistant-common'; import { uniq } from 'lodash/fp'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { useLocalStorage } from 'react-use'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; import { SecurityPageName } from '../../../common/constants'; import { HeaderPage } from '../../common/components/header_page'; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_security_solution_navigation.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_security_solution_navigation.tsx index 30ebf658f0020..c436b7ed9feb5 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_security_solution_navigation.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_security_solution_navigation.tsx @@ -14,7 +14,7 @@ import React, { useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import type { KibanaPageTemplateProps } from '@kbn/shared-ux-page-kibana-template'; -import { useObservable } from 'react-use'; +import useObservable from 'react-use/lib/useObservable'; import { useKibana } from '../../../lib/kibana'; import { useBreadcrumbsNav } from '../breadcrumbs'; import { SecuritySideNav } from '../security_side_nav'; diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/actions.tsx b/x-pack/plugins/security_solution/public/common/components/visualization_actions/actions.tsx index b1ec30833b396..bcdb9d163164c 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/actions.tsx +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/actions.tsx @@ -10,7 +10,7 @@ import { buildContextMenuForActions } from '@kbn/ui-actions-plugin/public'; import React, { useCallback, useMemo, useState } from 'react'; import styled from 'styled-components'; -import { useAsync } from 'react-use'; +import useAsync from 'react-use/lib/useAsync'; import { InputsModelId } from '../../store/inputs/constants'; import { ModalInspectQuery } from '../inspect/modal'; diff --git a/x-pack/plugins/security_solution/public/common/lib/endpoint/utils/get_host_platform.test.ts b/x-pack/plugins/security_solution/public/common/lib/endpoint/utils/get_host_platform.test.ts index 1459c690068b4..c87129319597c 100644 --- a/x-pack/plugins/security_solution/public/common/lib/endpoint/utils/get_host_platform.test.ts +++ b/x-pack/plugins/security_solution/public/common/lib/endpoint/utils/get_host_platform.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { set } from 'lodash'; +import { set } from '@kbn/safer-lodash-set'; import { getHostPlatform } from './get_host_platform'; import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common'; diff --git a/x-pack/plugins/security_solution/public/common/mock/router.tsx b/x-pack/plugins/security_solution/public/common/mock/router.tsx index d9cf89a74db08..b946c3bd9bd5f 100644 --- a/x-pack/plugins/security_solution/public/common/mock/router.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/router.tsx @@ -5,6 +5,7 @@ * 2.0. */ +// eslint-disable-next-line no-restricted-imports import { Router } from 'react-router-dom'; // eslint-disable-next-line @kbn/eslint/module_migration import routeData from 'react-router'; diff --git a/x-pack/plugins/security_solution/public/common/utils/global_query_string/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/utils/global_query_string/helpers.test.tsx index 69f1b5fcbf4e0..6da409bcf92d9 100644 --- a/x-pack/plugins/security_solution/public/common/utils/global_query_string/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/utils/global_query_string/helpers.test.tsx @@ -16,6 +16,7 @@ import { } from './helpers'; import { renderHook } from '@testing-library/react-hooks'; import { createMemoryHistory } from 'history'; +// eslint-disable-next-line no-restricted-imports import { Router } from 'react-router-dom'; import React from 'react'; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integrations_help_info.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integrations_help_info.tsx index 08c4a8e22edfd..1b5d3784364b6 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integrations_help_info.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/related_integrations/related_integrations_help_info.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { useToggle } from 'react-use'; +import useToggle from 'react-use/lib/useToggle'; import { EuiLink, EuiPopover, EuiText, EuiButtonIcon } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { useKibana } from '../../../../common/lib/kibana'; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/required_fields_help_info.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/required_fields_help_info.tsx index 187f05880d205..9cc1a085507a7 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/required_fields_help_info.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_creation/components/required_fields/required_fields_help_info.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { useToggle } from 'react-use'; +import useToggle from 'react-use/lib/useToggle'; import { EuiPopover, EuiText, EuiButtonIcon } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import * as defineRuleI18n from '../../../rule_creation_ui/components/step_define_rule/translations'; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/comparison_side/comparison_side_help_info.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/comparison_side/comparison_side_help_info.tsx index a2b7e1a360150..e1eaa9b1e96cd 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/comparison_side/comparison_side_help_info.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/comparison_side/comparison_side_help_info.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { useToggle } from 'react-use'; +import useToggle from 'react-use/lib/useToggle'; import { EuiPopover, EuiText, EuiButtonIcon } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/kql_query.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/kql_query.tsx index abd3c93550694..69a00436b6992 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/kql_query.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_edit/fields/kql_query.tsx @@ -6,7 +6,7 @@ */ import React, { useCallback } from 'react'; -import { useToggle } from 'react-use'; +import useToggle from 'react-use/lib/useToggle'; import { css } from '@emotion/css'; import { EuiButtonEmpty } from '@elastic/eui'; import type { Type } from '@kbn/securitysolution-io-ts-alerting-types'; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_side/final_side_help_info.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_side/final_side_help_info.tsx index 766692e9efecd..51e0c5097b97d 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_side/final_side_help_info.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/three_way_diff/final_side/final_side_help_info.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { useToggle } from 'react-use'; +import useToggle from 'react-use/lib/useToggle'; import { EuiPopover, EuiText, EuiButtonIcon } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/add_prebuilt_rules_header_buttons.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/add_prebuilt_rules_header_buttons.tsx index b4ff6ab29a3ff..6fbdd5b4f8910 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/add_prebuilt_rules_header_buttons.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/add_prebuilt_rules_header_buttons.tsx @@ -16,7 +16,7 @@ import { EuiPopover, } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; -import { useBoolean } from 'react-use'; +import useBoolean from 'react-use/lib/useBoolean'; import { useUserData } from '../../../../../detections/components/user_info'; import { useAddPrebuiltRulesTableContext } from './add_prebuilt_rules_table_context'; import * as i18n from './translations'; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/add_prebuilt_rules_install_button.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/add_prebuilt_rules_install_button.tsx index ea83efae768fa..6ea9e9dd6a749 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/add_prebuilt_rules_install_button.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/add_prebuilt_rules_table/add_prebuilt_rules_install_button.tsx @@ -16,7 +16,7 @@ import { EuiPopover, } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; -import { useBoolean } from 'react-use'; +import useBoolean from 'react-use/lib/useBoolean'; import type { Rule } from '../../../../rule_management/logic'; import type { RuleSignatureId } from '../../../../../../common/api/detection_engine'; import type { AddPrebuiltRulesTableActions } from './add_prebuilt_rules_table_context'; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/asset_criticality_selector.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/asset_criticality_selector.tsx index e29dca9d48f3d..51ebecedac3d4 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/asset_criticality_selector.tsx +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/asset_criticality_selector.tsx @@ -34,7 +34,7 @@ import React, { useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { css } from '@emotion/css'; import { i18n } from '@kbn/i18n'; -import { useToggle } from 'react-use'; +import useToggle from 'react-use/lib/useToggle'; import { PICK_ASSET_CRITICALITY } from './translations'; import { AssetCriticalityBadge } from './asset_criticality_badge'; import type { Entity, State } from './use_asset_criticality'; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/routes.tsx b/x-pack/plugins/security_solution/public/entity_analytics/routes.tsx index 048b37915e0f4..835265c7402fe 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/routes.tsx +++ b/x-pack/plugins/security_solution/public/entity_analytics/routes.tsx @@ -5,8 +5,7 @@ * 2.0. */ import React from 'react'; -import { Switch } from 'react-router-dom'; -import { Route } from '@kbn/shared-ux-router'; +import { Route, Routes } from '@kbn/shared-ux-router'; import { TrackApplicationView } from '@kbn/usage-collection-plugin/public'; @@ -33,14 +32,14 @@ const EntityAnalyticsManagementTelemetry = () => ( const EntityAnalyticsManagementContainer: React.FC = React.memo(() => { return ( - + - + ); }); EntityAnalyticsManagementContainer.displayName = 'EntityAnalyticsManagementContainer'; @@ -56,14 +55,14 @@ const EntityAnalyticsAssetClassificationTelemetry = () => ( const EntityAnalyticsAssetClassificationContainer: React.FC = React.memo(() => { return ( - + - + ); }); diff --git a/x-pack/plugins/security_solution/public/explore/network/pages/details/index.test.tsx b/x-pack/plugins/security_solution/public/explore/network/pages/details/index.test.tsx index 57cbbb4bc65e6..19b3f653b8f14 100644 --- a/x-pack/plugins/security_solution/public/explore/network/pages/details/index.test.tsx +++ b/x-pack/plugins/security_solution/public/explore/network/pages/details/index.test.tsx @@ -6,7 +6,8 @@ */ import React from 'react'; -import { Router, useParams } from 'react-router-dom'; +import { Router } from '@kbn/shared-ux-router'; +import { useParams } from 'react-router-dom'; import { useSourcererDataView } from '../../../../sourcerer/containers'; import { TestProviders } from '../../../../common/mock'; diff --git a/x-pack/plugins/security_solution/public/management/components/privileged_route/privileged_route.test.tsx b/x-pack/plugins/security_solution/public/management/components/privileged_route/privileged_route.test.tsx index 2fdac844b5e41..32294d09ea82d 100644 --- a/x-pack/plugins/security_solution/public/management/components/privileged_route/privileged_route.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/privileged_route/privileged_route.test.tsx @@ -6,6 +6,7 @@ */ import React from 'react'; +// eslint-disable-next-line no-restricted-imports import { Switch, MemoryRouter } from 'react-router-dom'; import type { AppContextTestRender } from '../../../common/mock/endpoint'; import { createAppRootMockRenderer } from '../../../common/mock/endpoint'; diff --git a/x-pack/plugins/security_solution/public/management/hooks/policy/use_fetch_endpoint_policy.test.ts b/x-pack/plugins/security_solution/public/management/hooks/policy/use_fetch_endpoint_policy.test.ts index 85647202a755c..6feaeb878790c 100644 --- a/x-pack/plugins/security_solution/public/management/hooks/policy/use_fetch_endpoint_policy.test.ts +++ b/x-pack/plugins/security_solution/public/management/hooks/policy/use_fetch_endpoint_policy.test.ts @@ -16,7 +16,7 @@ import { DefaultPolicyNotificationMessage, DefaultPolicyRuleNotificationMessage, } from '../../../../common/endpoint/models/policy_config'; -import { set } from 'lodash'; +import { set } from '@kbn/safer-lodash-set'; import { API_VERSIONS } from '@kbn/fleet-plugin/common'; const useQueryMock = _useQuery as jest.Mock; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/advanced_section.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/advanced_section.test.tsx index 937804565e29f..b86c79c46242d 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/advanced_section.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/advanced_section.test.tsx @@ -18,7 +18,7 @@ import { AdvancedSection } from './advanced_section'; import userEvent from '@testing-library/user-event'; import { AdvancedPolicySchema } from '../../../models/advanced_policy_schema'; import { within } from '@testing-library/react'; -import { set } from 'lodash'; +import { set } from '@kbn/safer-lodash-set'; jest.mock('../../../../../../common/hooks/use_license'); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/attack_surface_reduction_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/attack_surface_reduction_card.test.tsx index c55f0793027e5..35cf98b5f5075 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/attack_surface_reduction_card.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/attack_surface_reduction_card.test.tsx @@ -17,7 +17,8 @@ import { SWITCH_LABEL, } from './attack_surface_reduction_card'; import { useLicense as _useLicense } from '../../../../../../../common/hooks/use_license'; -import { cloneDeep, set } from 'lodash'; +import { cloneDeep } from 'lodash'; +import { set } from '@kbn/safer-lodash-set'; import userEvent from '@testing-library/user-event'; import { createLicenseServiceMock } from '../../../../../../../../common/license/mocks'; import { licenseService as licenseServiceMocked } from '../../../../../../../common/hooks/__mocks__/use_license'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/behaviour_protection_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/behaviour_protection_card.test.tsx index 9a5c9db321b4b..94399b7c33c4c 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/behaviour_protection_card.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/behaviour_protection_card.test.tsx @@ -13,7 +13,7 @@ import React from 'react'; import { licenseService as licenseServiceMocked } from '../../../../../../../common/hooks/__mocks__/use_license'; import { useLicense as _useLicense } from '../../../../../../../common/hooks/use_license'; import { createLicenseServiceMock } from '../../../../../../../../common/license/mocks'; -import { set } from 'lodash'; +import { set } from '@kbn/safer-lodash-set'; import { ProtectionModes } from '../../../../../../../../common/endpoint/types'; import type { BehaviourProtectionCardProps } from './protection_seetings_card/behaviour_protection_card'; import { diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/linux_event_collection_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/linux_event_collection_card.test.tsx index 7be10cb5ca6d0..f28b379ce5140 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/linux_event_collection_card.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/linux_event_collection_card.test.tsx @@ -12,7 +12,7 @@ import { FleetPackagePolicyGenerator } from '../../../../../../../../common/endp import React from 'react'; import type { LinuxEventCollectionCardProps } from './linux_event_collection_card'; import { LinuxEventCollectionCard } from './linux_event_collection_card'; -import { set } from 'lodash'; +import { set } from '@kbn/safer-lodash-set'; describe('Policy Linux Event Collection Card', () => { const testSubj = getPolicySettingsFormTestSubjects('test').linuxEvents; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/mac_event_collection_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/mac_event_collection_card.test.tsx index ac2c81da1c121..d951975d467f0 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/mac_event_collection_card.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/mac_event_collection_card.test.tsx @@ -10,7 +10,7 @@ import type { AppContextTestRender } from '../../../../../../../common/mock/endp import { createAppRootMockRenderer } from '../../../../../../../common/mock/endpoint'; import { FleetPackagePolicyGenerator } from '../../../../../../../../common/endpoint/data_generators/fleet_package_policy_generator'; import React from 'react'; -import { set } from 'lodash'; +import { set } from '@kbn/safer-lodash-set'; import type { MacEventCollectionCardProps } from './mac_event_collection_card'; import { MacEventCollectionCard } from './mac_event_collection_card'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/malware_protections_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/malware_protections_card.test.tsx index c4060cf0d7de0..d4ca438c5e25f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/malware_protections_card.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/malware_protections_card.test.tsx @@ -19,7 +19,8 @@ import type { MalwareProtectionsProps } from './malware_protections_card'; import { MalwareProtectionsCard } from './malware_protections_card'; import type { PolicyConfig } from '../../../../../../../../common/endpoint/types'; import { ProtectionModes } from '../../../../../../../../common/endpoint/types'; -import { cloneDeep, set } from 'lodash'; +import { cloneDeep } from 'lodash'; +import { set } from '@kbn/safer-lodash-set'; import userEvent from '@testing-library/user-event'; jest.mock('../../../../../../../common/hooks/use_license'); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/memory_protection_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/memory_protection_card.test.tsx index 35ee4eb4fd2d5..4d5236a559985 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/memory_protection_card.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/memory_protection_card.test.tsx @@ -11,7 +11,7 @@ import { createAppRootMockRenderer } from '../../../../../../../common/mock/endp import { FleetPackagePolicyGenerator } from '../../../../../../../../common/endpoint/data_generators/fleet_package_policy_generator'; import React from 'react'; import { ProtectionModes } from '../../../../../../../../common/endpoint/types'; -import { set } from 'lodash'; +import { set } from '@kbn/safer-lodash-set'; import type { MemoryProtectionCardProps } from './memory_protection_card'; import { LOCKED_CARD_MEMORY_TITLE, MemoryProtectionCard } from './memory_protection_card'; import { createLicenseServiceMock } from '../../../../../../../../common/license/mocks'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/ransomware_protection_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/ransomware_protection_card.test.tsx index 1970c5915fe07..d78840a44e9ce 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/ransomware_protection_card.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/ransomware_protection_card.test.tsx @@ -11,7 +11,7 @@ import { createAppRootMockRenderer } from '../../../../../../../common/mock/endp import { FleetPackagePolicyGenerator } from '../../../../../../../../common/endpoint/data_generators/fleet_package_policy_generator'; import React from 'react'; import { ProtectionModes } from '../../../../../../../../common/endpoint/types'; -import { set } from 'lodash'; +import { set } from '@kbn/safer-lodash-set'; import { createLicenseServiceMock } from '../../../../../../../../common/license/mocks'; import { licenseService as licenseServiceMocked } from '../../../../../../../common/hooks/__mocks__/use_license'; import { useLicense as _useLicense } from '../../../../../../../common/hooks/use_license'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/windows_event_collection_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/windows_event_collection_card.test.tsx index 2ee20f4a51a51..4dfe847297ef2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/windows_event_collection_card.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/cards/windows_event_collection_card.test.tsx @@ -10,7 +10,7 @@ import type { AppContextTestRender } from '../../../../../../../common/mock/endp import { createAppRootMockRenderer } from '../../../../../../../common/mock/endpoint'; import { FleetPackagePolicyGenerator } from '../../../../../../../../common/endpoint/data_generators/fleet_package_policy_generator'; import React from 'react'; -import { set } from 'lodash'; +import { set } from '@kbn/safer-lodash-set'; import type { WindowsEventCollectionCardProps } from './windows_event_collection_card'; import { WindowsEventCollectionCard } from './windows_event_collection_card'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/detect_prevent_protection_level.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/detect_prevent_protection_level.test.tsx index ee31a690c27db..6616ac02e3c5a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/detect_prevent_protection_level.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/detect_prevent_protection_level.test.tsx @@ -12,7 +12,8 @@ import React from 'react'; import type { DetectPreventProtectionLevelProps } from './detect_prevent_protection_level'; import { DetectPreventProtectionLevel } from './detect_prevent_protection_level'; import userEvent from '@testing-library/user-event'; -import { cloneDeep, set } from 'lodash'; +import { cloneDeep } from 'lodash'; +import { set } from '@kbn/safer-lodash-set'; import { ProtectionModes } from '../../../../../../../common/endpoint/types'; import { expectIsViewOnly, exactMatchText } from '../mocks'; import { createLicenseServiceMock } from '../../../../../../../common/license/mocks'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/event_collection_card.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/event_collection_card.test.tsx index 51e2fb275a78a..ba2cd95989cde 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/event_collection_card.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/event_collection_card.test.tsx @@ -17,7 +17,8 @@ import { EventCollectionCard } from './event_collection_card'; import { OperatingSystem } from '@kbn/securitysolution-utils'; import { expectIsViewOnly, exactMatchText } from '../mocks'; import userEvent from '@testing-library/user-event'; -import { cloneDeep, set } from 'lodash'; +import { cloneDeep } from 'lodash'; +import { set } from '@kbn/safer-lodash-set'; import { within } from '@testing-library/react'; describe('Policy Event Collection Card common component', () => { diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/event_collection_card.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/event_collection_card.tsx index a1fee2d77b01d..8ab5f92da27c0 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/event_collection_card.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/event_collection_card.tsx @@ -19,7 +19,8 @@ import { EuiSpacer, EuiText, } from '@elastic/eui'; -import { cloneDeep, get, set } from 'lodash'; +import { cloneDeep, get } from 'lodash'; +import { set } from '@kbn/safer-lodash-set'; import type { EuiCheckboxProps } from '@elastic/eui'; import { getEmptyValue } from '../../../../../../common/components/empty_value'; import { useTestIdGenerator } from '../../../../../hooks/use_test_id_generator'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/notify_user_option.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/notify_user_option.test.tsx index b75084f6b97a5..eb0686d7e07ef 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/notify_user_option.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/notify_user_option.test.tsx @@ -20,7 +20,8 @@ import { NotifyUserOption, } from './notify_user_option'; import { expectIsViewOnly, exactMatchText } from '../mocks'; -import { cloneDeep, set } from 'lodash'; +import { cloneDeep } from 'lodash'; +import { set } from '@kbn/safer-lodash-set'; import { ProtectionModes } from '../../../../../../../common/endpoint/types'; import userEvent from '@testing-library/user-event'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/protection_setting_card_switch.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/protection_setting_card_switch.test.tsx index 9a2bb55d85ea5..aa57720d58160 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/protection_setting_card_switch.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/components/protection_setting_card_switch.test.tsx @@ -16,7 +16,8 @@ import type { ProtectionSettingCardSwitchProps } from './protection_setting_card import { ProtectionSettingCardSwitch } from './protection_setting_card_switch'; import { exactMatchText, expectIsViewOnly, setMalwareMode } from '../mocks'; import { ProtectionModes } from '../../../../../../../common/endpoint/types'; -import { cloneDeep, set } from 'lodash'; +import { cloneDeep } from 'lodash'; +import { set } from '@kbn/safer-lodash-set'; import userEvent from '@testing-library/user-event'; jest.mock('../../../../../../common/hooks/use_license'); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/mocks.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/mocks.ts index 9448b93c7627e..e51382a6b91a8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/mocks.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_form/mocks.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { set } from 'lodash'; +import { set } from '@kbn/safer-lodash-set'; import type { PolicyConfig } from '../../../../../../common/endpoint/types'; import { AntivirusRegistrationModes, diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_layout/policy_settings_layout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_layout/policy_settings_layout.test.tsx index 84642ffdc1582..f26d520406407 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_layout/policy_settings_layout.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_settings_layout/policy_settings_layout.test.tsx @@ -21,7 +21,8 @@ import { getPolicySettingsFormTestSubjects, setMalwareMode, } from '../policy_settings_form/mocks'; -import { cloneDeep, set } from 'lodash'; +import { cloneDeep } from 'lodash'; +import { set } from '@kbn/safer-lodash-set'; import { ProtectionModes } from '../../../../../../common/endpoint/types'; import { waitFor, cleanup } from '@testing-library/react'; import { packagePolicyRouteService, API_VERSIONS } from '@kbn/fleet-plugin/common'; diff --git a/x-pack/plugins/security_solution/public/notes/routes.tsx b/x-pack/plugins/security_solution/public/notes/routes.tsx index 7bd17c2b012ef..c49f54f9c9a93 100644 --- a/x-pack/plugins/security_solution/public/notes/routes.tsx +++ b/x-pack/plugins/security_solution/public/notes/routes.tsx @@ -6,8 +6,7 @@ */ import React from 'react'; -import { Switch } from 'react-router-dom'; -import { Route } from '@kbn/shared-ux-router'; +import { Route, Routes } from '@kbn/shared-ux-router'; import { TrackApplicationView } from '@kbn/usage-collection-plugin/public'; import { NoteManagementPage } from './pages/note_management_page'; import { SpyRoute } from '../common/utils/route/spy_routes'; @@ -26,10 +25,10 @@ const NotesManagementTelemetry = () => ( const NotesManagementContainer: React.FC = React.memo(() => { return ( - + - + ); }); NotesManagementContainer.displayName = 'NotesManagementContainer'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/integration_card_top_callout.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/integration_card_top_callout.tsx index 27c92d0f0b11f..3a6b5ae3be92c 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/integration_card_top_callout.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/cards/integrations/callouts/integration_card_top_callout.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { useObservable } from 'react-use'; +import useObservable from 'react-use/lib/useObservable'; import { useOnboardingService } from '../../../../../hooks/use_onboarding_service'; import { AgentlessAvailableCallout } from './agentless_available_callout'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_body_config.test.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_body_config.test.ts index 19e80e4005a59..775ff09546fe6 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_body_config.test.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_body_config.test.ts @@ -7,7 +7,7 @@ import { renderHook } from '@testing-library/react-hooks'; import { useBodyConfig } from './use_body_config'; import { useKibana } from '../../../../common/lib/kibana/kibana_react'; -import { useObservable } from 'react-use'; +import useObservable from 'react-use/lib/useObservable'; import { hasCapabilities } from '../../../../common/lib/capabilities'; const bodyConfig = [ @@ -43,7 +43,7 @@ const bodyConfig = [ ]; // Mock dependencies -jest.mock('react-use'); +jest.mock('react-use/lib/useObservable'); jest.mock('../../../../common/lib/kibana/kibana_react'); jest.mock('../../../../common/lib/capabilities'); jest.mock('../body_config', () => ({ bodyConfig })); diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_body_config.ts b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_body_config.ts index e140f953fb028..f7b12e5988c0d 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_body_config.ts +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_body/hooks/use_body_config.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { useObservable } from 'react-use'; +import useObservable from 'react-use/lib/useObservable'; import { useMemo } from 'react'; import { hasCapabilities } from '../../../../common/lib/capabilities'; import { useKibana } from '../../../../common/lib/kibana/kibana_react'; diff --git a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/cards/teammates_card/teammates_card.tsx b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/cards/teammates_card/teammates_card.tsx index da316e0d0d907..a79b288dd8562 100644 --- a/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/cards/teammates_card/teammates_card.tsx +++ b/x-pack/plugins/security_solution/public/onboarding/components/onboarding_header/cards/teammates_card/teammates_card.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { useObservable } from 'react-use'; +import useObservable from 'react-use/lib/useObservable'; import { useOnboardingService } from '../../../../hooks/use_onboarding_service'; import { LinkCard } from '../common/link_card'; import teammatesImage from './images/teammates_card.png'; diff --git a/x-pack/plugins/security_solution/public/onboarding/hooks/use_stored_state.ts b/x-pack/plugins/security_solution/public/onboarding/hooks/use_stored_state.ts index c22c8f0f5310c..eac269f3a4a35 100644 --- a/x-pack/plugins/security_solution/public/onboarding/hooks/use_stored_state.ts +++ b/x-pack/plugins/security_solution/public/onboarding/hooks/use_stored_state.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { useLocalStorage } from 'react-use'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; import type { OnboardingCardId } from '../constants'; import type { IntegrationTabId } from '../components/onboarding_body/cards/integrations/types'; diff --git a/x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_save.ts b/x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_save.ts index 58e8aced4470b..a0d0ab4dd1061 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_save.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/middlewares/timeline_save.ts @@ -5,7 +5,8 @@ * 2.0. */ -import { get, has, set, omit, isObject, toString as fpToString } from 'lodash/fp'; +import { get, has, omit, isObject, toString as fpToString } from 'lodash/fp'; +import { set } from '@kbn/safer-lodash-set/fp'; import type { Action, Middleware } from 'redux'; import type { CoreStart } from '@kbn/core/public'; import type { Filter, MatchAllFilter } from '@kbn/es-query'; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/response_actions.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/response_actions.test.ts index 66b804e07eb10..b3011005a8b76 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/response_actions.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/response_actions.test.ts @@ -71,7 +71,8 @@ import type { HapiReadableStream, SecuritySolutionRequestHandlerContext } from ' import { createHapiReadableStreamMock } from '../../services/actions/mocks'; import { EndpointActionGenerator } from '../../../../common/endpoint/data_generators/endpoint_action_generator'; import { CustomHttpRequestError } from '../../../utils/custom_http_request_error'; -import { omit, set } from 'lodash'; +import { omit } from 'lodash'; +import { set } from '@kbn/safer-lodash-set'; import type { ResponseActionAgentType } from '../../../../common/endpoint/service/response_actions/constants'; import { responseActionsClientMock } from '../../services/actions/clients/mocks'; import type { ActionsApiRequestHandlerContext } from '@kbn/actions-plugin/server'; diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.test.ts index 20389d41f3956..dafc0b285f489 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.test.ts @@ -36,7 +36,7 @@ import { ENDPOINT_ACTIONS_INDEX, } from '../../../../../../common/endpoint/constants'; import type { DeepMutable } from '../../../../../../common/endpoint/types/utility_types'; -import { set } from 'lodash'; +import { set } from '@kbn/safer-lodash-set'; import { responseActionsClientMock } from '../mocks'; import type { ResponseActionAgentType } from '../../../../../../common/endpoint/service/response_actions/constants'; import { getResponseActionFeatureKey } from '../../../feature_usage/feature_keys'; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/traverse_and_mutate_doc.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/traverse_and_mutate_doc.ts index c9720a139ae7d..128cabf02aca6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/traverse_and_mutate_doc.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/traverse_and_mutate_doc.ts @@ -8,7 +8,8 @@ import { ecsFieldMap } from '@kbn/alerts-as-data-utils'; import { flattenWithPrefix } from '@kbn/securitysolution-rules'; -import { isPlainObject, isArray, set } from 'lodash'; +import { isPlainObject, isArray } from 'lodash'; +import { set } from '@kbn/safer-lodash-set'; import type { SearchTypes } from '../../../../../../common/detection_engine/types'; import { isValidIpType } from './ecs_types_validators/is_valid_ip_type'; diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/helpers.ts b/x-pack/plugins/security_solution/server/lib/telemetry/helpers.ts index cb33d608ea0c9..0f29a415dfeba 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/helpers.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/helpers.ts @@ -8,7 +8,8 @@ import moment from 'moment'; import type { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; import type { PackagePolicy } from '@kbn/fleet-plugin/common/types/models/package_policy'; -import { merge, set } from 'lodash'; +import { merge } from 'lodash'; +import { set } from '@kbn/safer-lodash-set'; import type { Logger, LogMeta } from '@kbn/core/server'; import { sha256 } from 'js-sha256'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; From bdc9ce932bbfa606dd1f1e188c8b32df4327a0a4 Mon Sep 17 00:00:00 2001 From: Ilya Nikokoshev Date: Tue, 15 Oct 2024 15:02:00 +0300 Subject: [PATCH 10/13] [Auto Import] Improve log format recognition (#196228) Previously the LLM would often select `unstructured` format for what (to our eye) clearly are CSV samples. We add the missing line break between the log samples (which should help format recognition in general) and change the prompt to clarify when the comma-separated list should be treated as a `csv` and when as `structured` format. See GitHub for examples. --------- Co-authored-by: Bharat Pasupula <123897612+bhapas@users.noreply.github.com> --- .../graphs/log_type_detection/detection.ts | 2 +- .../graphs/log_type_detection/prompts.ts | 22 ++++++++++--------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/x-pack/plugins/integration_assistant/server/graphs/log_type_detection/detection.ts b/x-pack/plugins/integration_assistant/server/graphs/log_type_detection/detection.ts index a8334432a0211..c0172f2d139d0 100644 --- a/x-pack/plugins/integration_assistant/server/graphs/log_type_detection/detection.ts +++ b/x-pack/plugins/integration_assistant/server/graphs/log_type_detection/detection.ts @@ -26,7 +26,7 @@ export async function handleLogFormatDetection({ const logFormatDetectionResult = await logFormatDetectionNode.invoke({ ex_answer: state.exAnswer, - log_samples: samples, + log_samples: samples.join('\n'), package_title: state.packageTitle, datastream_title: state.dataStreamTitle, }); diff --git a/x-pack/plugins/integration_assistant/server/graphs/log_type_detection/prompts.ts b/x-pack/plugins/integration_assistant/server/graphs/log_type_detection/prompts.ts index 71246d46363cb..b6e777a87888a 100644 --- a/x-pack/plugins/integration_assistant/server/graphs/log_type_detection/prompts.ts +++ b/x-pack/plugins/integration_assistant/server/graphs/log_type_detection/prompts.ts @@ -17,16 +17,18 @@ export const LOG_FORMAT_DETECTION_PROMPT = ChatPromptTemplate.fromMessages([ The samples apply to the data stream {datastream_title} inside the integration package {package_title}. Follow these steps to do this: -1. Go through each log sample and identify the log format. Output this as "name: ". -2. If the samples have any or all of priority, timestamp, loglevel, hostname, ipAddress, messageId in the beginning information then set "header: true". -3. If the samples have a syslog header then set "header: true" , else set "header: false". If you are unable to determine the syslog header presence then set "header: false". -4. If the log samples have structured message body with key-value pairs then classify it as "name: structured". Look for a flat list of key-value pairs, often separated by spaces, commas, or other delimiters. -5. Consider variations in formatting, such as quotes around values ("key=value", key="value"), special characters in keys or values, or escape sequences. -6. If the log samples have unstructured body like a free-form text then classify it as "name: unstructured". -7. If the log samples follow a csv format then classify it with "name: csv". There are two sub-cases for csv: - a. If there is a csv header then set "header: true". - b. If there is no csv header then set "header: false" and try to find good names for the columns in the "columns" array by looking into the values of data in those columns. For each column, if you are unable to find good name candidate for it then output an empty string, like in the example. -8. If you cannot put the format into any of the above categories then classify it with "name: unsupported". +1. Go through each log sample and identify the log format. Output this as "name: ". Here are the values for log_format: + * 'csv': If the log samples follow a Comma-Separated Values format then classify it with "name: csv". There are two sub-cases for csv: + a. If there is a csv header then set "header: true". + b. If there is no csv header then set "header: false" and try to find good names for the columns in the "columns" array by looking into the values of data in those columns. For each column, if you are unable to find good name candidate for it then output an empty string, like in the example. + * 'structured': If the log samples have structured message body with key-value pairs then classify it as "name: structured". Look for a flat list of key-value pairs, often separated by some delimiters. Consider variations in formatting, such as quotes around values ("key=value", key="value"), special characters in keys or values, or escape sequences. + * 'unstructured': If the log samples have unstructured body like a free-form text then classify it as "name: unstructured". + * 'unsupported': If you cannot put the format into any of the above categories then classify it with "name: unsupported". +2. Header: for structured and unstructured format: + - if the samples have any or all of priority, timestamp, loglevel, hostname, ipAddress, messageId in the beginning information then set "header: true". + - if the samples have a syslog header then set "header: true" + - else set "header: false". If you are unable to determine the syslog header presence then set "header: false". +3. Note that a comma-separated list should be classified as 'csv' if its rows only contain values separated by commas. But if it looks like a list of comma separated key-values pairs like 'key1=value1, key2=value2' it should be classified as 'structured'. You ALWAYS follow these guidelines when writing your response: From 63e116bb078c29c70e4e23cba1c88d0ac022801d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Luis=20Gonz=C3=A1lez?= Date: Tue, 15 Oct 2024 14:09:30 +0200 Subject: [PATCH 11/13] [Search] New search connector creation flow (#187582) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR brings a new and dedicated search connector creation flow for ES3 and ESS. [Figma Prototype](https://www.figma.com/proto/eKQr4HYlz0v9pTofRPWIyH/Ingestion-methods-flow?page-id=411%3A158867&node-id=411-158870&viewport=3831%2C-1905%2C1.23&t=ZP9e3LtaSeJ5FMAz-9&scaling=min-zoom&content-scaling=fixed&starting-point-node-id=411%3A158870&show-proto-sidebar=1) ![CleanShot 2024-07-04 at 16 27 21](https://github.com/elastic/kibana/assets/3108788/45e61110-f222-4bad-b24d-87ebad07ca98) ### Checklist Delete any items that are not applicable to this PR. - [ ] 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 - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [ ] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [ ] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [ ] If a plugin configuration key changed, check if it needs to be allowlisted in the cloud and added to the [docker list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker) - [ ] 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)) - [ ] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### Risk Matrix Delete this section if it is not applicable to this PR. Before closing this PR, invite QA, stakeholders, and other developers to identify risks that should be tested prior to the change/feature release. When forming the risk matrix, consider some of the following examples and how they may potentially impact the change: | Risk | Probability | Severity | Mitigation/Notes | |---------------------------|-------------|----------|-------------------------| | Multiple Spaces—unexpected behavior in non-default Kibana Space. | Low | High | Integration tests will verify that all features are still supported in non-default Kibana Space and when user switches between spaces. | | Multiple nodes—Elasticsearch polling might have race conditions when multiple Kibana nodes are polling for the same tasks. | High | Low | Tasks are idempotent, so executing them multiple times will not result in logical error, but will degrade performance. To test for this case we add plenty of unit tests around this logic and document manual testing procedure. | | Code should gracefully handle cases when feature X or plugin Y are disabled. | Medium | High | Unit tests will verify that any feature flag or plugin combination still results in our service operational. | | [See more potential risk examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) | ### For maintainers - [ ] 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: Efe Gürkan YALAMAN Co-authored-by: Elastic Machine Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../configuration/connector_configuration.tsx | 8 + .../connector_configuration_form.tsx | 48 ++- .../enterprise_search/common/constants.ts | 54 +++ .../api/connector/add_connector_api_logic.ts | 14 +- .../generate_connector_config_api_logic.ts | 4 +- .../generate_connector_names_api_logic.ts | 23 +- .../components/generate_config_button.tsx | 5 +- .../components/generated_config_fields.tsx | 48 +-- .../connector_detail/deployment.tsx | 31 +- .../connector_detail/deployment_logic.ts | 5 +- .../components/connectors/connectors.tsx | 4 +- .../connectors/connectors_router.tsx | 8 +- .../assets/connector_logo.svg | 11 + .../assets/connector_logos_comp.png | Bin 0 -> 80544 bytes .../choose_connector_selectable.tsx | 172 +++++++++ .../connector_description_popover.tsx | 166 +++++++++ .../components/manual_configuration.tsx | 114 ++++++ .../manual_configuration_flyout.tsx | 228 ++++++++++++ .../create_connector/configuration_step.tsx | 122 ++++++ .../create_connector/create_connector.tsx | 265 +++++++++++++ .../create_connector/deployment_step.tsx | 83 +++++ .../create_connector/finish_up_step.tsx | 348 ++++++++++++++++++ .../connectors/create_connector/index.ts | 8 + .../create_connector/start_step.tsx | 340 +++++++++++++++++ .../connectors/utils/generate_step_state.ts | 29 ++ .../method_connector/new_connector_logic.ts | 244 +++++++++--- .../new_connector_template.tsx | 44 +-- .../enterprise_search_content/routes.ts | 1 + .../applications/shared/constants/actions.ts | 4 + .../lib/connectors/generate_connector_name.ts | 47 ++- .../routes/enterprise_search/connectors.ts | 10 +- .../translations/translations/fr-FR.json | 2 - .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - 34 files changed, 2336 insertions(+), 158 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/assets/connector_logo.svg create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/assets/connector_logos_comp.png create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/components/choose_connector_selectable.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/components/connector_description_popover.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/components/manual_configuration.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/components/manual_configuration_flyout.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/configuration_step.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/create_connector.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/deployment_step.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/finish_up_step.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/start_step.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/utils/generate_step_state.ts diff --git a/packages/kbn-search-connectors/components/configuration/connector_configuration.tsx b/packages/kbn-search-connectors/components/configuration/connector_configuration.tsx index 34cb1a4b0ed7a..8cb83176a6591 100644 --- a/packages/kbn-search-connectors/components/configuration/connector_configuration.tsx +++ b/packages/kbn-search-connectors/components/configuration/connector_configuration.tsx @@ -45,6 +45,7 @@ interface ConnectorConfigurationProps { hasPlatinumLicense: boolean; isLoading: boolean; saveConfig: (configuration: Record) => void; + saveAndSync?: (configuration: Record) => void; stackManagementLink?: string; subscriptionLink?: string; children?: React.ReactNode; @@ -90,6 +91,7 @@ export const ConnectorConfigurationComponent: FC< hasPlatinumLicense, isLoading, saveConfig, + saveAndSync, subscriptionLink, stackManagementLink, }) => { @@ -166,6 +168,12 @@ export const ConnectorConfigurationComponent: FC< saveConfig(config); setIsEditing(false); }} + {...(saveAndSync && { + saveAndSync: (config) => { + saveAndSync(config); + setIsEditing(false); + }, + })} /> ) : ( uncategorizedDisplayList.length > 0 && ( diff --git a/packages/kbn-search-connectors/components/configuration/connector_configuration_form.tsx b/packages/kbn-search-connectors/components/configuration/connector_configuration_form.tsx index f7e619f407f12..9b83f7c0d3302 100644 --- a/packages/kbn-search-connectors/components/configuration/connector_configuration_form.tsx +++ b/packages/kbn-search-connectors/components/configuration/connector_configuration_form.tsx @@ -36,6 +36,7 @@ interface ConnectorConfigurationForm { isLoading: boolean; isNative: boolean; saveConfig: (config: Record) => void; + saveAndSync?: (config: Record) => void; stackManagementHref?: string; subscriptionLink?: string; } @@ -60,6 +61,7 @@ export const ConnectorConfigurationForm: React.FC = isLoading, isNative, saveConfig, + saveAndSync, }) => { const [localConfig, setLocalConfig] = useState(configuration); const [configView, setConfigView] = useState( @@ -167,19 +169,7 @@ export const ConnectorConfigurationForm: React.FC = )} - - - - {i18n.translate('searchConnectors.configurationConnector.config.submitButton.title', { - defaultMessage: 'Save configuration', - })} - - + = )} + + + {i18n.translate('searchConnectors.configurationConnector.config.submitButton.title', { + defaultMessage: 'Save', + })} + + + {saveAndSync && ( + + { + saveAndSync(configViewToConfigValues(configView)); + }} + > + {i18n.translate( + 'searchConnectors.configurationConnector.config.submitButton.title', + { + defaultMessage: 'Save and sync', + } + )} + + + )} diff --git a/x-pack/plugins/enterprise_search/common/constants.ts b/x-pack/plugins/enterprise_search/common/constants.ts index 795237ef9b427..4da0244b2ec5e 100644 --- a/x-pack/plugins/enterprise_search/common/constants.ts +++ b/x-pack/plugins/enterprise_search/common/constants.ts @@ -5,6 +5,8 @@ * 2.0. */ +import dedent from 'dedent'; + import { ENTERPRISE_SEARCH_APP_ID, ENTERPRISE_SEARCH_CONTENT_APP_ID, @@ -210,6 +212,58 @@ export const SEARCH_RELEVANCE_PLUGIN = { SUPPORT_URL: 'https://discuss.elastic.co/c/enterprise-search/', }; +export const CREATE_CONNECTOR_PLUGIN = { + CLI_SNIPPET: dedent`./bin/connectors connector create + --index-name my-index + --index-language en + --from-file config.yml + `, + CONSOLE_SNIPPET: dedent`# Create an index +PUT /my-index-000001 +{ + "settings": { + "index": { + "number_of_shards": 3, + "number_of_replicas": 2 + } + } +} + +# Create an API key +POST /_security/api_key +{ + "name": "my-api-key", + "expiration": "1d", + "role_descriptors": + { + "role-a": { + "cluster": ["all"], + "indices": [ + { + "names": ["index-a*"], + "privileges": ["read"] + } + ] + }, + "role-b": { + "cluster": ["all"], + "indices": [ + { + "names": ["index-b*"], + "privileges": ["all"] + }] + } + }, "metadata": + { "application": "my-application", + "environment": { + "level": 1, + "trusted": true, + "tags": ["dev", "staging"] + } + } + }`, +}; + export const LICENSED_SUPPORT_URL = 'https://support.elastic.co'; export const JSON_HEADER = { diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector/add_connector_api_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector/add_connector_api_logic.ts index be8e23bdca1c5..3593a7b123533 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector/add_connector_api_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector/add_connector_api_logic.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { createApiLogic } from '../../../shared/api_logic/create_api_logic'; +import { Actions, createApiLogic } from '../../../shared/api_logic/create_api_logic'; import { HttpLogic } from '../../../shared/http'; interface AddConnectorValue { @@ -20,11 +20,17 @@ export interface AddConnectorApiLogicArgs { language: string | null; name: string; serviceType?: string; + // Without a proper refactoring there is no good way to chain actions. + // This prop is simply passed back with the result to let listeners + // know what was the intent of the request. And call the next action + // accordingly. + uiFlags?: Record; } export interface AddConnectorApiLogicResponse { id: string; indexName: string; + uiFlags?: Record; } export const addConnector = async ({ @@ -34,6 +40,7 @@ export const addConnector = async ({ isNative, language, serviceType, + uiFlags, }: AddConnectorApiLogicArgs): Promise => { const route = '/internal/enterprise_search/connectors'; @@ -54,7 +61,12 @@ export const addConnector = async ({ return { id: result.id, indexName: result.index_name, + uiFlags, }; }; export const AddConnectorApiLogic = createApiLogic(['add_connector_api_logic'], addConnector); +export type AddConnectorApiLogicActions = Actions< + AddConnectorApiLogicArgs, + AddConnectorApiLogicResponse +>; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector/generate_connector_config_api_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector/generate_connector_config_api_logic.ts index 21edf734bc230..449d3f6628648 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector/generate_connector_config_api_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector/generate_connector_config_api_logic.ts @@ -5,13 +5,15 @@ * 2.0. */ -import { createApiLogic } from '../../../shared/api_logic/create_api_logic'; +import { Actions, createApiLogic } from '../../../shared/api_logic/create_api_logic'; import { HttpLogic } from '../../../shared/http'; export interface GenerateConfigApiArgs { connectorId: string; } +export type GenerateConfigApiActions = Actions; + export const generateConnectorConfig = async ({ connectorId }: GenerateConfigApiArgs) => { const route = `/internal/enterprise_search/connectors/${connectorId}/generate_config`; return await HttpLogic.values.http.post(route); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector/generate_connector_names_api_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector/generate_connector_names_api_logic.ts index 5583c8c8e22e4..8d2ee0ee87aa3 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector/generate_connector_names_api_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/api/connector/generate_connector_names_api_logic.ts @@ -4,23 +4,38 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { createApiLogic } from '../../../shared/api_logic/create_api_logic'; +import { Actions, createApiLogic } from '../../../shared/api_logic/create_api_logic'; import { HttpLogic } from '../../../shared/http'; export interface GenerateConnectorNamesApiArgs { + connectorName?: string; connectorType?: string; } +export interface GenerateConnectorNamesApiResponse { + apiKeyName: string; + connectorName: string; + indexName: string; +} + export const generateConnectorNames = async ( - { connectorType }: GenerateConnectorNamesApiArgs = { connectorType: 'custom' } + { connectorType, connectorName }: GenerateConnectorNamesApiArgs = { connectorType: 'custom' } ) => { + if (connectorType === '') { + connectorType = 'custom'; + } const route = `/internal/enterprise_search/connectors/generate_connector_name`; return await HttpLogic.values.http.post(route, { - body: JSON.stringify({ connectorType }), + body: JSON.stringify({ connectorName, connectorType }), }); }; export const GenerateConnectorNamesApiLogic = createApiLogic( - ['generate_config_api_logic'], + ['generate_connector_names_api_logic'], generateConnectorNames ); + +export type GenerateConnectorNamesApiLogicActions = Actions< + GenerateConnectorNamesApiArgs, + GenerateConnectorNamesApiResponse +>; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/generate_config_button.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/generate_config_button.tsx index bb34d652ee74d..ed28ba575d824 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/generate_config_button.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/components/generate_config_button.tsx @@ -12,13 +12,15 @@ import { i18n } from '@kbn/i18n'; export interface GenerateConfigButtonProps { connectorId: string; + disabled?: boolean; generateConfiguration: (params: { connectorId: string }) => void; isGenerateLoading: boolean; } export const GenerateConfigButton: React.FC = ({ connectorId, + disabled, generateConfiguration, - isGenerateLoading, + isGenerateLoading = false, }) => { return ( @@ -26,6 +28,7 @@ export const GenerateConfigButton: React.FC = ({ void; + generateApiKey?: () => void; isGenerateLoading: boolean; } @@ -93,7 +93,7 @@ export const GeneratedConfigFields: React.FC = ({ }; const onConfirm = () => { - generateApiKey(); + if (generateApiKey) generateApiKey(); setIsModalVisible(false); }; @@ -222,16 +222,18 @@ export const GeneratedConfigFields: React.FC = ({ {apiKey?.encoded} - - - + {generateApiKey && ( + + + + )} = ({ ) : ( - - - + generateApiKey && ( + + + + ) )} diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/deployment.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/deployment.tsx index 2c20902793093..e3bd0e867af3d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/deployment.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/deployment.tsx @@ -61,6 +61,22 @@ export const ConnectorDeployment: React.FC = () => { Record >('search:connector-ui-options', {}); + useEffect(() => { + if (connectorId && connector && connector.api_key_id) { + getApiKeyById(connector.api_key_id); + } + }, [connector, connectorId]); + + const selectDeploymentMethod = (deploymentMethod: 'docker' | 'source') => { + if (connector) { + setSelectedDeploymentMethod(deploymentMethod); + setConnectorUiOptions({ + ...connectorUiOptions, + [connector.id]: { deploymentMethod }, + }); + } + }; + useEffect(() => { if (connectorUiOptions && connectorId && connectorUiOptions[connectorId]) { setSelectedDeploymentMethod(connectorUiOptions[connectorId].deploymentMethod); @@ -68,25 +84,10 @@ export const ConnectorDeployment: React.FC = () => { selectDeploymentMethod('docker'); } }, [connectorUiOptions, connectorId]); - - useEffect(() => { - if (connectorId && connector && connector.api_key_id) { - getApiKeyById(connector.api_key_id); - } - }, [connector, connectorId]); - if (!connector || connector.is_native) { return <>; } - const selectDeploymentMethod = (deploymentMethod: 'docker' | 'source') => { - setSelectedDeploymentMethod(deploymentMethod); - setConnectorUiOptions({ - ...connectorUiOptions, - [connector.id]: { deploymentMethod }, - }); - }; - const hasApiKey = !!(connector.api_key_id ?? generatedData?.apiKey); const isWaitingForConnector = !connector.status || connector.status === ConnectorStatus.CREATED; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/deployment_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/deployment_logic.ts index 09c2c8db48e03..13f3cc0b30369 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/deployment_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connector_detail/deployment_logic.ts @@ -10,15 +10,12 @@ import { kea, MakeLogicType } from 'kea'; import { Connector } from '@kbn/search-connectors'; import { HttpError, Status } from '../../../../../common/types/api'; -import { Actions } from '../../../shared/api_logic/create_api_logic'; import { - GenerateConfigApiArgs, + GenerateConfigApiActions, GenerateConfigApiLogic, } from '../../api/connector/generate_connector_config_api_logic'; import { APIKeyResponse } from '../../api/generate_api_key/generate_api_key_logic'; -type GenerateConfigApiActions = Actions; - export interface DeploymentLogicValues { generateConfigurationError: HttpError; generateConfigurationStatus: Status; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/connectors.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/connectors.tsx index a29f6c540b7ce..c12dd8036b6b9 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/connectors.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/connectors.tsx @@ -44,8 +44,8 @@ import { ConnectorStats } from './connector_stats'; import { ConnectorsLogic } from './connectors_logic'; import { ConnectorsTable } from './connectors_table'; import { CrawlerEmptyState } from './crawler_empty_state'; +import { CreateConnector } from './create_connector'; import { DeleteConnectorModal } from './delete_connector_modal'; -import { SelectConnector } from './select_connector/select_connector'; export const connectorsBreadcrumbs = [ i18n.translate('xpack.enterpriseSearch.content.connectors.breadcrumb', { @@ -81,7 +81,7 @@ export const Connectors: React.FC = ({ isCrawler }) => { }, [searchParams.from, searchParams.size, searchQuery, isCrawler]); return !isLoading && isEmpty && !isCrawler ? ( - + ) : ( <> diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/connectors_router.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/connectors_router.tsx index dc5ed0342c3be..9020a1d165168 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/connectors_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/connectors_router.tsx @@ -13,23 +13,27 @@ import { CONNECTORS_PATH, NEW_INDEX_SELECT_CONNECTOR_PATH, NEW_CONNECTOR_PATH, + NEW_CONNECTOR_FLOW_PATH, CONNECTOR_DETAIL_PATH, } from '../../routes'; import { ConnectorDetailRouter } from '../connector_detail/connector_detail_router'; import { NewSearchIndexPage } from '../new_index/new_search_index_page'; import { Connectors } from './connectors'; -import { SelectConnector } from './select_connector/select_connector'; +import { CreateConnector } from './create_connector'; export const ConnectorsRouter: React.FC = () => { return ( - + + + + diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/assets/connector_logo.svg b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/assets/connector_logo.svg new file mode 100644 index 0000000000000..f827c8dce36eb --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/assets/connector_logo.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/assets/connector_logos_comp.png b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/assets/connector_logos_comp.png new file mode 100644 index 0000000000000000000000000000000000000000..22f5ad4c31a315f9920fc0d84222fb8f9b855886 GIT binary patch literal 80544 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

=Fn_|x6KHk707#Dy1LSTt(f#G>+f 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 Ef`D = ({ + selfManaged, +}) => { + const { euiTheme } = useEuiTheme(); + const [isOpen, setIsOpen] = useState(false); + const [selectableOptions, selectableSetOptions] = useState< + Array> + >([]); + const { connectorTypes } = useValues(KibanaLogic); + const allConnectors = useMemo( + () => connectorTypes.sort((a, b) => a.name.localeCompare(b.name)), + [connectorTypes] + ); + const { selectedConnector } = useValues(NewConnectorLogic); + const { setSelectedConnector } = useActions(NewConnectorLogic); + + const getInitialOptions = () => { + return allConnectors.map((connector, key) => { + const append: JSX.Element[] = []; + if (connector.isTechPreview) { + append.push( + + {i18n.translate( + 'xpack.enterpriseSearch.createConnector.chooseConnectorSelectable.thechPreviewBadgeLabel', + { defaultMessage: 'Tech preview' } + )} + + ); + } + if (connector.isBeta) { + append.push( + + {i18n.translate( + 'xpack.enterpriseSearch.createConnector.chooseConnectorSelectable.BetaBadgeLabel', + { + defaultMessage: 'Beta', + } + )} + + ); + } + if (selfManaged === 'native' && !connector.isNative) { + append.push( + + {i18n.translate( + 'xpack.enterpriseSearch.createConnector.chooseConnectorSelectable.OnlySelfManagedBadgeLabel', + { + defaultMessage: 'Self managed', + } + )} + + ); + } + + return { + append, + key: key.toString(), + label: connector.name, + prepend: , + }; + }); + }; + + const initialOptions = getInitialOptions(); + + useEffect(() => { + selectableSetOptions(initialOptions); + }, [selfManaged]); + const [searchValue, setSearchValue] = useState(''); + + const openPopover = useCallback(() => { + setIsOpen(true); + }, []); + const closePopover = useCallback(() => { + setIsOpen(false); + }, []); + + return ( + + { + selectableSetOptions(newOptions); + closePopover(); + if (changedOption.checked === 'on') { + const keySelected = Number(changedOption.key); + setSelectedConnector(allConnectors[keySelected]); + setSearchValue(allConnectors[keySelected].name); + } else { + setSelectedConnector(null); + setSearchValue(''); + } + }} + listProps={{ + isVirtualized: true, + rowHeight: Number(euiTheme.base * 3), + showIcons: false, + }} + singleSelection + searchable + searchProps={{ + fullWidth: true, + isClearable: true, + onChange: (value) => { + if (value !== selectedConnector?.name) { + setSearchValue(value); + } + }, + onClick: openPopover, + onFocus: openPopover, + placeholder: i18n.translate( + 'xpack.enterpriseSearch.createConnector.chooseConnectorSelectable.placeholder.text', + { defaultMessage: 'Choose a data source' } + ), + value: searchValue, + }} + > + {(list, search) => ( + + {list} + + )} + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/components/connector_description_popover.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/components/connector_description_popover.tsx new file mode 100644 index 0000000000000..b19a5ac8ddba5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/components/connector_description_popover.tsx @@ -0,0 +1,166 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useState } from 'react'; + +import { + EuiButtonIcon, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiPanel, + EuiPopover, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import connectorLogo from '../../../../../../assets/images/connector_logo_network_drive_version.svg'; + +const nativePopoverPanels = [ + { + description: i18n.translate( + 'xpack.enterpriseSearch.connectorDescriptionPopover.connectorDescriptionBadge.native.chooseADataSourceLabel', + { defaultMessage: 'Choose a data source you would like to sync' } + ), + icons: [], + id: 'native-choose-source', + }, + { + description: i18n.translate( + 'xpack.enterpriseSearch.connectorDescriptionPopover.connectorDescriptionBadge.native.configureConnectorLabel', + { defaultMessage: 'Configure your connector using our Kibana UI' } + ), + icons: [, ], + id: 'native-configure-connector', + }, +]; + +const connectorClientPopoverPanels = [ + { + description: i18n.translate( + 'xpack.enterpriseSearch.connectorDescriptionPopover.connectorDescriptionBadge.client.chooseADataSourceLabel', + { defaultMessage: 'Choose a data source you would like to sync' } + ), + icons: [], + id: 'client-choose-source', + }, + { + description: i18n.translate( + 'xpack.enterpriseSearch.connectorDescriptionPopover.connectorDescriptionBadge.client.configureConnectorLabel', + { + defaultMessage: + 'Deploy connector code on your own infrastructure by running from source or using Docker', + } + ), + icons: [ + , + , + , + ], + id: 'client-deploy', + }, + { + description: i18n.translate( + 'xpack.enterpriseSearch.connectorDescriptionPopover.connectorDescriptionBadge.client.enterDetailsLabel', + { + defaultMessage: 'Enter access and connection details for your data source', + } + ), + icons: [ + , + , + , + , + , + ], + id: 'client-configure-connector', + }, +]; + +export interface ConnectorDescriptionPopoverProps { + isDisabled: boolean; + isNative: boolean; +} + +export const ConnectorDescriptionPopover: React.FC = ({ + isNative, + isDisabled, +}) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const panels = isNative ? nativePopoverPanels : connectorClientPopoverPanels; + return ( + setIsPopoverOpen(!isPopoverOpen)} + /> + } + isOpen={isPopoverOpen} + closePopover={() => { + setIsPopoverOpen(false); + }} + > + + {isDisabled && ( + + + + + + )} + + + {panels.map((panel) => { + return ( + + + + + {panel.icons.map((icon, index) => ( + + {icon} + + ))} + + + + +

{panel.description}

+ + + + + ); + })} + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/components/manual_configuration.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/components/manual_configuration.tsx new file mode 100644 index 0000000000000..13273266a2068 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/components/manual_configuration.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useState } from 'react'; + +import { + EuiButtonIcon, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiPopover, + useGeneratedHtmlId, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { SelfManagePreference } from '../create_connector'; + +import { ManualConfigurationFlyout } from './manual_configuration_flyout'; + +export interface ManualConfigurationProps { + isDisabled: boolean; + selfManagePreference: SelfManagePreference; +} + +export const ManualConfiguration: React.FC = ({ + isDisabled, + selfManagePreference, +}) => { + const [isPopoverOpen, setPopover] = useState(false); + const splitButtonPopoverId = useGeneratedHtmlId({ + prefix: 'splitButtonPopover', + }); + const onButtonClick = () => { + setPopover(!isPopoverOpen); + }; + + const closePopover = () => { + setPopover(false); + }; + + const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); + const [flyoutContent, setFlyoutContent] = useState<'manual_config' | 'client'>(); + + const items = [ + { + setFlyoutContent('manual_config'); + setIsFlyoutVisible(true); + closePopover(); + }} + > + {i18n.translate( + 'xpack.enterpriseSearch.createConnector.finishUpStep.manageAttachedIndexContextMenuItemLabel', + { defaultMessage: 'Manual configuration' } + )} + , + { + setFlyoutContent('client'); + setIsFlyoutVisible(true); + closePopover(); + }} + > + {i18n.translate( + 'xpack.enterpriseSearch.createConnector.finishUpStep.scheduleASyncContextMenuItemLabel', + { + defaultMessage: 'Try with CLI', + } + )} + , + ]; + + return ( + <> + + } + isOpen={isPopoverOpen} + closePopover={closePopover} + panelPaddingSize="none" + anchorPosition="downLeft" + > + + + {isFlyoutVisible && ( + + )} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/components/manual_configuration_flyout.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/components/manual_configuration_flyout.tsx new file mode 100644 index 0000000000000..6fc80ec3a81f1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/components/manual_configuration_flyout.tsx @@ -0,0 +1,228 @@ +/* + * 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 { useActions, useValues } from 'kea'; + +import { + EuiButton, + EuiButtonEmpty, + EuiCode, + EuiCodeBlock, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiFormRow, + EuiLink, + EuiPanel, + EuiSpacer, + EuiText, + EuiTitle, + useGeneratedHtmlId, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { FormattedMessage } from '@kbn/i18n-react'; + +import { CREATE_CONNECTOR_PLUGIN } from '../../../../../../../common/constants'; +import { NewConnectorLogic } from '../../../new_index/method_connector/new_connector_logic'; + +import { SelfManagePreference } from '../create_connector'; + +const CLI_LABEL = i18n.translate( + 'xpack.enterpriseSearch.createConnector.manualConfiguration.cliLabel', + { + defaultMessage: 'CLI', + } +); + +export interface ManualConfigurationFlyoutProps { + flyoutContent: string | undefined; + selfManagePreference: SelfManagePreference; + setIsFlyoutVisible: (value: boolean) => void; +} +export const ManualConfigurationFlyout: React.FC = ({ + flyoutContent, + selfManagePreference, + setIsFlyoutVisible, +}) => { + const simpleFlyoutTitleId = useGeneratedHtmlId({ + prefix: 'simpleFlyoutTitle', + }); + + const { connectorName } = useValues(NewConnectorLogic); + const { setRawName, createConnector } = useActions(NewConnectorLogic); + + return ( + setIsFlyoutVisible(false)} + aria-labelledby={simpleFlyoutTitleId} + size="s" + > + {flyoutContent === 'manual_config' && ( + <> + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.createConnector.manualConfiguration.h2.cliLabel', + { + defaultMessage: 'Manual configuration', + } + )} +

+
+ + +

+ + {i18n.translate( + 'xpack.enterpriseSearch.createConnector.manualConfiguration.generateConfigLinkLabel', + { + defaultMessage: 'Generate configuration', + } + )} + + ), + }} + /> +

+
+
+ + + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.createConnector.manualConfiguration.connectorName', + { + defaultMessage: 'Connector', + } + )} +

+
+ + + { + setRawName(e.target.value); + }} + /> + + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.createConnector.manualConfiguration.p.connectorNameDescription', + { + defaultMessage: + 'You will be redirected to the connector page to configure the rest of your connector', + } + )} +

+
+
+
+
+ + + + setIsFlyoutVisible(false)} + flush="left" + > + {i18n.translate( + 'xpack.enterpriseSearch.createConnector.flyoutManualConfigContent.closeButtonEmptyLabel', + { defaultMessage: 'Close' } + )} + + + + { + createConnector({ + isSelfManaged: selfManagePreference === 'selfManaged', + shouldGenerateAfterCreate: false, + shouldNavigateToConnectorAfterCreate: true, + }); + }} + fill + > + {i18n.translate( + 'xpack.enterpriseSearch.createConnector.flyoutManualConfigContent.saveConfigurationButtonLabel', + { defaultMessage: 'Save configuration' } + )} + + + + + + )} + {flyoutContent === 'client' && ( + <> + + +

{CLI_LABEL}

+
+
+ + +

+ + {CLI_LABEL} + + ), + myIndex: my-index, + }} + /> +

+
+ + + {CREATE_CONNECTOR_PLUGIN.CLI_SNIPPET} + +
+ + )} +
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/configuration_step.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/configuration_step.tsx new file mode 100644 index 0000000000000..8644cd72f53d3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/configuration_step.tsx @@ -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 React, { useEffect } from 'react'; + +import { useActions, useValues } from 'kea'; + +import { + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiSpacer, + EuiTitle, + EuiText, + EuiButton, + EuiProgress, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { ConnectorConfigurationComponent, ConnectorStatus } from '@kbn/search-connectors'; + +import { Status } from '../../../../../../common/types/api'; + +import * as Constants from '../../../../shared/constants'; +import { ConnectorConfigurationApiLogic } from '../../../api/connector/update_connector_configuration_api_logic'; +import { ConnectorViewLogic } from '../../connector_detail/connector_view_logic'; + +interface ConfigurationStepProps { + setCurrentStep: Function; + title: string; +} + +export const ConfigurationStep: React.FC = ({ title, setCurrentStep }) => { + const { connector } = useValues(ConnectorViewLogic); + const { updateConnectorConfiguration } = useActions(ConnectorViewLogic); + const { status } = useValues(ConnectorConfigurationApiLogic); + const isSyncing = false; + + const isNextStepEnabled = + connector?.status === ConnectorStatus.CONNECTED || + connector?.status === ConnectorStatus.CONFIGURED; + + useEffect(() => { + setTimeout(() => { + window.scrollTo({ + behavior: 'smooth', + top: 0, + }); + }, 100); + }, []); + + if (!connector) return null; + + return ( + <> + + + + +

{title}

+
+ + { + updateConnectorConfiguration({ + configuration: config, + connectorId: connector.id, + }); + }} + /> + + {isSyncing && ( + + )} +
+
+ + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.createConnector.configurationStep.h4.finishUpLabel', + { + defaultMessage: 'Finish up', + } + )} +

+
+ + +

+ {i18n.translate( + 'xpack.enterpriseSearch.createConnector.configurationStep.p.description', + { + defaultMessage: + 'You can manually sync your data, schedule a recurring sync or manage your domains.', + } + )} +

+
+ + setCurrentStep('finish')} + fill + > + {Constants.NEXT_BUTTON_LABEL} + +
+
+
+ + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/create_connector.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/create_connector.tsx new file mode 100644 index 0000000000000..e8cef81662096 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/create_connector.tsx @@ -0,0 +1,265 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, useState } from 'react'; + +import { css } from '@emotion/react'; + +import { useActions, useValues } from 'kea'; + +import { + EuiBadge, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiIcon, + EuiLink, + EuiPanel, + EuiSpacer, + EuiSteps, + EuiSuperSelect, + EuiText, + useEuiTheme, +} from '@elastic/eui'; + +import { EuiContainedStepProps } from '@elastic/eui/src/components/steps/steps'; +import { i18n } from '@kbn/i18n'; + +import { AddConnectorApiLogic } from '../../../api/connector/add_connector_api_logic'; +import { EnterpriseSearchContentPageTemplate } from '../../layout'; +import { NewConnectorLogic } from '../../new_index/method_connector/new_connector_logic'; +import { errorToText } from '../../new_index/utils/error_to_text'; +import { connectorsBreadcrumbs } from '../connectors'; + +import { generateStepState } from '../utils/generate_step_state'; + +import connectorsBackgroundImage from './assets/connector_logos_comp.png'; + +import { ConfigurationStep } from './configuration_step'; +import { DeploymentStep } from './deployment_step'; +import { FinishUpStep } from './finish_up_step'; +import { StartStep } from './start_step'; + +export type ConnectorCreationSteps = 'start' | 'deployment' | 'configure' | 'finish'; +export type SelfManagePreference = 'native' | 'selfManaged'; +export const CreateConnector: React.FC = () => { + const { error } = useValues(AddConnectorApiLogic); + const { euiTheme } = useEuiTheme(); + const [selfManagePreference, setSelfManagePreference] = useState('native'); + + const { selectedConnector, currentStep } = useValues(NewConnectorLogic); + const { setCurrentStep } = useActions(NewConnectorLogic); + const stepStates = generateStepState(currentStep); + + useEffect(() => { + // TODO: separate this to ability and preference + if (!selectedConnector?.isNative || !selfManagePreference) { + setSelfManagePreference('selfManaged'); + } else { + setSelfManagePreference('native'); + } + }, [selectedConnector]); + + const getSteps = (selfManaged: boolean): EuiContainedStepProps[] => { + return [ + { + children: null, + status: stepStates.start, + title: i18n.translate('xpack.enterpriseSearch.createConnector.startStep.startLabel', { + defaultMessage: 'Start', + }), + }, + ...(selfManaged + ? [ + { + children: null, + status: stepStates.deployment, + title: i18n.translate( + 'xpack.enterpriseSearch.createConnector.deploymentStep.deploymentLabel', + { defaultMessage: 'Deployment' } + ), + }, + ] + : []), + { + children: null, + status: stepStates.configure, + title: i18n.translate( + 'xpack.enterpriseSearch.createConnector.configurationStep.configurationLabel', + { defaultMessage: 'Configuration' } + ), + }, + + { + children: null, + status: stepStates.finish, + title: i18n.translate('xpack.enterpriseSearch.createConnector.finishUpStep.finishUpLabel', { + defaultMessage: 'Finish up', + }), + }, + ]; + }; + + const stepContent: Record<'start' | 'deployment' | 'configure' | 'finish', React.ReactNode> = { + configure: ( + + ), + deployment: , + finish: ( + + ), + start: ( + { + setSelfManagePreference(preference); + }} + error={errorToText(error)} + /> + ), + }; + + return ( + + + {/* Col 1 */} + + + css` + .euiStep__content { + padding-block-end: ${euiTheme.size.xs}; + } + `} + /> + + {selectedConnector?.docsUrl && selectedConnector?.docsUrl !== '' && ( + <> + +

+ + {'Elastic '} + {selectedConnector?.name} + {i18n.translate( + 'xpack.enterpriseSearch.createConnector.connectorDocsLinkLabel', + { defaultMessage: ' connector reference' } + )} + +

+
+ + + )} + {currentStep !== 'start' && ( + <> + + + + {selectedConnector?.name} + + ), + value: 'item1', + }, + ]} + /> + + + + {selfManagePreference + ? i18n.translate( + 'xpack.enterpriseSearch.createConnector.badgeType.selfManaged', + { + defaultMessage: 'Self managed', + } + ) + : i18n.translate( + 'xpack.enterpriseSearch.createConnector.badgeType.ElasticManaged', + { + defaultMessage: 'Elastic managed', + } + )} + + + )} +
+
+ {/* Col 2 */} + {stepContent[currentStep]} +
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/deployment_step.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/deployment_step.tsx new file mode 100644 index 0000000000000..6e5245f072b4b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/deployment_step.tsx @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect } from 'react'; + +import { useValues } from 'kea'; + +import { EuiFlexItem, EuiPanel, EuiSpacer, EuiText, EuiButton, EuiFlexGroup } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { ConnectorStatus } from '@kbn/search-connectors'; + +import * as Constants from '../../../../shared/constants'; +import { ConnectorViewLogic } from '../../connector_detail/connector_view_logic'; +import { ConnectorDeployment } from '../../connector_detail/deployment'; + +interface DeploymentStepProps { + setCurrentStep: Function; +} + +export const DeploymentStep: React.FC = ({ setCurrentStep }) => { + const { connector } = useValues(ConnectorViewLogic); + const isNextStepEnabled = + connector && !(!connector.status || connector.status === ConnectorStatus.CREATED); + + useEffect(() => { + setTimeout(() => { + window.scrollTo({ + behavior: 'smooth', + top: 0, + }); + }, 100); + }, []); + return ( + + + + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.createConnector.DeploymentStep.Configuration.title', + { + defaultMessage: 'Configuration', + } + )} +

+
+ + +

+ {i18n.translate( + 'xpack.enterpriseSearch.createConnector.DeploymentStep.Configuration.description', + { + defaultMessage: 'Now configure your Elastic crawler and sync the data.', + } + )} +

+
+ + setCurrentStep('configure')} + fill + disabled={!isNextStepEnabled} + > + {Constants.NEXT_BUTTON_LABEL} + +
+
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/finish_up_step.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/finish_up_step.tsx new file mode 100644 index 0000000000000..28d5387ae4b70 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/finish_up_step.tsx @@ -0,0 +1,348 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, useState } from 'react'; + +import { css } from '@emotion/react'; + +import { useActions, useValues } from 'kea'; + +import { + EuiButton, + EuiCard, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiIcon, + EuiPanel, + EuiSpacer, + EuiTitle, + useEuiTheme, + EuiProgress, + EuiText, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { useKibana } from '@kbn/kibana-react-plugin/public'; + +import { APPLICATIONS_PLUGIN } from '../../../../../../common/constants'; + +import { KibanaDeps } from '../../../../../../common/types'; + +import { PLAYGROUND_PATH } from '../../../../applications/routes'; +import { generateEncodedPath } from '../../../../shared/encode_path_params'; +import { KibanaLogic } from '../../../../shared/kibana'; + +import { CONNECTOR_DETAIL_TAB_PATH } from '../../../routes'; +import { ConnectorDetailTabId } from '../../connector_detail/connector_detail'; +import { ConnectorViewLogic } from '../../connector_detail/connector_view_logic'; +import { IndexViewLogic } from '../../search_index/index_view_logic'; +import { SyncsLogic } from '../../shared/header_actions/syncs_logic'; + +import connectorLogo from './assets/connector_logo.svg'; + +interface FinishUpStepProps { + title: string; +} + +export const FinishUpStep: React.FC = ({ title }) => { + const { euiTheme } = useEuiTheme(); + const { + services: { discover }, + } = useKibana(); + const [showNext, setShowNext] = useState(false); + + const { isWaitingForSync, isSyncing: isSyncingProp } = useValues(IndexViewLogic); + const { connector } = useValues(ConnectorViewLogic); + const { startSync } = useActions(SyncsLogic); + + const isSyncing = isWaitingForSync || isSyncingProp; + useEffect(() => { + setTimeout(() => { + window.scrollTo({ + behavior: 'smooth', + top: 0, + }); + }, 100); + }, []); + return ( + <> + + + + +

{title}

+
+ + {isSyncing && ( + <> + + + + {i18n.translate( + 'xpack.enterpriseSearch.createConnector.finishUpStep.syncingDataTextLabel', + { + defaultMessage: 'Syncing data', + } + )} + + + + + { + setShowNext(true); + }} + /> + + + )} + + + + } + titleSize="s" + title={i18n.translate( + 'xpack.enterpriseSearch.createConnector.finishUpStep.euiCard.chatWithYourDataLabel', + { defaultMessage: 'Chat with your data' } + )} + description={i18n.translate( + 'xpack.enterpriseSearch.createConnector.finishUpStep.euiCard.chatWithYourDataDescriptionl', + { + defaultMessage: + 'Combine your data with the power of LLMs for retrieval augmented generation (RAG)', + } + )} + footer={ + showNext ? ( + { + if (connector) { + KibanaLogic.values.navigateToUrl( + `${APPLICATIONS_PLUGIN.URL}${PLAYGROUND_PATH}?default-index=${connector.index_name}`, + { shouldNotCreateHref: true } + ); + } + }} + > + {i18n.translate( + 'xpack.enterpriseSearch.createConnector.finishUpStep.startSearchPlaygroundButtonLabel', + { defaultMessage: 'Start Search Playground' } + )} + + ) : ( + { + startSync(connector); + setShowNext(true); + }} + > + {isSyncing ? 'Syncing data' : 'First sync data'} + + ) + } + /> + + + } + titleSize="s" + title={i18n.translate( + 'xpack.enterpriseSearch.createConnector.finishUpStep.euiCard.exploreYourDataLabel', + { defaultMessage: 'Explore your data' } + )} + description={i18n.translate( + 'xpack.enterpriseSearch.createConnector.finishUpStep.euiCard.exploreYourDataDescription', + { + defaultMessage: + 'See your connector documents or make a data view to explore them', + } + )} + footer={ + showNext ? ( + { + discover.locator?.navigate({ + dataViewSpec: { + title: connector?.name, + }, + indexPattern: connector?.index_name, + title: connector?.name, + }); + }} + > + {i18n.translate( + 'xpack.enterpriseSearch.createConnector.finishUpStep.viewInDiscoverButtonLabel', + { defaultMessage: 'View in Discover' } + )} + + ) : ( + { + startSync(connector); + setShowNext(true); + }} + > + {isSyncing ? 'Syncing data' : 'First sync data'} + + ) + } + /> + + + } + titleSize="s" + title={i18n.translate( + 'xpack.enterpriseSearch.createConnector.finishUpStep.euiCard.manageYourConnectorLabel', + { defaultMessage: 'Manage your connector' } + )} + description={i18n.translate( + 'xpack.enterpriseSearch.createConnector.finishUpStep.euiCard.manageYourConnectorDescription', + { + defaultMessage: + 'Now you can manage your connector, schedule a sync and much more', + } + )} + footer={ + + + { + if (connector) { + KibanaLogic.values.navigateToUrl( + generateEncodedPath(CONNECTOR_DETAIL_TAB_PATH, { + connectorId: connector.id, + tabId: ConnectorDetailTabId.CONFIGURATION, + }) + ); + } + }} + > + {i18n.translate( + 'xpack.enterpriseSearch.createConnector.finishUpStep.manageConnectorButtonLabel', + { defaultMessage: 'Manage connector' } + )} + + + + } + /> + + + + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.createConnector.finishUpStep.h3.queryYourDataLabel', + { + defaultMessage: 'Query your data', + } + )} +

+
+ + + + css` + margin-top: ${euiTheme.size.xs}; + `} + size="m" + type="visVega" + /> + } + title={i18n.translate( + 'xpack.enterpriseSearch.createConnector.finishUpStep.euiCard.queryWithLanguageClientsLabel', + { defaultMessage: 'Query with language clients' } + )} + titleSize="xs" + description={i18n.translate( + 'xpack.enterpriseSearch.createConnector.finishUpStep.euiCard.queryWithLanguageClientsLDescription', + { + defaultMessage: + 'Use your favorite language client to query your data in your app', + } + )} + onClick={() => {}} + display="subdued" + /> + + + css` + margin-top: ${euiTheme.size.xs}; + `} + size="m" + type="console" + /> + } + title={i18n.translate( + 'xpack.enterpriseSearch.createConnector.finishUpStep.euiCard.devToolsLabel', + { defaultMessage: 'Dev tools' } + )} + titleSize="xs" + description={i18n.translate( + 'xpack.enterpriseSearch.createConnector.finishUpStep.euiCard.devToolsDescription', + { + defaultMessage: + 'Tools for interacting with your data, such as console, profiler, Grok debugger and more', + } + )} + onClick={() => {}} + display="subdued" + /> + + +
+
+
+ + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/index.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/index.ts new file mode 100644 index 0000000000000..f3992cbcf9fc9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/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 { CreateConnector } from './create_connector'; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/start_step.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/start_step.tsx new file mode 100644 index 0000000000000..633ea8f58d25c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/start_step.tsx @@ -0,0 +1,340 @@ +/* + * 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, { ChangeEvent } from 'react'; + +import { useActions, useValues } from 'kea'; + +import { + EuiButton, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiPanel, + EuiRadio, + EuiSpacer, + EuiText, + EuiTitle, + useGeneratedHtmlId, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import * as Constants from '../../../../shared/constants'; +import { GeneratedConfigFields } from '../../connector_detail/components/generated_config_fields'; + +import { ConnectorViewLogic } from '../../connector_detail/connector_view_logic'; +import { NewConnectorLogic } from '../../new_index/method_connector/new_connector_logic'; + +import { ChooseConnectorSelectable } from './components/choose_connector_selectable'; +import { ConnectorDescriptionPopover } from './components/connector_description_popover'; +import { ManualConfiguration } from './components/manual_configuration'; +import { SelfManagePreference } from './create_connector'; + +interface StartStepProps { + error?: string | React.ReactNode; + onSelfManagePreferenceChange(preference: SelfManagePreference): void; + selfManagePreference: SelfManagePreference; + setCurrentStep: Function; + title: string; +} + +export const StartStep: React.FC = ({ + title, + selfManagePreference, + setCurrentStep, + onSelfManagePreferenceChange, + error, +}) => { + const elasticManagedRadioButtonId = useGeneratedHtmlId({ prefix: 'elasticManagedRadioButton' }); + const selfManagedRadioButtonId = useGeneratedHtmlId({ prefix: 'selfManagedRadioButton' }); + + const { + rawName, + canConfigureConnector, + selectedConnector, + generatedConfigData, + isGenerateLoading, + isCreateLoading, + } = useValues(NewConnectorLogic); + const { setRawName, createConnector, generateConnectorName } = useActions(NewConnectorLogic); + const { connector } = useValues(ConnectorViewLogic); + + const handleNameChange = (e: ChangeEvent) => { + setRawName(e.target.value); + }; + + return ( + + + + + +

{title}

+
+ + + + + + + + + + { + if (selectedConnector) { + generateConnectorName({ + connectorName: rawName, + connectorType: selectedConnector.serviceType, + }); + } + }} + /> + + + + + + + + + +
+
+ {/* Set up */} + + + +

+ {i18n.translate('xpack.enterpriseSearch.createConnector.startStep.h4.setUpLabel', { + defaultMessage: 'Set up', + })} +

+
+ + +

+ {i18n.translate( + 'xpack.enterpriseSearch.createConnector.startStep.p.whereDoYouWantLabel', + { + defaultMessage: + 'Where do you want to store the connector and how do you want to manage it?', + } + )} +

+
+ + + + onSelfManagePreferenceChange('native')} + name="setUp" + /> + + + + +     + + onSelfManagePreferenceChange('selfManaged')} + name="setUp" + /> + + + + + +
+
+ {selfManagePreference === 'selfManaged' ? ( + + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.createConnector.startStep.h4.deploymentLabel', + { + defaultMessage: 'Deployment', + } + )} +

+
+ + +

+ {i18n.translate( + 'xpack.enterpriseSearch.createConnector.startStep.p.youWillStartTheLabel', + { + defaultMessage: + 'You will start the process of creating a new index, API key, and a Web Crawler Connector ID manually. Optionally you can bring your own configuration as well.', + } + )} +

+
+ + { + if (selectedConnector && selectedConnector.name) { + createConnector({ + isSelfManaged: true, + }); + setCurrentStep('deployment'); + } + }} + fill + disabled={!canConfigureConnector} + isLoading={isCreateLoading || isGenerateLoading} + > + {Constants.NEXT_BUTTON_LABEL} + +
+
+ ) : ( + + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.createConnector.startStep.h4.configureIndexAndAPILabel', + { + defaultMessage: 'Configure index and API key', + } + )} +

+
+ + +

+ {i18n.translate( + 'xpack.enterpriseSearch.createConnector.startStep.p.thisProcessWillCreateLabel', + { + defaultMessage: + 'This process will create a new index, API key, and a Connector ID. Optionally you can bring your own configuration as well.', + } + )} +

+
+ + {generatedConfigData && connector ? ( + <> + + + setCurrentStep('configure')} + > + {Constants.NEXT_BUTTON_LABEL} + + + ) : ( + + + { + createConnector({ + isSelfManaged: false, + }); + }} + > + {i18n.translate( + 'xpack.enterpriseSearch.content.connector_detail.configurationConnector.steps.generateApiKey.button.label', + { + defaultMessage: 'Generate configuration', + } + )} + + + + + + + )} +
+
+ )} +
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/utils/generate_step_state.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/utils/generate_step_state.ts new file mode 100644 index 0000000000000..329ab69b5550f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/utils/generate_step_state.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiStepStatus } from '@elastic/eui'; + +type Steps = 'start' | 'configure' | 'deployment' | 'finish'; + +export const generateStepState = (currentStep: Steps): { [key in Steps]: EuiStepStatus } => { + return { + configure: + currentStep === 'start' || currentStep === 'deployment' + ? 'incomplete' + : currentStep === 'configure' + ? 'current' + : 'complete', + deployment: + currentStep === 'deployment' + ? 'current' + : currentStep === 'finish' || currentStep === 'configure' + ? 'complete' + : 'incomplete', + finish: currentStep === 'finish' ? 'current' : 'incomplete', + start: currentStep === 'start' ? 'current' : 'complete', + }; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/new_index/method_connector/new_connector_logic.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/new_index/method_connector/new_connector_logic.ts index 3eeb8f306dc2f..da2dcb1198800 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/new_index/method_connector/new_connector_logic.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/new_index/method_connector/new_connector_logic.ts @@ -7,65 +7,214 @@ import { kea, MakeLogicType } from 'kea'; +import { Connector } from '@kbn/search-connectors'; +import { ConnectorDefinition } from '@kbn/search-connectors-plugin/public'; + +import { Status } from '../../../../../../common/types/api'; import { Actions } from '../../../../shared/api_logic/create_api_logic'; +import { generateEncodedPath } from '../../../../shared/encode_path_params'; +import { KibanaLogic } from '../../../../shared/kibana'; import { AddConnectorApiLogic, + AddConnectorApiLogicActions, AddConnectorApiLogicArgs, AddConnectorApiLogicResponse, } from '../../../api/connector/add_connector_api_logic'; import { - IndexExistsApiLogic, - IndexExistsApiParams, - IndexExistsApiResponse, -} from '../../../api/index/index_exists_api_logic'; - -import { isValidIndexName } from '../../../utils/validate_index_name'; + GenerateConfigApiActions, + GenerateConfigApiLogic, +} from '../../../api/connector/generate_connector_config_api_logic'; +import { + GenerateConnectorNamesApiLogic, + GenerateConnectorNamesApiLogicActions, + GenerateConnectorNamesApiResponse, +} from '../../../api/connector/generate_connector_names_api_logic'; +import { APIKeyResponse } from '../../../api/generate_api_key/generate_api_key_logic'; -import { UNIVERSAL_LANGUAGE_VALUE } from '../constants'; -import { LanguageForOptimization } from '../types'; -import { getLanguageForOptimization } from '../utils'; +import { CONNECTOR_DETAIL_TAB_PATH } from '../../../routes'; +import { + ConnectorViewActions, + ConnectorViewLogic, +} from '../../connector_detail/connector_view_logic'; +import { ConnectorCreationSteps } from '../../connectors/create_connector/create_connector'; +import { SearchIndexTabId } from '../../search_index/search_index'; export interface NewConnectorValues { - data: IndexExistsApiResponse; - fullIndexName: string; - fullIndexNameExists: boolean; - fullIndexNameIsValid: boolean; - language: LanguageForOptimization; - languageSelectValue: string; + canConfigureConnector: boolean; + connectorId: string; + connectorName: string; + createConnectorApiStatus: Status; + currentStep: ConnectorCreationSteps; + generateConfigurationStatus: Status; + generatedConfigData: + | { + apiKey: APIKeyResponse['apiKey']; + connectorId: Connector['id']; + indexName: string; + } + | undefined; + generatedNameData: GenerateConnectorNamesApiResponse | undefined; + isCreateLoading: boolean; + isGenerateLoading: boolean; rawName: string; + selectedConnector: ConnectorDefinition | null; + shouldGenerateConfigAfterCreate: boolean; } -type NewConnectorActions = Pick< - Actions, - 'makeRequest' -> & { +type NewConnectorActions = { + generateConnectorName: GenerateConnectorNamesApiLogicActions['makeRequest']; +} & { + configurationGenerated: GenerateConfigApiActions['apiSuccess']; + generateConfiguration: GenerateConfigApiActions['makeRequest']; +} & { connectorCreated: Actions['apiSuccess']; - setLanguageSelectValue(language: string): { language: string }; + createConnector: ({ + isSelfManaged, + shouldGenerateAfterCreate, + shouldNavigateToConnectorAfterCreate, + }: { + isSelfManaged: boolean; + shouldGenerateAfterCreate?: boolean; + shouldNavigateToConnectorAfterCreate?: boolean; + }) => { + isSelfManaged: boolean; + shouldGenerateAfterCreate?: boolean; + shouldNavigateToConnectorAfterCreate?: boolean; + }; + createConnectorApi: AddConnectorApiLogicActions['makeRequest']; + fetchConnector: ConnectorViewActions['fetchConnector']; + setCurrentStep(step: ConnectorCreationSteps): { step: ConnectorCreationSteps }; setRawName(rawName: string): { rawName: string }; + setSelectedConnector(connector: ConnectorDefinition | null): { + connector: ConnectorDefinition | null; + }; }; export const NewConnectorLogic = kea>({ actions: { - setLanguageSelectValue: (language) => ({ language }), + createConnector: ({ + isSelfManaged, + shouldGenerateAfterCreate, + shouldNavigateToConnectorAfterCreate, + }) => ({ + isSelfManaged, + shouldGenerateAfterCreate, + shouldNavigateToConnectorAfterCreate, + }), + setCurrentStep: (step) => ({ step }), setRawName: (rawName) => ({ rawName }), + setSelectedConnector: (connector) => ({ connector }), }, connect: { actions: [ + GenerateConnectorNamesApiLogic, + ['makeRequest as generateConnectorName', 'apiSuccess as connectorNameGenerated'], AddConnectorApiLogic, - ['apiSuccess as connectorCreated'], - IndexExistsApiLogic, - ['makeRequest'], + ['makeRequest as createConnectorApi', 'apiSuccess as connectorCreated'], + GenerateConfigApiLogic, + ['makeRequest as generateConfiguration', 'apiSuccess as configurationGenerated'], + ConnectorViewLogic, + ['fetchConnector'], + ], + values: [ + GenerateConnectorNamesApiLogic, + ['data as generatedNameData'], + GenerateConfigApiLogic, + ['data as generatedConfigData', 'status as generateConfigurationStatus'], + AddConnectorApiLogic, + ['status as createConnectorApiStatus'], ], - values: [IndexExistsApiLogic, ['data']], }, - path: ['enterprise_search', 'content', 'new_search_index'], + listeners: ({ actions, values }) => ({ + connectorCreated: ({ id, uiFlags }) => { + if (uiFlags?.shouldNavigateToConnectorAfterCreate) { + KibanaLogic.values.navigateToUrl( + generateEncodedPath(CONNECTOR_DETAIL_TAB_PATH, { + connectorId: id, + tabId: SearchIndexTabId.CONFIGURATION, + }) + ); + } else { + actions.fetchConnector({ connectorId: id }); + if (!uiFlags || uiFlags.shouldGenerateAfterCreate) { + actions.generateConfiguration({ connectorId: id }); + } + } + }, + connectorNameGenerated: ({ connectorName }) => { + if (!values.rawName) { + actions.setRawName(connectorName); + } + }, + createConnector: ({ + isSelfManaged, + shouldGenerateAfterCreate = true, + shouldNavigateToConnectorAfterCreate = false, + }) => { + if ( + !values.rawName && + values.selectedConnector && + values.connectorName && + values.generatedNameData + ) { + // name is generated, use everything generated + actions.createConnectorApi({ + deleteExistingConnector: false, + indexName: values.connectorName, + isNative: !values.selectedConnector.isNative ? false : !isSelfManaged, + language: null, + name: values.generatedNameData.connectorName, + serviceType: values.selectedConnector.serviceType, + uiFlags: { + shouldGenerateAfterCreate, + shouldNavigateToConnectorAfterCreate, + }, + }); + } else { + if (values.generatedNameData && values.selectedConnector) { + actions.createConnectorApi({ + deleteExistingConnector: false, + indexName: values.generatedNameData.indexName, + isNative: !values.selectedConnector.isNative ? false : !isSelfManaged, + language: null, + name: values.connectorName, + serviceType: values.selectedConnector?.serviceType, + uiFlags: { + shouldGenerateAfterCreate, + shouldNavigateToConnectorAfterCreate, + }, + }); + } + } + }, + setSelectedConnector: ({ connector }) => { + if (connector) { + actions.generateConnectorName({ + connectorName: values.rawName, + connectorType: connector.serviceType, + }); + } + }, + }), + path: ['enterprise_search', 'content', 'new_search_connector'], reducers: { - languageSelectValue: [ - UNIVERSAL_LANGUAGE_VALUE, + connectorId: [ + '', { - // @ts-expect-error upgrade typescript v5.1.6 - setLanguageSelectValue: (_, { language }) => language ?? null, + connectorCreated: ( + _: NewConnectorValues['connectorId'], + { id }: { id: NewConnectorValues['connectorId'] } + ) => id, + }, + ], + currentStep: [ + 'start', + { + setCurrentStep: ( + _: NewConnectorValues['currentStep'], + { step }: { step: NewConnectorValues['currentStep'] } + ) => step, }, ], rawName: [ @@ -75,21 +224,34 @@ export const NewConnectorLogic = kea rawName, }, ], + selectedConnector: [ + null, + { + setSelectedConnector: ( + _: NewConnectorValues['selectedConnector'], + { connector }: { connector: NewConnectorValues['selectedConnector'] } + ) => connector, + }, + ], }, selectors: ({ selectors }) => ({ - fullIndexName: [() => [selectors.rawName], (name: string) => name], - fullIndexNameExists: [ - () => [selectors.data, selectors.fullIndexName], - (data: IndexExistsApiResponse | undefined, fullIndexName: string) => - data?.exists === true && data.indexName === fullIndexName, + canConfigureConnector: [ + () => [selectors.connectorName, selectors.selectedConnector], + (connectorName: string, selectedConnector: NewConnectorValues['selectedConnector']) => + (connectorName && selectedConnector?.name) ?? false, + ], + connectorName: [ + () => [selectors.rawName, selectors.generatedNameData], + (name: string, generatedName: NewConnectorValues['generatedNameData']) => + name ? name : generatedName?.connectorName ?? '', ], - fullIndexNameIsValid: [ - () => [selectors.fullIndexName], - (fullIndexName) => isValidIndexName(fullIndexName), + isCreateLoading: [ + () => [selectors.createConnectorApiStatus], + (status) => status === Status.LOADING, ], - language: [ - () => [selectors.languageSelectValue], - (languageSelectValue) => getLanguageForOptimization(languageSelectValue), + isGenerateLoading: [ + () => [selectors.generateConfigurationStatus], + (status) => status === Status.LOADING, ], }), }); diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/new_index/method_connector/new_connector_template.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/new_index/method_connector/new_connector_template.tsx index 4b4aba1761450..773c81761944d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/new_index/method_connector/new_connector_template.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/new_index/method_connector/new_connector_template.tsx @@ -54,44 +54,17 @@ export const NewConnectorTemplate: React.FC = ({ type, isBeta, }) => { - const { fullIndexName, fullIndexNameExists, fullIndexNameIsValid, rawName } = - useValues(NewConnectorLogic); + const { connectorName, rawName } = useValues(NewConnectorLogic); const { setRawName } = useActions(NewConnectorLogic); const handleNameChange = (e: ChangeEvent) => { setRawName(e.target.value); if (onNameChange) { - onNameChange(fullIndexName); + onNameChange(connectorName); } }; - const formInvalid = !!error || fullIndexNameExists || !fullIndexNameIsValid; - - const formError = () => { - if (fullIndexNameExists) { - return i18n.translate( - 'xpack.enterpriseSearch.content.newConnector.newConnectorTemplate.alreadyExists.error', - { - defaultMessage: 'A connector with the name {connectorName} already exists', - values: { - connectorName: fullIndexName, - }, - } - ); - } - if (!fullIndexNameIsValid) { - return i18n.translate( - 'xpack.enterpriseSearch.content.newConnector.newConnectorTemplate.isInvalid.error', - { - defaultMessage: '{connectorName} is an invalid connector name', - values: { - connectorName: fullIndexName, - }, - } - ); - } - return error; - }; + const formInvalid = !!error; return ( <> @@ -100,7 +73,7 @@ export const NewConnectorTemplate: React.FC = ({ id="enterprise-search-create-connector" onSubmit={(event) => { event.preventDefault(); - onSubmit(fullIndexName); + onSubmit(connectorName); }} > @@ -131,10 +104,10 @@ export const NewConnectorTemplate: React.FC = ({ } )} isInvalid={formInvalid} - error={formError()} fullWidth > = ({ {type === INGESTION_METHOD_IDS.CONNECTOR && ( - + {i18n.translate( 'xpack.enterpriseSearch.content.newConnector.newConnectorTemplate.learnMoreConnectors.linkText', { @@ -182,6 +159,7 @@ export const NewConnectorTemplate: React.FC = ({ history.back()} diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/routes.ts b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/routes.ts index 6be30af4e986b..092b60bf7666f 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/routes.ts +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/routes.ts @@ -21,6 +21,7 @@ export const NEW_ES_INDEX_PATH = `${NEW_INDEX_PATH}/elasticsearch`; export const NEW_DIRECT_UPLOAD_PATH = `${NEW_INDEX_PATH}/upload`; export const NEW_INDEX_SELECT_CONNECTOR_PATH = `${CONNECTORS_PATH}/select_connector`; export const NEW_CONNECTOR_PATH = `${CONNECTORS_PATH}/new_connector`; +export const NEW_CONNECTOR_FLOW_PATH = `${CONNECTORS_PATH}/new_connector_flow`; export const NEW_CRAWLER_PATH = `${CRAWLERS_PATH}/new_crawler`; export const NEW_INDEX_SELECT_CONNECTOR_NATIVE_PATH = `${CONNECTORS_PATH}/select_connector?filter=native`; export const NEW_INDEX_SELECT_CONNECTOR_CLIENTS_PATH = `${CONNECTORS_PATH}/select_connector?filter=connector_clients`; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/constants/actions.ts b/x-pack/plugins/enterprise_search/public/applications/shared/constants/actions.ts index f163158462f0d..fc9860e202130 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/constants/actions.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/constants/actions.ts @@ -49,6 +49,10 @@ export const BACK_BUTTON_LABEL = i18n.translate('xpack.enterpriseSearch.actions. defaultMessage: 'Back', }); +export const NEXT_BUTTON_LABEL = i18n.translate('xpack.enterpriseSearch.actions.nextButtonLabel', { + defaultMessage: 'Next', +}); + export const CLOSE_BUTTON_LABEL = i18n.translate( 'xpack.enterpriseSearch.actions.closeButtonLabel', { defaultMessage: 'Close' } diff --git a/x-pack/plugins/enterprise_search/server/lib/connectors/generate_connector_name.ts b/x-pack/plugins/enterprise_search/server/lib/connectors/generate_connector_name.ts index f6c209707a8f7..56f849c551400 100644 --- a/x-pack/plugins/enterprise_search/server/lib/connectors/generate_connector_name.ts +++ b/x-pack/plugins/enterprise_search/server/lib/connectors/generate_connector_name.ts @@ -16,24 +16,51 @@ import { indexOrAliasExists } from '../indices/exists_index'; export const generateConnectorName = async ( client: IScopedClusterClient, - connectorType: string + connectorType: string, + userConnectorName?: string ): Promise<{ apiKeyName: string; connectorName: string; indexName: string }> => { const prefix = toAlphanumeric(connectorType); if (!prefix || prefix.length === 0) { - throw new Error('Connector type is required'); + throw new Error('Connector type or connectorName is required'); } - for (let i = 0; i < 20; i++) { - const connectorName = `${prefix}-${uuidv4().split('-')[1]}`; - const indexName = `connector-${connectorName}`; - - const result = await indexOrAliasExists(client, indexName); - if (!result) { + if (userConnectorName) { + let indexName = `connector-${userConnectorName}`; + const resultSameName = await indexOrAliasExists(client, indexName); + // index with same name doesn't exist + if (!resultSameName) { return { - apiKeyName: indexName, - connectorName, + apiKeyName: userConnectorName, + connectorName: userConnectorName, indexName, }; } + // if the index name already exists, we will generate until it doesn't for 20 times + for (let i = 0; i < 20; i++) { + indexName = `connector-${userConnectorName}-${uuidv4().split('-')[1].slice(0, 4)}`; + + const result = await indexOrAliasExists(client, indexName); + if (!result) { + return { + apiKeyName: indexName, + connectorName: userConnectorName, + indexName, + }; + } + } + } else { + for (let i = 0; i < 20; i++) { + const connectorName = `${prefix}-${uuidv4().split('-')[1].slice(0, 4)}`; + const indexName = `connector-${connectorName}`; + + const result = await indexOrAliasExists(client, indexName); + if (!result) { + return { + apiKeyName: indexName, + connectorName, + indexName, + }; + } + } } throw new Error(ErrorCode.GENERATE_INDEX_NAME_ERROR); }; diff --git a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/connectors.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/connectors.ts index 21b00e82b6aa0..6108580463893 100644 --- a/x-pack/plugins/enterprise_search/server/routes/enterprise_search/connectors.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/connectors.ts @@ -6,7 +6,6 @@ */ import { schema } from '@kbn/config-schema'; - import { ElasticsearchErrorDetails } from '@kbn/es-errors'; import { i18n } from '@kbn/i18n'; @@ -841,15 +840,20 @@ export function registerConnectorRoutes({ router, log }: RouteDependencies) { path: '/internal/enterprise_search/connectors/generate_connector_name', validate: { body: schema.object({ + connectorName: schema.maybe(schema.string()), connectorType: schema.string(), }), }, }, elasticsearchErrorHandler(log, async (context, request, response) => { const { client } = (await context.core).elasticsearch; - const { connectorType } = request.body; + const { connectorType, connectorName } = request.body; try { - const generatedNames = await generateConnectorName(client, connectorType ?? 'custom'); + const generatedNames = await generateConnectorName( + client, + connectorType ?? 'custom', + connectorName + ); return response.ok({ body: generatedNames, headers: { 'content-type': 'application/json' }, diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index af71b7b1b9eda..c9713d7d10c73 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -16858,10 +16858,8 @@ "xpack.enterpriseSearch.content.new_index.genericTitle": "Nouvel index de recherche", "xpack.enterpriseSearch.content.new_index.successToast.title": "L’index a bien été créé", "xpack.enterpriseSearch.content.new_web_crawler.breadcrumbs": "Nouveau robot d'indexation", - "xpack.enterpriseSearch.content.newConnector.newConnectorTemplate.alreadyExists.error": "Un connecteur nommé {connectorName} existe déjà", "xpack.enterpriseSearch.content.newConnector.newConnectorTemplate.createIndex.buttonText": "Créer un connecteur", "xpack.enterpriseSearch.content.newConnector.newConnectorTemplate.formTitle": "Créer un connecteur", - "xpack.enterpriseSearch.content.newConnector.newConnectorTemplate.isInvalid.error": "{connectorName} est un nom de connecteur non valide", "xpack.enterpriseSearch.content.newConnector.newConnectorTemplate.learnMoreConnectors.linkText": "En savoir plus sur les connecteurs", "xpack.enterpriseSearch.content.newConnector.newConnectorTemplate.nameInputHelpText.lineTwo": "Les noms doivent être en minuscules et ne peuvent pas contenir d'espaces ni de caractères spéciaux.", "xpack.enterpriseSearch.content.newConnector.newConnectorTemplate.nameInputLabel": "Nom du connecteur", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index cdd8afc68af2e..d123d0edd8948 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -16604,10 +16604,8 @@ "xpack.enterpriseSearch.content.new_index.genericTitle": "新しい検索インデックス", "xpack.enterpriseSearch.content.new_index.successToast.title": "インデックスが正常に作成されました", "xpack.enterpriseSearch.content.new_web_crawler.breadcrumbs": "新しいWebクローラー", - "xpack.enterpriseSearch.content.newConnector.newConnectorTemplate.alreadyExists.error": "名前\"{connectorName}\"のコネクターはすでに存在しています", "xpack.enterpriseSearch.content.newConnector.newConnectorTemplate.createIndex.buttonText": "コネクターを作成", "xpack.enterpriseSearch.content.newConnector.newConnectorTemplate.formTitle": "コネクターを作成する", - "xpack.enterpriseSearch.content.newConnector.newConnectorTemplate.isInvalid.error": "{connectorName}は無効なコネクター名です", "xpack.enterpriseSearch.content.newConnector.newConnectorTemplate.learnMoreConnectors.linkText": "コネクターの詳細", "xpack.enterpriseSearch.content.newConnector.newConnectorTemplate.nameInputHelpText.lineTwo": "名前は小文字で入力してください。スペースや特殊文字は使用できません。", "xpack.enterpriseSearch.content.newConnector.newConnectorTemplate.nameInputLabel": "コネクター名", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index b94fb455c8ad5..3e658947b010b 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -16633,10 +16633,8 @@ "xpack.enterpriseSearch.content.new_index.genericTitle": "新搜索索引", "xpack.enterpriseSearch.content.new_index.successToast.title": "已成功创建索引", "xpack.enterpriseSearch.content.new_web_crawler.breadcrumbs": "新网络爬虫", - "xpack.enterpriseSearch.content.newConnector.newConnectorTemplate.alreadyExists.error": "名为 {connectorName} 的连接器已存在", "xpack.enterpriseSearch.content.newConnector.newConnectorTemplate.createIndex.buttonText": "创建连接器", "xpack.enterpriseSearch.content.newConnector.newConnectorTemplate.formTitle": "创建连接器", - "xpack.enterpriseSearch.content.newConnector.newConnectorTemplate.isInvalid.error": "{connectorName} 为无效的连接器名称", "xpack.enterpriseSearch.content.newConnector.newConnectorTemplate.learnMoreConnectors.linkText": "详细了解连接器", "xpack.enterpriseSearch.content.newConnector.newConnectorTemplate.nameInputHelpText.lineTwo": "名称应为小写,并且不能包含空格或特殊字符。", "xpack.enterpriseSearch.content.newConnector.newConnectorTemplate.nameInputLabel": "连接器名称", From 8cceaee0f42c6c0e7ee064ef98a0e652fd77e286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Tue, 15 Oct 2024 13:18:30 +0100 Subject: [PATCH 12/13] [Stateful sidenav] Welcome tour (#194926) --- x-pack/plugins/spaces/common/constants.ts | 5 + .../components/spaces_description.tsx | 4 +- .../nav_control/components/spaces_menu.tsx | 3 +- .../spaces/public/nav_control/nav_control.tsx | 5 + .../nav_control/nav_control_popover.test.tsx | 73 +++++++++- .../nav_control/nav_control_popover.tsx | 63 +++++++-- .../nav_control/solution_view_tour/index.ts | 10 ++ .../nav_control/solution_view_tour/lib.ts | 84 +++++++++++ .../solution_view_tour/solution_view_tour.tsx | 94 +++++++++++++ x-pack/plugins/spaces/server/plugin.ts | 2 + x-pack/plugins/spaces/server/ui_settings.ts | 24 ++++ x-pack/test/common/services/spaces.ts | 33 +++++ .../solution_view_flag_enabled/index.ts | 1 + .../solution_tour.ts | 133 ++++++++++++++++++ 14 files changed, 509 insertions(+), 25 deletions(-) create mode 100644 x-pack/plugins/spaces/public/nav_control/solution_view_tour/index.ts create mode 100644 x-pack/plugins/spaces/public/nav_control/solution_view_tour/lib.ts create mode 100644 x-pack/plugins/spaces/public/nav_control/solution_view_tour/solution_view_tour.tsx create mode 100644 x-pack/plugins/spaces/server/ui_settings.ts create mode 100644 x-pack/test/functional/apps/spaces/solution_view_flag_enabled/solution_tour.ts diff --git a/x-pack/plugins/spaces/common/constants.ts b/x-pack/plugins/spaces/common/constants.ts index 14932a93a06b7..232892ab7b9ad 100644 --- a/x-pack/plugins/spaces/common/constants.ts +++ b/x-pack/plugins/spaces/common/constants.ts @@ -52,3 +52,8 @@ export const API_VERSIONS = { v1: '2023-10-31', }, }; + +/** + * The setting to control whether the Space Solution Tour is shown. + */ +export const SHOW_SPACE_SOLUTION_TOUR_SETTING = 'showSpaceSolutionTour'; diff --git a/x-pack/plugins/spaces/public/nav_control/components/spaces_description.tsx b/x-pack/plugins/spaces/public/nav_control/components/spaces_description.tsx index 982e11ffbf4e7..03667f48f4166 100644 --- a/x-pack/plugins/spaces/public/nav_control/components/spaces_description.tsx +++ b/x-pack/plugins/spaces/public/nav_control/components/spaces_description.tsx @@ -20,7 +20,7 @@ import { getSpacesFeatureDescription } from '../../constants'; interface Props { id: string; isLoading: boolean; - toggleSpaceSelector: () => void; + onClickManageSpaceBtn: () => void; capabilities: Capabilities; navigateToApp: ApplicationStart['navigateToApp']; } @@ -45,7 +45,7 @@ export const SpacesDescription: FC = (props: Props) => { diff --git a/x-pack/plugins/spaces/public/nav_control/components/spaces_menu.tsx b/x-pack/plugins/spaces/public/nav_control/components/spaces_menu.tsx index 47f7d840b9bee..29d360fe91f3f 100644 --- a/x-pack/plugins/spaces/public/nav_control/components/spaces_menu.tsx +++ b/x-pack/plugins/spaces/public/nav_control/components/spaces_menu.tsx @@ -43,6 +43,7 @@ interface Props { spaces: Space[]; serverBasePath: string; toggleSpaceSelector: () => void; + onClickManageSpaceBtn: () => void; intl: InjectedIntl; capabilities: Capabilities; navigateToApp: ApplicationStart['navigateToApp']; @@ -218,7 +219,7 @@ class SpacesMenuUI extends Component { key="manageSpacesButton" className="spcMenu__manageButton" size="s" - onClick={this.props.toggleSpaceSelector} + onClick={this.props.onClickManageSpaceBtn} capabilities={this.props.capabilities} navigateToApp={this.props.navigateToApp} /> diff --git a/x-pack/plugins/spaces/public/nav_control/nav_control.tsx b/x-pack/plugins/spaces/public/nav_control/nav_control.tsx index 7cb32fff01e1e..1dc888333fdf5 100644 --- a/x-pack/plugins/spaces/public/nav_control/nav_control.tsx +++ b/x-pack/plugins/spaces/public/nav_control/nav_control.tsx @@ -12,6 +12,7 @@ import ReactDOM from 'react-dom'; import type { CoreStart } from '@kbn/core/public'; import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; +import { initTour } from './solution_view_tour'; import type { EventTracker } from '../analytics'; import type { ConfigType } from '../config'; import type { SpacesManager } from '../spaces_manager'; @@ -22,6 +23,8 @@ export function initSpacesNavControl( config: ConfigType, eventTracker: EventTracker ) { + const { showTour$, onFinishTour } = initTour(core, spacesManager); + core.chrome.navControls.registerLeft({ order: 1000, mount(targetDomElement: HTMLElement) { @@ -47,6 +50,8 @@ export function initSpacesNavControl( navigateToUrl={core.application.navigateToUrl} allowSolutionVisibility={config.allowSolutionVisibility} eventTracker={eventTracker} + showTour$={showTour$} + onFinishTour={onFinishTour} /> , diff --git a/x-pack/plugins/spaces/public/nav_control/nav_control_popover.test.tsx b/x-pack/plugins/spaces/public/nav_control/nav_control_popover.test.tsx index 9b573615c65b9..f1ba5c9f3f3cf 100644 --- a/x-pack/plugins/spaces/public/nav_control/nav_control_popover.test.tsx +++ b/x-pack/plugins/spaces/public/nav_control/nav_control_popover.test.tsx @@ -8,7 +8,6 @@ import { EuiFieldSearch, EuiHeaderSectionItemButton, - EuiPopover, EuiSelectable, EuiSelectableListItem, } from '@elastic/eui'; @@ -18,7 +17,7 @@ import * as Rx from 'rxjs'; import { findTestSubject, mountWithIntl } from '@kbn/test-jest-helpers'; -import { NavControlPopover } from './nav_control_popover'; +import { NavControlPopover, type Props as NavControlPopoverProps } from './nav_control_popover'; import type { Space } from '../../common'; import { EventTracker } from '../analytics'; import { SpaceAvatarInternal } from '../space_avatar/space_avatar_internal'; @@ -49,7 +48,12 @@ const reportEvent = jest.fn(); const eventTracker = new EventTracker({ reportEvent }); describe('NavControlPopover', () => { - async function setup(spaces: Space[], allowSolutionVisibility = false, activeSpace?: Space) { + async function setup( + spaces: Space[], + allowSolutionVisibility = false, + activeSpace?: Space, + props?: Partial + ) { const spacesManager = spacesManagerMock.create(); spacesManager.getSpaces = jest.fn().mockResolvedValue(spaces); @@ -68,6 +72,9 @@ describe('NavControlPopover', () => { navigateToUrl={jest.fn()} allowSolutionVisibility={allowSolutionVisibility} eventTracker={eventTracker} + showTour$={Rx.of(false)} + onFinishTour={jest.fn()} + {...props} /> ); @@ -81,7 +88,7 @@ describe('NavControlPopover', () => { it('renders without crashing', () => { const spacesManager = spacesManagerMock.create(); - const { baseElement } = render( + const { baseElement, queryByTestId } = render( { navigateToUrl={jest.fn()} allowSolutionVisibility={false} eventTracker={eventTracker} + showTour$={Rx.of(false)} + onFinishTour={jest.fn()} /> ); expect(baseElement).toMatchSnapshot(); + expect(queryByTestId('spaceSolutionTour')).toBeNull(); }); it('renders a SpaceAvatar with the active space', async () => { @@ -117,6 +127,8 @@ describe('NavControlPopover', () => { navigateToUrl={jest.fn()} allowSolutionVisibility={false} eventTracker={eventTracker} + showTour$={Rx.of(false)} + onFinishTour={jest.fn()} /> ); @@ -223,20 +235,29 @@ describe('NavControlPopover', () => { }); it('can close its popover', async () => { + jest.useFakeTimers(); const wrapper = await setup(mockSpaces); + expect(findTestSubject(wrapper, 'spaceMenuPopoverPanel').exists()).toEqual(false); // closed + + // Open the popover await act(async () => { wrapper.find(EuiHeaderSectionItemButton).find('button').simulate('click'); }); wrapper.update(); - expect(wrapper.find(EuiPopover).props().isOpen).toEqual(true); + expect(findTestSubject(wrapper, 'spaceMenuPopoverPanel').exists()).toEqual(true); // open + // Close the popover await act(async () => { - wrapper.find(EuiPopover).props().closePopover(); + wrapper.find(EuiHeaderSectionItemButton).find('button').simulate('click'); + }); + act(() => { + jest.runAllTimers(); }); wrapper.update(); + expect(findTestSubject(wrapper, 'spaceMenuPopoverPanel').exists()).toEqual(false); // closed - expect(wrapper.find(EuiPopover).props().isOpen).toEqual(false); + jest.useRealTimers(); }); it('should render solution for spaces', async () => { @@ -301,4 +322,42 @@ describe('NavControlPopover', () => { space_id_prev: 'space-1', }); }); + + it('should show the solution view tour', async () => { + jest.useFakeTimers(); // the underlying EUI tour component has a timeout that needs to be flushed for the test to pass + + const spaces: Space[] = [ + { + id: 'space-1', + name: 'Space-1', + disabledFeatures: [], + solution: 'es', + }, + ]; + + const activeSpace = spaces[0]; + const showTour$ = new Rx.BehaviorSubject(true); + const onFinishTour = jest.fn().mockImplementation(() => { + showTour$.next(false); + }); + + const wrapper = await setup(spaces, true /** allowSolutionVisibility **/, activeSpace, { + showTour$, + onFinishTour, + }); + + expect(findTestSubject(wrapper, 'spaceSolutionTour').exists()).toBe(true); + + act(() => { + findTestSubject(wrapper, 'closeTourBtn').simulate('click'); + }); + act(() => { + jest.runAllTimers(); + }); + wrapper.update(); + + expect(findTestSubject(wrapper, 'spaceSolutionTour').exists()).toBe(false); + + jest.useRealTimers(); + }); }); diff --git a/x-pack/plugins/spaces/public/nav_control/nav_control_popover.tsx b/x-pack/plugins/spaces/public/nav_control/nav_control_popover.tsx index b9830b2063dd5..d84fac2fdced4 100644 --- a/x-pack/plugins/spaces/public/nav_control/nav_control_popover.tsx +++ b/x-pack/plugins/spaces/public/nav_control/nav_control_popover.tsx @@ -13,13 +13,14 @@ import { withEuiTheme, } from '@elastic/eui'; import React, { Component, lazy, Suspense } from 'react'; -import type { Subscription } from 'rxjs'; +import type { Observable, Subscription } from 'rxjs'; import type { ApplicationStart, Capabilities } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; import { SpacesDescription } from './components/spaces_description'; import { SpacesMenu } from './components/spaces_menu'; +import { SolutionViewTour } from './solution_view_tour'; import type { Space } from '../../common'; import type { EventTracker } from '../analytics'; import { getSpaceAvatarComponent } from '../space_avatar'; @@ -30,7 +31,7 @@ const LazySpaceAvatar = lazy(() => getSpaceAvatarComponent().then((component) => ({ default: component })) ); -interface Props { +export interface Props { spacesManager: SpacesManager; anchorPosition: PopoverAnchorPosition; capabilities: Capabilities; @@ -40,6 +41,8 @@ interface Props { theme: WithEuiThemeProps['theme']; allowSolutionVisibility: boolean; eventTracker: EventTracker; + showTour$: Observable; + onFinishTour: () => void; } interface State { @@ -47,12 +50,14 @@ interface State { loading: boolean; activeSpace: Space | null; spaces: Space[]; + showTour: boolean; } const popoutContentId = 'headerSpacesMenuContent'; class NavControlPopoverUI extends Component { private activeSpace$?: Subscription; + private showTour$Sub?: Subscription; constructor(props: Props) { super(props); @@ -61,6 +66,7 @@ class NavControlPopoverUI extends Component { loading: false, activeSpace: null, spaces: [], + showTour: false, }; } @@ -72,15 +78,23 @@ class NavControlPopoverUI extends Component { }); }, }); + + this.showTour$Sub = this.props.showTour$.subscribe((showTour) => { + this.setState({ showTour }); + }); } public componentWillUnmount() { this.activeSpace$?.unsubscribe(); + this.showTour$Sub?.unsubscribe(); } public render() { const button = this.getActiveSpaceButton(); const { theme } = this.props; + const { activeSpace } = this.state; + + const isTourOpen = Boolean(activeSpace) && this.state.showTour && !this.state.showSpaceSelector; let element: React.ReactNode; if (this.state.loading || this.state.spaces.length < 2) { @@ -88,9 +102,13 @@ class NavControlPopoverUI extends Component { { + // No need to show the tour anymore, the user is taking action + this.props.onFinishTour(); + this.toggleSpaceSelector(); + }} /> ); } else { @@ -106,24 +124,38 @@ class NavControlPopoverUI extends Component { activeSpace={this.state.activeSpace} allowSolutionVisibility={this.props.allowSolutionVisibility} eventTracker={this.props.eventTracker} + onClickManageSpaceBtn={() => { + // No need to show the tour anymore, the user is taking action + this.props.onFinishTour(); + this.toggleSpaceSelector(); + }} /> ); } return ( - - {element} - + + {element} + + ); } @@ -195,6 +227,7 @@ class NavControlPopoverUI extends Component { protected toggleSpaceSelector = () => { const isOpening = !this.state.showSpaceSelector; + if (isOpening) { this.loadSpaces(); } diff --git a/x-pack/plugins/spaces/public/nav_control/solution_view_tour/index.ts b/x-pack/plugins/spaces/public/nav_control/solution_view_tour/index.ts new file mode 100644 index 0000000000000..d85a76c586925 --- /dev/null +++ b/x-pack/plugins/spaces/public/nav_control/solution_view_tour/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { initTour } from './lib'; + +export { SolutionViewTour } from './solution_view_tour'; diff --git a/x-pack/plugins/spaces/public/nav_control/solution_view_tour/lib.ts b/x-pack/plugins/spaces/public/nav_control/solution_view_tour/lib.ts new file mode 100644 index 0000000000000..7936eea09dab6 --- /dev/null +++ b/x-pack/plugins/spaces/public/nav_control/solution_view_tour/lib.ts @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { BehaviorSubject, defer, from, map, of, shareReplay, switchMap } from 'rxjs'; + +import type { CoreStart } from '@kbn/core/public'; + +import type { Space } from '../../../common'; +import { + DEFAULT_SPACE_ID, + SHOW_SPACE_SOLUTION_TOUR_SETTING, + SOLUTION_VIEW_CLASSIC, +} from '../../../common/constants'; +import type { SpacesManager } from '../../spaces_manager'; + +export function initTour(core: CoreStart, spacesManager: SpacesManager) { + const showTourUiSettingValue = core.settings.globalClient.get(SHOW_SPACE_SOLUTION_TOUR_SETTING); + const showTour$ = new BehaviorSubject(showTourUiSettingValue ?? true); + + const allSpaces$ = defer(() => from(spacesManager.getSpaces())).pipe(shareReplay(1)); + + const hasMultipleSpaces = (spaces: Space[]) => { + return spaces.length > 1; + }; + + const isDefaultSpaceOnClassic = (spaces: Space[]) => { + const defaultSpace = spaces.find((space) => space.id === DEFAULT_SPACE_ID); + + if (!defaultSpace) { + // Don't show the tour if the default space doesn't exist (this should never happen) + return true; + } + + if (!defaultSpace.solution || defaultSpace.solution === SOLUTION_VIEW_CLASSIC) { + return true; + } + }; + + const showTourObservable$ = showTour$.pipe( + switchMap((showTour) => { + if (!showTour) return of(false); + + return allSpaces$.pipe( + map((spaces) => { + if (hasMultipleSpaces(spaces) || isDefaultSpaceOnClassic(spaces)) { + return false; + } + + return true; + }) + ); + }) + ); + + const hideTourInGlobalSettings = () => { + core.settings.globalClient.set(SHOW_SPACE_SOLUTION_TOUR_SETTING, false).catch(() => { + // Silently swallow errors, the user will just see the tour again next time they load the page + }); + }; + + if (showTourUiSettingValue !== false) { + allSpaces$.subscribe((spaces) => { + if (hasMultipleSpaces(spaces) || isDefaultSpaceOnClassic(spaces)) { + // If we have either (1) multiple space or (2) only one space and it's the default space with the classic solution, + // we don't want to show the tour later on. This can happen in the following scenarios: + // - the user deletes all the spaces but one (and that last space has a solution set) + // - the user edits the default space and sets a solution + // So we can immediately hide the tour in the global settings from now on. + hideTourInGlobalSettings(); + } + }); + } + + const onFinishTour = () => { + hideTourInGlobalSettings(); + showTour$.next(false); + }; + + return { showTour$: showTourObservable$, onFinishTour }; +} diff --git a/x-pack/plugins/spaces/public/nav_control/solution_view_tour/solution_view_tour.tsx b/x-pack/plugins/spaces/public/nav_control/solution_view_tour/solution_view_tour.tsx new file mode 100644 index 0000000000000..bf80bf92bdf4e --- /dev/null +++ b/x-pack/plugins/spaces/public/nav_control/solution_view_tour/solution_view_tour.tsx @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiButtonEmpty, EuiLink, EuiText, EuiTourStep } from '@elastic/eui'; +import React from 'react'; +import type { FC, PropsWithChildren } from 'react'; + +import type { OnBoardingDefaultSolution } from '@kbn/cloud-plugin/common'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import type { SolutionView } from '../../../common'; +import { SOLUTION_VIEW_CLASSIC } from '../../../common/constants'; + +const tourLearnMoreLink = 'https://ela.st/left-nav'; + +const LearnMoreLink = () => ( + + {i18n.translate('xpack.spaces.navControl.tour.learnMore', { + defaultMessage: 'Learn more', + })} + +); + +const solutionMap: Record = { + es: i18n.translate('xpack.spaces.navControl.tour.esSolution', { + defaultMessage: 'Search', + }), + security: i18n.translate('xpack.spaces.navControl.tour.securitySolution', { + defaultMessage: 'Security', + }), + oblt: i18n.translate('xpack.spaces.navControl.tour.obltSolution', { + defaultMessage: 'Observability', + }), +}; + +interface Props extends PropsWithChildren<{}> { + solution?: SolutionView; + isTourOpen: boolean; + onFinishTour: () => void; +} + +export const SolutionViewTour: FC = ({ children, solution, isTourOpen, onFinishTour }) => { + const solutionLabel = solution && solution !== SOLUTION_VIEW_CLASSIC ? solutionMap[solution] : ''; + if (!solutionLabel) { + return children; + } + + return ( + +

+ , + }} + /> +

+ + } + isStepOpen={isTourOpen} + minWidth={300} + maxWidth={360} + onFinish={onFinishTour} + step={1} + stepsTotal={1} + title={i18n.translate('xpack.spaces.navControl.tour.title', { + defaultMessage: 'You chose the {solution} solution view', + values: { solution: solutionLabel }, + })} + anchorPosition="downCenter" + footerAction={ + + {i18n.translate('xpack.spaces.navControl.tour.closeBtn', { + defaultMessage: 'Close', + })} + + } + panelProps={{ + 'data-test-subj': 'spaceSolutionTour', + }} + > + <>{children} +
+ ); +}; diff --git a/x-pack/plugins/spaces/server/plugin.ts b/x-pack/plugins/spaces/server/plugin.ts index 2f8fb2ec30842..e36a6fb3cc7f1 100644 --- a/x-pack/plugins/spaces/server/plugin.ts +++ b/x-pack/plugins/spaces/server/plugin.ts @@ -35,6 +35,7 @@ import { SpacesClientService } from './spaces_client'; import type { SpacesServiceSetup, SpacesServiceStart } from './spaces_service'; import { SpacesService } from './spaces_service'; import type { SpacesRequestHandlerContext } from './types'; +import { getUiSettings } from './ui_settings'; import { registerSpacesUsageCollector } from './usage_collection'; import { UsageStatsService } from './usage_stats'; import { SpacesLicenseService } from '../common/licensing'; @@ -149,6 +150,7 @@ export class SpacesPlugin public setup(core: CoreSetup, plugins: PluginsSetup): SpacesPluginSetup { this.onCloud$.next(plugins.cloud !== undefined && plugins.cloud.isCloudEnabled); const spacesClientSetup = this.spacesClientService.setup({ config$: this.config$ }); + core.uiSettings.registerGlobal(getUiSettings()); const spacesServiceSetup = this.spacesService.setup({ basePath: core.http.basePath, diff --git a/x-pack/plugins/spaces/server/ui_settings.ts b/x-pack/plugins/spaces/server/ui_settings.ts new file mode 100644 index 0000000000000..cfb6c996296da --- /dev/null +++ b/x-pack/plugins/spaces/server/ui_settings.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 { schema } from '@kbn/config-schema'; +import type { UiSettingsParams } from '@kbn/core/types'; + +import { SHOW_SPACE_SOLUTION_TOUR_SETTING } from '../common/constants'; + +/** + * uiSettings definitions for Spaces + */ +export const getUiSettings = (): Record => { + return { + [SHOW_SPACE_SOLUTION_TOUR_SETTING]: { + schema: schema.boolean(), + readonly: true, + readonlyMode: 'ui', + }, + }; +}; diff --git a/x-pack/test/common/services/spaces.ts b/x-pack/test/common/services/spaces.ts index 98cc54e456200..67da912fb6a54 100644 --- a/x-pack/test/common/services/spaces.ts +++ b/x-pack/test/common/services/spaces.ts @@ -77,6 +77,25 @@ export function SpacesServiceProvider({ getService }: FtrProviderContext) { }; } + public async update( + id: string, + updatedSpace: Partial, + { overwrite = true }: { overwrite?: boolean } = {} + ) { + log.debug(`updating space ${id}`); + const { data, status, statusText } = await axios.put( + `/api/spaces/space/${id}?overwrite=${overwrite}`, + updatedSpace + ); + + if (status !== 200) { + throw new Error( + `Expected status code of 200, received ${status} ${statusText}: ${util.inspect(data)}` + ); + } + log.debug(`updated space ${id}`); + } + public async delete(spaceId: string) { log.debug(`deleting space id: ${spaceId}`); const { data, status, statusText } = await axios.delete(`/api/spaces/space/${spaceId}`); @@ -89,6 +108,20 @@ export function SpacesServiceProvider({ getService }: FtrProviderContext) { log.debug(`deleted space id: ${spaceId}`); } + public async get(id: string) { + log.debug(`retrieving space ${id}`); + const { data, status, statusText } = await axios.get(`/api/spaces/space/${id}`); + + if (status !== 200) { + throw new Error( + `Expected status code of 200, received ${status} ${statusText}: ${util.inspect(data)}` + ); + } + log.debug(`retrieved space ${id}`); + + return data; + } + public async getAll() { log.debug('retrieving all spaces'); const { data, status, statusText } = await axios.get('/api/spaces/space'); diff --git a/x-pack/test/functional/apps/spaces/solution_view_flag_enabled/index.ts b/x-pack/test/functional/apps/spaces/solution_view_flag_enabled/index.ts index 99ce8f2ab16e7..45a8f78387154 100644 --- a/x-pack/test/functional/apps/spaces/solution_view_flag_enabled/index.ts +++ b/x-pack/test/functional/apps/spaces/solution_view_flag_enabled/index.ts @@ -10,5 +10,6 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function spacesApp({ loadTestFile }: FtrProviderContext) { describe('Spaces app (with solution view)', function spacesAppTestSuite() { loadTestFile(require.resolve('./create_edit_space')); + loadTestFile(require.resolve('./solution_tour')); }); } diff --git a/x-pack/test/functional/apps/spaces/solution_view_flag_enabled/solution_tour.ts b/x-pack/test/functional/apps/spaces/solution_view_flag_enabled/solution_tour.ts new file mode 100644 index 0000000000000..852a2a83031cd --- /dev/null +++ b/x-pack/test/functional/apps/spaces/solution_view_flag_enabled/solution_tour.ts @@ -0,0 +1,133 @@ +/* + * 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 type { SolutionView, Space } from '@kbn/spaces-plugin/common'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const kibanaServer = getService('kibanaServer'); + const PageObjects = getPageObjects(['common', 'settings', 'security', 'spaceSelector']); + const testSubjects = getService('testSubjects'); + const spacesService = getService('spaces'); + const browser = getService('browser'); + const es = getService('es'); + const log = getService('log'); + + describe('space solution tour', () => { + let version: string | undefined; + + const removeGlobalSettings = async () => { + version = version ?? (await kibanaServer.version.get()); + version = version.replace(/-SNAPSHOT$/, ''); + + log.debug(`Deleting [config-global:${version}] doc from the .kibana index`); + + await es + .delete( + { id: `config-global:${version}`, index: '.kibana', refresh: true }, + { headers: { 'kbn-xsrf': 'spaces' } } + ) + .catch((error) => { + if (error.statusCode === 404) return; // ignore 404 errors + throw error; + }); + }; + + before(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + }); + + after(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + }); + + describe('solution tour', () => { + let _defaultSpace: Space | undefined = { + id: 'default', + name: 'Default', + disabledFeatures: [], + }; + + const updateSolutionDefaultSpace = async (solution: SolutionView) => { + log.debug(`Updating default space solution: [${solution}].`); + + await spacesService.update('default', { + ..._defaultSpace, + solution, + }); + }; + + before(async () => { + _defaultSpace = await spacesService.get('default'); + + await PageObjects.common.navigateToUrl('management', 'kibana/spaces', { + shouldUseHashForSubUrl: false, + }); + + await PageObjects.common.sleep(1000); // wait to save the setting + }); + + afterEach(async () => { + await updateSolutionDefaultSpace('classic'); // revert to not impact future tests + }); + + it('does not show the solution tour for the classic space', async () => { + await testSubjects.missingOrFail('spaceSolutionTour', { timeout: 3000 }); + }); + + it('does show the solution tour if the default space has a solution set', async () => { + await updateSolutionDefaultSpace('es'); // set a solution + await PageObjects.common.sleep(500); + await removeGlobalSettings(); // Make sure we start from a clean state + await browser.refresh(); + + await testSubjects.existOrFail('spaceSolutionTour', { timeout: 3000 }); + + await testSubjects.click('closeTourBtn'); // close the tour + await PageObjects.common.sleep(1000); // wait to save the setting + + await browser.refresh(); + await testSubjects.missingOrFail('spaceSolutionTour', { timeout: 3000 }); // The tour does not appear after refresh + }); + + it('does not show the solution tour after updating the default space from classic to solution', async () => { + await updateSolutionDefaultSpace('es'); // set a solution + await PageObjects.common.sleep(500); + await browser.refresh(); + + // The tour does not appear after refresh, even with the default space with a solution set + await testSubjects.missingOrFail('spaceSolutionTour', { timeout: 3000 }); + }); + + it('does not show the solution tour after deleting spaces and leave only the default', async () => { + await updateSolutionDefaultSpace('es'); // set a solution + + await spacesService.create({ + id: 'foo-space', + name: 'Foo Space', + disabledFeatures: [], + color: '#AABBCC', + }); + + const allSpaces = await spacesService.getAll(); + expect(allSpaces).to.have.length(2); // Make sure we have 2 spaces + + await removeGlobalSettings(); // Make sure we start from a clean state + await browser.refresh(); + + await testSubjects.missingOrFail('spaceSolutionTour', { timeout: 3000 }); + + await spacesService.delete('foo-space'); + await browser.refresh(); + + // The tour still does not appear after refresh, even with 1 space with a solution set + await testSubjects.missingOrFail('spaceSolutionTour', { timeout: 3000 }); + }); + }); + }); +} From 04efa04d6b8fc7ac6bc6996717453bd56200104a Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Tue, 15 Oct 2024 15:30:55 +0300 Subject: [PATCH 13/13] fix: [Stateful: Home page] Wrong announcement of code editor (#195922) Closes: https://github.com/elastic/kibana/issues/195289 Closes: https://github.com/elastic/kibana/issues/195198 Closes: https://github.com/elastic/kibana/issues/195358 ## Description - The text editor must be fully accessible and functional across all devices, ensuring users can edit text using various input methods, not just a mouse. This functionality should be available in both the expanded and collapsed states. - Appropriate aria-label attribute must be assigned to elements to provide users with clear context and understanding of the type of element they are interacting with. This enhances usability and accessibility for all users. ## What was changed: - Updated the aria-label attribute for the editor button to improve accessibility. - Resolved an issue with the background color when activating full-screen mode from the dialog. - Fixed keyboard navigation for full-screen mode, enabling users to activate Edit Mode using only the keyboard. ## Screen https://github.com/user-attachments/assets/af122fab-3ce9-4a7f-b8b1-d75d39969781 --- .../impl/__snapshots__/code_editor.test.tsx.snap | 4 ++-- packages/shared-ux/code_editor/impl/code_editor.tsx | 13 ++++++++++--- .../shared-ux/code_editor/impl/editor.styles.ts | 3 ++- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/shared-ux/code_editor/impl/__snapshots__/code_editor.test.tsx.snap b/packages/shared-ux/code_editor/impl/__snapshots__/code_editor.test.tsx.snap index e58bd37dead6c..787c5e348e51a 100644 --- a/packages/shared-ux/code_editor/impl/__snapshots__/code_editor.test.tsx.snap +++ b/packages/shared-ux/code_editor/impl/__snapshots__/code_editor.test.tsx.snap @@ -2,7 +2,7 @@ exports[` hint element should be tabable 1`] = `
is rendered 1`] = ` onMouseOver={[Function]} >
= ({ role="button" onClick={startEditing} onKeyDown={onKeyDownHint} - aria-label={ariaLabel} + aria-label={i18n.translate('sharedUXPackages.codeEditor.codeEditorEditButton', { + defaultMessage: '{codeEditorAriaLabel}, activate edit mode', + values: { + codeEditorAriaLabel: ariaLabel, + }, + })} data-test-subj={`codeEditorHint codeEditorHint--${isHintActive ? 'active' : 'inactive'}`} /> @@ -528,6 +533,7 @@ export const CodeEditor: React.FC = ({
) : null} + {accessibilityOverlayEnabled && isFullScreen && renderPrompt()} = ({ const useFullScreen = ({ allowFullScreen }: { allowFullScreen?: boolean }) => { const [isFullScreen, setIsFullScreen] = useState(false); + const { euiTheme } = useEuiTheme(); const toggleFullScreen = () => { setIsFullScreen(!isFullScreen); @@ -617,12 +624,12 @@ const useFullScreen = ({ allowFullScreen }: { allowFullScreen?: boolean }) => { return ( -
{children}
+
{children}
); }, - [isFullScreen] + [isFullScreen, euiTheme] ); return { diff --git a/packages/shared-ux/code_editor/impl/editor.styles.ts b/packages/shared-ux/code_editor/impl/editor.styles.ts index 62f15a4a88317..2d12cd01d031b 100644 --- a/packages/shared-ux/code_editor/impl/editor.styles.ts +++ b/packages/shared-ux/code_editor/impl/editor.styles.ts @@ -15,10 +15,11 @@ export const styles = { position: relative; height: 100%; `, - fullscreenContainer: css` + fullscreenContainer: (euiTheme: EuiThemeComputed) => css` position: absolute; left: 0; top: 0; + background: ${euiTheme.colors.body}; `, keyboardHint: (euiTheme: EuiThemeComputed) => css` position: absolute;