diff --git a/x-pack/plugins/transform/common/api_schemas/transforms.ts b/x-pack/plugins/transform/common/api_schemas/transforms.ts index 7fb1a62a67bb8..8867ecb5cc760 100644 --- a/x-pack/plugins/transform/common/api_schemas/transforms.ts +++ b/x-pack/plugins/transform/common/api_schemas/transforms.ts @@ -12,7 +12,7 @@ import type { ES_FIELD_TYPES } from '../../../../../src/plugins/data/common'; import type { Dictionary } from '../types/common'; import type { PivotAggDict } from '../types/pivot_aggs'; import type { PivotGroupByDict } from '../types/pivot_group_by'; -import type { TransformId, TransformPivotConfig } from '../types/transform'; +import type { TransformId, TransformConfigUnion } from '../types/transform'; import { transformStateSchema, runtimeMappingsSchema } from './common'; @@ -33,7 +33,7 @@ export type GetTransformsRequestSchema = TypeOf; + +export type TransformHealthAlertRule = Omit, 'apiKey'>; diff --git a/x-pack/plugins/transform/common/types/transform.ts b/x-pack/plugins/transform/common/types/transform.ts index f1e7efdadca9d..a478946ff917c 100644 --- a/x-pack/plugins/transform/common/types/transform.ts +++ b/x-pack/plugins/transform/common/types/transform.ts @@ -5,11 +5,12 @@ * 2.0. */ -import { EuiComboBoxOptionOption } from '@elastic/eui/src/components/combo_box/types'; +import type { EuiComboBoxOptionOption } from '@elastic/eui/src/components/combo_box/types'; import type { LatestFunctionConfig, PutTransformsRequestSchema } from '../api_schemas/transforms'; import { isPopulatedObject } from '../shared_imports'; -import { PivotGroupByDict } from './pivot_group_by'; -import { PivotAggDict } from './pivot_aggs'; +import type { PivotGroupByDict } from './pivot_group_by'; +import type { PivotAggDict } from './pivot_aggs'; +import type { TransformHealthAlertRule } from './alerting'; export type IndexName = string; export type IndexPattern = string; @@ -22,6 +23,7 @@ export type TransformBaseConfig = PutTransformsRequestSchema & { id: TransformId; create_time?: number; version?: string; + alerting_rules?: TransformHealthAlertRule[]; }; export interface PivotConfigDefinition { @@ -45,6 +47,11 @@ export type TransformLatestConfig = Omit & { export type TransformConfigUnion = TransformPivotConfig | TransformLatestConfig; +export type ContinuousTransform = Omit & + Required<{ + sync: TransformConfigUnion['sync']; + }>; + export function isPivotTransform(transform: unknown): transform is TransformPivotConfig { return isPopulatedObject(transform, ['pivot']); } @@ -53,6 +60,10 @@ export function isLatestTransform(transform: unknown): transform is TransformLat return isPopulatedObject(transform, ['latest']); } +export function isContinuousTransform(transform: unknown): transform is ContinuousTransform { + return isPopulatedObject(transform, ['sync']); +} + export interface LatestFunctionConfigUI { unique_key: Array> | undefined; sort: EuiComboBoxOptionOption | undefined; diff --git a/x-pack/plugins/transform/public/alerting/transform_alerting_flyout.tsx b/x-pack/plugins/transform/public/alerting/transform_alerting_flyout.tsx new file mode 100644 index 0000000000000..63d00f280f3f3 --- /dev/null +++ b/x-pack/plugins/transform/public/alerting/transform_alerting_flyout.tsx @@ -0,0 +1,127 @@ +/* + * Copyright 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, { createContext, FC, useContext, useMemo } from 'react'; +import { memoize } from 'lodash'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { pluck } from 'rxjs/operators'; +import useObservable from 'react-use/lib/useObservable'; +import { useAppDependencies } from '../app/app_dependencies'; +import { TransformHealthAlertRule, TransformHealthRuleParams } from '../../common/types/alerting'; +import { TRANSFORM_RULE_TYPE } from '../../common'; + +interface TransformAlertFlyoutProps { + initialAlert?: TransformHealthAlertRule | null; + ruleParams?: TransformHealthRuleParams | null; + onSave?: () => void; + onCloseFlyout: () => void; +} + +export const TransformAlertFlyout: FC = ({ + initialAlert, + ruleParams, + onCloseFlyout, + onSave, +}) => { + const { triggersActionsUi } = useAppDependencies(); + + const AlertFlyout = useMemo(() => { + if (!triggersActionsUi) return; + + const commonProps = { + onClose: () => { + onCloseFlyout(); + }, + onSave: async () => { + if (onSave) { + onSave(); + } + }, + }; + + if (initialAlert) { + return triggersActionsUi.getEditAlertFlyout({ + ...commonProps, + initialAlert, + }); + } + + return triggersActionsUi.getAddAlertFlyout({ + ...commonProps, + consumer: 'stackAlerts', + canChangeTrigger: false, + alertTypeId: TRANSFORM_RULE_TYPE.TRANSFORM_HEALTH, + metadata: {}, + initialValues: { + params: ruleParams!, + }, + }); + // deps on id to avoid re-rendering on auto-refresh + }, [triggersActionsUi, initialAlert, ruleParams, onCloseFlyout, onSave]); + + return <>{AlertFlyout}; +}; + +interface AlertRulesManage { + editAlertRule$: Observable; + createAlertRule$: Observable; + setEditAlertRule: (alertRule: TransformHealthAlertRule) => void; + setCreateAlertRule: (transformId: string) => void; + hideAlertFlyout: () => void; +} + +export const getAlertRuleManageContext = memoize(function (): AlertRulesManage { + const ruleState$ = new BehaviorSubject<{ + editAlertRule: null | TransformHealthAlertRule; + createAlertRule: null | TransformHealthRuleParams; + }>({ + editAlertRule: null, + createAlertRule: null, + }); + return { + editAlertRule$: ruleState$.pipe(pluck('editAlertRule')), + createAlertRule$: ruleState$.pipe(pluck('createAlertRule')), + setEditAlertRule: (initialRule) => { + ruleState$.next({ + createAlertRule: null, + editAlertRule: initialRule, + }); + }, + setCreateAlertRule: (transformId: string) => { + ruleState$.next({ + createAlertRule: { includeTransforms: [transformId] }, + editAlertRule: null, + }); + }, + hideAlertFlyout: () => { + ruleState$.next({ + createAlertRule: null, + editAlertRule: null, + }); + }, + }; +}); + +export const AlertRulesManageContext = createContext(getAlertRuleManageContext()); + +export function useAlertRuleFlyout(): AlertRulesManage { + return useContext(AlertRulesManageContext); +} + +export const TransformAlertFlyoutWrapper = () => { + const { editAlertRule$, createAlertRule$, hideAlertFlyout } = useAlertRuleFlyout(); + const editAlertRule = useObservable(editAlertRule$); + const createAlertRule = useObservable(createAlertRule$); + + return editAlertRule || createAlertRule ? ( + + ) : null; +}; 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 8dc0e277c284d..ab38d05ec9f8f 100644 --- a/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx +++ b/x-pack/plugins/transform/public/app/__mocks__/app_dependencies.tsx @@ -19,6 +19,7 @@ import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; import type { AppDependencies } from '../app_dependencies'; import { MlSharedContext } from './shared_context'; import type { GetMlSharedImportsReturnType } from '../../shared_imports'; +import type { TriggersAndActionsUIPublicPluginStart } from '../../../../triggers_actions_ui/public'; const coreSetup = coreMock.createSetup(); const coreStart = coreMock.createStart(); @@ -43,6 +44,7 @@ const appDependencies: AppDependencies = { savedObjectsPlugin: savedObjectsPluginMock.createStartContract(), share: { urlGenerators: { getUrlGenerator: jest.fn() } } as unknown as SharePluginStart, ml: {} as GetMlSharedImportsReturnType, + triggersActionsUi: {} as jest.Mocked, }; export const useAppDependencies = () => { diff --git a/x-pack/plugins/transform/public/app/app_dependencies.tsx b/x-pack/plugins/transform/public/app/app_dependencies.tsx index d3f356f3e83b3..da1178e395720 100644 --- a/x-pack/plugins/transform/public/app/app_dependencies.tsx +++ b/x-pack/plugins/transform/public/app/app_dependencies.tsx @@ -16,6 +16,7 @@ import { useKibana } from '../../../../../src/plugins/kibana_react/public'; import type { Storage } from '../../../../../src/plugins/kibana_utils/public'; import type { GetMlSharedImportsReturnType } from '../shared_imports'; +import type { TriggersAndActionsUIPublicPluginStart } from '../../../triggers_actions_ui/public'; export interface AppDependencies { application: CoreStart['application']; @@ -34,6 +35,7 @@ export interface AppDependencies { share: SharePluginStart; ml: GetMlSharedImportsReturnType; spaces?: SpacesPluginStart; + triggersActionsUi: TriggersAndActionsUIPublicPluginStart; } export const useAppDependencies = () => { diff --git a/x-pack/plugins/transform/public/app/common/transform_list.ts b/x-pack/plugins/transform/public/app/common/transform_list.ts index d73018e284a8c..c8ddd32f6a8ff 100644 --- a/x-pack/plugins/transform/public/app/common/transform_list.ts +++ b/x-pack/plugins/transform/public/app/common/transform_list.ts @@ -5,10 +5,10 @@ * 2.0. */ -import { EuiTableActionsColumnType } from '@elastic/eui'; - -import { TransformConfigUnion, TransformId } from '../../../common/types/transform'; -import { TransformStats } from '../../../common/types/transform_stats'; +import type { EuiTableActionsColumnType } from '@elastic/eui'; +import type { TransformConfigUnion, TransformId } from '../../../common/types/transform'; +import type { TransformStats } from '../../../common/types/transform_stats'; +import type { TransformHealthAlertRule } from '../../../common/types/alerting'; // Used to pass on attribute names to table columns export enum TRANSFORM_LIST_COLUMN { @@ -21,6 +21,7 @@ export interface TransformListRow { config: TransformConfigUnion; mode?: string; // added property on client side to allow filtering by this field stats: TransformStats; + alerting_rules?: TransformHealthAlertRule[]; } // The single Action type is not exported as is diff --git a/x-pack/plugins/transform/public/app/hooks/use_get_transforms.ts b/x-pack/plugins/transform/public/app/hooks/use_get_transforms.ts index 2d3425dfeedca..7879e15118a33 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_get_transforms.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_get_transforms.ts @@ -87,6 +87,7 @@ export const useGetTransforms = ( mode: typeof config.sync !== 'undefined' ? TRANSFORM_MODE.CONTINUOUS : TRANSFORM_MODE.BATCH, stats, + alerting_rules: config.alerting_rules, }); return reducedtableRows; }, [] as TransformListRow[]); diff --git a/x-pack/plugins/transform/public/app/lib/authorization/components/authorization_provider.tsx b/x-pack/plugins/transform/public/app/lib/authorization/components/authorization_provider.tsx index 875c0f60969ed..cc6313bf058c6 100644 --- a/x-pack/plugins/transform/public/app/lib/authorization/components/authorization_provider.tsx +++ b/x-pack/plugins/transform/public/app/lib/authorization/components/authorization_provider.tsx @@ -20,12 +20,14 @@ interface Authorization { capabilities: Capabilities; } -const initialCapabalities: Capabilities = { +const initialCapabilities: Capabilities = { canGetTransform: false, canDeleteTransform: false, canPreviewTransform: false, canCreateTransform: false, canStartStopTransform: false, + canCreateTransformAlerts: false, + canUseTransformAlerts: false, }; const initialValue: Authorization = { @@ -35,7 +37,7 @@ const initialValue: Authorization = { hasAllPrivileges: false, missingPrivileges: {}, }, - capabilities: initialCapabalities, + capabilities: initialCapabilities, }; export const AuthorizationContext = createContext({ ...initialValue }); @@ -58,7 +60,7 @@ export const AuthorizationProvider = ({ privilegesEndpoint, children }: Props) = const value = { isLoading, privileges: isLoading ? { ...initialValue.privileges } : privilegesData, - capabilities: { ...initialCapabalities }, + capabilities: { ...initialCapabilities }, apiError: error ? (error as Error) : null, }; @@ -85,6 +87,10 @@ export const AuthorizationProvider = ({ privilegesEndpoint, children }: Props) = hasPrivilege(['cluster', 'cluster:admin/transform/start_task']) && hasPrivilege(['cluster', 'cluster:admin/transform/stop']); + value.capabilities.canCreateTransformAlerts = value.capabilities.canCreateTransform; + + value.capabilities.canUseTransformAlerts = value.capabilities.canGetTransform; + return ( {children} ); diff --git a/x-pack/plugins/transform/public/app/lib/authorization/components/common.ts b/x-pack/plugins/transform/public/app/lib/authorization/components/common.ts index d059f73a76137..d430a4d059e5c 100644 --- a/x-pack/plugins/transform/public/app/lib/authorization/components/common.ts +++ b/x-pack/plugins/transform/public/app/lib/authorization/components/common.ts @@ -16,6 +16,8 @@ export interface Capabilities { canPreviewTransform: boolean; canCreateTransform: boolean; canStartStopTransform: boolean; + canCreateTransformAlerts: boolean; + canUseTransformAlerts: boolean; } export type Privilege = [string, string]; @@ -67,6 +69,14 @@ export function createCapabilityFailureMessage( defaultMessage: 'You do not have permission to create transforms.', }); break; + case 'canCreateTransformAlerts': + message = i18n.translate( + 'xpack.transform.capability.noPermission.canCreateTransformAlertsTooltip', + { + defaultMessage: 'You do not have permission to create transform alert rules.', + } + ); + break; case 'canStartStopTransform': message = i18n.translate( 'xpack.transform.capability.noPermission.startOrStopTransformTooltip', 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 1747330818547..6e63094064584 100644 --- a/x-pack/plugins/transform/public/app/mount_management_section.ts +++ b/x-pack/plugins/transform/public/app/mount_management_section.ts @@ -29,7 +29,7 @@ export async function mountManagementSection( const startServices = await getStartServices(); const [core, plugins] = startServices; const { application, chrome, docLinks, i18n, overlays, savedObjects, uiSettings } = core; - const { data, share, spaces } = plugins; + const { data, share, spaces, triggersActionsUi } = plugins; const { docTitle } = chrome; // Initialize services @@ -55,6 +55,7 @@ export async function mountManagementSection( share, spaces, ml: await getMlSharedImports(), + triggersActionsUi, }; const unmountAppCallback = renderApp(element, appDependencies); diff --git a/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx b/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx index 8aecf403186c5..218edb95c5f4f 100644 --- a/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx +++ b/x-pack/plugins/transform/public/app/sections/clone_transform/clone_transform_section.tsx @@ -21,7 +21,7 @@ import { } from '@elastic/eui'; import { APP_CREATE_TRANSFORM_CLUSTER_PRIVILEGES } from '../../../../common/constants'; -import { TransformPivotConfig } from '../../../../common/types/transform'; +import { TransformConfigUnion } from '../../../../common/types/transform'; import { isHttpFetchError } from '../../common/request'; import { useApi } from '../../hooks/use_api'; @@ -50,7 +50,7 @@ export const CloneTransformSection: FC = ({ match, location }) => { const transformId = match.params.transformId; - const [transformConfig, setTransformConfig] = useState(); + const [transformConfig, setTransformConfig] = useState(); const [errorMessage, setErrorMessage] = useState(); const [isInitialized, setIsInitialized] = useState(false); const { error: searchItemsError, searchItems, setSavedObjectId } = useSearchItems(undefined); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx index 7ccf986d5d497..859ea77ea5a14 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_create/step_create_form.tsx @@ -23,6 +23,7 @@ import { EuiText, } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; import { toMountPoint } from '../../../../../../../../../src/plugins/kibana_react/public'; import { @@ -52,7 +53,8 @@ import { } from '../../../../../../common/api_schemas/transforms'; import type { RuntimeField } from '../../../../../../../../../src/plugins/data/common'; import { isPopulatedObject } from '../../../../../../common/shared_imports'; -import { isLatestTransform } from '../../../../../../common/types/transform'; +import { isContinuousTransform, isLatestTransform } from '../../../../../../common/types/transform'; +import { TransformAlertFlyout } from '../../../../../alerting/transform_alerting_flyout'; export interface StepDetailsExposedState { created: boolean; @@ -86,6 +88,7 @@ export const StepCreateForm: FC = React.memo( const [loading, setLoading] = useState(false); const [created, setCreated] = useState(defaults.created); const [started, setStarted] = useState(defaults.started); + const [alertFlyoutVisible, setAlertFlyoutVisible] = useState(false); const [indexPatternId, setIndexPatternId] = useState(defaults.indexPatternId); const [progressPercentComplete, setProgressPercentComplete] = useState( undefined @@ -398,6 +401,31 @@ export const StepCreateForm: FC = React.memo( )} + {isContinuousTransform(transformConfig) && created ? ( + + + + + + + + + {i18n.translate('xpack.transform.stepCreateForm.createAlertRuleDescription', { + defaultMessage: + 'Opens a wizard to create an alert rule for monitoring transform health.', + })} + + + + ) : null} = React.memo( {i18n.translate('xpack.transform.stepCreateForm.createTransformDescription', { defaultMessage: - 'Create the transform without starting it. You will be able to start the transform later by returning to the transforms list.', + 'Creates the transform without starting it. You will be able to start the transform later by returning to the transforms list.', })} @@ -535,6 +563,12 @@ export const StepCreateForm: FC = React.memo( )} + {alertFlyoutVisible ? ( + + ) : null} ); } diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/common.ts b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/common.ts index fbe32e9bea12f..39b1a2de26f8e 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/common.ts +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/common.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { TransformId, TransformPivotConfig } from '../../../../../../common/types/transform'; +import type { TransformConfigUnion, TransformId } from '../../../../../../common/types/transform'; export type EsIndexName = string; export type IndexPatternTitle = string; @@ -55,7 +55,7 @@ export function getDefaultStepDetailsState(): StepDetailsExposedState { export function applyTransformConfigToDetailsState( state: StepDetailsExposedState, - transformConfig?: TransformPivotConfig + transformConfig?: TransformConfigUnion ): StepDetailsExposedState { // apply the transform configuration to wizard DETAILS state if (transformConfig !== undefined) { diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx index 0d39ec77d059f..7a47cc539c4aa 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/step_details/step_details_form.tsx @@ -29,7 +29,7 @@ import { isEsIndices, isPostTransformsPreviewResponseSchema, } from '../../../../../../common/api_schemas/type_guards'; -import { TransformId, TransformPivotConfig } from '../../../../../../common/types/transform'; +import { TransformId } from '../../../../../../common/types/transform'; import { isValidIndexName } from '../../../../../../common/utils/es_utils'; import { getErrorMessage } from '../../../../../../common/utils/errors'; @@ -158,7 +158,7 @@ export const StepDetailsForm: FC = React.memo( ), }); } else { - setTransformIds(resp.transforms.map((transform: TransformPivotConfig) => transform.id)); + setTransformIds(resp.transforms.map((transform) => transform.id)); } const indices = await api.getEsIndices(); diff --git a/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx b/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx index 63e21e5d8aa14..27c43ed01a934 100644 --- a/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx +++ b/x-pack/plugins/transform/public/app/sections/create_transform/components/wizard/wizard.tsx @@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n'; import { EuiSteps, EuiStepStatus } from '@elastic/eui'; -import { TransformPivotConfig } from '../../../../../../common/types/transform'; +import type { TransformConfigUnion } from '../../../../../../common/types/transform'; import { getCreateTransformRequestBody } from '../../../../common'; import { SearchItems } from '../../../../hooks/use_search_items'; @@ -81,7 +81,7 @@ const StepDefine: FC = ({ }; interface WizardProps { - cloneConfig?: TransformPivotConfig; + cloneConfig?: TransformConfigUnion; searchItems: SearchItems; } diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_create_alert/create_alert_rule_action_name.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_create_alert/create_alert_rule_action_name.tsx new file mode 100644 index 0000000000000..c8d67a86d579a --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_create_alert/create_alert_rule_action_name.tsx @@ -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 React, { FC } from 'react'; +import { EuiToolTip } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; +import { createCapabilityFailureMessage } from '../../../../lib/authorization'; + +interface CreateAlertRuleActionProps { + disabled: boolean; +} + +export const crateAlertRuleActionNameText = i18n.translate( + 'xpack.transform.transformList.createAlertRuleNameText', + { + defaultMessage: 'Create alert rule', + } +); + +export const CreateAlertRuleActionName: FC = ({ disabled }) => { + if (disabled) { + return ( + + <>{crateAlertRuleActionNameText} + + ); + } + + return <>{crateAlertRuleActionNameText}; +}; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_create_alert/index.ts b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_create_alert/index.ts new file mode 100644 index 0000000000000..80999d774bdcb --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_create_alert/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 { useCreateAlertRuleAction } from './use_create_alert_rule_action'; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/action_create_alert/use_create_alert_rule_action.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_create_alert/use_create_alert_rule_action.tsx new file mode 100644 index 0000000000000..070f1eb08ac60 --- /dev/null +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/action_create_alert/use_create_alert_rule_action.tsx @@ -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 React, { useCallback, useContext, useMemo } from 'react'; +import { AuthorizationContext } from '../../../../lib/authorization'; +import { TransformListAction, TransformListRow } from '../../../../common'; +import { + crateAlertRuleActionNameText, + CreateAlertRuleActionName, +} from './create_alert_rule_action_name'; +import { useAlertRuleFlyout } from '../../../../../alerting/transform_alerting_flyout'; +import { isContinuousTransform } from '../../../../../../common/types/transform'; + +export type CreateAlertRuleAction = ReturnType; +export const useCreateAlertRuleAction = (forceDisable: boolean) => { + const { canCreateTransformAlerts } = useContext(AuthorizationContext).capabilities; + const { setCreateAlertRule } = useAlertRuleFlyout(); + + const clickHandler = useCallback( + (item: TransformListRow) => { + setCreateAlertRule(item.id); + }, + [setCreateAlertRule] + ); + + const action: TransformListAction = useMemo( + () => ({ + name: (item: TransformListRow) => ( + + ), + available: (item: TransformListRow) => isContinuousTransform(item.config), + enabled: () => canCreateTransformAlerts && !forceDisable, + description: crateAlertRuleActionNameText, + type: 'icon', + icon: 'bell', + onClick: clickHandler, + 'data-test-subj': 'transformActionCreateAlertRule', + }), + [canCreateTransformAlerts, forceDisable, clickHandler] + ); + + return { action }; +}; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.test.tsx index 2c085fc333868..152fb0fef6916 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.test.tsx @@ -22,6 +22,7 @@ import { getMlSharedImports } from '../../../../../shared_imports'; // FLAKY https://github.com/elastic/kibana/issues/112922 describe.skip('Transform: Transform List ', () => { + const onAlertEdit = jest.fn(); // Set timezone to US/Eastern for consistent test results. beforeEach(() => { moment.tz.setDefault('US/Eastern'); @@ -39,7 +40,7 @@ describe.skip('Transform: Transform List ', () => { render( - + ); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.tsx index dff2ba17cb3f0..84110e67d701e 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row.tsx @@ -7,17 +7,18 @@ import React, { FC } from 'react'; -import { EuiTabbedContent } from '@elastic/eui'; +import { EuiButtonEmpty, EuiTabbedContent } from '@elastic/eui'; import { Optional } from '@kbn/utility-types'; import { i18n } from '@kbn/i18n'; import moment from 'moment-timezone'; import { TransformListRow } from '../../../../common'; import { useAppDependencies } from '../../../../app_dependencies'; -import { ExpandedRowDetailsPane, SectionConfig } from './expanded_row_details_pane'; +import { ExpandedRowDetailsPane, SectionConfig, SectionItem } from './expanded_row_details_pane'; import { ExpandedRowJsonPane } from './expanded_row_json_pane'; import { ExpandedRowMessagesPane } from './expanded_row_messages_pane'; import { ExpandedRowPreviewPane } from './expanded_row_preview_pane'; +import { TransformHealthAlertRule } from '../../../../../../common/types/alerting'; function getItemDescription(value: any) { if (typeof value === 'object') { @@ -44,18 +45,16 @@ export function stringHash(str: string): number { return hash < 0 ? hash * -2 : hash; } -interface Item { - title: string; - description: any; -} +type Item = SectionItem; interface Props { item: TransformListRow; + onAlertEdit: (alertRule: TransformHealthAlertRule) => void; } type StateValues = Optional; -export const ExpandedRow: FC = ({ item }) => { +export const ExpandedRow: FC = ({ item, onAlertEdit }) => { const { ml: { formatHumanReadableDateTimeSeconds }, } = useAppDependencies(); @@ -166,12 +165,40 @@ export const ExpandedRow: FC = ({ item }) => { } } + const alertRuleItems: Item[] | undefined = item.alerting_rules?.map((rule) => { + return { + title: ( + { + onAlertEdit(rule); + }} + flush="left" + size={'xs'} + iconSize={'s'} + > + {rule.name} + + ), + description: rule.executionStatus.status, + }; + }); + const checkpointing: SectionConfig = { title: 'Checkpointing', items: checkpointingItems, position: 'right', }; + const alertingRules: SectionConfig = { + title: i18n.translate('xpack.transform.transformList.transformDetails.alertRulesTitle', { + defaultMessage: 'Alert rules', + }), + items: alertRuleItems!, + position: 'right', + }; + const stats: SectionConfig = { title: 'Stats', items: Object.entries(item.stats.stats).map((s) => { @@ -192,7 +219,16 @@ export const ExpandedRow: FC = ({ item }) => { defaultMessage: 'Details', } ), - content: , + content: ( + + ), }, { id: `transform-stats-tab-${tabId}`, diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_details_pane.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_details_pane.tsx index 03e2fb2115d62..1b2dde0a2e576 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_details_pane.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/expanded_row_details_pane.tsx @@ -17,9 +17,10 @@ import { } from '@elastic/eui'; export interface SectionItem { - title: string; - description: string; + title: string | JSX.Element; + description: string | number | JSX.Element; } + export interface SectionConfig { title: string; position: 'left' | 'right'; diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx index ab30f4793a315..8b7aaf1cf8fd2 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/transform_list.tsx @@ -50,15 +50,18 @@ import { useColumns } from './use_columns'; import { ExpandedRow } from './expanded_row'; import { transformFilters, filterTransforms } from './transform_search_bar_filters'; import { useTableSettings } from './use_table_settings'; +import { useAlertRuleFlyout } from '../../../../../alerting/transform_alerting_flyout'; +import { TransformHealthAlertRule } from '../../../../../../common/types/alerting'; function getItemIdToExpandedRowMap( itemIds: TransformId[], - transforms: TransformListRow[] + transforms: TransformListRow[], + onAlertEdit: (alertRule: TransformHealthAlertRule) => void ): ItemIdToExpandedRowMap { return itemIds.reduce((m: ItemIdToExpandedRowMap, transformId: TransformId) => { const item = transforms.find((transform) => transform.config.id === transformId); if (item !== undefined) { - m[transformId] = ; + m[transformId] = ; } return m; }, {} as ItemIdToExpandedRowMap); @@ -79,6 +82,7 @@ export const TransformList: FC = ({ }) => { const [isLoading, setIsLoading] = useState(false); const { refresh } = useRefreshTransformList({ isLoading: setIsLoading }); + const { setEditAlertRule } = useAlertRuleFlyout(); const [filterActive, setFilterActive] = useState(false); @@ -171,7 +175,11 @@ export const TransformList: FC = ({ ); } - const itemIdToExpandedRowMap = getItemIdToExpandedRowMap(expandedRowItemIds, transforms); + const itemIdToExpandedRowMap = getItemIdToExpandedRowMap( + expandedRowItemIds, + transforms, + setEditAlertRule + ); const bulkActionMenuItems = [
diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.test.tsx index b7d5a2b7104ae..20d2f784a4d8b 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.test.tsx @@ -27,6 +27,7 @@ describe('Transform: Transform List Actions', () => { // in the runtime result here anyway. expect(actions.map((a: any) => a['data-test-subj'])).toStrictEqual([ 'transformActionDiscover', + 'transformActionCreateAlertRule', 'transformActionStart', 'transformActionStop', 'transformActionEdit', diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx index 81e51cdafc32e..40b40cfa8c7ba 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_actions.tsx @@ -18,6 +18,7 @@ import { EditTransformFlyout } from '../edit_transform_flyout'; import { useEditAction } from '../action_edit'; import { useStartAction, StartActionModal } from '../action_start'; import { useStopAction } from '../action_stop'; +import { useCreateAlertRuleAction } from '../action_create_alert'; export const useActions = ({ forceDisable, @@ -35,6 +36,7 @@ export const useActions = ({ const editAction = useEditAction(forceDisable, transformNodes); const startAction = useStartAction(forceDisable, transformNodes); const stopAction = useStopAction(forceDisable); + const createAlertRuleAction = useCreateAlertRuleAction(forceDisable); return { modals: ( @@ -52,6 +54,7 @@ export const useActions = ({ ), actions: [ discoverAction.action, + createAlertRuleAction.action, startAction.action, stopAction.action, editAction.action, diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.test.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.test.tsx index af2325ede2021..a26ccf0348c9a 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.test.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.test.tsx @@ -20,14 +20,15 @@ describe('Transform: Job List Columns', () => { const columns: ReturnType['columns'] = result.current.columns; - expect(columns).toHaveLength(8); + expect(columns).toHaveLength(9); expect(columns[0].isExpander).toBeTruthy(); expect(columns[1].name).toBe('ID'); - expect(columns[2].name).toBe('Description'); - expect(columns[3].name).toBe('Type'); - expect(columns[4].name).toBe('Status'); - expect(columns[5].name).toBe('Mode'); - expect(columns[6].name).toBe('Progress'); - expect(columns[7].name).toBe('Actions'); + expect(columns[2].id).toBe('alertRule'); + expect(columns[3].name).toBe('Description'); + expect(columns[4].name).toBe('Type'); + expect(columns[5].name).toBe('Status'); + expect(columns[6].name).toBe('Mode'); + expect(columns[7].name).toBe('Progress'); + expect(columns[8].name).toBe('Actions'); }); }); diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.tsx index dbdd3409c7e34..bad42c212293d 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/components/transform_list/use_columns.tsx @@ -21,6 +21,7 @@ import { EuiText, EuiToolTip, RIGHT_ALIGNMENT, + EuiIcon, } from '@elastic/eui'; import { @@ -95,6 +96,7 @@ export const useColumns = ( const columns: [ EuiTableComputedColumnType, EuiTableFieldDataColumnType, + EuiTableComputedColumnType, EuiTableFieldDataColumnType, EuiTableComputedColumnType, EuiTableComputedColumnType, @@ -143,6 +145,38 @@ export const useColumns = ( truncateText: true, scope: 'row', }, + { + id: 'alertRule', + name: ( + +

+ +

+
+ ), + width: '30px', + render: (item) => { + return Array.isArray(item.alerting_rules) ? ( + + } + > + + + ) : ( + + ); + }, + }, { field: TRANSFORM_LIST_COLUMN.DESCRIPTION, 'data-test-subj': 'transformListColumnDescription', diff --git a/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx b/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx index 2479d34f1579a..055e1e50701f8 100644 --- a/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx +++ b/x-pack/plugins/transform/public/app/sections/transform_management/transform_management_section.tsx @@ -35,6 +35,11 @@ import { useRefreshInterval } from './components/transform_list/use_refresh_inte import { SearchSelection } from './components/search_selection'; import { TransformList } from './components/transform_list'; import { TransformStatsBar } from './components/transform_list/transforms_stats_bar'; +import { + AlertRulesManageContext, + getAlertRuleManageContext, + TransformAlertFlyoutWrapper, +} from '../../../alerting/transform_alerting_flyout'; export const TransformManagement: FC = () => { const { esTransform } = useDocumentationLinks(); @@ -149,12 +154,15 @@ export const TransformManagement: FC = () => { )} {typeof errorMessage === 'undefined' && ( - + + + + )} )} diff --git a/x-pack/plugins/transform/public/plugin.ts b/x-pack/plugins/transform/public/plugin.ts index da280452c1f0f..a7d0dce256640 100644 --- a/x-pack/plugins/transform/public/plugin.ts +++ b/x-pack/plugins/transform/public/plugin.ts @@ -16,7 +16,7 @@ import type { SharePluginStart } from 'src/plugins/share/public'; import type { SpacesApi } from '../../spaces/public'; import { registerFeature } from './register_feature'; import type { PluginSetupContract as AlertingSetup } from '../../alerting/public'; -import type { TriggersAndActionsUIPublicPluginSetup } from '../../triggers_actions_ui/public'; +import type { TriggersAndActionsUIPublicPluginStart } from '../../triggers_actions_ui/public'; import { getTransformHealthRuleType } from './alerting'; export interface PluginsDependencies { @@ -27,7 +27,7 @@ export interface PluginsDependencies { share: SharePluginStart; spaces?: SpacesApi; alerting?: AlertingSetup; - triggersActionsUi?: TriggersAndActionsUIPublicPluginSetup; + triggersActionsUi: TriggersAndActionsUIPublicPluginStart; } export class TransformUiPlugin { diff --git a/x-pack/plugins/transform/server/lib/alerting/transform_health_rule_type/transform_health_service.ts b/x-pack/plugins/transform/server/lib/alerting/transform_health_rule_type/transform_health_service.ts index 88b5396c7b110..eb51c04e0bca7 100644 --- a/x-pack/plugins/transform/server/lib/alerting/transform_health_rule_type/transform_health_service.ts +++ b/x-pack/plugins/transform/server/lib/alerting/transform_health_rule_type/transform_health_service.ts @@ -8,16 +8,21 @@ import { ElasticsearchClient } from 'kibana/server'; import { i18n } from '@kbn/i18n'; import type { Transform as EsTransform } from '@elastic/elasticsearch/api/types'; +import { keyBy } from 'lodash'; import { TransformHealthRuleParams } from './schema'; import { ALL_TRANSFORMS_SELECTION, TRANSFORM_HEALTH_CHECK_NAMES, + TRANSFORM_RULE_TYPE, } from '../../../../common/constants'; import { getResultTestConfig } from '../../../../common/utils/alerts'; import { NotStartedTransformResponse, TransformHealthAlertContext, } from './register_transform_health_rule_type'; +import type { RulesClient } from '../../../../../alerting/server'; +import type { TransformHealthAlertRule } from '../../../../common/types/alerting'; +import { isContinuousTransform } from '../../../../common/types/transform'; interface TestResult { name: string; @@ -27,37 +32,48 @@ interface TestResult { // @ts-ignore FIXME update types in the elasticsearch client type Transform = EsTransform & { id: string; description?: string; sync: object }; -export function transformHealthServiceProvider(esClient: ElasticsearchClient) { +type TransformWithAlertingRules = Transform & { alerting_rules: TransformHealthAlertRule[] }; + +export function transformHealthServiceProvider( + esClient: ElasticsearchClient, + rulesClient?: RulesClient +) { const transformsDict = new Map(); /** * Resolves result transform selection. * @param includeTransforms * @param excludeTransforms + * @param skipIDsCheck */ const getResultsTransformIds = async ( includeTransforms: string[], - excludeTransforms: string[] | null + excludeTransforms: string[] | null, + skipIDsCheck = false ): Promise => { const includeAll = includeTransforms.some((id) => id === ALL_TRANSFORMS_SELECTION); - // Fetch transforms to make sure assigned transforms exists. - const transformsResponse = ( - await esClient.transform.getTransform({ - ...(includeAll ? {} : { transform_id: includeTransforms.join(',') }), - allow_no_match: true, - size: 1000, - }) - ).body.transforms as Transform[]; - let resultTransformIds: string[] = []; - transformsResponse.forEach((t) => { - transformsDict.set(t.id, t); - if (t.sync) { - resultTransformIds.push(t.id); - } - }); + if (skipIDsCheck) { + resultTransformIds = includeTransforms; + } else { + // Fetch transforms to make sure assigned transforms exists. + const transformsResponse = ( + await esClient.transform.getTransform({ + ...(includeAll ? {} : { transform_id: includeTransforms.join(',') }), + allow_no_match: true, + size: 1000, + }) + ).body.transforms as Transform[]; + + transformsResponse.forEach((t) => { + transformsDict.set(t.id, t); + if (t.sync) { + resultTransformIds.push(t.id); + } + }); + } if (excludeTransforms && excludeTransforms.length > 0) { const excludeIdsSet = new Set(excludeTransforms); @@ -129,6 +145,53 @@ export function transformHealthServiceProvider(esClient: ElasticsearchClient) { return result; }, + + /** + * Updates transform list with associated alerting rules. + */ + async populateTransformsWithAssignedRules( + transforms: Transform[] + ): Promise { + const newList = transforms.filter(isContinuousTransform) as TransformWithAlertingRules[]; + + if (!rulesClient) { + throw new Error('Rules client is missing'); + } + + const transformMap = keyBy(newList, 'id'); + + const transformAlertingRules = await rulesClient.find({ + options: { + perPage: 1000, + filter: `alert.attributes.alertTypeId:${TRANSFORM_RULE_TYPE.TRANSFORM_HEALTH}`, + }, + }); + + for (const ruleInstance of transformAlertingRules.data) { + // Retrieve result transform IDs + const resultTransformIds: string[] = await getResultsTransformIds( + ruleInstance.params.includeTransforms.includes(ALL_TRANSFORMS_SELECTION) + ? Object.keys(transformMap) + : ruleInstance.params.includeTransforms, + ruleInstance.params.excludeTransforms, + true + ); + + resultTransformIds.forEach((transformId) => { + const transformRef = transformMap[transformId] as TransformWithAlertingRules; + + if (transformRef) { + if (Array.isArray(transformRef.alerting_rules)) { + transformRef.alerting_rules.push(ruleInstance); + } else { + transformRef.alerting_rules = [ruleInstance]; + } + } + }); + } + + return newList; + }, }; } diff --git a/x-pack/plugins/transform/server/routes/api/transforms.ts b/x-pack/plugins/transform/server/routes/api/transforms.ts index 76aac9686c37e..4a657ae615d94 100644 --- a/x-pack/plugins/transform/server/routes/api/transforms.ts +++ b/x-pack/plugins/transform/server/routes/api/transforms.ts @@ -63,6 +63,7 @@ import { registerTransformNodesRoutes } from './transforms_nodes'; import { IIndexPattern } from '../../../../../../src/plugins/data/common'; import { isLatestTransform } from '../../../common/types/transform'; import { isKeywordDuplicate } from '../../../common/utils/field_utils'; +import { transformHealthServiceProvider } from '../../lib/alerting/transform_health_rule_type/transform_health_service'; enum TRANSFORM_ACTIONS { STOP = 'stop', @@ -90,6 +91,17 @@ export function registerTransformsRoutes(routeDependencies: RouteDependencies) { size: 1000, ...req.params, }); + + if (ctx.alerting) { + const transformHealthService = transformHealthServiceProvider( + ctx.core.elasticsearch.client.asCurrentUser, + ctx.alerting.getRulesClient() + ); + + // @ts-ignore + await transformHealthService.populateTransformsWithAssignedRules(body.transforms); + } + return res.ok({ body }); } catch (e) { return res.customError(wrapError(wrapEsError(e))); diff --git a/x-pack/plugins/transform/server/services/license.ts b/x-pack/plugins/transform/server/services/license.ts index 978912ce08baf..ce28e0365bb21 100644 --- a/x-pack/plugins/transform/server/services/license.ts +++ b/x-pack/plugins/transform/server/services/license.ts @@ -15,6 +15,7 @@ import { } from 'kibana/server'; import { LicensingPluginSetup, LicenseType } from '../../../licensing/server'; +import type { AlertingApiRequestHandlerContext } from '../../../alerting/server'; export interface LicenseStatus { isValid: boolean; @@ -28,6 +29,10 @@ interface SetupSettings { defaultErrorMessage: string; } +type TransformRequestHandlerContext = RequestHandlerContext & { + alerting?: AlertingApiRequestHandlerContext; +}; + export class License { private licenseStatus: LicenseStatus = { isValid: false, @@ -64,7 +69,9 @@ export class License { }); } - guardApiRoute(handler: RequestHandler) { + guardApiRoute( + handler: RequestHandler + ) { const license = this; return function licenseCheck( diff --git a/x-pack/test/security_solution_endpoint/services/endpoint.ts b/x-pack/test/security_solution_endpoint/services/endpoint.ts index 2e774dcd84782..5bcc5c415a0db 100644 --- a/x-pack/test/security_solution_endpoint/services/endpoint.ts +++ b/x-pack/test/security_solution_endpoint/services/endpoint.ts @@ -18,7 +18,7 @@ import { IndexedHostsAndAlertsResponse, indexHostsAndAlerts, } from '../../../plugins/security_solution/common/endpoint/index_data'; -import { TransformPivotConfig } from '../../../plugins/transform/common/types/transform'; +import { TransformConfigUnion } from '../../../plugins/transform/common/types/transform'; import { GetTransformsResponseSchema } from '../../../plugins/transform/common/api_schemas/transforms'; import { catchAndWrapError } from '../../../plugins/security_solution/server/endpoint/utils'; import { installOrUpgradeEndpointFleetPackage } from '../../../plugins/security_solution/common/endpoint/data_loaders/setup_fleet_for_endpoint'; @@ -38,9 +38,9 @@ export class EndpointTestResources extends FtrService { * * @param [endpointPackageVersion] if set, it will be used to get the specific transform this this package version. Else just returns first one found */ - async getTransform(endpointPackageVersion?: string): Promise { + async getTransform(endpointPackageVersion?: string): Promise { const transformId = this.generateTransformId(endpointPackageVersion); - let transform: TransformPivotConfig | undefined; + let transform: TransformConfigUnion | undefined; if (endpointPackageVersion) { await this.transform.api.waitForTransformToExist(transformId);