Snapshot Repositories UI (#34407) (#36522)

* [SR] Snapshot and restore plugin boilerplate (#32276)

* Initial plugin set up

* Set up client shell

* Add initial repository list routes

* Fix merge issues and some typings

* Decouple server from plugin.ts files, tighten up typings

* Use exported constant for required license

* Translate plugin name, more typings

* Fix more types, move list components under /home

* Remove unused var

* Change scss prefix

* Uncouple unmount logic from routing shim, and some other PR feedback

* [SR] Repository list and details UI (#33367)

* Initial pass at repositories list UI

* Add detail panel for file system repositories, and a generic detail panel with json settings view

* Add detail components for other types

* Add detail panel footer, rename `useStateValue` to `useAppState`

* Fix detail panel footer

* Fix unused vars

* PR feedback

* PR feedback

* [SR] Refactor proposal (#33690)

* Move app dependencies to its own context provider

* Add index.ts barrel file for common types

* Move Enums to constants.ts file

* Refactor function component using `React.FunctionComponent<Props>`

* Refactor service folder structure

* Fix type import

* Move REPOSITORY_DOC_PATHS from common to public constants

* Move AppCore and AppPlugins interfaces back to shim and re-export them from app types

* [SR] Create and edit repositories UI (#34020)

* Add routing and placeholder form

* Fix typings

* Set up edit repository route, and basic form UI

* Add typings for wrapCustomError, and copy extractCausedByChain from CCR wrapEsError

* Throw errors that are already boomified

* Create and edit for basic repository types (fs, url, source)

* Add repository verification UI to table and details

* Create and edit for plugin repository types (hdfs, azure, s3, gcs)

* Fix linting

* Fix test

* Fix test

* Remove unused import

* Fix duplicate i18n key

* Fix details opening on cancel edit, remove unnecessary Fragments, definition file for some EUI components to x-pack, rename saveError

* Remove breaks

* Adjust add and edit repo routes so they don't conflict with list route

* Add repo plugin and types doc links to form

* Bootstrap documentation service

* Bootstrap text service and replace RepositoryTypeName component with it

* Bootstrap breadcrumb service and replace usages

* Bootstrap httpService, remove chrome and http from app dependencies(!)

* Add request creator and replace all instances of useRequest and sendRequest with it

* Fix typo

* Simplify update repository and update repository setting methods

* Adjust copy

* Lint

* Remove unused var

* Remove unused import

* [SR] Add API for retrieving snapshots. (#34598)

* [SR] Single and multiple repository delete (#34593)

* Add single/multi repository delete API and UI

* Address PR feedback

* [SR] Add SnapshotTable and SnapshotDetails. (#34837)

* Remove associations between multiple repositories with a single snapshot.
* Retrieve complete snapshot details in getAllHandler.
* Fix cleanup function bug in useRequest hook.
* Fix bug in useRequest which prevented old data from being cleared when subsequent requests returned errors.
* Add initialValue config option to useRequest.
* Add formatDate service to text module.

* [SR] Fix linting and add (de)serialization for repositories (#35031)

* Fix eslint issues and add (de)serialization for repositories

* Add comment about flattening settings

* [SR] Surface repository errors and index failures more prominently (#35042)

* Add links to repositories from Snapshot Table and Snapshot Details.
- Rename services/breadcrumbs to services/navigation and add linkToRepository function.
- Refactor home component to update active tab when URL was changed.
* Add warning callout to let user know when their repositories contain errors.
* Sort failures by shard and add test for snapshot serialization.
* Sort failures and indices.
* Add filter for filtering snapshots by their repository.
* Surface states with humanized text, icons, and tooltips where necessary.
* Fix pluralization of seconds.
* Surface failures tab even if there are none.
- Display a '-' for missing times and durations.
- Create DataPlaceholder component.

* [SR] Polish repositories UX (#35123)

* Refactor repository detail panel to load repository based directly on route param.
* Display repository detail panel while table is loading.
* Make 'Edit repository' table action a link instead of a button.
* Render disabled EuiSelect as a readonly EuiFieldText.
* Prepend HDFS URI with hdfs:// protocol.
* Present scheme options for Read-Only URL repository as a select.

* [SR] Add client-side validation to repository form and link to snapshots from details (#35238)

* Add client side repository form validation, extract `flatten` into common lib

* Add snapshot count to repository details and link to snapshot list

* Reset validation when changing repository type

* Fix snapshot list filter deep linking for repository names with slashes and spaces

* Fix imports

* PR feedback

* [SR] Design and copywriting fixes (#35591)

* Split repository form into two steps; move `clean_settings.ts` to server

* Default to snapshots tab, adjust snapshot empty prompt, add app description

* Add minimum timeout to list view requests to avoid flicker, use EuiEmptyPrompt for loading screen, add doc link to settings step

* Add information about snapshots to delete repository behavior, add doc link for source only toggle, add size notation help text

* Add main doc link

* Copywriting and i18n fixes, and add some common settings to third party repo types

* Add fields to third party repo detail panel

* More copywriting fixes

* Use spinner for duration and end time if snapshotting is still in progress

* Show all repository type options, mark missing plugins

* Revert "Show all repository type options, mark missing plugins"

This reverts commit e34ee47cec.

* Fix space

* [SR] Add permissions UI and Cloud-specific repository type UI branch (#35833)

* Add missing permissions UI and cloud-specific repository type UI branch

* Add ES UI as owners of /snapshot_restore directory

* Add no repository types callout for Cloud edge case

* Redirect invalid section param to repositories

* Add warning empty prompt if all repositories have errrors

* Replace repository cards with EuiCard

* Add snapshot doc link to repository error empty prompt

* Remove auto-verification from list and get routes, add separate verification route, add manual verification to repository detail panel

* Update copy and remove obsolete test

* Remove unused scss files

* Final changes to repository cards
This commit is contained in:
Jen Huang 2019-05-13 16:27:11 -04:00 committed by GitHub
parent f16d70c75a
commit bba50a8839
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
113 changed files with 10139 additions and 6 deletions

View file

@ -45,6 +45,7 @@
"xpack.siem": "x-pack/plugins/siem",
"xpack.security": "x-pack/plugins/security",
"xpack.server": "x-pack/server",
"xpack.snapshotRestore": "x-pack/plugins/snapshot_restore",
"xpack.spaces": "x-pack/plugins/spaces",
"xpack.upgradeAssistant": "x-pack/plugins/upgrade_assistant",
"xpack.uptime": "x-pack/plugins/uptime",

View file

@ -28,4 +28,8 @@ declare module 'ui/management' {
allowOverride: boolean
): void;
export const management: any; // TODO - properly provide types
export const MANAGEMENT_BREADCRUMB: {
text: string;
href: string;
};
}

View file

@ -40,6 +40,7 @@ import { upgradeAssistant } from './plugins/upgrade_assistant';
import { uptime } from './plugins/uptime';
import { ossTelemetry } from './plugins/oss_telemetry';
import { encryptedSavedObjects } from './plugins/encrypted_saved_objects';
import { snapshotRestore } from './plugins/snapshot_restore';
module.exports = function (kibana) {
return [
@ -79,5 +80,6 @@ module.exports = function (kibana) {
uptime(kibana),
ossTelemetry(kibana),
encryptedSavedObjects(kibana),
snapshotRestore(kibana),
];
};

View file

@ -64,8 +64,8 @@ export const registerCcrRoutes = (server) => {
}
const securityInfo = (xpackInfo && xpackInfo.isAvailable() && xpackInfo.feature('security'));
if (!securityInfo || !securityInfo.isEnabled()) {
// If security isn't enabled, let the user use CCR.
if (!securityInfo || !securityInfo.isAvailable() || !securityInfo.isEnabled()) {
// If security isn't enabled or available (in the case where security is enabled but license reverted to Basic) let the user use CCR.
return {
hasPermission: true,
missingClusterPrivileges: [],

View file

@ -116,6 +116,8 @@ declare module '@elastic/eui' {
loading?: any;
hasActions?: any;
message?: any;
rowProps?: any;
cellProps?: any;
};
export const EuiInMemoryTable: React.SFC<EuiInMemoryTableProps>;
}

View file

@ -0,0 +1,53 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { LICENSE_TYPE_BASIC, LicenseType } from '../../../common/constants';
import { RepositoryType } from './types';
const PLUGIN_NAME = 'Snapshot Repositories';
export const PLUGIN = {
ID: 'snapshot_restore',
MINIMUM_LICENSE_REQUIRED: LICENSE_TYPE_BASIC as LicenseType,
getI18nName: (translate: (key: string, config: object) => string): string => {
return translate('xpack.snapshotRestore.appName', {
defaultMessage: PLUGIN_NAME,
});
},
};
export const API_BASE_PATH = '/api/snapshot_restore/';
export enum REPOSITORY_TYPES {
fs = 'fs',
url = 'url',
source = 'source',
s3 = 's3',
hdfs = 'hdfs',
azure = 'azure',
gcs = 'gcs',
}
// Deliberately do not include `source` as a default repository since we treat it as a flag
export const DEFAULT_REPOSITORY_TYPES: RepositoryType[] = [
REPOSITORY_TYPES.fs,
REPOSITORY_TYPES.url,
];
export const PLUGIN_REPOSITORY_TYPES: RepositoryType[] = [
REPOSITORY_TYPES.s3,
REPOSITORY_TYPES.hdfs,
REPOSITORY_TYPES.azure,
REPOSITORY_TYPES.gcs,
];
export const REPOSITORY_PLUGINS_MAP: { [key: string]: RepositoryType } = {
'repository-s3': REPOSITORY_TYPES.s3,
'repository-hdfs': REPOSITORY_TYPES.hdfs,
'repository-azure': REPOSITORY_TYPES.azure,
'repository-gcs': REPOSITORY_TYPES.gcs,
};
export const APP_PERMISSIONS = ['monitor', 'create_snapshot', 'cluster:admin/repository'];

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { flatten } from './flatten';
describe('flatten()', () => {
it('should return an empty object', () => {
expect(flatten({})).toEqual({});
});
it('should flatten a nested object', () => {
expect(
flatten({
foo: 'bar',
nested: {
test: 'value',
list: [{ fruit: 'apple' }, { fruit: 'banana' }],
},
'with.dot': 123,
})
).toEqual({
foo: 'bar',
'nested.test': 'value',
'nested.list.0.fruit': 'apple',
'nested.list.1.fruit': 'banana',
'with.dot': 123,
});
});
});

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export const flatten = (source: any, path: any[] = []): { [key: string]: any } => {
if (!(source instanceof Object)) {
return {
[path.join('.')]: source,
};
}
return Object.keys(source).reduce((result, key) => {
const flattened: any = flatten(source[key], [...path, key]);
return {
...result,
...flattened,
};
}, {});
};

View file

@ -0,0 +1,6 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { flatten } from './flatten';

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export * from './repository';
export * from './snapshot';

View file

@ -0,0 +1,159 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export type FSRepositoryType = 'fs';
export type ReadonlyRepositoryType = 'url';
export type SourceRepositoryType = 'source';
export type S3RepositoryType = 's3';
export type HDFSRepositoryType = 'hdfs';
export type AzureRepositoryType = 'azure';
export type GCSRepositoryType = 'gcs';
export type RepositoryType =
| FSRepositoryType
| ReadonlyRepositoryType
| SourceRepositoryType
| S3RepositoryType
| HDFSRepositoryType
| AzureRepositoryType
| GCSRepositoryType;
export interface FSRepository {
name: string;
type: FSRepositoryType;
settings: {
location: string;
compress?: boolean;
chunkSize?: string | null;
maxRestoreBytesPerSec?: string;
maxSnapshotBytesPerSec?: string;
readonly?: boolean;
};
}
export interface ReadonlyRepository {
name: string;
type: ReadonlyRepositoryType;
settings: {
url: string;
};
}
export interface S3Repository {
name: string;
type: S3RepositoryType;
settings: {
bucket: string;
client?: string;
basePath?: string;
compress?: boolean;
chunkSize?: string | null;
serverSideEncryption?: boolean;
bufferSize?: string;
cannedAcl?: string;
storageClass?: string;
maxRestoreBytesPerSec?: string;
maxSnapshotBytesPerSec?: string;
readonly?: boolean;
};
}
export interface HDFSRepository {
name: string;
type: HDFSRepositoryType;
settings: {
uri: string;
path: string;
loadDefaults?: boolean;
compress?: boolean;
chunkSize?: string | null;
maxRestoreBytesPerSec?: string;
maxSnapshotBytesPerSec?: string;
readonly?: boolean;
['security.principal']?: string;
[key: string]: any; // For conf.* settings
};
}
export interface AzureRepository {
name: string;
type: AzureRepositoryType;
settings: {
client?: string;
container?: string;
basePath?: string;
locationMode?: string;
compress?: boolean;
chunkSize?: string | null;
maxRestoreBytesPerSec?: string;
maxSnapshotBytesPerSec?: string;
readonly?: boolean;
};
}
export interface GCSRepository {
name: string;
type: GCSRepositoryType;
settings: {
bucket: string;
client?: string;
basePath?: string;
compress?: boolean;
chunkSize?: string | null;
maxRestoreBytesPerSec?: string;
maxSnapshotBytesPerSec?: string;
readonly?: boolean;
};
}
export interface EmptyRepository {
name: string;
type: null;
settings: {
[key: string]: any;
};
}
export interface SourceRepository<T> {
name: string;
type: SourceRepositoryType;
settings: SourceRepositorySettings<T>;
}
export type SourceRepositorySettings<T> = T extends FSRepositoryType
? FSRepository['settings']
: T extends S3RepositoryType
? S3Repository['settings']
: T extends HDFSRepositoryType
? HDFSRepository['settings']
: T extends AzureRepositoryType
? AzureRepository['settings']
: T extends GCSRepositoryType
? GCSRepository['settings']
: any & {
delegateType: T;
};
export type Repository<T = null> =
| FSRepository
| ReadonlyRepository
| S3Repository
| HDFSRepository
| AzureRepository
| GCSRepository
| SourceRepository<T>;
export interface ValidRepositoryVerification {
valid: true;
response: object;
}
export interface InvalidRepositoryVerification {
valid: false;
error: object;
}
export type RepositoryVerification = ValidRepositoryVerification | InvalidRepositoryVerification;

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export interface SnapshotDetails {
repository: string;
snapshot: string;
uuid: string;
versionId: number;
version: string;
indices: string[];
includeGlobalState: number;
state: string;
/** e.g. '2019-04-05T21:56:40.438Z' */
startTime: string;
startTimeInMillis: number;
/** e.g. '2019-04-05T21:56:45.210Z' */
endTime: string;
endTimeInMillis: number;
durationInMillis: number;
indexFailures: any[];
shards: SnapshotDetailsShardsStatus;
}
interface SnapshotDetailsShardsStatus {
total: number;
failed: number;
successful: number;
}

View file

@ -0,0 +1,39 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Legacy } from 'kibana';
import { resolve } from 'path';
import { PLUGIN } from './common/constants';
import { Plugin as SnapshotRestorePlugin } from './plugin';
import { createShim } from './shim';
export function snapshotRestore(kibana: any) {
return new kibana.Plugin({
id: PLUGIN.ID,
configPrefix: 'xpack.snapshot_restore',
publicDir: resolve(__dirname, 'public'),
require: ['kibana', 'elasticsearch', 'xpack_main'],
uiExports: {
managementSections: ['plugins/snapshot_restore'],
},
init(server: Legacy.Server) {
const { core, plugins } = createShim(server, PLUGIN.ID);
const { i18n } = core;
const snapshotRestorePlugin = new SnapshotRestorePlugin();
// Start plugin
snapshotRestorePlugin.start(core, plugins);
// Register license checker
plugins.license.registerLicenseChecker(
server,
PLUGIN.ID,
PLUGIN.getI18nName(i18n.translate),
PLUGIN.MINIMUM_LICENSE_REQUIRED
);
},
});
}

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { API_BASE_PATH } from './common/constants';
import { registerRoutes } from './server/routes/api/register_routes';
import { Core, Plugins } from './shim';
export class Plugin {
public start(core: Core, plugins: Plugins): void {
const router = core.http.createRouter(API_BASE_PATH);
// Register routes
registerRoutes(router, plugins);
}
}

View file

@ -0,0 +1,107 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* 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 from 'react';
import { Redirect, Route, Switch } from 'react-router-dom';
import { EuiPageContent, EuiEmptyPrompt } from '@elastic/eui';
import { SectionLoading, SectionError } from './components';
import { BASE_PATH, DEFAULT_SECTION, Section } from './constants';
import { RepositoryAdd, RepositoryEdit, SnapshotRestoreHome } from './sections';
import { loadPermissions } from './services/http';
import { useAppDependencies } from './index';
export const App: React.FunctionComponent = () => {
const {
core: {
i18n: { FormattedMessage },
},
} = useAppDependencies();
// Load permissions
const {
error: permissionsError,
loading: loadingPermissions,
data: { hasPermission, missingClusterPrivileges } = {
hasPermission: true,
missingClusterPrivileges: [],
},
} = loadPermissions();
if (loadingPermissions) {
return (
<SectionLoading>
<FormattedMessage
id="xpack.snapshotRestore.app.checkingPermissionsDescription"
defaultMessage="Checking permissions…"
/>
</SectionLoading>
);
}
if (permissionsError) {
return (
<SectionError
title={
<FormattedMessage
id="xpack.snapshotRestore.app.checkingPermissionsErrorMessage"
defaultMessage="Error checking permissions"
/>
}
error={permissionsError}
/>
);
}
if (!hasPermission) {
return (
<EuiPageContent horizontalPosition="center">
<EuiEmptyPrompt
iconType="securityApp"
title={
<h2>
<FormattedMessage
id="xpack.snapshotRestore.app.deniedPermissionTitle"
defaultMessage="You're missing cluster privileges"
/>
</h2>
}
body={
<p>
<FormattedMessage
id="xpack.snapshotRestore.app.deniedPermissionDescription"
defaultMessage="To use Snapshot Repositories, you must have {clusterPrivilegesCount,
plural, one {this cluster privilege} other {these cluster privileges}}: {clusterPrivileges}."
values={{
clusterPrivileges: missingClusterPrivileges.join(', '),
clusterPrivilegesCount: missingClusterPrivileges.length,
}}
/>
</p>
}
/>
</EuiPageContent>
);
}
const sections: Section[] = ['repositories', 'snapshots'];
const sectionsRegex = sections.join('|');
return (
<div>
<Switch>
<Route exact path={`${BASE_PATH}/add_repository`} component={RepositoryAdd} />
<Route exact path={`${BASE_PATH}/edit_repository/:name*`} component={RepositoryEdit} />
<Route
exact
path={`${BASE_PATH}/:section(${sectionsRegex})/:repositoryName?/:snapshotId*`}
component={SnapshotRestoreHome}
/>
<Redirect from={`${BASE_PATH}`} to={`${BASE_PATH}/${DEFAULT_SECTION}`} />
</Switch>
</div>
);
};

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* 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 from 'react';
import { useAppDependencies } from '../index';
interface Props {
data: any;
children: React.ReactNode;
}
export const DataPlaceholder: React.SFC<Props> = ({ data, children }) => {
const {
core: { i18n },
} = useAppDependencies();
if (data != null) {
return children;
}
return i18n.translate('xpack.snapshotRestore.dataPlaceholderLabel', {
defaultMessage: '-',
});
};

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { DataPlaceholder } from './data_placeholder';
export { RepositoryDeleteProvider } from './repository_delete_provider';
export { RepositoryForm } from './repository_form';
export { RepositoryVerificationBadge } from './repository_verification_badge';
export { RepositoryTypeLogo } from './repository_type_logo';
export { SectionError } from './section_error';
export { SectionLoading } from './section_loading';

View file

@ -0,0 +1,191 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* 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, { Fragment, useRef, useState } from 'react';
import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui';
import { Repository } from '../../../common/types';
import { useAppDependencies } from '../index';
import { deleteRepositories } from '../services/http';
interface Props {
children: (deleteRepository: DeleteRepository) => React.ReactElement;
}
export type DeleteRepository = (
names: Array<Repository['name']>,
onSuccess?: OnSuccessCallback
) => void;
type OnSuccessCallback = (repositoriesDeleted: Array<Repository['name']>) => void;
export const RepositoryDeleteProvider: React.FunctionComponent<Props> = ({ children }) => {
const {
core: {
i18n,
notification: { toastNotifications },
},
} = useAppDependencies();
const { FormattedMessage } = i18n;
const [repositoryNames, setRepositoryNames] = useState<Array<Repository['name']>>([]);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const onSuccessCallback = useRef<OnSuccessCallback | null>(null);
const deleteRepositoryPrompt: DeleteRepository = (names, onSuccess = () => undefined) => {
if (!names || !names.length) {
throw new Error('No repository names specified for deletion');
}
setIsModalOpen(true);
setRepositoryNames(names);
onSuccessCallback.current = onSuccess;
};
const closeModal = () => {
setIsModalOpen(false);
setRepositoryNames([]);
};
const deleteRepository = () => {
const repositoriesToDelete = [...repositoryNames];
deleteRepositories(repositoriesToDelete).then(({ data: { itemsDeleted, errors }, error }) => {
// Surface success notifications
if (itemsDeleted && itemsDeleted.length) {
const hasMultipleSuccesses = itemsDeleted.length > 1;
const successMessage = hasMultipleSuccesses
? i18n.translate(
'xpack.snapshotRestore.deleteRepository.successMultipleNotificationTitle',
{
defaultMessage: 'Removed {count} repositories',
values: { count: itemsDeleted.length },
}
)
: i18n.translate(
'xpack.snapshotRestore.deleteRepository.successSingleNotificationTitle',
{
defaultMessage: "Removed repository '{name}'",
values: { name: itemsDeleted[0] },
}
);
toastNotifications.addSuccess(successMessage);
if (onSuccessCallback.current) {
onSuccessCallback.current([...itemsDeleted]);
}
}
// Surface error notifications
// `error` is generic server error
// `data.errors` are specific errors with removing particular repository(ies)
if (error || (errors && errors.length)) {
const hasMultipleErrors =
(errors && errors.length > 1) || (error && repositoriesToDelete.length > 1);
const errorMessage = hasMultipleErrors
? i18n.translate(
'xpack.snapshotRestore.deleteRepository.errorMultipleNotificationTitle',
{
defaultMessage: 'Error removing {count} repositories',
values: {
count: (errors && errors.length) || repositoriesToDelete.length,
},
}
)
: i18n.translate('xpack.snapshotRestore.deleteRepository.errorSingleNotificationTitle', {
defaultMessage: "Error removing repository '{name}'",
values: { name: (errors && errors[0].name) || repositoriesToDelete[0] },
});
toastNotifications.addDanger(errorMessage);
}
});
closeModal();
};
const renderModal = () => {
if (!isModalOpen) {
return null;
}
const isSingle = repositoryNames.length === 1;
return (
<EuiOverlayMask>
<EuiConfirmModal
title={
isSingle ? (
<FormattedMessage
id="xpack.snapshotRestore.deleteRepository.confirmModal.deleteSingleTitle"
defaultMessage="Remove repository '{name}'?"
values={{ name: repositoryNames[0] }}
/>
) : (
<FormattedMessage
id="xpack.snapshotRestore.deleteRepository.confirmModal.deleteMultipleTitle"
defaultMessage="Remove {count} repositories?"
values={{ count: repositoryNames.length }}
/>
)
}
onCancel={closeModal}
onConfirm={deleteRepository}
cancelButtonText={
<FormattedMessage
id="xpack.snapshotRestore.deleteRepository.confirmModal.cancelButtonLabel"
defaultMessage="Cancel"
/>
}
confirmButtonText={
isSingle ? (
<FormattedMessage
id="xpack.snapshotRestore.deleteRepository.confirmModal.confirmSingleButtonLabel"
defaultMessage="Remove repository"
/>
) : (
<FormattedMessage
id="xpack.snapshotRestore.deleteRepository.confirmModal.confirmMultipleButtonLabel"
defaultMessage="Remove repositories"
/>
)
}
buttonColor="danger"
data-test-subj="srDeleteRepositoryConfirmationModal"
>
{isSingle ? (
<p>
<FormattedMessage
id="xpack.snapshotRestore.deleteRepository.confirmModal.deleteSingleDescription"
defaultMessage="The snapshots in this repository will still exist, but Elasticsearch wont have access to them."
/>
</p>
) : (
<Fragment>
<p>
<FormattedMessage
id="xpack.snapshotRestore.deleteRepository.confirmModal.deleteMultipleListDescription"
defaultMessage="You are about to remove these repositories:"
/>
</p>
<ul>
{repositoryNames.map(name => (
<li key={name}>{name}</li>
))}
</ul>
<p>
<FormattedMessage
id="xpack.snapshotRestore.deleteRepository.confirmModal.deleteMultipleDescription"
defaultMessage="The snapshots in these repositories will still exist, but Elasticsearch won't have access to them."
/>
</p>
</Fragment>
)}
</EuiConfirmModal>
</EuiOverlayMask>
);
};
return (
<Fragment>
{children(deleteRepositoryPrompt)}
{renderModal()}
</Fragment>
);
};

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { RepositoryForm } from './repository_form';

View file

@ -0,0 +1,124 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* 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, { useState } from 'react';
import { EuiForm } from '@elastic/eui';
import { Repository, EmptyRepository } from '../../../../common/types';
import { flatten } from '../../../../common/lib';
import { RepositoryValidation, validateRepository } from '../../services/validation';
import { RepositoryFormStepOne } from './step_one';
import { RepositoryFormStepTwo } from './step_two';
interface Props {
repository: Repository | EmptyRepository;
isEditing?: boolean;
isSaving: boolean;
saveError?: React.ReactNode;
clearSaveError: () => void;
onSave: (repository: Repository | EmptyRepository) => void;
}
export const RepositoryForm: React.FunctionComponent<Props> = ({
repository: originalRepository,
isEditing,
isSaving,
saveError,
clearSaveError,
onSave,
}) => {
const [currentStep, setCurrentStep] = useState<1 | 2>(isEditing ? 2 : 1);
// Repository state
const [repository, setRepository] = useState<Repository | EmptyRepository>({
...originalRepository,
settings: {
...originalRepository.settings,
},
});
// Repository validation state
const [validation, setValidation] = useState<RepositoryValidation>({
isValid: true,
errors: {},
});
const updateRepository = (updatedFields: any): void => {
const newRepository = { ...repository, ...updatedFields };
setRepository(newRepository);
};
const saveRepository = () => {
const newValidation = validateRepository(repository, true);
const { isValid } = newValidation;
setValidation(newValidation);
if (isValid) {
onSave(repository);
}
};
const goToNextStep = () => {
const newValidation = validateRepository(repository, false);
const { isValid } = newValidation;
setValidation(newValidation);
if (isValid) {
setCurrentStep(2);
}
};
const goToPreviousStep = () => {
if (isEditing) {
return;
}
setValidation({
isValid: true,
errors: {},
});
setCurrentStep(1);
clearSaveError();
};
const hasValidationErrors: boolean = !validation.isValid;
const validationErrors = Object.entries(flatten(validation.errors)).reduce(
(acc: string[], [key, value]) => {
return [...acc, value];
},
[]
);
const renderStepOne = () => (
<RepositoryFormStepOne
repository={repository}
onNext={() => goToNextStep()}
updateRepository={updateRepository}
validation={validation}
/>
);
const renderStepTwo = () => (
<RepositoryFormStepTwo
repository={repository as Repository}
isEditing={isEditing}
isSaving={isSaving}
onSave={saveRepository}
updateRepository={updateRepository}
validation={validation}
saveError={saveError}
onBack={() => goToPreviousStep()}
/>
);
return (
<EuiForm isInvalid={hasValidationErrors} error={validationErrors}>
{currentStep === 1 && !isEditing ? renderStepOne() : renderStepTwo()}
</EuiForm>
);
};
RepositoryForm.defaultProps = {
isEditing: false,
isSaving: false,
};

View file

@ -0,0 +1,385 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* 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, { Fragment } from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiCallOut,
EuiCard,
EuiDescribedFormGroup,
EuiFieldText,
EuiFlexGrid,
EuiFlexItem,
EuiFormRow,
EuiLink,
EuiSpacer,
EuiSwitch,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { Repository, RepositoryType, EmptyRepository } from '../../../../common/types';
import { REPOSITORY_TYPES } from '../../../../common/constants';
import { useAppDependencies } from '../../index';
import { documentationLinksService } from '../../services/documentation';
import { loadRepositoryTypes } from '../../services/http';
import { textService } from '../../services/text';
import { RepositoryValidation } from '../../services/validation';
import { SectionError, SectionLoading, RepositoryTypeLogo } from '../';
interface Props {
repository: Repository | EmptyRepository;
onNext: () => void;
updateRepository: (updatedFields: any) => void;
validation: RepositoryValidation;
}
export const RepositoryFormStepOne: React.FunctionComponent<Props> = ({
repository,
onNext,
updateRepository,
validation,
}) => {
const {
core: {
i18n: { FormattedMessage },
},
} = useAppDependencies();
// Load repository types
const {
error: repositoryTypesError,
loading: repositoryTypesLoading,
data: repositoryTypes = [],
} = loadRepositoryTypes();
const hasValidationErrors: boolean = !validation.isValid;
const onTypeChange = (newType: RepositoryType) => {
if (repository.type === REPOSITORY_TYPES.source) {
updateRepository({
settings: {
delegateType: newType,
},
});
} else {
updateRepository({
type: newType,
settings: {},
});
}
};
const pluginDocLink = (
<EuiLink href={documentationLinksService.getRepositoryPluginDocUrl()} target="_blank">
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.fields.typePluginsDocLinkText"
defaultMessage="Learn more about plugins."
/>
</EuiLink>
);
const renderNameField = () => (
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.fields.nameDescriptionTitle"
defaultMessage="Repository name"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.fields.nameDescription"
defaultMessage="A unique name for the repository."
/>
}
idAria="repositoryNameDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.fields.nameLabel"
defaultMessage="Name"
/>
}
describedByIds={['repositoryNameDescription']}
isInvalid={Boolean(hasValidationErrors && validation.errors.name)}
error={validation.errors.name}
fullWidth
>
<EuiFieldText
defaultValue={repository.name}
fullWidth
onChange={e => {
updateRepository({
name: e.target.value,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
);
const renderTypeCard = (type: RepositoryType, index: number) => {
const isSelectedType =
(repository.type === REPOSITORY_TYPES.source
? repository.settings.delegateType
: repository.type) === type;
const displayName = textService.getRepositoryTypeName(type);
return (
<EuiFlexItem key={index}>
<EuiCard
title={displayName}
icon={<RepositoryTypeLogo type={type} size="l" />}
footer={
<EuiButtonEmpty
href={documentationLinksService.getRepositoryTypeDocUrl(type)}
target="_blank"
size="xs"
iconType="iInCircle"
>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.fields.typeDocsLinkText"
defaultMessage="Learn more"
/>
</EuiButtonEmpty>
}
selectable={{
onClick: () => onTypeChange(type),
isSelected: isSelectedType,
}}
/>
</EuiFlexItem>
);
};
const renderTypes = () => {
if (repositoryTypesError) {
return (
<SectionError
title={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.loadingRepositoryTypesErrorMessage"
defaultMessage="Error loading repository types"
/>
}
error={repositoryTypesError}
/>
);
}
if (repositoryTypesLoading) {
return (
<SectionLoading>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.loadingRepositoryTypesDescription"
defaultMessage="Loading repository types…"
/>
</SectionLoading>
);
}
if (!repositoryTypes.length) {
return (
<EuiCallOut
title={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.noRepositoryTypesErrorTitle"
defaultMessage="No repository types available"
/>
}
color="warning"
data-test-subj="noRepositoryTypesError"
>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.noRepositoryTypesErrorMessage"
defaultMessage="You can install plugins to enable different repository types. {docLink}"
values={{
docLink: pluginDocLink,
}}
/>
</EuiCallOut>
);
}
return (
<EuiFlexGrid columns={4}>
{repositoryTypes.map((type: RepositoryType, index: number) => renderTypeCard(type, index))}
</EuiFlexGrid>
);
};
const renderTypeField = () => {
return (
<Fragment>
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.fields.typeDescriptionTitle"
defaultMessage="Repository type"
/>
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiText id="repositoryTypeDescription" size="s" color="subdued">
{repositoryTypes.includes(REPOSITORY_TYPES.fs) &&
repositoryTypes.includes(REPOSITORY_TYPES.url) ? (
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.fields.defaultTypeDescription"
defaultMessage="Elasticsearch supports file system and read-only URL repositories.
Additional types require plugins. {docLink}"
values={{
docLink: pluginDocLink,
}}
/>
) : (
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.fields.cloudTypeDescription"
defaultMessage="Elasticsearch provides core plugins for custom repositories. {docLink}"
values={{
docLink: pluginDocLink,
}}
/>
)}
</EuiText>
<EuiFormRow
hasEmptyLabelSpace
describedByIds={['repositoryTypeDescription']}
fullWidth
isInvalid={Boolean(hasValidationErrors && validation.errors.type)}
error={validation.errors.type}
>
{renderTypes()}
</EuiFormRow>
<EuiSpacer size="m" />
</Fragment>
);
};
const renderSourceOnlyToggle = () => (
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.fields.sourceOnlyDescriptionTitle"
defaultMessage="Source-only snapshots"
/>
</h3>
</EuiTitle>
}
description={
<Fragment>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.fields.sourceOnlyDescription"
defaultMessage="Creates source-only snapshots that take up to 50% less space. {docLink}"
values={{
docLink: (
<EuiLink
href={documentationLinksService.getRepositoryTypeDocUrl(REPOSITORY_TYPES.source)}
target="_blank"
>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.fields.sourceOnlyDocLinkText"
defaultMessage="Learn more about source-only repositories."
/>
</EuiLink>
),
}}
/>
</Fragment>
}
idAria="sourceOnlyDescription"
fullWidth
>
<EuiFormRow hasEmptyLabelSpace={true} fullWidth describedByIds={['sourceOnlyDescription']}>
<EuiSwitch
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.fields.sourceOnlyLabel"
defaultMessage="Source-only snapshots"
/>
}
checked={repository.type === REPOSITORY_TYPES.source}
onChange={e => {
if (e.target.checked) {
updateRepository({
type: REPOSITORY_TYPES.source,
settings: {
...repository.settings,
delegateType: repository.type,
},
});
} else {
const {
settings: { delegateType, ...rest },
} = repository;
updateRepository({
type: delegateType || null,
settings: rest,
});
}
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
);
const renderActions = () => (
<EuiButton
color="primary"
onClick={onNext}
fill
iconType="arrowRight"
iconSide="right"
data-test-subj="srRepositoryFormNextButton"
>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.nextButtonLabel"
defaultMessage="Next"
/>
</EuiButton>
);
const renderFormValidationError = () => {
if (!hasValidationErrors) {
return null;
}
return (
<Fragment>
<EuiCallOut
title={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.validationErrorTitle"
defaultMessage="Fix errors before continuing."
/>
}
color="danger"
data-test-subj="repositoryFormError"
/>
<EuiSpacer size="m" />
</Fragment>
);
};
return (
<Fragment>
{renderNameField()}
{renderTypeField()}
{renderSourceOnlyToggle()}
{renderFormValidationError()}
{renderActions()}
</Fragment>
);
};

View file

@ -0,0 +1,203 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* 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, { Fragment } from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import { Repository } from '../../../../common/types';
import { REPOSITORY_TYPES } from '../../../../common/constants';
import { useAppDependencies } from '../../index';
import { RepositoryValidation } from '../../services/validation';
import { documentationLinksService } from '../../services/documentation';
import { TypeSettings } from './type_settings';
import { textService } from '../../services/text';
interface Props {
repository: Repository;
isEditing?: boolean;
isSaving: boolean;
onSave: () => void;
updateRepository: (updatedFields: any) => void;
validation: RepositoryValidation;
saveError?: React.ReactNode;
onBack: () => void;
}
export const RepositoryFormStepTwo: React.FunctionComponent<Props> = ({
repository,
isEditing,
isSaving,
onSave,
updateRepository,
validation,
saveError,
onBack,
}) => {
const {
core: {
i18n: { FormattedMessage },
},
} = useAppDependencies();
const hasValidationErrors: boolean = !validation.isValid;
const {
name,
type,
settings: { delegateType },
} = repository;
const typeForDocs = type === REPOSITORY_TYPES.source ? delegateType : type;
const renderSettings = () => (
<Fragment>
{/* Repository settings title */}
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiTitle size="m">
<h2>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.fields.settingsTitle"
defaultMessage="{repositoryName} settings"
values={{
repositoryName: `'${name}'`,
}}
/>
</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="s"
flush="right"
href={documentationLinksService.getRepositoryTypeDocUrl(typeForDocs)}
target="_blank"
iconType="help"
>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.repositoryTypeDocLink"
defaultMessage="{repositoryType} repository docs"
values={{
repositoryType: textService.getRepositoryTypeName(typeForDocs),
}}
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="l" />
{/* Repository settings fields */}
<TypeSettings
repository={repository}
updateRepository={updateRepository}
settingErrors={
hasValidationErrors && validation.errors.settings ? validation.errors.settings : {}
}
/>
</Fragment>
);
const renderActions = () => {
const saveLabel = isEditing ? (
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.saveButtonLabel"
defaultMessage="Save"
/>
) : (
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.registerButtonLabel"
defaultMessage="Register"
/>
);
const savingLabel = (
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.savingButtonLabel"
defaultMessage="Saving…"
/>
);
return (
<EuiFlexGroup gutterSize="m" alignItems="center">
{isEditing ? null : (
<EuiFlexItem grow={false}>
<EuiButtonEmpty
color="primary"
iconType="arrowLeft"
onClick={onBack}
data-test-subj="srRepositoryFormSubmitButton"
>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.backButtonLabel"
defaultMessage="Back"
/>
</EuiButtonEmpty>
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<EuiButton
color="secondary"
iconType="check"
onClick={onSave}
fill
data-test-subj="srRepositoryFormSubmitButton"
isLoading={isSaving}
>
{isSaving ? savingLabel : saveLabel}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
);
};
const renderFormValidationError = () => {
if (!hasValidationErrors) {
return null;
}
return (
<Fragment>
<EuiCallOut
title={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.validationErrorTitle"
defaultMessage="Fix errors before continuing."
/>
}
color="danger"
iconType="cross"
data-test-subj="repositoryFormError"
/>
<EuiSpacer size="m" />
</Fragment>
);
};
const renderSaveError = () => {
if (!saveError) {
return null;
}
return (
<Fragment>
{saveError}
<EuiSpacer size="m" />
</Fragment>
);
};
return (
<Fragment>
{renderSettings()}
{renderFormValidationError()}
{renderSaveError()}
{renderActions()}
</Fragment>
);
};

View file

@ -0,0 +1,474 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* 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, { Fragment } from 'react';
import {
EuiDescribedFormGroup,
EuiFieldText,
EuiFormRow,
EuiSelect,
EuiSwitch,
EuiTitle,
} from '@elastic/eui';
import { AzureRepository, Repository } from '../../../../../common/types';
import { useAppDependencies } from '../../../index';
import { RepositorySettingsValidation } from '../../../services/validation';
import { textService } from '../../../services/text';
interface Props {
repository: AzureRepository;
updateRepositorySettings: (
updatedSettings: Partial<Repository['settings']>,
replaceSettings?: boolean
) => void;
settingErrors: RepositorySettingsValidation;
}
export const AzureSettings: React.FunctionComponent<Props> = ({
repository,
updateRepositorySettings,
settingErrors,
}) => {
const {
core: {
i18n: { FormattedMessage },
},
} = useAppDependencies();
const {
settings: {
client,
container,
basePath,
compress,
chunkSize,
readonly,
locationMode,
maxRestoreBytesPerSec,
maxSnapshotBytesPerSec,
},
} = repository;
const hasErrors: boolean = Boolean(Object.keys(settingErrors).length);
const locationModeOptions = ['primary_only', 'secondary_only'].map(option => ({
value: option,
text: option,
}));
return (
<Fragment>
{/* Client field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeAzure.clientTitle"
defaultMessage="Client"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeAzure.clientDescription"
defaultMessage="The name of the Azure client."
/>
}
idAria="azureRepositoryClientDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeAzure.clientLabel"
defaultMessage="Client"
/>
}
fullWidth
describedByIds={['azureRepositoryClientDescription']}
isInvalid={Boolean(hasErrors && settingErrors.client)}
error={settingErrors.client}
>
<EuiFieldText
defaultValue={client || ''}
fullWidth
onChange={e => {
updateRepositorySettings({
client: e.target.value,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Container field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeAzure.containerTitle"
defaultMessage="Container"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeAzure.containerDescription"
defaultMessage="The name of the Azure container to use for snapshots."
/>
}
idAria="azureRepositoryContainerDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeAzure.containerLabel"
defaultMessage="Container"
/>
}
fullWidth
describedByIds={['azureRepositoryContainerDescription']}
isInvalid={Boolean(hasErrors && settingErrors.container)}
error={settingErrors.container}
>
<EuiFieldText
defaultValue={container || ''}
fullWidth
onChange={e => {
updateRepositorySettings({
container: e.target.value,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Base path field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeAzure.basePathTitle"
defaultMessage="Base path"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeAzure.basePathDescription"
defaultMessage="The container path to the repository data."
/>
}
idAria="azureRepositoryBasePathDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeAzure.basePathLabel"
defaultMessage="Base path"
/>
}
fullWidth
describedByIds={['azureRepositoryBasePathDescription']}
isInvalid={Boolean(hasErrors && settingErrors.basePath)}
error={settingErrors.basePath}
>
<EuiFieldText
defaultValue={basePath || ''}
fullWidth
onChange={e => {
updateRepositorySettings({
basePath: e.target.value,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Compress field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeAzure.compressTitle"
defaultMessage="Snapshot compression"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeAzure.compressDescription"
defaultMessage="Compresses the index mapping and setting files for snapshots. Data files are not compressed."
/>
}
idAria="azureRepositoryCompressDescription"
fullWidth
>
<EuiFormRow
hasEmptyLabelSpace={true}
fullWidth
describedByIds={['azureRepositoryCompressDescription']}
isInvalid={Boolean(hasErrors && settingErrors.compress)}
error={settingErrors.compress}
>
<EuiSwitch
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeAzure.compressLabel"
defaultMessage="Compress snapshots"
/>
}
checked={!(compress === false)}
onChange={e => {
updateRepositorySettings({
compress: e.target.checked,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Chunk size field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeAzure.chunkSizeTitle"
defaultMessage="Chunk size"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeAzure.chunkSizeDescription"
defaultMessage="Breaks files into smaller units when taking snapshots."
/>
}
idAria="azureRepositoryChunkSizeDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeAzure.chunkSizeLabel"
defaultMessage="Chunk size"
/>
}
fullWidth
describedByIds={['azureRepositoryChunkSizeDescription']}
isInvalid={Boolean(hasErrors && settingErrors.chunkSize)}
error={settingErrors.chunkSize}
helpText={textService.getSizeNotationHelpText()}
>
<EuiFieldText
defaultValue={chunkSize || ''}
fullWidth
onChange={e => {
updateRepositorySettings({
chunkSize: e.target.value,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Max snapshot bytes field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeAzure.maxSnapshotBytesTitle"
defaultMessage="Max snapshot bytes per second"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeAzure.maxSnapshotBytesDescription"
defaultMessage="The rate for creating snapshots for each node."
/>
}
idAria="azureRepositoryMaxSnapshotBytesDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeAzure.maxSnapshotBytesLabel"
defaultMessage="Max snapshot bytes per second"
/>
}
fullWidth
describedByIds={['azureRepositoryMaxSnapshotBytesDescription']}
isInvalid={Boolean(hasErrors && settingErrors.maxSnapshotBytesPerSec)}
error={settingErrors.maxSnapshotBytesPerSec}
helpText={textService.getSizeNotationHelpText()}
>
<EuiFieldText
defaultValue={maxSnapshotBytesPerSec || ''}
fullWidth
onChange={e => {
updateRepositorySettings({
maxSnapshotBytesPerSec: e.target.value,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Max restore bytes field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeAzure.maxRestoreBytesTitle"
defaultMessage="Max restore bytes per second"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeAzure.maxRestoreBytesDescription"
defaultMessage="The snapshot restore rate for each node."
/>
}
idAria="azureRepositoryMaxRestoreBytesDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeAzure.maxRestoreBytesLabel"
defaultMessage="Max restore bytes per second"
/>
}
fullWidth
describedByIds={['azureRepositoryMaxRestoreBytesDescription']}
isInvalid={Boolean(hasErrors && settingErrors.maxRestoreBytesPerSec)}
error={settingErrors.maxRestoreBytesPerSec}
helpText={textService.getSizeNotationHelpText()}
>
<EuiFieldText
defaultValue={maxRestoreBytesPerSec || ''}
fullWidth
onChange={e => {
updateRepositorySettings({
maxRestoreBytesPerSec: e.target.value,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Location mode field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeAzure.locationModeTitle"
defaultMessage="Location mode"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeAzure.locationModeDescription"
defaultMessage="The primary or secondary location. If secondary, read-only is true."
/>
}
idAria="azureRepositoryLocationModeDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeAzure.locationModeLabel"
defaultMessage="Location mode"
/>
}
fullWidth
describedByIds={['azureRepositoryLocationModeDescription']}
isInvalid={Boolean(hasErrors && settingErrors.locationMode)}
error={settingErrors.locationMode}
>
<EuiSelect
options={locationModeOptions}
value={locationMode || locationModeOptions[0].value}
onChange={e => {
updateRepositorySettings({
locationMode: e.target.value,
readonly: e.target.value === locationModeOptions[1].value ? true : readonly,
});
}}
fullWidth
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Readonly field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeAzure.readonlyTitle"
defaultMessage="Read-only"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeAzure.readonlyDescription"
defaultMessage="Only one cluster should have write access to this repository. All other clusters should be read-only."
/>
}
idAria="azureRepositoryReadonlyDescription"
fullWidth
>
<EuiFormRow
hasEmptyLabelSpace={true}
fullWidth
describedByIds={['azureRepositoryReadonlyDescription']}
isInvalid={Boolean(hasErrors && settingErrors.readonly)}
error={settingErrors.readonly}
>
<EuiSwitch
disabled={locationMode === locationModeOptions[1].value}
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeAzure.readonlyLabel"
defaultMessage="Read-only repository"
/>
}
checked={!!readonly}
onChange={e => {
updateRepositorySettings({
readonly: locationMode === locationModeOptions[1].value ? true : e.target.checked,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
</Fragment>
);
};

View file

@ -0,0 +1,332 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* 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, { Fragment } from 'react';
import {
EuiCode,
EuiDescribedFormGroup,
EuiFieldText,
EuiFormRow,
EuiSwitch,
EuiTitle,
} from '@elastic/eui';
import { FSRepository, Repository } from '../../../../../common/types';
import { useAppDependencies } from '../../../index';
import { RepositorySettingsValidation } from '../../../services/validation';
import { textService } from '../../../services/text';
interface Props {
repository: FSRepository;
updateRepositorySettings: (
updatedSettings: Partial<Repository['settings']>,
replaceSettings?: boolean
) => void;
settingErrors: RepositorySettingsValidation;
}
export const FSSettings: React.FunctionComponent<Props> = ({
repository,
updateRepositorySettings,
settingErrors,
}) => {
const {
core: { i18n },
} = useAppDependencies();
const { FormattedMessage } = i18n;
const {
settings: {
location,
compress,
chunkSize,
maxRestoreBytesPerSec,
maxSnapshotBytesPerSec,
readonly,
},
} = repository;
const hasErrors: boolean = Boolean(Object.keys(settingErrors).length);
return (
<Fragment>
{/* Location field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeFS.locationTitle"
defaultMessage="File system location"
/>
</h3>
</EuiTitle>
}
description={
<Fragment>
<FormattedMessage
id="xpack.snapshotRestore.repositoryFor.typeFS.locationDescription"
defaultMessage="The location must be registered in the {settingKey} setting on all master and data nodes."
values={{
settingKey: <EuiCode>path.repo</EuiCode>,
}}
/>
</Fragment>
}
idAria="fsRepositoryLocationDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeFS.locationLabel"
defaultMessage="Location (required)"
/>
}
fullWidth
describedByIds={['fsRepositoryLocationDescription']}
isInvalid={Boolean(hasErrors && settingErrors.location)}
error={settingErrors.location}
>
<EuiFieldText
defaultValue={location || ''}
fullWidth
onChange={e => {
updateRepositorySettings({
location: e.target.value,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Compress field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeFS.compressTitle"
defaultMessage="Snapshot compression"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeFS.compressDescription"
defaultMessage="Compresses the index mapping and setting files for snapshots. Data files are not compressed."
/>
}
idAria="fsRepositoryCompressDescription"
fullWidth
>
<EuiFormRow
hasEmptyLabelSpace={true}
fullWidth
describedByIds={['fsRepositoryCompressDescription']}
isInvalid={Boolean(hasErrors && settingErrors.compress)}
error={settingErrors.compress}
>
<EuiSwitch
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeFS.compressLabel"
defaultMessage="Compress snapshots"
/>
}
checked={!!compress}
onChange={e => {
updateRepositorySettings({
compress: e.target.checked,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Chunk size field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeFS.chunkSizeTitle"
defaultMessage="Chunk size"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeFS.chunkSizeDescription"
defaultMessage="Breaks files into smaller units when taking snapshots."
/>
}
idAria="fsRepositoryChunkSizeDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeFS.chunkSizeLabel"
defaultMessage="Chunk size"
/>
}
fullWidth
describedByIds={['fsRepositoryChunkSizeDescription']}
isInvalid={Boolean(hasErrors && settingErrors.chunkSize)}
error={settingErrors.chunkSize}
helpText={textService.getSizeNotationHelpText()}
>
<EuiFieldText
defaultValue={chunkSize || ''}
fullWidth
onChange={e => {
updateRepositorySettings({
chunkSize: e.target.value,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Max snapshot bytes field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeFS.maxSnapshotBytesTitle"
defaultMessage="Max snapshot bytes per second"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeFS.maxSnapshotBytesDescription"
defaultMessage="The rate for creating snapshots for each node."
/>
}
idAria="fsRepositoryMaxSnapshotBytesDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeFS.maxSnapshotBytesLabel"
defaultMessage="Max snapshot bytes per second"
/>
}
fullWidth
describedByIds={['fsRepositoryMaxSnapshotBytesDescription']}
isInvalid={Boolean(hasErrors && settingErrors.maxSnapshotBytesPerSec)}
error={settingErrors.maxSnapshotBytesPerSec}
helpText={textService.getSizeNotationHelpText()}
>
<EuiFieldText
defaultValue={maxSnapshotBytesPerSec || ''}
fullWidth
onChange={e => {
updateRepositorySettings({
maxSnapshotBytesPerSec: e.target.value,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Max restore bytes field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeFS.maxRestoreBytesTitle"
defaultMessage="Max restore bytes per second"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeFS.maxRestoreBytesDescription"
defaultMessage="The snapshot restore rate for each node."
/>
}
idAria="fsRepositoryMaxRestoreBytesDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeFS.maxRestoreBytesLabel"
defaultMessage="Max restore bytes per second"
/>
}
fullWidth
describedByIds={['fsRepositoryMaxRestoreBytesDescription']}
isInvalid={Boolean(hasErrors && settingErrors.maxRestoreBytesPerSec)}
error={settingErrors.maxRestoreBytesPerSec}
helpText={textService.getSizeNotationHelpText()}
>
<EuiFieldText
defaultValue={maxRestoreBytesPerSec || ''}
fullWidth
onChange={e => {
updateRepositorySettings({
maxRestoreBytesPerSec: e.target.value,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Readonly field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeFS.readonlyTitle"
defaultMessage="Read-only"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeFS.readonlyDescription"
defaultMessage="Only one cluster should have write access to this repository. All other clusters should be read-only."
/>
}
idAria="fsRepositoryReadonlyDescription"
fullWidth
>
<EuiFormRow
hasEmptyLabelSpace={true}
fullWidth
describedByIds={['fsRepositoryReadonlyDescription']}
isInvalid={Boolean(hasErrors && settingErrors.readonly)}
error={settingErrors.readonly}
>
<EuiSwitch
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeFS.readonlyLabel"
defaultMessage="Read-only repository"
/>
}
checked={!!readonly}
onChange={e => {
updateRepositorySettings({
readonly: e.target.checked,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
</Fragment>
);
};

View file

@ -0,0 +1,413 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* 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, { Fragment } from 'react';
import { EuiDescribedFormGroup, EuiFieldText, EuiFormRow, EuiSwitch, EuiTitle } from '@elastic/eui';
import { GCSRepository, Repository } from '../../../../../common/types';
import { useAppDependencies } from '../../../index';
import { RepositorySettingsValidation } from '../../../services/validation';
import { textService } from '../../../services/text';
interface Props {
repository: GCSRepository;
updateRepositorySettings: (
updatedSettings: Partial<Repository['settings']>,
replaceSettings?: boolean
) => void;
settingErrors: RepositorySettingsValidation;
}
export const GCSSettings: React.FunctionComponent<Props> = ({
repository,
updateRepositorySettings,
settingErrors,
}) => {
const {
core: {
i18n: { FormattedMessage },
},
} = useAppDependencies();
const {
settings: {
bucket,
client,
basePath,
compress,
chunkSize,
maxRestoreBytesPerSec,
maxSnapshotBytesPerSec,
readonly,
},
} = repository;
const hasErrors: boolean = Boolean(Object.keys(settingErrors).length);
return (
<Fragment>
{/* Client field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeGCS.clientTitle"
defaultMessage="Client"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeGCS.clientDescription"
defaultMessage="The name of the Google Cloud Storage client."
/>
}
idAria="gcsRepositoryClientDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeGCS.clientLabel"
defaultMessage="Client"
/>
}
fullWidth
describedByIds={['gcsRepositoryClientDescription']}
isInvalid={Boolean(hasErrors && settingErrors.client)}
error={settingErrors.client}
>
<EuiFieldText
defaultValue={client || ''}
fullWidth
onChange={e => {
updateRepositorySettings({
client: e.target.value,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Bucket field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeGCS.bucketTitle"
defaultMessage="Bucket"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeGCS.bucketDescription"
defaultMessage="The name of the Google Cloud Storage bucket to use for snapshots."
/>
}
idAria="gcsRepositoryBucketDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeGCS.bucketLabel"
defaultMessage="Bucket (required)"
/>
}
fullWidth
describedByIds={['gcsRepositoryBucketDescription']}
isInvalid={Boolean(hasErrors && settingErrors.bucket)}
error={settingErrors.bucket}
>
<EuiFieldText
defaultValue={bucket || ''}
fullWidth
onChange={e => {
updateRepositorySettings({
bucket: e.target.value,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Base path field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeGCS.basePathTitle"
defaultMessage="Base path"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeGCS.basePathDescription"
defaultMessage="The bucket path to the repository data."
/>
}
idAria="gcsRepositoryBasePathDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeGCS.basePathLabel"
defaultMessage="Base path"
/>
}
fullWidth
describedByIds={['gcsRepositoryBasePathDescription']}
isInvalid={Boolean(hasErrors && settingErrors.basePath)}
error={settingErrors.basePath}
>
<EuiFieldText
defaultValue={basePath || ''}
fullWidth
onChange={e => {
updateRepositorySettings({
basePath: e.target.value,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Compress field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeGCS.compressTitle"
defaultMessage="Compress snapshots"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeGCS.compressDescription"
defaultMessage="Compresses the index mapping and setting files for snapshots. Data files are not compressed."
/>
}
idAria="gcsRepositoryCompressDescription"
fullWidth
>
<EuiFormRow
hasEmptyLabelSpace={true}
fullWidth
describedByIds={['gcsRepositoryCompressDescription']}
isInvalid={Boolean(hasErrors && settingErrors.compress)}
error={settingErrors.compress}
>
<EuiSwitch
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeGCS.compressLabel"
defaultMessage="Compress snapshots"
/>
}
checked={!(compress === false)}
onChange={e => {
updateRepositorySettings({
compress: e.target.checked,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Chunk size field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeGCS.chunkSizeTitle"
defaultMessage="Chunk size"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeGCS.chunkSizeDescription"
defaultMessage="Breaks files into smaller units when taking snapshots."
/>
}
idAria="gcsRepositoryChunkSizeDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeGCS.chunkSizeLabel"
defaultMessage="Chunk size"
/>
}
fullWidth
describedByIds={['gcsRepositoryChunkSizeDescription']}
isInvalid={Boolean(hasErrors && settingErrors.chunkSize)}
error={settingErrors.chunkSize}
helpText={textService.getSizeNotationHelpText()}
>
<EuiFieldText
defaultValue={chunkSize || ''}
fullWidth
onChange={e => {
updateRepositorySettings({
chunkSize: e.target.value,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Max snapshot bytes field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeGCS.maxSnapshotBytesTitle"
defaultMessage="Max snapshot bytes per second"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeGCS.maxSnapshotBytesDescription"
defaultMessage="The rate for creating snapshots for each node."
/>
}
idAria="gcsRepositoryMaxSnapshotBytesDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeGCS.maxSnapshotBytesLabel"
defaultMessage="Max snapshot bytes per second"
/>
}
fullWidth
describedByIds={['gcsRepositoryMaxSnapshotBytesDescription']}
isInvalid={Boolean(hasErrors && settingErrors.maxSnapshotBytesPerSec)}
error={settingErrors.maxSnapshotBytesPerSec}
helpText={textService.getSizeNotationHelpText()}
>
<EuiFieldText
defaultValue={maxSnapshotBytesPerSec || ''}
fullWidth
onChange={e => {
updateRepositorySettings({
maxSnapshotBytesPerSec: e.target.value,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Max restore bytes field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeGCS.maxRestoreBytesTitle"
defaultMessage="Max restore bytes per second"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeGCS.maxRestoreBytesDescription"
defaultMessage="The snapshot restore rate for each node."
/>
}
idAria="gcsRepositoryMaxRestoreBytesDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeGCS.maxRestoreBytesLabel"
defaultMessage="Max restore bytes per second"
/>
}
fullWidth
describedByIds={['gcsRepositoryMaxRestoreBytesDescription']}
isInvalid={Boolean(hasErrors && settingErrors.maxRestoreBytesPerSec)}
error={settingErrors.maxRestoreBytesPerSec}
helpText={textService.getSizeNotationHelpText()}
>
<EuiFieldText
defaultValue={maxRestoreBytesPerSec || ''}
fullWidth
onChange={e => {
updateRepositorySettings({
maxRestoreBytesPerSec: e.target.value,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Readonly field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeGCS.readonlyTitle"
defaultMessage="Read-only"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeGCS.readonlyDescription"
defaultMessage="Only one cluster should have write access to this repository. All other clusters should be read-only."
/>
}
idAria="gcsRepositoryReadonlyDescription"
fullWidth
>
<EuiFormRow
hasEmptyLabelSpace={true}
fullWidth
describedByIds={['gcsRepositoryReadonlyDescription']}
isInvalid={Boolean(hasErrors && settingErrors.readonly)}
error={settingErrors.readonly}
>
<EuiSwitch
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeGCS.readonlyLabel"
defaultMessage="Read-only repository"
/>
}
checked={!!readonly}
onChange={e => {
updateRepositorySettings({
readonly: e.target.checked,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
</Fragment>
);
};

View file

@ -0,0 +1,579 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* 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, { Fragment, useState } from 'react';
import {
EuiCode,
EuiCodeEditor,
EuiDescribedFormGroup,
EuiFieldText,
EuiFormRow,
EuiSwitch,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { HDFSRepository, Repository, SourceRepository } from '../../../../../common/types';
import { useAppDependencies } from '../../../index';
import { RepositorySettingsValidation } from '../../../services/validation';
import { textService } from '../../../services/text';
interface Props {
repository: HDFSRepository | SourceRepository<HDFSRepository>;
updateRepositorySettings: (
updatedSettings: Partial<Repository['settings']>,
replaceSettings?: boolean
) => void;
settingErrors: RepositorySettingsValidation;
}
export const HDFSSettings: React.FunctionComponent<Props> = ({
repository,
updateRepositorySettings,
settingErrors,
}) => {
const {
core: {
i18n: { FormattedMessage },
},
} = useAppDependencies();
const {
settings: {
delegateType,
uri,
path,
loadDefaults,
compress,
chunkSize,
maxRestoreBytesPerSec,
maxSnapshotBytesPerSec,
readonly,
'security.principal': securityPrincipal,
...rest // For conf.* settings
},
} = repository;
const hasErrors: boolean = Boolean(Object.keys(settingErrors).length);
const [additionalConf, setAdditionalConf] = useState<string>(JSON.stringify(rest, null, 2));
const [isConfInvalid, setIsConfInvalid] = useState<boolean>(false);
return (
<Fragment>
{/* URI field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeHDFS.uriTitle"
defaultMessage="URI"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeHDFS.uriDescription"
defaultMessage="The URI address for HDFS."
/>
}
idAria="hdfsRepositoryUriDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeHDFS.uriLabel"
defaultMessage="URI (required)"
/>
}
fullWidth
describedByIds={['hdfsRepositoryUriDescription']}
isInvalid={Boolean(hasErrors && settingErrors.uri)}
error={settingErrors.uri}
>
<EuiFieldText
prepend={
<EuiText size="s" id="hdfsRepositoryUriProtocolDescription">
{/* Wrap as string due to prettier not parsing `//` inside JSX correctly (prettier/prettier#2347) */}
{'hdfs://'}
</EuiText>
}
defaultValue={uri ? uri.split('hdfs://')[1] : ''}
fullWidth
onChange={e => {
updateRepositorySettings({
uri: e.target.value ? `hdfs://${e.target.value}` : '',
});
}}
aria-describedby="hdfsRepositoryUriDescription hdfsRepositoryUriProtocolDescription"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Path field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeHDFS.pathTitle"
defaultMessage="Path"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeHDFS.pathDescription"
defaultMessage="The file path where data is stored."
/>
}
idAria="hdfsRepositoryPathDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeHDFS.pathLabel"
defaultMessage="Path (required)"
/>
}
fullWidth
describedByIds={['hdfsRepositoryPathDescription']}
isInvalid={Boolean(hasErrors && settingErrors.path)}
error={settingErrors.path}
>
<EuiFieldText
defaultValue={path || ''}
fullWidth
onChange={e => {
updateRepositorySettings({
path: e.target.value,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Load defaults field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeHDFS.loadDefaultsTitle"
defaultMessage="Load defaults"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeHDFS.loadDefaultsDescription"
defaultMessage="Loads the default Hadoop configuration."
/>
}
idAria="hdfsRepositoryLoadDefaultsDescription"
fullWidth
>
<EuiFormRow
hasEmptyLabelSpace={true}
fullWidth
describedByIds={['hdfsRepositoryLoadDefaultsDescription']}
isInvalid={Boolean(hasErrors && settingErrors.loadDefaults)}
error={settingErrors.loadDefaults}
>
<EuiSwitch
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeHDFS.loadDefaultsLabel"
defaultMessage="Load defaults"
/>
}
checked={!(loadDefaults === false)}
onChange={e => {
updateRepositorySettings({
loadDefaults: e.target.checked,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Compress field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeHDFS.compressTitle"
defaultMessage="Snapshot compression"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeHDFS.compressDescription"
defaultMessage="Compresses the index mapping and setting files for snapshots. Data files are not compressed."
/>
}
idAria="hdfsRepositoryCompressDescription"
fullWidth
>
<EuiFormRow
hasEmptyLabelSpace={true}
fullWidth
describedByIds={['hdfsRepositoryCompressDescription']}
isInvalid={Boolean(hasErrors && settingErrors.compress)}
error={settingErrors.compress}
>
<EuiSwitch
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeHDFS.compressLabel"
defaultMessage="Compress snapshots"
/>
}
checked={!(compress === false)}
onChange={e => {
updateRepositorySettings({
compress: e.target.checked,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Chunk size field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeHDFS.chunkSizeTitle"
defaultMessage="Chunk size"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeHDFS.chunkSizeDescription"
defaultMessage="Breaks files into smaller units when taking snapshots."
/>
}
idAria="hdfsRepositoryChunkSizeDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeHDFS.chunkSizeLabel"
defaultMessage="Chunk size"
/>
}
fullWidth
describedByIds={['hdfsRepositoryChunkSizeDescription']}
isInvalid={Boolean(hasErrors && settingErrors.chunkSize)}
error={settingErrors.chunkSize}
helpText={textService.getSizeNotationHelpText()}
>
<EuiFieldText
defaultValue={chunkSize || ''}
fullWidth
onChange={e => {
updateRepositorySettings({
chunkSize: e.target.value,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Security principal field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeHDFS.securityPrincipalTitle"
defaultMessage="Security principal"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeHDFS.securityPrincipalDescription"
defaultMessage="The Kerberos principal to use when connecting to a secured HDFS cluster."
/>
}
idAria="hdfsRepositorySecurityPrincipalDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeHDFS.securityPrincipalLabel"
defaultMessage="Security principal"
/>
}
fullWidth
describedByIds={['hdfsRepositorySecurityPrincipalDescription']}
isInvalid={Boolean(hasErrors && settingErrors.securityPrincipal)}
error={settingErrors.securityPrincipal}
>
<EuiFieldText
defaultValue={securityPrincipal || ''}
fullWidth
onChange={e => {
updateRepositorySettings({
'security.principal': e.target.value,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Additional HDFS parameters field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeHDFS.configurationTitle"
defaultMessage="Configuration"
/>
</h3>
</EuiTitle>
}
description={
<Fragment>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeHDFS.configurationDescription"
defaultMessage="Additional JSON format configuration parameters to add to the Hadoop configuration. Only client-oriented properties from the Hadoop core and HDFS files are recognized."
/>
</Fragment>
}
idAria="hdfsRepositoryConfigurationDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeHDFS.configurationLabel"
defaultMessage="Configuration"
/>
}
fullWidth
describedByIds={['hdfsRepositoryConfigurationDescription']}
isInvalid={isConfInvalid}
error={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeHDFS.configurationFormatError"
defaultMessage="Invalid JSON format"
/>
}
helpText={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeHDFS.configurationKeyDescription"
defaultMessage="Keys should be in the format {confKeyFormat}."
values={{
confKeyFormat: <EuiCode>{'conf.<key>'}</EuiCode>,
}}
/>
}
>
<EuiCodeEditor
mode="json"
theme="textmate"
width="100%"
value={additionalConf}
setOptions={{
showLineNumbers: false,
tabSize: 2,
maxLines: Infinity,
}}
editorProps={{
$blockScrolling: Infinity,
}}
showGutter={false}
minLines={6}
aria-label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeHDFS.configurationAriaLabel"
defaultMessage="Additional configuration for HDFS repository '{name}'"
values={{
name,
}}
/>
}
onChange={(value: string) => {
setAdditionalConf(value);
try {
const parsedConf = JSON.parse(value);
setIsConfInvalid(false);
updateRepositorySettings(
{
delegateType,
uri,
path,
loadDefaults,
compress,
chunkSize,
'security.principal': securityPrincipal,
...parsedConf,
},
true
);
} catch (e) {
setIsConfInvalid(true);
}
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Max snapshot bytes field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeHDFS.maxSnapshotBytesTitle"
defaultMessage="Max snapshot bytes per second"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeHDFS.maxSnapshotBytesDescription"
defaultMessage="The rate for creating snapshots for each node."
/>
}
idAria="hdfsRepositoryMaxSnapshotBytesDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeHDFS.maxSnapshotBytesLabel"
defaultMessage="Max snapshot bytes per second"
/>
}
fullWidth
describedByIds={['hdfsRepositoryMaxSnapshotBytesDescription']}
isInvalid={Boolean(hasErrors && settingErrors.maxSnapshotBytesPerSec)}
error={settingErrors.maxSnapshotBytesPerSec}
helpText={textService.getSizeNotationHelpText()}
>
<EuiFieldText
defaultValue={maxSnapshotBytesPerSec || ''}
fullWidth
onChange={e => {
updateRepositorySettings({
maxSnapshotBytesPerSec: e.target.value,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Max restore bytes field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeHDFS.maxRestoreBytesTitle"
defaultMessage="Max restore bytes per second"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeHDFS.maxRestoreBytesDescription"
defaultMessage="The snapshot restore rate for each node."
/>
}
idAria="hdfsRepositoryMaxRestoreBytesDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeHDFS.maxRestoreBytesLabel"
defaultMessage="Max restore bytes per second"
/>
}
fullWidth
describedByIds={['hdfsRepositoryMaxRestoreBytesDescription']}
isInvalid={Boolean(hasErrors && settingErrors.maxRestoreBytesPerSec)}
error={settingErrors.maxRestoreBytesPerSec}
helpText={textService.getSizeNotationHelpText()}
>
<EuiFieldText
defaultValue={maxRestoreBytesPerSec || ''}
fullWidth
onChange={e => {
updateRepositorySettings({
maxRestoreBytesPerSec: e.target.value,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Readonly field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeHDFS.readonlyTitle"
defaultMessage="Read-only"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeHDFS.readonlyDescription"
defaultMessage="Only one cluster should have write access to this repository. All other clusters should be read-only."
/>
}
idAria="hdfsRepositoryReadonlyDescription"
fullWidth
>
<EuiFormRow
hasEmptyLabelSpace={true}
fullWidth
describedByIds={['hdfsRepositoryReadonlyDescription']}
isInvalid={Boolean(hasErrors && settingErrors.readonly)}
error={settingErrors.readonly}
>
<EuiSwitch
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeHDFS.readonlyLabel"
defaultMessage="Read-only repository"
/>
}
checked={!!readonly}
onChange={e => {
updateRepositorySettings({
readonly: e.target.checked,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
</Fragment>
);
};

View file

@ -0,0 +1,107 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* 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 from 'react';
import { REPOSITORY_TYPES } from '../../../../../common/constants';
import { Repository, RepositoryType, EmptyRepository } from '../../../../../common/types';
import { useAppDependencies } from '../../../index';
import { RepositorySettingsValidation } from '../../../services/validation';
import { SectionError } from '../../index';
import { AzureSettings } from './azure_settings';
import { FSSettings } from './fs_settings';
import { GCSSettings } from './gcs_settings';
import { HDFSSettings } from './hdfs_settings';
import { ReadonlySettings } from './readonly_settings';
import { S3Settings } from './s3_settings';
interface Props {
repository: Repository | EmptyRepository;
updateRepository: (updatedFields: Partial<Repository>) => void;
settingErrors: RepositorySettingsValidation;
}
export const TypeSettings: React.FunctionComponent<Props> = ({
repository,
updateRepository,
settingErrors,
}) => {
const {
core: { i18n },
} = useAppDependencies();
const { FormattedMessage } = i18n;
const { type, settings } = repository;
const updateRepositorySettings = (
updatedSettings: Partial<Repository['settings']>,
replaceSettings?: boolean
): void => {
if (replaceSettings) {
updateRepository({
settings: updatedSettings,
});
} else {
updateRepository({
settings: {
...settings,
...updatedSettings,
},
});
}
};
const typeSettingsMap: { [key: string]: any } = {
[REPOSITORY_TYPES.fs]: FSSettings,
[REPOSITORY_TYPES.url]: ReadonlySettings,
[REPOSITORY_TYPES.azure]: AzureSettings,
[REPOSITORY_TYPES.gcs]: GCSSettings,
[REPOSITORY_TYPES.hdfs]: HDFSSettings,
[REPOSITORY_TYPES.s3]: S3Settings,
};
const renderTypeSettings = (repositoryType: RepositoryType | null) => {
if (!repositoryType) {
return null;
}
const RepositorySettings = typeSettingsMap[repositoryType];
if (RepositorySettings) {
return (
<RepositorySettings
repository={repository}
updateRepositorySettings={updateRepositorySettings}
settingErrors={settingErrors}
/>
);
}
return (
<SectionError
title={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.errorUnknownRepositoryTypesTitle"
defaultMessage="Unknown repository type"
/>
}
error={{
data: {
error: i18n.translate(
'xpack.snapshotRestore.repositoryForm.errorUnknownRepositoryTypesMessage',
{
defaultMessage: `The repository type '{type}' is not supported.`,
values: {
type: repositoryType,
},
}
),
},
}}
/>
);
};
return type === REPOSITORY_TYPES.source
? renderTypeSettings(settings.delegateType)
: renderTypeSettings(type);
};

View file

@ -0,0 +1,177 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* 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, { Fragment, useState } from 'react';
import {
EuiCode,
EuiDescribedFormGroup,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiFormHelpText,
EuiFormRow,
EuiSelect,
EuiTitle,
} from '@elastic/eui';
import { ReadonlyRepository, Repository } from '../../../../../common/types';
import { useAppDependencies } from '../../../index';
import { RepositorySettingsValidation } from '../../../services/validation';
interface Props {
repository: ReadonlyRepository;
updateRepositorySettings: (
updatedSettings: Partial<Repository['settings']>,
replaceSettings?: boolean
) => void;
settingErrors: RepositorySettingsValidation;
}
export const ReadonlySettings: React.FunctionComponent<Props> = ({
repository,
updateRepositorySettings,
settingErrors,
}) => {
const {
core: {
i18n: { FormattedMessage },
},
} = useAppDependencies();
const {
settings: { url },
} = repository;
const hasErrors: boolean = Boolean(Object.keys(settingErrors).length);
function getSchemeHelpText(scheme: string): React.ReactNode {
switch (scheme) {
case 'http':
case 'https':
case 'ftp':
return (
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeReadonly.urlWhitelistDescription"
defaultMessage="This URL must be registered in the {settingKey} setting."
values={{
settingKey: <EuiCode>repositories.url.allowed_urls</EuiCode>,
}}
/>
);
case 'file':
return (
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeReadonly.urlFilePathDescription"
defaultMessage="This file location must be registered in the {settingKey} setting."
values={{
settingKey: <EuiCode>path.repo</EuiCode>,
}}
/>
);
default:
return null;
}
}
const schemeOptions = [
{
value: 'http',
text: 'http://',
},
{
value: 'https',
text: 'https://',
},
{
value: 'ftp',
text: 'ftp://',
},
{
value: 'file',
text: 'file://',
},
];
const [selectedScheme, selectScheme] = useState(url ? url.split('://')[0] : 'http');
return (
<Fragment>
{/* URL field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeReadonly.urlTitle"
defaultMessage="URL"
/>
</h3>
</EuiTitle>
}
description={
<Fragment>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeReadonly.urlDescription"
defaultMessage="The location of the snapshots."
/>
</Fragment>
}
idAria="readonlyRepositoryUrlDescription"
fullWidth
>
<div>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeReadonly.urlSchemeLabel"
defaultMessage="Scheme"
/>
}
fullWidth
describedByIds={['readonlyRepositoryUrlDescription']}
>
<EuiSelect
options={schemeOptions}
value={selectedScheme}
onChange={e => selectScheme(e.target.value)}
aria-controls="readonlyRepositoryUrlHelp"
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeReadonly.urlLabel"
defaultMessage="Path (required)"
/>
}
fullWidth
describedByIds={['readonlyRepositoryUrlDescription readonlyRepositoryUrlHelp']}
isInvalid={Boolean(hasErrors && settingErrors.url)}
error={settingErrors.url}
>
<EuiFieldText
defaultValue={url ? url.split('://')[1] : ''}
fullWidth
onChange={e => {
updateRepositorySettings({
url: `${selectedScheme}://${e.target.value}`,
});
}}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFormHelpText id="readonlyRepositoryUrlHelp" aria-live="polite">
{getSchemeHelpText(selectedScheme)}
</EuiFormHelpText>
</div>
</EuiDescribedFormGroup>
</Fragment>
);
};

View file

@ -0,0 +1,626 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* 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, { Fragment } from 'react';
import {
EuiDescribedFormGroup,
EuiFieldText,
EuiFormRow,
EuiSelect,
EuiSwitch,
EuiTitle,
} from '@elastic/eui';
import { Repository, S3Repository } from '../../../../../common/types';
import { useAppDependencies } from '../../../index';
import { RepositorySettingsValidation } from '../../../services/validation';
import { textService } from '../../../services/text';
interface Props {
repository: S3Repository;
updateRepositorySettings: (
updatedSettings: Partial<Repository['settings']>,
replaceSettings?: boolean
) => void;
settingErrors: RepositorySettingsValidation;
}
export const S3Settings: React.FunctionComponent<Props> = ({
repository,
updateRepositorySettings,
settingErrors,
}) => {
const {
core: {
i18n: { FormattedMessage },
},
} = useAppDependencies();
const {
settings: {
bucket,
client,
basePath,
compress,
chunkSize,
serverSideEncryption,
bufferSize,
cannedAcl,
storageClass,
maxRestoreBytesPerSec,
maxSnapshotBytesPerSec,
readonly,
},
} = repository;
const cannedAclOptions = [
'private',
'public-read',
'public-read-write',
'authenticated-read',
'log-delivery-write',
'bucket-owner-read',
'bucket-owner-full-control',
].map(option => ({
value: option,
text: option,
}));
const hasErrors: boolean = Boolean(Object.keys(settingErrors).length);
const storageClassOptions = ['standard', 'reduced_redundancy', 'standard_ia'].map(option => ({
value: option,
text: option,
}));
return (
<Fragment>
{/* Client field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeS3.clientTitle"
defaultMessage="Client"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeS3.clientDescription"
defaultMessage="The name of the AWS S3 client."
/>
}
idAria="s3RepositoryClientDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeS3.clientLabel"
defaultMessage="Client"
/>
}
fullWidth
describedByIds={['s3RepositoryClientDescription']}
isInvalid={Boolean(hasErrors && settingErrors.client)}
error={settingErrors.client}
>
<EuiFieldText
defaultValue={client || ''}
fullWidth
onChange={e => {
updateRepositorySettings({
client: e.target.value,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Bucket field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeS3.bucketTitle"
defaultMessage="Bucket"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeS3.bucketDescription"
defaultMessage="The name of the AWS S3 bucket to use for snapshots."
/>
}
idAria="s3RepositoryBucketDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeS3.bucketLabel"
defaultMessage="Bucket (required)"
/>
}
fullWidth
describedByIds={['s3RepositoryBucketDescription']}
isInvalid={Boolean(hasErrors && settingErrors.bucket)}
error={settingErrors.bucket}
>
<EuiFieldText
defaultValue={bucket || ''}
fullWidth
onChange={e => {
updateRepositorySettings({
bucket: e.target.value,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Base path field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeS3.basePathTitle"
defaultMessage="Base path"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeS3.basePathDescription"
defaultMessage="The bucket path to the repository data."
/>
}
idAria="s3RepositoryBasePathDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeS3.basePathLabel"
defaultMessage="Base path"
/>
}
fullWidth
describedByIds={['s3RepositoryBasePathDescription']}
isInvalid={Boolean(hasErrors && settingErrors.basePath)}
error={settingErrors.basePath}
>
<EuiFieldText
defaultValue={basePath || ''}
fullWidth
onChange={e => {
updateRepositorySettings({
basePath: e.target.value,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Compress field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeS3.compressTitle"
defaultMessage="Snapshot compression"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeS3.compressDescription"
defaultMessage="Compresses the index mapping and setting files for snapshots. Data files are not compressed."
/>
}
idAria="s3RepositoryCompressDescription"
fullWidth
>
<EuiFormRow
hasEmptyLabelSpace={true}
fullWidth
describedByIds={['s3RepositoryCompressDescription']}
isInvalid={Boolean(hasErrors && settingErrors.compress)}
error={settingErrors.compress}
>
<EuiSwitch
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeS3.compressLabel"
defaultMessage="Compress snapshots"
/>
}
checked={!(compress === false)}
onChange={e => {
updateRepositorySettings({
compress: e.target.checked,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Chunk size field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeS3.chunkSizeTitle"
defaultMessage="Chunk size"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeS3.chunkSizeDescription"
defaultMessage="Breaks files into smaller units when taking snapshots."
/>
}
idAria="s3RepositoryChunkSizeDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeS3.chunkSizeLabel"
defaultMessage="Chunk size"
/>
}
fullWidth
describedByIds={['s3RepositoryChunkSizeDescription']}
isInvalid={Boolean(hasErrors && settingErrors.chunkSize)}
error={settingErrors.chunkSize}
helpText={textService.getSizeNotationHelpText()}
>
<EuiFieldText
defaultValue={chunkSize || ''}
fullWidth
onChange={e => {
updateRepositorySettings({
chunkSize: e.target.value,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Server side encryption field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeS3.serverSideEncryptionTitle"
defaultMessage="Server-side encryption"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeS3.serverSideEncryptionDescription"
defaultMessage="Encrypts files on the server using AES256 algorithm."
/>
}
idAria="s3RepositoryServerSideEncryptionDescription"
fullWidth
>
<EuiFormRow
hasEmptyLabelSpace={true}
fullWidth
describedByIds={['s3RepositoryServerSideEncryptionDescription']}
isInvalid={Boolean(hasErrors && settingErrors.serverSideEncryption)}
error={settingErrors.serverSideEncryption}
>
<EuiSwitch
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeS3.serverSideEncryptionLabel"
defaultMessage="Server-side encryption"
/>
}
checked={!!serverSideEncryption}
onChange={e => {
updateRepositorySettings({
serverSideEncryption: e.target.checked,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Buffer size field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeS3.bufferSizeTitle"
defaultMessage="Buffer size"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeS3.bufferSizeDescription"
defaultMessage="Beyond this minimum threshold, the S3 repository will use the AWS Multipart Upload API
to split the chunk into several parts and upload each in its own request."
/>
}
idAria="s3RepositoryBufferSizeDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeS3.bufferSizeLabel"
defaultMessage="Buffer size"
/>
}
fullWidth
describedByIds={['s3RepositoryBufferSizeDescription']}
isInvalid={Boolean(hasErrors && settingErrors.bufferSize)}
error={settingErrors.bufferSize}
helpText={textService.getSizeNotationHelpText()}
>
<EuiFieldText
defaultValue={bufferSize || ''}
fullWidth
onChange={e => {
updateRepositorySettings({
bufferSize: e.target.value,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Canned ACL field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeS3.cannedAclTitle"
defaultMessage="Canned ACL"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeS3.cannedAclDescription"
defaultMessage="The canned ACL to add to new S3 buckets and objects."
/>
}
idAria="s3RepositoryCannedAclDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeS3.cannedAclLabel"
defaultMessage="Canned ACL"
/>
}
fullWidth
describedByIds={['s3RepositoryCannedAclDescription']}
isInvalid={Boolean(hasErrors && settingErrors.cannedAcl)}
error={settingErrors.cannedAcl}
>
<EuiSelect
options={cannedAclOptions}
value={cannedAcl || cannedAclOptions[0].value}
onChange={e => {
updateRepositorySettings({
cannedAcl: e.target.value,
});
}}
fullWidth
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Storage class field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeS3.storageClassTitle"
defaultMessage="Storage class"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeS3.storageClassDescription"
defaultMessage="The storage class for new objects in the S3 repository."
/>
}
idAria="s3RepositoryStorageClassDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeS3.storageClassLabel"
defaultMessage="Storage class"
/>
}
fullWidth
describedByIds={['s3RepositoryStorageClassDescription']}
isInvalid={Boolean(hasErrors && settingErrors.storageClass)}
error={settingErrors.storageClass}
>
<EuiSelect
options={storageClassOptions}
value={storageClass || storageClassOptions[0].value}
onChange={e => {
updateRepositorySettings({
storageClass: e.target.value,
});
}}
fullWidth
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Max snapshot bytes field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeS3.maxSnapshotBytesTitle"
defaultMessage="Max snapshot bytes per second"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeS3.maxSnapshotBytesDescription"
defaultMessage="The rate for creating snapshots for each node."
/>
}
idAria="s3RepositoryMaxSnapshotBytesDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeS3.maxSnapshotBytesLabel"
defaultMessage="Max snapshot bytes per second"
/>
}
fullWidth
describedByIds={['s3RepositoryMaxSnapshotBytesDescription']}
isInvalid={Boolean(hasErrors && settingErrors.maxSnapshotBytesPerSec)}
error={settingErrors.maxSnapshotBytesPerSec}
helpText={textService.getSizeNotationHelpText()}
>
<EuiFieldText
defaultValue={maxSnapshotBytesPerSec || ''}
fullWidth
onChange={e => {
updateRepositorySettings({
maxSnapshotBytesPerSec: e.target.value,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Max restore bytes field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeS3.maxRestoreBytesTitle"
defaultMessage="Max restore bytes per second"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeS3.maxRestoreBytesDescription"
defaultMessage="The snapshot restore rate for each node."
/>
}
idAria="s3RepositoryMaxRestoreBytesDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeS3.maxRestoreBytesLabel"
defaultMessage="Max restore bytes per second"
/>
}
fullWidth
describedByIds={['s3RepositoryMaxRestoreBytesDescription']}
isInvalid={Boolean(hasErrors && settingErrors.maxRestoreBytesPerSec)}
error={settingErrors.maxRestoreBytesPerSec}
helpText={textService.getSizeNotationHelpText()}
>
<EuiFieldText
defaultValue={maxRestoreBytesPerSec || ''}
fullWidth
onChange={e => {
updateRepositorySettings({
maxRestoreBytesPerSec: e.target.value,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Readonly field */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeS3.readonlyTitle"
defaultMessage="Read-only"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeS3.readonlyDescription"
defaultMessage="Only one cluster should have write access to this repository. All other clusters should be read-only."
/>
}
idAria="s3RepositoryReadonlyDescription"
fullWidth
>
<EuiFormRow
hasEmptyLabelSpace={true}
fullWidth
describedByIds={['s3RepositoryReadonlyDescription']}
isInvalid={Boolean(hasErrors && settingErrors.readonly)}
error={settingErrors.readonly}
>
<EuiSwitch
label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.typeS3.readonlyLabel"
defaultMessage="Read-only repository"
/>
}
checked={!!readonly}
onChange={e => {
updateRepositorySettings({
readonly: e.target.checked,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
</Fragment>
);
};

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* 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 from 'react';
import { EuiIcon } from '@elastic/eui';
import { REPOSITORY_TYPES } from '../../../common/constants';
import { RepositoryType } from '../../../common/types';
interface Props {
type: RepositoryType;
[key: string]: any;
}
export const RepositoryTypeLogo: React.SFC<Props> = ({ type, ...rest }) => {
const typeLogoMap: { [key: string]: any } = {
[REPOSITORY_TYPES.fs]: 'storage',
[REPOSITORY_TYPES.url]: 'eye',
[REPOSITORY_TYPES.azure]: 'logoAzure',
[REPOSITORY_TYPES.gcs]: 'logoGCP',
[REPOSITORY_TYPES.hdfs]: 'logoApache',
[REPOSITORY_TYPES.s3]: 'logoAWS',
};
return <EuiIcon type={typeLogoMap[type] || 'folderOpen'} {...rest} />;
};

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* 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 from 'react';
import { EuiHealth } from '@elastic/eui';
import { RepositoryVerification } from '../../../common/types';
import { useAppDependencies } from '../index';
interface Props {
verificationResults: RepositoryVerification | null;
}
export const RepositoryVerificationBadge: React.FunctionComponent<Props> = ({
verificationResults,
}) => {
const {
core: {
i18n: { FormattedMessage },
},
} = useAppDependencies();
if (!verificationResults) {
return (
<EuiHealth color="subdued">
<FormattedMessage
id="xpack.snapshotRestore.repositoryVerification.verificationUnknownValue"
defaultMessage="Unknown"
/>
</EuiHealth>
);
}
if (verificationResults.valid) {
return (
<EuiHealth color="success">
<FormattedMessage
id="xpack.snapshotRestore.repositoryVerification.verificationSuccessfulValue"
defaultMessage="Connected"
/>
</EuiHealth>
);
}
return (
<EuiHealth color="warning">
<FormattedMessage
id="xpack.snapshotRestore.repositoryVerification.verificationErrorValue"
defaultMessage="Not connected"
/>
</EuiHealth>
);
};

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiCallOut, EuiSpacer } from '@elastic/eui';
import React, { Fragment } from 'react';
interface Props {
title: React.ReactNode;
error: {
data: {
error: string;
cause?: string[];
message?: string;
};
};
}
export const SectionError: React.FunctionComponent<Props> = ({ title, error }) => {
const {
error: errorString,
cause, // wrapEsError() on the server adds a "cause" array
message,
} = error.data;
return (
<EuiCallOut title={title} color="danger" iconType="alert">
<div>{message || errorString}</div>
{cause && (
<Fragment>
<EuiSpacer size="m" />
<ul>
{cause.map((causeMsg, i) => (
<li key={i}>{causeMsg}</li>
))}
</ul>
</Fragment>
)}
</EuiCallOut>
);
};

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* 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 from 'react';
import { EuiEmptyPrompt, EuiLoadingSpinner, EuiText } from '@elastic/eui';
interface Props {
children: React.ReactNode;
}
export const SectionLoading: React.FunctionComponent<Props> = ({ children }) => {
return (
<EuiEmptyPrompt
title={<EuiLoadingSpinner size="xl" />}
body={<EuiText color="subdued">{children}</EuiText>}
/>
);
};

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export const BASE_PATH = '/management/elasticsearch/snapshot_restore';
export const DEFAULT_SECTION: Section = 'repositories';
export type Section = 'repositories' | 'snapshots';
// Set a minimum request duration to avoid strange UI flickers
export const MINIMUM_TIMEOUT_MS = 300;
export enum REPOSITORY_DOC_PATHS {
default = 'modules-snapshots.html',
fs = 'modules-snapshots.html#_shared_file_system_repository',
url = 'modules-snapshots.html#_read_only_url_repository',
source = 'modules-snapshots.html#_source_only_repository',
s3 = 'repository-s3.html',
hdfs = 'repository-hdfs.html',
azure = 'repository-azure.html',
gcs = 'repository-gcs.html',
plugins = 'repository.html',
}
export enum SNAPSHOT_STATE {
IN_PROGRESS = 'IN_PROGRESS',
SUCCESS = 'SUCCESS',
FAILED = 'FAILED',
PARTIAL = 'PARTIAL',
INCOMPATIBLE = 'INCOMPATIBLE',
}

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* 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, { createContext, useContext, useReducer } from 'react';
import { render } from 'react-dom';
import { HashRouter } from 'react-router-dom';
import { App } from './app';
import { AppStateProvider, initialState, reducer } from './services/state';
import { AppCore, AppDependencies, AppPlugins } from './types';
export { BASE_PATH as CLIENT_BASE_PATH } from './constants';
/**
* App dependencies
*/
let DependenciesContext: React.Context<AppDependencies>;
export const useAppDependencies = () => useContext<AppDependencies>(DependenciesContext);
const ReactApp: React.FunctionComponent<AppDependencies> = ({ core, plugins }) => {
const {
i18n: { Context: I18nContext },
} = core;
const appDependencies: AppDependencies = {
core,
plugins,
};
DependenciesContext = createContext<AppDependencies>(appDependencies);
return (
<I18nContext>
<HashRouter>
<DependenciesContext.Provider value={appDependencies}>
<AppStateProvider value={useReducer(reducer, initialState)}>
<App />
</AppStateProvider>
</DependenciesContext.Provider>
</HashRouter>
</I18nContext>
);
};
export const renderReact = async (
elem: Element,
core: AppCore,
plugins: AppPlugins
): Promise<void> => {
render(<ReactApp core={core} plugins={plugins} />, elem);
};

View file

@ -0,0 +1,152 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* 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 } from 'react';
import { Route, RouteComponentProps, Switch } from 'react-router-dom';
import {
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiPageBody,
EuiPageContent,
EuiSpacer,
EuiTab,
EuiTabs,
EuiTitle,
EuiText,
} from '@elastic/eui';
import { BASE_PATH, Section } from '../../constants';
import { useAppDependencies } from '../../index';
import { breadcrumbService } from '../../services/navigation';
import { RepositoryList } from './repository_list';
import { SnapshotList } from './snapshot_list';
import { documentationLinksService } from '../../services/documentation';
interface MatchParams {
section: Section;
}
export const SnapshotRestoreHome: React.FunctionComponent<RouteComponentProps<MatchParams>> = ({
match: {
params: { section },
},
history,
}) => {
const {
core: {
i18n: { FormattedMessage },
},
} = useAppDependencies();
const tabs = [
{
id: 'snapshots' as Section,
name: (
<FormattedMessage
id="xpack.snapshotRestore.home.snapshotsTabTitle"
defaultMessage="Snapshots"
/>
),
testSubj: 'srSnapshotsTab',
},
{
id: 'repositories' as Section,
name: (
<FormattedMessage
id="xpack.snapshotRestore.home.repositoriesTabTitle"
defaultMessage="Repositories"
/>
),
testSubj: 'srRepositoriesTab',
},
];
const onSectionChange = (newSection: Section) => {
history.push(`${BASE_PATH}/${newSection}`);
};
// Set breadcrumb
useEffect(() => {
breadcrumbService.setBreadcrumbs('home');
}, []);
return (
<EuiPageBody>
<EuiPageContent>
<EuiTitle size="l">
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={true}>
<h1>
<FormattedMessage
id="xpack.snapshotRestore.home.snapshotRestoreTitle"
defaultMessage="Snapshot Repositories"
/>
</h1>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
href={documentationLinksService.getRepositoryTypeDocUrl()}
target="_blank"
iconType="help"
>
<FormattedMessage
id="xpack.snapshotRestore.home.snapshotRestoreDocsLinkText"
defaultMessage="Snapshot docs"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiTitle>
<EuiSpacer size="s" />
<EuiTitle size="s">
<EuiText color="subdued">
<FormattedMessage
id="xpack.snapshotRestore.home.snapshotRestoreDescription"
defaultMessage="Use repositories to store backups of your Elasticsearch indices and clusters."
/>
</EuiText>
</EuiTitle>
<EuiSpacer size="m" />
<EuiTabs>
{tabs.map(tab => (
<EuiTab
onClick={() => onSectionChange(tab.id)}
isSelected={tab.id === section}
key={tab.id}
data-test-subject={tab.testSubj}
>
{tab.name}
</EuiTab>
))}
</EuiTabs>
<EuiSpacer size="m" />
<Switch>
<Route
exact
path={`${BASE_PATH}/repositories/:repositoryName*`}
component={RepositoryList}
/>
{/* We have two separate SnapshotList routes because repository names could have slashes in
* them. This would break a route with a path like snapshots/:repositoryName?/:snapshotId*
*/}
<Route exact path={`${BASE_PATH}/snapshots`} component={SnapshotList} />
<Route
exact
path={`${BASE_PATH}/snapshots/:repositoryName*/:snapshotId`}
component={SnapshotList}
/>
</Switch>
</EuiPageContent>
</EuiPageBody>
);
};

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { SnapshotRestoreHome } from './home';

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { RepositoryList } from './repository_list';

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { RepositoryDetails } from './repository_details';

View file

@ -0,0 +1,387 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* 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, { Fragment, useState, useEffect } from 'react';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import {
EuiButton,
EuiButtonEmpty,
EuiCodeEditor,
EuiFlexGroup,
EuiFlexItem,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiHorizontalRule,
EuiLink,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import 'brace/theme/textmate';
import { useAppDependencies } from '../../../../index';
import { documentationLinksService } from '../../../../services/documentation';
import {
loadRepository,
verifyRepository as verifyRepositoryRequest,
} from '../../../../services/http';
import { textService } from '../../../../services/text';
import { linkToSnapshots } from '../../../../services/navigation';
import { REPOSITORY_TYPES } from '../../../../../../common/constants';
import { Repository, RepositoryVerification } from '../../../../../../common/types';
import {
RepositoryDeleteProvider,
SectionError,
SectionLoading,
RepositoryVerificationBadge,
} from '../../../../components';
import { BASE_PATH } from '../../../../constants';
import { TypeDetails } from './type_details';
interface Props extends RouteComponentProps {
repositoryName: Repository['name'];
onClose: () => void;
onRepositoryDeleted: (repositoriesDeleted: Array<Repository['name']>) => void;
}
const RepositoryDetailsUi: React.FunctionComponent<Props> = ({
repositoryName,
onClose,
onRepositoryDeleted,
history,
}) => {
const {
core: { i18n },
} = useAppDependencies();
const { FormattedMessage } = i18n;
const { error, data: repositoryDetails } = loadRepository(repositoryName);
const [verification, setVerification] = useState<RepositoryVerification | undefined>(undefined);
const [isLoadingVerification, setIsLoadingVerification] = useState<boolean>(false);
const verifyRepository = async () => {
setIsLoadingVerification(true);
const { data } = await verifyRepositoryRequest(repositoryName);
setVerification(data.verification);
setIsLoadingVerification(false);
};
// Reset verification state when repository name changes, either from adjust URL or clicking
// into a different repository in table list.
useEffect(
() => {
setVerification(undefined);
setIsLoadingVerification(false);
},
[repositoryName]
);
const renderBody = () => {
if (repositoryDetails) {
return renderRepository();
}
if (error) {
return renderError();
}
return renderLoading();
};
const renderLoading = () => {
return (
<SectionLoading>
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.loadingRepositoryDescription"
defaultMessage="Loading repository…"
/>
</SectionLoading>
);
};
const renderError = () => {
const notFound = error.status === 404;
const errorObject = notFound
? {
data: {
error: i18n.translate(
'xpack.snapshotRestore.repositoryDetails.repositoryNotFoundErrorMessage',
{
defaultMessage: `The repository '{name}' does not exist.`,
values: {
name: repositoryName,
},
}
),
},
}
: error;
return (
<SectionError
title={
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.loadingRepositoryErrorTitle"
defaultMessage="Error loading repository"
/>
}
error={errorObject}
/>
);
};
const renderSnapshotCount = () => {
const { snapshots } = repositoryDetails;
if (!Number.isInteger(snapshots.count)) {
return (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.noSnapshotInformationDescription"
defaultMessage="No snapshot information"
/>
);
}
if (snapshots.count === 0) {
return (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.zeroSnapshotsDescription"
defaultMessage="Repository has no snapshots"
/>
);
}
return (
<EuiLink href={linkToSnapshots(repositoryName)}>
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.snapshotsDescription"
defaultMessage="{count} {count, plural, one {snapshot} other {snapshots}} found"
values={{ count: snapshots.count }}
/>
</EuiLink>
);
};
const renderRepository = () => {
const { repository } = repositoryDetails;
if (!repository) {
return null;
}
const { type } = repository as Repository;
return (
<Fragment>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="flexStart">
<EuiFlexItem>
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeTitle"
defaultMessage="Repository type"
/>
</h3>
</EuiTitle>
<EuiSpacer size="s" />
{type === REPOSITORY_TYPES.source
? textService.getRepositoryTypeName(type, repository.settings.delegateType)
: textService.getRepositoryTypeName(type)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="s"
flush="right"
href={documentationLinksService.getRepositoryTypeDocUrl(type)}
target="_blank"
iconType="help"
>
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.repositoryTypeDocLink"
defaultMessage="Repository docs"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="l" />
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.snapshotsTitle"
defaultMessage="Snapshots"
/>
</h3>
</EuiTitle>
<EuiSpacer size="s" />
{renderSnapshotCount()}
<EuiSpacer size="l" />
<TypeDetails repository={repository} />
<EuiHorizontalRule />
{renderVerification()}
</Fragment>
);
};
const renderVerification = () => (
<Fragment>
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.verificationTitle"
defaultMessage="Verification status"
/>
</h3>
</EuiTitle>
{verification ? (
<Fragment>
<EuiSpacer size="s" />
<RepositoryVerificationBadge verificationResults={verification} />
<EuiSpacer size="s" />
<EuiTitle size="xs">
<h4>
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.verificationDetailsTitle"
defaultMessage="Details"
/>
</h4>
</EuiTitle>
<EuiSpacer size="s" />
{verification ? (
<EuiCodeEditor
mode="json"
theme="textmate"
width="100%"
isReadOnly
value={JSON.stringify(
verification.valid ? verification.response : verification.error,
null,
2
)}
setOptions={{
showLineNumbers: false,
tabSize: 2,
maxLines: Infinity,
}}
editorProps={{
$blockScrolling: Infinity,
}}
showGutter={false}
minLines={6}
aria-label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.verificationDetails"
defaultMessage="Verification details repository '{name}'"
values={{
name,
}}
/>
}
/>
) : null}
<EuiSpacer size="m" />
<EuiButton onClick={verifyRepository} color="primary" isLoading={isLoadingVerification}>
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.reverifyButtonLabel"
defaultMessage="Re-verify repository"
/>
</EuiButton>
</Fragment>
) : (
<Fragment>
<EuiSpacer size="m" />
<EuiButton onClick={verifyRepository} color="primary" isLoading={isLoadingVerification}>
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.verifyButtonLabel"
defaultMessage="Verify repository"
/>
</EuiButton>
</Fragment>
)}
</Fragment>
);
const renderFooter = () => {
return (
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="cross"
flush="left"
onClick={onClose}
data-test-subj="srRepositoryDetailsFlyoutCloseButton"
>
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.closeButtonLabel"
defaultMessage="Close"
/>
</EuiButtonEmpty>
</EuiFlexItem>
{repositoryDetails ? (
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<RepositoryDeleteProvider>
{deleteRepositoryPrompt => {
return (
<EuiButtonEmpty
color="danger"
data-test-subj="srRepositoryDetailsDeleteActionButton"
onClick={() =>
deleteRepositoryPrompt([repositoryName], onRepositoryDeleted)
}
>
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.removeButtonLabel"
defaultMessage="Remove"
/>
</EuiButtonEmpty>
);
}}
</RepositoryDeleteProvider>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
href={history.createHref({
pathname: `${BASE_PATH}/edit_repository/${repositoryName}`,
})}
fill
color="primary"
>
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.editButtonLabel"
defaultMessage="Edit"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
);
};
return (
<EuiFlyout
onClose={onClose}
data-test-subj="srRepositoryDetailsFlyout"
aria-labelledby="srRepositoryDetailsFlyoutTitle"
size="m"
maxWidth={400}
>
<EuiFlyoutHeader>
<EuiTitle size="m">
<h2 id="srRepositoryDetailsFlyoutTitle" data-test-subj="srRepositoryDetailsFlyoutTitle">
{repositoryName}
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody data-test-subj="srRepositoryDetailsContent">{renderBody()}</EuiFlyoutBody>
<EuiFlyoutFooter>{renderFooter()}</EuiFlyoutFooter>
</EuiFlyout>
);
};
export const RepositoryDetails = withRouter(RepositoryDetailsUi);

View file

@ -0,0 +1,168 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* 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, { Fragment } from 'react';
import { EuiDescriptionList, EuiSpacer, EuiTitle } from '@elastic/eui';
import { AzureRepository } from '../../../../../../../common/types';
import { useAppDependencies } from '../../../../../index';
interface Props {
repository: AzureRepository;
}
export const AzureDetails: React.FunctionComponent<Props> = ({ repository }) => {
const {
core: {
i18n: { FormattedMessage },
},
} = useAppDependencies();
const {
settings: {
client,
container,
basePath,
compress,
chunkSize,
readonly,
locationMode,
maxRestoreBytesPerSec,
maxSnapshotBytesPerSec,
},
} = repository;
const listItems = [];
if (client !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeAzure.clientLabel"
defaultMessage="Client"
/>
),
description: client,
});
}
if (container !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeAzure.containerLabel"
defaultMessage="Container"
/>
),
description: container,
});
}
if (basePath !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeAzure.basePathLabel"
defaultMessage="Base path"
/>
),
description: basePath,
});
}
if (compress !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeAzure.compressLabel"
defaultMessage="Snapshot compression"
/>
),
description: String(compress),
});
}
if (chunkSize !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeAzure.chunkSizeLabel"
defaultMessage="Chunk size"
/>
),
description: String(chunkSize),
});
}
if (maxSnapshotBytesPerSec !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeAzure.maxSnapshotBytesLabel"
defaultMessage="Max snapshot bytes per second"
/>
),
description: maxSnapshotBytesPerSec,
});
}
if (maxRestoreBytesPerSec !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeAzure.maxRestoreBytesLabel"
defaultMessage="Max restore bytes per second"
/>
),
description: maxRestoreBytesPerSec,
});
}
if (readonly !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeAzure.readonlyLabel"
defaultMessage="Read-only"
/>
),
description: String(readonly),
});
}
if (locationMode !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeAzure.locationModeLabel"
defaultMessage="Location mode"
/>
),
description: locationMode,
});
}
if (!listItems.length) {
return null;
}
return (
<Fragment>
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.settingsTitle"
defaultMessage="Settings"
/>
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiDescriptionList listItems={listItems} />
</Fragment>
);
};

View file

@ -0,0 +1,69 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* 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, { Fragment } from 'react';
import { EuiCodeEditor, EuiSpacer, EuiTitle } from '@elastic/eui';
import { Repository } from '../../../../../../../common/types';
import { useAppDependencies } from '../../../../../index';
import 'brace/theme/textmate';
interface Props {
repository: Repository;
}
export const DefaultDetails: React.FunctionComponent<Props> = ({
repository: { name, settings },
}) => {
const {
core: {
i18n: { FormattedMessage },
},
} = useAppDependencies();
return (
<Fragment>
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.settingsTitle"
defaultMessage="Settings"
/>
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiCodeEditor
mode="json"
theme="textmate"
width="100%"
isReadOnly
value={JSON.stringify(settings, null, 2)}
setOptions={{
showLineNumbers: false,
tabSize: 2,
maxLines: Infinity,
}}
editorProps={{
$blockScrolling: Infinity,
}}
showGutter={false}
minLines={6}
aria-label={
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.genericSettingsDescription"
defaultMessage="Readonly settings for repository '{name}'"
values={{
name,
}}
/>
}
/>
</Fragment>
);
};

View file

@ -0,0 +1,123 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* 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, { Fragment } from 'react';
import { EuiDescriptionList, EuiSpacer, EuiTitle } from '@elastic/eui';
import { FSRepository } from '../../../../../../../common/types';
import { useAppDependencies } from '../../../../../index';
interface Props {
repository: FSRepository;
}
export const FSDetails: React.FunctionComponent<Props> = ({ repository }) => {
const {
core: {
i18n: { FormattedMessage },
},
} = useAppDependencies();
const {
settings: {
location,
compress,
chunkSize,
maxRestoreBytesPerSec,
maxSnapshotBytesPerSec,
readonly,
},
} = repository;
const listItems = [
{
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeFS.locationLabel"
defaultMessage="Location"
/>
),
description: location,
},
];
if (compress !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeFS.compressLabel"
defaultMessage="Snapshot compression"
/>
),
description: String(compress),
});
}
if (chunkSize !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeFS.chunkSizeLabel"
defaultMessage="Chunk size"
/>
),
description: String(chunkSize),
});
}
if (maxSnapshotBytesPerSec !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeFS.maxSnapshotBytesLabel"
defaultMessage="Max snapshot bytes per second"
/>
),
description: maxSnapshotBytesPerSec,
});
}
if (maxRestoreBytesPerSec !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeFS.maxRestoreBytesLabel"
defaultMessage="Max restore bytes per second"
/>
),
description: maxRestoreBytesPerSec,
});
}
if (readonly !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeFS.readonlyLabel"
defaultMessage="Read-only"
/>
),
description: String(readonly),
});
}
return (
<Fragment>
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.settingsTitle"
defaultMessage="Settings"
/>
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiDescriptionList listItems={listItems} />
</Fragment>
);
};

View file

@ -0,0 +1,149 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* 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, { Fragment } from 'react';
import { EuiDescriptionList, EuiSpacer, EuiTitle } from '@elastic/eui';
import { GCSRepository } from '../../../../../../../common/types';
import { useAppDependencies } from '../../../../../index';
interface Props {
repository: GCSRepository;
}
export const GCSDetails: React.FunctionComponent<Props> = ({ repository }) => {
const {
core: {
i18n: { FormattedMessage },
},
} = useAppDependencies();
const {
settings: {
bucket,
client,
basePath,
compress,
chunkSize,
readonly,
maxRestoreBytesPerSec,
maxSnapshotBytesPerSec,
},
} = repository;
const listItems = [
{
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeGCS.bucketLabel"
defaultMessage="Bucket"
/>
),
description: bucket || '',
},
];
if (client !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeGCS.clientLabel"
defaultMessage="Client"
/>
),
description: client,
});
}
if (basePath !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeGCS.basePathLabel"
defaultMessage="Base path"
/>
),
description: basePath,
});
}
if (compress !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeGCS.compressLabel"
defaultMessage="Snapshot compression"
/>
),
description: String(compress),
});
}
if (chunkSize !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeGCS.chunkSizeLabel"
defaultMessage="Chunk size"
/>
),
description: String(chunkSize),
});
}
if (maxSnapshotBytesPerSec !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeGCS.maxSnapshotBytesLabel"
defaultMessage="Max snapshot bytes per second"
/>
),
description: maxSnapshotBytesPerSec,
});
}
if (maxRestoreBytesPerSec !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeGCS.maxRestoreBytesLabel"
defaultMessage="Max restore bytes per second"
/>
),
description: maxRestoreBytesPerSec,
});
}
if (readonly !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeGCS.readonlyLabel"
defaultMessage="Read-only"
/>
),
description: String(readonly),
});
}
return (
<Fragment>
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.settingsTitle"
defaultMessage="Settings"
/>
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiDescriptionList listItems={listItems} />
</Fragment>
);
};

View file

@ -0,0 +1,166 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* 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, { Fragment } from 'react';
import { EuiDescriptionList, EuiSpacer, EuiTitle } from '@elastic/eui';
import { HDFSRepository } from '../../../../../../../common/types';
import { useAppDependencies } from '../../../../../index';
interface Props {
repository: HDFSRepository;
}
export const HDFSDetails: React.FunctionComponent<Props> = ({ repository }) => {
const {
core: {
i18n: { FormattedMessage },
},
} = useAppDependencies();
const { settings } = repository;
const {
uri,
path,
loadDefaults,
compress,
chunkSize,
readonly,
maxSnapshotBytesPerSec,
maxRestoreBytesPerSec,
'security.principal': securityPrincipal,
...rest
} = settings;
const listItems = [
{
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeHDFS.uriLabel"
defaultMessage="URI"
/>
),
description: uri || '',
},
{
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeHDFS.pathLabel"
defaultMessage="Path"
/>
),
description: path || '',
},
];
if (loadDefaults !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeHDFS.loadDefaultsLabel"
defaultMessage="Load defaults"
/>
),
description: String(loadDefaults),
});
}
if (compress !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeHDFS.compressLabel"
defaultMessage="Snapshot compression"
/>
),
description: String(compress),
});
}
if (chunkSize !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeHDFS.chunkSizeLabel"
defaultMessage="Chunk size"
/>
),
description: String(chunkSize),
});
}
if (maxSnapshotBytesPerSec !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeHDFS.maxSnapshotBytesLabel"
defaultMessage="Max snapshot bytes per second"
/>
),
description: maxSnapshotBytesPerSec,
});
}
if (maxRestoreBytesPerSec !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeHDFS.maxRestoreBytesLabel"
defaultMessage="Max restore bytes per second"
/>
),
description: maxRestoreBytesPerSec,
});
}
if (readonly !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeHDFS.readonlyLabel"
defaultMessage="Read-only"
/>
),
description: String(readonly),
});
}
if (securityPrincipal !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeHDFS.securityPrincipalLabel"
defaultMessage="Security principal"
/>
),
description: securityPrincipal,
});
}
Object.keys(rest).forEach(key => {
listItems.push({
title: <Fragment>{key}</Fragment>,
description: String(settings[key]),
});
});
return (
<Fragment>
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.settingsTitle"
defaultMessage="Settings"
/>
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiDescriptionList listItems={listItems} />
</Fragment>
);
};

View file

@ -0,0 +1,56 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { REPOSITORY_TYPES } from '../../../../../../../common/constants';
import {
AzureRepository,
FSRepository,
GCSRepository,
HDFSRepository,
ReadonlyRepository,
Repository,
S3Repository,
} from '../../../../../../../common/types';
import { AzureDetails } from './azure_details';
import { DefaultDetails } from './default_details';
import { FSDetails } from './fs_details';
import { GCSDetails } from './gcs_details';
import { HDFSDetails } from './hdfs_details';
import { ReadonlyDetails } from './readonly_details';
import { S3Details } from './s3_details';
interface Props {
repository: Repository;
}
export const TypeDetails: React.FunctionComponent<Props> = ({ repository }) => {
const { type, settings } = repository;
switch (type) {
case REPOSITORY_TYPES.fs:
return <FSDetails repository={repository as FSRepository} />;
case REPOSITORY_TYPES.url:
return <ReadonlyDetails repository={repository as ReadonlyRepository} />;
case REPOSITORY_TYPES.source:
const { delegateType } = settings;
const delegatedRepository = {
...repository,
type: delegateType,
};
return <TypeDetails repository={delegatedRepository} />;
case REPOSITORY_TYPES.azure:
return <AzureDetails repository={repository as AzureRepository} />;
case REPOSITORY_TYPES.gcs:
return <GCSDetails repository={repository as GCSRepository} />;
case REPOSITORY_TYPES.hdfs:
return <HDFSDetails repository={repository as HDFSRepository} />;
case REPOSITORY_TYPES.s3:
return <S3Details repository={repository as S3Repository} />;
default:
return <DefaultDetails repository={repository} />;
}
};

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* 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, { Fragment } from 'react';
import { EuiDescriptionList, EuiSpacer, EuiTitle } from '@elastic/eui';
import { ReadonlyRepository } from '../../../../../../../common/types';
import { useAppDependencies } from '../../../../../index';
interface Props {
repository: ReadonlyRepository;
}
export const ReadonlyDetails: React.FunctionComponent<Props> = ({ repository }) => {
const {
core: {
i18n: { FormattedMessage },
},
} = useAppDependencies();
const {
settings: { url },
} = repository;
const listItems = [
{
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeReadonly.urlLabel"
defaultMessage="URL"
/>
),
description: url,
},
];
return (
<Fragment>
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.settingsTitle"
defaultMessage="Settings"
/>
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiDescriptionList listItems={listItems} />
</Fragment>
);
};

View file

@ -0,0 +1,201 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* 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, { Fragment } from 'react';
import { EuiDescriptionList, EuiSpacer, EuiTitle } from '@elastic/eui';
import { S3Repository } from '../../../../../../../common/types';
import { useAppDependencies } from '../../../../../index';
interface Props {
repository: S3Repository;
}
export const S3Details: React.FunctionComponent<Props> = ({ repository }) => {
const {
core: {
i18n: { FormattedMessage },
},
} = useAppDependencies();
const {
settings: {
bucket,
client,
basePath,
compress,
chunkSize,
serverSideEncryption,
bufferSize,
cannedAcl,
storageClass,
readonly,
maxRestoreBytesPerSec,
maxSnapshotBytesPerSec,
},
} = repository;
const listItems = [
{
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeS3.bucketLabel"
defaultMessage="Bucket"
/>
),
description: bucket || '',
},
];
if (client !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeS3.clientLabel"
defaultMessage="Client"
/>
),
description: client,
});
}
if (basePath !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeS3.basePathLabel"
defaultMessage="Base path"
/>
),
description: basePath,
});
}
if (compress !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeS3.compressLabel"
defaultMessage="Snapshot compression"
/>
),
description: String(compress),
});
}
if (chunkSize !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeS3.chunkSizeLabel"
defaultMessage="Chunk size"
/>
),
description: String(chunkSize),
});
}
if (serverSideEncryption !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeS3.serverSideEncryptionLabel"
defaultMessage="Server-side encryption"
/>
),
description: String(serverSideEncryption),
});
}
if (bufferSize !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeS3.bufferSizeLabel"
defaultMessage="Buffer size"
/>
),
description: bufferSize,
});
}
if (cannedAcl !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeS3.cannedAclLabel"
defaultMessage="Canned ACL"
/>
),
description: cannedAcl,
});
}
if (storageClass !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeS3.storageClassLabel"
defaultMessage="Storage class"
/>
),
description: storageClass,
});
}
if (maxSnapshotBytesPerSec !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeS3.maxSnapshotBytesLabel"
defaultMessage="Max snapshot bytes per second"
/>
),
description: maxSnapshotBytesPerSec,
});
}
if (maxRestoreBytesPerSec !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeS3.maxRestoreBytesLabel"
defaultMessage="Max restore bytes per second"
/>
),
description: maxRestoreBytesPerSec,
});
}
if (readonly !== undefined) {
listItems.push({
title: (
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.typeS3.readonlyLabel"
defaultMessage="Read-only"
/>
),
description: String(readonly),
});
}
return (
<Fragment>
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.settingsTitle"
defaultMessage="Settings"
/>
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiDescriptionList listItems={listItems} />
</Fragment>
);
};

View file

@ -0,0 +1,145 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* 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, { Fragment } from 'react';
import { RouteComponentProps } from 'react-router-dom';
import { EuiButton, EuiEmptyPrompt } from '@elastic/eui';
import { Repository } from '../../../../../common/types';
import { SectionError, SectionLoading } from '../../../components';
import { BASE_PATH } from '../../../constants';
import { useAppDependencies } from '../../../index';
import { loadRepositories } from '../../../services/http';
import { RepositoryDetails } from './repository_details';
import { RepositoryTable } from './repository_table';
interface MatchParams {
repositoryName?: Repository['name'];
}
export const RepositoryList: React.FunctionComponent<RouteComponentProps<MatchParams>> = ({
match: {
params: { repositoryName },
},
history,
}) => {
const {
core: {
i18n: { FormattedMessage },
},
} = useAppDependencies();
const {
error,
loading,
data: { repositories } = { repositories: undefined },
request: reload,
} = loadRepositories();
const openRepositoryDetails = (newRepositoryName: Repository['name']) => {
history.push(`${BASE_PATH}/repositories/${newRepositoryName}`);
};
const closeRepositoryDetails = () => {
history.push(`${BASE_PATH}/repositories`);
};
const onRepositoryDeleted = (repositoriesDeleted: Array<Repository['name']>): void => {
if (repositoryName && repositoriesDeleted.includes(repositoryName)) {
closeRepositoryDetails();
}
if (repositoriesDeleted.length) {
reload();
}
};
let content;
if (loading) {
content = (
<SectionLoading>
<FormattedMessage
id="xpack.snapshotRestore.repositoryList.loadingRepositoriesDescription"
defaultMessage="Loading repositories…"
/>
</SectionLoading>
);
} else if (error) {
content = (
<SectionError
title={
<FormattedMessage
id="xpack.snapshotRestore.repositoryList.LoadingRepositoriesErrorMessage"
defaultMessage="Error loading repositories"
/>
}
error={error}
/>
);
} else if (repositories && repositories.length === 0) {
content = (
<EuiEmptyPrompt
iconType="managementApp"
title={
<h1>
<FormattedMessage
id="xpack.snapshotRestore.repositoryList.emptyPromptTitle"
defaultMessage="You don't have any repositories yet"
/>
</h1>
}
body={
<Fragment>
<p>
<FormattedMessage
id="xpack.snapshotRestore.repositoryList.emptyPromptDescription"
defaultMessage="You need a repository to store your snapshots."
/>
</p>
</Fragment>
}
actions={
<EuiButton
href={history.createHref({
pathname: `${BASE_PATH}/add_repository`,
})}
fill
iconType="plusInCircle"
data-test-subj="srRepositoriesEmptyPromptAddButton"
>
<FormattedMessage
id="xpack.snapshotRestore.addRepositoryButtonLabel"
defaultMessage="Register a repository"
/>
</EuiButton>
}
/>
);
} else {
content = (
<RepositoryTable
repositories={repositories || []}
reload={reload}
openRepositoryDetails={openRepositoryDetails}
onRepositoryDeleted={onRepositoryDeleted}
/>
);
}
return (
<Fragment>
{repositoryName ? (
<RepositoryDetails
repositoryName={repositoryName}
onClose={closeRepositoryDetails}
onRepositoryDeleted={onRepositoryDeleted}
/>
) : null}
{content}
</Fragment>
);
};

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { RepositoryTable } from './repository_table';

View file

@ -0,0 +1,265 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* 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, { useState } from 'react';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import {
EuiButton,
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiInMemoryTable,
EuiLink,
EuiToolTip,
} from '@elastic/eui';
import { REPOSITORY_TYPES } from '../../../../../../common/constants';
import { Repository, RepositoryType } from '../../../../../../common/types';
import { RepositoryDeleteProvider } from '../../../../components';
import { BASE_PATH } from '../../../../constants';
import { useAppDependencies } from '../../../../index';
import { textService } from '../../../../services/text';
interface Props extends RouteComponentProps {
repositories: Repository[];
reload: () => Promise<void>;
openRepositoryDetails: (name: Repository['name']) => void;
onRepositoryDeleted: (repositoriesDeleted: Array<Repository['name']>) => void;
}
const RepositoryTableUi: React.FunctionComponent<Props> = ({
repositories,
reload,
openRepositoryDetails,
onRepositoryDeleted,
history,
}) => {
const {
core: { i18n },
} = useAppDependencies();
const { FormattedMessage } = i18n;
const [selectedItems, setSelectedItems] = useState<Repository[]>([]);
const columns = [
{
field: 'name',
name: i18n.translate('xpack.snapshotRestore.repositoryList.table.nameColumnTitle', {
defaultMessage: 'Name',
}),
truncateText: true,
sortable: true,
render: (name: Repository['name'], repository: Repository) => {
return <EuiLink onClick={() => openRepositoryDetails(name)}>{name}</EuiLink>;
},
},
{
field: 'type',
name: i18n.translate('xpack.snapshotRestore.repositoryList.table.typeColumnTitle', {
defaultMessage: 'Type',
}),
truncateText: true,
sortable: true,
render: (type: RepositoryType, repository: Repository) => {
if (type === REPOSITORY_TYPES.source) {
return textService.getRepositoryTypeName(type, repository.settings.delegateType);
}
return textService.getRepositoryTypeName(type);
},
},
{
name: i18n.translate('xpack.snapshotRestore.repositoryList.table.actionsColumnTitle', {
defaultMessage: 'Actions',
}),
actions: [
{
render: ({ name }: { name: string }) => {
const label = i18n.translate(
'xpack.snapshotRestore.repositoryList.table.actionEditTooltip',
{ defaultMessage: 'Edit' }
);
return (
<EuiToolTip content={label} delay="long">
<EuiButtonIcon
aria-label={i18n.translate(
'xpack.snapshotRestore.repositoryList.table.actionEditAriaLabel',
{
defaultMessage: 'Edit repository `{name}`',
values: { name },
}
)}
iconType="pencil"
color="primary"
href={`#${BASE_PATH}/edit_repository/${name}`}
/>
</EuiToolTip>
);
},
},
{
render: ({ name }: Repository) => {
return (
<RepositoryDeleteProvider>
{deleteRepositoryPrompt => {
const label = i18n.translate(
'xpack.snapshotRestore.repositoryList.table.actionRemoveTooltip',
{ defaultMessage: 'Remove' }
);
return (
<EuiToolTip content={label} delay="long">
<EuiButtonIcon
aria-label={i18n.translate(
'xpack.snapshotRestore.repositoryList.table.actionRemoveAriaLabel',
{
defaultMessage: 'Remove repository `{name}`',
values: { name },
}
)}
iconType="trash"
color="danger"
data-test-subj="srRepositoryListDeleteActionButton"
onClick={() => deleteRepositoryPrompt([name], onRepositoryDeleted)}
/>
</EuiToolTip>
);
}}
</RepositoryDeleteProvider>
);
},
},
],
width: '100px',
},
];
const sorting = {
sort: {
field: 'name',
direction: 'asc',
},
};
const pagination = {
initialPageSize: 20,
pageSizeOptions: [10, 20, 50],
};
const selection = {
onSelectionChange: (newSelectedItems: Repository[]) => setSelectedItems(newSelectedItems),
};
const search = {
toolsLeft: selectedItems.length ? (
<RepositoryDeleteProvider>
{(
deleteRepositoryPrompt: (
names: Array<Repository['name']>,
onSuccess?: (repositoriesDeleted: Array<Repository['name']>) => void
) => void
) => {
return (
<EuiButton
onClick={() =>
deleteRepositoryPrompt(
selectedItems.map(repository => repository.name),
onRepositoryDeleted
)
}
color="danger"
data-test-subj="srRepositoryListBulkDeleteActionButton"
>
{selectedItems.length === 1 ? (
<FormattedMessage
id="xpack.snapshotRestore.repositoryList.table.deleteSingleRepositoryButton"
defaultMessage="Remove repository"
/>
) : (
<FormattedMessage
id="xpack.snapshotRestore.repositoryList.table.deleteMultipleRepositoriesButton"
defaultMessage="Remove repositories"
/>
)}
</EuiButton>
);
}}
</RepositoryDeleteProvider>
) : (
undefined
),
toolsRight: (
<EuiFlexGroup gutterSize="m" justifyContent="spaceAround">
<EuiFlexItem>
<EuiButton color="secondary" iconType="refresh" onClick={reload}>
<FormattedMessage
id="xpack.snapshotRestore.repositoryList.table.reloadRepositoriesButton"
defaultMessage="Reload"
/>
</EuiButton>
</EuiFlexItem>
<EuiFlexItem>
<EuiButton
href={history.createHref({
pathname: `${BASE_PATH}/add_repository`,
})}
fill
iconType="plusInCircle"
data-test-subj="srRepositoriesAddButton"
>
<FormattedMessage
id="xpack.snapshotRestore.repositoryList.addRepositoryButtonLabel"
defaultMessage="Register a repository"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
),
box: {
incremental: true,
schema: true,
},
filters: [
{
type: 'field_value_selection',
field: 'type',
name: 'Type',
multiSelect: false,
options: Object.keys(
repositories.reduce((typeMap: any, repository) => {
typeMap[repository.type] = true;
return typeMap;
}, {})
).map(type => {
return {
value: type,
view: textService.getRepositoryTypeName(type),
};
}),
},
],
};
return (
<EuiInMemoryTable
items={repositories}
itemId="name"
columns={columns}
search={search}
sorting={sorting}
selection={selection}
pagination={pagination}
isSelectable={true}
rowProps={() => ({
'data-test-subj': 'srRepositoryListTableRow',
})}
cellProps={(item: any, column: any) => ({
'data-test-subj': `srRepositoryListTableCell-${column.field}`,
})}
/>
);
};
export const RepositoryTable = withRouter(RepositoryTableUi);

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { SnapshotList } from './snapshot_list';

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { SnapshotDetails } from './snapshot_details';

View file

@ -0,0 +1,194 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
EuiFlexGroup,
EuiFlexItem,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutHeader,
EuiLink,
EuiSpacer,
EuiTab,
EuiTabs,
EuiText,
EuiTitle,
} from '@elastic/eui';
import React, { Fragment, useState, useEffect } from 'react';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import { SectionError, SectionLoading } from '../../../../components';
import { useAppDependencies } from '../../../../index';
import { loadSnapshot } from '../../../../services/http';
import { linkToRepository } from '../../../../services/navigation';
import { TabSummary, TabFailures } from './tabs';
interface Props extends RouteComponentProps {
repositoryName: string;
snapshotId: string;
onClose: () => void;
}
const TAB_SUMMARY = 'summary';
const TAB_FAILURES = 'failures';
const SnapshotDetailsUi: React.FunctionComponent<Props> = ({
repositoryName,
snapshotId,
onClose,
}) => {
const {
core: {
i18n: { FormattedMessage, translate },
},
} = useAppDependencies();
const { error, data: snapshotDetails } = loadSnapshot(repositoryName, snapshotId);
const [activeTab, setActiveTab] = useState<string>(TAB_SUMMARY);
// Reset tab when we look at a different snapshot.
useEffect(
() => {
setActiveTab(TAB_SUMMARY);
},
[repositoryName, snapshotId]
);
let tabs;
let content;
if (snapshotDetails) {
const { indexFailures, state: snapshotState } = snapshotDetails;
const tabOptions = [
{
id: TAB_SUMMARY,
name: (
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.summaryTabTitle"
defaultMessage="Summary"
/>
),
testSubj: 'srSnapshotDetailsSummaryTab',
},
{
id: TAB_FAILURES,
name: (
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.failuresTabTitle"
defaultMessage="Failed indices ({failuresCount})"
values={{ failuresCount: indexFailures.length }}
/>
),
testSubj: 'srSnapshotDetailsFailuresTab',
},
];
tabs = (
<Fragment>
<EuiSpacer size="s" />
<EuiTabs>
{tabOptions.map(tab => (
<EuiTab
onClick={() => setActiveTab(tab.id)}
isSelected={tab.id === activeTab}
key={tab.id}
data-test-subject={tab.testSubj}
>
{tab.name}
</EuiTab>
))}
</EuiTabs>
</Fragment>
);
if (activeTab === TAB_SUMMARY) {
content = <TabSummary snapshotDetails={snapshotDetails} />;
} else if (activeTab === TAB_FAILURES) {
content = <TabFailures snapshotState={snapshotState} indexFailures={indexFailures} />;
}
} else if (error) {
const notFound = error.status === 404;
const errorObject = notFound
? {
data: {
error: translate('xpack.snapshotRestore.snapshotDetails.errorSnapshotNotFound', {
defaultMessage: `Either the snapshot '{snapshotId}' doesn't exist in the repository '{repositoryName}' or the repository doesn't exist.`,
values: {
snapshotId,
repositoryName,
},
}),
},
}
: error;
content = (
<SectionError
title={
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.errorLoadingRepositoryTitle"
defaultMessage="Error loading repository"
/>
}
error={errorObject}
/>
);
} else {
// Assume the content is loading.
content = (
<SectionLoading>
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.loadingSnapshotDescription"
defaultMessage="Loading snapshot…"
/>
</SectionLoading>
);
}
return (
<EuiFlyout
onClose={onClose}
data-test-subj="srSnapshotDetailsFlyout"
aria-labelledby="srSnapshotDetailsFlyoutTitle"
size="m"
maxWidth={400}
>
<EuiFlyoutHeader>
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem>
<EuiTitle size="m">
<h2 id="srSnapshotDetailsFlyoutTitle" data-test-subj="srSnapshotDetailsFlyoutTitle">
{snapshotId}
</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="s">
<p>
<EuiLink href={linkToRepository(repositoryName)}>
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.repositoryTitle"
defaultMessage="'{repositoryName}' repository"
values={{ repositoryName }}
/>
</EuiLink>
</p>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
{tabs}
</EuiFlyoutHeader>
<EuiFlyoutBody data-test-subj="srSnapshotDetailsContent">{content}</EuiFlyoutBody>
</EuiFlyout>
);
};
export const SnapshotDetails = withRouter(SnapshotDetailsUi);

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { TabSummary } from './tab_summary';
export { TabFailures } from './tab_failures';

View file

@ -0,0 +1,91 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* 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, { Fragment } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiIconTip, EuiLoadingSpinner } from '@elastic/eui';
import { SNAPSHOT_STATE } from '../../../../../constants';
import { useAppDependencies } from '../../../../../index';
interface Props {
state: any;
}
export const SnapshotState: React.SFC<Props> = ({ state }) => {
const {
core: {
i18n: { translate },
},
} = useAppDependencies();
const stateMap: any = {
[SNAPSHOT_STATE.IN_PROGRESS]: {
icon: <EuiLoadingSpinner size="m" />,
label: translate('xpack.snapshotRestore.snapshotState.inProgressLabel', {
defaultMessage: 'Taking snapshot…',
}),
},
[SNAPSHOT_STATE.SUCCESS]: {
icon: <EuiIcon color="success" type="check" />,
label: translate('xpack.snapshotRestore.snapshotState.inProgressLabel', {
defaultMessage: 'Snapshot complete',
}),
},
[SNAPSHOT_STATE.FAILED]: {
icon: <EuiIcon color="danger" type="cross" />,
label: translate('xpack.snapshotRestore.snapshotState.failedLabel', {
defaultMessage: 'Snapshot failed',
}),
},
[SNAPSHOT_STATE.PARTIAL]: {
icon: <EuiIcon color="warning" type="alert" />,
label: translate('xpack.snapshotRestore.snapshotState.partialLabel', {
defaultMessage: 'Partial failure',
}),
tip: translate('xpack.snapshotRestore.snapshotState.partialTipDescription', {
defaultMessage: `Global cluster state was stored, but at least one shard wasn't stored successfully. See the 'Failed indices' tab.`,
}),
},
[SNAPSHOT_STATE.INCOMPATIBLE]: {
icon: <EuiIcon color="warning" type="alert" />,
label: translate('xpack.snapshotRestore.snapshotState.incompatibleLabel', {
defaultMessage: 'Incompatible version',
}),
tip: translate('xpack.snapshotRestore.snapshotState.partialTipDescription', {
defaultMessage: `Snapshot was created with a version of Elasticsearch incompatible with the cluster's version.`,
}),
},
};
if (!stateMap[state]) {
// Help debug unexpected state.
return state;
}
const { icon, label, tip } = stateMap[state];
const iconTip = tip && (
<Fragment>
{' '}
<EuiIconTip content={tip} />
</Fragment>
);
return (
<EuiFlexGroup gutterSize="xs" alignItems="center" responsive={false}>
<EuiFlexItem grow={false}>{icon}</EuiFlexItem>
<EuiFlexItem grow={false}>
{/* Escape flex layout created by EuiFlexItem. */}
<div>
{label}
{iconTip}
</div>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,86 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* 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 from 'react';
import { EuiCodeBlock, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui';
import { SNAPSHOT_STATE } from '../../../../../constants';
import { useAppDependencies } from '../../../../../index';
interface Props {
indexFailures: any;
snapshotState: string;
}
export const TabFailures: React.SFC<Props> = ({ indexFailures, snapshotState }) => {
const {
core: {
i18n: { FormattedMessage },
},
} = useAppDependencies();
if (!indexFailures.length) {
// If the snapshot is in progress then we still might encounter errors later.
if (snapshotState === SNAPSHOT_STATE.IN_PROGRESS) {
return (
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.snapshotIsBeingCreatedMessage"
defaultMessage="Snapshot is being created."
/>
);
} else {
return (
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.noIndexFailuresMessage"
defaultMessage="All indices were stored successfully."
/>
);
}
}
return indexFailures.map((indexObject: any, count: number) => {
const { index, failures } = indexObject;
return (
<div key={index}>
<EuiTitle size="xs">
<h3>{index}</h3>
</EuiTitle>
<EuiSpacer size="s" />
{failures.map((failure: any, failuresCount: number) => {
const { status, reason, shard_id: shardId } = failure;
return (
<div key={`${shardId}${reason}`}>
<EuiText size="xs">
<p>
<strong>
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.failureShardTitle"
defaultMessage="Shard {shardId}"
values={{ shardId }}
/>
</strong>
</p>
</EuiText>
<EuiCodeBlock paddingSize="s">
{status}: {reason}
</EuiCodeBlock>
{failuresCount < failures.length - 1 ? <EuiSpacer size="s" /> : undefined}
</div>
);
})}
{count < indexFailures.length - 1 ? <EuiSpacer size="l" /> : undefined}
</div>
);
});
};

View file

@ -0,0 +1,253 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* 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 from 'react';
import {
EuiDescriptionList,
EuiDescriptionListDescription,
EuiDescriptionListTitle,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { SNAPSHOT_STATE } from '../../../../../constants';
import { useAppDependencies } from '../../../../../index';
import { formatDate } from '../../../../../services/text';
import { DataPlaceholder } from '../../../../../components';
import { SnapshotState } from './snapshot_state';
interface Props {
snapshotDetails: any;
}
export const TabSummary: React.SFC<Props> = ({ snapshotDetails }) => {
const {
core: {
i18n: { FormattedMessage },
},
} = useAppDependencies();
const includeGlobalStateToHumanizedMap: Record<string, any> = {
0: (
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.itemIncludeGlobalStateNoLabel"
defaultMessage="No"
/>
),
1: (
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.itemIncludeGlobalStateYesLabel"
defaultMessage="Yes"
/>
),
};
const {
versionId,
version,
// TODO: Add a tooltip explaining that: a false value means that the cluster global state
// is not stored as part of the snapshot.
includeGlobalState,
indices,
state,
startTimeInMillis,
endTimeInMillis,
durationInMillis,
uuid,
} = snapshotDetails;
const indicesList = indices.length ? (
<ul>
{indices.map((index: string) => (
<li key={index}>
<EuiTitle size="xs">
<span>{index}</span>
</EuiTitle>
</li>
))}
</ul>
) : (
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.itemIndicesNoneLabel"
data-test-subj="srSnapshotDetailsIndicesNoneTitle"
defaultMessage="-"
/>
);
return (
<EuiDescriptionList textStyle="reverse">
<EuiFlexGroup>
<EuiFlexItem data-test-subj="srSnapshotDetailsVersionItem">
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.itemVersionLabel"
data-test-subj="srSnapshotDetailsVersionTitle"
defaultMessage="Version / Version ID"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription
className="eui-textBreakWord"
data-test-subj="srSnapshotDetailsVersionDescription"
>
{version} / {versionId}
</EuiDescriptionListDescription>
</EuiFlexItem>
<EuiFlexItem data-test-subj="srSnapshotDetailsIncludeGlobalUuidItem">
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.itemUuidLabel"
data-test-subj="srSnapshotDetailsUuidTitle"
defaultMessage="UUID"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription
className="eui-textBreakWord"
data-test-subj="srSnapshotDetailUuidDescription"
>
{uuid}
</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem data-test-subj="srSnapshotDetailsStateItem">
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.itemStateLabel"
data-test-subj="srSnapshotDetailsStateTitle"
defaultMessage="State"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription
className="eui-textBreakWord"
data-test-subj="srSnapshotDetailStateDescription"
>
<SnapshotState state={state} />
</EuiDescriptionListDescription>
</EuiFlexItem>
<EuiFlexItem data-test-subj="srSnapshotDetailsIncludeGlobalStateItem">
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.itemIncludeGlobalStateLabel"
data-test-subj="srSnapshotDetailsIncludeGlobalStateTitle"
defaultMessage="Includes global state"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription
className="eui-textBreakWord"
data-test-subj="srSnapshotDetailIncludeGlobalStateDescription"
>
{includeGlobalStateToHumanizedMap[includeGlobalState]}
</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem data-test-subj="srSnapshotDetailsIndicesItem">
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.itemIndicesLabel"
data-test-subj="srSnapshotDetailsIndicesTitle"
defaultMessage="Indices ({indicesCount})"
values={{ indicesCount: indices.length }}
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription
className="eui-textBreakWord"
data-test-subj="srSnapshotDetailIndicesDescription"
>
<EuiText>{indicesList}</EuiText>
</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem data-test-subj="srSnapshotDetailsStartTimeItem">
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.itemStartTimeLabel"
data-test-subj="srSnapshotDetailsStartTimeTitle"
defaultMessage="Start time"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription
className="eui-textBreakWord"
data-test-subj="srSnapshotDetailStartTimeDescription"
>
<DataPlaceholder data={startTimeInMillis}>
{formatDate(startTimeInMillis)}
</DataPlaceholder>
</EuiDescriptionListDescription>
</EuiFlexItem>
<EuiFlexItem data-test-subj="srSnapshotDetailsEndTimeItem">
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.itemEndTimeLabel"
data-test-subj="srSnapshotDetailsEndTimeTitle"
defaultMessage="End time"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription
className="eui-textBreakWord"
data-test-subj="srSnapshotDetailEndTimeDescription"
>
{state === SNAPSHOT_STATE.IN_PROGRESS ? (
<EuiLoadingSpinner size="m" />
) : (
<DataPlaceholder data={endTimeInMillis}>
{formatDate(endTimeInMillis)}
</DataPlaceholder>
)}
</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem data-test-subj="srSnapshotDetailsDurationItem">
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.itemDurationLabel"
data-test-subj="srSnapshotDetailsDurationTitle"
defaultMessage="Duration"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription
className="eui-textBreakWord"
data-test-subj="srSnapshotDetailDurationDescription"
>
{state === SNAPSHOT_STATE.IN_PROGRESS ? (
<EuiLoadingSpinner size="m" />
) : (
<DataPlaceholder data={durationInMillis}>
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.itemDurationValueLabel"
data-test-subj="srSnapshotDetailsDurationValue"
defaultMessage="{seconds} {seconds, plural, one {second} other {seconds}}"
values={{ seconds: Math.ceil(durationInMillis / 1000) }}
/>
</DataPlaceholder>
)}
</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>
</EuiDescriptionList>
);
};

View file

@ -0,0 +1,276 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* 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, { Fragment, useState, useEffect } from 'react';
import { RouteComponentProps } from 'react-router-dom';
import { parse } from 'querystring';
import { EuiButton, EuiCallOut, EuiIcon, EuiLink, EuiEmptyPrompt, EuiSpacer } from '@elastic/eui';
import { SectionError, SectionLoading } from '../../../components';
import { BASE_PATH } from '../../../constants';
import { useAppDependencies } from '../../../index';
import { documentationLinksService } from '../../../services/documentation';
import { loadSnapshots } from '../../../services/http';
import { linkToRepositories } from '../../../services/navigation';
import { SnapshotDetails } from './snapshot_details';
import { SnapshotTable } from './snapshot_table';
interface MatchParams {
repositoryName?: string;
snapshotId?: string;
}
export const SnapshotList: React.FunctionComponent<RouteComponentProps<MatchParams>> = ({
match: {
params: { repositoryName, snapshotId },
},
location: { search },
history,
}) => {
const {
core: {
i18n: { FormattedMessage },
},
} = useAppDependencies();
const {
error,
loading,
data: { snapshots = [], repositories = [], errors = {} },
request: reload,
} = loadSnapshots();
const openSnapshotDetails = (repositoryNameToOpen: string, snapshotIdToOpen: string) => {
history.push(
`${BASE_PATH}/snapshots/${encodeURIComponent(repositoryNameToOpen)}/${encodeURIComponent(
snapshotIdToOpen
)}`
);
};
const closeSnapshotDetails = () => {
history.push(`${BASE_PATH}/snapshots`);
};
// Allow deeplinking to list pre-filtered by repository name
const [filteredRepository, setFilteredRepository] = useState<string | undefined>(undefined);
useEffect(() => {
if (search) {
const parsedParams = parse(search.replace(/^\?/, ''));
if (parsedParams.repository && parsedParams.repository !== filteredRepository) {
setFilteredRepository(String(parsedParams.repository));
history.replace(`${BASE_PATH}/snapshots`);
}
}
}, []);
let content;
if (loading) {
content = (
<SectionLoading>
<FormattedMessage
id="xpack.snapshotRestore.snapshotList.loadingSnapshotsDescription"
defaultMessage="Loading snapshots…"
/>
</SectionLoading>
);
} else if (error) {
content = (
<SectionError
title={
<FormattedMessage
id="xpack.snapshotRestore.snapshotList.loadingSnapshotsErrorMessage"
defaultMessage="Error loading snapshots"
/>
}
error={error}
/>
);
} else if (Object.keys(errors).length && repositories.length === 0) {
content = (
<EuiEmptyPrompt
iconType="managementApp"
title={
<h1>
<FormattedMessage
id="xpack.snapshotRestore.snapshotList.emptyPrompt.errorRepositoriesTitle"
defaultMessage="Some repositories contain errors"
/>
</h1>
}
body={
<Fragment>
<p>
<FormattedMessage
id="xpack.snapshotRestore.snapshotList.emptyPrompt.repositoryWarningDescription"
defaultMessage="Go to {repositoryLink} to fix the errors."
values={{
repositoryLink: (
<EuiLink href={linkToRepositories()}>
<FormattedMessage
id="xpack.snapshotRestore.repositoryWarningLinkText"
defaultMessage="Repositories"
/>
</EuiLink>
),
}}
/>
</p>
<p>
<EuiLink
href={documentationLinksService.getSnapshotDocUrl()}
target="_blank"
data-test-subj="srSnapshotsEmptyPromptDocLink"
>
<FormattedMessage
id="xpack.snapshotRestore.emptyPrompt.noSnapshotsDocLinkText"
defaultMessage="Learn how to create a snapshot"
/>{' '}
<EuiIcon type="link" />
</EuiLink>
</p>
</Fragment>
}
/>
);
} else if (repositories.length === 0) {
content = (
<EuiEmptyPrompt
iconType="managementApp"
title={
<h1>
<FormattedMessage
id="xpack.snapshotRestore.snapshotList.emptyPrompt.noRepositoriesTitle"
defaultMessage="You don't have any snapshots or repositories yet"
/>
</h1>
}
body={
<Fragment>
<p>
<FormattedMessage
id="xpack.snapshotRestore.snapshotList.emptyPrompt.noRepositoriesDescription"
defaultMessage="Start by registering a repository for your snapshots."
/>
</p>
<p>
<EuiButton
href={history.createHref({
pathname: `${BASE_PATH}/add_repository`,
})}
fill
iconType="plusInCircle"
data-test-subj="srSnapshotsEmptyPromptAddRepositoryButton"
>
<FormattedMessage
id="xpack.snapshotRestore.snapshotList.emptyPrompt.noRepositoriesAddButtonLabel"
defaultMessage="Register a repository"
/>
</EuiButton>
</p>
</Fragment>
}
/>
);
} else if (snapshots.length === 0) {
content = (
<EuiEmptyPrompt
iconType="managementApp"
title={
<h1>
<FormattedMessage
id="xpack.snapshotRestore.snapshotList.emptyPrompt.noSnapshotsTitle"
defaultMessage="You don't have any snapshots yet"
/>
</h1>
}
body={
<Fragment>
<p>
<FormattedMessage
id="xpack.snapshotRestore.snapshotList.emptyPrompt.noSnapshotsDescription"
defaultMessage="Create a snapshot using the Elasticsearch API."
/>
</p>
<p>
<EuiLink
href={documentationLinksService.getSnapshotDocUrl()}
target="_blank"
data-test-subj="srSnapshotsEmptyPromptDocLink"
>
<FormattedMessage
id="xpack.snapshotRestore.emptyPrompt.noSnapshotsDocLinkText"
defaultMessage="Learn how to create a snapshot"
/>{' '}
<EuiIcon type="link" />
</EuiLink>
</p>
</Fragment>
}
/>
);
} else {
const repositoryErrorsWarning = Object.keys(errors).length ? (
<EuiCallOut
title={
<FormattedMessage
id="xpack.snapshotRestore.repositoryWarningTitle"
defaultMessage="Some repositories contain errors"
/>
}
color="warning"
iconType="alert"
>
<FormattedMessage
id="xpack.snapshotRestore.repositoryWarningDescription"
defaultMessage="Snapshots might load slowly. Go to {repositoryLink} to fix the errors."
values={{
repositoryLink: (
<EuiLink href={linkToRepositories()}>
<FormattedMessage
id="xpack.snapshotRestore.repositoryWarningLinkText"
defaultMessage="Repositories"
/>
</EuiLink>
),
}}
/>
</EuiCallOut>
) : null;
content = (
<Fragment>
{repositoryErrorsWarning}
<EuiSpacer />
<SnapshotTable
snapshots={snapshots}
repositories={repositories}
reload={reload}
openSnapshotDetails={openSnapshotDetails}
repositoryFilter={filteredRepository}
/>
</Fragment>
);
}
return (
<Fragment>
{repositoryName && snapshotId ? (
<SnapshotDetails
repositoryName={repositoryName}
snapshotId={snapshotId}
onClose={closeSnapshotDetails}
/>
) : null}
{content}
</Fragment>
);
};

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { SnapshotTable } from './snapshot_table';

View file

@ -0,0 +1,201 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* 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 from 'react';
import { EuiButton, EuiInMemoryTable, EuiLink, Query, EuiLoadingSpinner } from '@elastic/eui';
import { SnapshotDetails } from '../../../../../../common/types';
import { SNAPSHOT_STATE } from '../../../../constants';
import { useAppDependencies } from '../../../../index';
import { formatDate } from '../../../../services/text';
import { linkToRepository } from '../../../../services/navigation';
import { DataPlaceholder } from '../../../../components';
interface Props {
snapshots: SnapshotDetails[];
repositories: string[];
reload: () => Promise<void>;
openSnapshotDetails: (repositoryName: string, snapshotId: string) => void;
repositoryFilter?: string;
}
export const SnapshotTable: React.FunctionComponent<Props> = ({
snapshots,
repositories,
reload,
openSnapshotDetails,
repositoryFilter,
}) => {
const {
core: {
i18n: { FormattedMessage, translate },
},
} = useAppDependencies();
const columns = [
{
field: 'snapshot',
name: translate('xpack.snapshotRestore.snapshotList.table.snapshotColumnTitle', {
defaultMessage: 'Snapshot',
}),
truncateText: true,
sortable: true,
render: (snapshotId: string, snapshot: SnapshotDetails) => (
<EuiLink onClick={() => openSnapshotDetails(snapshot.repository, snapshotId)}>
{snapshotId}
</EuiLink>
),
},
{
field: 'repository',
name: translate('xpack.snapshotRestore.snapshotList.table.repositoryColumnTitle', {
defaultMessage: 'Repository',
}),
truncateText: true,
sortable: true,
render: (repositoryName: string) => (
<EuiLink href={linkToRepository(repositoryName)}>{repositoryName}</EuiLink>
),
},
{
field: 'startTimeInMillis',
name: translate('xpack.snapshotRestore.snapshotList.table.startTimeColumnTitle', {
defaultMessage: 'Date created',
}),
truncateText: true,
sortable: true,
render: (startTimeInMillis: number) => (
<DataPlaceholder data={startTimeInMillis}>{formatDate(startTimeInMillis)}</DataPlaceholder>
),
},
{
field: 'durationInMillis',
name: translate('xpack.snapshotRestore.snapshotList.table.durationColumnTitle', {
defaultMessage: 'Duration',
}),
truncateText: true,
sortable: true,
width: '100px',
render: (durationInMillis: number, { state }: SnapshotDetails) => {
if (state === SNAPSHOT_STATE.IN_PROGRESS) {
return <EuiLoadingSpinner size="m" />;
}
return (
<DataPlaceholder data={durationInMillis}>
<FormattedMessage
id="xpack.snapshotRestore.snapshotList.table.durationColumnValueLabel"
defaultMessage="{seconds}s"
values={{ seconds: Math.ceil(durationInMillis / 1000) }}
/>
</DataPlaceholder>
);
},
},
{
field: 'indices',
name: translate('xpack.snapshotRestore.snapshotList.table.indicesColumnTitle', {
defaultMessage: 'Indices',
}),
truncateText: true,
sortable: true,
width: '100px',
render: (indices: string[]) => indices.length,
},
{
field: 'shards.total',
name: translate('xpack.snapshotRestore.snapshotList.table.shardsColumnTitle', {
defaultMessage: 'Shards',
}),
truncateText: true,
sortable: true,
width: '100px',
render: (totalShards: number) => totalShards,
},
{
field: 'shards.failed',
name: translate('xpack.snapshotRestore.snapshotList.table.failedShardsColumnTitle', {
defaultMessage: 'Failed shards',
}),
truncateText: true,
sortable: true,
width: '100px',
render: (failedShards: number) => failedShards,
},
];
// By default, we'll display the most recent snapshots at the top of the table.
const sorting = {
sort: {
field: 'startTimeInMillis',
direction: 'desc',
},
};
const pagination = {
initialPageSize: 20,
pageSizeOptions: [10, 20, 50],
};
const searchSchema = {
fields: {
repository: {
type: 'string',
},
},
};
const search = {
toolsRight: (
<EuiButton color="secondary" iconType="refresh" onClick={reload}>
<FormattedMessage
id="xpack.snapshotRestore.snapshotList.table.reloadSnapshotsButton"
defaultMessage="Reload"
/>
</EuiButton>
),
box: {
incremental: true,
schema: searchSchema,
},
filters: [
{
type: 'field_value_selection',
field: 'repository',
name: 'Repository',
multiSelect: false,
options: repositories.map(repository => ({
value: repository,
view: repository,
})),
},
],
defaultQuery: repositoryFilter
? Query.parse(`repository:'${repositoryFilter}'`, {
schema: {
...searchSchema,
strict: true,
},
})
: '',
};
return (
<EuiInMemoryTable
items={snapshots}
itemId="name"
columns={columns}
search={search}
sorting={sorting}
pagination={pagination}
rowProps={() => ({
'data-test-subj': 'srSnapshotListTableRow',
})}
cellProps={(item: any, column: any) => ({
'data-test-subj': `srSnapshotListTableCell-${column.field}`,
})}
/>
);
};

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { SnapshotRestoreHome } from './home';
export { RepositoryAdd } from './repository_add';
export { RepositoryEdit } from './repository_edit';

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { RepositoryAdd } from './repository_add';

View file

@ -0,0 +1,92 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* 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 { RouteComponentProps } from 'react-router-dom';
import { EuiPageBody, EuiPageContent, EuiSpacer, EuiTitle } from '@elastic/eui';
import { Repository, EmptyRepository } from '../../../../common/types';
import { RepositoryForm, SectionError } from '../../components';
import { BASE_PATH, Section } from '../../constants';
import { useAppDependencies } from '../../index';
import { breadcrumbService } from '../../services/navigation';
import { addRepository } from '../../services/http';
export const RepositoryAdd: React.FunctionComponent<RouteComponentProps> = ({ history }) => {
const {
core: {
i18n: { FormattedMessage },
},
} = useAppDependencies();
const section = 'repositories' as Section;
const [isSaving, setIsSaving] = useState<boolean>(false);
const [saveError, setSaveError] = useState<any>(null);
// Set breadcrumb
useEffect(() => {
breadcrumbService.setBreadcrumbs('repositoryAdd');
}, []);
const onSave = async (newRepository: Repository | EmptyRepository) => {
setIsSaving(true);
setSaveError(null);
const { name } = newRepository;
const { error } = await addRepository(newRepository);
setIsSaving(false);
if (error) {
setSaveError(error);
} else {
history.push(`${BASE_PATH}/${section}/${name}`);
}
};
const emptyRepository = {
name: '',
type: null,
settings: {},
};
const renderSaveError = () => {
return saveError ? (
<SectionError
title={
<FormattedMessage
id="xpack.snapshotRestore.addRepository.savingRepositoryErrorTitle"
defaultMessage="Cannot register new repository"
/>
}
error={saveError}
/>
) : null;
};
const clearSaveError = () => {
setSaveError(null);
};
return (
<EuiPageBody>
<EuiPageContent>
<EuiTitle size="l">
<h1>
<FormattedMessage
id="xpack.snapshotRestore.addRepositoryTitle"
defaultMessage="Register repository"
/>
</h1>
</EuiTitle>
<EuiSpacer size="l" />
<RepositoryForm
repository={emptyRepository}
isSaving={isSaving}
saveError={renderSaveError()}
clearSaveError={clearSaveError}
onSave={onSave}
/>
</EuiPageContent>
</EuiPageBody>
);
};

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { RepositoryEdit } from './repository_edit';

View file

@ -0,0 +1,175 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* 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 { RouteComponentProps } from 'react-router-dom';
import { EuiPageBody, EuiPageContent, EuiSpacer, EuiTitle } from '@elastic/eui';
import { Repository, EmptyRepository } from '../../../../common/types';
import { RepositoryForm, SectionError, SectionLoading } from '../../components';
import { BASE_PATH, Section } from '../../constants';
import { useAppDependencies } from '../../index';
import { breadcrumbService } from '../../services/navigation';
import { editRepository, loadRepository } from '../../services/http';
interface MatchParams {
name: string;
}
export const RepositoryEdit: React.FunctionComponent<RouteComponentProps<MatchParams>> = ({
match: {
params: { name },
},
history,
}) => {
const {
core: { i18n },
} = useAppDependencies();
const { FormattedMessage } = i18n;
const section = 'repositories' as Section;
// Set breadcrumb
useEffect(() => {
breadcrumbService.setBreadcrumbs('repositoryEdit');
}, []);
// Repository state with default empty repository
const [repository, setRepository] = useState<Repository | EmptyRepository>({
name: '',
type: null,
settings: {},
});
// Load repository
const {
error: repositoryError,
loading: loadingRepository,
data: repositoryData,
} = loadRepository(name);
// Update repository state when data is loaded
useEffect(
() => {
if (repositoryData && repositoryData.repository) {
setRepository(repositoryData.repository);
}
},
[repositoryData]
);
// Saving repository states
const [isSaving, setIsSaving] = useState<boolean>(false);
const [saveError, setSaveError] = useState<any>(null);
// Save repository
const onSave = async (editedRepository: Repository | EmptyRepository) => {
setIsSaving(true);
setSaveError(null);
const { error } = await editRepository(editedRepository);
setIsSaving(false);
if (error) {
setSaveError(error);
} else {
history.push(`${BASE_PATH}/${section}/${name}`);
}
};
const renderLoading = () => {
return (
<SectionLoading>
<FormattedMessage
id="xpack.snapshotRestore.editRepository.loadingRepositoryDescription"
defaultMessage="Loading repository details…"
/>
</SectionLoading>
);
};
const renderError = () => {
const notFound = repositoryError.status === 404;
const errorObject = notFound
? {
data: {
error: i18n.translate(
'xpack.snapshotRestore.editRepository.repositoryNotFoundErrorMessage',
{
defaultMessage: `The repository '{name}' does not exist.`,
values: {
name,
},
}
),
},
}
: repositoryError;
return (
<SectionError
title={
<FormattedMessage
id="xpack.snapshotRestore.editRepository.loadingRepositoryErrorTitle"
defaultMessage="Error loading repository details"
/>
}
error={errorObject}
/>
);
};
const renderSaveError = () => {
return saveError ? (
<SectionError
title={
<FormattedMessage
id="xpack.snapshotRestore.editRepository.avingRepositoryErrorTitle"
defaultMessage="Cannot save repository"
/>
}
error={saveError}
/>
) : null;
};
const clearSaveError = () => {
setSaveError(null);
};
const renderContent = () => {
if (loadingRepository) {
return renderLoading();
}
if (repositoryError) {
return renderError();
}
return (
<RepositoryForm
repository={repository}
isEditing={true}
isSaving={isSaving}
saveError={renderSaveError()}
clearSaveError={clearSaveError}
onSave={onSave}
/>
);
};
return (
<EuiPageBody>
<EuiPageContent>
<EuiTitle size="l">
<h1>
<FormattedMessage
id="xpack.snapshotRestore.editRepositoryTitle"
defaultMessage="Edit repository"
/>
</h1>
</EuiTitle>
<EuiSpacer size="l" />
{renderContent()}
</EuiPageContent>
</EuiPageBody>
);
};

View file

@ -0,0 +1,49 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { REPOSITORY_TYPES } from '../../../../common/constants';
import { RepositoryType } from '../../../../common/types';
import { REPOSITORY_DOC_PATHS } from '../../constants';
class DocumentationLinksService {
private esDocBasePath: string = '';
private esPluginDocBasePath: string = '';
public init(esDocBasePath: string, esPluginDocBasePath: string): void {
this.esDocBasePath = esDocBasePath;
this.esPluginDocBasePath = esPluginDocBasePath;
}
public getRepositoryPluginDocUrl() {
return `${this.esPluginDocBasePath}${REPOSITORY_DOC_PATHS.plugins}`;
}
public getRepositoryTypeDocUrl(type?: RepositoryType) {
switch (type) {
case REPOSITORY_TYPES.fs:
return `${this.esDocBasePath}${REPOSITORY_DOC_PATHS.fs}`;
case REPOSITORY_TYPES.url:
return `${this.esDocBasePath}${REPOSITORY_DOC_PATHS.url}`;
case REPOSITORY_TYPES.source:
return `${this.esDocBasePath}${REPOSITORY_DOC_PATHS.source}`;
case REPOSITORY_TYPES.s3:
return `${this.esPluginDocBasePath}${REPOSITORY_DOC_PATHS.s3}`;
case REPOSITORY_TYPES.hdfs:
return `${this.esPluginDocBasePath}${REPOSITORY_DOC_PATHS.hdfs}`;
case REPOSITORY_TYPES.azure:
return `${this.esPluginDocBasePath}${REPOSITORY_DOC_PATHS.azure}`;
case REPOSITORY_TYPES.gcs:
return `${this.esPluginDocBasePath}${REPOSITORY_DOC_PATHS.gcs}`;
default:
return `${this.esDocBasePath}${REPOSITORY_DOC_PATHS.default}`;
}
}
public getSnapshotDocUrl() {
return `${this.esDocBasePath}/modules-snapshots.html#_snapshot`;
}
}
export const documentationLinksService = new DocumentationLinksService();

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { documentationLinksService } from './documentation_links';

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { API_BASE_PATH } from '../../../../common/constants';
import { httpService } from './http';
import { useRequest } from './use_request';
export const loadPermissions = () => {
return useRequest({
path: httpService.addBasePath(`${API_BASE_PATH}permissions`),
method: 'get',
});
};

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
class HttpService {
private client: any;
public addBasePath: (path: string) => string = () => '';
public init(httpClient: any, chrome: any): void {
this.client = httpClient;
this.addBasePath = chrome.addBasePath.bind(chrome);
}
get httpClient(): any {
return this.client;
}
}
export const httpService = new HttpService();

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { httpService } from './http';
export * from './app_requests';
export * from './repository_requests';
export * from './snapshot_requests';

View file

@ -0,0 +1,70 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { API_BASE_PATH } from '../../../../common/constants';
import { Repository, EmptyRepository } from '../../../../common/types';
import { MINIMUM_TIMEOUT_MS } from '../../constants';
import { httpService } from './http';
import { sendRequest, useRequest } from './use_request';
export const loadRepositories = () => {
return useRequest({
path: httpService.addBasePath(`${API_BASE_PATH}repositories`),
method: 'get',
initialData: [],
timeout: MINIMUM_TIMEOUT_MS,
});
};
export const loadRepository = (name: Repository['name']) => {
return useRequest({
path: httpService.addBasePath(`${API_BASE_PATH}repositories/${encodeURIComponent(name)}`),
method: 'get',
});
};
export const verifyRepository = (name: Repository['name']) => {
return sendRequest({
path: httpService.addBasePath(
`${API_BASE_PATH}repositories/${encodeURIComponent(name)}/verify`
),
method: 'get',
});
};
export const loadRepositoryTypes = () => {
return useRequest({
path: httpService.addBasePath(`${API_BASE_PATH}repository_types`),
method: 'get',
initialData: [],
});
};
export const addRepository = async (newRepository: Repository | EmptyRepository) => {
return sendRequest({
path: httpService.addBasePath(`${API_BASE_PATH}repositories`),
method: 'put',
body: newRepository,
});
};
export const editRepository = async (editedRepository: Repository | EmptyRepository) => {
return sendRequest({
path: httpService.addBasePath(
`${API_BASE_PATH}repositories/${encodeURIComponent(editedRepository.name)}`
),
method: 'put',
body: editedRepository,
});
};
export const deleteRepositories = async (names: Array<Repository['name']>) => {
return sendRequest({
path: httpService.addBasePath(
`${API_BASE_PATH}repositories/${names.map(name => encodeURIComponent(name)).join(',')}`
),
method: 'delete',
});
};

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { API_BASE_PATH } from '../../../../common/constants';
import { MINIMUM_TIMEOUT_MS } from '../../constants';
import { httpService } from './http';
import { useRequest } from './use_request';
export const loadSnapshots = () =>
useRequest({
path: httpService.addBasePath(`${API_BASE_PATH}snapshots`),
method: 'get',
initialData: [],
timeout: MINIMUM_TIMEOUT_MS,
});
export const loadSnapshot = (repositoryName: string, snapshotId: string) =>
useRequest({
path: httpService.addBasePath(
`${API_BASE_PATH}snapshots/${encodeURIComponent(repositoryName)}/${encodeURIComponent(
snapshotId
)}`
),
method: 'get',
});

View file

@ -0,0 +1,116 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useEffect, useState } from 'react';
import { httpService } from './index';
interface SendRequest {
path: string;
method: string;
body?: any;
}
interface SendRequestResponse {
data: any;
error: Error;
}
export const sendRequest = async ({
path,
method,
body,
}: SendRequest): Promise<Partial<SendRequestResponse>> => {
try {
const response = await httpService.httpClient[method](path, body);
if (!response.data) {
throw new Error(response.statusText);
}
return {
data: response.data,
};
} catch (e) {
return {
error: e,
};
}
};
interface UseRequest extends SendRequest {
interval?: number;
initialData?: any;
timeout?: number;
}
export const useRequest = ({ path, method, body, interval, initialData, timeout }: UseRequest) => {
const [error, setError] = useState<null | any>(null);
const [loading, setLoading] = useState<boolean>(true);
const [data, setData] = useState<any>(initialData);
// Tied to every render and bound to each request.
let isOutdatedRequest = false;
const request = async () => {
setError(null);
setData(initialData);
setLoading(true);
const requestBody = {
path,
method,
body,
};
let response;
if (timeout) {
[response] = await Promise.all([
sendRequest(requestBody),
new Promise(resolve => setTimeout(resolve, timeout)),
]);
} else {
response = await sendRequest(requestBody);
}
// Don't update state if an outdated request has resolved.
if (isOutdatedRequest) {
return;
}
setError(response.error);
setData(response.data);
setLoading(false);
};
useEffect(
() => {
function cancelOutdatedRequest() {
isOutdatedRequest = true;
}
request();
if (interval) {
const intervalRequest = setInterval(request, interval);
return () => {
cancelOutdatedRequest();
clearInterval(intervalRequest);
};
}
// Called when a new render will trigger this effect.
return cancelOutdatedRequest;
},
[path]
);
return {
error,
loading,
data,
request,
};
};

View file

@ -0,0 +1,51 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { BASE_PATH } from '../../constants';
import { textService } from '../text';
class BreadcrumbService {
private chrome: any;
private breadcrumbs: any = {
management: {},
home: {},
repositoryAdd: {},
repositoryEdit: {},
};
public init(chrome: any, managementBreadcrumb: any): void {
this.chrome = chrome;
this.breadcrumbs.management = managementBreadcrumb;
this.breadcrumbs.home = {
text: textService.breadcrumbs.home,
href: `#${BASE_PATH}`,
};
this.breadcrumbs.repositoryAdd = {
text: textService.breadcrumbs.repositoryAdd,
href: `#${BASE_PATH}/add_repository`,
};
this.breadcrumbs.repositoryEdit = {
text: textService.breadcrumbs.repositoryEdit,
};
}
public setBreadcrumbs(type: string): void {
if (!this.breadcrumbs[type]) {
return;
}
if (type === 'home') {
this.chrome.breadcrumbs.set([this.breadcrumbs.management, this.breadcrumbs.home]);
} else {
this.chrome.breadcrumbs.set([
this.breadcrumbs.management,
this.breadcrumbs.home,
this.breadcrumbs[type],
]);
}
}
}
export const breadcrumbService = new BreadcrumbService();

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { breadcrumbService } from './breadcrumb';
export { linkToRepository, linkToRepositories, linkToSnapshots } from './links';

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { BASE_PATH } from '../../constants';
export function linkToRepositories() {
return `#${BASE_PATH}/repositories`;
}
export function linkToRepository(repositoryName: string) {
return `#${BASE_PATH}/repositories/${encodeURIComponent(repositoryName)}`;
}
export function linkToSnapshots(repositoryName?: string) {
if (repositoryName) {
return `#${BASE_PATH}/snapshots?repository=${repositoryName}`;
}
return `#${BASE_PATH}/snapshots`;
}

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { createContext, useContext } from 'react';
import { AppState } from '../../types';
const StateContext = createContext<AppState>({});
export const initialState = {};
export const reducer = (state: any, action: { type: string }) => {
switch (action.type) {
default:
return state;
}
};
export const AppStateProvider = StateContext.Provider;
export const useAppState = () => useContext<AppState>(StateContext);

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { initialState, reducer, AppStateProvider, useAppState } from './app_state';

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { dateFormatAliases } from '@elastic/eui/lib/services/format';
import moment from 'moment';
export function formatDate(epochMs: number): string {
return moment(Number(epochMs)).format(dateFormatAliases.longDateTime);
}

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { formatDate } from './format_date';
export { textService } from './text';

View file

@ -0,0 +1,96 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { REPOSITORY_TYPES } from '../../../../common/constants';
class TextService {
public breadcrumbs: { [key: string]: string } = {};
public i18n: any;
private repositoryTypeNames: { [key: string]: string } = {};
public init(i18n: any): void {
this.i18n = i18n;
this.repositoryTypeNames = {
[REPOSITORY_TYPES.fs]: i18n.translate(
'xpack.snapshotRestore.repositoryType.fileSystemTypeName',
{
defaultMessage: 'Shared file system',
}
),
[REPOSITORY_TYPES.url]: i18n.translate(
'xpack.snapshotRestore.repositoryType.readonlyTypeName',
{
defaultMessage: 'Read-only URL',
}
),
[REPOSITORY_TYPES.s3]: i18n.translate('xpack.snapshotRestore.repositoryType.s3TypeName', {
defaultMessage: 'AWS S3',
}),
[REPOSITORY_TYPES.hdfs]: i18n.translate('xpack.snapshotRestore.repositoryType.hdfsTypeName', {
defaultMessage: 'Hadoop HDFS',
}),
[REPOSITORY_TYPES.azure]: i18n.translate(
'xpack.snapshotRestore.repositoryType.azureTypeName',
{
defaultMessage: 'Azure',
}
),
[REPOSITORY_TYPES.gcs]: i18n.translate('xpack.snapshotRestore.repositoryType.gcsTypeName', {
defaultMessage: 'Google Cloud Storage',
}),
[REPOSITORY_TYPES.source]: i18n.translate(
'xpack.snapshotRestore.repositoryType.sourceTypeName',
{
defaultMessage: 'Source-only',
}
),
};
this.breadcrumbs = {
home: i18n.translate('xpack.snapshotRestore.home.breadcrumbTitle', {
defaultMessage: 'Snapshot Repositories',
}),
repositoryAdd: i18n.translate('xpack.snapshotRestore.addRepository.breadcrumbTitle', {
defaultMessage: 'Add repository',
}),
repositoryEdit: i18n.translate('xpack.snapshotRestore.editRepository.breadcrumbTitle', {
defaultMessage: 'Edit repository',
}),
};
}
public getRepositoryTypeName(type: string, delegateType?: string) {
const getTypeName = (repositoryType: string): string => {
return this.repositoryTypeNames[repositoryType] || type || '';
};
if (type === REPOSITORY_TYPES.source && delegateType) {
return this.i18n.translate(
'xpack.snapshotRestore.repositoryType.sourceTypeWithDelegateName',
{
defaultMessage: '{delegateType} (Source-only)',
values: {
delegateType: getTypeName(delegateType),
},
}
);
}
return getTypeName(type);
}
public getSizeNotationHelpText() {
return this.i18n.translate('xpack.snapshotRestore.repositoryForm.sizeNotationPlaceholder', {
defaultMessage: 'Examples: {example1}, {example2}, {example3}, {example4}',
values: {
example1: '1g',
example2: '10mb',
example3: '5k',
example4: '1024B',
},
});
}
}
export const textService = new TextService();

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export {
RepositoryValidation,
RepositorySettingsValidation,
validateRepository,
} from './validate_repository';

View file

@ -0,0 +1,189 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { textService } from '../text';
import {
Repository,
RepositoryType,
FSRepository,
ReadonlyRepository,
S3Repository,
GCSRepository,
HDFSRepository,
EmptyRepository,
} from '../../../../common/types';
import { REPOSITORY_TYPES } from '../../../../common/constants';
export interface RepositoryValidation {
isValid: boolean;
errors: {
name?: string[];
type?: string[];
settings?: RepositorySettingsValidation;
};
}
export interface RepositorySettingsValidation {
[key: string]: string[];
}
export const validateRepository = (
repository: Repository | EmptyRepository,
validateSettings: boolean = true
): RepositoryValidation => {
const { name, type, settings } = repository;
const { i18n } = textService;
const validation: RepositoryValidation = {
isValid: true,
errors: {},
};
if (validateSettings) {
const settingsValidation = validateRepositorySettings(type, settings);
if (Object.keys(settingsValidation).length) {
validation.errors.settings = settingsValidation;
}
}
if (isStringEmpty(name)) {
validation.errors.name = [
i18n.translate('xpack.snapshotRestore.repositoryValidation.nameRequired', {
defaultMessage: 'Repository name is required.',
}),
];
}
if (
isStringEmpty(type) ||
(type === REPOSITORY_TYPES.source && isStringEmpty(settings.delegateType))
) {
validation.errors.type = [
i18n.translate('xpack.snapshotRestore.repositoryValidation.delegateTypeRequired', {
defaultMessage: 'Type is required.',
}),
];
}
if (Object.keys(validation.errors).length) {
validation.isValid = false;
}
return validation;
};
const isStringEmpty = (str: string | null): boolean => {
return str ? !Boolean(str.trim()) : true;
};
const validateRepositorySettings = (
type: RepositoryType | null,
settings: Repository['settings']
): RepositorySettingsValidation => {
switch (type) {
case REPOSITORY_TYPES.fs:
return validateFSRepositorySettings(settings);
case REPOSITORY_TYPES.url:
return validateReadonlyRepositorySettings(settings);
case REPOSITORY_TYPES.source:
return validateRepositorySettings(settings.delegateType, settings);
case REPOSITORY_TYPES.s3:
return validateS3RepositorySettings(settings);
case REPOSITORY_TYPES.gcs:
return validateGCSRepositorySettings(settings);
case REPOSITORY_TYPES.hdfs:
return validateHDFSRepositorySettings(settings);
// No validation on settings needed for azure (all settings are optional)
default:
return {};
}
};
const validateFSRepositorySettings = (
settings: FSRepository['settings']
): RepositorySettingsValidation => {
const i18n = textService.i18n;
const validation: RepositorySettingsValidation = {};
const { location } = settings;
if (isStringEmpty(location)) {
validation.location = [
i18n.translate('xpack.snapshotRestore.repositoryValidation.locationRequired', {
defaultMessage: 'Location is required.',
}),
];
}
return validation;
};
const validateReadonlyRepositorySettings = (
settings: ReadonlyRepository['settings']
): RepositorySettingsValidation => {
const i18n = textService.i18n;
const validation: RepositorySettingsValidation = {};
const { url } = settings;
if (isStringEmpty(url)) {
validation.url = [
i18n.translate('xpack.snapshotRestore.repositoryValidation.urlRequired', {
defaultMessage: 'URL is required.',
}),
];
}
return validation;
};
const validateS3RepositorySettings = (
settings: S3Repository['settings']
): RepositorySettingsValidation => {
const i18n = textService.i18n;
const validation: RepositorySettingsValidation = {};
const { bucket } = settings;
if (isStringEmpty(bucket)) {
validation.bucket = [
i18n.translate('xpack.snapshotRestore.repositoryValidation.bucketRequired', {
defaultMessage: 'Bucket is required.',
}),
];
}
return validation;
};
const validateGCSRepositorySettings = (
settings: GCSRepository['settings']
): RepositorySettingsValidation => {
const i18n = textService.i18n;
const validation: RepositorySettingsValidation = {};
const { bucket } = settings;
if (isStringEmpty(bucket)) {
validation.bucket = [
i18n.translate('xpack.snapshotRestore.repositoryValidation.bucketRequired', {
defaultMessage: 'Bucket is required.',
}),
];
}
return validation;
};
const validateHDFSRepositorySettings = (
settings: HDFSRepository['settings']
): RepositorySettingsValidation => {
const i18n = textService.i18n;
const validation: RepositorySettingsValidation = {};
const { uri, path } = settings;
if (isStringEmpty(uri)) {
validation.uri = [
i18n.translate('xpack.snapshotRestore.repositoryValidation.uriRequired', {
defaultMessage: 'URI is required.',
}),
];
}
if (isStringEmpty(path)) {
validation.path = [
i18n.translate('xpack.snapshotRestore.repositoryValidation.pathRequired', {
defaultMessage: 'Path is required.',
}),
];
}
return validation;
};

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { AppCore, AppPlugins } from '../../shim';
export { AppCore, AppPlugins } from '../../shim';
export interface AppDependencies {
core: AppCore;
plugins: AppPlugins;
}
export interface AppState {
[key: string]: any;
}

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export * from './app';

View file

@ -0,0 +1,3 @@
<kbn-management-app section="elasticsearch/snapshot_restore">
<div id="snapshotRestoreReactRoot"></div>
</kbn-management-app>

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Plugin as SnapshotRestorePlugin } from './plugin';
import { createShim } from './shim';
const { core, plugins } = createShim();
const snapshotRestorePlugin = new SnapshotRestorePlugin();
snapshotRestorePlugin.start(core, plugins);

View file

@ -0,0 +1,94 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { unmountComponentAtNode } from 'react-dom';
import { PLUGIN } from '../common/constants';
import { CLIENT_BASE_PATH, renderReact } from './app';
import { AppCore, AppPlugins } from './app/types';
import template from './index.html';
import { Core, Plugins } from './shim';
import { breadcrumbService } from './app/services/navigation';
import { documentationLinksService } from './app/services/documentation';
import { httpService } from './app/services/http';
import { textService } from './app/services/text';
const REACT_ROOT_ID = 'snapshotRestoreReactRoot';
export class Plugin {
public start(core: Core, plugins: Plugins): void {
const { i18n, routing, http, chrome, notification, documentation } = core;
const { management } = plugins;
// Register management section
const esSection = management.sections.getSection('elasticsearch');
esSection.register(PLUGIN.ID, {
visible: true,
display: i18n.translate('xpack.snapshotRestore.appName', {
defaultMessage: 'Snapshot Repositories',
}),
order: 7,
url: `#${CLIENT_BASE_PATH}`,
});
// Initialize services
textService.init(i18n);
breadcrumbService.init(chrome, management.constants.BREADCRUMB);
documentationLinksService.init(documentation.esDocBasePath, documentation.esPluginDocBasePath);
const unmountReactApp = (): void => {
const elem = document.getElementById(REACT_ROOT_ID);
if (elem) {
unmountComponentAtNode(elem);
}
};
// Register react root
routing.registerAngularRoute(`${CLIENT_BASE_PATH}/:section?/:subsection?/:view?/:id?`, {
template,
controllerAs: 'snapshotRestoreController',
controller: ($scope: any, $route: any, $http: ng.IHttpService, $q: any) => {
// NOTE: We depend upon Angular's $http service because it's decorated with interceptors,
// e.g. to check license status per request.
http.setClient($http);
httpService.init(http.getClient(), chrome);
// Angular Lifecycle
const appRoute = $route.current;
const stopListeningForLocationChange = $scope.$on('$locationChangeSuccess', () => {
const currentRoute = $route.current;
const isNavigationInApp = currentRoute.$$route.template === appRoute.$$route.template;
// When we navigate within SR, prevent Angular from re-matching the route and rebuild the app
if (isNavigationInApp) {
$route.current = appRoute;
} else {
// Any clean up when user leaves SR
}
$scope.$on('$destroy', () => {
if (stopListeningForLocationChange) {
stopListeningForLocationChange();
}
unmountReactApp();
});
});
$scope.$$postDigest(() => {
unmountReactApp();
const elem = document.getElementById(REACT_ROOT_ID);
if (elem) {
renderReact(
elem,
{ i18n, notification } as AppCore,
{ management: { sections: management.sections } } as AppPlugins
);
}
});
},
});
}
}

View file

@ -0,0 +1,112 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { I18nContext } from 'ui/i18n';
import chrome from 'ui/chrome';
import { DOC_LINK_VERSION, ELASTIC_WEBSITE_URL } from 'ui/documentation_links';
import { management, MANAGEMENT_BREADCRUMB } from 'ui/management';
import { fatalError, toastNotifications } from 'ui/notify';
import routes from 'ui/routes';
import { HashRouter } from 'react-router-dom';
export interface AppCore {
i18n: {
[i18nPackage: string]: any;
Context: typeof I18nContext;
FormattedMessage: typeof FormattedMessage;
};
notification: {
fatalError: typeof fatalError;
toastNotifications: typeof toastNotifications;
};
}
export interface AppPlugins {
management: {
sections: typeof management;
};
}
export interface Core extends AppCore {
chrome: typeof chrome;
http: {
getClient(): any;
setClient(client: any): void;
};
routing: {
registerAngularRoute(path: string, config: object): void;
registerRouter(router: HashRouter): void;
getRouter(): HashRouter | undefined;
};
documentation: {
esDocBasePath: string;
esPluginDocBasePath: string;
};
}
export interface Plugins extends AppPlugins {
management: {
sections: typeof management;
constants: {
BREADCRUMB: typeof MANAGEMENT_BREADCRUMB;
};
};
}
export function createShim(): { core: Core; plugins: Plugins } {
// This is an Angular service, which is why we use this provider pattern
// to access it within our React app.
let httpClient: ng.IHttpService;
let reactRouter: HashRouter | undefined;
return {
core: {
i18n: {
...i18n,
Context: I18nContext,
FormattedMessage,
},
routing: {
registerAngularRoute: (path: string, config: object): void => {
routes.when(path, config);
},
registerRouter: (router: HashRouter): void => {
reactRouter = router;
},
getRouter: (): HashRouter | undefined => {
return reactRouter;
},
},
http: {
setClient: (client: any): void => {
httpClient = client;
},
getClient: (): any => httpClient,
},
chrome,
notification: {
fatalError,
toastNotifications,
},
documentation: {
esDocBasePath: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/`,
esPluginDocBasePath: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/plugins/${DOC_LINK_VERSION}/`,
},
},
plugins: {
management: {
sections: management,
constants: {
BREADCRUMB: MANAGEMENT_BREADCRUMB,
},
},
},
};
}

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Repository } from '../../common/types';
/**
* Utility to remove empty fields ("") from repository settings
*/
export const cleanSettings = (settings: Repository['settings']) =>
Object.entries(settings).reduce((acc: Repository['settings'], [key, value]) => {
if (value !== '') {
acc[key] = value;
}
return acc;
}, {});

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export {
deserializeRepositorySettings,
serializeRepositorySettings,
} from './repository_serialization';
export { deserializeSnapshotDetails } from './snapshot_serialization';
export { cleanSettings } from './clean_settings';

View file

@ -0,0 +1,63 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
deserializeRepositorySettings,
serializeRepositorySettings,
} from './repository_serialization';
describe('repository_serialization', () => {
describe('deserializeRepositorySettings()', () => {
it('should deserialize repository settings', () => {
expect(
deserializeRepositorySettings({
uri: 'test://uri',
path: '/foo/bar',
load_defaults: true,
compress: false,
chunk_size: null,
security: {
principal: 'fooBar',
},
conf: {
some_setting: 'test',
},
})
).toEqual({
uri: 'test://uri',
path: '/foo/bar',
loadDefaults: true,
compress: false,
chunkSize: null,
'security.principal': 'fooBar',
'conf.some_setting': 'test',
});
});
});
describe('serializeRepositorySettings()', () => {
it('should serialize repository settings', () => {
expect(
serializeRepositorySettings({
uri: 'test://uri',
path: '/foo/bar',
loadDefaults: true,
compress: false,
chunkSize: null,
'security.principal': 'fooBar',
'conf.some_setting': 'test',
})
).toEqual({
uri: 'test://uri',
path: '/foo/bar',
load_defaults: true,
compress: false,
chunk_size: null,
'security.principal': 'fooBar',
'conf.some_setting': 'test',
});
});
});
});

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { camelCase, snakeCase } from 'lodash';
import { flatten } from '../../common/lib';
import { cleanSettings } from './clean_settings';
interface RepositorySettings {
[key: string]: any;
}
const booleanizeValue = (value: any) => {
if (value === 'true') {
return true;
} else if (value === 'false') {
return false;
}
return value;
};
export const deserializeRepositorySettings = (settings: RepositorySettings): RepositorySettings => {
// HDFS repositories return settings like:
// `{ security: { principal: 'some_value'}, conf: { foo: { bar: 'another_value' }}}`
// Flattening such settings makes it easier to consume in the UI, for both viewing and updating
const flattenedSettings: RepositorySettings = flatten(settings);
const deserializedSettings: RepositorySettings = {};
Object.entries(flattenedSettings).forEach(([key, value]) => {
// Avoid camel casing keys that are the result of being flattened, such as `security.principal` and `conf.*`
if (key.includes('.')) {
deserializedSettings[key] = booleanizeValue(value);
} else {
deserializedSettings[camelCase(key)] = booleanizeValue(value);
}
});
return deserializedSettings;
};
export const serializeRepositorySettings = (settings: RepositorySettings): RepositorySettings => {
const serializedSettings: RepositorySettings = {};
Object.entries(settings).forEach(([key, value]) => {
// Avoid snake casing keys that are the result of being flattened, such as `security.principal` and `conf.*`
if (key.includes('.')) {
serializedSettings[key] = value;
} else {
serializedSettings[snakeCase(key)] = value;
}
});
return cleanSettings(serializedSettings);
};

View file

@ -0,0 +1,97 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { deserializeSnapshotDetails } from './snapshot_serialization';
describe('deserializeSnapshotDetails', () => {
test('deserializes a snapshot', () => {
expect(
deserializeSnapshotDetails('repositoryName', {
snapshot: 'snapshot name',
uuid: 'UUID',
version_id: 5,
version: 'version',
indices: ['index2', 'index3', 'index1'],
include_global_state: false,
state: 'SUCCESS',
start_time: '0',
start_time_in_millis: 0,
end_time: '1',
end_time_in_millis: 1,
duration_in_millis: 1,
shards: {
total: 3,
failed: 1,
successful: 2,
},
failures: [
{
index: 'z',
shard: 1,
},
{
index: 'a',
shard: 3,
},
{
index: 'a',
shard: 1,
},
{
index: 'a',
shard: 2,
},
],
})
).toEqual({
repository: 'repositoryName',
snapshot: 'snapshot name',
uuid: 'UUID',
versionId: 5,
version: 'version',
// Indices are sorted.
indices: ['index1', 'index2', 'index3'],
// Converted from a boolean into 0 or 1.
includeGlobalState: 0,
// Failures are grouped and sorted by index, and the failures themselves are sorted by shard.
indexFailures: [
{
index: 'a',
failures: [
{
shard: 1,
},
{
shard: 2,
},
{
shard: 3,
},
],
},
{
index: 'z',
failures: [
{
shard: 1,
},
],
},
],
state: 'SUCCESS',
startTime: '0',
startTimeInMillis: 0,
endTime: '1',
endTimeInMillis: 1,
durationInMillis: 1,
shards: {
total: 3,
failed: 1,
successful: 2,
},
});
});
});

View file

@ -0,0 +1,79 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { sortBy } from 'lodash';
import { SnapshotDetails } from '../../common/types';
import { SnapshotDetailsEs } from '../types';
export function deserializeSnapshotDetails(
repository: string,
snapshotDetailsEs: SnapshotDetailsEs
): SnapshotDetails {
if (!snapshotDetailsEs || typeof snapshotDetailsEs !== 'object') {
throw new Error('Unable to deserialize snapshot details');
}
const {
snapshot,
uuid,
version_id: versionId,
version,
indices = [],
include_global_state: includeGlobalState,
state,
start_time: startTime,
start_time_in_millis: startTimeInMillis,
end_time: endTime,
end_time_in_millis: endTimeInMillis,
duration_in_millis: durationInMillis,
failures = [],
shards,
} = snapshotDetailsEs;
// If an index has multiple failures, we'll want to see them grouped together.
const indexToFailuresMap = failures.reduce((map, failure) => {
const { index, ...rest } = failure;
if (!map[index]) {
map[index] = {
index,
failures: [],
};
}
map[index].failures.push(rest);
return map;
}, {});
// Sort all failures by their shard.
Object.keys(indexToFailuresMap).forEach(index => {
indexToFailuresMap[index].failures = sortBy(
indexToFailuresMap[index].failures,
({ shard }) => shard
);
});
// Sort by index name.
const indexFailures = sortBy(Object.values(indexToFailuresMap), ({ index }) => index);
return {
repository,
snapshot,
uuid,
versionId,
version,
indices: [...indices].sort(),
includeGlobalState: Boolean(includeGlobalState) ? 1 : 0,
state,
startTime,
startTimeInMillis,
endTime,
endTimeInMillis,
durationInMillis,
indexFailures,
shards,
};
}

View file

@ -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;
* you may not use this file except in compliance with the Elastic License.
*/
import { Router, RouterRouteHandler } from '../../../../../server/lib/create_router';
import { wrapCustomError } from '../../../../../server/lib/create_router/error_wrappers';
import { APP_PERMISSIONS } from '../../../common/constants';
import { Plugins } from '../../../shim';
let xpackMainPlugin: any;
export function registerAppRoutes(router: Router, plugins: Plugins) {
xpackMainPlugin = plugins.xpack_main;
router.get('permissions', getPermissionsHandler);
}
export function getXpackMainPlugin() {
return xpackMainPlugin;
}
export const getPermissionsHandler: RouterRouteHandler = async (req, callWithRequest) => {
const xpackInfo = getXpackMainPlugin() && getXpackMainPlugin().info;
if (!xpackInfo) {
// xpackInfo is updated via poll, so it may not be available until polling has begun.
// In this rare situation, tell the client the service is temporarily unavailable.
throw wrapCustomError(new Error('Security info unavailable'), 503);
}
const securityInfo = xpackInfo && xpackInfo.isAvailable() && xpackInfo.feature('security');
if (!securityInfo || !securityInfo.isAvailable() || !securityInfo.isEnabled()) {
// If security isn't enabled, let the user use app.
return {
hasPermission: true,
missingClusterPrivileges: [],
};
}
const { has_all_requested: hasPermission, cluster } = await callWithRequest('transport.request', {
path: '/_security/user/_has_privileges',
method: 'POST',
body: {
cluster: APP_PERMISSIONS,
},
});
const missingClusterPrivileges = Object.keys(cluster).reduce(
(permissions: string[], permissionName: string): string[] => {
if (!cluster[permissionName]) {
permissions.push(permissionName);
}
return permissions;
},
[]
);
return {
hasPermission,
missingClusterPrivileges,
};
};

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Router } from '../../../../../server/lib/create_router';
import { Plugins } from '../../../shim';
import { registerAppRoutes } from './app';
import { registerRepositoriesRoutes } from './repositories';
import { registerSnapshotsRoutes } from './snapshots';
export const registerRoutes = (router: Router, plugins: Plugins): void => {
registerAppRoutes(router, plugins);
registerRepositoriesRoutes(router, plugins);
registerSnapshotsRoutes(router);
};

View file

@ -0,0 +1,360 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Request, ResponseToolkit } from 'hapi';
import { DEFAULT_REPOSITORY_TYPES, REPOSITORY_PLUGINS_MAP } from '../../../common/constants';
import {
createHandler,
deleteHandler,
getAllHandler,
getOneHandler,
getTypesHandler,
getVerificationHandler,
updateHandler,
} from './repositories';
describe('[Snapshot and Restore API Routes] Repositories', () => {
const mockRequest = {} as Request;
const mockResponseToolkit = {} as ResponseToolkit;
describe('getAllHandler()', () => {
it('should arrify repositories returned from ES', async () => {
const mockEsResponse = {
fooRepository: {},
barRepository: {},
};
const callWithRequest = jest.fn().mockReturnValueOnce(mockEsResponse);
const expectedResponse = {
repositories: [
{
name: 'fooRepository',
type: '',
settings: {},
},
{
name: 'barRepository',
type: '',
settings: {},
},
],
};
await expect(
getAllHandler(mockRequest, callWithRequest, mockResponseToolkit)
).resolves.toEqual(expectedResponse);
});
it('should return empty array if no repositories returned from ES', async () => {
const mockEsResponse = {};
const callWithRequest = jest.fn().mockReturnValueOnce(mockEsResponse);
const expectedResponse = {
repositories: [],
};
await expect(
getAllHandler(mockRequest, callWithRequest, mockResponseToolkit)
).resolves.toEqual(expectedResponse);
});
it('should throw if ES error', async () => {
const callWithRequest = jest.fn().mockRejectedValueOnce(new Error());
await expect(
getAllHandler(mockRequest, callWithRequest, mockResponseToolkit)
).rejects.toThrow();
});
});
describe('getOneHandler()', () => {
const name = 'fooRepository';
const mockOneRequest = ({
params: {
name,
},
} as unknown) as Request;
it('should return repository object if returned from ES', async () => {
const mockEsResponse = {
[name]: { type: '', settings: {} },
};
const callWithRequest = jest
.fn()
.mockReturnValueOnce(mockEsResponse)
.mockResolvedValueOnce({});
const expectedResponse = {
repository: { name, ...mockEsResponse[name] },
snapshots: { count: null },
};
await expect(
getOneHandler(mockOneRequest, callWithRequest, mockResponseToolkit)
).resolves.toEqual(expectedResponse);
});
it('should return empty repository object if not returned from ES', async () => {
const mockEsResponse = {};
const callWithRequest = jest
.fn()
.mockReturnValueOnce(mockEsResponse)
.mockResolvedValueOnce({});
const expectedResponse = {
repository: {},
snapshots: {},
};
await expect(
getOneHandler(mockOneRequest, callWithRequest, mockResponseToolkit)
).resolves.toEqual(expectedResponse);
});
it('should return snapshot count from ES', async () => {
const mockEsResponse = {
[name]: { type: '', settings: {} },
};
const mockEsSnapshotResponse = {
snapshots: [{}, {}],
};
const callWithRequest = jest
.fn()
.mockReturnValueOnce(mockEsResponse)
.mockResolvedValueOnce(mockEsSnapshotResponse);
const expectedResponse = {
repository: { name, ...mockEsResponse[name] },
snapshots: {
count: 2,
},
};
await expect(
getOneHandler(mockOneRequest, callWithRequest, mockResponseToolkit)
).resolves.toEqual(expectedResponse);
});
it('should return null snapshot count if ES error', async () => {
const mockEsResponse = {
[name]: { type: '', settings: {} },
};
const mockEsSnapshotError = new Error('snapshot error');
const callWithRequest = jest
.fn()
.mockReturnValueOnce(mockEsResponse)
.mockRejectedValueOnce(mockEsSnapshotError);
const expectedResponse = {
repository: { name, ...mockEsResponse[name] },
snapshots: {
count: null,
},
};
await expect(
getOneHandler(mockOneRequest, callWithRequest, mockResponseToolkit)
).resolves.toEqual(expectedResponse);
});
it('should throw if ES error', async () => {
const callWithRequest = jest.fn().mockRejectedValueOnce(new Error());
await expect(
getOneHandler(mockOneRequest, callWithRequest, mockResponseToolkit)
).rejects.toThrow();
});
});
describe('getVerificationHandler', () => {
const name = 'fooRepository';
const mockVerificationRequest = ({
params: {
name,
},
} as unknown) as Request;
it('should return repository verification response if returned from ES', async () => {
const mockEsResponse = { nodes: {} };
const callWithRequest = jest.fn().mockResolvedValueOnce(mockEsResponse);
const expectedResponse = {
verification: { valid: true, response: mockEsResponse },
};
await expect(
getVerificationHandler(mockVerificationRequest, callWithRequest, mockResponseToolkit)
).resolves.toEqual(expectedResponse);
});
it('should return repository verification error if returned from ES', async () => {
const mockEsResponse = { error: {}, status: 500 };
const callWithRequest = jest.fn().mockRejectedValueOnce(mockEsResponse);
const expectedResponse = {
verification: { valid: false, error: mockEsResponse },
};
await expect(
getVerificationHandler(mockVerificationRequest, callWithRequest, mockResponseToolkit)
).resolves.toEqual(expectedResponse);
});
});
describe('getTypesHandler()', () => {
it('should return default types if no repository plugins returned from ES', async () => {
const mockEsResponse = {};
const callWithRequest = jest.fn().mockReturnValue(mockEsResponse);
const expectedResponse = [...DEFAULT_REPOSITORY_TYPES];
await expect(
getTypesHandler(mockRequest, callWithRequest, mockResponseToolkit)
).resolves.toEqual(expectedResponse);
});
it('should return default types with any repository plugins returned from ES', async () => {
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 expectedResponse = [...DEFAULT_REPOSITORY_TYPES, ...pluginTypes];
await expect(
getTypesHandler(mockRequest, callWithRequest, mockResponseToolkit)
).resolves.toEqual(expectedResponse);
});
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 expectedResponse = [...DEFAULT_REPOSITORY_TYPES];
await expect(
getTypesHandler(mockRequest, callWithRequest, mockResponseToolkit)
).resolves.toEqual(expectedResponse);
});
it('should throw if ES error', async () => {
const callWithRequest = jest.fn().mockRejectedValueOnce(new Error());
await expect(
getOneHandler(mockRequest, callWithRequest, mockResponseToolkit)
).rejects.toThrow();
});
});
describe('createHandler()', () => {
const name = 'fooRepository';
const mockCreateRequest = ({
payload: {
name,
},
} as unknown) as Request;
it('should return successful ES response', async () => {
const mockEsResponse = { acknowledged: true };
const callWithRequest = jest
.fn()
.mockReturnValueOnce({})
.mockReturnValueOnce(mockEsResponse);
const expectedResponse = { ...mockEsResponse };
await expect(
createHandler(mockCreateRequest, callWithRequest, mockResponseToolkit)
).resolves.toEqual(expectedResponse);
});
it('should return error if repository with the same name already exists', async () => {
const mockEsResponse = { [name]: {} };
const callWithRequest = jest.fn().mockReturnValue(mockEsResponse);
await expect(
createHandler(mockCreateRequest, callWithRequest, mockResponseToolkit)
).rejects.toThrow();
});
it('should throw if ES error', async () => {
const callWithRequest = jest
.fn()
.mockReturnValueOnce({})
.mockRejectedValueOnce(new Error());
await expect(
createHandler(mockCreateRequest, callWithRequest, mockResponseToolkit)
).rejects.toThrow();
});
});
describe('updateHandler()', () => {
const name = 'fooRepository';
const mockCreateRequest = ({
params: {
name,
},
payload: {
name,
},
} as unknown) as Request;
it('should return successful ES response', async () => {
const mockEsResponse = { acknowledged: true };
const callWithRequest = jest
.fn()
.mockReturnValueOnce({ [name]: {} })
.mockReturnValueOnce(mockEsResponse);
const expectedResponse = { ...mockEsResponse };
await expect(
updateHandler(mockCreateRequest, callWithRequest, mockResponseToolkit)
).resolves.toEqual(expectedResponse);
});
it('should throw if ES error', async () => {
const callWithRequest = jest.fn().mockRejectedValueOnce(new Error());
await expect(
updateHandler(mockCreateRequest, callWithRequest, mockResponseToolkit)
).rejects.toThrow();
});
});
describe('deleteHandler()', () => {
const names = ['fooRepository', 'barRepository'];
const mockCreateRequest = ({
params: {
names: names.join(','),
},
} as unknown) as Request;
it('should return successful ES responses', async () => {
const mockEsResponse = { acknowledged: true };
const callWithRequest = jest
.fn()
.mockResolvedValueOnce(mockEsResponse)
.mockResolvedValueOnce(mockEsResponse);
const expectedResponse = { itemsDeleted: names, errors: [] };
await expect(
deleteHandler(mockCreateRequest, callWithRequest, mockResponseToolkit)
).resolves.toEqual(expectedResponse);
});
it('should return error ES responses', async () => {
const mockEsError = new Error('Test error') as any;
mockEsError.response = '{}';
mockEsError.statusCode = 500;
const callWithRequest = jest
.fn()
.mockRejectedValueOnce(mockEsError)
.mockRejectedValueOnce(mockEsError);
const expectedResponse = {
itemsDeleted: [],
errors: names.map(name => ({
name,
error: mockEsError,
})),
};
await expect(
deleteHandler(mockCreateRequest, callWithRequest, mockResponseToolkit)
).resolves.toEqual(expectedResponse);
});
it('should return combination of ES successes and errors', async () => {
const mockEsError = new Error('Test error') as any;
mockEsError.response = '{}';
mockEsError.statusCode = 500;
const mockEsResponse = { acknowledged: true };
const callWithRequest = jest
.fn()
.mockRejectedValueOnce(mockEsError)
.mockResolvedValueOnce(mockEsResponse);
const expectedResponse = {
itemsDeleted: [names[1]],
errors: [
{
name: names[0],
error: mockEsError,
},
],
};
await expect(
deleteHandler(mockCreateRequest, callWithRequest, mockResponseToolkit)
).resolves.toEqual(expectedResponse);
});
});
});

View file

@ -0,0 +1,198 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Router, RouterRouteHandler } from '../../../../../server/lib/create_router';
import {
wrapCustomError,
wrapEsError,
} from '../../../../../server/lib/create_router/error_wrappers';
import { DEFAULT_REPOSITORY_TYPES, REPOSITORY_PLUGINS_MAP } from '../../../common/constants';
import { Repository, RepositoryType, RepositoryVerification } from '../../../common/types';
import { Plugins } from '../../../shim';
import { deserializeRepositorySettings, serializeRepositorySettings } from '../../lib';
let isCloudEnabled = false;
export function registerRepositoriesRoutes(router: Router, plugins: Plugins) {
isCloudEnabled = plugins.cloud.config.isCloudEnabled;
router.get('repository_types', getTypesHandler);
router.get('repositories', getAllHandler);
router.get('repositories/{name}', getOneHandler);
router.get('repositories/{name}/verify', getVerificationHandler);
router.put('repositories', createHandler);
router.put('repositories/{name}', updateHandler);
router.delete('repositories/{names}', deleteHandler);
}
export const getAllHandler: RouterRouteHandler = async (
req,
callWithRequest
): Promise<{
repositories: Repository[];
}> => {
const repositoriesByName = await callWithRequest('snapshot.getRepository', {
repository: '_all',
});
const repositoryNames = Object.keys(repositoriesByName);
const repositories: Repository[] = repositoryNames.map(name => {
const { type = '', settings = {} } = repositoriesByName[name];
return {
name,
type,
settings: deserializeRepositorySettings(settings),
};
});
return { repositories };
};
export const getOneHandler: RouterRouteHandler = async (
req,
callWithRequest
): Promise<{
repository: Repository | {};
snapshots: { count: number | undefined } | {};
}> => {
const { name } = req.params;
const repositoryByName = await callWithRequest('snapshot.getRepository', { repository: name });
const { snapshots } = await callWithRequest('snapshot.get', {
repository: name,
snapshot: '_all',
}).catch(e => ({
snapshots: null,
}));
if (repositoryByName[name]) {
const { type = '', settings = {} } = repositoryByName[name];
return {
repository: {
name,
type,
settings: deserializeRepositorySettings(settings),
},
snapshots: {
count: snapshots ? snapshots.length : null,
},
};
} else {
return {
repository: {},
snapshots: {},
};
}
};
export const getVerificationHandler: RouterRouteHandler = async (
req,
callWithRequest
): Promise<{
verification: RepositoryVerification | {};
}> => {
const { name } = req.params;
const verificationResults = await callWithRequest('snapshot.verifyRepository', {
repository: name,
}).catch(e => ({
valid: false,
error: e.response ? JSON.parse(e.response) : e,
}));
return {
verification: verificationResults.error
? verificationResults
: {
valid: true,
response: verificationResults,
},
};
};
export const getTypesHandler: RouterRouteHandler = async (req, callWithRequest) => {
// 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' });
if (plugins && plugins.length) {
const pluginNames: string[] = [...new Set(plugins.map(plugin => plugin.component))];
pluginNames.forEach(pluginName => {
if (REPOSITORY_PLUGINS_MAP[pluginName]) {
types.push(REPOSITORY_PLUGINS_MAP[pluginName]);
}
});
}
return types;
};
export const createHandler: RouterRouteHandler = async (req, callWithRequest) => {
const { name = '', type = '', settings = {} } = req.payload as Repository;
const conflictError = wrapCustomError(
new Error('There is already a repository with that name.'),
409
);
// Check that repository with the same name doesn't already exist
try {
const repositoryByName = await callWithRequest('snapshot.getRepository', { repository: name });
if (repositoryByName[name]) {
throw conflictError;
}
} catch (e) {
// Rethrow conflict error but silently swallow all others
if (e === conflictError) {
throw e;
}
}
// Otherwise create new repository
return await callWithRequest('snapshot.createRepository', {
repository: name,
body: {
type,
settings: serializeRepositorySettings(settings),
},
verify: false,
});
};
export const updateHandler: RouterRouteHandler = async (req, callWithRequest) => {
const { name } = req.params;
const { type = '', settings = {} } = req.payload as Repository;
// Check that repository with the given name exists
// If it doesn't exist, 404 will be thrown by ES and will be returned
await callWithRequest('snapshot.getRepository', { repository: name });
// Otherwise update repository
return await callWithRequest('snapshot.createRepository', {
repository: name,
body: {
type,
settings: serializeRepositorySettings(settings),
},
verify: false,
});
};
export const deleteHandler: RouterRouteHandler = async (req, callWithRequest) => {
const { names } = req.params;
const repositoryNames = names.split(',');
const response: { itemsDeleted: string[]; errors: any[] } = {
itemsDeleted: [],
errors: [],
};
await Promise.all(
repositoryNames.map(name => {
return callWithRequest('snapshot.deleteRepository', { repository: name })
.then(() => response.itemsDeleted.push(name))
.catch(e =>
response.errors.push({
name,
error: wrapEsError(e),
})
);
})
);
return response;
};

Some files were not shown because too many files have changed in this diff Show more