diff --git a/.buildkite/ftr_platform_stateful_configs.yml b/.buildkite/ftr_platform_stateful_configs.yml index fbbfc25e89a26..60b702fa1d8fc 100644 --- a/.buildkite/ftr_platform_stateful_configs.yml +++ b/.buildkite/ftr_platform_stateful_configs.yml @@ -34,7 +34,9 @@ disabled: # Cypress configs, for now these are still run manually - x-pack/test/fleet_cypress/cli_config.ts + - x-pack/test/fleet_cypress/cli_config.space_awareness.ts - x-pack/test/fleet_cypress/config.ts + - x-pack/test/fleet_cypress/config.space_awareness.ts - x-pack/test/fleet_cypress/visual_config.ts defaultQueue: 'n2-4-spot' diff --git a/.buildkite/scripts/steps/functional/fleet_cypress.sh b/.buildkite/scripts/steps/functional/fleet_cypress.sh index 43eb329f860b7..e050b73a91c3e 100755 --- a/.buildkite/scripts/steps/functional/fleet_cypress.sh +++ b/.buildkite/scripts/steps/functional/fleet_cypress.sh @@ -12,4 +12,4 @@ echo "--- Fleet Cypress tests (Chrome)" cd x-pack/plugins/fleet set +e -yarn cypress:run:reporter; status=$?; yarn junit:merge || :; exit $status +yarn cypress:run:reporter; status=$?; yarn cypress_space_awareness:run:reporter; space_status=$?; yarn junit:merge || :; [ "$status" -ne 0 ] && exit $status || [ "$space_status" -ne 0 ] && exit $space_status || exit 0 diff --git a/config/serverless.security.yml b/config/serverless.security.yml index 0396857d7ed38..ea83f23a2311b 100644 --- a/config/serverless.security.yml +++ b/config/serverless.security.yml @@ -118,3 +118,8 @@ xpack.ml.compatibleModuleType: 'security' # Disable the embedded Dev Console console.ui.embeddedEnabled: false + +# Experimental Security Solution features + +# This feature is disabled in Serverless until fully performance tested within a Serverless environment +xpack.securitySolution.enableExperimental: ['entityStoreDisabled'] 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/src/plugins/saved_objects_tagging_oss/public/decorator/index.ts b/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_default_params.ts similarity index 52% rename from src/plugins/saved_objects_tagging_oss/public/decorator/index.ts rename to packages/kbn-alerts-ui-shared/src/rule_form/utils/get_default_params.ts index e2c9f0dcf14cf..d2aab787d6eb5 100644 --- a/src/plugins/saved_objects_tagging_oss/public/decorator/index.ts +++ b/packages/kbn-alerts-ui-shared/src/rule_form/utils/get_default_params.ts @@ -6,15 +6,20 @@ * 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'; -import { SavedObjectDecoratorConfig } from '@kbn/saved-objects-plugin/public'; -import { tagDecoratorFactory, decoratorId } from './factory'; -import { InternalTagDecoratedSavedObject } from './types'; - -export type { TagDecoratedSavedObject } from './types'; - -export const tagDecoratorConfig: SavedObjectDecoratorConfig = { - id: decoratorId, - priority: 100, - factory: tagDecoratorFactory, +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/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/src/plugins/saved_objects/kibana.jsonc b/src/plugins/saved_objects/kibana.jsonc index aa1c9aae31194..1f063a7cdfa59 100644 --- a/src/plugins/saved_objects/kibana.jsonc +++ b/src/plugins/saved_objects/kibana.jsonc @@ -9,9 +9,6 @@ "requiredPlugins": [ "data", "dataViews" - ], - "requiredBundles": [ - "kibanaUtils" ] } } diff --git a/src/plugins/saved_objects/public/index.ts b/src/plugins/saved_objects/public/index.ts index 4ce1d679c743f..6d7a013cf59ca 100644 --- a/src/plugins/saved_objects/public/index.ts +++ b/src/plugins/saved_objects/public/index.ts @@ -11,13 +11,7 @@ import { SavedObjectsPublicPlugin } from './plugin'; export type { OnSaveProps, OriginSaveModalProps, SaveModalState, SaveResult } from './save_modal'; export { SavedObjectSaveModal, SavedObjectSaveModalOrigin, showSaveModal } from './save_modal'; -export type { - SavedObjectDecorator, - SavedObjectDecoratorFactory, - SavedObjectDecoratorConfig, -} from './saved_object'; export { checkForDuplicateTitle, saveWithConfirmation, isErrorNonFatal } from './saved_object'; export type { SavedObjectSaveOpts, SavedObject, SavedObjectConfig } from './types'; -export type { SavedObjectsStart, SavedObjectSetup } from './plugin'; export const plugin = () => new SavedObjectsPublicPlugin(); diff --git a/src/plugins/saved_objects/public/mocks.ts b/src/plugins/saved_objects/public/mocks.ts index 124f9bcd2d659..5d15efcb0b22f 100644 --- a/src/plugins/saved_objects/public/mocks.ts +++ b/src/plugins/saved_objects/public/mocks.ts @@ -7,21 +7,4 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { SavedObjectsStart, SavedObjectSetup } from './plugin'; - -const createStartContract = (): SavedObjectsStart => { - return { - SavedObjectClass: jest.fn(), - }; -}; - -const createSetupContract = (): jest.Mocked => { - return { - registerDecorator: jest.fn(), - }; -}; - -export const savedObjectsPluginMock = { - createStartContract, - createSetupContract, -}; +export const savedObjectsPluginMock = {}; diff --git a/src/plugins/saved_objects/public/plugin.ts b/src/plugins/saved_objects/public/plugin.ts index cbf3a5e69cf7f..fd67e783fab75 100644 --- a/src/plugins/saved_objects/public/plugin.ts +++ b/src/plugins/saved_objects/public/plugin.ts @@ -12,55 +12,19 @@ import { CoreStart, Plugin } from '@kbn/core/public'; import './index.scss'; import { DataPublicPluginStart } from '@kbn/data-plugin/public'; import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; -import { - createSavedObjectClass, - SavedObjectDecoratorRegistry, - SavedObjectDecoratorConfig, -} from './saved_object'; -import { SavedObject } from './types'; import { setStartServices } from './kibana_services'; -export interface SavedObjectSetup { - registerDecorator: (config: SavedObjectDecoratorConfig) => void; -} - -export interface SavedObjectsStart { - /** - * @deprecated - * @removeBy 8.8.0 - */ - SavedObjectClass: new (raw: Record) => SavedObject; -} - export interface SavedObjectsStartDeps { data: DataPublicPluginStart; dataViews: DataViewsPublicPluginStart; } -export class SavedObjectsPublicPlugin - implements Plugin -{ - private decoratorRegistry = new SavedObjectDecoratorRegistry(); - - public setup(): SavedObjectSetup { - return { - registerDecorator: (config) => this.decoratorRegistry.register(config), - }; +export class SavedObjectsPublicPlugin implements Plugin<{}, {}, object, SavedObjectsStartDeps> { + public setup() { + return {}; } public start(core: CoreStart, { data, dataViews }: SavedObjectsStartDeps) { setStartServices(core); - return { - SavedObjectClass: createSavedObjectClass( - { - dataViews, - savedObjectsClient: core.savedObjects.client, - search: data.search, - chrome: core.chrome, - overlays: core.overlays, - }, - core, - this.decoratorRegistry - ), - }; + return {}; } } diff --git a/src/plugins/saved_objects/public/saved_object/decorators/index.ts b/src/plugins/saved_objects/public/saved_object/decorators/index.ts deleted file mode 100644 index 62f4f27bd2590..0000000000000 --- a/src/plugins/saved_objects/public/saved_object/decorators/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", 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". - */ - -export type { ISavedObjectDecoratorRegistry, SavedObjectDecoratorConfig } from './registry'; -export { SavedObjectDecoratorRegistry } from './registry'; -export type { SavedObjectDecorator, SavedObjectDecoratorFactory } from './types'; diff --git a/src/plugins/saved_objects/public/saved_object/decorators/registry.mock.ts b/src/plugins/saved_objects/public/saved_object/decorators/registry.mock.ts deleted file mode 100644 index ad292b65e4e51..0000000000000 --- a/src/plugins/saved_objects/public/saved_object/decorators/registry.mock.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", 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 { ISavedObjectDecoratorRegistry } from './registry'; - -const createRegistryMock = () => { - const mock: jest.Mocked = { - register: jest.fn(), - getOrderedDecorators: jest.fn(), - }; - - mock.getOrderedDecorators.mockReturnValue([]); - - return mock; -}; - -export const savedObjectsDecoratorRegistryMock = { - create: createRegistryMock, -}; diff --git a/src/plugins/saved_objects/public/saved_object/decorators/registry.test.ts b/src/plugins/saved_objects/public/saved_object/decorators/registry.test.ts deleted file mode 100644 index 590e2717c48ff..0000000000000 --- a/src/plugins/saved_objects/public/saved_object/decorators/registry.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", 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 { SavedObjectDecoratorRegistry } from './registry'; - -const mockDecorator = (id: string = 'foo') => { - return { - getId: () => id, - decorateConfig: () => undefined, - decorateObject: () => undefined, - }; -}; - -describe('SavedObjectDecoratorRegistry', () => { - let registry: SavedObjectDecoratorRegistry; - - beforeEach(() => { - registry = new SavedObjectDecoratorRegistry(); - }); - - describe('register', () => { - it('allow to register a decorator', () => { - expect(() => { - registry.register({ - id: 'foo', - priority: 9000, - factory: () => mockDecorator(), - }); - }).not.toThrow(); - }); - - it('throws when trying to register the same id twice', () => { - registry.register({ - id: 'foo', - priority: 9000, - factory: () => mockDecorator(), - }); - - expect(() => { - registry.register({ - id: 'foo', - priority: 42, - factory: () => mockDecorator(), - }); - }).toThrowErrorMatchingInlineSnapshot(`"A decorator is already registered for id foo"`); - }); - - it('throws when trying to register multiple decorators with the same priority', () => { - registry.register({ - id: 'foo', - priority: 100, - factory: () => mockDecorator(), - }); - - expect(() => { - registry.register({ - id: 'bar', - priority: 100, - factory: () => mockDecorator(), - }); - }).toThrowErrorMatchingInlineSnapshot(`"A decorator is already registered for priority 100"`); - }); - }); - - describe('getOrderedDecorators', () => { - it('returns the decorators in correct order', () => { - registry.register({ - id: 'A', - priority: 1000, - factory: () => mockDecorator('A'), - }); - registry.register({ - id: 'B', - priority: 100, - factory: () => mockDecorator('B'), - }); - registry.register({ - id: 'C', - priority: 2000, - factory: () => mockDecorator('C'), - }); - - const decorators = registry.getOrderedDecorators({} as any); - expect(decorators.map((d) => d.getId())).toEqual(['B', 'A', 'C']); - }); - - it('invoke the decorators factory with the provided services', () => { - const services = Symbol('services'); - - const decorator = { - id: 'foo', - priority: 9000, - factory: jest.fn(), - }; - registry.register(decorator); - registry.getOrderedDecorators(services as any); - - expect(decorator.factory).toHaveBeenCalledTimes(1); - expect(decorator.factory).toHaveBeenCalledWith(services); - }); - - it('invoke the factory each time the method is called', () => { - const services = Symbol('services'); - - const decorator = { - id: 'foo', - priority: 9000, - factory: jest.fn(), - }; - registry.register(decorator); - registry.getOrderedDecorators(services as any); - - expect(decorator.factory).toHaveBeenCalledTimes(1); - - registry.getOrderedDecorators(services as any); - - expect(decorator.factory).toHaveBeenCalledTimes(2); - }); - }); -}); diff --git a/src/plugins/saved_objects/public/saved_object/decorators/registry.ts b/src/plugins/saved_objects/public/saved_object/decorators/registry.ts deleted file mode 100644 index 816ba2a84c1ae..0000000000000 --- a/src/plugins/saved_objects/public/saved_object/decorators/registry.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", 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 { PublicMethodsOf } from '@kbn/utility-types'; -import { SavedObjectDecoratorFactory } from './types'; -import { SavedObjectKibanaServices, SavedObject } from '../../types'; - -export interface SavedObjectDecoratorConfig { - /** - * The id of the decorator - */ - id: string; - /** - * Highest priority will be called **last** - * (the decoration will be at the highest level) - */ - priority: number; - /** - * The factory to use to create the decorator - */ - factory: SavedObjectDecoratorFactory; -} - -export type ISavedObjectDecoratorRegistry = PublicMethodsOf; - -export class SavedObjectDecoratorRegistry { - private readonly registry = new Map>(); - - public register(config: SavedObjectDecoratorConfig) { - if (this.registry.has(config.id)) { - throw new Error(`A decorator is already registered for id ${config.id}`); - } - if ([...this.registry.values()].find(({ priority }) => priority === config.priority)) { - throw new Error(`A decorator is already registered for priority ${config.priority}`); - } - this.registry.set(config.id, config); - } - - public getOrderedDecorators(services: SavedObjectKibanaServices) { - return [...this.registry.values()] - .sort((a, b) => a.priority - b.priority) - .map(({ factory }) => factory(services)); - } -} diff --git a/src/plugins/saved_objects/public/saved_object/decorators/types.ts b/src/plugins/saved_objects/public/saved_object/decorators/types.ts deleted file mode 100644 index 8f992a46a8fb9..0000000000000 --- a/src/plugins/saved_objects/public/saved_object/decorators/types.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", 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 { SavedObject, SavedObjectKibanaServices, SavedObjectConfig } from '../../types'; - -export interface SavedObjectDecorator { - /** - * Id of the decorator - */ - getId(): string; - - /** - * Decorate the saved object provided config. This can be used to enhance or alter the object's provided - * configuration. - */ - decorateConfig: (config: SavedObjectConfig) => void; - /** - * Decorate the saved object instance. Can be used to add additional methods to it. - * - * @remarks This will be called before the internal constructor of the object, meaning that - * wrapping existing methods is not possible (and is not a desired pattern). - */ - decorateObject: (object: T) => void; -} - -export type SavedObjectDecoratorFactory = ( - services: SavedObjectKibanaServices -) => SavedObjectDecorator; diff --git a/src/plugins/saved_objects/public/saved_object/helpers/build_saved_object.ts b/src/plugins/saved_objects/public/saved_object/helpers/build_saved_object.ts deleted file mode 100644 index 388f84fcd524d..0000000000000 --- a/src/plugins/saved_objects/public/saved_object/helpers/build_saved_object.ts +++ /dev/null @@ -1,135 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", 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 { once } from 'lodash'; -import { hydrateIndexPattern } from './hydrate_index_pattern'; -import { intializeSavedObject } from './initialize_saved_object'; -import { serializeSavedObject } from './serialize_saved_object'; - -import { - EsResponse, - SavedObject, - SavedObjectConfig, - SavedObjectKibanaServices, - SavedObjectSaveOpts, - StartServices, -} from '../../types'; -import { applyESResp } from './apply_es_resp'; -import { saveSavedObject } from './save_saved_object'; -import { SavedObjectDecorator } from '../decorators'; - -const applyDecorators = ( - object: SavedObject, - config: SavedObjectConfig, - decorators: SavedObjectDecorator[] -) => { - decorators.forEach((decorator) => { - decorator.decorateConfig(config); - decorator.decorateObject(object); - }); -}; - -export function buildSavedObject( - savedObject: SavedObject, - config: SavedObjectConfig, - services: SavedObjectKibanaServices, - startServices: StartServices, - decorators: SavedObjectDecorator[] = [] -) { - applyDecorators(savedObject, config, decorators); - - const { dataViews, savedObjectsClient } = services; - // type name for this object, used as the ES-type - const esType = config.type || ''; - - savedObject.getDisplayName = () => esType; - - // NOTE: this.type (not set in this file, but somewhere else) is the sub type, e.g. 'area' or - // 'data table', while esType is the more generic type - e.g. 'visualization' or 'saved search'. - savedObject.getEsType = () => esType; - - /** - * Flips to true during a save operation, and back to false once the save operation - * completes. - * @type {boolean} - */ - savedObject.isSaving = false; - savedObject.defaults = config.defaults || {}; - // optional search source which this object configures - savedObject.searchSource = config.searchSource - ? services.search.searchSource.createEmpty() - : undefined; - // the id of the document - savedObject.id = config.id || void 0; - // the migration version of the document, should only be set on imports - savedObject.migrationVersion = config.migrationVersion; - // Whether to create a copy when the object is saved. This should eventually go away - // in favor of a better rename/save flow. - savedObject.copyOnSave = false; - - /** - * After creation or fetching from ES, ensure that the searchSources index indexPattern - * is an bonafide IndexPattern object. - * - * @return {Promise} - */ - savedObject.hydrateIndexPattern = (id?: string) => - hydrateIndexPattern(id || '', savedObject, dataViews, config); - /** - * Asynchronously initialize this object - will only run - * once even if called multiple times. - * - * @return {Promise} - * @resolved {SavedObject} - */ - savedObject.init = once(() => intializeSavedObject(savedObject, savedObjectsClient, config)); - - savedObject.applyESResp = (resp: EsResponse) => applyESResp(resp, savedObject, config, services); - - /** - * Serialize this object - * @return {Object} - */ - savedObject._serialize = () => serializeSavedObject(savedObject, config); - - /** - * Returns true if the object's original title has been changed. New objects return false. - * @return {boolean} - */ - savedObject.isTitleChanged = () => - savedObject._source && savedObject._source.title !== savedObject.title; - - savedObject.creationOpts = (opts: Record = {}) => ({ - id: savedObject.id, - migrationVersion: savedObject.migrationVersion, - ...opts, - }); - - savedObject.save = async (opts: SavedObjectSaveOpts) => { - try { - const result = await saveSavedObject(savedObject, config, opts, services, startServices); - return Promise.resolve(result); - } catch (e) { - return Promise.reject(e); - } - }; - - savedObject.destroy = () => {}; - - /** - * Delete this object from Elasticsearch - * @return {promise} - */ - savedObject.delete = () => { - if (!savedObject.id) { - return Promise.reject(new Error('Deleting a saved Object requires type and id')); - } - return savedObjectsClient.delete(esType, savedObject.id); - }; -} diff --git a/src/plugins/saved_objects/public/saved_object/index.ts b/src/plugins/saved_objects/public/saved_object/index.ts index 89cfa263093b1..560178fdf4f36 100644 --- a/src/plugins/saved_objects/public/saved_object/index.ts +++ b/src/plugins/saved_objects/public/saved_object/index.ts @@ -7,13 +7,6 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { createSavedObjectClass } from './saved_object'; export { checkForDuplicateTitle } from './helpers/check_for_duplicate_title'; export { saveWithConfirmation } from './helpers/save_with_confirmation'; export { isErrorNonFatal } from './helpers/save_saved_object'; -export type { - SavedObjectDecoratorFactory, - SavedObjectDecorator, - SavedObjectDecoratorConfig, -} from './decorators'; -export { SavedObjectDecoratorRegistry } from './decorators'; diff --git a/src/plugins/saved_objects/public/saved_object/saved_object.test.ts b/src/plugins/saved_objects/public/saved_object/saved_object.test.ts deleted file mode 100644 index 645ae855b277b..0000000000000 --- a/src/plugins/saved_objects/public/saved_object/saved_object.test.ts +++ /dev/null @@ -1,888 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", 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 { createSavedObjectClass } from './saved_object'; -import { - SavedObject, - SavedObjectConfig, - SavedObjectKibanaServices, - SavedObjectSaveOpts, -} from '../types'; -import { SavedObjectDecorator } from './decorators'; - -import { - analyticsServiceMock, - coreMock, - i18nServiceMock, - themeServiceMock, -} from '@kbn/core/public/mocks'; -import { dataPluginMock, createSearchSourceMock } from '@kbn/data-plugin/public/mocks'; -import { createStubIndexPattern } from '@kbn/data-plugin/common/stubs'; -import { SavedObjectAttributes, SimpleSavedObject } from '@kbn/core/public'; -import { DataView } from '@kbn/data-plugin/common'; -import { savedObjectsDecoratorRegistryMock } from './decorators/registry.mock'; - -describe('Saved Object', () => { - const startMock = coreMock.createStart(); - const dataStartMock = dataPluginMock.createStartContract(); - const saveOptionsMock = {} as SavedObjectSaveOpts; - const savedObjectsClientStub = startMock.savedObjects.client; - const startServices = { - analytics: analyticsServiceMock.createAnalyticsServiceStart(), - i18n: i18nServiceMock.createStartContract(), - theme: themeServiceMock.createStartContract(), - }; - let decoratorRegistry: ReturnType; - - let SavedObjectClass: new (config: SavedObjectConfig) => SavedObject; - - /** - * Returns a fake doc response with the given index and id, of type dashboard - * that can be used to stub es calls. - * @param indexPatternId - * @param additionalOptions - object that will be assigned to the mocked doc response. - * @returns {{attributes: {}, type: string, id: *, _version: string}} - */ - function getMockedDocResponse(indexPatternId: string, additionalOptions = {}) { - return { - type: 'dashboard', - id: indexPatternId, - _version: 'foo', - attributes: {}, - ...additionalOptions, - } as SimpleSavedObject; - } - - /** - * Stubs some of the es retrieval calls so it returns the given response. - * @param {Object} mockDocResponse - */ - function stubESResponse(mockDocResponse: SimpleSavedObject) { - // Stub out search for duplicate title: - savedObjectsClientStub.get = jest.fn().mockReturnValue(Promise.resolve(mockDocResponse)); - savedObjectsClientStub.update = jest.fn().mockReturnValue(Promise.resolve(mockDocResponse)); - - savedObjectsClientStub.find = jest - .fn() - .mockReturnValue(Promise.resolve({ savedObjects: [], total: 0 })); - - savedObjectsClientStub.bulkGet = jest - .fn() - .mockReturnValue(Promise.resolve({ savedObjects: [mockDocResponse] })); - } - - function stubSavedObjectsClientCreate( - resp: SimpleSavedObject | string, - resolve = true - ) { - savedObjectsClientStub.create = jest - .fn() - .mockReturnValue(resolve ? Promise.resolve(resp) : Promise.reject(resp)); - } - - /** - * Creates a new saved object with the given configuration and initializes it. - * Returns the promise that will be completed when the initialization finishes. - * - * @param {Object} config - * @returns {Promise} A promise that resolves with an instance of - * SavedObject - */ - function createInitializedSavedObject(config: SavedObjectConfig = { type: 'dashboard' }) { - const savedObject = new SavedObjectClass(config); - savedObject.title = 'my saved object'; - - return savedObject.init!(); - } - - const initSavedObjectClass = () => { - SavedObjectClass = createSavedObjectClass( - { - savedObjectsClient: savedObjectsClientStub, - indexPatterns: dataStartMock.indexPatterns, - search: { - ...dataStartMock.search, - searchSource: { - ...dataStartMock.search.searchSource, - create: createSearchSourceMock, - createEmpty: createSearchSourceMock, - }, - }, - } as unknown as SavedObjectKibanaServices, - startServices, - decoratorRegistry - ); - }; - - beforeEach(() => { - decoratorRegistry = savedObjectsDecoratorRegistryMock.create(); - initSavedObjectClass(); - }); - - describe('decorators', () => { - it('calls the decorators during construct', () => { - const decorA = { - getId: () => 'A', - decorateConfig: jest.fn(), - decorateObject: jest.fn(), - }; - const decorB = { - getId: () => 'B', - decorateConfig: jest.fn(), - decorateObject: jest.fn(), - }; - - decoratorRegistry.getOrderedDecorators.mockReturnValue([decorA, decorB]); - - initSavedObjectClass(); - createInitializedSavedObject(); - - expect(decorA.decorateConfig).toHaveBeenCalledTimes(1); - expect(decorA.decorateObject).toHaveBeenCalledTimes(1); - }); - - it('calls the decorators in correct order', () => { - const decorA = { - getId: () => 'A', - decorateConfig: jest.fn(), - decorateObject: jest.fn(), - }; - const decorB = { - getId: () => 'B', - decorateConfig: jest.fn(), - decorateObject: jest.fn(), - }; - - decoratorRegistry.getOrderedDecorators.mockReturnValue([decorA, decorB]); - - initSavedObjectClass(); - createInitializedSavedObject(); - - expect(decorA.decorateConfig.mock.invocationCallOrder[0]).toBeLessThan( - decorB.decorateConfig.mock.invocationCallOrder[0] - ); - expect(decorA.decorateObject.mock.invocationCallOrder[0]).toBeLessThan( - decorB.decorateObject.mock.invocationCallOrder[0] - ); - }); - - it('passes the mutated config and object down the decorator chain', () => { - expect.assertions(2); - - const newMappingValue = 'string'; - const newObjectMethod = jest.fn(); - - const decorA: SavedObjectDecorator = { - getId: () => 'A', - decorateConfig: (config) => { - config.mapping = { - ...config.mapping, - addedFromA: newMappingValue, - }; - }, - decorateObject: (object) => { - (object as any).newMethod = newObjectMethod; - }, - }; - const decorB: SavedObjectDecorator = { - getId: () => 'B', - decorateConfig: (config) => { - expect(config.mapping!.addedFromA).toBe(newMappingValue); - }, - decorateObject: (object) => { - expect((object as any).newMethod).toBe(newObjectMethod); - }, - }; - - decoratorRegistry.getOrderedDecorators.mockReturnValue([decorA, decorB]); - - initSavedObjectClass(); - createInitializedSavedObject(); - }); - }); - - describe('save', () => { - describe('with confirmOverwrite', () => { - it('when false does not request overwrite', () => { - stubESResponse(getMockedDocResponse('myId')); - - return createInitializedSavedObject({ type: 'dashboard', id: 'myId' }).then( - (savedObject) => { - stubSavedObjectsClientCreate({ - id: 'myId', - } as SimpleSavedObject); - - return savedObject.save({ confirmOverwrite: false }).then(() => { - expect(startMock.overlays.openModal).not.toHaveBeenCalled(); - }); - } - ); - }); - }); - - describe('with copyOnSave', () => { - it('as true creates a copy on save success', () => { - stubESResponse(getMockedDocResponse('myId')); - - return createInitializedSavedObject({ type: 'dashboard', id: 'myId' }).then( - (savedObject) => { - stubSavedObjectsClientCreate({ - type: 'dashboard', - id: 'newUniqueId', - } as SimpleSavedObject); - savedObject.copyOnSave = true; - - return savedObject.save(saveOptionsMock).then((id) => { - expect(id).toBe('newUniqueId'); - }); - } - ); - }); - - it('as true does not create a copy when save fails', () => { - const originalId = 'id1'; - stubESResponse(getMockedDocResponse(originalId)); - - return createInitializedSavedObject({ type: 'dashboard', id: originalId }).then( - (savedObject) => { - stubSavedObjectsClientCreate('simulated error', false); - savedObject.copyOnSave = true; - - return savedObject - .save(saveOptionsMock) - .then(() => { - expect(false).toBe(true); - }) - .catch(() => { - expect(savedObject.id).toBe(originalId); - }); - } - ); - }); - - it('as false does not create a copy', () => { - const myId = 'myId'; - stubESResponse(getMockedDocResponse(myId)); - - return createInitializedSavedObject({ type: 'dashboard', id: myId }).then((savedObject) => { - savedObjectsClientStub.create = jest.fn().mockImplementation(() => { - expect(savedObject.id).toBe(myId); - return Promise.resolve({ id: myId }); - }); - savedObject.copyOnSave = false; - - return savedObject.save(saveOptionsMock).then((id) => { - expect(id).toBe(myId); - }); - }); - }); - }); - - it('returns id from server on success', () => { - return createInitializedSavedObject({ type: 'dashboard' }).then((savedObject) => { - stubESResponse(getMockedDocResponse('myId')); - stubSavedObjectsClientCreate({ - type: 'dashboard', - id: 'myId', - _version: 'foo', - } as SimpleSavedObject); - - return savedObject.save(saveOptionsMock).then((id) => { - expect(id).toBe('myId'); - }); - }); - }); - - describe('updates isSaving variable', () => { - it('on success', () => { - const id = 'id'; - stubESResponse(getMockedDocResponse(id)); - - return createInitializedSavedObject({ type: 'dashboard', id }).then((savedObject) => { - savedObjectsClientStub.create = jest.fn().mockImplementation(() => { - expect(savedObject.isSaving).toBe(true); - return Promise.resolve({ - type: 'dashboard', - id, - _version: 'foo', - }); - }); - - expect(savedObject.isSaving).toBe(false); - return savedObject.save(saveOptionsMock).then(() => { - expect(savedObject.isSaving).toBe(false); - }); - }); - }); - - it('on failure', () => { - stubESResponse(getMockedDocResponse('id')); - return createInitializedSavedObject({ type: 'dashboard' }).then((savedObject) => { - savedObjectsClientStub.create = jest.fn().mockImplementation(() => { - expect(savedObject.isSaving).toBe(true); - return Promise.reject(''); - }); - - expect(savedObject.isSaving).toBe(false); - return savedObject.save(saveOptionsMock).catch(() => { - expect(savedObject.isSaving).toBe(false); - }); - }); - }); - }); - - describe('to extract references', () => { - it('when "extractReferences" function when passed in', async () => { - const id = '123'; - stubESResponse(getMockedDocResponse(id)); - const extractReferences: SavedObjectConfig['extractReferences'] = ({ - attributes, - references, - }) => { - references.push({ - name: 'test', - type: 'index-pattern', - id: 'my-index', - }); - return { attributes, references }; - }; - return createInitializedSavedObject({ type: 'dashboard', extractReferences }).then( - (savedObject) => { - stubSavedObjectsClientCreate({ - id, - _version: 'foo', - type: 'dashboard', - } as SimpleSavedObject); - - return savedObject.save(saveOptionsMock).then(() => { - const { references } = (savedObjectsClientStub.create as jest.Mock).mock.calls[0][2]; - expect(references).toHaveLength(1); - expect(references[0]).toEqual({ - name: 'test', - type: 'index-pattern', - id: 'my-index', - }); - }); - } - ); - }); - - it('when search source references saved object', () => { - const id = '123'; - stubESResponse(getMockedDocResponse(id)); - return createInitializedSavedObject({ type: 'dashboard', searchSource: true }).then( - (savedObject) => { - stubSavedObjectsClientCreate({ - id, - _version: '2', - type: 'dashboard', - } as SimpleSavedObject); - - const indexPattern = createStubIndexPattern({ - spec: { id: 'my-index', title: 'my-index', version: '1' }, - }); - savedObject.searchSource!.setField('index', indexPattern); - return savedObject.save(saveOptionsMock).then(() => { - const args = (savedObjectsClientStub.create as jest.Mock).mock.calls[0]; - expect(args[1]).toEqual({ - kibanaSavedObjectMeta: { - searchSourceJSON: JSON.stringify({ - indexRefName: 'kibanaSavedObjectMeta.searchSourceJSON.index', - }), - }, - }); - - expect(args[2].references).toHaveLength(1); - expect(args[2].references[0]).toEqual({ - name: 'kibanaSavedObjectMeta.searchSourceJSON.index', - type: 'index-pattern', - id: 'my-index', - }); - }); - } - ); - }); - - it('when index in searchSourceJSON is not found', () => { - const id = '123'; - stubESResponse(getMockedDocResponse(id)); - return createInitializedSavedObject({ type: 'dashboard', searchSource: true }).then( - (savedObject) => { - stubSavedObjectsClientCreate({ - id, - _version: '2', - type: 'dashboard', - } as SimpleSavedObject); - - const indexPattern = createStubIndexPattern({ - spec: { - id: 'non-existant-index', - version: '1', - }, - }); - - savedObject.searchSource!.setField('index', indexPattern); - return savedObject.save(saveOptionsMock).then(() => { - const args = (savedObjectsClientStub.create as jest.Mock).mock.calls[0]; - expect(args[1]).toEqual({ - kibanaSavedObjectMeta: { - searchSourceJSON: JSON.stringify({ - indexRefName: 'kibanaSavedObjectMeta.searchSourceJSON.index', - }), - }, - }); - expect(args[2].references).toHaveLength(1); - expect(args[2].references[0]).toEqual({ - name: 'kibanaSavedObjectMeta.searchSourceJSON.index', - type: 'index-pattern', - id: 'non-existant-index', - }); - }); - } - ); - }); - - it('when indexes exists in filter of searchSourceJSON', () => { - const id = '123'; - stubESResponse(getMockedDocResponse(id)); - return createInitializedSavedObject({ type: 'dashboard', searchSource: true }).then( - (savedObject) => { - stubSavedObjectsClientCreate({ - id, - _version: '2', - type: 'dashboard', - } as SimpleSavedObject); - - savedObject.searchSource!.setField('filter', [ - { - meta: { - index: 'my-index', - }, - }, - ] as any); - return savedObject.save(saveOptionsMock).then(() => { - const args = (savedObjectsClientStub.create as jest.Mock).mock.calls[0]; - expect(args[1]).toEqual({ - kibanaSavedObjectMeta: { - searchSourceJSON: JSON.stringify({ - filter: [ - { - meta: { - indexRefName: - 'kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index', - }, - }, - ], - }), - }, - }); - expect(args[2].references).toHaveLength(1); - expect(args[2].references[0]).toEqual({ - name: 'kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index', - type: 'index-pattern', - id: 'my-index', - }); - }); - } - ); - }); - }); - }); - - describe('applyESResp', () => { - it('throws error if not found', () => { - return createInitializedSavedObject({ type: 'dashboard' }).then((savedObject) => { - const response = { _source: {} }; - try { - savedObject.applyESResp(response); - expect(true).toBe(false); - } catch (err) { - expect(!!err).toBe(true); - } - }); - }); - - it('preserves original defaults if not overridden', () => { - const id = 'anid'; - const preserveMeValue = 'here to stay!'; - const config = { - defaults: { - preserveMe: preserveMeValue, - }, - type: 'dashboard', - id, - }; - - const mockDocResponse = getMockedDocResponse(id); - stubESResponse(mockDocResponse); - - const savedObject = new SavedObjectClass(config); - return savedObject.init!() - .then(() => { - expect(savedObject._source.preserveMe).toEqual(preserveMeValue); - const response = { found: true, _source: {} }; - return savedObject.applyESResp(response); - }) - .then(() => { - expect(savedObject._source.preserveMe).toEqual(preserveMeValue); - }); - }); - - it('overrides defaults', () => { - const id = 'anid'; - const config = { - defaults: { - flower: 'rose', - }, - type: 'dashboard', - id, - }; - - stubESResponse(getMockedDocResponse(id)); - - const savedObject = new SavedObjectClass(config); - return savedObject.init!() - .then(() => { - expect(savedObject._source.flower).toEqual('rose'); - const response = { - found: true, - _source: { - flower: 'orchid', - }, - }; - return savedObject.applyESResp(response); - }) - .then(() => { - expect(savedObject._source.flower).toEqual('orchid'); - }); - }); - - it('overrides previous _source and default values', () => { - const id = 'anid'; - const config = { - defaults: { - dinosaurs: { - tRex: 'is the scariest', - }, - }, - type: 'dashboard', - id, - }; - - const mockDocResponse = getMockedDocResponse(id, { - attributes: { dinosaurs: { tRex: 'is not so bad' } }, - }); - stubESResponse(mockDocResponse); - - const savedObject = new SavedObjectClass(config); - return savedObject.init!() - .then(() => { - const response = { - found: true, - _source: { dinosaurs: { tRex: 'has big teeth' } }, - }; - - return savedObject.applyESResp(response); - }) - .then(() => { - expect((savedObject._source as any).dinosaurs.tRex).toEqual('has big teeth'); - }); - }); - - it('does not inject references when references array is missing', async () => { - const injectReferences = jest.fn(); - const config = { - type: 'dashboard', - injectReferences, - }; - const savedObject = new SavedObjectClass(config); - return savedObject.init!() - .then(() => { - const response = { - found: true, - _source: { - dinosaurs: { tRex: 'has big teeth' }, - }, - }; - return savedObject.applyESResp(response); - }) - .then(() => { - expect(injectReferences).not.toHaveBeenCalled(); - }); - }); - - it('does not inject references when references array is empty', async () => { - const injectReferences = jest.fn(); - const config = { - type: 'dashboard', - injectReferences, - }; - const savedObject = new SavedObjectClass(config); - return savedObject.init!() - .then(() => { - const response = { - found: true, - _source: { - dinosaurs: { tRex: 'has big teeth' }, - }, - references: [], - }; - return savedObject.applyESResp(response); - }) - .then(() => { - expect(injectReferences).not.toHaveBeenCalled(); - }); - }); - - it('injects references when function is provided and references exist', async () => { - const injectReferences = jest.fn(); - const config = { - type: 'dashboard', - injectReferences, - }; - const savedObject = new SavedObjectClass(config); - return savedObject.init!() - .then(() => { - const response = { - found: true, - _source: { - dinosaurs: { tRex: 'has big teeth' }, - }, - references: [{}], - }; - return savedObject.applyESResp(response); - }) - .then(() => { - expect(injectReferences).toHaveBeenCalledTimes(1); - }); - }); - - it('passes references to search source parsing function', async () => { - SavedObjectClass = createSavedObjectClass( - { - savedObjectsClient: savedObjectsClientStub, - indexPatterns: dataStartMock.indexPatterns, - search: { - ...dataStartMock.search, - }, - } as unknown as SavedObjectKibanaServices, - startServices, - decoratorRegistry - ); - const savedObject = new SavedObjectClass({ type: 'dashboard', searchSource: true }); - return savedObject.init!().then(async () => { - const searchSourceJSON = JSON.stringify({ - indexRefName: 'kibanaSavedObjectMeta.searchSourceJSON.index', - filter: [ - { - meta: { - indexRefName: 'kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index', - }, - }, - ], - }); - const response = { - found: true, - _source: { - kibanaSavedObjectMeta: { - searchSourceJSON, - }, - }, - references: [ - { - name: 'kibanaSavedObjectMeta.searchSourceJSON.index', - type: 'index-pattern', - id: 'my-index-1', - }, - { - name: 'kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index', - type: 'index-pattern', - id: 'my-index-2', - }, - ], - }; - await savedObject.applyESResp(response); - expect(dataStartMock.search.searchSource.create).toBeCalledWith({ - filter: [{ meta: { index: 'my-index-2' } }], - index: 'my-index-1', - }); - }); - }); - }); - - describe('config', () => { - it('afterESResp is called', () => { - const afterESRespCallback = jest.fn(); - const config = { - type: 'dashboard', - afterESResp: afterESRespCallback, - }; - - return createInitializedSavedObject(config).then(() => { - expect(afterESRespCallback).toHaveBeenCalled(); - }); - }); - - describe('searchSource', () => { - it('when true, creates index', () => { - const indexPatternId = 'testIndexPattern'; - const afterESRespCallback = jest.fn(); - - const config: SavedObjectConfig = { - type: 'dashboard', - afterESResp: afterESRespCallback, - searchSource: true, - indexPattern: { id: indexPatternId } as DataView, - }; - - stubESResponse( - getMockedDocResponse(indexPatternId, { - attributes: { - title: 'testIndexPattern', - }, - }) - ); - - const savedObject = new SavedObjectClass(config); - savedObject.hydrateIndexPattern = jest.fn().mockImplementation(() => { - const indexPattern = createStubIndexPattern({ - spec: { - id: indexPatternId, - title: indexPatternId, - }, - }); - savedObject.searchSource!.setField('index', indexPattern); - return Promise.resolve(indexPattern); - }); - expect(!!savedObject.searchSource!.getField('index')).toBe(false); - - return savedObject.init!().then(() => { - expect(afterESRespCallback).toHaveBeenCalled(); - const index = savedObject.searchSource!.getField('index'); - expect(index instanceof DataView).toBe(true); - expect(index!.id).toEqual(indexPatternId); - }); - }); - - it('when false, does not create index', () => { - const indexPatternId = 'testIndexPattern'; - const afterESRespCallback = jest.fn(); - - const config: SavedObjectConfig = { - type: 'dashboard', - afterESResp: afterESRespCallback, - searchSource: false, - indexPattern: { id: indexPatternId } as DataView, - }; - - stubESResponse(getMockedDocResponse(indexPatternId)); - - const savedObject = new SavedObjectClass(config); - expect(!!savedObject.searchSource).toBe(false); - - return savedObject.init!().then(() => { - expect(afterESRespCallback).toHaveBeenCalled(); - expect(!!savedObject.searchSource).toBe(false); - }); - }); - }); - - describe('type', () => { - it('that is not specified throws an error', (done) => { - const config = {}; - - const savedObject = new SavedObjectClass(config); - savedObject.init!().catch(() => { - done(); - }); - }); - - it('that is invalid invalid throws an error', () => { - const config = { type: 'notypeexists' }; - - const savedObject = new SavedObjectClass(config); - try { - savedObject.init!(); - expect(false).toBe(true); - } catch (err) { - expect(err).not.toBeNull(); - } - }); - - it('that is valid passes', () => { - const config = { type: 'dashboard' }; - return new SavedObjectClass(config).init!(); - }); - }); - - describe('defaults', () => { - function getTestDefaultConfig(extraOptions: object = {}) { - return { - defaults: { testDefault: 'hi' }, - type: 'dashboard', - ...extraOptions, - }; - } - - function expectDefaultApplied(config: SavedObjectConfig) { - return createInitializedSavedObject(config).then((savedObject) => { - expect(savedObject.defaults).toBe(config.defaults); - }); - } - - describe('applied to object when id', () => { - it('is not specified', () => { - expectDefaultApplied(getTestDefaultConfig()); - }); - - it('is undefined', () => { - expectDefaultApplied(getTestDefaultConfig({ id: undefined })); - }); - - it('is 0', () => { - expectDefaultApplied(getTestDefaultConfig({ id: 0 })); - }); - - it('is false', () => { - expectDefaultApplied(getTestDefaultConfig({ id: false })); - }); - }); - - it('applied to source if an id is given', () => { - const myId = 'myid'; - const customDefault = 'hi'; - const initialOverwriteMeValue = 'this should get overwritten by the server response'; - - const config = { - defaults: { - overwriteMe: initialOverwriteMeValue, - customDefault, - }, - type: 'dashboard', - id: myId, - }; - - const serverValue = 'this should override the initial default value given'; - - const mockDocResponse = getMockedDocResponse(myId, { - attributes: { overwriteMe: serverValue }, - }); - - stubESResponse(mockDocResponse); - - return createInitializedSavedObject(config).then((savedObject) => { - expect(!!savedObject._source).toBe(true); - expect(savedObject.defaults).toBe(config.defaults); - expect(savedObject._source.overwriteMe).toBe(serverValue); - expect(savedObject._source.customDefault).toBe(customDefault); - }); - }); - }); - }); -}); diff --git a/src/plugins/saved_objects/public/saved_object/saved_object.ts b/src/plugins/saved_objects/public/saved_object/saved_object.ts deleted file mode 100644 index cb83021582b2e..0000000000000 --- a/src/plugins/saved_objects/public/saved_object/saved_object.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", 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". - */ - -/** - * @name SavedObject - * - * NOTE: SavedObject seems to track a reference to an object in ES, - * and surface methods for CRUD functionality (save and delete). This seems - * similar to how Backbone Models work. - * - * This class seems to interface with ES primarily through the es Angular - * service and the saved object api. - */ -import { SavedObject, SavedObjectConfig, SavedObjectKibanaServices, StartServices } from '../types'; -import { ISavedObjectDecoratorRegistry } from './decorators'; -import { buildSavedObject } from './helpers/build_saved_object'; - -export function createSavedObjectClass( - services: SavedObjectKibanaServices, - startServices: StartServices, - decoratorRegistry: ISavedObjectDecoratorRegistry -) { - /** - * The SavedObject class is a base class for saved objects loaded from the server and - * provides additional functionality besides loading/saving/deleting/etc. - * - * It is overloaded and configured to provide type-aware functionality. - * @param {*} config - */ - class SavedObjectClass { - constructor(config: SavedObjectConfig = {}) { - // @ts-ignore - const self: SavedObject = this; - buildSavedObject( - self, - config, - services, - startServices, - decoratorRegistry.getOrderedDecorators(services) - ); - } - } - - return SavedObjectClass as new (config: SavedObjectConfig) => SavedObject; -} diff --git a/src/plugins/saved_objects/tsconfig.json b/src/plugins/saved_objects/tsconfig.json index 4dbced6ac31a7..ccec7b43ad9f1 100644 --- a/src/plugins/saved_objects/tsconfig.json +++ b/src/plugins/saved_objects/tsconfig.json @@ -11,7 +11,6 @@ "@kbn/i18n", "@kbn/data-views-plugin", "@kbn/i18n-react", - "@kbn/utility-types", "@kbn/ui-theme", "@kbn/react-kibana-mount", "@kbn/test-jest-helpers", diff --git a/src/plugins/saved_objects_tagging_oss/public/api.mock.ts b/src/plugins/saved_objects_tagging_oss/public/api.mock.ts index 09655c6d28108..07539956c1485 100644 --- a/src/plugins/saved_objects_tagging_oss/public/api.mock.ts +++ b/src/plugins/saved_objects_tagging_oss/public/api.mock.ts @@ -55,8 +55,6 @@ type SavedObjectsTaggingApiUiMock = Omit, const createApiUiMock = () => { const mock: SavedObjectsTaggingApiUiMock = { components: createApiUiComponentsMock(), - // TS is very picky with type guards - hasTagDecoration: jest.fn() as any, getSearchBarFilter: jest.fn(), getTableColumnDefinition: jest.fn(), convertNameToReference: jest.fn(), diff --git a/src/plugins/saved_objects_tagging_oss/public/api.ts b/src/plugins/saved_objects_tagging_oss/public/api.ts index f211250532094..fa7f3665ac023 100644 --- a/src/plugins/saved_objects_tagging_oss/public/api.ts +++ b/src/plugins/saved_objects_tagging_oss/public/api.ts @@ -12,8 +12,6 @@ import { SearchFilterConfig, EuiTableFieldDataColumnType, EuiComboBoxProps } fro import type { FunctionComponent } from 'react'; import { SavedObject, SavedObjectReference } from '@kbn/core/types'; import { SavedObjectsFindOptionsReference } from '@kbn/core/public'; -import { SavedObject as SavedObjectClass } from '@kbn/saved-objects-plugin/public'; -import { TagDecoratedSavedObject } from './decorator'; import { ITagsClient, Tag, TagWithOptionalId } from '../common'; /** @@ -50,11 +48,6 @@ export interface ITagsCache { getState$(params?: { waitForInitialization?: boolean }): Observable; } -/** - * @public - */ -export type SavedObjectTagDecoratorTypeGuard = SavedObjectsTaggingApiUi['hasTagDecoration']; - /** * React components and utility methods to use the SO tagging feature * @@ -72,13 +65,6 @@ export interface SavedObjectsTaggingApiUi { */ getTagList(): Tag[]; - /** - * Type-guard to safely manipulate tag-enhanced `SavedObject` from the `savedObject` plugin. - * - * @param object - */ - hasTagDecoration(object: SavedObjectClass): object is TagDecoratedSavedObject; - /** * Return a filter that can be used to filter by tag with `EuiSearchBar` or EUI tables using `EuiSearchBar`. * diff --git a/src/plugins/saved_objects_tagging_oss/public/decorator/decorate_config.test.mocks.ts b/src/plugins/saved_objects_tagging_oss/public/decorator/decorate_config.test.mocks.ts deleted file mode 100644 index 5ca655b8a667d..0000000000000 --- a/src/plugins/saved_objects_tagging_oss/public/decorator/decorate_config.test.mocks.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", 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". - */ - -export const injectTagReferencesMock = jest.fn(); -jest.doMock('./inject_tag_references', () => ({ - injectTagReferences: injectTagReferencesMock, -})); - -export const extractTagReferencesMock = jest.fn(); -jest.doMock('./extract_tag_references', () => ({ - extractTagReferences: extractTagReferencesMock, -})); diff --git a/src/plugins/saved_objects_tagging_oss/public/decorator/decorate_config.test.ts b/src/plugins/saved_objects_tagging_oss/public/decorator/decorate_config.test.ts deleted file mode 100644 index 37536804e6c87..0000000000000 --- a/src/plugins/saved_objects_tagging_oss/public/decorator/decorate_config.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", 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 { extractTagReferencesMock, injectTagReferencesMock } from './decorate_config.test.mocks'; - -import { SavedObjectConfig } from '@kbn/saved-objects-plugin/public'; -import { decorateConfig } from './decorate_config'; - -describe('decorateConfig', () => { - afterEach(() => { - extractTagReferencesMock.mockReset(); - injectTagReferencesMock.mockReset(); - }); - - describe('mapping', () => { - it('adds the `__tags` key to the config mapping', () => { - const config: SavedObjectConfig = { - mapping: { - someText: 'text', - someNum: 'number', - }, - }; - - decorateConfig(config); - - expect(config.mapping).toEqual({ - __tags: 'text', - someText: 'text', - someNum: 'number', - }); - }); - - it('adds mapping to the config if missing', () => { - const config: SavedObjectConfig = {}; - - decorateConfig(config); - - expect(config.mapping).toEqual({ - __tags: 'text', - }); - }); - }); - - describe('injectReferences', () => { - it('decorates to only call `injectTagReferences` when not present on the config', () => { - const config: SavedObjectConfig = {}; - - decorateConfig(config); - - const object: any = Symbol('object'); - const references: any = Symbol('referebces'); - - config.injectReferences!(object, references); - - expect(injectTagReferencesMock).toHaveBeenCalledTimes(1); - expect(injectTagReferencesMock).toHaveBeenCalledWith(object, references); - }); - - it('decorates to call both functions when present on the config', () => { - const initialInjectReferences = jest.fn(); - - const config: SavedObjectConfig = { - injectReferences: initialInjectReferences, - }; - - decorateConfig(config); - - const object: any = Symbol('object'); - const references: any = Symbol('references'); - - config.injectReferences!(object, references); - - expect(initialInjectReferences).toHaveBeenCalledTimes(1); - expect(initialInjectReferences).toHaveBeenCalledWith(object, references); - - expect(injectTagReferencesMock).toHaveBeenCalledTimes(1); - expect(injectTagReferencesMock).toHaveBeenCalledWith(object, references); - }); - }); - - describe('extractReferences', () => { - it('decorates to only call `extractTagReference` when not present on the config', () => { - const config: SavedObjectConfig = {}; - - decorateConfig(config); - - const params: any = Symbol('params'); - const expectedReturn = Symbol('return-from-extractTagReferences'); - - extractTagReferencesMock.mockReturnValue(expectedReturn); - - const result = config.extractReferences!(params); - - expect(result).toBe(expectedReturn); - - expect(extractTagReferencesMock).toHaveBeenCalledTimes(1); - expect(extractTagReferencesMock).toHaveBeenCalledWith(params); - }); - - it('decorates to call both functions in order when present on the config', () => { - const initialExtractReferences = jest.fn(); - - const config: SavedObjectConfig = { - extractReferences: initialExtractReferences, - }; - - decorateConfig(config); - - const params: any = Symbol('initial-params'); - const initialReturn = Symbol('return-from-initial-extractReferences'); - const tagReturn = Symbol('return-from-extractTagReferences'); - - initialExtractReferences.mockReturnValue(initialReturn); - extractTagReferencesMock.mockReturnValue(tagReturn); - - const result = config.extractReferences!(params); - - expect(initialExtractReferences).toHaveBeenCalledTimes(1); - expect(initialExtractReferences).toHaveBeenCalledWith(params); - - expect(extractTagReferencesMock).toHaveBeenCalledTimes(1); - expect(extractTagReferencesMock).toHaveBeenCalledWith(initialReturn); - - expect(result).toBe(tagReturn); - }); - }); -}); diff --git a/src/plugins/saved_objects_tagging_oss/public/decorator/decorate_config.ts b/src/plugins/saved_objects_tagging_oss/public/decorator/decorate_config.ts deleted file mode 100644 index 8cdb6a4229d44..0000000000000 --- a/src/plugins/saved_objects_tagging_oss/public/decorator/decorate_config.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", 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 { SavedObjectConfig } from '@kbn/saved-objects-plugin/public'; -import { injectTagReferences } from './inject_tag_references'; -import { extractTagReferences } from './extract_tag_references'; - -export const decorateConfig = (config: SavedObjectConfig) => { - config.mapping = { - ...config.mapping, - __tags: 'text', - }; - - const initialExtractReferences = config.extractReferences; - const initialInjectReferences = config.injectReferences; - - config.injectReferences = (object, references) => { - if (initialInjectReferences) { - initialInjectReferences(object, references); - } - injectTagReferences(object, references); - }; - - config.extractReferences = (attrsAndRefs) => { - if (initialExtractReferences) { - attrsAndRefs = initialExtractReferences(attrsAndRefs); - } - return extractTagReferences(attrsAndRefs); - }; -}; diff --git a/src/plugins/saved_objects_tagging_oss/public/decorator/decorate_object.test.ts b/src/plugins/saved_objects_tagging_oss/public/decorator/decorate_object.test.ts deleted file mode 100644 index a997e7c2591a3..0000000000000 --- a/src/plugins/saved_objects_tagging_oss/public/decorator/decorate_object.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", 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 { InternalTagDecoratedSavedObject } from './types'; -import { decorateObject } from './decorate_object'; - -const createObject = (): InternalTagDecoratedSavedObject => { - // we really just need TS not to complain here. - return {} as InternalTagDecoratedSavedObject; -}; - -describe('decorateObject', () => { - it('adds the `getTags` method', () => { - const object = createObject(); - object.__tags = ['foo', 'bar']; - - decorateObject(object); - - expect(object.getTags).toBeDefined(); - expect(object.getTags()).toEqual(['foo', 'bar']); - }); - - it('adds the `setTags` method', () => { - const object = createObject(); - object.__tags = ['foo', 'bar']; - - decorateObject(object); - - expect(object.setTags).toBeDefined(); - - object.setTags(['hello', 'dolly']); - - expect(object.getTags()).toEqual(['hello', 'dolly']); - }); -}); diff --git a/src/plugins/saved_objects_tagging_oss/public/decorator/decorate_object.ts b/src/plugins/saved_objects_tagging_oss/public/decorator/decorate_object.ts deleted file mode 100644 index 0c03863b55ea9..0000000000000 --- a/src/plugins/saved_objects_tagging_oss/public/decorator/decorate_object.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", 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 { InternalTagDecoratedSavedObject } from './types'; - -/** - * Enhance the object with tag accessors - */ -export const decorateObject = (object: InternalTagDecoratedSavedObject) => { - object.getTags = () => { - return object.__tags ?? []; - }; - object.setTags = (tagIds) => { - object.__tags = tagIds; - }; -}; diff --git a/src/plugins/saved_objects_tagging_oss/public/decorator/extract_tag_references.test.ts b/src/plugins/saved_objects_tagging_oss/public/decorator/extract_tag_references.test.ts deleted file mode 100644 index 2ac4c4d0db8c2..0000000000000 --- a/src/plugins/saved_objects_tagging_oss/public/decorator/extract_tag_references.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", 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 { SavedObjectReference } from '@kbn/core/public'; -import { extractTagReferences } from './extract_tag_references'; - -const ref = (type: string, id: string, name = `ref-to-${type}-${id}`): SavedObjectReference => ({ - id, - type, - name, -}); - -const tagRef = (id: string): SavedObjectReference => ref('tag', id, `tag-${id}`); - -describe('extractTagReferences', () => { - it('generate tag references from the attributes', () => { - const attributes = { - __tags: ['tag-id-1', 'tag-id-2'], - }; - const references: SavedObjectReference[] = []; - - const { references: resultRefs } = extractTagReferences({ - attributes, - references, - }); - - expect(resultRefs).toEqual([tagRef('tag-id-1'), tagRef('tag-id-2')]); - }); - - it('removes the `__tag` property from the attributes', () => { - const attributes = { - someString: 'foo', - someNumber: 42, - __tags: ['tag-id-1', 'tag-id-2'], - }; - const references: SavedObjectReference[] = []; - - const { attributes: resultAttrs } = extractTagReferences({ - attributes, - references, - }); - - expect(resultAttrs).toEqual({ someString: 'foo', someNumber: 42 }); - }); - - it('preserves the other references', () => { - const attributes = { - __tags: ['tag-id-1'], - }; - - const refA = ref('dashboard', 'dash-1'); - const refB = ref('visualization', 'vis-1'); - - const { references: resultRefs } = extractTagReferences({ - attributes, - references: [refA, refB], - }); - - expect(resultRefs).toEqual([refA, refB, tagRef('tag-id-1')]); - }); - - it('does not fail if `attributes` does not contain `__tags`', () => { - const attributes = { - someString: 'foo', - someNumber: 42, - }; - - const refA = ref('dashboard', 'dash-1'); - const refB = ref('visualization', 'vis-1'); - - const { attributes: resultAttrs, references: resultRefs } = extractTagReferences({ - attributes, - references: [refA, refB], - }); - - expect(resultRefs).toEqual([refA, refB]); - expect(resultAttrs).toEqual({ someString: 'foo', someNumber: 42 }); - }); - - it('removes duplicated tags', () => { - const attributes = { - __tags: ['tag-id-1', 'tag-id-1', 'tag-id-1', 'tag-id-1', 'tag-id-2'], - }; - - const { references: resultRefs } = extractTagReferences({ - attributes, - references: [] as SavedObjectReference[], - }); - - expect(resultRefs).toEqual([ - { id: 'tag-id-1', name: 'tag-tag-id-1', type: 'tag' }, - { id: 'tag-id-2', name: 'tag-tag-id-2', type: 'tag' }, - ]); - }); -}); diff --git a/src/plugins/saved_objects_tagging_oss/public/decorator/extract_tag_references.ts b/src/plugins/saved_objects_tagging_oss/public/decorator/extract_tag_references.ts deleted file mode 100644 index 35ee2a75a8803..0000000000000 --- a/src/plugins/saved_objects_tagging_oss/public/decorator/extract_tag_references.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", 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 { SavedObjectConfig } from '@kbn/saved-objects-plugin/public'; - -/** - * Extract the tag references from the object's attribute - * - * (`extractReferences` is used when persisting the saved object to the backend) - */ -export const extractTagReferences: Required['extractReferences'] = ({ - attributes, - references, -}) => { - const { __tags, ...otherAttributes } = attributes; - const tags = [...new Set(__tags as string[])] ?? []; - return { - attributes: otherAttributes, - references: [ - ...references, - ...tags.map((tagId) => ({ - id: tagId, - type: 'tag', - name: `tag-${tagId}`, - })), - ], - }; -}; diff --git a/src/plugins/saved_objects_tagging_oss/public/decorator/factory.ts b/src/plugins/saved_objects_tagging_oss/public/decorator/factory.ts deleted file mode 100644 index 003fa5c626b09..0000000000000 --- a/src/plugins/saved_objects_tagging_oss/public/decorator/factory.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", 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 { SavedObjectDecoratorFactory } from '@kbn/saved-objects-plugin/public'; -import { InternalTagDecoratedSavedObject } from './types'; -import { decorateConfig } from './decorate_config'; -import { decorateObject } from './decorate_object'; - -export const decoratorId = 'tag'; - -export const tagDecoratorFactory: SavedObjectDecoratorFactory< - InternalTagDecoratedSavedObject -> = () => { - return { - getId: () => decoratorId, - decorateConfig, - decorateObject, - }; -}; diff --git a/src/plugins/saved_objects_tagging_oss/public/decorator/inject_tag_references.test.ts b/src/plugins/saved_objects_tagging_oss/public/decorator/inject_tag_references.test.ts deleted file mode 100644 index 2eb3db3937b86..0000000000000 --- a/src/plugins/saved_objects_tagging_oss/public/decorator/inject_tag_references.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", 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 { SavedObjectReference } from '@kbn/core/public'; -import { injectTagReferences } from './inject_tag_references'; -import { InternalTagDecoratedSavedObject } from './types'; - -const ref = (type: string, id: string): SavedObjectReference => ({ - id, - type, - name: `ref-to-${type}-${id}`, -}); - -const tagRef = (id: string) => ref('tag', id); - -const createObject = (): InternalTagDecoratedSavedObject => { - // we really just need TS not to complain here. - return {} as InternalTagDecoratedSavedObject; -}; - -describe('injectTagReferences', () => { - let object: InternalTagDecoratedSavedObject; - - beforeEach(() => { - object = createObject(); - }); - - it('injects the `tag` references to the `__tags` field', () => { - const references = [tagRef('tag-id-1'), tagRef('tag-id-2')]; - - injectTagReferences(object, references); - - expect(object.__tags).toEqual(['tag-id-1', 'tag-id-2']); - }); - - it('only process the tag references', () => { - const references = [ - tagRef('tag-id-1'), - ref('dashboard', 'foo'), - tagRef('tag-id-2'), - ref('dashboard', 'baz'), - ]; - - injectTagReferences(object, references); - - expect(object.__tags).toEqual(['tag-id-1', 'tag-id-2']); - }); - - it('injects an empty list when not tag references are present', () => { - injectTagReferences(object, [ref('dashboard', 'foo'), ref('dashboard', 'baz')]); - - expect(object.__tags).toEqual([]); - }); -}); diff --git a/src/plugins/saved_objects_tagging_oss/public/decorator/inject_tag_references.ts b/src/plugins/saved_objects_tagging_oss/public/decorator/inject_tag_references.ts deleted file mode 100644 index fd550a81837f9..0000000000000 --- a/src/plugins/saved_objects_tagging_oss/public/decorator/inject_tag_references.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", 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 { SavedObjectConfig } from '@kbn/saved-objects-plugin/public'; -import { InternalTagDecoratedSavedObject } from './types'; - -/** - * Inject the tags back into the object's references - * - * (`injectReferences`) is used when fetching the object from the backend - */ -export const injectTagReferences: Required['injectReferences'] = ( - object, - references = [] -) => { - (object as unknown as InternalTagDecoratedSavedObject).__tags = references - .filter(({ type }) => type === 'tag') - .map(({ id }) => id); -}; diff --git a/src/plugins/saved_objects_tagging_oss/public/decorator/types.ts b/src/plugins/saved_objects_tagging_oss/public/decorator/types.ts deleted file mode 100644 index b50f1b183ae39..0000000000000 --- a/src/plugins/saved_objects_tagging_oss/public/decorator/types.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the "Elastic License - * 2.0", 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 { SavedObject } from '@kbn/saved-objects-plugin/public'; - -/** - * @public - */ -export type TagDecoratedSavedObject = SavedObject & { - getTags(): string[]; - setTags(tags: string[]): void; -}; - -/** - * @internal - */ -export type InternalTagDecoratedSavedObject = TagDecoratedSavedObject & { - __tags: string[]; -}; diff --git a/src/plugins/saved_objects_tagging_oss/public/index.ts b/src/plugins/saved_objects_tagging_oss/public/index.ts index 58dce1bd20842..b754d763427c8 100644 --- a/src/plugins/saved_objects_tagging_oss/public/index.ts +++ b/src/plugins/saved_objects_tagging_oss/public/index.ts @@ -23,11 +23,8 @@ export type { ParsedSearchQuery, ParseSearchQueryOptions, SavedObjectSaveModalTagSelectorComponentProps, - SavedObjectTagDecoratorTypeGuard, GetTableColumnDefinitionOptions, } from './api'; -export type { TagDecoratedSavedObject } from './decorator'; - export const plugin = (initializerContext: PluginInitializerContext) => new SavedObjectTaggingOssPlugin(initializerContext); diff --git a/src/plugins/saved_objects_tagging_oss/public/plugin.test.ts b/src/plugins/saved_objects_tagging_oss/public/plugin.test.ts index 7c26f5a9a8289..bb3f0f0e78f83 100644 --- a/src/plugins/saved_objects_tagging_oss/public/plugin.test.ts +++ b/src/plugins/saved_objects_tagging_oss/public/plugin.test.ts @@ -8,8 +8,6 @@ */ import { coreMock } from '@kbn/core/public/mocks'; -import { savedObjectsPluginMock } from '@kbn/saved-objects-plugin/public/mocks'; -import { tagDecoratorConfig } from './decorator'; import { taggingApiMock } from './api.mock'; import { SavedObjectTaggingOssPlugin } from './plugin'; @@ -22,23 +20,6 @@ describe('SavedObjectTaggingOssPlugin', () => { plugin = new SavedObjectTaggingOssPlugin(coreMock.createPluginInitializerContext()); }); - describe('#setup', () => { - it('registers the tag SO decorator if the `savedObjects` plugin is present', () => { - const savedObjects = savedObjectsPluginMock.createSetupContract(); - - plugin.setup(coreSetup, { savedObjects }); - - expect(savedObjects.registerDecorator).toHaveBeenCalledTimes(1); - expect(savedObjects.registerDecorator).toHaveBeenCalledWith(tagDecoratorConfig); - }); - - it('does not fail if the `savedObjects` plugin is not present', () => { - expect(() => { - plugin.setup(coreSetup, {}); - }).not.toThrow(); - }); - }); - describe('#start', () => { let coreStart: ReturnType; @@ -54,7 +35,7 @@ describe('SavedObjectTaggingOssPlugin', () => { it('returns the tagging API if registered', async () => { const taggingApi = taggingApiMock.create(); - const { registerTaggingApi } = plugin.setup(coreSetup, {}); + const { registerTaggingApi } = plugin.setup(coreSetup); registerTaggingApi(Promise.resolve(taggingApi)); @@ -66,7 +47,7 @@ describe('SavedObjectTaggingOssPlugin', () => { expect(getTaggingApi()).toStrictEqual(taggingApi); }); it('does not return the tagging API if not registered', async () => { - plugin.setup(coreSetup, {}); + plugin.setup(coreSetup); await nextTick(); @@ -76,7 +57,7 @@ describe('SavedObjectTaggingOssPlugin', () => { expect(getTaggingApi()).toBeUndefined(); }); it('does not return the tagging API if resolution promise rejects', async () => { - const { registerTaggingApi } = plugin.setup(coreSetup, {}); + const { registerTaggingApi } = plugin.setup(coreSetup); registerTaggingApi(Promise.reject(new Error('something went bad'))); diff --git a/src/plugins/saved_objects_tagging_oss/public/plugin.ts b/src/plugins/saved_objects_tagging_oss/public/plugin.ts index 94a77dcdf2610..c6097b447ade7 100644 --- a/src/plugins/saved_objects_tagging_oss/public/plugin.ts +++ b/src/plugins/saved_objects_tagging_oss/public/plugin.ts @@ -8,29 +8,18 @@ */ import { CoreSetup, CoreStart, PluginInitializerContext, Plugin } from '@kbn/core/public'; -import { SavedObjectSetup } from '@kbn/saved-objects-plugin/public'; import { SavedObjectTaggingOssPluginSetup, SavedObjectTaggingOssPluginStart } from './types'; import { SavedObjectsTaggingApi } from './api'; -import { tagDecoratorConfig } from './decorator'; - -interface SetupDeps { - savedObjects?: SavedObjectSetup; -} export class SavedObjectTaggingOssPlugin - implements - Plugin + implements Plugin { private apiRegistered = false; private api?: SavedObjectsTaggingApi; constructor(context: PluginInitializerContext) {} - public setup({}: CoreSetup, { savedObjects }: SetupDeps) { - if (savedObjects) { - savedObjects.registerDecorator(tagDecoratorConfig); - } - + public setup({}: CoreSetup) { return { registerTaggingApi: (provider: Promise) => { if (this.apiRegistered) { diff --git a/src/plugins/saved_objects_tagging_oss/tsconfig.json b/src/plugins/saved_objects_tagging_oss/tsconfig.json index 6b98cba4cbd12..fa0436bab9161 100644 --- a/src/plugins/saved_objects_tagging_oss/tsconfig.json +++ b/src/plugins/saved_objects_tagging_oss/tsconfig.json @@ -9,7 +9,6 @@ ], "kbn_references": [ "@kbn/core", - "@kbn/saved-objects-plugin", ], "exclude": [ "target/**/*", diff --git a/src/plugins/visualizations/public/mocks.ts b/src/plugins/visualizations/public/mocks.ts index 6d58b3a65c11f..2ba40ef14d38a 100644 --- a/src/plugins/visualizations/public/mocks.ts +++ b/src/plugins/visualizations/public/mocks.ts @@ -17,7 +17,6 @@ import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; import { indexPatternEditorPluginMock } from '@kbn/data-view-editor-plugin/public/mocks'; import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks'; import { inspectorPluginMock } from '@kbn/inspector-plugin/public/mocks'; -import { savedObjectsPluginMock } from '@kbn/saved-objects-plugin/public/mocks'; import { urlForwardingPluginMock } from '@kbn/url-forwarding-plugin/public/mocks'; import { navigationPluginMock } from '@kbn/navigation-plugin/public/mocks'; import { presentationUtilPluginMock } from '@kbn/presentation-util-plugin/public/mocks'; @@ -77,7 +76,6 @@ const createInstance = async () => { embeddable: embeddablePluginMock.createStartContract(), spaces: spacesPluginMock.createStartContract(), savedObjectsClient: coreMock.createStart().savedObjects.client, - savedObjects: savedObjectsPluginMock.createStartContract(), savedObjectsTaggingOss: savedObjectTaggingOssPluginMock.createStart(), savedSearch: savedSearchPluginMock.createStartContract(), navigation: navigationPluginMock.createStartContract(), diff --git a/src/plugins/visualizations/public/plugin.ts b/src/plugins/visualizations/public/plugin.ts index 24a2c488e0f79..fcb3dc5137161 100644 --- a/src/plugins/visualizations/public/plugin.ts +++ b/src/plugins/visualizations/public/plugin.ts @@ -38,7 +38,6 @@ import type { SavedObjectsClientContract, } from '@kbn/core/public'; import { UiActionsStart, UiActionsSetup, ADD_PANEL_TRIGGER } from '@kbn/ui-actions-plugin/public'; -import type { SavedObjectsStart } from '@kbn/saved-objects-plugin/public'; import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; import type { Setup as InspectorSetup, @@ -166,7 +165,6 @@ export interface VisualizationsStartDeps { application: ApplicationStart; navigation: NavigationStart; presentationUtil: PresentationUtilPluginStart; - savedObjects: SavedObjectsStart; savedObjectsClient: SavedObjectsClientContract; savedSearch: SavedSearchPublicPluginStart; spaces?: SpacesPluginStart; 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/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/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 0000000000000..22f5ad4c31a31 Binary files /dev/null and b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/assets/connector_logos_comp.png differ diff --git a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/components/choose_connector_selectable.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/components/choose_connector_selectable.tsx new file mode 100644 index 0000000000000..6c5505a22f81e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/connectors/create_connector/components/choose_connector_selectable.tsx @@ -0,0 +1,172 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +import { useActions, useValues } from 'kea'; + +import { + EuiBadge, + EuiFlexItem, + EuiIcon, + EuiInputPopover, + EuiSelectable, + EuiSelectableOption, + useEuiTheme, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { KibanaLogic } from '../../../../../shared/kibana'; +import { NewConnectorLogic } from '../../../new_index/method_connector/new_connector_logic'; +import { SelfManagePreference } from '../create_connector'; + +interface ChooseConnectorSelectableProps { + selfManaged: SelfManagePreference; +} +interface OptionData { + secondaryContent?: string; +} + +export const ChooseConnectorSelectable: React.FC = ({ + 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/components/shared/ingestion_card/ingestion_card.tsx b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/shared/ingestion_card/ingestion_card.tsx index 94bbc515f92bd..f935ea6803c69 100644 --- a/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/shared/ingestion_card/ingestion_card.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/enterprise_search_content/components/shared/ingestion_card/ingestion_card.tsx @@ -20,7 +20,7 @@ import { import { i18n } from '@kbn/i18n'; -import { EuiLinkTo } from '../../../../shared/react_router_helpers'; +import { EuiButtonTo } from '../../../../shared/react_router_helpers'; interface IngestionCardProps { buttonIcon: IconType; @@ -78,15 +78,25 @@ export const IngestionCard: React.FC = ({ } footer={ onClick ? ( - + {buttonLabel} ) : ( - - - {buttonLabel} - - + + {buttonLabel} + ) } /> 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/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', })} 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/fleet/cypress.config.space_awareness.ts b/x-pack/plugins/fleet/cypress.config.space_awareness.ts new file mode 100644 index 0000000000000..6efda828e6fbc --- /dev/null +++ b/x-pack/plugins/fleet/cypress.config.space_awareness.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { defineCypressConfig } from '@kbn/cypress-config'; + +// eslint-disable-next-line import/no-default-export +export default defineCypressConfig({ + defaultCommandTimeout: 60000, + requestTimeout: 60000, + responseTimeout: 60000, + execTimeout: 120000, + pageLoadTimeout: 120000, + + retries: { + runMode: 2, + }, + + env: { + grepFilterSpecs: false, + }, + + screenshotsFolder: '../../../target/kibana-fleet/cypress/screenshots', + trashAssetsBeforeRuns: false, + video: false, + videosFolder: '../../../target/kibana-fleet/cypress/videos', + viewportHeight: 900, + viewportWidth: 1440, + screenshotOnRunFailure: true, + + e2e: { + baseUrl: 'http://localhost:5601', + + experimentalRunAllSpecs: true, + experimentalMemoryManagement: true, + numTestsKeptInMemory: 3, + + specPattern: './cypress/e2e/space_awareness/**/*.cy.ts', + supportFile: './cypress/support/e2e.ts', + + setupNodeEvents(on, config) { + // eslint-disable-next-line @typescript-eslint/no-var-requires, @kbn/imports/no_boundary_crossing + return require('./cypress/plugins')(on, config); + }, + }, +}); diff --git a/x-pack/plugins/fleet/cypress.config.ts b/x-pack/plugins/fleet/cypress.config.ts index e4a5ad96938d6..2082142e23d7f 100644 --- a/x-pack/plugins/fleet/cypress.config.ts +++ b/x-pack/plugins/fleet/cypress.config.ts @@ -39,6 +39,7 @@ export default defineCypressConfig({ specPattern: './cypress/e2e/**/*.cy.ts', supportFile: './cypress/support/e2e.ts', + excludeSpecPattern: './cypress/e2e/space_awareness/**/*.cy.ts', setupNodeEvents(on, config) { // eslint-disable-next-line @typescript-eslint/no-var-requires, @kbn/imports/no_boundary_crossing diff --git a/x-pack/plugins/fleet/cypress/e2e/space_awareness/policies.cy.ts b/x-pack/plugins/fleet/cypress/e2e/space_awareness/policies.cy.ts new file mode 100644 index 0000000000000..8975de388248c --- /dev/null +++ b/x-pack/plugins/fleet/cypress/e2e/space_awareness/policies.cy.ts @@ -0,0 +1,75 @@ +/* + * Copyright 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 { + ADD_AGENT_POLICY_BTN, + AGENT_POLICIES_TABLE, + AGENT_POLICY_CREATE_AGENT_POLICY_NAME_FIELD, + AGENT_POLICY_DETAILS_PAGE, + AGENT_POLICY_FLYOUT_CREATE_BUTTON, + AGENT_POLICY_SYSTEM_MONITORING_CHECKBOX, +} from '../../screens/fleet'; +import { login } from '../../tasks/login'; +import { createSpaces, enableSpaceAwareness } from '../../tasks/spaces'; +import { cleanupAgentPolicies } from '../../tasks/cleanup'; + +describe('Space aware policies creation', { testIsolation: false }, () => { + before(() => { + enableSpaceAwareness(); + createSpaces(); + cleanupAgentPolicies(); + cleanupAgentPolicies('test'); + login(); + }); + + beforeEach(() => { + cy.intercept('GET', /\/api\/fleet\/agent_policies/).as('getAgentPolicies'); + cy.intercept('GET', /\/internal\/fleet\/agent_policies_spaces/).as('getAgentPoliciesSpaces'); + }); + + const POLICY_NAME = `Policy 1 space test`; + const NO_AGENT_POLICIES = 'No agent policies'; + it('should allow to create an agent policy in the test space', () => { + cy.visit('/s/test/app/fleet/policies'); + + cy.getBySel(ADD_AGENT_POLICY_BTN).click(); + cy.getBySel(AGENT_POLICY_CREATE_AGENT_POLICY_NAME_FIELD).type(POLICY_NAME); + cy.getBySel(AGENT_POLICY_SYSTEM_MONITORING_CHECKBOX).uncheck(); + + cy.getBySel(AGENT_POLICY_FLYOUT_CREATE_BUTTON).click(); + cy.getBySel(AGENT_POLICIES_TABLE).contains(POLICY_NAME); + }); + + it('the created policy should not be visible in the default space', () => { + cy.visit('/app/fleet/policies'); + cy.wait('@getAgentPolicies'); + cy.getBySel(AGENT_POLICIES_TABLE).contains(NO_AGENT_POLICIES); + }); + + it('should allow to update that policy to belong to both test and default space', () => { + cy.visit('/s/test/app/fleet/policies'); + cy.getBySel(AGENT_POLICIES_TABLE).contains(POLICY_NAME).click(); + + cy.getBySel(AGENT_POLICY_DETAILS_PAGE.SETTINGS_TAB).click(); + cy.wait('@getAgentPoliciesSpaces'); + cy.getBySel(AGENT_POLICY_DETAILS_PAGE.SPACE_SELECTOR_COMBOBOX).click().type('default{enter}'); + + cy.getBySel(AGENT_POLICY_DETAILS_PAGE.SAVE_BUTTON).click(); + }); + + it('the policy should be visible in the test space', () => { + cy.visit('/s/test/app/fleet/policies'); + cy.wait('@getAgentPolicies'); + cy.getBySel(AGENT_POLICIES_TABLE).contains(POLICY_NAME); + }); + + it('the policy should be visible in the default space', () => { + cy.visit('/app/fleet/policies'); + cy.wait('@getAgentPolicies'); + cy.getBySel(AGENT_POLICIES_TABLE).contains(POLICY_NAME); + }); +}); diff --git a/x-pack/plugins/fleet/cypress/screens/fleet.ts b/x-pack/plugins/fleet/cypress/screens/fleet.ts index 4e1a0ac0f7e19..0bd449652a800 100644 --- a/x-pack/plugins/fleet/cypress/screens/fleet.ts +++ b/x-pack/plugins/fleet/cypress/screens/fleet.ts @@ -46,6 +46,8 @@ export const AGENT_POLICY_CREATE_AGENT_POLICY_NAME_FIELD = 'createAgentPolicyNam export const AGENT_POLICIES_FLYOUT_ADVANCED_DEFAULT_NAMESPACE_HEADER = 'defaultNamespaceHeader'; export const AGENT_POLICY_FLYOUT_CREATE_BUTTON = 'createAgentPolicyFlyoutBtn'; +export const AGENT_POLICIES_TABLE = 'agentPoliciesTable'; + export const ENROLLMENT_TOKENS = { CREATE_TOKEN_BUTTON: 'createEnrollmentTokenButton', CREATE_TOKEN_MODAL_NAME_FIELD: 'createEnrollmentTokenNameField', @@ -241,4 +243,7 @@ export const API_KEYS = { export const AGENT_POLICY_DETAILS_PAGE = { ADD_AGENT_LINK: 'addAgentLink', + SETTINGS_TAB: 'agentPolicySettingsTab', + SPACE_SELECTOR_COMBOBOX: 'spaceSelectorComboBox', + SAVE_BUTTON: 'agentPolicyDetailsSaveButton', }; diff --git a/x-pack/plugins/fleet/cypress/tasks/cleanup.ts b/x-pack/plugins/fleet/cypress/tasks/cleanup.ts index 5e179bc9207f1..bad8743b66bd1 100644 --- a/x-pack/plugins/fleet/cypress/tasks/cleanup.ts +++ b/x-pack/plugins/fleet/cypress/tasks/cleanup.ts @@ -7,18 +7,20 @@ import { request } from './common'; -export function cleanupAgentPolicies() { - request({ url: '/api/fleet/agent_policies' }).then((response: any) => { - response.body.items - .filter((policy: any) => policy.agents === 0) - .forEach((policy: any) => { - request({ - method: 'POST', - url: '/api/fleet/agent_policies/delete', - body: { agentPolicyId: policy.id }, +export function cleanupAgentPolicies(spaceId?: string) { + request({ url: `${spaceId ? `/s/${spaceId}` : ''}/api/fleet/agent_policies` }).then( + (response: any) => { + response.body.items + .filter((policy: any) => policy.agents === 0) + .forEach((policy: any) => { + request({ + method: 'POST', + url: `${spaceId ? `/s/${spaceId}` : ''}/api/fleet/agent_policies/delete`, + body: { agentPolicyId: policy.id }, + }); }); - }); - }); + } + ); } export function unenrollAgent() { diff --git a/x-pack/plugins/fleet/cypress/tasks/common.ts b/x-pack/plugins/fleet/cypress/tasks/common.ts index 250dea07f89d1..de6e117bac4cc 100644 --- a/x-pack/plugins/fleet/cypress/tasks/common.ts +++ b/x-pack/plugins/fleet/cypress/tasks/common.ts @@ -28,6 +28,12 @@ export const COMMON_API_HEADERS = Object.freeze({ 'Elastic-Api-Version': API_VERSIONS.public.v1, }); +export const COMMON_INTERNAL_API_HEADERS = Object.freeze({ + 'kbn-xsrf': 'cypress', + 'x-elastic-internal-origin': 'fleet', + 'Elastic-Api-Version': API_VERSIONS.internal.v1, +}); + // Replaces request - adds baseline authentication + global headers export const request = ({ headers, @@ -40,6 +46,17 @@ export const request = ({ }); }; +export const internalRequest = ({ + headers, + ...options +}: Partial): Cypress.Chainable> => { + return cy.request({ + auth: API_AUTH, + headers: { ...COMMON_INTERNAL_API_HEADERS, ...headers }, + ...options, + }); +}; + /** * For all the new features tours we show in the app, this method disables them * by setting their configs in the local storage. It prevents the tours from appearing diff --git a/x-pack/plugins/fleet/cypress/tasks/spaces.ts b/x-pack/plugins/fleet/cypress/tasks/spaces.ts new file mode 100644 index 0000000000000..31c1d12a968cb --- /dev/null +++ b/x-pack/plugins/fleet/cypress/tasks/spaces.ts @@ -0,0 +1,38 @@ +/* + * Copyright 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 { request, internalRequest } from './common'; + +export function enableSpaceAwareness() { + return internalRequest({ + url: '/internal/fleet/enable_space_awareness', + failOnStatusCode: false, + method: 'POST', + }); +} + +export function createSpaces() { + return request({ + url: '/api/spaces/space', + failOnStatusCode: false, + method: 'POST', + body: { + id: 'test', + name: 'Test', + description: 'Test space', + color: '#aabbcc', + initials: 'TE', + disabledFeatures: [], + imageUrl: + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAD4AAABACAYAAABC6cT1AAAGf0lEQVRoQ+3abYydRRUH8N882xYo0IqagEVjokQJKAiKBjXExC9G/aCkGowCIghCkRcrVSSKIu/FEiqgGL6gBIlAYrAqUTH6hZgQFVEMKlQFfItWoQWhZe8z5uzMLdvbfbkLxb13d+fbvfe588x/zpn/+Z9zJpmnI81T3BaAzzfLL1h8weLzZAcWXH2eGHo7zAWLL1h8nuzAjFw9G1N6Kzq8HnuM36MR8iibF3Fv4q+7cv8yDV6K13bYq2furSP8Ag8ncr/vnSnwRViJT2GfCV7yL1yHGxLb+l3EdM9lluNEnIC9xz+f2ZL4Er6Z2DrdXN3fZwp8CU7OfDHxggle8lTLbQ1nJ/7Z7yKmey5zYGZt4h2IzR8/trRc2PDlxJPTzfVcgJ+CC0wMPOa9F6cm7up3EVM9V9386MxliVdM8GwAv6hh/awCz/w7lY25OtF5ruBz4ZLP42NYNrDAFbC3YPWuILnMAfgq3oaRQQYea/stViV+sgssvjKzLvGySeaaNVfP4d7Btokgvxj/bblgpueuF1hmWcyTCmfE3J3M1lTcv0vMswM88zR+jpw4osu6me8kzkpsfLZWzxyRuabO22buxxOJ12FxnXfWgEe83pB5sOE47BsLymzscOoi7nw2JJfZreUjiUsTyzKPZm5NvBDvSuw268AzNzV8H5/Am+qCnsAXgpgSW2Zq9cyKlksbPlTd+te4quWNieMHBfiNDdciYnwsdI/MaOaWhnMTf54J8CqNj8x8JXFIZltYu+HqlmNT8YSBsHgAPw/vxvlVV4du/s0oaxbxg0TbL/jMni0nNcVjQq7+HZfgtpbzBg342TgQ63AkmsymxBW4IjE6A+D7Vzd/fyWxIM/VuCe+HzTgZ2Jpy/kNJ2FJLmLm24mPJ/42A+Bvrxt4SISwlhsaPodH26LZB8rVA3inwwebsrixJCZzX+KMxI/7AV61eVh3DV6Mx3EOvh4kN6jAg8nfUCXm4d1wE66OyxNPTQc+s3/o/MoXizL3JE5O3F3P/uBZPPF4Zr+Wi5uSO48ZPRdyCwn7YB/A35m5KhWNHox4fcNnIs0ddOCRSBxf8+cQG+Huf0l8NJVYP+nI7NXy2ar4QqIGm69JfKPOE2w/mBavCzwM11R2D+ChsUO7hyUfmwx55qDM1xJvqZ7y08TpifuGBfjeURVJnNIVGpkNiXNS0ds7jcySDitDCCWW56LJ10fRo8sNA+3qXUSZD2CtQlZh9T+1rB7h9oliembflnMbzqgSNZKbKGHdPm7OwXb1CvQ1metSETMpszmzvikCJNh/h5E5PHNl4qga/+/cxqrdeWDYgIe7X5L4cGJPJX2940lOX8pD41FnFnc4riluvQKbK0dcHJFi2IBHNTQSlguru4d2/wPOTNzRA3x5y+U1E1uqWDkETOT026XuUJzx6u7ReLhSYenQ7uHua0fKZmwfmcPqsQjxE5WVONcRxn7X89zgn/EKPMRMxOVQXmP18Mx3q3b/Y/0cQE/IhFtHESMsHFlZ1Ml3CH3DZPHImY+pxcKumNmYirtvqMBfhMuU6s3iqOQkTsMPe1tCQwO8Ajs0lxr7W+vnp1MJc9EgCNd/cy6x+9D4veXmprj5wxMw/3C4egW6zzgZOlYZzfwo3F2J7ael0pJamvlPKgWNKFft1AAcKotXoFEbD7kaoSoQPVKB35+5KHF0lai/rJo+up87jWEE/qqqwY+qrL21LWLm95lPJ16ppKw31XC3PXYPJauPEx7B6BHCgrSizRs18qiaRp8tlN3ueCTYPHH9RNaunjI8Z7wLYpT3jZSCYXQ8e9vTsRE/q+no3XMKeObgGtaintbb/AvXj4JDkNw/5hrwYPfIvlZFUbLn7G5q+eQIN09Vnho6cqvnM/Lt99RixH49wO8K0ZL41WTWHoQzvsNVkOheZqKhEGpsp3SzB+BBtZAYve7uOR9tuTaaB6l0XScdYfEQPpkTUyHEGP+XqyDBzu+NBCITUjNWHynkrbWKOuWFn1xKzqsyx0bdvS78odp0+N503Zao0uCsWuSIDku8/7EO60b41vN5+Ses9BKlTdvd8bhp9EBvJjWJAIn/vxwHe6b3tSk6JFPV4nq85oAOrx555v/x/rh3E6Lo+bnuNS4uB4Cuq0ZfvO8X1rM6q/+vnjLVqZq7v83onttc2oYF4HPJmv1gWbB4P7s0l55ZsPhcsmY/WBYs3s8uzaVn5q3F/wf70mRuBCtbjQAAAABJRU5ErkJggg==', + }, + }).then((response: any) => { + if (response.status !== 200 && response.status !== 409) { + throw new Error(`Failed to create space test`); + } + }); +} diff --git a/x-pack/plugins/fleet/cypress/tsconfig.json b/x-pack/plugins/fleet/cypress/tsconfig.json index fe8ae5dc6806c..1422b6ff469cb 100644 --- a/x-pack/plugins/fleet/cypress/tsconfig.json +++ b/x-pack/plugins/fleet/cypress/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../../../tsconfig.base.json", "include": [ "**/*", - "../cypress.config.ts" + "../cypress.config.ts", + "../cypress.config.space_awareness.ts" ], "exclude": [ "target/**/*" diff --git a/x-pack/plugins/fleet/package.json b/x-pack/plugins/fleet/package.json index 3e20162ab1d91..dc0bc6a6bcacb 100644 --- a/x-pack/plugins/fleet/package.json +++ b/x-pack/plugins/fleet/package.json @@ -5,6 +5,10 @@ "private": true, "license": "Elastic License 2.0", "scripts": { + "cypress_space_awareness": "NODE_OPTIONS=--openssl-legacy-provider node ../security_solution/scripts/start_cypress_parallel --config-file ../fleet/cypress.config.space_awareness.ts --ftr-config-file ../../../x-pack/test/fleet_cypress/cli_config.space_awareness", + "cypress_space_awareness:open": "yarn cypress_space_awareness open", + "cypress_space_awareness:run": "yarn cypress_space_awareness run", + "cypress_space_awareness:run:reporter": "yarn cypress_space_awareness run --reporter ../../../node_modules/cypress-multi-reporters --reporter-options configFile=../fleet/cypress/reporter_config.json", "cypress": "NODE_OPTIONS=--openssl-legacy-provider node ../security_solution/scripts/start_cypress_parallel --config-file ../fleet/cypress.config.ts --ftr-config-file ../../../x-pack/test/fleet_cypress/cli_config", "cypress:open": "yarn cypress open", "cypress:run": "yarn cypress run", diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/settings/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/settings/index.tsx index 14dcf6df21b9f..6e4f1e06b45a0 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/settings/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/components/settings/index.tsx @@ -244,6 +244,7 @@ export const SettingsView = memo<{ agentPolicy: AgentPolicy }>( Object.keys(validation).length > 0 || hasAdvancedSettingsErrors } + data-test-subj="agentPolicyDetailsSaveButton" iconType="save" color="primary" fill diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/index.tsx index 0643ac82634d9..7f4c1a3b91ead 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/details_page/index.tsx @@ -90,6 +90,7 @@ export const AgentPolicyDetailsPage: React.FunctionComponent = () => { name: i18n.translate('xpack.fleet.policyDetails.subTabs.settingsTabText', { defaultMessage: 'Settings', }), + 'data-test-subj': 'agentPolicySettingsTab', href: getHref('policy_details', { policyId, tabId: 'settings' }), isSelected: tabId === 'settings', }, diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/list_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/list_page/index.tsx index dcbd29c1ef74e..2682a5239071d 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/list_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/list_page/index.tsx @@ -356,6 +356,7 @@ export const AgentPolicyListPage: React.FunctionComponent<{}> = () => { loading={isLoading} + data-test-subj="agentPoliciesTable" noItemsMessage={ isLoading ? ( ". -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: 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" ] diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/has_tag_decoration.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/has_tag_decoration.ts deleted file mode 100644 index 6dccf97f0a672..0000000000000 --- a/x-pack/plugins/saved_objects_tagging/public/ui_api/has_tag_decoration.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - SavedObjectsTaggingApiUi, - TagDecoratedSavedObject, -} from '@kbn/saved-objects-tagging-oss-plugin/public'; - -export const hasTagDecoration: SavedObjectsTaggingApiUi['hasTagDecoration'] = ( - object -): object is TagDecoratedSavedObject => { - return 'getTags' in object && 'setTags' in object; -}; diff --git a/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts b/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts index d25b04dd1002c..635e2e5af0440 100644 --- a/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts +++ b/x-pack/plugins/saved_objects_tagging/public/ui_api/index.ts @@ -21,7 +21,6 @@ import { buildGetSearchBarFilter } from './get_search_bar_filter'; import { buildParseSearchQuery } from './parse_search_query'; import { buildConvertNameToReference } from './convert_name_to_reference'; import { buildGetTagList } from './get_tag_list'; -import { hasTagDecoration } from './has_tag_decoration'; interface GetUiApiOptions extends StartServices { capabilities: TagsCapabilities; @@ -50,7 +49,6 @@ export const getUiApi = ({ getSearchBarFilter: buildGetSearchBarFilter({ getTagList }), parseSearchQuery: buildParseSearchQuery({ cache }), convertNameToReference: buildConvertNameToReference({ cache }), - hasTagDecoration, getTagIdsFromReferences, getTagIdFromName: (tagName: string) => convertTagNameToId(tagName, cache.getState()), updateTagsReferences, diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index 1e5ffee50afc7..f18ddff6e4f17 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -236,9 +236,10 @@ export const allowedExperimentalValues = Object.freeze({ dataIngestionHubEnabled: false, /** - * Enables the new Entity Store engine routes + * Disables Security's Entity Store engine routes. The Entity Store feature is available by default, but + * can be disabled if necessary in a given environment. */ - entityStoreEnabled: false, + entityStoreDisabled: false, }); type ExperimentalConfigKeys = Array; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/pages/entity_analytics_dashboard.tsx b/x-pack/plugins/security_solution/public/entity_analytics/pages/entity_analytics_dashboard.tsx index 48d2911e7c36a..90f5ec66c8a38 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/pages/entity_analytics_dashboard.tsx +++ b/x-pack/plugins/security_solution/public/entity_analytics/pages/entity_analytics_dashboard.tsx @@ -32,7 +32,7 @@ const EntityAnalyticsComponent = () => { const { indicesExist, loading: isSourcererLoading, sourcererDataView } = useSourcererDataView(); const isRiskScoreModuleLicenseAvailable = useHasSecurityCapability('entity-analytics'); - const isEntityStoreEnabled = useIsExperimentalFeatureEnabled('entityStoreEnabled'); + const isEntityStoreDisabled = useIsExperimentalFeatureEnabled('entityStoreDisabled'); return ( <> @@ -71,7 +71,7 @@ const EntityAnalyticsComponent = () => { - {isEntityStoreEnabled ? ( + {!isEntityStoreDisabled ? ( diff --git a/x-pack/plugins/security_solution/scripts/run_cypress/parallel.ts b/x-pack/plugins/security_solution/scripts/run_cypress/parallel.ts index 5a6f9e2aa78de..d4b4c8addc099 100644 --- a/x-pack/plugins/security_solution/scripts/run_cypress/parallel.ts +++ b/x-pack/plugins/security_solution/scripts/run_cypress/parallel.ts @@ -87,8 +87,10 @@ ${JSON.stringify(cypressConfigFile, null, 2)} const specConfig = cypressConfigFile.e2e.specPattern; const specArg = argv.spec; const specPattern = specArg ?? specConfig; + const excludeSpecPattern = cypressConfigFile.e2e.excludeSpecPattern; log.info('Config spec pattern:', specConfig); + log.info('Exclude spec pattern:', excludeSpecPattern); log.info('Arguments spec pattern:', specArg); log.info('Resulting spec pattern:', specPattern); @@ -121,7 +123,14 @@ ${JSON.stringify(cypressConfigFile, null, 2)} const concreteFilePaths = isGrepReturnedFilePaths ? grepSpecPattern // use the returned concrete file paths - : globby.sync(specPattern); // convert the glob pattern to concrete file paths + : globby.sync( + specPattern, + excludeSpecPattern + ? { + ignore: excludeSpecPattern, + } + : undefined + ); // convert the glob pattern to concrete file paths let files = retrieveIntegrations(concreteFilePaths); diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/register_entity_analytics_routes.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/register_entity_analytics_routes.ts index b4eb0d36e21fb..bd097e8641637 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/register_entity_analytics_routes.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/register_entity_analytics_routes.ts @@ -15,7 +15,7 @@ export const registerEntityAnalyticsRoutes = (routeDeps: EntityAnalyticsRoutesDe registerAssetCriticalityRoutes(routeDeps); registerRiskScoreRoutes(routeDeps); registerRiskEngineRoutes(routeDeps); - if (routeDeps.config.experimentalFeatures.entityStoreEnabled) { + if (!routeDeps.config.experimentalFeatures.entityStoreDisabled) { registerEntityStoreRoutes(routeDeps); } }; diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 9203a068b278d..2ac776d37f1e5 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -221,7 +221,7 @@ export class Plugin implements ISecuritySolutionPlugin { logger.error(`Error scheduling entity analytics migration: ${err}`); }); - if (experimentalFeatures.entityStoreEnabled) { + if (!experimentalFeatures.entityStoreDisabled) { registerEntityStoreFieldRetentionEnrichTask({ getStartServices: core.getStartServices, logger: this.logger, diff --git a/x-pack/plugins/spaces/common/constants.ts b/x-pack/plugins/spaces/common/constants.ts index bbbe38451fedf..b18fdbbe1f91e 100644 --- a/x-pack/plugins/spaces/common/constants.ts +++ b/x-pack/plugins/spaces/common/constants.ts @@ -43,3 +43,8 @@ export const SOLUTION_VIEW_CLASSIC = 'classic' as const; export const FEATURE_PRIVILEGES_ALL = 'all' as const; export const FEATURE_PRIVILEGES_READ = 'read' as const; export const FEATURE_PRIVILEGES_CUSTOM = 'custom' as const; + +/** + * 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/plugins/transform/public/app/__mocks__/app_dependencies.tsx b/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx index ad7487305391e..cdb159c158d10 100644 --- a/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx +++ b/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx @@ -15,7 +15,6 @@ import type { ScopedHistory } from '@kbn/core/public'; import { coreMock, themeServiceMock } from '@kbn/core/public/mocks'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; -import { savedObjectsPluginMock } from '@kbn/saved-objects-plugin/public/mocks'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; import { fieldFormatsServiceMock } from '@kbn/field-formats-plugin/public/mocks'; import type { SharePluginStart } from '@kbn/share-plugin/public'; @@ -88,7 +87,6 @@ const appDependencies: AppDependencies = { theme: themeServiceMock.createStartContract(), http: coreSetup.http, history: {} as ScopedHistory, - savedObjectsPlugin: savedObjectsPluginMock.createStartContract(), share: { urlGenerators: { getUrlGenerator: jest.fn() } } as unknown as SharePluginStart, triggersActionsUi: {} as jest.Mocked, unifiedSearch: unifiedSearchPluginMock.createStartContract(), diff --git a/x-pack/plugins/transform/public/app/app_dependencies.tsx b/x-pack/plugins/transform/public/app/app_dependencies.tsx index c8516ddbc6226..9033540ee0d4c 100644 --- a/x-pack/plugins/transform/public/app/app_dependencies.tsx +++ b/x-pack/plugins/transform/public/app/app_dependencies.tsx @@ -19,7 +19,6 @@ import type { ScopedHistory, ThemeServiceStart, } from '@kbn/core/public'; -import type { SavedObjectsStart as SavedObjectsPluginStart } from '@kbn/saved-objects-plugin/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import type { SharePluginStart } from '@kbn/share-plugin/public'; @@ -59,7 +58,6 @@ export interface AppDependencies { overlays: OverlayStart; theme: ThemeServiceStart; history: ScopedHistory; - savedObjectsPlugin: SavedObjectsPluginStart; share: SharePluginStart; spaces?: SpacesPluginStart; triggersActionsUi: TriggersAndActionsUIPublicPluginStart; diff --git a/x-pack/plugins/transform/public/app/mount_management_section.ts b/x-pack/plugins/transform/public/app/mount_management_section.ts index 579e80034918e..35ae95feb9a44 100644 --- a/x-pack/plugins/transform/public/app/mount_management_section.ts +++ b/x-pack/plugins/transform/public/app/mount_management_section.ts @@ -84,7 +84,6 @@ export async function mountManagementSection( uiSettings, settings, history, - savedObjectsPlugin: plugins.savedObjects, share, spaces, triggersActionsUi, diff --git a/x-pack/plugins/transform/public/plugin.ts b/x-pack/plugins/transform/public/plugin.ts index f1677bbcb7c78..07bda2b5400b1 100644 --- a/x-pack/plugins/transform/public/plugin.ts +++ b/x-pack/plugins/transform/public/plugin.ts @@ -11,7 +11,6 @@ import type { CoreSetup } from '@kbn/core/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import type { HomePublicPluginSetup } from '@kbn/home-plugin/public'; -import type { SavedObjectsStart } from '@kbn/saved-objects-plugin/public'; import type { ManagementSetup } from '@kbn/management-plugin/public'; import type { SharePluginStart } from '@kbn/share-plugin/public'; import type { SpacesApi } from '@kbn/spaces-plugin/public'; @@ -37,7 +36,6 @@ export interface PluginsDependencies { dataViews: DataViewsPublicPluginStart; management: ManagementSetup; home: HomePublicPluginSetup; - savedObjects: SavedObjectsStart; savedSearch: SavedSearchPublicPluginStart; share: SharePluginStart; spaces?: SpacesApi; diff --git a/x-pack/plugins/transform/tsconfig.json b/x-pack/plugins/transform/tsconfig.json index fbef3a777d5c0..b1f43e3f096a0 100644 --- a/x-pack/plugins/transform/tsconfig.json +++ b/x-pack/plugins/transform/tsconfig.json @@ -23,7 +23,6 @@ "@kbn/alerting-plugin", "@kbn/data-views-plugin", "@kbn/home-plugin", - "@kbn/saved-objects-plugin", "@kbn/management-plugin", "@kbn/share-plugin", "@kbn/spaces-plugin", diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index c1cc8ac727f4f..3f25644369b38 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 45e4e1c987ee2..1f2cde0bed378 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 98e12bb1a4d87..c37e74ea25e3e 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": "连接器名称", 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/common/services/spaces.ts b/x-pack/test/common/services/spaces.ts index a1657996239ac..855817def1fe2 100644 --- a/x-pack/test/common/services/spaces.ts +++ b/x-pack/test/common/services/spaces.ts @@ -75,6 +75,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}`); @@ -87,6 +106,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/fleet_cypress/cli_config.space_awareness.ts b/x-pack/test/fleet_cypress/cli_config.space_awareness.ts new file mode 100644 index 0000000000000..2fbaca2da9eca --- /dev/null +++ b/x-pack/test/fleet_cypress/cli_config.space_awareness.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrConfigProviderContext } from '@kbn/test'; + +import { FleetCypressCliTestRunner } from './runner'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const cypressConfig = await readConfigFile(require.resolve('./config.space_awareness.ts')); + return { + ...cypressConfig.getAll(), + + testRunner: FleetCypressCliTestRunner, + }; +} diff --git a/x-pack/test/fleet_cypress/config.space_awareness.ts b/x-pack/test/fleet_cypress/config.space_awareness.ts new file mode 100644 index 0000000000000..eeee016b0c4d3 --- /dev/null +++ b/x-pack/test/fleet_cypress/config.space_awareness.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrConfigProviderContext, getKibanaCliLoggers } from '@kbn/test'; +import { CA_CERT_PATH } from '@kbn/dev-utils'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const kibanaCommonTestsConfig = await readConfigFile( + require.resolve('@kbn/test-suites-src/common/config') + ); + const xpackFunctionalTestsConfig = await readConfigFile( + require.resolve('../functional/config.base.js') + ); + + return { + ...kibanaCommonTestsConfig.getAll(), + + esTestCluster: { + ...xpackFunctionalTestsConfig.get('esTestCluster'), + serverArgs: [ + ...xpackFunctionalTestsConfig.get('esTestCluster.serverArgs'), + // define custom es server here + // API Keys is enabled at the top level + 'xpack.security.enabled=true', + 'http.host=0.0.0.0', + ], + }, + + kbnTestServer: { + ...xpackFunctionalTestsConfig.get('kbnTestServer'), + serverArgs: [ + ...xpackFunctionalTestsConfig.get('kbnTestServer.serverArgs'), + '--csp.warnLegacyBrowsers=false', + '--csp.strict=false', + // define custom kibana server args here + `--elasticsearch.ssl.certificateAuthorities=${CA_CERT_PATH}`, + + // add feature flags here + `--xpack.fleet.enableExperimental=${JSON.stringify([ + 'agentTamperProtectionEnabled', + 'subfeaturePrivileges', + 'useSpaceAwareness', + ])}`, + + `--logging.loggers=${JSON.stringify([ + ...getKibanaCliLoggers(xpackFunctionalTestsConfig.get('kbnTestServer.serverArgs')), + + // Enable debug fleet logs by default + { + name: 'plugins.fleet', + level: 'debug', + appenders: ['default'], + }, + ])}`, + ], + }, + }; +} 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 }); + }); + }); + }); +} 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/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts b/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts index eeb8b6e3474c9..55856f3c80402 100644 --- a/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts @@ -138,6 +138,7 @@ export default function ({ getService }: FtrProviderContext) { 'endpoint:complete-external-response-actions', 'endpoint:metadata-check-transforms-task', 'endpoint:user-artifact-packager', + 'entity_store:field_retention:enrichment', 'fleet:check-deleted-files-task', 'fleet:delete-unenrolled-agents-task', 'fleet:deploy_agent_policies', diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/configs/ess.config.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/configs/ess.config.ts index ba7a4c83e2ad7..9c168e481df2e 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/configs/ess.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/configs/ess.config.ts @@ -15,10 +15,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ...functionalConfig.getAll(), kbnTestServer: { ...functionalConfig.get('kbnTestServer'), - serverArgs: [ - ...functionalConfig.get('kbnTestServer.serverArgs'), - `--xpack.securitySolution.enableExperimental=${JSON.stringify(['entityStoreEnabled'])}`, - ], + serverArgs: [...functionalConfig.get('kbnTestServer.serverArgs')], }, testFiles: [require.resolve('..')], junit: { diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/configs/serverless.config.ts index 990bdd8778aeb..f447df7d83cbc 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/configs/serverless.config.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/configs/serverless.config.ts @@ -9,7 +9,6 @@ import { createTestConfig } from '../../../../../config/serverless/config.base'; export default createTestConfig({ kbnTestServerArgs: [ - `--xpack.securitySolution.enableExperimental=${JSON.stringify(['entityStoreEnabled'])}`, `--xpack.securitySolutionServerless.productTypes=${JSON.stringify([ { product_line: 'security', product_tier: 'complete' }, { product_line: 'endpoint', product_tier: 'complete' }, diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/engine.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/engine.ts index d6963c28b2f73..9d7bc9989f3af 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/engine.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/engine.ts @@ -24,6 +24,7 @@ export default ({ getService }: FtrProviderContext) => { } = elasticAssetCheckerFactory(getService); const utils = EntityStoreUtils(getService); + // TODO: unskip once permissions issue is resolved describe.skip('@ess @serverless @skipInServerlessMKI Entity Store Engine APIs', () => { const dataView = dataViewRouteHelpersFactory(supertest); diff --git a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/entities_list.ts b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/entities_list.ts index 0a772f637ef55..69f9c14d06086 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/entities_list.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/entity_analytics/entity_store/trial_license_complete_tier/entities_list.ts @@ -10,8 +10,9 @@ import { FtrProviderContext } from '../../../../ftr_provider_context'; export default ({ getService }: FtrProviderContext) => { const securitySolutionApi = getService('securitySolutionApi'); + // TODO: unskip once permissions issue is resolved - describe.skip('@ess @serverless @skipInServerlessMKI Entity store - Entities list API', () => { + describe.skip('@ess Entity store - Entities list API', () => { describe('when the entity store is disable', () => { it("should return response with success status when the index doesn't exist", async () => { const { body } = await securitySolutionApi.listEntities({ diff --git a/x-pack/test_serverless/functional/config.base.ts b/x-pack/test_serverless/functional/config.base.ts index 963be692d58ea..05674c6222ae5 100644 --- a/x-pack/test_serverless/functional/config.base.ts +++ b/x-pack/test_serverless/functional/config.base.ts @@ -32,6 +32,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', + ])}`, ...(options.kbnServerArgs ?? []), ], },