[RAM] Apply maintenance windows privilege to UI (#156191)

## Summary

We will have three scenarios with kibana privileges

### NONE
Kibana privileges form maintenance window:
<img width="680" alt="image"
src="https://user-images.githubusercontent.com/189600/235188523-acaff7de-54d4-4991-a014-05c0f449738c.png">

`The expected result is to not see maintenance window at all`

<img width="1481" alt="image"
src="https://user-images.githubusercontent.com/189600/235188658-6a53b463-4856-42c7-916e-aa8e6d7e326b.png">

### READ
Kibana privileges form maintenance window:
<img width="677" alt="image"
src="https://user-images.githubusercontent.com/189600/235188908-623d32ac-39a7-484e-bd5c-f858e04d16b2.png">

`The expected result is to only see the table with window maintenance
and you can not edit them`

<img width="1487" alt="image"
src="https://user-images.githubusercontent.com/189600/235189169-f71422bf-6394-4574-87fb-14c653ca1e79.png">
<img width="1484" alt="image"
src="https://user-images.githubusercontent.com/189600/235192048-149519ba-0505-46e3-b737-2703560eb3d6.png">


### ALL
Kibana privileges form maintenance window:
<img width="668" alt="image"
src="https://user-images.githubusercontent.com/189600/235189384-e71d9138-221c-4024-91bb-2ae32da1bd3b.png">

`The expected result is to be able to create/edit/etc on any maintenance
windows`

<img width="1484" alt="image"
src="https://user-images.githubusercontent.com/189600/235189974-e36c1e65-0586-4840-ace5-32caf06455c6.png">
<img width="1481" alt="image"
src="https://user-images.githubusercontent.com/189600/235192269-0f8d1922-d48f-494c-9979-2288bf142286.png">



### Checklist

Delete any items that are not applicable to this PR.

- [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>
This commit is contained in:
Xavier Mouligneau 2023-05-02 09:47:29 -04:00 committed by GitHub
parent aeded80d86
commit 3c9da2cd29
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 327 additions and 42 deletions

View file

@ -11,7 +11,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { I18nProvider } from '@kbn/i18n-react'; import { I18nProvider } from '@kbn/i18n-react';
import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { render as reactRender, RenderOptions, RenderResult } from '@testing-library/react'; import { render as reactRender, RenderOptions, RenderResult } from '@testing-library/react';
import { CoreStart } from '@kbn/core/public'; import { Capabilities, CoreStart } from '@kbn/core/public';
import { coreMock } from '@kbn/core/public/mocks'; import { coreMock } from '@kbn/core/public/mocks';
import { euiDarkVars } from '@kbn/ui-theme'; import { euiDarkVars } from '@kbn/ui-theme';
import type { ILicense } from '@kbn/licensing-plugin/public'; import type { ILicense } from '@kbn/licensing-plugin/public';
@ -22,6 +22,7 @@ import { licensingMock } from '@kbn/licensing-plugin/public/mocks';
type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResult; type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResult;
interface AppMockRendererArgs { interface AppMockRendererArgs {
capabilities?: Capabilities;
license?: ILicense | null; license?: ILicense | null;
} }
@ -30,9 +31,15 @@ export interface AppMockRenderer {
coreStart: CoreStart; coreStart: CoreStart;
queryClient: QueryClient; queryClient: QueryClient;
AppWrapper: React.FC<{ children: React.ReactElement }>; AppWrapper: React.FC<{ children: React.ReactElement }>;
mocked: {
setBadge: jest.Mock;
};
} }
export const createAppMockRenderer = ({ license }: AppMockRendererArgs = {}): AppMockRenderer => { export const createAppMockRenderer = ({
capabilities,
license,
}: AppMockRendererArgs = {}): AppMockRenderer => {
const theme$ = of({ eui: euiDarkVars, darkMode: true }); const theme$ = of({ eui: euiDarkVars, darkMode: true });
const licensingPluginMock = licensingMock.createStart(); const licensingPluginMock = licensingMock.createStart();
@ -53,13 +60,26 @@ export const createAppMockRenderer = ({ license }: AppMockRendererArgs = {}): Ap
error: () => {}, error: () => {},
}, },
}); });
const mockedSetBadge = jest.fn();
const core = coreMock.createStart(); const core = coreMock.createStart();
const services = { const services = {
...core, ...core,
application: {
...core.application,
capabilities: {
...core.application.capabilities,
...capabilities,
},
},
licensing: licensing:
license != null license != null
? { ...licensingPluginMock, license$: new BehaviorSubject(license) } ? { ...licensingPluginMock, license$: new BehaviorSubject(license) }
: licensingPluginMock, : licensingPluginMock,
chrome: {
...core.chrome,
setBadge: mockedSetBadge,
},
}; };
const AppWrapper: React.FC<{ children: React.ReactElement }> = React.memo(({ children }) => ( const AppWrapper: React.FC<{ children: React.ReactElement }> = React.memo(({ children }) => (
<I18nProvider> <I18nProvider>
@ -85,5 +105,8 @@ export const createAppMockRenderer = ({ license }: AppMockRendererArgs = {}): Ap
render, render,
queryClient, queryClient,
AppWrapper, AppWrapper,
mocked: {
setBadge: mockedSetBadge,
},
}; };
}; };

View file

@ -41,7 +41,12 @@ export const EmptyPrompt = React.memo<EmptyPromptProps>(
}, [showCreateButton, onClickCreate, docLinks]); }, [showCreateButton, onClickCreate, docLinks]);
return ( return (
<EuiPageTemplate.EmptyPrompt title={emptyTitle} body={emptyBody} actions={renderActions} /> <EuiPageTemplate.EmptyPrompt
data-test-subj="mw-empty-prompt"
title={emptyTitle}
body={emptyBody}
actions={renderActions}
/>
); );
} }
); );

View file

@ -24,6 +24,7 @@ export const LicensePrompt = React.memo(() => {
return ( return (
<EuiPageTemplate.EmptyPrompt <EuiPageTemplate.EmptyPrompt
data-test-subj="mw-license-prompt"
title={title} title={title}
body={ body={
<EuiFlexGroup direction="column"> <EuiFlexGroup direction="column">

View file

@ -94,7 +94,12 @@ describe('MaintenanceWindowsList', () => {
test('it renders', () => { test('it renders', () => {
const result = appMockRenderer.render( const result = appMockRenderer.render(
<MaintenanceWindowsList refreshData={() => {}} loading={false} items={items} /> <MaintenanceWindowsList
refreshData={() => {}}
loading={false}
items={items}
readOnly={false}
/>
); );
expect(result.getAllByTestId('list-item')).toHaveLength(items.length); expect(result.getAllByTestId('list-item')).toHaveLength(items.length);
@ -115,5 +120,24 @@ describe('MaintenanceWindowsList', () => {
// check the endDate formatting // check the endDate formatting
expect(result.getAllByText('05/05/23 00:00 AM')).toHaveLength(4); expect(result.getAllByText('05/05/23 00:00 AM')).toHaveLength(4);
// check if action menu is there
expect(result.getAllByTestId('table-actions-icon-button')).toHaveLength(items.length);
});
test('it does NOT renders action column in readonly', () => {
const result = appMockRenderer.render(
<MaintenanceWindowsList
refreshData={() => {}}
loading={false}
items={items}
readOnly={true}
/>
);
expect(result.getAllByTestId('list-item')).toHaveLength(items.length);
// check if action menu is there
expect(result.queryByTestId('table-actions-icon-button')).not.toBeInTheDocument();
}); });
}); });

View file

@ -32,10 +32,11 @@ import { useFinishAndArchiveMaintenanceWindow } from '../../../hooks/use_finish_
interface MaintenanceWindowsListProps { interface MaintenanceWindowsListProps {
loading: boolean; loading: boolean;
items: MaintenanceWindowFindResponse[]; items: MaintenanceWindowFindResponse[];
readOnly: boolean;
refreshData: () => void; refreshData: () => void;
} }
const columns: Array<EuiBasicTableColumn<MaintenanceWindowFindResponse>> = [ const COLUMNS: Array<EuiBasicTableColumn<MaintenanceWindowFindResponse>> = [
{ {
field: 'title', field: 'title',
name: i18n.NAME, name: i18n.NAME,
@ -99,7 +100,7 @@ const search: { filters: SearchFilterConfig[] } = {
}; };
export const MaintenanceWindowsList = React.memo<MaintenanceWindowsListProps>( export const MaintenanceWindowsList = React.memo<MaintenanceWindowsListProps>(
({ loading, items, refreshData }) => { ({ loading, items, readOnly, refreshData }) => {
const { euiTheme } = useEuiTheme(); const { euiTheme } = useEuiTheme();
const { navigateToEditMaintenanceWindows } = useEditMaintenanceWindowsNavigation(); const { navigateToEditMaintenanceWindows } = useEditMaintenanceWindowsNavigation();
const onEdit = useCallback( const onEdit = useCallback(
@ -139,32 +140,41 @@ export const MaintenanceWindowsList = React.memo<MaintenanceWindowsListProps>(
`; `;
}, [euiTheme.colors.highlight]); }, [euiTheme.colors.highlight]);
const actions: Array<EuiBasicTableColumn<MaintenanceWindowFindResponse>> = [ const actions: Array<EuiBasicTableColumn<MaintenanceWindowFindResponse>> = useMemo(
{ () => [
name: '', {
render: ({ status, id }: { status: MaintenanceWindowStatus; id: string }) => { name: '',
return ( render: ({ status, id }: { status: MaintenanceWindowStatus; id: string }) => {
<TableActionsPopover return (
id={id} <TableActionsPopover
status={status} id={id}
onEdit={onEdit} status={status}
onCancel={onCancel} onEdit={onEdit}
onArchive={onArchive} onCancel={onCancel}
onCancelAndArchive={onCancelAndArchive} onArchive={onArchive}
/> onCancelAndArchive={onCancelAndArchive}
); />
);
},
}, },
}, ],
]; [onArchive, onCancel, onCancelAndArchive, onEdit]
);
const columns = useMemo(
() => (readOnly ? COLUMNS : COLUMNS.concat(actions)),
[actions, readOnly]
);
return ( return (
<EuiInMemoryTable <EuiInMemoryTable
data-test-subj="mw-table"
css={tableCss} css={tableCss}
itemId="id" itemId="id"
loading={loading || isLoadingFinish || isLoadingArchive || isLoadingFinishAndArchive} loading={loading || isLoadingFinish || isLoadingArchive || isLoadingFinishAndArchive}
tableCaption="Maintenance Windows List" tableCaption="Maintenance Windows List"
items={items} items={items}
columns={columns.concat(actions)} columns={columns}
pagination={true} pagination={true}
sorting={sorting} sorting={sorting}
rowProps={rowProps} rowProps={rowProps}

View file

@ -0,0 +1,84 @@
/*
* 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 from 'react';
import { licensingMock } from '@kbn/licensing-plugin/public/mocks';
import type { Capabilities } from '@kbn/core-capabilities-common';
import { AppMockRenderer, createAppMockRenderer } from '../../lib/test_utils';
import { useFindMaintenanceWindows } from '../../hooks/use_find_maintenance_windows';
import { MaintenanceWindowsPage } from '.';
import { MAINTENANCE_WINDOW_FEATURE_ID } from '../../../common';
jest.mock('../../hooks/use_find_maintenance_windows', () => ({
useFindMaintenanceWindows: jest.fn(),
}));
describe('Maintenance windows page', () => {
let appMockRenderer: AppMockRenderer;
let license = licensingMock.createLicense({
license: { type: 'platinum' },
});
let capabilities: Capabilities = {
[MAINTENANCE_WINDOW_FEATURE_ID]: {
show: true,
save: true,
},
navLinks: {},
management: {},
catalogue: {},
};
beforeEach(() => {
jest.clearAllMocks();
(useFindMaintenanceWindows as jest.Mock).mockReturnValue({
isLoading: false,
maintenanceWindows: [],
refetch: jest.fn(),
});
license = licensingMock.createLicense({
license: { type: 'platinum' },
});
capabilities = {
maintenanceWindow: {
show: true,
save: true,
},
navLinks: {},
management: {},
catalogue: {},
};
appMockRenderer = createAppMockRenderer({ capabilities, license });
});
test('show license prompt', () => {
license = licensingMock.createLicense({
license: { type: 'gold' },
});
appMockRenderer = createAppMockRenderer({ capabilities, license });
const result = appMockRenderer.render(<MaintenanceWindowsPage />);
expect(result.queryByTestId('mw-license-prompt')).toBeInTheDocument();
});
test('show empty prompt', () => {
const result = appMockRenderer.render(<MaintenanceWindowsPage />);
expect(result.queryByTestId('mw-empty-prompt')).toBeInTheDocument();
expect(appMockRenderer.mocked.setBadge).not.toBeCalled();
});
test('show table in read only', () => {
capabilities = {
...capabilities,
[MAINTENANCE_WINDOW_FEATURE_ID]: {
show: true,
save: false,
},
};
appMockRenderer = createAppMockRenderer({ capabilities, license });
const result = appMockRenderer.render(<MaintenanceWindowsPage />);
expect(result.queryByTestId('mw-table')).toBeInTheDocument();
expect(appMockRenderer.mocked.setBadge).toBeCalledTimes(1);
});
});

View file

@ -5,7 +5,7 @@
* 2.0. * 2.0.
*/ */
import React, { useCallback } from 'react'; import React, { useCallback, useEffect } from 'react';
import { import {
EuiButton, EuiButton,
EuiFlexGroup, EuiFlexGroup,
@ -28,9 +28,14 @@ import { CenterJustifiedSpinner } from './components/center_justified_spinner';
import { ExperimentalBadge } from './components/page_header'; import { ExperimentalBadge } from './components/page_header';
import { useLicense } from '../../hooks/use_license'; import { useLicense } from '../../hooks/use_license';
import { LicensePrompt } from './components/license_prompt'; import { LicensePrompt } from './components/license_prompt';
import { MAINTENANCE_WINDOW_FEATURE_ID } from '../../../common';
export const MaintenanceWindowsPage = React.memo(() => { export const MaintenanceWindowsPage = React.memo(() => {
const { docLinks } = useKibana().services; const {
application: { capabilities },
chrome,
docLinks,
} = useKibana().services;
const { isAtLeastPlatinum } = useLicense(); const { isAtLeastPlatinum } = useLicense();
const { navigateToCreateMaintenanceWindow } = useCreateMaintenanceWindowNavigation(); const { navigateToCreateMaintenanceWindow } = useCreateMaintenanceWindowNavigation();
@ -44,10 +49,37 @@ export const MaintenanceWindowsPage = React.memo(() => {
}, [navigateToCreateMaintenanceWindow]); }, [navigateToCreateMaintenanceWindow]);
const refreshData = useCallback(() => refetch(), [refetch]); const refreshData = useCallback(() => refetch(), [refetch]);
const showWindowMaintenance = capabilities[MAINTENANCE_WINDOW_FEATURE_ID].show;
const showEmptyPrompt = !isLoading && maintenanceWindows.length === 0; const writeWindowMaintenance = capabilities[MAINTENANCE_WINDOW_FEATURE_ID].save;
const showEmptyPrompt =
!isLoading &&
maintenanceWindows.length === 0 &&
showWindowMaintenance &&
writeWindowMaintenance;
const hasLicense = isAtLeastPlatinum(); const hasLicense = isAtLeastPlatinum();
const readOnly = showWindowMaintenance && !writeWindowMaintenance;
// if the user is read only then display the glasses badge in the global navigation header
const setBadge = useCallback(() => {
if (readOnly) {
chrome.setBadge({
text: i18n.READ_ONLY_BADGE_TEXT,
tooltip: i18n.READ_ONLY_BADGE_TOOLTIP,
iconType: 'glasses',
});
}
}, [chrome, readOnly]);
useEffect(() => {
setBadge();
// remove the icon after the component unmounts
return () => {
chrome.setBadge();
};
}, [setBadge, chrome]);
if (isLoading) { if (isLoading) {
return <CenterJustifiedSpinner />; return <CenterJustifiedSpinner />;
} }
@ -71,9 +103,14 @@ export const MaintenanceWindowsPage = React.memo(() => {
<p>{i18n.MAINTENANCE_WINDOWS_DESCRIPTION}</p> <p>{i18n.MAINTENANCE_WINDOWS_DESCRIPTION}</p>
</EuiText> </EuiText>
</EuiPageHeaderSection> </EuiPageHeaderSection>
{!showEmptyPrompt && hasLicense ? ( {!showEmptyPrompt && hasLicense && writeWindowMaintenance ? (
<EuiPageHeaderSection> <EuiPageHeaderSection>
<EuiButton onClick={handleClickCreate} iconType="plusInCircle" fill> <EuiButton
data-test-subj="mw-create-button"
onClick={handleClickCreate}
iconType="plusInCircle"
fill
>
{i18n.CREATE_NEW_BUTTON} {i18n.CREATE_NEW_BUTTON}
</EuiButton> </EuiButton>
</EuiPageHeaderSection> </EuiPageHeaderSection>
@ -87,6 +124,7 @@ export const MaintenanceWindowsPage = React.memo(() => {
<> <>
<EuiSpacer size="xl" /> <EuiSpacer size="xl" />
<MaintenanceWindowsList <MaintenanceWindowsList
readOnly={readOnly}
refreshData={refreshData} refreshData={refreshData}
loading={isLoading} loading={isLoading}
items={maintenanceWindows} items={maintenanceWindows}

View file

@ -20,6 +20,20 @@ export const MAINTENANCE_WINDOWS_DESCRIPTION = i18n.translate(
} }
); );
export const READ_ONLY_BADGE_TEXT = i18n.translate(
'xpack.alerting.maintenanceWindows.badge.readOnly.text',
{
defaultMessage: 'Read only',
}
);
export const READ_ONLY_BADGE_TOOLTIP = i18n.translate(
'xpack.alerting.maintenanceWindows.badge.readOnly.tooltip',
{
defaultMessage: 'Unable to create or edit maintenance Windows',
}
);
export const CREATE_NEW_BUTTON = i18n.translate( export const CREATE_NEW_BUTTON = i18n.translate(
'xpack.alerting.maintenanceWindows.createNewButton', 'xpack.alerting.maintenanceWindows.createNewButton',
{ {

View file

@ -22,7 +22,7 @@ export const maintenanceWindowFeature: KibanaFeatureConfig = {
category: DEFAULT_APP_CATEGORIES.management, category: DEFAULT_APP_CATEGORIES.management,
app: [], app: [],
management: { management: {
insightsAndAlerting: ['triggersActions'], insightsAndAlerting: ['maintenanceWindows'],
}, },
privileges: { privileges: {
all: { all: {
@ -32,7 +32,7 @@ export const maintenanceWindowFeature: KibanaFeatureConfig = {
MAINTENANCE_WINDOW_API_PRIVILEGES.WRITE_MAINTENANCE_WINDOW, MAINTENANCE_WINDOW_API_PRIVILEGES.WRITE_MAINTENANCE_WINDOW,
], ],
management: { management: {
insightsAndAlerting: ['triggersActions'], insightsAndAlerting: ['maintenanceWindows'],
}, },
savedObject: { savedObject: {
all: [MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE], all: [MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE],
@ -44,7 +44,7 @@ export const maintenanceWindowFeature: KibanaFeatureConfig = {
app: [], app: [],
api: [MAINTENANCE_WINDOW_API_PRIVILEGES.READ_MAINTENANCE_WINDOW], api: [MAINTENANCE_WINDOW_API_PRIVILEGES.READ_MAINTENANCE_WINDOW],
management: { management: {
insightsAndAlerting: ['triggersActions'], insightsAndAlerting: ['maintenanceWindows'],
}, },
savedObject: { savedObject: {
all: [], all: [],

View file

@ -52,6 +52,7 @@
"@kbn/doc-links", "@kbn/doc-links",
"@kbn/core-saved-objects-utils-server", "@kbn/core-saved-objects-utils-server",
"@kbn/core-ui-settings-common", "@kbn/core-ui-settings-common",
"@kbn/core-capabilities-common",
], ],
"exclude": [ "exclude": [
"target/**/*", "target/**/*",

View file

@ -8,8 +8,12 @@
import React from 'react'; import React from 'react';
import { render, waitFor, cleanup } from '@testing-library/react'; import { render, waitFor, cleanup } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MaintenanceWindowStatus } from '@kbn/alerting-plugin/common'; import {
MaintenanceWindowStatus,
MAINTENANCE_WINDOW_FEATURE_ID,
} from '@kbn/alerting-plugin/common';
import type { MaintenanceWindow } from '@kbn/alerting-plugin/common'; import type { MaintenanceWindow } from '@kbn/alerting-plugin/common';
import { useKibana } from '../../../../common/lib/kibana';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import { useAppToastsMock } from '../../../../common/hooks/use_app_toasts.mock'; import { useAppToastsMock } from '../../../../common/hooks/use_app_toasts.mock';
import { MaintenanceWindowCallout } from './maintenance_window_callout'; import { MaintenanceWindowCallout } from './maintenance_window_callout';
@ -22,6 +26,8 @@ jest.mock('./api', () => ({
fetchActiveMaintenanceWindows: jest.fn(() => Promise.resolve([])), fetchActiveMaintenanceWindows: jest.fn(() => Promise.resolve([])),
})); }));
jest.mock('../../../../common/lib/kibana');
const RUNNING_MAINTENANCE_WINDOW_1: Partial<MaintenanceWindow> = { const RUNNING_MAINTENANCE_WINDOW_1: Partial<MaintenanceWindow> = {
title: 'Maintenance window 1', title: 'Maintenance window 1',
id: '63057284-ac31-42ba-fe22-adfe9732e5ae', id: '63057284-ac31-42ba-fe22-adfe9732e5ae',
@ -46,6 +52,9 @@ const UPCOMING_MAINTENANCE_WINDOW: Partial<MaintenanceWindow> = {
], ],
}; };
const useKibanaMock = useKibana as jest.Mock;
const fetchActiveMaintenanceWindowsMock = fetchActiveMaintenanceWindows as jest.Mock;
describe('MaintenanceWindowCallout', () => { describe('MaintenanceWindowCallout', () => {
let appToastsMock: jest.Mocked<ReturnType<typeof useAppToastsMock.create>>; let appToastsMock: jest.Mocked<ReturnType<typeof useAppToastsMock.create>>;
@ -54,6 +63,18 @@ describe('MaintenanceWindowCallout', () => {
appToastsMock = useAppToastsMock.create(); appToastsMock = useAppToastsMock.create();
(useAppToasts as jest.Mock).mockReturnValue(appToastsMock); (useAppToasts as jest.Mock).mockReturnValue(appToastsMock);
useKibanaMock.mockReturnValue({
services: {
application: {
capabilities: {
[MAINTENANCE_WINDOW_FEATURE_ID]: {
save: true,
show: true,
},
},
},
},
});
}); });
afterEach(() => { afterEach(() => {
@ -62,7 +83,7 @@ describe('MaintenanceWindowCallout', () => {
}); });
it('should be visible if currently there is at least one "running" maintenance window', async () => { it('should be visible if currently there is at least one "running" maintenance window', async () => {
(fetchActiveMaintenanceWindows as jest.Mock).mockResolvedValue([RUNNING_MAINTENANCE_WINDOW_1]); fetchActiveMaintenanceWindowsMock.mockResolvedValue([RUNNING_MAINTENANCE_WINDOW_1]);
const { findByText } = render(<MaintenanceWindowCallout />, { wrapper: TestProviders }); const { findByText } = render(<MaintenanceWindowCallout />, { wrapper: TestProviders });
@ -70,7 +91,7 @@ describe('MaintenanceWindowCallout', () => {
}); });
it('should be visible if currently there are multiple "running" maintenance windows', async () => { it('should be visible if currently there are multiple "running" maintenance windows', async () => {
(fetchActiveMaintenanceWindows as jest.Mock).mockResolvedValue([ fetchActiveMaintenanceWindowsMock.mockResolvedValue([
RUNNING_MAINTENANCE_WINDOW_1, RUNNING_MAINTENANCE_WINDOW_1,
RUNNING_MAINTENANCE_WINDOW_2, RUNNING_MAINTENANCE_WINDOW_2,
]); ]);
@ -81,7 +102,7 @@ describe('MaintenanceWindowCallout', () => {
}); });
it('should NOT be visible if currently there are no active (running or upcoming) maintenance windows', async () => { it('should NOT be visible if currently there are no active (running or upcoming) maintenance windows', async () => {
(fetchActiveMaintenanceWindows as jest.Mock).mockResolvedValue([]); fetchActiveMaintenanceWindowsMock.mockResolvedValue([]);
const { container } = render(<MaintenanceWindowCallout />, { wrapper: TestProviders }); const { container } = render(<MaintenanceWindowCallout />, { wrapper: TestProviders });
@ -89,7 +110,7 @@ describe('MaintenanceWindowCallout', () => {
}); });
it('should NOT be visible if currently there are no "running" maintenance windows', async () => { it('should NOT be visible if currently there are no "running" maintenance windows', async () => {
(fetchActiveMaintenanceWindows as jest.Mock).mockResolvedValue([UPCOMING_MAINTENANCE_WINDOW]); fetchActiveMaintenanceWindowsMock.mockResolvedValue([UPCOMING_MAINTENANCE_WINDOW]);
const { container } = render(<MaintenanceWindowCallout />, { wrapper: TestProviders }); const { container } = render(<MaintenanceWindowCallout />, { wrapper: TestProviders });
@ -121,7 +142,7 @@ describe('MaintenanceWindowCallout', () => {
}; };
const mockError = new Error('Network error'); const mockError = new Error('Network error');
(fetchActiveMaintenanceWindows as jest.Mock).mockRejectedValue(mockError); fetchActiveMaintenanceWindowsMock.mockRejectedValue(mockError);
render(<MaintenanceWindowCallout />, { wrapper: createReactQueryWrapper() }); render(<MaintenanceWindowCallout />, { wrapper: createReactQueryWrapper() });
@ -133,4 +154,44 @@ describe('MaintenanceWindowCallout', () => {
}); });
}); });
}); });
it('should return null if window maintenance privilege is NONE', async () => {
useKibanaMock.mockReturnValue({
services: {
application: {
capabilities: {
[MAINTENANCE_WINDOW_FEATURE_ID]: {
save: false,
show: false,
},
},
},
},
});
fetchActiveMaintenanceWindowsMock.mockResolvedValue([RUNNING_MAINTENANCE_WINDOW_1]);
const { container } = render(<MaintenanceWindowCallout />, { wrapper: TestProviders });
expect(container).toBeEmptyDOMElement();
});
it('should work as expected if window maintenance privilege is READ ', async () => {
useKibanaMock.mockReturnValue({
services: {
application: {
capabilities: {
[MAINTENANCE_WINDOW_FEATURE_ID]: {
save: false,
show: true,
},
},
},
},
});
fetchActiveMaintenanceWindowsMock.mockResolvedValue([RUNNING_MAINTENANCE_WINDOW_1]);
const { findByText } = render(<MaintenanceWindowCallout />, { wrapper: TestProviders });
expect(await findByText('A maintenance window is currently running')).toBeInTheDocument();
});
}); });

View file

@ -7,12 +7,28 @@
import React from 'react'; import React from 'react';
import { EuiCallOut } from '@elastic/eui'; import { EuiCallOut } from '@elastic/eui';
import { MaintenanceWindowStatus } from '@kbn/alerting-plugin/common'; import {
MaintenanceWindowStatus,
MAINTENANCE_WINDOW_FEATURE_ID,
} from '@kbn/alerting-plugin/common';
import { useFetchActiveMaintenanceWindows } from './use_fetch_active_maintenance_windows'; import { useFetchActiveMaintenanceWindows } from './use_fetch_active_maintenance_windows';
import * as i18n from './translations'; import * as i18n from './translations';
import { useKibana } from '../../../../common/lib/kibana';
export function MaintenanceWindowCallout(): JSX.Element | null { export function MaintenanceWindowCallout(): JSX.Element | null {
const { data } = useFetchActiveMaintenanceWindows(); const {
application: { capabilities },
} = useKibana().services;
const isMaintenanceWindowDisabled =
!capabilities[MAINTENANCE_WINDOW_FEATURE_ID].show &&
!capabilities[MAINTENANCE_WINDOW_FEATURE_ID].save;
const { data } = useFetchActiveMaintenanceWindows({ enabled: !isMaintenanceWindowDisabled });
if (isMaintenanceWindowDisabled) {
return null;
}
const activeMaintenanceWindows = data || []; const activeMaintenanceWindows = data || [];
if (activeMaintenanceWindows.some(({ status }) => status === MaintenanceWindowStatus.Running)) { if (activeMaintenanceWindows.some(({ status }) => status === MaintenanceWindowStatus.Running)) {

View file

@ -5,19 +5,21 @@
* 2.0. * 2.0.
*/ */
import type { UseQueryOptions } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { INTERNAL_ALERTING_API_GET_ACTIVE_MAINTENANCE_WINDOWS_PATH } from '@kbn/alerting-plugin/common'; import { INTERNAL_ALERTING_API_GET_ACTIVE_MAINTENANCE_WINDOWS_PATH } from '@kbn/alerting-plugin/common';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import * as i18n from './translations'; import * as i18n from './translations';
import { fetchActiveMaintenanceWindows } from './api'; import { fetchActiveMaintenanceWindows } from './api';
export const useFetchActiveMaintenanceWindows = () => { export const useFetchActiveMaintenanceWindows = ({ enabled }: Pick<UseQueryOptions, 'enabled'>) => {
const { addError } = useAppToasts(); const { addError } = useAppToasts();
return useQuery( return useQuery(
['GET', INTERNAL_ALERTING_API_GET_ACTIVE_MAINTENANCE_WINDOWS_PATH], ['GET', INTERNAL_ALERTING_API_GET_ACTIVE_MAINTENANCE_WINDOWS_PATH],
({ signal }) => fetchActiveMaintenanceWindows(signal), ({ signal }) => fetchActiveMaintenanceWindows(signal),
{ {
enabled,
refetchInterval: 60000, refetchInterval: 60000,
onError: (error) => { onError: (error) => {
addError(error, { title: i18n.FETCH_ERROR, toastMessage: i18n.FETCH_ERROR_DESCRIPTION }); addError(error, { title: i18n.FETCH_ERROR, toastMessage: i18n.FETCH_ERROR_DESCRIPTION });

View file

@ -64,7 +64,13 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
expect(sections).to.have.length(2); expect(sections).to.have.length(2);
expect(sections[0]).to.eql({ expect(sections[0]).to.eql({
sectionId: 'insightsAndAlerting', sectionId: 'insightsAndAlerting',
sectionLinks: ['triggersActions', 'cases', 'triggersActionsConnectors', 'jobsListLink'], sectionLinks: [
'triggersActions',
'cases',
'triggersActionsConnectors',
'jobsListLink',
'maintenanceWindows',
],
}); });
expect(sections[1]).to.eql({ expect(sections[1]).to.eql({
sectionId: 'kibana', sectionId: 'kibana',