From 43abe233d814cb9a5519a63a2f5942f8802879b2 Mon Sep 17 00:00:00 2001 From: Julia Date: Sat, 21 Dec 2024 21:01:11 +0100 Subject: [PATCH] [ResponceOps][MaintenanceWindow] MX Pagination (#202539) Fixes: https://github.com/elastic/kibana/issues/198252 In this PR I introduced pagination in MW frontend part and also pass filters(status and search) to the backend. Pagination arguments were passed to backend in another PR: https://github.com/elastic/kibana/pull/197172/files#diff-f375a192a08a6db3fbb6b6e927cecaab89ff401efc4034f00761e8fc4478734c How to test: Go to Maintenance Window, create more than 10 MW with different statuses. Try pagination, search on text and filter by status. Check the PR satisfies following conditions: - [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 - [x] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [x] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../current_fields.json | 5 +- .../current_mappings.json | 14 + .../check_registered_types.test.ts | 2 +- x-pack/plugins/alerting/common/index.ts | 2 + .../alerting/common/maintenance_window.ts | 3 + .../apis/find/schemas/v1.ts | 16 + .../use_find_maintenance_windows.test.tsx | 18 +- .../hooks/use_find_maintenance_windows.ts | 33 +- .../maintenance_windows_list.test.tsx | 21 ++ .../components/maintenance_windows_list.tsx | 134 ++++--- .../components/status_filter.test.tsx | 34 +- .../components/status_filter.tsx | 123 ++++--- .../pages/maintenance_windows/index.test.tsx | 2 +- .../pages/maintenance_windows/index.tsx | 56 ++- .../pages/maintenance_windows/translations.ts | 5 + .../maintenance_windows_api/find.test.ts | 19 +- .../services/maintenance_windows_api/find.ts | 29 +- .../find/find_maintenance_windows.test.ts | 183 +++++++++- .../methods/find/find_maintenance_windows.ts | 42 ++- .../find_maintenance_window_params_schema.ts | 9 + .../methods/find/schemas/index.ts | 5 +- .../types/find_maintenance_window_params.ts | 3 +- .../methods/find/types/index.ts | 5 +- .../methods/find_maintenance_window_so.ts | 4 +- .../find_maintenance_windows_route.test.ts | 9 +- .../v1.test.ts | 61 ++++ .../v1.ts | 14 +- .../maintenance_window_mapping.ts | 29 +- .../maintenance_window_model_versions.ts | 26 ++ .../find_maintenance_windows.ts | 339 +++++++++++++++++- .../maintenance_windows_table.ts | 88 ++++- 31 files changed, 1149 insertions(+), 184 deletions(-) create mode 100644 x-pack/plugins/alerting/server/routes/maintenance_window/apis/find/transforms/transform_find_maintenance_window_params/v1.test.ts diff --git a/packages/kbn-check-mappings-update-cli/current_fields.json b/packages/kbn-check-mappings-update-cli/current_fields.json index 45313933ab1b4..b902407b92e7a 100644 --- a/packages/kbn-check-mappings-update-cli/current_fields.json +++ b/packages/kbn-check-mappings-update-cli/current_fields.json @@ -762,7 +762,10 @@ ], "maintenance-window": [ "enabled", - "events" + "events", + "expirationDate", + "title", + "updatedAt" ], "map": [ "bounds", diff --git a/packages/kbn-check-mappings-update-cli/current_mappings.json b/packages/kbn-check-mappings-update-cli/current_mappings.json index 663fef8f4fef1..352e753162a36 100644 --- a/packages/kbn-check-mappings-update-cli/current_mappings.json +++ b/packages/kbn-check-mappings-update-cli/current_mappings.json @@ -2521,6 +2521,20 @@ "events": { "format": "epoch_millis||strict_date_optional_time", "type": "date_range" + }, + "expirationDate": { + "type": "date" + }, + "title": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "updatedAt": { + "type": "date" } } }, diff --git a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts index b2d9693cfcc22..5aca6f1c04446 100644 --- a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts +++ b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts @@ -132,7 +132,7 @@ describe('checking migration metadata changes on all registered SO types', () => "lens": "5cfa2c52b979b4f8df56dd13c477e152183468b9", "lens-ui-telemetry": "8c47a9e393861f76e268345ecbadfc8a5fb1e0bd", "links": "1dd432cc94619a513b75cec43660a50be7aadc90", - "maintenance-window": "bf36863f5577c2d22625258bdad906eeb4cccccc", + "maintenance-window": "b84d9e0b3f89be0ae4b6fe1af6e38b4cd2554931", "map": "76c71023bd198fb6b1163b31bafd926fe2ceb9da", "metrics-data-source": "81b69dc9830699d9ead5ac8dcb9264612e2a3c89", "metrics-explorer-view": "98cf395d0e87b89ab63f173eae16735584a8ff42", diff --git a/x-pack/plugins/alerting/common/index.ts b/x-pack/plugins/alerting/common/index.ts index 8e5a258f15e6c..94c307b67fd02 100644 --- a/x-pack/plugins/alerting/common/index.ts +++ b/x-pack/plugins/alerting/common/index.ts @@ -62,6 +62,8 @@ export { MAINTENANCE_WINDOW_PATHS, MAINTENANCE_WINDOW_DEEP_LINK_IDS, MAINTENANCE_WINDOW_DATE_FORMAT, + MAINTENANCE_WINDOW_DEFAULT_PER_PAGE, + MAINTENANCE_WINDOW_DEFAULT_TABLE_ACTIVE_PAGE, } from './maintenance_window'; export { diff --git a/x-pack/plugins/alerting/common/maintenance_window.ts b/x-pack/plugins/alerting/common/maintenance_window.ts index 70e02814bd4e1..8b893835f9157 100644 --- a/x-pack/plugins/alerting/common/maintenance_window.ts +++ b/x-pack/plugins/alerting/common/maintenance_window.ts @@ -110,3 +110,6 @@ export type MaintenanceWindowDeepLinkIds = (typeof MAINTENANCE_WINDOW_DEEP_LINK_IDS)[keyof typeof MAINTENANCE_WINDOW_DEEP_LINK_IDS]; export const MAINTENANCE_WINDOW_DATE_FORMAT = 'MM/DD/YY hh:mm A'; + +export const MAINTENANCE_WINDOW_DEFAULT_PER_PAGE = 10 as const; +export const MAINTENANCE_WINDOW_DEFAULT_TABLE_ACTIVE_PAGE = 1 as const; diff --git a/x-pack/plugins/alerting/common/routes/maintenance_window/apis/find/schemas/v1.ts b/x-pack/plugins/alerting/common/routes/maintenance_window/apis/find/schemas/v1.ts index 7c4dffdd1d94c..9fece19882f4a 100644 --- a/x-pack/plugins/alerting/common/routes/maintenance_window/apis/find/schemas/v1.ts +++ b/x-pack/plugins/alerting/common/routes/maintenance_window/apis/find/schemas/v1.ts @@ -10,6 +10,13 @@ import { maintenanceWindowResponseSchemaV1 } from '../../../response'; const MAX_DOCS = 10000; +const statusSchema = schema.oneOf([ + schema.literal('running'), + schema.literal('finished'), + schema.literal('upcoming'), + schema.literal('archived'), +]); + export const findMaintenanceWindowsRequestQuerySchema = schema.object( { // we do not need to use schema.maybe here, because if we do not pass property page, defaultValue will be used @@ -30,6 +37,15 @@ export const findMaintenanceWindowsRequestQuerySchema = schema.object( description: 'The number of maintenance windows to return per page.', }, }), + search: schema.maybe( + schema.string({ + meta: { + description: + 'An Elasticsearch simple_query_string query that filters the objects in the response.', + }, + }) + ), + status: schema.maybe(schema.oneOf([statusSchema, schema.arrayOf(statusSchema)])), }, { validate: (params) => { diff --git a/x-pack/plugins/alerting/public/hooks/use_find_maintenance_windows.test.tsx b/x-pack/plugins/alerting/public/hooks/use_find_maintenance_windows.test.tsx index b543d7940cd9d..19a4f65d88036 100644 --- a/x-pack/plugins/alerting/public/hooks/use_find_maintenance_windows.test.tsx +++ b/x-pack/plugins/alerting/public/hooks/use_find_maintenance_windows.test.tsx @@ -11,6 +11,7 @@ import { AppMockRenderer, createAppMockRenderer } from '../lib/test_utils'; import { useFindMaintenanceWindows } from './use_find_maintenance_windows'; const mockAddDanger = jest.fn(); +const mockedHttp = jest.fn(); jest.mock('../utils/kibana_react', () => { const originalModule = jest.requireActual('../utils/kibana_react'); @@ -21,6 +22,7 @@ jest.mock('../utils/kibana_react', () => { return { services: { ...services, + http: mockedHttp, notifications: { toasts: { addDanger: mockAddDanger } }, }, }; @@ -33,6 +35,8 @@ jest.mock('../services/maintenance_windows_api/find', () => ({ const { findMaintenanceWindows } = jest.requireMock('../services/maintenance_windows_api/find'); +const defaultHookProps = { page: 1, perPage: 10, search: '', selectedStatus: [] }; + let appMockRenderer: AppMockRenderer; describe('useFindMaintenanceWindows', () => { @@ -42,10 +46,20 @@ describe('useFindMaintenanceWindows', () => { appMockRenderer = createAppMockRenderer(); }); + it('should call findMaintenanceWindows with correct arguments on successful scenario', async () => { + renderHook(() => useFindMaintenanceWindows({ ...defaultHookProps }), { + wrapper: appMockRenderer.AppWrapper, + }); + + await waitFor(() => + expect(findMaintenanceWindows).toHaveBeenCalledWith({ http: mockedHttp, ...defaultHookProps }) + ); + }); + it('should call onError if api fails', async () => { findMaintenanceWindows.mockRejectedValue('This is an error.'); - renderHook(() => useFindMaintenanceWindows(), { + renderHook(() => useFindMaintenanceWindows({ ...defaultHookProps }), { wrapper: appMockRenderer.AppWrapper, }); @@ -55,7 +69,7 @@ describe('useFindMaintenanceWindows', () => { }); it('should not try to find maintenance windows if not enabled', async () => { - renderHook(() => useFindMaintenanceWindows({ enabled: false }), { + renderHook(() => useFindMaintenanceWindows({ enabled: false, ...defaultHookProps }), { wrapper: appMockRenderer.AppWrapper, }); 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 7f8293dbd5dd0..503cb58f1df88 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 @@ -9,13 +9,18 @@ import { i18n } from '@kbn/i18n'; import { useQuery } from '@tanstack/react-query'; import { useKibana } from '../utils/kibana_react'; import { findMaintenanceWindows } from '../services/maintenance_windows_api/find'; +import { type MaintenanceWindowStatus } from '../../common'; interface UseFindMaintenanceWindowsProps { enabled?: boolean; + page: number; + perPage: number; + search: string; + selectedStatus: MaintenanceWindowStatus[]; } -export const useFindMaintenanceWindows = (props?: UseFindMaintenanceWindowsProps) => { - const { enabled = true } = props || {}; +export const useFindMaintenanceWindows = (params: UseFindMaintenanceWindowsProps) => { + const { enabled = true, page, perPage, search, selectedStatus } = params; const { http, @@ -23,7 +28,13 @@ export const useFindMaintenanceWindows = (props?: UseFindMaintenanceWindowsProps } = useKibana().services; const queryFn = () => { - return findMaintenanceWindows({ http }); + return findMaintenanceWindows({ + http, + page, + perPage, + search, + selectedStatus, + }); }; const onErrorFn = (error: Error) => { @@ -36,24 +47,22 @@ export const useFindMaintenanceWindows = (props?: UseFindMaintenanceWindowsProps } }; - const { - isLoading, - isFetching, - isInitialLoading, - data = [], - refetch, - } = useQuery({ - queryKey: ['findMaintenanceWindows'], + const queryKey = ['findMaintenanceWindows', page, perPage, search, selectedStatus]; + + const { isLoading, isFetching, isInitialLoading, data, refetch } = useQuery({ + queryKey, queryFn, onError: onErrorFn, refetchOnWindowFocus: false, retry: false, cacheTime: 0, enabled, + placeholderData: { maintenanceWindows: [], total: 0 }, + keepPreviousData: true, }); return { - maintenanceWindows: data, + data, isLoading: enabled && (isLoading || isFetching), isInitialLoading, refetch, diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/maintenance_windows_list.test.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/maintenance_windows_list.test.tsx index 5f9c5b23409c0..50880bf216ce7 100644 --- a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/maintenance_windows_list.test.tsx +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/maintenance_windows_list.test.tsx @@ -95,6 +95,13 @@ describe('MaintenanceWindowsList', () => { isLoading={false} items={items} readOnly={false} + page={1} + perPage={10} + total={22} + onPageChange={() => {}} + onStatusChange={() => {}} + selectedStatus={[]} + onSearchChange={() => {}} /> ); @@ -128,6 +135,13 @@ describe('MaintenanceWindowsList', () => { isLoading={false} items={items} readOnly={true} + page={1} + perPage={10} + total={22} + onPageChange={() => {}} + onStatusChange={() => {}} + selectedStatus={[]} + onSearchChange={() => {}} /> ); @@ -145,6 +159,13 @@ describe('MaintenanceWindowsList', () => { isLoading={false} items={items} readOnly={false} + page={1} + perPage={10} + total={22} + onPageChange={() => {}} + onStatusChange={() => {}} + selectedStatus={[]} + onSearchChange={() => {}} /> ); fireEvent.click(result.getByTestId('refresh-button')); 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 f91856dc8aec9..e4f69106ffa46 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,20 +5,20 @@ * 2.0. */ -import React, { useCallback, useMemo } from 'react'; +import React, { useState, useCallback, useMemo } from 'react'; import { formatDate, - EuiInMemoryTable, EuiBasicTableColumn, EuiFlexGroup, EuiFlexItem, EuiBadge, useEuiTheme, EuiButton, - EuiSearchBarProps, + EuiBasicTable, + EuiFieldSearch, + EuiSpacer, } from '@elastic/eui'; import { css } from '@emotion/react'; -import { SortDirection } from '../types'; import * as i18n from '../translations'; import { useEditMaintenanceWindowsNavigation } from '../../../hooks/use_navigation'; import { STATUS_DISPLAY, STATUS_SORT } from '../constants'; @@ -39,6 +39,13 @@ interface MaintenanceWindowsListProps { items: MaintenanceWindow[]; readOnly: boolean; refreshData: () => void; + page: number; + perPage: number; + total: number; + onPageChange: ({ page: { index, size } }: { page: { index: number; size: number } }) => void; + onStatusChange: (status: MaintenanceWindowStatus[]) => void; + selectedStatus: MaintenanceWindowStatus[]; + onSearchChange: (value: string) => void; } const COLUMNS: Array> = [ @@ -86,21 +93,27 @@ const COLUMNS: Array> = [ }, ]; -const sorting = { - sort: { - field: 'status', - direction: SortDirection.asc, - }, -}; - const rowProps = (item: MaintenanceWindow) => ({ className: item.status, 'data-test-subj': 'list-item', }); export const MaintenanceWindowsList = React.memo( - ({ isLoading, items, readOnly, refreshData }) => { + ({ + isLoading, + items, + readOnly, + refreshData, + page, + perPage, + total, + onPageChange, + selectedStatus, + onStatusChange, + onSearchChange, + }) => { const { euiTheme } = useEuiTheme(); + const [search, setSearch] = useState(''); const { navigateToEditMaintenanceWindows } = useEditMaintenanceWindowsNavigation(); const onEdit = useCallback( @@ -173,44 +186,73 @@ export const MaintenanceWindowsList = React.memo( [actions, readOnly] ); - const search: EuiSearchBarProps = useMemo( - () => ({ - filters: [ - { - type: 'custom_component', - component: StatusFilter, - }, - ], - toolsRight: ( - - {i18n.REFRESH} - - ), - }), - [isMutatingOrLoading, refreshData] + const onInputChange = useCallback( + (e: React.ChangeEvent) => { + setSearch(e.target.value); + if (e.target.value === '') { + onSearchChange(e.target.value); + } + }, + [onSearchChange] ); return ( - + <> + + + + + + + + + + + {i18n.REFRESH} + + + + + + + + + + + ); } ); + MaintenanceWindowsList.displayName = 'MaintenanceWindowsList'; diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/status_filter.test.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/status_filter.test.tsx index 3875545e36df4..b5fa6c1d6b871 100644 --- a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/status_filter.test.tsx +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/status_filter.test.tsx @@ -5,17 +5,16 @@ * 2.0. */ -import { Query } from '@elastic/eui'; -import { fireEvent } from '@testing-library/react'; import React from 'react'; - +import { fireEvent, screen } from '@testing-library/react'; +import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; import { AppMockRenderer, createAppMockRenderer } from '../../../lib/test_utils'; import { StatusFilter } from './status_filter'; +import { MaintenanceWindowStatus } from '../../../../common'; describe('StatusFilter', () => { let appMockRenderer: AppMockRenderer; const onChange = jest.fn(); - const query = Query.parse(''); beforeEach(() => { jest.clearAllMocks(); @@ -23,18 +22,39 @@ describe('StatusFilter', () => { }); test('it renders', () => { - const result = appMockRenderer.render(); + const result = appMockRenderer.render(); expect(result.getByTestId('status-filter-button')).toBeInTheDocument(); }); - test('it shows the popover', () => { - const result = appMockRenderer.render(); + test('it shows the popover', async () => { + const result = appMockRenderer.render(); fireEvent.click(result.getByTestId('status-filter-button')); + + await waitForEuiPopoverOpen(); expect(result.getByTestId('status-filter-running')).toBeInTheDocument(); expect(result.getByTestId('status-filter-upcoming')).toBeInTheDocument(); expect(result.getByTestId('status-filter-finished')).toBeInTheDocument(); expect(result.getByTestId('status-filter-archived')).toBeInTheDocument(); }); + + test('should have 2 active filters', async () => { + const result = appMockRenderer.render( + + ); + + fireEvent.click(result.getByTestId('status-filter-button')); + await waitForEuiPopoverOpen(); + + // Find the span containing the notification badge (with the active filter count) + const notificationBadge = screen.getByRole('marquee', { + name: /2 active filters/i, + }); + + expect(notificationBadge).toHaveTextContent('2'); + }); }); diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/status_filter.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/status_filter.tsx index d9f7802050d8d..f8c3ff8880857 100644 --- a/x-pack/plugins/alerting/public/pages/maintenance_windows/components/status_filter.tsx +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/components/status_filter.tsx @@ -7,74 +7,73 @@ import React, { useState, useCallback } from 'react'; import { EuiFilterButton, EuiPopover, EuiFilterGroup, EuiFilterSelectItem } from '@elastic/eui'; -import { CustomComponentProps } from '@elastic/eui/src/components/search_bar/filters/custom_component_filter'; import { STATUS_OPTIONS } from '../constants'; import * as i18n from '../translations'; +import { MaintenanceWindowStatus } from '../../../../common'; -export const StatusFilter: React.FC = React.memo(({ query, onChange }) => { - const [selectedOptions, setSelectedOptions] = useState([]); - const [isPopoverOpen, setIsPopoverOpen] = useState(false); +export interface RuleStatusFilterProps { + selectedStatus: MaintenanceWindowStatus[]; + onChange: (selectedStatus: MaintenanceWindowStatus[]) => void; +} - const onFilterItemClick = useCallback( - (newOption: string) => () => { - const options = selectedOptions.includes(newOption) - ? selectedOptions.filter((option) => option !== newOption) - : [...selectedOptions, newOption]; - setSelectedOptions(options); +export const StatusFilter: React.FC = React.memo( + ({ selectedStatus, onChange }) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); - let q = query.removeSimpleFieldClauses('status').removeOrFieldClauses('status'); - if (options.length > 0) { - q = options.reduce((acc, curr) => { - return acc.addOrFieldValue('status', curr, true, 'eq'); - }, q); - } - onChange?.(q); - }, - [query, onChange, selectedOptions] - ); + const onFilterItemClick = useCallback( + (newOption: MaintenanceWindowStatus) => () => { + const options = selectedStatus.includes(newOption) + ? selectedStatus.filter((option) => option !== newOption) + : [...selectedStatus, newOption]; + onChange(options); + }, + [onChange, selectedStatus] + ); - const openPopover = useCallback(() => { - setIsPopoverOpen((prevIsOpen) => !prevIsOpen); - }, [setIsPopoverOpen]); + const openPopover = useCallback(() => { + setIsPopoverOpen((prevIsOpen) => !prevIsOpen); + }, [setIsPopoverOpen]); - const closePopover = useCallback(() => { - setIsPopoverOpen(false); - }, [setIsPopoverOpen]); + const closePopover = useCallback(() => { + setIsPopoverOpen(false); + }, [setIsPopoverOpen]); + + return ( + + 0} + numActiveFilters={selectedStatus.length} + numFilters={selectedStatus.length} + onClick={openPopover} + > + {i18n.TABLE_STATUS} + + } + > + <> + {STATUS_OPTIONS.map((status) => { + return ( + + {status.name} + + ); + })} + + + + ); + } +); - return ( - - 0} - numActiveFilters={selectedOptions.length} - numFilters={selectedOptions.length} - onClick={openPopover} - > - {i18n.TABLE_STATUS} - - } - > - <> - {STATUS_OPTIONS.map((status) => { - return ( - - {status.name} - - ); - })} - - - - ); -}); StatusFilter.displayName = 'StatusFilter'; diff --git a/x-pack/plugins/alerting/public/pages/maintenance_windows/index.test.tsx b/x-pack/plugins/alerting/public/pages/maintenance_windows/index.test.tsx index c0fa96a8e1637..ad3f8cd52b2af 100644 --- a/x-pack/plugins/alerting/public/pages/maintenance_windows/index.test.tsx +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/index.test.tsx @@ -35,7 +35,7 @@ describe('Maintenance windows page', () => { jest.clearAllMocks(); (useFindMaintenanceWindows as jest.Mock).mockReturnValue({ isLoading: false, - maintenanceWindows: [], + data: { maintenanceWindows: [], total: 0 }, refetch: jest.fn(), }); license = licensingMock.createLicense({ 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 f88cba50d8a39..f1f08f5f37c1f 100644 --- a/x-pack/plugins/alerting/public/pages/maintenance_windows/index.tsx +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { EuiButton, EuiFlexGroup, @@ -23,11 +23,16 @@ import * as i18n from './translations'; import { useCreateMaintenanceWindowNavigation } from '../../hooks/use_navigation'; import { MaintenanceWindowsList } from './components/maintenance_windows_list'; import { useFindMaintenanceWindows } from '../../hooks/use_find_maintenance_windows'; -import { CenterJustifiedSpinner } from './components/center_justified_spinner'; import { ExperimentalBadge } from './components/page_header'; import { useLicense } from '../../hooks/use_license'; import { LicensePrompt } from './components/license_prompt'; -import { MAINTENANCE_WINDOW_FEATURE_ID, MAINTENANCE_WINDOW_DEEP_LINK_IDS } from '../../../common'; +import { + MAINTENANCE_WINDOW_FEATURE_ID, + MAINTENANCE_WINDOW_DEEP_LINK_IDS, + MAINTENANCE_WINDOW_DEFAULT_PER_PAGE, + MAINTENANCE_WINDOW_DEFAULT_TABLE_ACTIVE_PAGE, + MaintenanceWindowStatus, +} from '../../../common'; export const MaintenanceWindowsPage = React.memo(() => { const { @@ -38,12 +43,24 @@ export const MaintenanceWindowsPage = React.memo(() => { const { isAtLeastPlatinum } = useLicense(); const hasLicense = isAtLeastPlatinum(); + const [page, setPage] = useState(MAINTENANCE_WINDOW_DEFAULT_TABLE_ACTIVE_PAGE); + const [perPage, setPerPage] = useState(MAINTENANCE_WINDOW_DEFAULT_PER_PAGE); + + const [selectedStatus, setSelectedStatus] = useState([]); + const [search, setSearch] = useState(''); + const { navigateToCreateMaintenanceWindow } = useCreateMaintenanceWindowNavigation(); - const { isLoading, isInitialLoading, maintenanceWindows, refetch } = useFindMaintenanceWindows({ + const { isLoading, isInitialLoading, data, refetch } = useFindMaintenanceWindows({ enabled: hasLicense, + page, + perPage, + search, + selectedStatus, }); + const { maintenanceWindows, total } = data || { maintenanceWindows: [], total: 0 }; + useBreadcrumbs(MAINTENANCE_WINDOW_DEEP_LINK_IDS.maintenanceWindows); const handleClickCreate = useCallback(() => { @@ -53,9 +70,12 @@ export const MaintenanceWindowsPage = React.memo(() => { const refreshData = useCallback(() => refetch(), [refetch]); const showWindowMaintenance = capabilities[MAINTENANCE_WINDOW_FEATURE_ID].show; const writeWindowMaintenance = capabilities[MAINTENANCE_WINDOW_FEATURE_ID].save; + const isNotFiltered = search === '' && selectedStatus.length === 0; + const showEmptyPrompt = !isLoading && maintenanceWindows.length === 0 && + isNotFiltered && showWindowMaintenance && writeWindowMaintenance; @@ -81,9 +101,21 @@ export const MaintenanceWindowsPage = React.memo(() => { }; }, [setBadge, chrome]); - if (isInitialLoading) { - return ; - } + const onPageChange = useCallback( + ({ page: { index, size } }: { page: { index: number; size: number } }) => { + setPage(index + 1); + setPerPage(size); + }, + [] + ); + + const onSelectedStatusChange = useCallback((status: MaintenanceWindowStatus[]) => { + setSelectedStatus(status); + }, []); + + const onSearchChange = useCallback((value: string) => { + setSearch(value); + }, []); return ( <> @@ -127,14 +159,22 @@ export const MaintenanceWindowsPage = React.memo(() => { )} ); }); + MaintenanceWindowsPage.displayName = 'MaintenanceWindowsPage'; // eslint-disable-next-line import/no-default-export export { MaintenanceWindowsPage as default }; 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 16b2ad825a2e1..71f0e82fbb48b 100644 --- a/x-pack/plugins/alerting/public/pages/maintenance_windows/translations.ts +++ b/x-pack/plugins/alerting/public/pages/maintenance_windows/translations.ts @@ -721,3 +721,8 @@ export const START_TRIAL = i18n.translate( export const REFRESH = i18n.translate('xpack.alerting.maintenanceWindows.refreshButton', { defaultMessage: 'Refresh', }); + +export const SEARCH_PLACEHOLDER = i18n.translate( + 'xpack.alerting.maintenanceWindows.searchPlaceholder', + { defaultMessage: 'Search' } +); diff --git a/x-pack/plugins/alerting/public/services/maintenance_windows_api/find.test.ts b/x-pack/plugins/alerting/public/services/maintenance_windows_api/find.test.ts index 14b7336187293..e2509521e79a7 100644 --- a/x-pack/plugins/alerting/public/services/maintenance_windows_api/find.test.ts +++ b/x-pack/plugins/alerting/public/services/maintenance_windows_api/find.test.ts @@ -69,11 +69,26 @@ describe('findMaintenanceWindows', () => { }, ]; - const result = await findMaintenanceWindows({ http }); - expect(result).toEqual(maintenanceWindow); + const result = await findMaintenanceWindows({ + http, + page: 1, + perPage: 10, + search: '', + selectedStatus: [], + }); + + expect(result).toEqual({ maintenanceWindows: maintenanceWindow, total: 1 }); expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` Array [ "/internal/alerting/rules/maintenance_window/_find", + Object { + "query": Object { + "page": 1, + "per_page": 10, + "search": "", + "status": Array [], + }, + }, ] `); }); diff --git a/x-pack/plugins/alerting/public/services/maintenance_windows_api/find.ts b/x-pack/plugins/alerting/public/services/maintenance_windows_api/find.ts index 822fb6e2bae1f..c299f0975feb6 100644 --- a/x-pack/plugins/alerting/public/services/maintenance_windows_api/find.ts +++ b/x-pack/plugins/alerting/public/services/maintenance_windows_api/find.ts @@ -4,20 +4,37 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + import type { HttpSetup } from '@kbn/core/public'; -import type { MaintenanceWindow } from '../../../common'; +import type { MaintenanceWindow, MaintenanceWindowStatus } from '../../../common'; import type { FindMaintenanceWindowsResponse } from '../../../common/routes/maintenance_window/apis/find'; - -import { INTERNAL_BASE_ALERTING_API_PATH } from '../../../common'; import { transformMaintenanceWindowResponse } from './transform_maintenance_window_response'; +import { INTERNAL_BASE_ALERTING_API_PATH } from '../../../common'; export async function findMaintenanceWindows({ http, + page, + perPage, + search, + selectedStatus, }: { http: HttpSetup; -}): Promise { + page: number; + perPage: number; + search: string; + selectedStatus: MaintenanceWindowStatus[]; +}): Promise<{ maintenanceWindows: MaintenanceWindow[]; total: number }> { const res = await http.get( - `${INTERNAL_BASE_ALERTING_API_PATH}/rules/maintenance_window/_find` + `${INTERNAL_BASE_ALERTING_API_PATH}/rules/maintenance_window/_find`, + { + query: { + page, + per_page: perPage, + search, + status: selectedStatus, + }, + } ); - return res.data.map((mw) => transformMaintenanceWindowResponse(mw)); + + return { maintenanceWindows: res.data.map(transformMaintenanceWindowResponse), total: res.total }; } diff --git a/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/find_maintenance_windows.test.ts b/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/find_maintenance_windows.test.ts index 35c15ebb57c61..5e52824d3e249 100644 --- a/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/find_maintenance_windows.test.ts +++ b/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/find_maintenance_windows.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { findMaintenanceWindows } from './find_maintenance_windows'; +import { findMaintenanceWindows, getStatusFilter } from './find_maintenance_windows'; import { savedObjectsClientMock, loggingSystemMock, @@ -15,6 +15,7 @@ import { SavedObjectsFindResponse } from '@kbn/core/server'; import { MaintenanceWindowClientContext, MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE, + MaintenanceWindowStatus, } from '../../../../../common'; import { getMockMaintenanceWindow } from '../../../../data/maintenance_window/test_helpers'; import { findMaintenanceWindowsParamsSchema } from './schemas'; @@ -95,10 +96,28 @@ describe('MaintenanceWindowClient - find', () => { per_page: 5, } as unknown as SavedObjectsFindResponse); - const result = await findMaintenanceWindows(mockContext, {}); + const result = await findMaintenanceWindows(mockContext, { status: ['running'] }); - expect(spy).toHaveBeenCalledWith({}); + expect(spy).toHaveBeenCalledWith({ status: ['running'] }); expect(savedObjectsClient.find).toHaveBeenLastCalledWith({ + filter: { + arguments: [ + { + isQuoted: false, + type: 'literal', + value: 'maintenance-window.attributes.events', + }, + { + isQuoted: true, + type: 'literal', + value: 'now', + }, + ], + function: 'is', + type: 'function', + }, + sortField: 'updatedAt', + sortOrder: 'desc', type: MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE, }); @@ -109,3 +128,161 @@ describe('MaintenanceWindowClient - find', () => { expect(result.perPage).toEqual(5); }); }); + +describe('getStatusFilter', () => { + it('return proper filter for running status', () => { + expect(getStatusFilter(['running'])).toMatchInlineSnapshot(` + Object { + "arguments": Array [ + Object { + "isQuoted": false, + "type": "literal", + "value": "maintenance-window.attributes.events", + }, + Object { + "isQuoted": true, + "type": "literal", + "value": "now", + }, + ], + "function": "is", + "type": "function", + } + `); + }); + + it('return proper filter for upcomimg status', () => { + expect(getStatusFilter(['upcoming'])).toMatchInlineSnapshot(` + Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "isQuoted": false, + "type": "literal", + "value": "maintenance-window.attributes.events", + }, + Object { + "isQuoted": true, + "type": "literal", + "value": "now", + }, + ], + "function": "is", + "type": "function", + }, + ], + "function": "not", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "isQuoted": false, + "type": "literal", + "value": "maintenance-window.attributes.events", + }, + "gt", + Object { + "isQuoted": true, + "type": "literal", + "value": "now", + }, + ], + "function": "range", + "type": "function", + }, + ], + "function": "and", + "type": "function", + } + `); + }); + + it('return proper filter for fininshed status', () => { + expect(getStatusFilter(['finished'])).toMatchInlineSnapshot(` + Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "isQuoted": false, + "type": "literal", + "value": "maintenance-window.attributes.events", + }, + "gte", + Object { + "isQuoted": true, + "type": "literal", + "value": "now", + }, + ], + "function": "range", + "type": "function", + }, + ], + "function": "not", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "isQuoted": false, + "type": "literal", + "value": "maintenance-window.attributes.expirationDate", + }, + "gt", + Object { + "isQuoted": true, + "type": "literal", + "value": "now", + }, + ], + "function": "range", + "type": "function", + }, + ], + "function": "and", + "type": "function", + } + `); + }); + + it('return proper filter for archived status', () => { + expect(getStatusFilter(['archived'])).toMatchInlineSnapshot(` + Object { + "arguments": Array [ + Object { + "isQuoted": false, + "type": "literal", + "value": "maintenance-window.attributes.expirationDate", + }, + "lt", + Object { + "isQuoted": true, + "type": "literal", + "value": "now", + }, + ], + "function": "range", + "type": "function", + } + `); + }); + + it('return empty string if status does not exist', () => { + expect(getStatusFilter(['weird' as MaintenanceWindowStatus])).toBeUndefined(); + }); + + it('return empty string if pass empty arguments', () => { + expect(getStatusFilter()).toBeUndefined(); + }); + + it('return empty string if pass empty array', () => { + expect(getStatusFilter([])).toBeUndefined(); + }); +}); diff --git a/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/find_maintenance_windows.ts b/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/find_maintenance_windows.ts index 5cb1e01c1f1a0..86db5b699a029 100644 --- a/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/find_maintenance_windows.ts +++ b/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/find_maintenance_windows.ts @@ -6,12 +6,39 @@ */ import Boom from '@hapi/boom'; -import { MaintenanceWindowClientContext } from '../../../../../common'; +import { fromKueryExpression, KueryNode } from '@kbn/es-query'; +import { MaintenanceWindowClientContext, MaintenanceWindowStatus } from '../../../../../common'; import { transformMaintenanceWindowAttributesToMaintenanceWindow } from '../../transforms'; import { findMaintenanceWindowSo } from '../../../../data/maintenance_window'; -import type { FindMaintenanceWindowsResult, FindMaintenanceWindowsParams } from './types'; +import type { + FindMaintenanceWindowsResult, + FindMaintenanceWindowsParams, + MaintenanceWindowsStatus, +} from './types'; import { findMaintenanceWindowsParamsSchema } from './schemas'; +export const getStatusFilter = ( + status?: MaintenanceWindowsStatus[] +): KueryNode | string | undefined => { + if (!status || status.length === 0) return undefined; + + const statusToQueryMapping = { + [MaintenanceWindowStatus.Running]: '(maintenance-window.attributes.events: "now")', + [MaintenanceWindowStatus.Upcoming]: + '(not maintenance-window.attributes.events: "now" and maintenance-window.attributes.events > "now")', + [MaintenanceWindowStatus.Finished]: + '(not maintenance-window.attributes.events >= "now" and maintenance-window.attributes.expirationDate >"now")', + [MaintenanceWindowStatus.Archived]: '(maintenance-window.attributes.expirationDate < "now")', + }; + + const fullQuery = status + .map((value) => statusToQueryMapping[value]) + .filter(Boolean) + .join(' or '); + + return fullQuery ? fromKueryExpression(fullQuery) : undefined; +}; + export async function findMaintenanceWindows( context: MaintenanceWindowClientContext, params?: FindMaintenanceWindowsParams @@ -26,11 +53,20 @@ export async function findMaintenanceWindows( throw Boom.badRequest(`Error validating find maintenance windows data - ${error.message}`); } + const filter = getStatusFilter(params?.status); + try { const result = await findMaintenanceWindowSo({ savedObjectsClient, ...(params - ? { savedObjectsFindOptions: { page: params.page, perPage: params.perPage } } + ? { + savedObjectsFindOptions: { + ...(params.page ? { page: params.page } : {}), + ...(params.perPage ? { perPage: params.perPage } : {}), + ...(params.search ? { search: params.search } : {}), + ...(filter ? { filter } : {}), + }, + } : {}), }); diff --git a/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/schemas/find_maintenance_window_params_schema.ts b/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/schemas/find_maintenance_window_params_schema.ts index e874882450c26..c5c3c0242eebb 100644 --- a/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/schemas/find_maintenance_window_params_schema.ts +++ b/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/schemas/find_maintenance_window_params_schema.ts @@ -7,7 +7,16 @@ import { schema } from '@kbn/config-schema'; +export const maintenanceWindowsStatusSchema = schema.oneOf([ + schema.literal('running'), + schema.literal('finished'), + schema.literal('upcoming'), + schema.literal('archived'), +]); + export const findMaintenanceWindowsParamsSchema = schema.object({ perPage: schema.maybe(schema.number()), page: schema.maybe(schema.number()), + search: schema.maybe(schema.string()), + status: schema.maybe(schema.arrayOf(maintenanceWindowsStatusSchema)), }); diff --git a/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/schemas/index.ts b/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/schemas/index.ts index 4e6c55b08955f..b7ef0b37cb378 100644 --- a/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/schemas/index.ts +++ b/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/schemas/index.ts @@ -6,4 +6,7 @@ */ export { findMaintenanceWindowsResultSchema } from './find_maintenance_windows_result_schema'; -export { findMaintenanceWindowsParamsSchema } from './find_maintenance_window_params_schema'; +export { + findMaintenanceWindowsParamsSchema, + maintenanceWindowsStatusSchema, +} from './find_maintenance_window_params_schema'; diff --git a/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/types/find_maintenance_window_params.ts b/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/types/find_maintenance_window_params.ts index 878d5168c7e55..5e3aced564cca 100644 --- a/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/types/find_maintenance_window_params.ts +++ b/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/types/find_maintenance_window_params.ts @@ -6,6 +6,7 @@ */ import { TypeOf } from '@kbn/config-schema'; -import { findMaintenanceWindowsParamsSchema } from '../schemas'; +import { findMaintenanceWindowsParamsSchema, maintenanceWindowsStatusSchema } from '../schemas'; +export type MaintenanceWindowsStatus = TypeOf; export type FindMaintenanceWindowsParams = TypeOf; diff --git a/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/types/index.ts b/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/types/index.ts index 97472fc231ab6..1c4e5cdcfb4d1 100644 --- a/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/types/index.ts +++ b/x-pack/plugins/alerting/server/application/maintenance_window/methods/find/types/index.ts @@ -6,4 +6,7 @@ */ export type { FindMaintenanceWindowsResult } from './find_maintenance_window_result'; -export type { FindMaintenanceWindowsParams } from './find_maintenance_window_params'; +export type { + FindMaintenanceWindowsParams, + MaintenanceWindowsStatus, +} from './find_maintenance_window_params'; diff --git a/x-pack/plugins/alerting/server/data/maintenance_window/methods/find_maintenance_window_so.ts b/x-pack/plugins/alerting/server/data/maintenance_window/methods/find_maintenance_window_so.ts index d08a3c360cbb0..82c8e3d65a98a 100644 --- a/x-pack/plugins/alerting/server/data/maintenance_window/methods/find_maintenance_window_so.ts +++ b/x-pack/plugins/alerting/server/data/maintenance_window/methods/find_maintenance_window_so.ts @@ -24,7 +24,9 @@ export const findMaintenanceWindowSo = ({ - ...(savedObjectsFindOptions ? savedObjectsFindOptions : {}), + ...(savedObjectsFindOptions + ? { ...savedObjectsFindOptions, sortField: 'updatedAt', sortOrder: 'desc' } + : {}), type: MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE, }); }; diff --git a/x-pack/plugins/alerting/server/routes/maintenance_window/apis/find/find_maintenance_windows_route.test.ts b/x-pack/plugins/alerting/server/routes/maintenance_window/apis/find/find_maintenance_windows_route.test.ts index bbabb42b28644..41fd22b6dfd41 100644 --- a/x-pack/plugins/alerting/server/routes/maintenance_window/apis/find/find_maintenance_windows_route.test.ts +++ b/x-pack/plugins/alerting/server/routes/maintenance_window/apis/find/find_maintenance_windows_route.test.ts @@ -102,6 +102,8 @@ describe('findMaintenanceWindowsRoute', () => { query: { page: 1, per_page: 3, + search: 'mw name', + status: ['running'], }, } ); @@ -125,7 +127,12 @@ describe('findMaintenanceWindowsRoute', () => { await handler(context, req, res); - expect(maintenanceWindowClient.find).toHaveBeenCalledWith({ page: 1, perPage: 3 }); + expect(maintenanceWindowClient.find).toHaveBeenCalledWith({ + page: 1, + perPage: 3, + search: 'mw name', + status: ['running'], + }); expect(res.ok).toHaveBeenLastCalledWith({ body: { data: mockMaintenanceWindows.data.map((data) => rewriteMaintenanceWindowRes(data)), diff --git a/x-pack/plugins/alerting/server/routes/maintenance_window/apis/find/transforms/transform_find_maintenance_window_params/v1.test.ts b/x-pack/plugins/alerting/server/routes/maintenance_window/apis/find/transforms/transform_find_maintenance_window_params/v1.test.ts new file mode 100644 index 0000000000000..566166f3baa0e --- /dev/null +++ b/x-pack/plugins/alerting/server/routes/maintenance_window/apis/find/transforms/transform_find_maintenance_window_params/v1.test.ts @@ -0,0 +1,61 @@ +/* + * 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 { transformFindMaintenanceWindowParams } from './v1'; + +describe('transformFindMaintenanceWindowParams', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('passing string in status should return array', () => { + const result = transformFindMaintenanceWindowParams({ + page: 1, + per_page: 10, + search: 'fake mw name', + status: 'running', + }); + + expect(result).toEqual({ page: 1, perPage: 10, search: 'fake mw name', status: ['running'] }); + }); + + it('passing undefined in status should return object without status', () => { + const result = transformFindMaintenanceWindowParams({ + page: 1, + per_page: 10, + search: 'fake mw name', + }); + + expect(result).toEqual({ page: 1, perPage: 10, search: 'fake mw name' }); + }); + + it('passing undefined in search should return object without search', () => { + const result = transformFindMaintenanceWindowParams({ + page: 1, + per_page: 10, + status: ['upcoming'], + }); + + expect(result).toEqual({ page: 1, perPage: 10, status: ['upcoming'] }); + }); + + it('passing array in status should return array', () => { + const result = transformFindMaintenanceWindowParams({ + page: 1, + per_page: 10, + search: 'fake mw name', + status: ['upcoming', 'finished'], + }); + + expect(result).toEqual({ + page: 1, + perPage: 10, + search: 'fake mw name', + status: ['upcoming', 'finished'], + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/routes/maintenance_window/apis/find/transforms/transform_find_maintenance_window_params/v1.ts b/x-pack/plugins/alerting/server/routes/maintenance_window/apis/find/transforms/transform_find_maintenance_window_params/v1.ts index c59f5d189716e..4445c1a0ff60e 100644 --- a/x-pack/plugins/alerting/server/routes/maintenance_window/apis/find/transforms/transform_find_maintenance_window_params/v1.ts +++ b/x-pack/plugins/alerting/server/routes/maintenance_window/apis/find/transforms/transform_find_maintenance_window_params/v1.ts @@ -10,7 +10,13 @@ import { FindMaintenanceWindowsParams } from '../../../../../../application/main export const transformFindMaintenanceWindowParams = ( params: FindMaintenanceWindowsRequestQuery -): FindMaintenanceWindowsParams => ({ - ...(params.page ? { page: params.page } : {}), - ...(params.per_page ? { perPage: params.per_page } : {}), -}); +): FindMaintenanceWindowsParams => { + const status = params.status && !Array.isArray(params.status) ? [params.status] : params.status; + + return { + ...(params.page ? { page: params.page } : {}), + ...(params.per_page ? { perPage: params.per_page } : {}), + ...(params.search ? { search: params.search } : {}), + ...(params.status ? { status } : {}), + }; +}; diff --git a/x-pack/plugins/alerting/server/saved_objects/maintenance_window_mapping.ts b/x-pack/plugins/alerting/server/saved_objects/maintenance_window_mapping.ts index 48bf1ab83dcc8..85759143c39d2 100644 --- a/x-pack/plugins/alerting/server/saved_objects/maintenance_window_mapping.ts +++ b/x-pack/plugins/alerting/server/saved_objects/maintenance_window_mapping.ts @@ -17,21 +17,24 @@ export const maintenanceWindowMappings: SavedObjectsTypeMappingDefinition = { type: 'date_range', format: 'epoch_millis||strict_date_optional_time', }, + title: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + }, + }, + }, + expirationDate: { + type: 'date', + }, + updatedAt: { + type: 'date', + }, // NO NEED TO BE INDEXED - // title: { - // type: 'text', - // fields: { - // keyword: { - // type: 'keyword', - // }, - // }, - // }, // duration: { // type: 'long', // }, - // expirationDate: { - // type: 'date', - // }, // rRule: rRuleMappingsField, // createdBy: { // index: false, @@ -45,9 +48,5 @@ export const maintenanceWindowMappings: SavedObjectsTypeMappingDefinition = { // index: false, // type: 'date', // }, - // updatedAt: { - // index: false, - // type: 'date', - // }, }, }; diff --git a/x-pack/plugins/alerting/server/saved_objects/model_versions/maintenance_window_model_versions.ts b/x-pack/plugins/alerting/server/saved_objects/model_versions/maintenance_window_model_versions.ts index dbfda11dc85fc..f7ea00e62880e 100644 --- a/x-pack/plugins/alerting/server/saved_objects/model_versions/maintenance_window_model_versions.ts +++ b/x-pack/plugins/alerting/server/saved_objects/model_versions/maintenance_window_model_versions.ts @@ -16,4 +16,30 @@ export const maintenanceWindowModelVersions: SavedObjectsModelVersionMap = { create: rawMaintenanceWindowSchemaV1, }, }, + '2': { + changes: [ + { + type: 'mappings_addition', + addedMappings: { + title: { + type: 'text', + fields: { + keyword: { + type: 'keyword', + }, + }, + }, + expirationDate: { + type: 'date', + }, + updatedAt: { + type: 'date', + }, + }, + }, + ], + schemas: { + forwardCompatibility: rawMaintenanceWindowSchemaV1.extends({}, { unknowns: 'ignore' }), + }, + }, }; diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/maintenance_window/find_maintenance_windows.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/maintenance_window/find_maintenance_windows.ts index a91a323ac1250..7c0067b159c8e 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/maintenance_window/find_maintenance_windows.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group3/tests/maintenance_window/find_maintenance_windows.ts @@ -26,6 +26,7 @@ export default function findMaintenanceWindowTests({ getService }: FtrProviderCo freq: 2, // weekly }, }; + afterEach(() => objectRemover.removeAll()); for (const scenario of UserAtSpaceScenarios) { @@ -146,8 +147,8 @@ export default function findMaintenanceWindowTests({ getService }: FtrProviderCo case 'space_1_all at space1': expect(response.body.total).to.eql(2); expect(response.statusCode).to.eql(200); - expect(response.body.data[0].id).to.eql(createdMaintenanceWindow1.id); - expect(response.body.data[0].title).to.eql('test-maintenance-window'); + expect(response.body.data[0].id).to.eql(createdMaintenanceWindow2.id); + expect(response.body.data[0].title).to.eql('test-maintenance-window2'); break; default: throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); @@ -205,6 +206,340 @@ export default function findMaintenanceWindowTests({ getService }: FtrProviderCo throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); } }); + + it('should filter maintenance windows based on search text', async () => { + const { body: createdMaintenanceWindow1 } = await supertest + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/maintenance_window`) + .set('kbn-xsrf', 'foo') + .send(createParams); + + const { body: createdMaintenanceWindow2 } = await supertest + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/maintenance_window`) + .set('kbn-xsrf', 'foo') + .send({ ...createParams, title: 'search-name' }); + + objectRemover.add( + space.id, + createdMaintenanceWindow1.id, + 'rules/maintenance_window', + 'alerting', + true + ); + objectRemover.add( + space.id, + createdMaintenanceWindow2.id, + 'rules/maintenance_window', + 'alerting', + true + ); + + const response = await supertestWithoutAuth + .get( + `${getUrlPrefix( + space.id + )}/internal/alerting/rules/maintenance_window/_find?page=1&per_page=1&search=search-name` + ) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send({}); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'space_1_all_with_restricted_fixture at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: + 'API [GET /internal/alerting/rules/maintenance_window/_find?page=1&per_page=1&search=search-name] is unauthorized for user, this action is granted by the Kibana privileges [read-maintenance-window]', + statusCode: 403, + }); + break; + case 'global_read at space1': + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.body.total).to.eql(1); + expect(response.statusCode).to.eql(200); + expect(response.body.data[0].id).to.eql(createdMaintenanceWindow2.id); + expect(response.body.data[0].title).to.eql('search-name'); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should filter maintenance windows based on running status', async () => { + const { body: runningMaintenanceWindow } = await supertest + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/maintenance_window`) + .set('kbn-xsrf', 'foo') + .send({ ...createParams, title: 'test-running-maintenance-window' }); + + const { body: upcomingMaintenanceWindow } = await supertest + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/maintenance_window`) + .set('kbn-xsrf', 'foo') + .send({ + title: 'test-upcoming-maintenance-window', + duration: 60 * 60 * 1000, // 1 hr + r_rule: { + dtstart: new Date(new Date().getTime() - 60 * 60 * 1000).toISOString(), + tzid: 'UTC', + freq: 2, // weekly + }, + }); + const { body: finishedMaintenanceWindow } = await supertest + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/maintenance_window`) + .set('kbn-xsrf', 'foo') + .send({ + title: 'test-finished-maintenance-window', + duration: 60 * 60 * 1000, // 1 hr + r_rule: { + dtstart: new Date('05-01-2023').toISOString(), + tzid: 'UTC', + freq: 1, + count: 1, + }, + }); + + objectRemover.add( + space.id, + runningMaintenanceWindow.id, + 'rules/maintenance_window', + 'alerting', + true + ); + objectRemover.add( + space.id, + upcomingMaintenanceWindow.id, + 'rules/maintenance_window', + 'alerting', + true + ); + objectRemover.add( + space.id, + finishedMaintenanceWindow.id, + 'rules/maintenance_window', + 'alerting', + true + ); + + const response = await supertestWithoutAuth + .get( + `${getUrlPrefix( + space.id + )}/internal/alerting/rules/maintenance_window/_find?page=1&per_page=10&status=running` + ) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send({}); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'space_1_all_with_restricted_fixture at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: + 'API [GET /internal/alerting/rules/maintenance_window/_find?page=1&per_page=10&status=running] is unauthorized for user, this action is granted by the Kibana privileges [read-maintenance-window]', + statusCode: 403, + }); + break; + case 'global_read at space1': + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.body.total).to.eql(1); + expect(response.statusCode).to.eql(200); + expect(response.body.data[0].title).to.eql('test-running-maintenance-window'); + expect(response.body.data[0].status).to.eql('running'); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should filter maintenance windows based on upcomimg status', async () => { + const { body: runningMaintenanceWindow } = await supertest + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/maintenance_window`) + .set('kbn-xsrf', 'foo') + .send({ ...createParams, title: 'test-running-maintenance-window' }); + + const { body: upcomingMaintenanceWindow } = await supertest + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/maintenance_window`) + .set('kbn-xsrf', 'foo') + .send({ + title: 'test-upcoming-maintenance-window', + duration: 60 * 60 * 1000, // 1 hr + r_rule: { + dtstart: new Date(new Date().getTime() - 60 * 60 * 1000).toISOString(), + tzid: 'UTC', + freq: 2, // weekly + }, + }); + const { body: finishedMaintenanceWindow } = await supertest + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/maintenance_window`) + .set('kbn-xsrf', 'foo') + .send({ + title: 'test-finished-maintenance-window', + duration: 60 * 60 * 1000, // 1 hr + r_rule: { + dtstart: new Date('05-01-2023').toISOString(), + tzid: 'UTC', + freq: 1, + count: 1, + }, + }); + + objectRemover.add( + space.id, + runningMaintenanceWindow.id, + 'rules/maintenance_window', + 'alerting', + true + ); + objectRemover.add( + space.id, + upcomingMaintenanceWindow.id, + 'rules/maintenance_window', + 'alerting', + true + ); + objectRemover.add( + space.id, + finishedMaintenanceWindow.id, + 'rules/maintenance_window', + 'alerting', + true + ); + + const response = await supertestWithoutAuth + .get( + `${getUrlPrefix( + space.id + )}/internal/alerting/rules/maintenance_window/_find?page=1&per_page=10&status=upcoming` + ) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send({}); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'space_1_all_with_restricted_fixture at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: + 'API [GET /internal/alerting/rules/maintenance_window/_find?page=1&per_page=10&status=upcoming] is unauthorized for user, this action is granted by the Kibana privileges [read-maintenance-window]', + statusCode: 403, + }); + break; + case 'global_read at space1': + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.body.total).to.eql(1); + expect(response.statusCode).to.eql(200); + expect(response.body.data[0].title).to.eql('test-upcoming-maintenance-window'); + expect(response.body.data[0].status).to.eql('upcoming'); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should filter maintenance windows based on finished and running status', async () => { + const { body: runningMaintenanceWindow } = await supertest + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/maintenance_window`) + .set('kbn-xsrf', 'foo') + .send({ ...createParams, title: 'test-running-maintenance-window' }); + + const { body: upcomingMaintenanceWindow } = await supertest + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/maintenance_window`) + .set('kbn-xsrf', 'foo') + .send({ + title: 'test-upcoming-maintenance-window', + duration: 60 * 60 * 1000, // 1 hr + r_rule: { + dtstart: new Date(new Date().getTime() - 60 * 60 * 1000).toISOString(), + tzid: 'UTC', + freq: 2, // weekly + }, + }); + const { body: finishedMaintenanceWindow } = await supertest + .post(`${getUrlPrefix(space.id)}/internal/alerting/rules/maintenance_window`) + .set('kbn-xsrf', 'foo') + .send({ + title: 'test-finished-maintenance-window', + duration: 60 * 60 * 1000, // 1 hr + r_rule: { + dtstart: new Date('05-01-2023').toISOString(), + tzid: 'UTC', + freq: 1, + count: 1, + }, + }); + + objectRemover.add( + space.id, + runningMaintenanceWindow.id, + 'rules/maintenance_window', + 'alerting', + true + ); + objectRemover.add( + space.id, + upcomingMaintenanceWindow.id, + 'rules/maintenance_window', + 'alerting', + true + ); + objectRemover.add( + space.id, + finishedMaintenanceWindow.id, + 'rules/maintenance_window', + 'alerting', + true + ); + + const response = await supertestWithoutAuth + .get( + `${getUrlPrefix( + space.id + )}/internal/alerting/rules/maintenance_window/_find?page=1&per_page=10&status=running&status=finished` + ) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send({}); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'space_1_all_with_restricted_fixture at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: + 'API [GET /internal/alerting/rules/maintenance_window/_find?page=1&per_page=10&status=running&status=finished] is unauthorized for user, this action is granted by the Kibana privileges [read-maintenance-window]', + statusCode: 403, + }); + break; + case 'global_read at space1': + case 'superuser at space1': + case 'space_1_all at space1': + expect(response.body.total).to.eql(2); + expect(response.statusCode).to.eql(200); + expect(response.body.data[0].title).to.eql('test-finished-maintenance-window'); + expect(response.body.data[0].status).to.eql('finished'); + expect(response.body.data[1].title).to.eql('test-running-maintenance-window'); + expect(response.body.data[1].status).to.eql('running'); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); }); } }); diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/maintenance_windows/maintenance_windows_table.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/maintenance_windows/maintenance_windows_table.ts index 5618a1a77cff6..4ebab9b03d307 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/maintenance_windows/maintenance_windows_table.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/maintenance_windows/maintenance_windows_table.ts @@ -29,7 +29,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await objectRemover.removeAll(); }); - it('should should cancel a running maintenance window', async () => { + it('should cancel a running maintenance window', async () => { const name = generateUniqueKey(); const createdMaintenanceWindow = await createMaintenanceWindow({ name, @@ -60,7 +60,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { expect(list[0].status).to.not.eql('Running'); }); - it('should should archive finished maintenance window', async () => { + it('should archive finished maintenance window', async () => { const name = generateUniqueKey(); const createdMaintenanceWindow = await createMaintenanceWindow({ name, @@ -93,7 +93,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { expect(list[0].status).to.eql('Archived'); }); - it('should should cancel and archive a running maintenance window', async () => { + it('should cancel and archive a running maintenance window', async () => { const name = generateUniqueKey(); const createdMaintenanceWindow = await createMaintenanceWindow({ name, @@ -124,7 +124,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { expect(list[0].status).to.eql('Archived'); }); - it('should should unarchive a maintenance window', async () => { + it('should unarchive a maintenance window', async () => { const name = generateUniqueKey(); const createdMaintenanceWindow = await createMaintenanceWindow({ name, @@ -209,5 +209,85 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { expect(upcomingList[0].status).to.equal('Upcoming'); }); }); + + it('should filter maintenance windows by the archived status', async () => { + const finished = await createMaintenanceWindow({ + name: 'finished-maintenance-window', + startDate: new Date('05-01-2023'), + notRecurring: true, + getService, + }); + objectRemover.add(finished.id, 'rules/maintenance_window', 'alerting', true); + + const date = new Date(); + date.setDate(date.getDate() + 1); + const upcoming = await createMaintenanceWindow({ + name: 'upcoming-maintenance-window', + startDate: date, + getService, + }); + objectRemover.add(upcoming.id, 'rules/maintenance_window', 'alerting', true); + + const archived = await createMaintenanceWindow({ + name: 'archived-maintenance-window', + getService, + }); + objectRemover.add(archived.id, 'rules/maintenance_window', 'alerting', true); + await browser.refresh(); + + await pageObjects.maintenanceWindows.searchMaintenanceWindows('window'); + + const list = await pageObjects.maintenanceWindows.getMaintenanceWindowsList(); + expect(list.length).to.eql(3); + expect(list[0].status).to.eql('Running'); + + await testSubjects.click('table-actions-popover'); + await testSubjects.click('table-actions-cancel-and-archive'); + await testSubjects.click('confirmModalConfirmButton'); + + await retry.try(async () => { + const toastTitle = await toasts.getTitleAndDismiss(); + expect(toastTitle).to.eql( + `Cancelled and archived running maintenance window 'archived-maintenance-window'` + ); + }); + + await testSubjects.click('status-filter-button'); + await testSubjects.click('status-filter-archived'); // select Archived status filter + + await retry.try(async () => { + const archivedList = await pageObjects.maintenanceWindows.getMaintenanceWindowsList(); + expect(archivedList.length).to.equal(1); + expect(archivedList[0].status).to.equal('Archived'); + }); + }); + + it('paginates maintenance windows correctly', async () => { + new Array(12).fill(null).map(async (_, index) => { + const mw = await createMaintenanceWindow({ + name: index + '-pagination', + getService, + }); + objectRemover.add(mw.id, 'rules/maintenance_window', 'alerting', true); + }); + await browser.refresh(); + + await pageObjects.maintenanceWindows.searchMaintenanceWindows('pagination'); + await pageObjects.maintenanceWindows.getMaintenanceWindowsList(); + + await testSubjects.click('tablePaginationPopoverButton'); + await testSubjects.click('tablePagination-25-rows'); + await testSubjects.missingOrFail('pagination-button-1'); + await testSubjects.click('tablePaginationPopoverButton'); + await testSubjects.click('tablePagination-10-rows'); + const listedOnFirstPageMWs = await testSubjects.findAll('list-item'); + expect(listedOnFirstPageMWs.length).to.be(10); + + await testSubjects.isEnabled('pagination-button-1'); + await testSubjects.click('pagination-button-1'); + await testSubjects.isEnabled('pagination-button-0'); + const listedOnSecondPageMWs = await testSubjects.findAll('list-item'); + expect(listedOnSecondPageMWs.length).to.be(2); + }); }); };