[ResponseOps][Window Maintenance] Add the stop and archive actions to the maintenance window table (#155201)

Resolves https://github.com/elastic/kibana/issues/154814

## Summary

This pr adds the cancel/archive actions to the Maintenance Windows
table, and adds an archive callout to the create form.
@lcawl I think I need your help with some of the text in the modals 🙂 

**Create Form:**
Callout
<img width="1370" alt="Screen Shot 2023-04-18 at 9 49 58 PM"
src="https://user-images.githubusercontent.com/109488926/232945833-77f01988-e5c4-4d5f-a2f9-b361085964e2.png">

Modal
<img width="1335" alt="Screen Shot 2023-04-18 at 9 50 05 PM"
src="https://user-images.githubusercontent.com/109488926/232945810-dacb3a26-fa59-4e5e-995c-14b7f0977c10.png">

**Cancel Modal:**
<img width="628" alt="Screen Shot 2023-04-18 at 9 52 18 PM"
src="https://user-images.githubusercontent.com/109488926/232946092-c4001ccc-d2f9-475a-bc67-3b2ab6fdf31e.png">

**Cancel and Archive Modal:**
<img width="621" alt="Screen Shot 2023-04-18 at 9 52 56 PM"
src="https://user-images.githubusercontent.com/109488926/232946171-154f8601-d5aa-40f1-824a-bff25b4d3d91.png">

**Archive Modal:**
<img width="611" alt="Screen Shot 2023-04-18 at 9 53 25 PM"
src="https://user-images.githubusercontent.com/109488926/232946240-ee25bcd0-80d6-403c-9fb3-9aec89d11ebd.png">

**Unarchive Modal:**
<img width="620" alt="Screen Shot 2023-04-18 at 9 54 16 PM"
src="https://user-images.githubusercontent.com/109488926/232946350-814d0c05-5511-4fad-8ac4-0c5824d94b8e.png">

### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Lisa Cawley <lcawley@elastic.co>
This commit is contained in:
Alexi Doak 2023-04-21 15:04:54 -04:00 committed by GitHub
parent da5a4b08d3
commit dbb8e2ebec
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 1186 additions and 59 deletions

View file

@ -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.')
);
});
});

View file

@ -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);
},
});
}

View file

@ -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,
},
})
);

View file

@ -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,
};
};

View file

@ -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.')
);
});
});

View file

@ -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.',
})
);
},
});
}

View file

@ -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.')
);
});
});

View file

@ -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.',
})
);
},
});
}

View file

@ -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.')
);
});
});

View file

@ -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.',
})
);
},

View file

@ -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<CreateMaintenanceWindowFor
({ onCancel, onSuccess, initialValue, maintenanceWindowId }) => {
const [defaultStartDateValue] = useState<string>(moment().toISOString());
const [defaultEndDateValue] = useState<string>(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<CreateMaintenanceWindowFor
useCreateMaintenanceWindow();
const { mutate: updateMaintenanceWindow, isLoading: isUpdateLoading } =
useUpdateMaintenanceWindow();
const { mutate: archiveMaintenanceWindow } = useArchiveMaintenanceWindow();
const submitMaintenanceWindow = useCallback(
async (formData, isValid) => {
@ -109,6 +115,35 @@ export const CreateMaintenanceWindowForm = React.memo<CreateMaintenanceWindowFor
const isRecurring = recurring || false;
const showTimezone = isBrowser || initialValue?.timezone !== undefined;
const closeModal = useCallback(() => setIsModalVisible(false), []);
const showModal = useCallback(() => setIsModalVisible(true), []);
const modal = useMemo(() => {
let m;
if (isModalVisible) {
m = (
<EuiConfirmModal
title={i18n.ARCHIVE_TITLE}
onCancel={closeModal}
onConfirm={() => {
closeModal();
archiveMaintenanceWindow(
{ maintenanceWindowId: maintenanceWindowId!, archive: true },
{ onSuccess }
);
}}
cancelButtonText={i18n.CANCEL}
confirmButtonText={i18n.ARCHIVE_TITLE}
defaultFocusedButton="confirm"
buttonColor="danger"
>
<p>{i18n.ARCHIVE_CALLOUT_SUBTITLE}</p>
</EuiConfirmModal>
);
}
return m;
}, [closeModal, archiveMaintenanceWindow, isModalVisible, maintenanceWindowId, onSuccess]);
return (
<Form form={form}>
<EuiFlexGroup direction="column" responsive={false}>
@ -192,6 +227,15 @@ export const CreateMaintenanceWindowForm = React.memo<CreateMaintenanceWindowFor
{isRecurring ? <RecurringSchedule data-test-subj="recurring-form" /> : null}
</EuiFlexItem>
</EuiFlexGroup>
{isEditMode ? (
<EuiCallOut title={i18n.ARCHIVE_TITLE} color="danger" iconType="trash">
<p>{i18n.ARCHIVE_SUBTITLE}</p>
<EuiButton fill color="danger" onClick={showModal}>
{i18n.ARCHIVE}
</EuiButton>
{modal}
</EuiCallOut>
) : null}
<EuiHorizontalRule margin="xl" />
<EuiFlexGroup
alignItems="center"

View file

@ -93,7 +93,9 @@ describe('MaintenanceWindowsList', () => {
});
test('it renders', () => {
const result = appMockRenderer.render(<MaintenanceWindowsList loading={false} items={items} />);
const result = appMockRenderer.render(
<MaintenanceWindowsList refreshData={() => {}} loading={false} items={items} />
);
expect(result.getAllByTestId('list-item')).toHaveLength(items.length);

View file

@ -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<EuiBasicTableColumn<MaintenanceWindowFindResponse>> = [
@ -39,23 +44,9 @@ const columns: Array<EuiBasicTableColumn<MaintenanceWindowFindResponse>> = [
{
field: 'status',
name: i18n.TABLE_STATUS,
render: (status: string) => {
render: (status: MaintenanceWindowStatus) => {
return (
<EuiButton
css={css`
cursor: default;
:hover:not(:disabled) {
text-decoration: none;
}
`}
fill={status === MaintenanceWindowStatus.Running}
color={STATUS_DISPLAY[status].color as StatusColor}
size="s"
onClick={() => {}}
>
{STATUS_DISPLAY[status].label}
</EuiButton>
<EuiBadge color={STATUS_DISPLAY[status].color}>{STATUS_DISPLAY[status].label}</EuiBadge>
);
},
sortable: ({ status }) => STATUS_SORT[status],
@ -108,38 +99,61 @@ const search: { filters: SearchFilterConfig[] } = {
};
export const MaintenanceWindowsList = React.memo<MaintenanceWindowsListProps>(
({ 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<EuiBasicTableColumn<MaintenanceWindowFindResponse>> = [
{
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 (
<TableActionsPopover
id={id}
status={status}
onEdit={onEdit}
onCancel={onCancel}
onArchive={onArchive}
onCancelAndArchive={onCancelAndArchive}
/>
);
},
},
];
@ -147,7 +161,7 @@ export const MaintenanceWindowsList = React.memo<MaintenanceWindowsListProps>(
<EuiInMemoryTable
css={tableCss}
itemId="id"
loading={loading}
loading={loading || isLoadingFinish || isLoadingArchive || isLoadingFinishAndArchive}
tableCaption="Maintenance Windows List"
items={items}
columns={columns.concat(actions)}

View file

@ -0,0 +1,101 @@
/*
* 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 { fireEvent } from '@testing-library/dom';
import React from 'react';
import { AppMockRenderer, createAppMockRenderer } from '../../../lib/test_utils';
import { TableActionsPopover } from './table_actions_popover';
import { MaintenanceWindowStatus } from '../../../../common';
describe('TableActionsPopover', () => {
let appMockRenderer: AppMockRenderer;
beforeEach(() => {
jest.clearAllMocks();
appMockRenderer = createAppMockRenderer();
});
test('it renders', () => {
const result = appMockRenderer.render(
<TableActionsPopover
id={'123'}
status={MaintenanceWindowStatus.Running}
onEdit={() => {}}
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(
<TableActionsPopover
id={'123'}
status={MaintenanceWindowStatus.Running}
onEdit={() => {}}
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(
<TableActionsPopover
id={'123'}
status={MaintenanceWindowStatus.Upcoming}
onEdit={() => {}}
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(
<TableActionsPopover
id={'123'}
status={MaintenanceWindowStatus.Finished}
onEdit={() => {}}
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(
<TableActionsPopover
id={'123'}
status={MaintenanceWindowStatus.Archived}
onEdit={() => {}}
onCancel={() => {}}
onArchive={() => {}}
onCancelAndArchive={() => {}}
/>
);
fireEvent.click(result.getByTestId('table-actions-icon-button'));
expect(result.getByTestId('table-actions-unarchive')).toBeInTheDocument();
});
});

View file

@ -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<TableActionsPopoverProps> = React.memo(
({ id, status, onEdit, onCancel, onArchive, onCancelAndArchive }) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [isModalVisible, setIsModalVisible] = useState(false);
const [modalType, setModalType] = useState<ModalType>();
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 = (
<EuiConfirmModal
{...modalProps.props}
style={{ width: 600 }}
onCancel={closeModal}
defaultFocusedButton="confirm"
buttonColor="danger"
>
<p>{modalProps.subtitle}</p>
</EuiConfirmModal>
);
}
return m;
}, [id, modalType, isModalVisible, closeModal, onArchive, onCancel, onCancelAndArchive]);
const items = useMemo(() => {
const menuItems = {
edit: (
<EuiContextMenuItem
data-test-subj="table-actions-edit"
key="edit"
icon="pencil"
onClick={() => {
closePopover();
onEdit(id);
}}
>
{i18n.TABLE_ACTION_EDIT}
</EuiContextMenuItem>
),
cancel: (
<EuiContextMenuItem
data-test-subj="table-actions-cancel"
key="cancel"
icon="stopSlash"
onClick={() => {
closePopover();
showModal('cancel');
}}
>
{i18n.TABLE_ACTION_CANCEL}
</EuiContextMenuItem>
),
cancelAndArchive: (
<EuiContextMenuItem
data-test-subj="table-actions-cancel-and-archive"
key="cancel-and-archive"
icon="trash"
onClick={() => {
closePopover();
showModal('cancelAndArchive');
}}
>
{i18n.TABLE_ACTION_CANCEL_AND_ARCHIVE}
</EuiContextMenuItem>
),
archive: (
<EuiContextMenuItem
data-test-subj="table-actions-archive"
key="archive"
icon="trash"
onClick={() => {
closePopover();
showModal('archive');
}}
>
{i18n.ARCHIVE}
</EuiContextMenuItem>
),
unarchive: (
<EuiContextMenuItem
data-test-subj="table-actions-unarchive"
key="unarchive"
icon="exit"
onClick={() => {
closePopover();
showModal('unarchive');
}}
>
{i18n.TABLE_ACTION_UNARCHIVE}
</EuiContextMenuItem>
),
};
const statusMenuItemsMap: Record<MaintenanceWindowStatus, ActionType[]> = {
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(
() => (
<EuiButtonIcon
data-test-subj="table-actions-icon-button"
iconType="boxesHorizontal"
size="s"
aria-label="Upcoming events"
onClick={onButtonClick}
/>
),
[onButtonClick]
);
return (
<>
<EuiFlexGroup justifyContent="flexEnd" alignItems="center">
<EuiFlexItem grow={false}>
<EuiPopover
button={button}
isOpen={isPopoverOpen}
closePopover={closePopover}
panelPaddingSize="none"
anchorPosition="downCenter"
>
<EuiContextMenuPanel items={items} />
</EuiPopover>
</EuiFlexItem>
</EuiFlexGroup>
{modal}
</>
);
}
);
TableActionsPopover.displayName = 'TableActionsPopover';

View file

@ -107,15 +107,13 @@ export const RRULE_WEEKDAYS_TO_ISO_WEEKDAYS = mapValues(invert(ISO_WEEKDAYS_TO_R
Number(v)
);
export const STATUS_DISPLAY: Record<string, { color: string; label: string }> = {
[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,

View file

@ -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(() => {
) : (
<>
<EuiSpacer size="xl" />
<MaintenanceWindowsList loading={isLoading} items={maintenanceWindows} />
<MaintenanceWindowsList
refreshData={refreshData}
loading={isLoading}
items={maintenanceWindows}
/>
</>
)}
</>

View file

@ -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',
{

View file

@ -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}",
},
]
`);
});
});

View file

@ -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<MaintenanceWindow> = ({ r_rule: rRule, ...rest }) => ({
...rest,
rRule,
});
export async function archiveMaintenanceWindow({
http,
maintenanceWindowId,
archive,
}: {
http: HttpSetup;
maintenanceWindowId: string;
archive: boolean;
}): Promise<MaintenanceWindow> {
const res = await http.post<AsApiContract<MaintenanceWindow>>(
`${INTERNAL_BASE_ALERTING_API_PATH}/rules/maintenance_window/${encodeURIComponent(
maintenanceWindowId
)}/_archive`,
{ body: JSON.stringify({ archive }) }
);
return rewriteBodyRes(res);
}

View file

@ -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",
]
`);
});
});

View file

@ -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<MaintenanceWindow> = ({ r_rule: rRule, ...rest }) => ({
...rest,
rRule,
});
export async function finishMaintenanceWindow({
http,
maintenanceWindowId,
}: {
http: HttpSetup;
maintenanceWindowId: string;
}): Promise<MaintenanceWindow> {
const res = await http.post<AsApiContract<MaintenanceWindow>>(
`${INTERNAL_BASE_ALERTING_API_PATH}/rules/maintenance_window/${encodeURIComponent(
maintenanceWindowId
)}/_finish`
);
return rewriteBodyRes(res);
}