diff --git a/x-pack/plugins/alerting/public/hooks/use_archive_maintenance_window.test.tsx b/x-pack/plugins/alerting/public/hooks/use_archive_maintenance_window.test.tsx new file mode 100644 index 0000000000000..e6bd2a4071b27 --- /dev/null +++ b/x-pack/plugins/alerting/public/hooks/use_archive_maintenance_window.test.tsx @@ -0,0 +1,116 @@ +/* + * 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 { act, renderHook } from '@testing-library/react-hooks/dom'; +import { waitFor } from '@testing-library/dom'; + +import { MaintenanceWindow } from '../pages/maintenance_windows/types'; +import { AppMockRenderer, createAppMockRenderer } from '../lib/test_utils'; +import { useArchiveMaintenanceWindow } from './use_archive_maintenance_window'; + +const mockAddDanger = jest.fn(); +const mockAddSuccess = jest.fn(); + +jest.mock('../utils/kibana_react', () => { + const originalModule = jest.requireActual('../utils/kibana_react'); + return { + ...originalModule, + useKibana: () => { + const { services } = originalModule.useKibana(); + return { + services: { + ...services, + notifications: { toasts: { addSuccess: mockAddSuccess, addDanger: mockAddDanger } }, + }, + }; + }, + }; +}); +jest.mock('../services/maintenance_windows_api/archive', () => ({ + archiveMaintenanceWindow: jest.fn(), +})); + +const { archiveMaintenanceWindow } = jest.requireMock( + '../services/maintenance_windows_api/archive' +); + +const maintenanceWindow: MaintenanceWindow = { + title: 'archive', + duration: 1, + rRule: { + dtstart: '2023-03-23T19:16:21.293Z', + tzid: 'America/New_York', + }, +}; + +let appMockRenderer: AppMockRenderer; + +describe('useArchiveMaintenanceWindow', () => { + beforeEach(() => { + jest.clearAllMocks(); + + appMockRenderer = createAppMockRenderer(); + archiveMaintenanceWindow.mockResolvedValue(maintenanceWindow); + }); + + it('should call onSuccess if api succeeds', async () => { + const { result } = renderHook(() => useArchiveMaintenanceWindow(), { + wrapper: appMockRenderer.AppWrapper, + }); + + await act(async () => { + await result.current.mutate({ maintenanceWindowId: '123', archive: true }); + }); + await waitFor(() => + expect(mockAddSuccess).toBeCalledWith("Archived maintenance window 'archive'") + ); + }); + + it('should call onError if api fails', async () => { + archiveMaintenanceWindow.mockRejectedValue(''); + + const { result } = renderHook(() => useArchiveMaintenanceWindow(), { + wrapper: appMockRenderer.AppWrapper, + }); + + await act(async () => { + await result.current.mutate({ maintenanceWindowId: '123', archive: true }); + }); + + await waitFor(() => + expect(mockAddDanger).toBeCalledWith('Failed to archive maintenance window.') + ); + }); + + it('should call onSuccess if api succeeds (unarchive)', async () => { + const { result } = renderHook(() => useArchiveMaintenanceWindow(), { + wrapper: appMockRenderer.AppWrapper, + }); + + await act(async () => { + await result.current.mutate({ maintenanceWindowId: '123', archive: false }); + }); + await waitFor(() => + expect(mockAddSuccess).toBeCalledWith("Unarchived maintenance window 'archive'") + ); + }); + + it('should call onError if api fails (unarchive)', async () => { + archiveMaintenanceWindow.mockRejectedValue(''); + + const { result } = renderHook(() => useArchiveMaintenanceWindow(), { + wrapper: appMockRenderer.AppWrapper, + }); + + await act(async () => { + await result.current.mutate({ maintenanceWindowId: '123', archive: false }); + }); + + await waitFor(() => + expect(mockAddDanger).toBeCalledWith('Failed to unarchive maintenance window.') + ); + }); +}); diff --git a/x-pack/plugins/alerting/public/hooks/use_archive_maintenance_window.ts b/x-pack/plugins/alerting/public/hooks/use_archive_maintenance_window.ts new file mode 100644 index 0000000000000..2bda74f83b9bf --- /dev/null +++ b/x-pack/plugins/alerting/public/hooks/use_archive_maintenance_window.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { useMutation } from '@tanstack/react-query'; + +import { useKibana } from '../utils/kibana_react'; +import { archiveMaintenanceWindow } from '../services/maintenance_windows_api/archive'; + +export function useArchiveMaintenanceWindow() { + const { + http, + notifications: { toasts }, + } = useKibana().services; + + const mutationFn = ({ + maintenanceWindowId, + archive, + }: { + maintenanceWindowId: string; + archive: boolean; + }) => { + return archiveMaintenanceWindow({ http, maintenanceWindowId, archive }); + }; + + return useMutation(mutationFn, { + onSuccess: (data, { archive }) => { + const archiveToast = i18n.translate('xpack.alerting.maintenanceWindowsArchiveSuccess', { + defaultMessage: "Archived maintenance window '{title}'", + values: { + title: data.title, + }, + }); + const unarchiveToast = i18n.translate('xpack.alerting.maintenanceWindowsUnarchiveSuccess', { + defaultMessage: "Unarchived maintenance window '{title}'", + values: { + title: data.title, + }, + }); + toasts.addSuccess(archive ? archiveToast : unarchiveToast); + }, + onError: (error, { archive }) => { + const archiveToast = i18n.translate('xpack.alerting.maintenanceWindowsArchiveFailure', { + defaultMessage: 'Failed to archive maintenance window.', + }); + const unarchiveToast = i18n.translate('xpack.alerting.maintenanceWindowsUnarchiveFailure', { + defaultMessage: 'Failed to unarchive maintenance window.', + }); + toasts.addDanger(archive ? archiveToast : unarchiveToast); + }, + }); +} diff --git a/x-pack/plugins/alerting/public/hooks/use_create_maintenance_window.ts b/x-pack/plugins/alerting/public/hooks/use_create_maintenance_window.ts index 08c01bb080055..e710595bc6180 100644 --- a/x-pack/plugins/alerting/public/hooks/use_create_maintenance_window.ts +++ b/x-pack/plugins/alerting/public/hooks/use_create_maintenance_window.ts @@ -23,12 +23,12 @@ export function useCreateMaintenanceWindow() { }; return useMutation(mutationFn, { - onSuccess: (variables: MaintenanceWindow) => { + onSuccess: (data) => { toasts.addSuccess( i18n.translate('xpack.alerting.maintenanceWindowsCreateSuccess', { defaultMessage: "Created maintenance window '{title}'", values: { - title: variables.title, + title: data.title, }, }) ); diff --git a/x-pack/plugins/alerting/public/hooks/use_find_maintenance_windows.ts b/x-pack/plugins/alerting/public/hooks/use_find_maintenance_windows.ts index 6a71bd9c64518..10b7f3402aca1 100644 --- a/x-pack/plugins/alerting/public/hooks/use_find_maintenance_windows.ts +++ b/x-pack/plugins/alerting/public/hooks/use_find_maintenance_windows.ts @@ -30,7 +30,11 @@ export const useFindMaintenanceWindows = () => { } }; - const { isLoading, data = [] } = useQuery({ + const { + isLoading, + data = [], + refetch, + } = useQuery({ queryKey: ['findMaintenanceWindows'], queryFn, onError: onErrorFn, @@ -42,5 +46,6 @@ export const useFindMaintenanceWindows = () => { return { maintenanceWindows: data, isLoading, + refetch, }; }; diff --git a/x-pack/plugins/alerting/public/hooks/use_finish_and_archive_maintenance_window.test.tsx b/x-pack/plugins/alerting/public/hooks/use_finish_and_archive_maintenance_window.test.tsx new file mode 100644 index 0000000000000..b80dbbae355bc --- /dev/null +++ b/x-pack/plugins/alerting/public/hooks/use_finish_and_archive_maintenance_window.test.tsx @@ -0,0 +1,110 @@ +/* + * 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 { act, renderHook } from '@testing-library/react-hooks/dom'; +import { waitFor } from '@testing-library/dom'; + +import { MaintenanceWindow } from '../pages/maintenance_windows/types'; +import { AppMockRenderer, createAppMockRenderer } from '../lib/test_utils'; +import { useFinishAndArchiveMaintenanceWindow } from './use_finish_and_archive_maintenance_window'; + +const mockAddDanger = jest.fn(); +const mockAddSuccess = jest.fn(); + +jest.mock('../utils/kibana_react', () => { + const originalModule = jest.requireActual('../utils/kibana_react'); + return { + ...originalModule, + useKibana: () => { + const { services } = originalModule.useKibana(); + return { + services: { + ...services, + notifications: { toasts: { addSuccess: mockAddSuccess, addDanger: mockAddDanger } }, + }, + }; + }, + }; +}); +jest.mock('../services/maintenance_windows_api/finish', () => ({ + finishMaintenanceWindow: jest.fn(), +})); +jest.mock('../services/maintenance_windows_api/archive', () => ({ + archiveMaintenanceWindow: jest.fn(), +})); + +const { finishMaintenanceWindow } = jest.requireMock('../services/maintenance_windows_api/finish'); +const { archiveMaintenanceWindow } = jest.requireMock( + '../services/maintenance_windows_api/archive' +); + +const maintenanceWindow: MaintenanceWindow = { + title: 'test', + duration: 1, + rRule: { + dtstart: '2023-03-23T19:16:21.293Z', + tzid: 'America/New_York', + }, +}; + +let appMockRenderer: AppMockRenderer; + +describe('useFinishAndArchiveMaintenanceWindow', () => { + beforeEach(() => { + jest.clearAllMocks(); + + appMockRenderer = createAppMockRenderer(); + finishMaintenanceWindow.mockResolvedValue(maintenanceWindow); + archiveMaintenanceWindow.mockResolvedValue(maintenanceWindow); + }); + + it('should call onSuccess if api succeeds', async () => { + const { result } = renderHook(() => useFinishAndArchiveMaintenanceWindow(), { + wrapper: appMockRenderer.AppWrapper, + }); + + await act(async () => { + await result.current.mutate('123'); + }); + await waitFor(() => + expect(mockAddSuccess).toBeCalledWith( + "Cancelled and archived running maintenance window 'test'" + ) + ); + }); + + it('should call onError if finish api fails', async () => { + finishMaintenanceWindow.mockRejectedValue(''); + + const { result } = renderHook(() => useFinishAndArchiveMaintenanceWindow(), { + wrapper: appMockRenderer.AppWrapper, + }); + + await act(async () => { + await result.current.mutate('123'); + }); + + await waitFor(() => + expect(mockAddDanger).toBeCalledWith('Failed to cancel and archive maintenance window.') + ); + }); + + it('should call onError if archive api fails', async () => { + archiveMaintenanceWindow.mockRejectedValue(''); + + const { result } = renderHook(() => useFinishAndArchiveMaintenanceWindow(), { + wrapper: appMockRenderer.AppWrapper, + }); + + await act(async () => { + await result.current.mutate('123'); + }); + + await waitFor(() => + expect(mockAddDanger).toBeCalledWith('Failed to cancel and archive maintenance window.') + ); + }); +}); diff --git a/x-pack/plugins/alerting/public/hooks/use_finish_and_archive_maintenance_window.ts b/x-pack/plugins/alerting/public/hooks/use_finish_and_archive_maintenance_window.ts new file mode 100644 index 0000000000000..d68bf2c89e379 --- /dev/null +++ b/x-pack/plugins/alerting/public/hooks/use_finish_and_archive_maintenance_window.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { useMutation } from '@tanstack/react-query'; + +import { useKibana } from '../utils/kibana_react'; +import { finishMaintenanceWindow } from '../services/maintenance_windows_api/finish'; +import { archiveMaintenanceWindow } from '../services/maintenance_windows_api/archive'; + +export function useFinishAndArchiveMaintenanceWindow() { + const { + http, + notifications: { toasts }, + } = useKibana().services; + + const mutationFn = async (maintenanceWindowId: string) => { + await finishMaintenanceWindow({ http, maintenanceWindowId }); + return archiveMaintenanceWindow({ http, maintenanceWindowId, archive: true }); + }; + + return useMutation(mutationFn, { + onSuccess: (data) => { + toasts.addSuccess( + i18n.translate('xpack.alerting.maintenanceWindowsFinishedAndArchiveSuccess', { + defaultMessage: "Cancelled and archived running maintenance window '{title}'", + values: { + title: data.title, + }, + }) + ); + }, + onError: () => { + toasts.addDanger( + i18n.translate('xpack.alerting.maintenanceWindowsFinishedAndArchiveFailure', { + defaultMessage: 'Failed to cancel and archive maintenance window.', + }) + ); + }, + }); +} diff --git a/x-pack/plugins/alerting/public/hooks/use_finish_maintenance_window.test.tsx b/x-pack/plugins/alerting/public/hooks/use_finish_maintenance_window.test.tsx new file mode 100644 index 0000000000000..ed534cb835c8d --- /dev/null +++ b/x-pack/plugins/alerting/public/hooks/use_finish_maintenance_window.test.tsx @@ -0,0 +1,85 @@ +/* + * 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 { act, renderHook } from '@testing-library/react-hooks/dom'; +import { waitFor } from '@testing-library/dom'; + +import { MaintenanceWindow } from '../pages/maintenance_windows/types'; +import { AppMockRenderer, createAppMockRenderer } from '../lib/test_utils'; +import { useFinishMaintenanceWindow } from './use_finish_maintenance_window'; + +const mockAddDanger = jest.fn(); +const mockAddSuccess = jest.fn(); + +jest.mock('../utils/kibana_react', () => { + const originalModule = jest.requireActual('../utils/kibana_react'); + return { + ...originalModule, + useKibana: () => { + const { services } = originalModule.useKibana(); + return { + services: { + ...services, + notifications: { toasts: { addSuccess: mockAddSuccess, addDanger: mockAddDanger } }, + }, + }; + }, + }; +}); +jest.mock('../services/maintenance_windows_api/finish', () => ({ + finishMaintenanceWindow: jest.fn(), +})); + +const { finishMaintenanceWindow } = jest.requireMock('../services/maintenance_windows_api/finish'); + +const maintenanceWindow: MaintenanceWindow = { + title: 'cancel', + duration: 1, + rRule: { + dtstart: '2023-03-23T19:16:21.293Z', + tzid: 'America/New_York', + }, +}; + +let appMockRenderer: AppMockRenderer; + +describe('useFinishMaintenanceWindow', () => { + beforeEach(() => { + jest.clearAllMocks(); + + appMockRenderer = createAppMockRenderer(); + finishMaintenanceWindow.mockResolvedValue(maintenanceWindow); + }); + + it('should call onSuccess if api succeeds', async () => { + const { result } = renderHook(() => useFinishMaintenanceWindow(), { + wrapper: appMockRenderer.AppWrapper, + }); + + await act(async () => { + await result.current.mutate('123'); + }); + await waitFor(() => + expect(mockAddSuccess).toBeCalledWith("Cancelled running maintenance window 'cancel'") + ); + }); + + it('should call onError if api fails', async () => { + finishMaintenanceWindow.mockRejectedValue(''); + + const { result } = renderHook(() => useFinishMaintenanceWindow(), { + wrapper: appMockRenderer.AppWrapper, + }); + + await act(async () => { + await result.current.mutate('123'); + }); + + await waitFor(() => + expect(mockAddDanger).toBeCalledWith('Failed to cancel maintenance window.') + ); + }); +}); diff --git a/x-pack/plugins/alerting/public/hooks/use_finish_maintenance_window.ts b/x-pack/plugins/alerting/public/hooks/use_finish_maintenance_window.ts new file mode 100644 index 0000000000000..7e8aafa1793ad --- /dev/null +++ b/x-pack/plugins/alerting/public/hooks/use_finish_maintenance_window.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 { i18n } from '@kbn/i18n'; +import { useMutation } from '@tanstack/react-query'; + +import { useKibana } from '../utils/kibana_react'; +import { finishMaintenanceWindow } from '../services/maintenance_windows_api/finish'; + +export function useFinishMaintenanceWindow() { + const { + http, + notifications: { toasts }, + } = useKibana().services; + + const mutationFn = (maintenanceWindowId: string) => { + return finishMaintenanceWindow({ http, maintenanceWindowId }); + }; + + return useMutation(mutationFn, { + onSuccess: (data) => { + toasts.addSuccess( + i18n.translate('xpack.alerting.maintenanceWindowsFinishedSuccess', { + defaultMessage: "Cancelled running maintenance window '{title}'", + values: { + title: data.title, + }, + }) + ); + }, + onError: () => { + toasts.addDanger( + i18n.translate('xpack.alerting.maintenanceWindowsFinishedFailure', { + defaultMessage: 'Failed to cancel maintenance window.', + }) + ); + }, + }); +} diff --git a/x-pack/plugins/alerting/public/hooks/use_update_maintenance_window.test.tsx b/x-pack/plugins/alerting/public/hooks/use_update_maintenance_window.test.tsx index 67545d83aba17..897b44295d8c0 100644 --- a/x-pack/plugins/alerting/public/hooks/use_update_maintenance_window.test.tsx +++ b/x-pack/plugins/alerting/public/hooks/use_update_maintenance_window.test.tsx @@ -79,7 +79,7 @@ describe('useUpdateMaintenanceWindow', () => { }); await waitFor(() => - expect(mockAddDanger).toBeCalledWith("Failed to update maintenance window '123'") + expect(mockAddDanger).toBeCalledWith('Failed to update maintenance window.') ); }); }); diff --git a/x-pack/plugins/alerting/public/hooks/use_update_maintenance_window.ts b/x-pack/plugins/alerting/public/hooks/use_update_maintenance_window.ts index de6596b1c766d..c7dd73724b6df 100644 --- a/x-pack/plugins/alerting/public/hooks/use_update_maintenance_window.ts +++ b/x-pack/plugins/alerting/public/hooks/use_update_maintenance_window.ts @@ -39,13 +39,10 @@ export function useUpdateMaintenanceWindow() { }) ); }, - onError: (error, variables) => { + onError: () => { toasts.addDanger( i18n.translate('xpack.alerting.maintenanceWindowsUpdateFailure', { - defaultMessage: "Failed to update maintenance window '{id}'", - values: { - id: variables.maintenanceWindowId, - }, + defaultMessage: 'Failed to update maintenance window.', }) ); }, diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/create_maintenance_windows_form.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/create_maintenance_windows_form.tsx index b3fe479c21a88..0dda6a8890529 100644 --- a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/create_maintenance_windows_form.tsx +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/create_maintenance_windows_form.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import moment from 'moment'; import { FIELD_TYPES, @@ -16,7 +16,10 @@ import { } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { Field } from '@kbn/es-ui-shared-plugin/static/forms/components'; import { + EuiButton, EuiButtonEmpty, + EuiCallOut, + EuiConfirmModal, EuiFlexGroup, EuiFlexItem, EuiFormLabel, @@ -33,6 +36,7 @@ import { useCreateMaintenanceWindow } from '../../../hooks/use_create_maintenanc import { useUpdateMaintenanceWindow } from '../../../hooks/use_update_maintenance_window'; import { useUiSetting } from '../../../utils/kibana_react'; import { DatePickerRangeField } from './fields/date_picker_range_field'; +import { useArchiveMaintenanceWindow } from '../../../hooks/use_archive_maintenance_window'; const UseField = getUseField({ component: Field }); @@ -56,6 +60,7 @@ export const CreateMaintenanceWindowForm = React.memo { const [defaultStartDateValue] = useState(moment().toISOString()); const [defaultEndDateValue] = useState(moment().add(30, 'minutes').toISOString()); + const [isModalVisible, setIsModalVisible] = useState(false); const { defaultTimezone, isBrowser } = useDefaultTimezone(); const isEditMode = initialValue !== undefined && maintenanceWindowId !== undefined; @@ -63,6 +68,7 @@ export const CreateMaintenanceWindowForm = React.memo { @@ -109,6 +115,35 @@ export const CreateMaintenanceWindowForm = React.memo setIsModalVisible(false), []); + const showModal = useCallback(() => setIsModalVisible(true), []); + + const modal = useMemo(() => { + let m; + if (isModalVisible) { + m = ( + { + closeModal(); + archiveMaintenanceWindow( + { maintenanceWindowId: maintenanceWindowId!, archive: true }, + { onSuccess } + ); + }} + cancelButtonText={i18n.CANCEL} + confirmButtonText={i18n.ARCHIVE_TITLE} + defaultFocusedButton="confirm" + buttonColor="danger" + > +

{i18n.ARCHIVE_CALLOUT_SUBTITLE}

+
+ ); + } + return m; + }, [closeModal, archiveMaintenanceWindow, isModalVisible, maintenanceWindowId, onSuccess]); + return (
@@ -192,6 +227,15 @@ export const CreateMaintenanceWindowForm = React.memo : null} + {isEditMode ? ( + +

{i18n.ARCHIVE_SUBTITLE}

+ + {i18n.ARCHIVE} + + {modal} +
+ ) : null} { }); test('it renders', () => { - const result = appMockRenderer.render(); + const result = appMockRenderer.render( + {}} loading={false} items={items} /> + ); expect(result.getAllByTestId('list-item')).toHaveLength(items.length); diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/maintenance_windows_list.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/maintenance_windows_list.tsx index 9d4fc521c3f66..705219b9baa2a 100644 --- a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/maintenance_windows_list.tsx +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/maintenance_windows_list.tsx @@ -5,29 +5,34 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { formatDate, EuiInMemoryTable, EuiBasicTableColumn, - EuiButton, - useEuiBackgroundColor, EuiFlexGroup, EuiFlexItem, SearchFilterConfig, + EuiBadge, + useEuiTheme, } from '@elastic/eui'; import { css } from '@emotion/react'; import { MaintenanceWindowFindResponse, SortDirection } from '../types'; import * as i18n from '../translations'; import { useEditMaintenanceWindowsNavigation } from '../../../hooks/use_navigation'; +import { STATUS_DISPLAY, STATUS_SORT } from '../constants'; import { UpcomingEventsPopover } from './upcoming_events_popover'; -import { StatusColor, STATUS_DISPLAY, STATUS_SORT } from '../constants'; import { MaintenanceWindowStatus } from '../../../../common'; import { StatusFilter } from './status_filter'; +import { TableActionsPopover } from './table_actions_popover'; +import { useFinishMaintenanceWindow } from '../../../hooks/use_finish_maintenance_window'; +import { useArchiveMaintenanceWindow } from '../../../hooks/use_archive_maintenance_window'; +import { useFinishAndArchiveMaintenanceWindow } from '../../../hooks/use_finish_and_archive_maintenance_window'; interface MaintenanceWindowsListProps { loading: boolean; items: MaintenanceWindowFindResponse[]; + refreshData: () => void; } const columns: Array> = [ @@ -39,23 +44,9 @@ const columns: Array> = [ { field: 'status', name: i18n.TABLE_STATUS, - render: (status: string) => { + render: (status: MaintenanceWindowStatus) => { return ( - {}} - > - {STATUS_DISPLAY[status].label} - + {STATUS_DISPLAY[status].label} ); }, sortable: ({ status }) => STATUS_SORT[status], @@ -108,38 +99,61 @@ const search: { filters: SearchFilterConfig[] } = { }; export const MaintenanceWindowsList = React.memo( - ({ loading, items }) => { + ({ loading, items, refreshData }) => { + const { euiTheme } = useEuiTheme(); const { navigateToEditMaintenanceWindows } = useEditMaintenanceWindowsNavigation(); - const warningBackgroundColor = useEuiBackgroundColor('warning'); - const subduedBackgroundColor = useEuiBackgroundColor('subdued'); + const onEdit = useCallback( + (id) => navigateToEditMaintenanceWindows(id), + [navigateToEditMaintenanceWindows] + ); + const { mutate: finishMaintenanceWindow, isLoading: isLoadingFinish } = + useFinishMaintenanceWindow(); + const onCancel = useCallback( + (id) => finishMaintenanceWindow(id, { onSuccess: () => refreshData() }), + [finishMaintenanceWindow, refreshData] + ); + const { mutate: archiveMaintenanceWindow, isLoading: isLoadingArchive } = + useArchiveMaintenanceWindow(); + const onArchive = useCallback( + (id: string, archive: boolean) => + archiveMaintenanceWindow( + { maintenanceWindowId: id, archive }, + { onSuccess: () => refreshData() } + ), + [archiveMaintenanceWindow, refreshData] + ); + const { mutate: finishAndArchiveMaintenanceWindow, isLoading: isLoadingFinishAndArchive } = + useFinishAndArchiveMaintenanceWindow(); + const onCancelAndArchive = useCallback( + (id: string) => finishAndArchiveMaintenanceWindow(id, { onSuccess: () => refreshData() }), + [finishAndArchiveMaintenanceWindow, refreshData] + ); + const tableCss = useMemo(() => { return css` .euiTableRow { &.running { - background-color: ${warningBackgroundColor}; - } - - &.archived { - background-color: ${subduedBackgroundColor}; + background-color: ${euiTheme.colors.highlight}; } } `; - }, [warningBackgroundColor, subduedBackgroundColor]); + }, [euiTheme.colors.highlight]); const actions: Array> = [ { name: '', - actions: [ - { - name: i18n.TABLE_ACTION_EDIT, - isPrimary: true, - description: 'Edit maintenance window', - icon: 'pencil', - type: 'icon', - onClick: (mw: MaintenanceWindowFindResponse) => navigateToEditMaintenanceWindows(mw.id), - 'data-test-subj': 'action-edit', - }, - ], + render: ({ status, id }: { status: MaintenanceWindowStatus; id: string }) => { + return ( + + ); + }, }, ]; @@ -147,7 +161,7 @@ export const MaintenanceWindowsList = React.memo( { + let appMockRenderer: AppMockRenderer; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRenderer = createAppMockRenderer(); + }); + + test('it renders', () => { + const result = appMockRenderer.render( + {}} + onCancel={() => {}} + onArchive={() => {}} + onCancelAndArchive={() => {}} + /> + ); + + expect(result.getByTestId('table-actions-icon-button')).toBeInTheDocument(); + }); + + test('it shows the correct actions when a maintenance window is running', () => { + const result = appMockRenderer.render( + {}} + onCancel={() => {}} + onArchive={() => {}} + onCancelAndArchive={() => {}} + /> + ); + fireEvent.click(result.getByTestId('table-actions-icon-button')); + expect(result.getByTestId('table-actions-edit')).toBeInTheDocument(); + expect(result.getByTestId('table-actions-cancel')).toBeInTheDocument(); + expect(result.getByTestId('table-actions-cancel-and-archive')).toBeInTheDocument(); + }); + + test('it shows the correct actions when a maintenance window is upcoming', () => { + const result = appMockRenderer.render( + {}} + onCancel={() => {}} + onArchive={() => {}} + onCancelAndArchive={() => {}} + /> + ); + fireEvent.click(result.getByTestId('table-actions-icon-button')); + expect(result.getByTestId('table-actions-edit')).toBeInTheDocument(); + expect(result.getByTestId('table-actions-archive')).toBeInTheDocument(); + }); + + test('it shows the correct actions when a maintenance window is finished', () => { + const result = appMockRenderer.render( + {}} + onCancel={() => {}} + onArchive={() => {}} + onCancelAndArchive={() => {}} + /> + ); + fireEvent.click(result.getByTestId('table-actions-icon-button')); + expect(result.getByTestId('table-actions-edit')).toBeInTheDocument(); + expect(result.getByTestId('table-actions-archive')).toBeInTheDocument(); + }); + + test('it shows the correct actions when a maintenance window is archived', () => { + const result = appMockRenderer.render( + {}} + onCancel={() => {}} + onArchive={() => {}} + onCancelAndArchive={() => {}} + /> + ); + fireEvent.click(result.getByTestId('table-actions-icon-button')); + expect(result.getByTestId('table-actions-unarchive')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/table_actions_popover.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/table_actions_popover.tsx new file mode 100644 index 0000000000000..4742ede93d53c --- /dev/null +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/table_actions_popover.tsx @@ -0,0 +1,230 @@ +/* + * 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, useMemo, useState } from 'react'; +import { + EuiButtonIcon, + EuiConfirmModal, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiFlexGroup, + EuiFlexItem, + EuiPopover, +} from '@elastic/eui'; +import * as i18n from '../translations'; +import { MaintenanceWindowStatus } from '../../../../common'; + +interface TableActionsPopoverProps { + id: string; + status: MaintenanceWindowStatus; + onEdit: (id: string) => void; + onCancel: (id: string) => void; + onArchive: (id: string, archive: boolean) => void; + onCancelAndArchive: (id: string) => void; +} +type ModalType = 'cancel' | 'cancelAndArchive' | 'archive' | 'unarchive'; +type ActionType = ModalType | 'edit'; + +export const TableActionsPopover: React.FC = React.memo( + ({ id, status, onEdit, onCancel, onArchive, onCancelAndArchive }) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [isModalVisible, setIsModalVisible] = useState(false); + const [modalType, setModalType] = useState(); + + const onButtonClick = useCallback(() => { + setIsPopoverOpen((open) => !open); + }, []); + const closePopover = useCallback(() => { + setIsPopoverOpen(false); + }, []); + + const closeModal = useCallback(() => setIsModalVisible(false), []); + const showModal = useCallback((type: ModalType) => { + setModalType(type); + setIsModalVisible(true); + }, []); + + const modal = useMemo(() => { + const modals = { + cancel: { + props: { + title: i18n.CANCEL_MODAL_TITLE, + onConfirm: () => { + closeModal(); + onCancel(id); + }, + cancelButtonText: i18n.CANCEL_MODAL_BUTTON, + confirmButtonText: i18n.CANCEL_MODAL_TITLE, + }, + subtitle: i18n.CANCEL_MODAL_SUBTITLE, + }, + cancelAndArchive: { + props: { + title: i18n.CANCEL_AND_ARCHIVE_MODAL_TITLE, + onConfirm: () => { + closeModal(); + onCancelAndArchive(id); + }, + cancelButtonText: i18n.CANCEL_MODAL_BUTTON, + confirmButtonText: i18n.CANCEL_AND_ARCHIVE_MODAL_TITLE, + }, + subtitle: i18n.CANCEL_AND_ARCHIVE_MODAL_SUBTITLE, + }, + archive: { + props: { + title: i18n.ARCHIVE_TITLE, + onConfirm: () => { + closeModal(); + onArchive(id, true); + }, + cancelButtonText: i18n.CANCEL, + confirmButtonText: i18n.ARCHIVE_TITLE, + }, + subtitle: i18n.ARCHIVE_SUBTITLE, + }, + unarchive: { + props: { + title: i18n.UNARCHIVE_MODAL_TITLE, + onConfirm: () => { + closeModal(); + onArchive(id, false); + }, + cancelButtonText: i18n.CANCEL, + confirmButtonText: i18n.UNARCHIVE_MODAL_TITLE, + }, + subtitle: i18n.UNARCHIVE_MODAL_SUBTITLE, + }, + }; + let m; + if (isModalVisible && modalType) { + const modalProps = modals[modalType]; + m = ( + +

{modalProps.subtitle}

+
+ ); + } + return m; + }, [id, modalType, isModalVisible, closeModal, onArchive, onCancel, onCancelAndArchive]); + + const items = useMemo(() => { + const menuItems = { + edit: ( + { + closePopover(); + onEdit(id); + }} + > + {i18n.TABLE_ACTION_EDIT} + + ), + cancel: ( + { + closePopover(); + showModal('cancel'); + }} + > + {i18n.TABLE_ACTION_CANCEL} + + ), + cancelAndArchive: ( + { + closePopover(); + showModal('cancelAndArchive'); + }} + > + {i18n.TABLE_ACTION_CANCEL_AND_ARCHIVE} + + ), + archive: ( + { + closePopover(); + showModal('archive'); + }} + > + {i18n.ARCHIVE} + + ), + unarchive: ( + { + closePopover(); + showModal('unarchive'); + }} + > + {i18n.TABLE_ACTION_UNARCHIVE} + + ), + }; + const statusMenuItemsMap: Record = { + running: ['edit', 'cancel', 'cancelAndArchive'], + upcoming: ['edit', 'archive'], + finished: ['edit', 'archive'], + archived: ['unarchive'], + }; + return statusMenuItemsMap[status].map((type) => menuItems[type]); + }, [id, status, onEdit, closePopover, showModal]); + + const button = useMemo( + () => ( + + ), + [onButtonClick] + ); + + return ( + <> + + + + + + + + {modal} + + ); + } +); +TableActionsPopover.displayName = 'TableActionsPopover'; diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/constants.ts b/x-pack/plugins/alerting/public/pages/maintenance_windows/constants.ts index 1aed4dac0568c..27b10e804693d 100644 --- a/x-pack/plugins/alerting/public/pages/maintenance_windows/constants.ts +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/constants.ts @@ -107,15 +107,13 @@ export const RRULE_WEEKDAYS_TO_ISO_WEEKDAYS = mapValues(invert(ISO_WEEKDAYS_TO_R Number(v) ); -export const STATUS_DISPLAY: Record = { - [MaintenanceWindowStatus.Running]: { color: 'warning', label: i18n.TABLE_STATUS_RUNNING }, +export const STATUS_DISPLAY = { + [MaintenanceWindowStatus.Running]: { color: 'primary', label: i18n.TABLE_STATUS_RUNNING }, [MaintenanceWindowStatus.Upcoming]: { color: 'warning', label: i18n.TABLE_STATUS_UPCOMING }, [MaintenanceWindowStatus.Finished]: { color: 'success', label: i18n.TABLE_STATUS_FINISHED }, - [MaintenanceWindowStatus.Archived]: { color: 'text', label: i18n.TABLE_STATUS_ARCHIVED }, + [MaintenanceWindowStatus.Archived]: { color: 'default', label: i18n.TABLE_STATUS_ARCHIVED }, }; -export type StatusColor = 'warning' | 'success' | 'text'; - export const STATUS_SORT = { [MaintenanceWindowStatus.Running]: 0, [MaintenanceWindowStatus.Upcoming]: 1, diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/index.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/index.tsx index ab5828f0dffa3..fa9b54122562d 100644 --- a/x-pack/plugins/alerting/public/pages/maintenance_windows/index.tsx +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/index.tsx @@ -31,7 +31,7 @@ export const MaintenanceWindowsPage = React.memo(() => { const { docLinks } = useKibana().services; const { navigateToCreateMaintenanceWindow } = useCreateMaintenanceWindowNavigation(); - const { isLoading, maintenanceWindows } = useFindMaintenanceWindows(); + const { isLoading, maintenanceWindows, refetch } = useFindMaintenanceWindows(); useBreadcrumbs(AlertingDeepLinkId.maintenanceWindows); @@ -39,6 +39,8 @@ export const MaintenanceWindowsPage = React.memo(() => { navigateToCreateMaintenanceWindow(); }, [navigateToCreateMaintenanceWindow]); + const refreshData = useCallback(() => refetch(), [refetch]); + const showEmptyPrompt = !isLoading && maintenanceWindows.length === 0; if (isLoading) { @@ -77,7 +79,11 @@ export const MaintenanceWindowsPage = React.memo(() => { ) : ( <> - + )} diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/translations.ts b/x-pack/plugins/alerting/public/pages/maintenance_windows/translations.ts index 30b83963cc5b7..65f24411a2a1a 100644 --- a/x-pack/plugins/alerting/public/pages/maintenance_windows/translations.ts +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/translations.ts @@ -454,6 +454,102 @@ export const SAVE_MAINTENANCE_WINDOW = i18n.translate( } ); +export const TABLE_ACTION_CANCEL = i18n.translate( + 'xpack.alerting.maintenanceWindows.table.cancel', + { + defaultMessage: 'Cancel', + } +); + +export const CANCEL_MODAL_TITLE = i18n.translate( + 'xpack.alerting.maintenanceWindows.cancelModal.title', + { + defaultMessage: 'Cancel maintenance window', + } +); + +export const CANCEL_MODAL_SUBTITLE = i18n.translate( + 'xpack.alerting.maintenanceWindows.cancelModal.subtitle', + { + defaultMessage: + 'Rule notifications resume immediately. Running maintenance window events are canceled; upcoming events are unaffected.', + } +); + +export const CANCEL_MODAL_BUTTON = i18n.translate( + 'xpack.alerting.maintenanceWindows.cancelModal.button', + { + defaultMessage: 'Keep running', + } +); + +export const TABLE_ACTION_CANCEL_AND_ARCHIVE = i18n.translate( + 'xpack.alerting.maintenanceWindows.table.cancelAndArchive', + { + defaultMessage: 'Cancel and archive', + } +); + +export const CANCEL_AND_ARCHIVE_MODAL_TITLE = i18n.translate( + 'xpack.alerting.maintenanceWindows.cancelAndArchiveModal.title', + { + defaultMessage: 'Cancel and archive maintenance window', + } +); + +export const CANCEL_AND_ARCHIVE_MODAL_SUBTITLE = i18n.translate( + 'xpack.alerting.maintenanceWindows.cancelAndArchiveModal.subtitle', + { + defaultMessage: + 'Rule notifications resume immediately. All running and upcoming maintenance window events are canceled and the window is queued for deletion.', + } +); + +export const ARCHIVE = i18n.translate('xpack.alerting.maintenanceWindows.archive', { + defaultMessage: 'Archive', +}); + +export const ARCHIVE_TITLE = i18n.translate('xpack.alerting.maintenanceWindows.archive.title', { + defaultMessage: 'Archive maintenance window', +}); + +export const ARCHIVE_SUBTITLE = i18n.translate( + 'xpack.alerting.maintenanceWindows.archive.subtitle', + { + defaultMessage: + 'Upcoming maintenance window events are canceled and the window is queued for deletion.', + } +); + +export const TABLE_ACTION_UNARCHIVE = i18n.translate( + 'xpack.alerting.maintenanceWindows.table.unarchive', + { + defaultMessage: 'Unarchive', + } +); + +export const UNARCHIVE_MODAL_TITLE = i18n.translate( + 'xpack.alerting.maintenanceWindows.unarchiveModal.title', + { + defaultMessage: 'Unarchive maintenance window', + } +); + +export const UNARCHIVE_MODAL_SUBTITLE = i18n.translate( + 'xpack.alerting.maintenanceWindows.unarchiveModal.subtitle', + { + defaultMessage: 'Upcoming maintenance window events are scheduled.', + } +); + +export const ARCHIVE_CALLOUT_SUBTITLE = i18n.translate( + 'xpack.alerting.maintenanceWindows.archiveCallout.subtitle', + { + defaultMessage: + 'The changes you have made here will not be saved. Are you sure you want to discard these unsaved changes and archive this maintenance window?', + } +); + export const EXPERIMENTAL_LABEL = i18n.translate( 'xpack.alerting.maintenanceWindows.badge.experimentalLabel', { diff --git a/x-pack/plugins/alerting/public/services/maintenance_windows_api/archive.test.ts b/x-pack/plugins/alerting/public/services/maintenance_windows_api/archive.test.ts new file mode 100644 index 0000000000000..8f0e44eaf2eb9 --- /dev/null +++ b/x-pack/plugins/alerting/public/services/maintenance_windows_api/archive.test.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { httpServiceMock } from '@kbn/core/public/mocks'; +import { MaintenanceWindow } from '../../pages/maintenance_windows/types'; +import { archiveMaintenanceWindow } from './archive'; + +const http = httpServiceMock.createStartContract(); + +beforeEach(() => jest.resetAllMocks()); + +describe('archiveMaintenanceWindow', () => { + test('should call archive maintenance window api', async () => { + const apiResponse = { + title: 'test', + duration: 1, + r_rule: { + dtstart: '2023-03-23T19:16:21.293Z', + tzid: 'America/New_York', + freq: 3, + interval: 1, + byweekday: ['TH'], + }, + }; + http.post.mockResolvedValueOnce(apiResponse); + + const maintenanceWindow: MaintenanceWindow = { + title: 'test', + duration: 1, + rRule: { + dtstart: '2023-03-23T19:16:21.293Z', + tzid: 'America/New_York', + freq: 3, + interval: 1, + byweekday: ['TH'], + }, + }; + + const result = await archiveMaintenanceWindow({ + http, + maintenanceWindowId: '123', + archive: true, + }); + expect(result).toEqual(maintenanceWindow); + expect(http.post.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/internal/alerting/rules/maintenance_window/123/_archive", + Object { + "body": "{\\"archive\\":true}", + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/alerting/public/services/maintenance_windows_api/archive.ts b/x-pack/plugins/alerting/public/services/maintenance_windows_api/archive.ts new file mode 100644 index 0000000000000..fe07ebb04b38e --- /dev/null +++ b/x-pack/plugins/alerting/public/services/maintenance_windows_api/archive.ts @@ -0,0 +1,35 @@ +/* + * 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 { HttpSetup } from '@kbn/core/public'; +import { AsApiContract, RewriteRequestCase } from '@kbn/actions-plugin/common'; + +import { MaintenanceWindow } from '../../pages/maintenance_windows/types'; +import { INTERNAL_BASE_ALERTING_API_PATH } from '../../../common'; + +const rewriteBodyRes: RewriteRequestCase = ({ r_rule: rRule, ...rest }) => ({ + ...rest, + rRule, +}); + +export async function archiveMaintenanceWindow({ + http, + maintenanceWindowId, + archive, +}: { + http: HttpSetup; + maintenanceWindowId: string; + archive: boolean; +}): Promise { + const res = await http.post>( + `${INTERNAL_BASE_ALERTING_API_PATH}/rules/maintenance_window/${encodeURIComponent( + maintenanceWindowId + )}/_archive`, + { body: JSON.stringify({ archive }) } + ); + + return rewriteBodyRes(res); +} diff --git a/x-pack/plugins/alerting/public/services/maintenance_windows_api/finish.test.ts b/x-pack/plugins/alerting/public/services/maintenance_windows_api/finish.test.ts new file mode 100644 index 0000000000000..a67b7246a64f5 --- /dev/null +++ b/x-pack/plugins/alerting/public/services/maintenance_windows_api/finish.test.ts @@ -0,0 +1,54 @@ +/* + * 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 { httpServiceMock } from '@kbn/core/public/mocks'; +import { MaintenanceWindow } from '../../pages/maintenance_windows/types'; +import { finishMaintenanceWindow } from './finish'; + +const http = httpServiceMock.createStartContract(); + +beforeEach(() => jest.resetAllMocks()); + +describe('finishMaintenanceWindow', () => { + test('should call finish maintenance window api', async () => { + const apiResponse = { + title: 'test', + duration: 1, + r_rule: { + dtstart: '2023-03-23T19:16:21.293Z', + tzid: 'America/New_York', + freq: 3, + interval: 1, + byweekday: ['TH'], + }, + }; + http.post.mockResolvedValueOnce(apiResponse); + + const maintenanceWindow: MaintenanceWindow = { + title: 'test', + duration: 1, + rRule: { + dtstart: '2023-03-23T19:16:21.293Z', + tzid: 'America/New_York', + freq: 3, + interval: 1, + byweekday: ['TH'], + }, + }; + + const result = await finishMaintenanceWindow({ + http, + maintenanceWindowId: '123', + }); + expect(result).toEqual(maintenanceWindow); + expect(http.post.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/internal/alerting/rules/maintenance_window/123/_finish", + ] + `); + }); +}); diff --git a/x-pack/plugins/alerting/public/services/maintenance_windows_api/finish.ts b/x-pack/plugins/alerting/public/services/maintenance_windows_api/finish.ts new file mode 100644 index 0000000000000..910fad0bee1c3 --- /dev/null +++ b/x-pack/plugins/alerting/public/services/maintenance_windows_api/finish.ts @@ -0,0 +1,32 @@ +/* + * 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 { HttpSetup } from '@kbn/core/public'; +import { AsApiContract, RewriteRequestCase } from '@kbn/actions-plugin/common'; + +import { MaintenanceWindow } from '../../pages/maintenance_windows/types'; +import { INTERNAL_BASE_ALERTING_API_PATH } from '../../../common'; + +const rewriteBodyRes: RewriteRequestCase = ({ r_rule: rRule, ...rest }) => ({ + ...rest, + rRule, +}); + +export async function finishMaintenanceWindow({ + http, + maintenanceWindowId, +}: { + http: HttpSetup; + maintenanceWindowId: string; +}): Promise { + const res = await http.post>( + `${INTERNAL_BASE_ALERTING_API_PATH}/rules/maintenance_window/${encodeURIComponent( + maintenanceWindowId + )}/_finish` + ); + + return rewriteBodyRes(res); +}