From 828a3daaebe3b43ff5388d9e416c239c599e6525 Mon Sep 17 00:00:00 2001 From: Dominique Belcher Date: Tue, 10 Mar 2026 17:15:03 -0400 Subject: [PATCH] alerting v2 - add enable/disable and clone rule to rule list --- .../src/rule_data_schema.test.ts | 15 ++ .../src/rule_data_schema.ts | 1 + .../public/hooks/use_toggle_rule_enabled.ts | 43 ++++ .../rule_form_page/rule_form_page.test.tsx | 196 ++++++++++++++++++ .../pages/rule_form_page/rule_form_page.tsx | 99 +++++---- .../rules_list_page/rules_list_page.test.tsx | 96 +++++++++ .../pages/rules_list_page/rules_list_page.tsx | 67 +++++- .../server/lib/rules_client/utils.ts | 2 +- 8 files changed, 473 insertions(+), 46 deletions(-) create mode 100644 x-pack/platform/plugins/shared/alerting_v2/public/hooks/use_toggle_rule_enabled.ts diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/rule_data_schema.test.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/rule_data_schema.test.ts index a54888f1d3ce8..9c9c5a779c984 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/rule_data_schema.test.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/rule_data_schema.test.ts @@ -542,6 +542,21 @@ describe('updateRuleDataSchema', () => { expect(result).toEqual({ metadata: { name: 'updated name' } }); }); + it('accepts an enabled field set to true', () => { + const result = updateRuleDataSchema.parse({ enabled: true }); + expect(result).toEqual({ enabled: true }); + }); + + it('accepts an enabled field set to false', () => { + const result = updateRuleDataSchema.parse({ enabled: false }); + expect(result).toEqual({ enabled: false }); + }); + + it('omits enabled when not provided', () => { + const result = updateRuleDataSchema.parse({}); + expect(result).not.toHaveProperty('enabled'); + }); + it('accepts a state_transition object', () => { const result = updateRuleDataSchema.parse({ state_transition: { pending_count: 3, recovering_count: 5 }, diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/rule_data_schema.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/rule_data_schema.ts index bb1e32158f2d3..9a361df9afe0a 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/rule_data_schema.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/rule_data_schema.ts @@ -250,6 +250,7 @@ export const updateRuleDataSchema = z grouping: groupingSchema.optional().nullable(), no_data: noDataSchema.optional().nullable(), notification_policies: z.array(notificationPolicyRefSchema).optional().nullable(), + enabled: z.boolean().optional().describe('Whether the rule is enabled.'), }) .strip(); diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/hooks/use_toggle_rule_enabled.ts b/x-pack/platform/plugins/shared/alerting_v2/public/hooks/use_toggle_rule_enabled.ts new file mode 100644 index 0000000000000..fd1219426673a --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/hooks/use_toggle_rule_enabled.ts @@ -0,0 +1,43 @@ +/* + * 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 { useMutation, useQueryClient } from '@kbn/react-query'; +import { i18n } from '@kbn/i18n'; +import { useService, CoreStart } from '@kbn/core-di-browser'; +import { RulesApi } from '../services/rules_api'; +import { ruleKeys } from './query_key_factory'; + +export const useToggleRuleEnabled = () => { + const rulesApi = useService(RulesApi); + const { toasts } = useService(CoreStart('notifications')); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => + rulesApi.updateRule(id, { enabled }), + onSuccess: (_data, variables) => { + toasts.addSuccess( + variables.enabled + ? i18n.translate('xpack.alertingV2.hooks.useToggleRuleEnabled.enabledMessage', { + defaultMessage: 'Rule enabled', + }) + : i18n.translate('xpack.alertingV2.hooks.useToggleRuleEnabled.disabledMessage', { + defaultMessage: 'Rule disabled', + }) + ); + queryClient.invalidateQueries(ruleKeys.lists()); + queryClient.invalidateQueries(ruleKeys.detail(variables.id)); + }, + onError: () => { + toasts.addDanger( + i18n.translate('xpack.alertingV2.hooks.useToggleRuleEnabled.errorMessage', { + defaultMessage: 'Failed to update rule status', + }) + ); + }, + }); +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/pages/rule_form_page/rule_form_page.test.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/pages/rule_form_page/rule_form_page.test.tsx index c69a814da981d..814e6b2eae127 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/pages/rule_form_page/rule_form_page.test.tsx +++ b/x-pack/platform/plugins/shared/alerting_v2/public/pages/rule_form_page/rule_form_page.test.tsx @@ -87,6 +87,20 @@ const renderEditPage = (ruleId: string = 'rule-1') => { ); }; +const renderClonePage = (sourceRuleId: string = 'rule-1') => { + return render( + + + + + + + + + + ); +}; + describe('RuleFormPage', () => { beforeEach(() => { jest.clearAllMocks(); @@ -272,4 +286,186 @@ describe('RuleFormPage', () => { expect(capturedStandaloneProps.query).toBe('FROM metrics-* | LIMIT 10'); }); }); + + describe('clone mode', () => { + it('shows loading spinner while fetching source rule', () => { + mockUseFetchRule.mockReturnValue({ + data: undefined, + isLoading: true, + isError: false, + error: null, + }); + + renderClonePage(); + + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('shows error state when source rule fetch fails', () => { + mockUseFetchRule.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + error: new Error('Rule not found'), + }); + + renderClonePage(); + + expect(screen.getByText('Failed to load source rule for cloning')).toBeInTheDocument(); + expect(screen.getByText('Rule not found')).toBeInTheDocument(); + }); + + it('renders the "Clone rule" page title', () => { + mockUseFetchRule.mockReturnValue({ + data: { + id: 'rule-1', + kind: 'alert', + enabled: true, + metadata: { name: 'Source Rule' }, + time_field: '@timestamp', + schedule: { every: '1m', lookback: '5m' }, + evaluation: { query: { base: 'FROM logs-* | LIMIT 1' } }, + }, + isLoading: false, + isError: false, + error: null, + }); + + renderClonePage(); + + expect(screen.getByRole('heading', { name: 'Clone rule' })).toBeInTheDocument(); + }); + + it('does not pass ruleId to StandaloneRuleForm (creates a new rule)', () => { + mockUseFetchRule.mockReturnValue({ + data: { + id: 'rule-1', + kind: 'alert', + enabled: true, + metadata: { name: 'Source Rule' }, + time_field: '@timestamp', + schedule: { every: '1m', lookback: '5m' }, + evaluation: { query: { base: 'FROM logs-* | LIMIT 1' } }, + }, + isLoading: false, + isError: false, + error: null, + }); + + renderClonePage(); + + expect(capturedStandaloneProps.ruleId).toBeUndefined(); + }); + + it('appends " (clone)" to the rule name in initialValues', () => { + mockUseFetchRule.mockReturnValue({ + data: { + id: 'rule-1', + kind: 'alert', + enabled: true, + metadata: { name: 'My Alert Rule', labels: ['prod'], owner: 'team-a' }, + time_field: '@timestamp', + schedule: { every: '10m', lookback: '2m' }, + evaluation: { + query: { + base: 'FROM logs-* | STATS count() BY host.name', + condition: 'WHERE count > 5', + }, + }, + grouping: { fields: ['host.name'] }, + recovery_policy: { type: 'no_breach' }, + state_transition: { pending_count: 3, pending_timeframe: '5m' }, + }, + isLoading: false, + isError: false, + error: null, + }); + + renderClonePage(); + + const initialValues = capturedStandaloneProps.initialValues as Record; + expect(initialValues).toBeDefined(); + + const metadata = initialValues.metadata as Record; + expect(metadata.name).toBe('My Alert Rule (clone)'); + }); + + it('preserves all source rule configurations in initialValues', () => { + mockUseFetchRule.mockReturnValue({ + data: { + id: 'rule-1', + kind: 'alert', + enabled: true, + metadata: { name: 'My Alert Rule', labels: ['prod'], owner: 'team-a' }, + time_field: '@timestamp', + schedule: { every: '10m', lookback: '2m' }, + evaluation: { + query: { + base: 'FROM logs-* | STATS count() BY host.name', + condition: 'WHERE count > 5', + }, + }, + grouping: { fields: ['host.name'] }, + recovery_policy: { type: 'no_breach' }, + state_transition: { pending_count: 3, pending_timeframe: '5m' }, + }, + isLoading: false, + isError: false, + error: null, + }); + + renderClonePage(); + + const initialValues = capturedStandaloneProps.initialValues as Record; + + // Metadata (labels, owner preserved) + const metadata = initialValues.metadata as Record; + expect(metadata.labels).toEqual(['prod']); + expect(metadata.owner).toBe('team-a'); + + // Schedule + const schedule = initialValues.schedule as Record; + expect(schedule.every).toBe('10m'); + expect(schedule.lookback).toBe('2m'); + + // Evaluation + const evaluation = initialValues.evaluation as { query: Record }; + expect(evaluation.query.base).toBe('FROM logs-* | STATS count() BY host.name'); + expect(evaluation.query.condition).toBe('WHERE count > 5'); + + // Grouping + const grouping = initialValues.grouping as { fields: string[] }; + expect(grouping.fields).toEqual(['host.name']); + + // Recovery policy + const recoveryPolicy = initialValues.recoveryPolicy as Record; + expect(recoveryPolicy.type).toBe('no_breach'); + + // State transition + const stateTransition = initialValues.stateTransition as Record; + expect(stateTransition.pendingCount).toBe(3); + expect(stateTransition.pendingTimeframe).toBe('5m'); + }); + + it('passes the base query from the source rule as the query prop', () => { + mockUseFetchRule.mockReturnValue({ + data: { + id: 'rule-1', + kind: 'alert', + enabled: true, + metadata: { name: 'Test' }, + time_field: '@timestamp', + schedule: { every: '5m' }, + evaluation: { query: { base: 'FROM metrics-* | LIMIT 10' } }, + }, + isLoading: false, + isError: false, + error: null, + }); + + renderClonePage(); + + expect(capturedStandaloneProps.query).toBe('FROM metrics-* | LIMIT 10'); + }); + }); }); diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/pages/rule_form_page/rule_form_page.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/pages/rule_form_page/rule_form_page.tsx index 28fc4b6ab24db..9c51a2b1ee7eb 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/pages/rule_form_page/rule_form_page.tsx +++ b/x-pack/platform/plugins/shared/alerting_v2/public/pages/rule_form_page/rule_form_page.tsx @@ -12,42 +12,65 @@ import { PluginStart } from '@kbn/core-di'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import { FormattedMessage } from '@kbn/i18n-react'; -import { useParams } from 'react-router-dom'; +import { useParams, useLocation } from 'react-router-dom'; import { useQueryClient } from '@kbn/react-query'; import { StandaloneRuleForm, mapRuleResponseToFormValues } from '@kbn/alerting-v2-rule-form'; import type { FormValues } from '@kbn/alerting-v2-rule-form'; +import { i18n } from '@kbn/i18n'; import { useFetchRule } from '../../hooks/use_fetch_rule'; import { ruleKeys } from '../../hooks/query_key_factory'; import { paths } from '../../constants'; const DEFAULT_QUERY = 'FROM logs-*\n| LIMIT 1'; +const CLONE_NAME_SUFFIX = i18n.translate('xpack.alertingV2.ruleFormPage.cloneNameSuffix', { + defaultMessage: ' (clone)', +}); + export const RuleFormPage = () => { const { id: ruleId } = useParams<{ id?: string }>(); - const isEditing = Boolean(ruleId); + const { search } = useLocation(); + const cloneFromId = new URLSearchParams(search).get('cloneFrom'); - if (isEditing && ruleId) { - return ; + if (ruleId) { + return ; + } + + if (cloneFromId) { + return ; } return ; }; -const EditRuleFormPageContent = ({ ruleId }: { ruleId: string }) => { +interface FetchedRuleFormPageProps { + ruleId: string; + mode: 'edit' | 'clone'; +} + +const FetchedRuleFormPage = ({ ruleId, mode }: FetchedRuleFormPageProps) => { + const isClone = mode === 'clone'; const { data: rule, isLoading, isError, error } = useFetchRule(ruleId); if (isLoading) { return ; } - if (isError) { + if (isError || (!rule && !isLoading)) { return ( + isClone ? ( + + ) : ( + + ) } color="danger" iconType="error" @@ -58,16 +81,19 @@ const EditRuleFormPageContent = ({ ruleId }: { ruleId: string }) => { ); } - if (!rule) { - return null; - } - const initialQuery = rule.evaluation?.query?.base ?? DEFAULT_QUERY; const initialValues = mapRuleResponseToFormValues(rule); + if (isClone && initialValues.metadata) { + initialValues.metadata = { + ...initialValues.metadata, + name: `${initialValues.metadata.name}${CLONE_NAME_SUFFIX}`, + }; + } + return ( @@ -103,7 +129,6 @@ const RuleFormPageContent = ({ ruleId, initialQuery, initialValues }: RuleFormPa ); const onSuccess = useCallback(() => { - // Invalidate cached rule data so the next edit fetches fresh data queryClient.invalidateQueries(ruleKeys.lists()); if (ruleId) { queryClient.invalidateQueries(ruleKeys.detail(ruleId)); @@ -115,23 +140,21 @@ const RuleFormPageContent = ({ ruleId, initialQuery, initialValues }: RuleFormPa navigateToUrl(basePath.prepend(paths.ruleList)); }; + const pageTitle = isEditing ? ( + + ) : ( + + ); + + const submitLabel = isEditing ? ( + + ) : ( + + ); + return ( <> - - ) : ( - - ) - } - /> + - ) : ( - - ) - } + submitLabel={submitLabel} /> ); diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_page.test.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_page.test.tsx index 525b046520b43..abc0a56f4bfec 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_page.test.tsx +++ b/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_page.test.tsx @@ -38,6 +38,12 @@ jest.mock('../../hooks/use_delete_rule', () => ({ useDeleteRule: () => mockUseDeleteRule(), })); +const mockToggleEnabledMutate = jest.fn(); +const mockUseToggleRuleEnabled = jest.fn(); +jest.mock('../../hooks/use_toggle_rule_enabled', () => ({ + useToggleRuleEnabled: () => mockUseToggleRuleEnabled(), +})); + const mockRules = [ { id: 'rule-1', @@ -81,6 +87,10 @@ describe('RulesListPage', () => { mutate: mockDeleteMutate, isLoading: false, }); + mockUseToggleRuleEnabled.mockReturnValue({ + mutate: mockToggleEnabledMutate, + isLoading: false, + }); }); it('renders loading state', () => { @@ -235,4 +245,90 @@ describe('RulesListPage', () => { }) ); }); + + it('renders the Status column with Enabled and Disabled badges', () => { + mockUseFetchRules.mockReturnValue({ + data: { items: mockRules, total: 2, page: 1, perPage: 20 }, + isLoading: false, + isError: false, + error: null, + }); + + renderPage(); + + expect(screen.getByText('Enabled')).toBeInTheDocument(); + expect(screen.getByText('Disabled')).toBeInTheDocument(); + }); + + it('shows "Disable" action for enabled rules and "Enable" for disabled rules', async () => { + mockUseFetchRules.mockReturnValue({ + data: { items: mockRules, total: 2, page: 1, perPage: 20 }, + isLoading: false, + isError: false, + error: null, + }); + + renderPage(); + + // Open the context menu for the enabled rule (rule-1) + fireEvent.click(screen.getByTestId('ruleActionsButton-rule-1')); + + await waitFor(() => { + expect(screen.getByTestId('toggleEnabledRule-rule-1')).toHaveTextContent('Disable'); + }); + }); + + it('calls toggleEnabledMutation when toggle action is clicked', async () => { + mockUseFetchRules.mockReturnValue({ + data: { items: mockRules, total: 2, page: 1, perPage: 20 }, + isLoading: false, + isError: false, + error: null, + }); + + renderPage(); + + // Open the context menu for the enabled rule (rule-1) + fireEvent.click(screen.getByTestId('ruleActionsButton-rule-1')); + + // Click the toggle action — should disable the enabled rule + fireEvent.click(screen.getByTestId('toggleEnabledRule-rule-1')); + + expect(mockToggleEnabledMutate).toHaveBeenCalledWith({ id: 'rule-1', enabled: false }); + }); + + it('shows "Clone" action in the context menu', async () => { + mockUseFetchRules.mockReturnValue({ + data: { items: mockRules, total: 2, page: 1, perPage: 20 }, + isLoading: false, + isError: false, + error: null, + }); + + renderPage(); + + fireEvent.click(screen.getByTestId('ruleActionsButton-rule-1')); + + await waitFor(() => { + expect(screen.getByTestId('cloneRule-rule-1')).toHaveTextContent('Clone'); + }); + }); + + it('navigates to the create page with cloneFrom query param when clone is clicked', async () => { + mockUseFetchRules.mockReturnValue({ + data: { items: mockRules, total: 2, page: 1, perPage: 20 }, + isLoading: false, + isError: false, + error: null, + }); + + renderPage(); + + fireEvent.click(screen.getByTestId('ruleActionsButton-rule-1')); + fireEvent.click(screen.getByTestId('cloneRule-rule-1')); + + expect(mockNavigateToUrl).toHaveBeenCalledWith( + '/app/management/insightsAndAlerting/alerting_v2/create?cloneFrom=rule-1' + ); + }); }); diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_page.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_page.tsx index f4e5ac4cd20f4..8b42393c19019 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_page.tsx +++ b/x-pack/platform/plugins/shared/alerting_v2/public/pages/rules_list_page/rules_list_page.tsx @@ -32,6 +32,7 @@ import { getIndexPatternFromESQLQuery } from '@kbn/esql-utils'; import type { RuleApiResponse } from '../../services/rules_api'; import { useFetchRules } from '../../hooks/use_fetch_rules'; import { useDeleteRule } from '../../hooks/use_delete_rule'; +import { useToggleRuleEnabled } from '../../hooks/use_toggle_rule_enabled'; import { DeleteConfirmationModal } from '../../components/rule/delete_confirmation_modal'; import { paths } from '../../constants'; @@ -40,10 +41,18 @@ const DEFAULT_PER_PAGE = 20; interface RuleActionsMenuProps { rule: RuleApiResponse; onEdit: (rule: RuleApiResponse) => void; + onClone: (rule: RuleApiResponse) => void; onDelete: (rule: RuleApiResponse) => void; + onToggleEnabled: (rule: RuleApiResponse) => void; } -const RuleActionsMenu = ({ rule, onEdit, onDelete }: RuleActionsMenuProps) => { +const RuleActionsMenu = ({ + rule, + onEdit, + onClone, + onDelete, + onToggleEnabled, +}: RuleActionsMenuProps) => { const [isOpen, setIsOpen] = useState(false); const menuItems = [ @@ -58,6 +67,34 @@ const RuleActionsMenu = ({ rule, onEdit, onDelete }: RuleActionsMenuProps) => { > {i18n.translate('xpack.alertingV2.rulesList.action.edit', { defaultMessage: 'Edit' })} , + } + onClick={() => { + setIsOpen(false); + onClone(rule); + }} + data-test-subj={`cloneRule-${rule.id}`} + > + {i18n.translate('xpack.alertingV2.rulesList.action.clone', { defaultMessage: 'Clone' })} + , + } + onClick={() => { + setIsOpen(false); + onToggleEnabled(rule); + }} + data-test-subj={`toggleEnabledRule-${rule.id}`} + > + {rule.enabled + ? i18n.translate('xpack.alertingV2.rulesList.action.disable', { + defaultMessage: 'Disable', + }) + : i18n.translate('xpack.alertingV2.rulesList.action.enable', { + defaultMessage: 'Enable', + })} + , } @@ -106,6 +143,7 @@ export const RulesListPage = () => { const { data, isLoading, isError, error } = useFetchRules({ page, perPage }); const deleteRuleMutation = useDeleteRule(); + const toggleEnabledMutation = useToggleRuleEnabled(); const onTableChange = ({ page: tablePage }: CriteriaWithPagination) => { setPage(tablePage.index + 1); @@ -200,6 +238,27 @@ export const RulesListPage = () => { ), }, + { + field: 'enabled', + name: ( + + ), + width: '10%', + render: (enabled: boolean) => + enabled ? ( + + {i18n.translate('xpack.alertingV2.rulesList.statusEnabled', { + defaultMessage: 'Enabled', + })} + + ) : ( + + {i18n.translate('xpack.alertingV2.rulesList.statusDisabled', { + defaultMessage: 'Disabled', + })} + + ), + }, { name: ( @@ -210,7 +269,13 @@ export const RulesListPage = () => { navigateToUrl(basePath.prepend(paths.ruleEdit(r.id)))} + onClone={(r) => + navigateToUrl( + basePath.prepend(`${paths.ruleCreate}?cloneFrom=${encodeURIComponent(r.id)}`) + ) + } onDelete={(r) => setRuleToDelete(r)} + onToggleEnabled={(r) => toggleEnabledMutation.mutate({ id: r.id, enabled: !r.enabled })} /> ), }, diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/utils.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/utils.ts index 5171c0b8c3e04..eb3cc1a83c0b3 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/utils.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/utils.ts @@ -100,8 +100,8 @@ export function buildUpdateRuleAttributes( updateData.notification_policies, existingAttrs.notification_policies ), + enabled: updateData.enabled ?? existingAttrs.enabled, // Server-managed fields — preserved as-is except timestamps and user. - enabled: existingAttrs.enabled, createdBy: existingAttrs.createdBy, createdAt: existingAttrs.createdAt, ...serverFields,