[SR] Add callout for managed repository and prevent deletion from UI (#36947) (#37536)

* Check repository plugins using callWithInternalUser instead

* Add information about whether repository is managed (by Cloud)

* Adjust warning copy, add warning Save button styling, and remove button tooltips

* Add same tooltip to trash can icon

* Fix prop
This commit is contained in:
Jen Huang 2019-05-30 14:49:57 -07:00 committed by GitHub
parent a93057262f
commit 4c72c7c93c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 188 additions and 35 deletions

View file

@ -50,4 +50,4 @@ export const REPOSITORY_PLUGINS_MAP: { [key: string]: RepositoryType } = {
'repository-gcs': REPOSITORY_TYPES.gcs,
};
export const APP_PERMISSIONS = ['monitor', 'create_snapshot', 'cluster:admin/repository'];
export const APP_PERMISSIONS = ['create_snapshot', 'cluster:admin/repository'];

View file

@ -15,6 +15,7 @@ import { RepositoryFormStepTwo } from './step_two';
interface Props {
repository: Repository | EmptyRepository;
isManagedRepository?: boolean;
isEditing?: boolean;
isSaving: boolean;
saveError?: React.ReactNode;
@ -24,6 +25,7 @@ interface Props {
export const RepositoryForm: React.FunctionComponent<Props> = ({
repository: originalRepository,
isManagedRepository,
isEditing,
isSaving,
saveError,
@ -101,6 +103,7 @@ export const RepositoryForm: React.FunctionComponent<Props> = ({
const renderStepTwo = () => (
<RepositoryFormStepTwo
repository={repository as Repository}
isManagedRepository={isManagedRepository}
isEditing={isEditing}
isSaving={isSaving}
onSave={saveRepository}

View file

@ -25,6 +25,7 @@ import { textService } from '../../services/text';
interface Props {
repository: Repository;
isManagedRepository?: boolean;
isEditing?: boolean;
isSaving: boolean;
onSave: () => void;
@ -36,6 +37,7 @@ interface Props {
export const RepositoryFormStepTwo: React.FunctionComponent<Props> = ({
repository,
isManagedRepository,
isEditing,
isSaving,
onSave,
@ -144,10 +146,10 @@ export const RepositoryFormStepTwo: React.FunctionComponent<Props> = ({
)}
<EuiFlexItem grow={false}>
<EuiButton
color="secondary"
color={isManagedRepository ? 'warning' : 'secondary'}
iconType="check"
onClick={onSave}
fill
fill={isManagedRepository ? false : true}
data-test-subj="srRepositoryFormSubmitButton"
isLoading={isSaving}
>

View file

@ -9,6 +9,7 @@ import { RouteComponentProps, withRouter } from 'react-router-dom';
import {
EuiButton,
EuiButtonEmpty,
EuiCallOut,
EuiCodeEditor,
EuiFlexGroup,
EuiFlexItem,
@ -163,7 +164,7 @@ const RepositoryDetailsUi: React.FunctionComponent<Props> = ({
};
const renderRepository = () => {
const { repository } = repositoryDetails;
const { repository, isManagedRepository } = repositoryDetails;
if (!repository) {
return null;
@ -172,6 +173,22 @@ const RepositoryDetailsUi: React.FunctionComponent<Props> = ({
const { type } = repository as Repository;
return (
<Fragment>
{isManagedRepository ? (
<Fragment>
<EuiCallOut
size="s"
color="warning"
iconType="iInCircle"
title={
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.managedRepositoryWarningTitle"
defaultMessage="This is a managed repository used by other systems. Any changes you make might affect how these systems operate."
/>
}
/>
<EuiSpacer size="l" />
</Fragment>
) : null}
<EuiFlexGroup justifyContent="spaceBetween" alignItems="flexStart">
<EuiFlexItem>
<EuiTitle size="s">
@ -329,6 +346,17 @@ const RepositoryDetailsUi: React.FunctionComponent<Props> = ({
onClick={() =>
deleteRepositoryPrompt([repositoryName], onRepositoryDeleted)
}
isDisabled={repositoryDetails.isManagedRepository}
title={
repositoryDetails.isManagedRepository
? i18n.translate(
'xpack.snapshotRestore.repositoryDetails.removeManagedRepositoryButtonTitle',
{
defaultMessage: 'You cannot delete a managed repository.',
}
)
: null
}
>
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.removeButtonLabel"

View file

@ -37,7 +37,10 @@ export const RepositoryList: React.FunctionComponent<RouteComponentProps<MatchPa
const {
error,
loading,
data: { repositories } = { repositories: undefined },
data: { repositories, managedRepository } = {
repositories: undefined,
managedRepository: undefined,
},
request: reload,
} = loadRepositories();
@ -132,6 +135,7 @@ export const RepositoryList: React.FunctionComponent<RouteComponentProps<MatchPa
content = (
<RepositoryTable
repositories={repositories || []}
managedRepository={managedRepository}
reload={reload}
openRepositoryDetailsUrl={openRepositoryDetailsUrl}
onRepositoryDeleted={onRepositoryDeleted}

View file

@ -4,10 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState } from 'react';
import React, { useState, Fragment } from 'react';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import {
EuiBadge,
EuiButton,
EuiButtonIcon,
EuiFlexGroup,
@ -27,6 +28,7 @@ import { uiMetricService } from '../../../../services/ui_metric';
interface Props extends RouteComponentProps {
repositories: Repository[];
managedRepository?: string;
reload: () => Promise<void>;
openRepositoryDetailsUrl: (name: Repository['name']) => string;
onRepositoryDeleted: (repositoriesDeleted: Array<Repository['name']>) => void;
@ -34,6 +36,7 @@ interface Props extends RouteComponentProps {
const RepositoryTableUi: React.FunctionComponent<Props> = ({
repositories,
managedRepository,
reload,
openRepositoryDetailsUrl,
onRepositoryDeleted,
@ -54,14 +57,25 @@ const RepositoryTableUi: React.FunctionComponent<Props> = ({
}),
truncateText: true,
sortable: true,
render: (name: Repository['name'], repository: Repository) => {
render: (name: Repository['name']) => {
return (
<EuiLink
onClick={() => trackUiMetric(UIM_REPOSITORY_SHOW_DETAILS_CLICK)}
href={openRepositoryDetailsUrl(name)}
>
{name}
</EuiLink>
<Fragment>
<EuiLink
onClick={() => trackUiMetric(UIM_REPOSITORY_SHOW_DETAILS_CLICK)}
href={openRepositoryDetailsUrl(name)}
>
{name}
</EuiLink>
&nbsp;&nbsp;
{managedRepository === name ? (
<EuiBadge color="primary">
<FormattedMessage
id="xpack.snapshotRestore.repositoryList.table.managedRepositoryBadgeLabel"
defaultMessage="Managed"
/>
</EuiBadge>
) : null}
</Fragment>
);
},
},
@ -114,10 +128,18 @@ const RepositoryTableUi: React.FunctionComponent<Props> = ({
return (
<RepositoryDeleteProvider>
{deleteRepositoryPrompt => {
const label = i18n.translate(
'xpack.snapshotRestore.repositoryList.table.actionRemoveTooltip',
{ defaultMessage: 'Remove' }
);
const label =
name !== managedRepository
? i18n.translate(
'xpack.snapshotRestore.repositoryList.table.actionRemoveTooltip',
{ defaultMessage: 'Remove' }
)
: i18n.translate(
'xpack.snapshotRestore.repositoryList.table.deleteManagedRepositoryTooltip',
{
defaultMessage: 'You cannot delete a managed repository.',
}
);
return (
<EuiToolTip content={label} delay="long">
<EuiButtonIcon
@ -132,6 +154,7 @@ const RepositoryTableUi: React.FunctionComponent<Props> = ({
color="danger"
data-test-subj="srRepositoryListDeleteActionButton"
onClick={() => deleteRepositoryPrompt([name], onRepositoryDeleted)}
isDisabled={Boolean(name === managedRepository)}
/>
</EuiToolTip>
);
@ -159,6 +182,17 @@ const RepositoryTableUi: React.FunctionComponent<Props> = ({
const selection = {
onSelectionChange: (newSelectedItems: Repository[]) => setSelectedItems(newSelectedItems),
selectable: ({ name }: Repository) => Boolean(name !== managedRepository),
selectableMessage: (selectable: boolean) => {
if (!selectable) {
return i18n.translate(
'xpack.snapshotRestore.repositoryList.table.deleteManagedRepositoryTooltip',
{
defaultMessage: 'You cannot delete a managed repository.',
}
);
}
},
};
const search = {

View file

@ -3,10 +3,10 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, Fragment } from 'react';
import { RouteComponentProps } from 'react-router-dom';
import { EuiPageBody, EuiPageContent, EuiSpacer, EuiTitle } from '@elastic/eui';
import { EuiCallOut, EuiPageBody, EuiPageContent, EuiSpacer, EuiTitle } from '@elastic/eui';
import { Repository, EmptyRepository } from '../../../../common/types';
import { RepositoryForm, SectionError, SectionLoading } from '../../components';
@ -123,7 +123,7 @@ export const RepositoryEdit: React.FunctionComponent<RouteComponentProps<MatchPa
<SectionError
title={
<FormattedMessage
id="xpack.snapshotRestore.editRepository.avingRepositoryErrorTitle"
id="xpack.snapshotRestore.editRepository.savingRepositoryErrorTitle"
defaultMessage="Cannot save repository"
/>
}
@ -144,15 +144,36 @@ export const RepositoryEdit: React.FunctionComponent<RouteComponentProps<MatchPa
return renderError();
}
const { isManagedRepository } = repositoryData;
return (
<RepositoryForm
repository={repository}
isEditing={true}
isSaving={isSaving}
saveError={renderSaveError()}
clearSaveError={clearSaveError}
onSave={onSave}
/>
<Fragment>
{isManagedRepository ? (
<Fragment>
<EuiCallOut
size="m"
color="warning"
iconType="iInCircle"
title={
<FormattedMessage
id="xpack.snapshotRestore.editRepository.managedRepositoryWarningTitle"
defaultMessage="This is a managed repository. Changing this repository might affect other systems that use it. Proceed with caution."
/>
}
/>
<EuiSpacer size="l" />
</Fragment>
) : null}
<RepositoryForm
repository={repository}
isManagedRepository={isManagedRepository}
isEditing={true}
isSaving={isSaving}
saveError={renderSaveError()}
clearSaveError={clearSaveError}
onSave={onSave}
/>
</Fragment>
);
};

View file

@ -6,6 +6,7 @@
import { Request, ResponseToolkit } from 'hapi';
import { DEFAULT_REPOSITORY_TYPES, REPOSITORY_PLUGINS_MAP } from '../../../common/constants';
import {
registerRepositoriesRoutes,
createHandler,
deleteHandler,
getAllHandler,
@ -18,6 +19,30 @@ import {
describe('[Snapshot and Restore API Routes] Repositories', () => {
const mockRequest = {} as Request;
const mockResponseToolkit = {} as ResponseToolkit;
const mockCallWithInternalUser = jest.fn().mockReturnValue({
persistent: {
'cluster.metadata.managed_repository': 'found-snapshots',
},
});
registerRepositoriesRoutes(
{
// @ts-ignore
get: () => {},
// @ts-ignore
post: () => {},
// @ts-ignore
put: () => {},
// @ts-ignore
delete: () => {},
// @ts-ignore
patch: () => {},
},
{
cloud: { config: { isCloudEnabled: false } },
elasticsearch: { getCluster: () => ({ callWithInternalUser: mockCallWithInternalUser }) },
}
);
describe('getAllHandler()', () => {
it('should arrify repositories returned from ES', async () => {
@ -39,6 +64,7 @@ describe('[Snapshot and Restore API Routes] Repositories', () => {
settings: {},
},
],
managedRepository: 'found-snapshots',
};
await expect(
getAllHandler(mockRequest, callWithRequest, mockResponseToolkit)
@ -50,6 +76,7 @@ describe('[Snapshot and Restore API Routes] Repositories', () => {
const callWithRequest = jest.fn().mockReturnValueOnce(mockEsResponse);
const expectedResponse = {
repositories: [],
managedRepository: 'found-snapshots',
};
await expect(
getAllHandler(mockRequest, callWithRequest, mockResponseToolkit)
@ -82,6 +109,7 @@ describe('[Snapshot and Restore API Routes] Repositories', () => {
.mockResolvedValueOnce({});
const expectedResponse = {
repository: { name, ...mockEsResponse[name] },
isManagedRepository: false,
snapshots: { count: null },
};
await expect(
@ -117,6 +145,7 @@ describe('[Snapshot and Restore API Routes] Repositories', () => {
.mockResolvedValueOnce(mockEsSnapshotResponse);
const expectedResponse = {
repository: { name, ...mockEsResponse[name] },
isManagedRepository: false,
snapshots: {
count: 2,
},
@ -137,6 +166,7 @@ describe('[Snapshot and Restore API Routes] Repositories', () => {
.mockRejectedValueOnce(mockEsSnapshotError);
const expectedResponse = {
repository: { name, ...mockEsResponse[name] },
isManagedRepository: false,
snapshots: {
count: null,
},
@ -188,7 +218,8 @@ describe('[Snapshot and Restore API Routes] Repositories', () => {
describe('getTypesHandler()', () => {
it('should return default types if no repository plugins returned from ES', async () => {
const mockEsResponse = {};
const callWithRequest = jest.fn().mockReturnValue(mockEsResponse);
const callWithRequest = jest.fn();
mockCallWithInternalUser.mockReturnValueOnce(mockEsResponse);
const expectedResponse = [...DEFAULT_REPOSITORY_TYPES];
await expect(
getTypesHandler(mockRequest, callWithRequest, mockResponseToolkit)
@ -199,7 +230,8 @@ describe('[Snapshot and Restore API Routes] Repositories', () => {
const pluginNames = Object.keys(REPOSITORY_PLUGINS_MAP);
const pluginTypes = Object.entries(REPOSITORY_PLUGINS_MAP).map(([key, value]) => value);
const mockEsResponse = [...pluginNames.map(key => ({ component: key }))];
const callWithRequest = jest.fn().mockReturnValue(mockEsResponse);
const callWithRequest = jest.fn();
mockCallWithInternalUser.mockReturnValueOnce(mockEsResponse);
const expectedResponse = [...DEFAULT_REPOSITORY_TYPES, ...pluginTypes];
await expect(
getTypesHandler(mockRequest, callWithRequest, mockResponseToolkit)
@ -209,7 +241,8 @@ describe('[Snapshot and Restore API Routes] Repositories', () => {
it('should not return non-repository plugins returned from ES', async () => {
const pluginNames = ['foo-plugin', 'bar-plugin'];
const mockEsResponse = [...pluginNames.map(key => ({ component: key }))];
const callWithRequest = jest.fn().mockReturnValue(mockEsResponse);
const callWithRequest = jest.fn();
mockCallWithInternalUser.mockReturnValueOnce(mockEsResponse);
const expectedResponse = [...DEFAULT_REPOSITORY_TYPES];
await expect(
getTypesHandler(mockRequest, callWithRequest, mockResponseToolkit)

View file

@ -15,10 +15,12 @@ import { Repository, RepositoryType, RepositoryVerification } from '../../../com
import { Plugins } from '../../../shim';
import { deserializeRepositorySettings, serializeRepositorySettings } from '../../lib';
let isCloudEnabled = false;
let isCloudEnabled: boolean = false;
let callWithInternalUser: any;
export function registerRepositoriesRoutes(router: Router, plugins: Plugins) {
isCloudEnabled = plugins.cloud.config.isCloudEnabled;
callWithInternalUser = plugins.elasticsearch.getCluster('data').callWithInternalUser;
router.get('repository_types', getTypesHandler);
router.get('repositories', getAllHandler);
router.get('repositories/{name}', getOneHandler);
@ -28,12 +30,28 @@ export function registerRepositoriesRoutes(router: Router, plugins: Plugins) {
router.delete('repositories/{names}', deleteHandler);
}
export const getManagedRepositoryName = async () => {
const { persistent, transient, defaults } = await callWithInternalUser('cluster.getSettings', {
filterPath: '*.*managed_repository',
flatSettings: true,
includeDefaults: true,
});
const { 'cluster.metadata.managed_repository': managedRepositoryName = undefined } = {
...defaults,
...persistent,
...transient,
};
return managedRepositoryName;
};
export const getAllHandler: RouterRouteHandler = async (
req,
callWithRequest
): Promise<{
repositories: Repository[];
managedRepository?: string;
}> => {
const managedRepository = await getManagedRepositoryName();
const repositoriesByName = await callWithRequest('snapshot.getRepository', {
repository: '_all',
});
@ -46,7 +64,7 @@ export const getAllHandler: RouterRouteHandler = async (
settings: deserializeRepositorySettings(settings),
};
});
return { repositories };
return { repositories, managedRepository };
};
export const getOneHandler: RouterRouteHandler = async (
@ -54,9 +72,11 @@ export const getOneHandler: RouterRouteHandler = async (
callWithRequest
): Promise<{
repository: Repository | {};
isManagedRepository?: boolean;
snapshots: { count: number | undefined } | {};
}> => {
const { name } = req.params;
const managedRepository = await getManagedRepositoryName();
const repositoryByName = await callWithRequest('snapshot.getRepository', { repository: name });
const { snapshots } = await callWithRequest('snapshot.get', {
repository: name,
@ -73,6 +93,7 @@ export const getOneHandler: RouterRouteHandler = async (
type,
settings: deserializeRepositorySettings(settings),
},
isManagedRepository: managedRepository === name,
snapshots: {
count: snapshots ? snapshots.length : null,
},
@ -108,10 +129,15 @@ export const getVerificationHandler: RouterRouteHandler = async (
};
};
export const getTypesHandler: RouterRouteHandler = async (req, callWithRequest) => {
export const getTypesHandler: RouterRouteHandler = async () => {
// In ECE/ESS, do not enable the default types
const types: RepositoryType[] = isCloudEnabled ? [] : [...DEFAULT_REPOSITORY_TYPES];
const plugins: any[] = await callWithRequest('cat.plugins', { format: 'json' });
// Call with internal user so that the requesting user does not need `monitoring` cluster
// privilege just to see list of available repository types
const plugins: any[] = await callWithInternalUser('cat.plugins', { format: 'json' });
// Filter list of plugins to repository-related ones
if (plugins && plugins.length) {
const pluginNames: string[] = [...new Set(plugins.map(plugin => plugin.component))];
pluginNames.forEach(pluginName => {

View file

@ -29,6 +29,7 @@ export interface Plugins {
};
};
xpack_main: any;
elasticsearch: any;
}
export function createShim(
@ -52,6 +53,7 @@ export function createShim(
},
},
xpack_main: server.plugins.xpack_main,
elasticsearch: server.plugins.elasticsearch,
},
};
}