[SR] Snapshot and Restore UI (#39193) (#39938)

* Change app name and change default tab to snapshots

* Fix i18n issues

* UI placeholder for deleting snapshots

* Add bulk snapshot delete endpoint and test, adjust UI delete components

* Add restore action buttons

* Set up restore snapshot form entry

* Add RestoreSettings type

* Restore step general

* Combobox for ignore settings

* Code editor for modifying index settings

* Truncate list of indices in snapshot details and provide link to show all

* Disable include global state option for snapshots that don't have global state

* Add step titles, rename General to Logistics

* Committing deleted file

* Change repository detail settings to reverse list style

* Review summary tab and placeholder json tab

* Add restore wizard validation

* Add restore serialization

* Create restore endpoint and integration

* Move new files to /legacy

* Fix bugs, add search filter bar to indices list

* Allow de/select all of indices

* Create new recovery status tab

* Prefix hook methods with `use`

* Remove unnecessary RouteComponentProps

* Add get all snapshot recoveries endpoint, deserialization, types, and tests

* Remove unused timeout variable; enhance to allow polling (interval'd requests) without resetting state like loading

* Add recovery table

* Use shim'd i18n

* Adjust disabled restore state

* Fix invariant error

* Fix partial restore label

* Fix misc bugs

* Address copywriting feedback

* Address i18n feedback

* Address PR feedback

* Rename recovery to restore

* Add toggle show/hide link to restore wizard summary

* Fix snapshot tests due to changes in includeGlobalState deserialization format

* Fix EuiCard warning
This commit is contained in:
Jen Huang 2019-06-28 13:24:01 -07:00 committed by GitHub
parent 860be24124
commit 8d7b34dd40
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
69 changed files with 3952 additions and 179 deletions

View file

@ -11,7 +11,7 @@ export const PLUGIN = {
MINIMUM_LICENSE_REQUIRED: LICENSE_TYPE_BASIC as LicenseType,
getI18nName: (i18n: any): string => {
return i18n.translate('xpack.snapshotRestore.appName', {
defaultMessage: 'Snapshot Repositories',
defaultMessage: 'Snapshot and Restore',
});
},
};

View file

@ -4,3 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { flatten } from './flatten';
export { serializeRestoreSettings } from './restore_settings_serialization';

View file

@ -0,0 +1,57 @@
/*
* 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 { serializeRestoreSettings } from './restore_settings_serialization';
describe('restore_settings_serialization()', () => {
it('should serialize blank restore settings', () => {
expect(serializeRestoreSettings({})).toEqual({});
});
it('should serialize partial restore settings', () => {
expect(serializeRestoreSettings({})).toEqual({});
expect(
serializeRestoreSettings({
indices: ['foo', 'bar'],
ignoreIndexSettings: ['setting1'],
partial: true,
})
).toEqual({
indices: ['foo', 'bar'],
ignore_index_settings: ['setting1'],
partial: true,
});
});
it('should serialize full restore settings', () => {
expect(
serializeRestoreSettings({
indices: ['foo', 'bar'],
renamePattern: 'capture_pattern',
renameReplacement: 'replacement_pattern',
includeGlobalState: true,
partial: true,
indexSettings: '{"modified_setting":123}',
ignoreIndexSettings: ['setting1'],
})
).toEqual({
indices: ['foo', 'bar'],
rename_pattern: 'capture_pattern',
rename_replacement: 'replacement_pattern',
include_global_state: true,
partial: true,
index_settings: { modified_setting: 123 },
ignore_index_settings: ['setting1'],
});
});
it('should skip serialization of invalid json index settings', () => {
expect(
serializeRestoreSettings({
indexSettings: '{"invalid_setting:123,}',
})
).toEqual({});
});
});

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 { RestoreSettings, RestoreSettingsEs } from '../types';
const removeUndefinedSettings = (settings: RestoreSettingsEs): RestoreSettingsEs => {
return Object.entries(settings).reduce((sts: RestoreSettingsEs, [key, value]) => {
if (value !== undefined) {
sts[key as keyof RestoreSettingsEs] = value;
}
return sts;
}, {});
};
export function serializeRestoreSettings(restoreSettings: RestoreSettings): RestoreSettingsEs {
const {
indices,
renamePattern,
renameReplacement,
includeGlobalState,
partial,
indexSettings,
ignoreIndexSettings,
} = restoreSettings;
let parsedIndexSettings: RestoreSettingsEs['index_settings'] | undefined;
if (indexSettings) {
try {
parsedIndexSettings = JSON.parse(indexSettings);
} catch (e) {
// Silently swallow parsing errors since parsing validation is done on client
// so we should never reach this point
}
}
const settings: RestoreSettingsEs = {
indices,
rename_pattern: renamePattern,
rename_replacement: renameReplacement,
include_global_state: includeGlobalState,
partial,
index_settings: parsedIndexSettings,
ignore_index_settings: ignoreIndexSettings,
};
return removeUndefinedSettings(settings);
}

View file

@ -6,3 +6,4 @@
export * from './repository';
export * from './snapshot';
export * from './restore';

View file

@ -0,0 +1,127 @@
/*
* 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 RestoreSettings {
indices?: string[];
renamePattern?: string;
renameReplacement?: string;
includeGlobalState?: boolean;
partial?: boolean;
indexSettings?: string;
ignoreIndexSettings?: string[];
}
export interface RestoreSettingsEs {
indices?: string[];
rename_pattern?: string;
rename_replacement?: string;
include_global_state?: boolean;
partial?: boolean;
index_settings?: { [key: string]: any };
ignore_index_settings?: string[];
}
export interface SnapshotRestore {
index: string;
latestActivityTimeInMillis: number;
shards: Array<Partial<SnapshotRestoreShard>>;
isComplete: boolean;
}
export interface SnapshotRestoreShard {
id: number;
primary: boolean;
stage: string;
snapshot: string;
repository: string;
version: string;
targetHost: string;
targetNode: string;
/** e.g. '2019-04-05T21:56:40.438Z' */
startTime: string;
startTimeInMillis: number;
/** e.g. '2019-04-05T21:56:40.438Z' */
stopTime: string;
stopTimeInMillis: number;
totalTime: string;
totalTimeInMillis: number;
bytesTotal: number;
bytesRecovered: number;
bytesPercent: string;
filesTotal: number;
filesRecovered: number;
filesPercent: string;
}
export interface SnapshotRestoreShardEs {
id: number;
type: string;
stage: string;
primary: boolean;
/** e.g. '2019-04-05T21:56:40.438Z' */
start_time: string;
start_time_in_millis: number;
/** e.g. '2019-04-05T21:56:40.438Z' */
stop_time: string;
stop_time_in_millis: number;
total_time: string;
total_time_in_millis: number;
source: {
repository: string;
snapshot: string;
version: string;
index: string;
restoreUUID: string;
};
target: {
id: string;
host: string;
transport_address: string;
ip: string;
name: string;
};
index: {
size: {
total: string;
total_in_bytes: number;
reused: string;
reused_in_bytes: number;
recovered: string;
recovered_in_bytes: number;
percent: string;
};
files: {
total: number;
reused: number;
recovered: number;
percent: string;
};
total_time: string;
total_time_in_millis: number;
source_throttle_time: string;
source_throttle_time_in_millis: number;
target_throttle_time: string;
target_throttle_time_in_millis: number;
};
translog: {
recovered: number;
total: number;
percent: string;
total_on_start: number;
total_time: string;
total_time_in_millis: number;
};
verify_index: {
check_index_time: string;
check_index_time_in_millis: number;
total_time: string;
total_time_in_millis: number;
};
}

View file

@ -11,7 +11,7 @@ export interface SnapshotDetails {
versionId: number;
version: string;
indices: string[];
includeGlobalState: number;
includeGlobalState: boolean;
state: string;
/** e.g. '2019-04-05T21:56:40.438Z' */
startTime: string;

View file

@ -17,6 +17,7 @@ export function snapshotRestore(kibana: any) {
publicDir: resolve(__dirname, 'public'),
require: ['kibana', 'elasticsearch', 'xpack_main'],
uiExports: {
styleSheetPaths: resolve(__dirname, 'public/app/index.scss'),
managementSections: ['plugins/snapshot_restore'],
},
init(server: Legacy.Server) {

View file

@ -10,8 +10,8 @@ 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 { RepositoryAdd, RepositoryEdit, RestoreSnapshot, SnapshotRestoreHome } from './sections';
import { useLoadPermissions } from './services/http';
import { useAppDependencies } from './index';
export const App: React.FunctionComponent = () => {
@ -29,7 +29,7 @@ export const App: React.FunctionComponent = () => {
hasPermission: true,
missingClusterPrivileges: [],
},
} = loadPermissions();
} = useLoadPermissions();
if (loadingPermissions) {
return (
@ -73,7 +73,7 @@ export const App: React.FunctionComponent = () => {
<p>
<FormattedMessage
id="xpack.snapshotRestore.app.deniedPermissionDescription"
defaultMessage="To use Snapshot Repositories, you must have {clusterPrivilegesCount,
defaultMessage="To use Snapshot and Restore, you must have {clusterPrivilegesCount,
plural, one {this cluster privilege} other {these cluster privileges}}: {clusterPrivileges}."
values={{
clusterPrivileges: missingClusterPrivileges.join(', '),
@ -87,7 +87,7 @@ export const App: React.FunctionComponent = () => {
);
}
const sections: Section[] = ['repositories', 'snapshots'];
const sections: Section[] = ['repositories', 'snapshots', 'restore_status'];
const sectionsRegex = sections.join('|');
return (
@ -100,6 +100,16 @@ export const App: React.FunctionComponent = () => {
path={`${BASE_PATH}/:section(${sectionsRegex})/:repositoryName?/:snapshotId*`}
component={SnapshotRestoreHome}
/>
<Redirect
exact
from={`${BASE_PATH}/restore/:repositoryName`}
to={`${BASE_PATH}/snapshots`}
/>
<Route
exact
path={`${BASE_PATH}/restore/:repositoryName/:snapshotId*`}
component={RestoreSnapshot}
/>
<Redirect from={`${BASE_PATH}`} to={`${BASE_PATH}/${DEFAULT_SECTION}`} />
</Switch>
</div>

View file

@ -11,3 +11,5 @@ export { RepositoryVerificationBadge } from './repository_verification_badge';
export { RepositoryTypeLogo } from './repository_type_logo';
export { SectionError } from './section_error';
export { SectionLoading } from './section_loading';
export { SnapshotDeleteProvider } from './snapshot_delete_provider';
export { RestoreSnapshotForm } from './restore_snapshot_form';

View file

@ -27,7 +27,7 @@ import { REPOSITORY_TYPES } from '../../../../common/constants';
import { useAppDependencies } from '../../index';
import { documentationLinksService } from '../../services/documentation';
import { loadRepositoryTypes } from '../../services/http';
import { useLoadRepositoryTypes } from '../../services/http';
import { textService } from '../../services/text';
import { RepositoryValidation } from '../../services/validation';
import { SectionError, SectionLoading, RepositoryTypeLogo } from '../';
@ -56,7 +56,7 @@ export const RepositoryFormStepOne: React.FunctionComponent<Props> = ({
error: repositoryTypesError,
loading: repositoryTypesLoading,
data: repositoryTypes = [],
} = loadRepositoryTypes();
} = useLoadRepositoryTypes();
const hasValidationErrors: boolean = !validation.isValid;
@ -143,6 +143,7 @@ export const RepositoryFormStepOne: React.FunctionComponent<Props> = ({
<EuiCard
title={displayName}
icon={<RepositoryTypeLogo type={type} size="l" />}
description={<Fragment />} /* EuiCard requires `description` */
footer={
<EuiButtonEmpty
href={documentationLinksService.getRepositoryTypeDocUrl(type)}

View file

@ -0,0 +1,10 @@
/*
* Prevent switch controls from moving around when toggling content
*/
.snapshotRestore__restoreForm__stepLogistics,
.snapshotRestore__restoreForm__stepSettings {
.euiFormRow--hasEmptyLabelSpace {
min-height: auto;
margin-top: $euiFontSizeXS + $euiSizeS + ($euiSizeXXL / 4);
}
}

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 { RestoreSnapshotForm } from './restore_snapshot_form';

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 { EuiStepsHorizontal } from '@elastic/eui';
import { useAppDependencies } from '../../index';
interface Props {
currentStep: number;
maxCompletedStep: number;
updateCurrentStep: (step: number) => void;
}
export const RestoreSnapshotNavigation: React.FunctionComponent<Props> = ({
currentStep,
maxCompletedStep,
updateCurrentStep,
}) => {
const {
core: { i18n },
} = useAppDependencies();
const steps = [
{
title: i18n.translate('xpack.snapshotRestore.restoreForm.navigation.stepLogisticsName', {
defaultMessage: 'Logistics',
}),
isComplete: maxCompletedStep >= 1,
isSelected: currentStep === 1,
onClick: () => updateCurrentStep(1),
},
{
title: i18n.translate('xpack.snapshotRestore.restoreForm.navigation.stepSettingsName', {
defaultMessage: 'Index settings',
}),
isComplete: maxCompletedStep >= 2,
isSelected: currentStep === 2,
disabled: maxCompletedStep < 1,
onClick: () => updateCurrentStep(2),
},
{
title: i18n.translate('xpack.snapshotRestore.restoreForm.navigation.stepReviewName', {
defaultMessage: 'Review',
}),
isComplete: maxCompletedStep >= 2,
isSelected: currentStep === 3,
disabled: maxCompletedStep < 2,
onClick: () => updateCurrentStep(3),
},
];
return <EuiStepsHorizontal steps={steps} />;
};

View file

@ -0,0 +1,182 @@
/*
* 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 {
EuiButton,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiSpacer,
} from '@elastic/eui';
import { SnapshotDetails, RestoreSettings } from '../../../../common/types';
import { RestoreValidation, validateRestore } from '../../services/validation';
import { useAppDependencies } from '../../index';
import {
RestoreSnapshotStepLogistics,
RestoreSnapshotStepSettings,
RestoreSnapshotStepReview,
} from './steps';
import { RestoreSnapshotNavigation } from './navigation';
interface Props {
snapshotDetails: SnapshotDetails;
isSaving: boolean;
saveError?: React.ReactNode;
clearSaveError: () => void;
onSave: (repository: RestoreSettings) => void;
}
export const RestoreSnapshotForm: React.FunctionComponent<Props> = ({
snapshotDetails,
isSaving,
saveError,
clearSaveError,
onSave,
}) => {
const {
core: {
i18n: { FormattedMessage },
},
} = useAppDependencies();
// Step state
const [currentStep, setCurrentStep] = useState<number>(1);
const [maxCompletedStep, setMaxCompletedStep] = useState<number>(0);
const stepMap: { [key: number]: any } = {
1: RestoreSnapshotStepLogistics,
2: RestoreSnapshotStepSettings,
3: RestoreSnapshotStepReview,
};
const CurrentStepForm = stepMap[currentStep];
// Restore details state
const [restoreSettings, setRestoreSettings] = useState<RestoreSettings>({});
// Restore validation state
const [validation, setValidation] = useState<RestoreValidation>({
isValid: true,
errors: {},
});
const updateRestoreSettings = (updatedSettings: Partial<RestoreSettings>): void => {
const newRestoreSettings = { ...restoreSettings, ...updatedSettings };
const newValidation = validateRestore(newRestoreSettings);
setRestoreSettings(newRestoreSettings);
setValidation(newValidation);
};
const updateCurrentStep = (step: number) => {
if (maxCompletedStep < step - 1) {
return;
}
setCurrentStep(step);
setMaxCompletedStep(step - 1);
clearSaveError();
};
const onBack = () => {
const previousStep = currentStep - 1;
setCurrentStep(previousStep);
setMaxCompletedStep(previousStep - 1);
clearSaveError();
};
const onNext = () => {
if (!validation.isValid) {
return;
}
const nextStep = currentStep + 1;
setMaxCompletedStep(Math.max(currentStep, maxCompletedStep));
setCurrentStep(nextStep);
};
const executeRestore = () => {
if (validation.isValid) {
onSave(restoreSettings);
}
};
return (
<Fragment>
<RestoreSnapshotNavigation
currentStep={currentStep}
maxCompletedStep={maxCompletedStep}
updateCurrentStep={updateCurrentStep}
/>
<EuiSpacer size="l" />
<EuiForm>
<CurrentStepForm
snapshotDetails={snapshotDetails}
restoreSettings={restoreSettings}
updateRestoreSettings={updateRestoreSettings}
errors={validation.errors}
updateCurrentStep={updateCurrentStep}
/>
<EuiSpacer size="l" />
{saveError ? (
<Fragment>
{saveError}
<EuiSpacer size="m" />
</Fragment>
) : null}
<EuiFlexGroup>
{currentStep > 1 ? (
<EuiFlexItem grow={false}>
<EuiButtonEmpty iconType="arrowLeft" onClick={() => onBack()}>
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.backButtonLabel"
defaultMessage="Back"
/>
</EuiButtonEmpty>
</EuiFlexItem>
) : null}
{currentStep < 3 ? (
<EuiFlexItem grow={false}>
<EuiButton
fill
iconType="arrowRight"
onClick={() => onNext()}
disabled={!validation.isValid}
>
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.nextButtonLabel"
defaultMessage="Next"
/>
</EuiButton>
</EuiFlexItem>
) : null}
{currentStep === 3 ? (
<EuiFlexItem grow={false}>
<EuiButton
fill
color="secondary"
iconType="check"
onClick={() => executeRestore()}
isLoading={isSaving}
>
{isSaving ? (
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.savingButtonLabel"
defaultMessage="Restoring…"
/>
) : (
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.submitButtonLabel"
defaultMessage="Restore snapshot"
/>
)}
</EuiButton>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
</EuiForm>
<EuiSpacer size="m" />
</Fragment>
);
};

View file

@ -0,0 +1,19 @@
/*
* 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 { SnapshotDetails, RestoreSettings } from '../../../../../common/types';
import { RestoreValidation } from '../../../services/validation';
export interface StepProps {
snapshotDetails: SnapshotDetails;
restoreSettings: RestoreSettings;
updateRestoreSettings: (updatedSettings: Partial<RestoreSettings>) => void;
errors: RestoreValidation['errors'];
updateCurrentStep: (step: number) => void;
}
export { RestoreSnapshotStepLogistics } from './step_logistics';
export { RestoreSnapshotStepSettings } from './step_settings';
export { RestoreSnapshotStepReview } from './step_review';

View file

@ -0,0 +1,460 @@
/*
* 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 {
EuiButtonEmpty,
EuiDescribedFormGroup,
EuiFlexGroup,
EuiFlexItem,
EuiFieldText,
EuiFormRow,
EuiLink,
EuiPanel,
EuiSelectable,
EuiSpacer,
EuiSwitch,
EuiTitle,
} from '@elastic/eui';
import { Option } from '@elastic/eui/src/components/selectable/types';
import { RestoreSettings } from '../../../../../common/types';
import { documentationLinksService } from '../../../services/documentation';
import { useAppDependencies } from '../../../index';
import { StepProps } from './';
export const RestoreSnapshotStepLogistics: React.FunctionComponent<StepProps> = ({
snapshotDetails,
restoreSettings,
updateRestoreSettings,
errors,
}) => {
const {
core: {
i18n: { FormattedMessage },
},
} = useAppDependencies();
const {
indices: snapshotIndices,
includeGlobalState: snapshotIncludeGlobalState,
} = snapshotDetails;
const {
indices: restoreIndices,
renamePattern,
renameReplacement,
partial,
includeGlobalState,
} = restoreSettings;
// States for choosing all indices, or a subset, including caching previously chosen subset list
const [isAllIndices, setIsAllIndices] = useState<boolean>(!Boolean(restoreIndices));
const [indicesOptions, setIndicesOptions] = useState<Option[]>(
snapshotIndices.map(
(index): Option => ({
label: index,
checked:
isAllIndices || (restoreIndices && restoreIndices.includes(index)) ? 'on' : undefined,
})
)
);
// State for setting renaming indices patterns
const [isRenamingIndices, setIsRenamingIndices] = useState<boolean>(
Boolean(renamePattern || renameReplacement)
);
// Caching state for togglable settings
const [cachedRestoreSettings, setCachedRestoreSettings] = useState<RestoreSettings>({
indices: [...snapshotIndices],
renamePattern: '',
renameReplacement: '',
});
return (
<div className="snapshotRestore__restoreForm__stepLogistics">
{/* Step title and doc link */}
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiTitle>
<h3>
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepLogisticsTitle"
defaultMessage="Logistics"
/>
</h3>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="s"
flush="right"
href={documentationLinksService.getRestoreDocUrl()}
target="_blank"
iconType="help"
>
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepLogistics.docsButtonLabel"
defaultMessage="Logistics docs"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="l" />
{/* Indices */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepLogistics.indicesTitle"
defaultMessage="Indices"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepLogistics.indicesDescription"
defaultMessage="Creates new indices if they dont exist. Restores existing indices
if they are closed and have the same number of shards as the snapshot index."
/>
}
idAria="stepLogisticsIndicesDescription"
fullWidth
>
<EuiFormRow
hasEmptyLabelSpace
fullWidth
describedByIds={['stepLogisticsIndicesDescription']}
>
{/* Fragment needed because EuiFormRow can only have one child: https://github.com/elastic/eui/issues/1931 */}
<Fragment>
<EuiSwitch
label={
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepLogistics.allIndicesLabel"
defaultMessage="All indices, including system indices"
/>
}
checked={isAllIndices}
onChange={e => {
const isChecked = e.target.checked;
setIsAllIndices(isChecked);
if (isChecked) {
updateRestoreSettings({ indices: undefined });
} else {
updateRestoreSettings({
indices: [...(cachedRestoreSettings.indices || [])],
});
}
}}
/>
{isAllIndices ? null : (
<Fragment>
<EuiSpacer size="m" />
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepLogistics.selectIndicesLabel"
defaultMessage="Select indices"
/>
}
helpText={
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepLogistics.selectIndicesHelpText"
defaultMessage="{count} {count, plural, one {index} other {indices}} will be restored. {selectOrDeselectAllLink}"
values={{
count: restoreIndices && restoreIndices.length,
selectOrDeselectAllLink:
restoreIndices && restoreIndices.length > 0 ? (
<EuiLink
onClick={() => {
indicesOptions.forEach((option: Option) => {
option.checked = undefined;
});
updateRestoreSettings({ indices: [] });
setCachedRestoreSettings({
...cachedRestoreSettings,
indices: [],
});
}}
>
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepLogistics.deselectAllIndicesLink"
defaultMessage="Deselect all"
/>
</EuiLink>
) : (
<EuiLink
onClick={() => {
indicesOptions.forEach((option: Option) => {
option.checked = 'on';
});
updateRestoreSettings({ indices: [...snapshotIndices] });
setCachedRestoreSettings({
...cachedRestoreSettings,
indices: [...snapshotIndices],
});
}}
>
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepLogistics.selectAllIndicesLink"
defaultMessage="Select all"
/>
</EuiLink>
),
}}
/>
}
isInvalid={Boolean(errors.indices)}
error={errors.indices}
>
<EuiSelectable
allowExclusions={false}
options={indicesOptions}
onChange={options => {
const newSelectedIndices: string[] = [];
options.forEach(({ label, checked }) => {
if (checked === 'on') {
newSelectedIndices.push(label);
}
});
setIndicesOptions(options);
updateRestoreSettings({ indices: [...newSelectedIndices] });
setCachedRestoreSettings({
...cachedRestoreSettings,
indices: [...newSelectedIndices],
});
}}
searchable
height={300}
>
{(list, search) => (
<EuiPanel paddingSize="s" hasShadow={false}>
{search}
{list}
</EuiPanel>
)}
</EuiSelectable>
</EuiFormRow>
</Fragment>
)}
</Fragment>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Rename indices */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepLogistics.renameIndicesTitle"
defaultMessage="Rename indices"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepLogistics.renameIndicesDescription"
defaultMessage="Renames indices on restore."
/>
}
idAria="stepLogisticsRenameIndicesDescription"
fullWidth
>
<EuiFormRow
hasEmptyLabelSpace
fullWidth
describedByIds={['stepLogisticsRenameIndicesDescription']}
>
{/* Fragment needed because EuiFormRow can only have one child: https://github.com/elastic/eui/issues/1931 */}
<Fragment>
<EuiSwitch
label={
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepLogistics.renameIndicesLabel"
defaultMessage="Rename indices"
/>
}
checked={isRenamingIndices}
onChange={e => {
const isChecked = e.target.checked;
setIsRenamingIndices(isChecked);
if (isChecked) {
updateRestoreSettings({
renamePattern: cachedRestoreSettings.renamePattern,
renameReplacement: cachedRestoreSettings.renameReplacement,
});
} else {
updateRestoreSettings({
renamePattern: undefined,
renameReplacement: undefined,
});
}
}}
/>
{!isRenamingIndices ? null : (
<Fragment>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepLogistics.renamePatternLabel"
defaultMessage="Capture pattern"
/>
}
helpText={
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepLogistics.renamePatternHelpText"
defaultMessage="Use regular expressions"
/>
}
isInvalid={Boolean(errors.renamePattern)}
error={errors.renamePattern}
>
<EuiFieldText
value={renamePattern}
placeholder="index_(.+)"
onChange={e => {
setCachedRestoreSettings({
...cachedRestoreSettings,
renamePattern: e.target.value,
});
updateRestoreSettings({
renamePattern: e.target.value,
});
}}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepLogistics.renameReplacementLabel"
defaultMessage="Replacement pattern"
/>
}
isInvalid={Boolean(errors.renameReplacement)}
error={errors.renameReplacement}
>
<EuiFieldText
value={renameReplacement}
placeholder="restored_index_$1"
onChange={e => {
setCachedRestoreSettings({
...cachedRestoreSettings,
renameReplacement: e.target.value,
});
updateRestoreSettings({
renameReplacement: e.target.value,
});
}}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</Fragment>
)}
</Fragment>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Partial restore */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepLogistics.partialTitle"
defaultMessage="Partial restore"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepLogistics.partialDescription"
defaultMessage="Allows restore of indices that dont have snapshots of all shards."
/>
}
idAria="stepLogisticsPartialDescription"
fullWidth
>
<EuiFormRow
hasEmptyLabelSpace={true}
fullWidth
describedByIds={['stepLogisticsPartialDescription']}
>
<EuiSwitch
label={
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepLogistics.partialLabel"
defaultMessage="Partial restore"
/>
}
checked={partial === undefined ? false : partial}
onChange={e => updateRestoreSettings({ partial: e.target.checked })}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Include global state */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepLogistics.includeGlobalStateTitle"
defaultMessage="Restore global state"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepLogistics.includeGlobalStateDescription"
defaultMessage="Restores templates that dont currently exist in the cluster and overrides
templates with the same name. Also restores persistent settings."
/>
}
idAria="stepLogisticsIncludeGlobalStateDescription"
fullWidth
>
<EuiFormRow
hasEmptyLabelSpace={true}
fullWidth
describedByIds={['stepLogisticsIncludeGlobalStateDescription']}
helpText={
snapshotIncludeGlobalState ? null : (
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepLogistics.includeGlobalStateDisabledDescription"
defaultMessage="Not available for this snapshot."
/>
)
}
>
<EuiSwitch
label={
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepLogistics.includeGlobalStateLabel"
defaultMessage="Restore global state"
/>
}
checked={includeGlobalState === undefined ? false : includeGlobalState}
onChange={e => updateRestoreSettings({ includeGlobalState: e.target.checked })}
disabled={!snapshotIncludeGlobalState}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
</div>
);
};

View file

@ -0,0 +1,374 @@
/*
* 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, Fragment } from 'react';
import {
EuiCodeEditor,
EuiFlexGrid,
EuiFlexGroup,
EuiFlexItem,
EuiDescriptionList,
EuiDescriptionListTitle,
EuiDescriptionListDescription,
EuiSpacer,
EuiTabbedContent,
EuiText,
EuiTitle,
EuiLink,
EuiIcon,
EuiToolTip,
} from '@elastic/eui';
import { serializeRestoreSettings } from '../../../../../common/lib';
import { useAppDependencies } from '../../../index';
import { StepProps } from './';
export const RestoreSnapshotStepReview: React.FunctionComponent<StepProps> = ({
restoreSettings,
updateCurrentStep,
}) => {
const {
core: { i18n },
} = useAppDependencies();
const { FormattedMessage } = i18n;
const {
indices,
renamePattern,
renameReplacement,
partial,
includeGlobalState,
ignoreIndexSettings,
} = restoreSettings;
const serializedRestoreSettings = serializeRestoreSettings(restoreSettings);
const { index_settings: serializedIndexSettings } = serializedRestoreSettings;
const [isShowingFullIndicesList, setIsShowingFullIndicesList] = useState<boolean>(false);
const hiddenIndicesCount = indices && indices.length > 10 ? indices.length - 10 : 0;
const renderSummaryTab = () => (
<Fragment>
<EuiSpacer size="m" />
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepReview.summaryTab.sectionLogisticsTitle"
defaultMessage="Logistics"
/>{' '}
<EuiToolTip
content={
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepReview.summaryTab.editStepTooltip"
defaultMessage="Edit"
/>
}
>
<EuiLink onClick={() => updateCurrentStep(1)}>
<EuiIcon type="pencil" />
</EuiLink>
</EuiToolTip>
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiDescriptionList textStyle="reverse">
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepReview.summaryTab.indicesLabel"
defaultMessage="Indices"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{indices ? (
<EuiText>
<ul>
{(isShowingFullIndicesList ? indices : [...indices].splice(0, 10)).map(
index => (
<li key={index}>
<EuiTitle size="xs">
<span>{index}</span>
</EuiTitle>
</li>
)
)}
{hiddenIndicesCount ? (
<li key="hiddenIndicesCount">
<EuiTitle size="xs">
{isShowingFullIndicesList ? (
<EuiLink onClick={() => setIsShowingFullIndicesList(false)}>
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepReview.summaryTab.indicesCollapseAllLink"
defaultMessage="Hide {count, plural, one {# index} other {# indices}}"
values={{ count: hiddenIndicesCount }}
/>{' '}
<EuiIcon type="arrowUp" />
</EuiLink>
) : (
<EuiLink onClick={() => setIsShowingFullIndicesList(true)}>
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepReview.summaryTab.indicesShowAllLink"
defaultMessage="Show {count} more {count, plural, one {index} other {indices}}"
values={{ count: hiddenIndicesCount }}
/>{' '}
<EuiIcon type="arrowDown" />
</EuiLink>
)}
</EuiTitle>
</li>
) : null}
</ul>
</EuiText>
) : (
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepReview.summaryTab.allIndicesValue"
defaultMessage="All indices"
/>
)}
</EuiDescriptionListDescription>
</EuiDescriptionList>
</EuiFlexItem>
</EuiFlexGroup>
{renamePattern || renameReplacement ? (
<Fragment>
<EuiSpacer size="m" />
<EuiTitle size="xs">
<h4>
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepReview.summaryTab.sectionRenameTitle"
defaultMessage="Rename indices"
/>
</h4>
</EuiTitle>
<EuiSpacer size="s" />
<EuiFlexGroup>
{renamePattern ? (
<EuiFlexItem>
<EuiDescriptionList textStyle="reverse">
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepReview.summaryTab.renamePatternLabel"
defaultMessage="Capture pattern"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>{renamePattern}</EuiDescriptionListDescription>
</EuiDescriptionList>
</EuiFlexItem>
) : null}
{renameReplacement ? (
<EuiFlexItem>
<EuiDescriptionList textStyle="reverse">
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepReview.summaryTab.renameReplacementLabel"
defaultMessage="Replacement pattern"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>{renameReplacement}</EuiDescriptionListDescription>
</EuiDescriptionList>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
</Fragment>
) : null}
{partial !== undefined || includeGlobalState !== undefined ? (
<EuiFlexGroup>
{partial !== undefined ? (
<EuiFlexItem>
<EuiDescriptionList textStyle="reverse">
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepReview.summaryTab.partialLabel"
defaultMessage="Partial restore"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{partial ? (
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepReview.summaryTab.partialTrueValue"
defaultMessage="Yes"
/>
) : (
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepReview.summaryTab.partialFalseValue"
defaultMessage="No"
/>
)}
</EuiDescriptionListDescription>
</EuiDescriptionList>
</EuiFlexItem>
) : null}
{includeGlobalState !== undefined ? (
<EuiFlexItem>
<EuiDescriptionList textStyle="reverse">
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepReview.summaryTab.includeGlobalStateLabel"
defaultMessage="Restore global state"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{includeGlobalState ? (
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepReview.summaryTab.includeGlobalStateTrueValue"
defaultMessage="Yes"
/>
) : (
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepReview.summaryTab.includeGlobalStateFalseValue"
defaultMessage="No"
/>
)}
</EuiDescriptionListDescription>
</EuiDescriptionList>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
) : null}
<EuiSpacer size="m" />
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepReview.summaryTab.sectionSettingsTitle"
defaultMessage="Index settings"
/>{' '}
<EuiToolTip
content={
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepReview.summaryTab.editStepTooltip"
defaultMessage="Edit"
/>
}
>
<EuiLink onClick={() => updateCurrentStep(2)}>
<EuiIcon type="pencil" />
</EuiLink>
</EuiToolTip>
</h3>
</EuiTitle>
<EuiSpacer size="s" />
{serializedIndexSettings || ignoreIndexSettings ? (
<EuiFlexGroup>
{serializedIndexSettings ? (
<EuiFlexItem style={{ maxWidth: '50%' }}>
<EuiDescriptionList textStyle="reverse">
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepReview.summaryTab.indexSettingsLabel"
defaultMessage="Modify"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
<EuiFlexGrid columns={2} gutterSize="none">
{Object.entries(serializedIndexSettings).map(([setting, value]) => (
<Fragment key={setting}>
<EuiFlexItem>
<EuiText size="s">
<strong>{setting}</strong>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="s">
<span> {value}</span>
</EuiText>
</EuiFlexItem>
</Fragment>
))}
</EuiFlexGrid>
</EuiDescriptionListDescription>
</EuiDescriptionList>
</EuiFlexItem>
) : null}
{ignoreIndexSettings ? (
<EuiFlexItem>
<EuiDescriptionList textStyle="reverse">
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepReview.summaryTab.ignoreIndexSettingsLabel"
defaultMessage="Reset"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
<EuiText>
<ul>
{ignoreIndexSettings.map(setting => (
<li key={setting}>
<EuiTitle size="xs">
<span>{setting}</span>
</EuiTitle>
</li>
))}
</ul>
</EuiText>
</EuiDescriptionListDescription>
</EuiDescriptionList>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
) : (
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepReview.summaryTab.noSettingsValue"
defaultMessage="No index setting modifications"
/>
)}
</Fragment>
);
const renderJsonTab = () => (
<Fragment>
<EuiSpacer size="m" />
<EuiCodeEditor
mode="json"
theme="textmate"
isReadOnly
setOptions={{ maxLines: Infinity }}
value={JSON.stringify(serializedRestoreSettings, null, 2)}
editorProps={{ $blockScrolling: Infinity }}
aria-label={
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepReview.jsonTab.jsonAriaLabel"
defaultMessage="Restore settings to be executed"
/>
}
/>
</Fragment>
);
return (
<Fragment>
<EuiTitle>
<h3>
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepReviewTitle"
defaultMessage="Review restore details"
/>
</h3>
</EuiTitle>
<EuiSpacer size="m" />
<EuiTabbedContent
tabs={[
{
id: 'summary',
name: i18n.translate('xpack.snapshotRestore.restoreForm.stepReview.summaryTabTitle', {
defaultMessage: 'Summary',
}),
content: renderSummaryTab(),
},
{
id: 'json',
name: i18n.translate('xpack.snapshotRestore.restoreForm.stepReview.jsonTabTitle', {
defaultMessage: 'JSON',
}),
content: renderJsonTab(),
},
]}
/>
</Fragment>
);
};

View file

@ -0,0 +1,330 @@
/*
* 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, Fragment } from 'react';
import {
EuiButtonEmpty,
EuiCode,
EuiCodeEditor,
EuiComboBox,
EuiDescribedFormGroup,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiSpacer,
EuiSwitch,
EuiTitle,
} from '@elastic/eui';
import { RestoreSettings } from '../../../../../common/types';
import { REMOVE_INDEX_SETTINGS_SUGGESTIONS } from '../../../constants';
import { documentationLinksService } from '../../../services/documentation';
import { useAppDependencies } from '../../../index';
import { StepProps } from './';
export const RestoreSnapshotStepSettings: React.FunctionComponent<StepProps> = ({
restoreSettings,
updateRestoreSettings,
errors,
}) => {
const {
core: { i18n },
} = useAppDependencies();
const { FormattedMessage } = i18n;
const { indexSettings, ignoreIndexSettings } = restoreSettings;
// State for index setting toggles
const [isUsingIndexSettings, setIsUsingIndexSettings] = useState<boolean>(Boolean(indexSettings));
const [isUsingIgnoreIndexSettings, setIsUsingIgnoreIndexSettings] = useState<boolean>(
Boolean(ignoreIndexSettings)
);
// Caching state for togglable settings
const [cachedRestoreSettings, setCachedRestoreSettings] = useState<RestoreSettings>({
indexSettings: indexSettings || '{}',
ignoreIndexSettings: ignoreIndexSettings ? [...ignoreIndexSettings] : [],
});
// List of settings for ignore settings combobox suggestions, using a state because users can add custom settings
const [ignoreIndexSettingsOptions, setIgnoreIndexSettingsOptions] = useState<
Array<{ label: string }>
>(
[
...new Set((ignoreIndexSettings || []).concat([...REMOVE_INDEX_SETTINGS_SUGGESTIONS].sort())),
].map(setting => ({
label: setting,
}))
);
return (
<div className="snapshotRestore__restoreForm__stepSettings">
{/* Step title and doc link */}
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiTitle>
<h3>
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepSettingsTitle"
defaultMessage="Index settings"
/>
</h3>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="s"
flush="right"
href={documentationLinksService.getRestoreIndexSettingsUrl()}
target="_blank"
iconType="help"
>
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepSettings.docsButtonLabel"
defaultMessage="Index settings docs"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="l" />
{/* Modify index settings */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepSettings.indexSettingsTitle"
defaultMessage="Modify index settings"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepSettings.indexSettingsDescription"
defaultMessage="Overrides index settings during restore."
/>
}
idAria="stepSettingsIndexSettingsDescription"
fullWidth
>
<EuiFormRow
hasEmptyLabelSpace
fullWidth
describedByIds={['stepSettingsIndexSettingsDescription']}
>
{/* Fragment needed because EuiFormRow can only have one child: https://github.com/elastic/eui/issues/1931 */}
<Fragment>
<EuiSwitch
label={
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepSettings.indexSettingsLabel"
defaultMessage="Modify index settings"
/>
}
checked={isUsingIndexSettings}
onChange={e => {
const isChecked = e.target.checked;
if (isChecked) {
setIsUsingIndexSettings(true);
updateRestoreSettings({
indexSettings: cachedRestoreSettings.indexSettings,
});
} else {
setIsUsingIndexSettings(false);
updateRestoreSettings({ indexSettings: undefined });
}
}}
/>
{!isUsingIndexSettings ? null : (
<Fragment>
<EuiSpacer size="m" />
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepSettings.indexSettingsEditorLabel"
defaultMessage="Index settings"
/>
}
fullWidth
describedByIds={['stepSettingsIndexSettingsDescription']}
isInvalid={Boolean(errors.indexSettings)}
error={errors.indexSettings}
helpText={
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepSettings.indexSettingsEditorDescription"
defaultMessage="Use JSON format: {format}"
values={{
format: <EuiCode>{'{ "index.number_of_replicas": 0 }'}</EuiCode>,
}}
/>
}
>
<EuiCodeEditor
mode="json"
theme="textmate"
width="100%"
value={indexSettings}
setOptions={{
showLineNumbers: false,
tabSize: 2,
maxLines: Infinity,
}}
editorProps={{
$blockScrolling: Infinity,
}}
showGutter={false}
minLines={6}
maxLines={15}
aria-label={
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepSettings.indexSettingsAriaLabel"
defaultMessage="Index settings to modify"
/>
}
onChange={(value: string) => {
updateRestoreSettings({
indexSettings: value,
});
setCachedRestoreSettings({
...cachedRestoreSettings,
indexSettings: value,
});
}}
/>
</EuiFormRow>
</Fragment>
)}
</Fragment>
</EuiFormRow>
</EuiDescribedFormGroup>
{/* Ignore index settings */}
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepSettings.ignoreIndexSettingsTitle"
defaultMessage="Reset index settings"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepSettings.ignoreIndexSettingsDescription"
defaultMessage="Resets selected settings to default during restore. "
/>
}
idAria="stepSettingsIgnoreIndexSettingsDescription"
fullWidth
>
<EuiFormRow
hasEmptyLabelSpace
fullWidth
describedByIds={['stepSettingsIgnoreIndexSettingsDescription']}
>
{/* Fragment needed because EuiFormRow can only have one child: https://github.com/elastic/eui/issues/1931 */}
<Fragment>
<EuiSwitch
label={
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepSettings.ignoreIndexSettingsLabel"
defaultMessage="Reset index settings"
/>
}
checked={isUsingIgnoreIndexSettings}
onChange={e => {
const isChecked = e.target.checked;
if (isChecked) {
setIsUsingIgnoreIndexSettings(true);
updateRestoreSettings({
ignoreIndexSettings: [...(cachedRestoreSettings.ignoreIndexSettings || [])],
});
} else {
setIsUsingIgnoreIndexSettings(false);
updateRestoreSettings({ ignoreIndexSettings: undefined });
}
}}
/>
{!isUsingIgnoreIndexSettings ? null : (
<Fragment>
<EuiSpacer size="m" />
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepSettings.selectIgnoreIndexSettingsLabel"
defaultMessage="Select settings"
/>
}
isInvalid={Boolean(errors.ignoreIndexSettings)}
error={errors.ignoreIndexSettings}
>
<EuiComboBox
placeholder={i18n.translate(
'xpack.snapshotRestore.restoreForm.stepSettings.ignoreIndexSettingsPlaceholder',
{
defaultMessage: 'Select or type index settings',
}
)}
options={ignoreIndexSettingsOptions}
selectedOptions={
ignoreIndexSettings
? ignoreIndexSettingsOptions.filter(({ label }) =>
ignoreIndexSettings.includes(label)
)
: []
}
onChange={selectedOptions => {
const newIgnoreIndexSettings = selectedOptions.map(({ label }) => label);
updateRestoreSettings({ ignoreIndexSettings: newIgnoreIndexSettings });
setCachedRestoreSettings({
...cachedRestoreSettings,
ignoreIndexSettings: newIgnoreIndexSettings,
});
}}
onCreateOption={(
newIndexSetting: string,
flattenedOptions: Array<{ label: string }>
) => {
const normalizedSettingName = newIndexSetting.trim().toLowerCase();
if (!normalizedSettingName) {
return;
}
const isCustomSetting = !Boolean(
flattenedOptions.find(({ label }) => label === normalizedSettingName)
);
if (isCustomSetting) {
setIgnoreIndexSettingsOptions([
{ label: normalizedSettingName },
...ignoreIndexSettingsOptions,
]);
}
updateRestoreSettings({
ignoreIndexSettings: [
...(ignoreIndexSettings || []),
normalizedSettingName,
],
});
setCachedRestoreSettings({
...cachedRestoreSettings,
ignoreIndexSettings: [
...(ignoreIndexSettings || []),
normalizedSettingName,
],
});
}}
isClearable={true}
/>
</EuiFormRow>
</Fragment>
)}
</Fragment>
</EuiFormRow>
</EuiDescribedFormGroup>
</div>
);
};

View file

@ -0,0 +1,218 @@
/*
* 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,
EuiCallOut,
EuiLoadingSpinner,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import { useAppDependencies } from '../index';
import { deleteSnapshots } from '../services/http';
interface Props {
children: (deleteSnapshot: DeleteSnapshot) => React.ReactElement;
}
export type DeleteSnapshot = (
ids: Array<{ snapshot: string; repository: string }>,
onSuccess?: OnSuccessCallback
) => void;
type OnSuccessCallback = (
snapshotsDeleted: Array<{ snapshot: string; repository: string }>
) => void;
export const SnapshotDeleteProvider: React.FunctionComponent<Props> = ({ children }) => {
const {
core: {
i18n,
notification: { toastNotifications },
},
} = useAppDependencies();
const { FormattedMessage } = i18n;
const [snapshotIds, setSnapshotIds] = useState<Array<{ snapshot: string; repository: string }>>(
[]
);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const onSuccessCallback = useRef<OnSuccessCallback | null>(null);
const deleteSnapshotPrompt: DeleteSnapshot = (ids, onSuccess = () => undefined) => {
if (!ids || !ids.length) {
throw new Error('No snapshot IDs specified for deletion');
}
setIsModalOpen(true);
setSnapshotIds(ids);
onSuccessCallback.current = onSuccess;
};
const closeModal = () => {
setIsModalOpen(false);
setSnapshotIds([]);
};
const deleteSnapshot = () => {
const snapshotsToDelete = [...snapshotIds];
setIsDeleting(true);
deleteSnapshots(snapshotsToDelete).then(({ data: { itemsDeleted, errors }, error }) => {
// Wait until request is done to close modal; deleting snapshots take longer due to their sequential nature
closeModal();
setIsDeleting(false);
// Surface success notifications
if (itemsDeleted && itemsDeleted.length) {
const hasMultipleSuccesses = itemsDeleted.length > 1;
const successMessage = hasMultipleSuccesses
? i18n.translate(
'xpack.snapshotRestore.deleteSnapshot.successMultipleNotificationTitle',
{
defaultMessage: 'Deleted {count} snapshots',
values: { count: itemsDeleted.length },
}
)
: i18n.translate('xpack.snapshotRestore.deleteSnapshot.successSingleNotificationTitle', {
defaultMessage: "Deleted snapshot '{name}'",
values: { name: itemsDeleted[0].snapshot },
});
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 snapshot(s)
if (error || (errors && errors.length)) {
const hasMultipleErrors =
(errors && errors.length > 1) || (error && snapshotsToDelete.length > 1);
const errorMessage = hasMultipleErrors
? i18n.translate('xpack.snapshotRestore.deleteSnapshot.errorMultipleNotificationTitle', {
defaultMessage: 'Error deleting {count} snapshots',
values: {
count: (errors && errors.length) || snapshotsToDelete.length,
},
})
: i18n.translate('xpack.snapshotRestore.deleteSnapshot.errorSingleNotificationTitle', {
defaultMessage: "Error deleting snapshot '{name}'",
values: { name: (errors && errors[0].id.snapshot) || snapshotsToDelete[0].snapshot },
});
toastNotifications.addDanger(errorMessage);
}
});
};
const renderModal = () => {
if (!isModalOpen) {
return null;
}
const isSingle = snapshotIds.length === 1;
return (
<EuiOverlayMask>
<EuiConfirmModal
title={
isSingle ? (
<FormattedMessage
id="xpack.snapshotRestore.deleteSnapshot.confirmModal.deleteSingleTitle"
defaultMessage="Delete snapshot '{name}'?"
values={{ name: snapshotIds[0].snapshot }}
/>
) : (
<FormattedMessage
id="xpack.snapshotRestore.deleteSnapshot.confirmModal.deleteMultipleTitle"
defaultMessage="Delete {count} snapshots?"
values={{ count: snapshotIds.length }}
/>
)
}
onCancel={closeModal}
onConfirm={deleteSnapshot}
cancelButtonText={
<FormattedMessage
id="xpack.snapshotRestore.deleteSnapshot.confirmModal.cancelButtonLabel"
defaultMessage="Cancel"
/>
}
confirmButtonText={
<FormattedMessage
id="xpack.snapshotRestore.deleteSnapshot.confirmModal.confirmButtonLabel"
defaultMessage="Delete {count, plural, one {snapshot} other {snapshots}}"
values={{ count: snapshotIds.length }}
/>
}
confirmButtonDisabled={isDeleting}
buttonColor="danger"
data-test-subj="srdeleteSnapshotConfirmationModal"
>
{!isSingle ? (
<Fragment>
<p>
<FormattedMessage
id="xpack.snapshotRestore.deleteSnapshot.confirmModal.deleteMultipleListDescription"
defaultMessage="You are about to delete these snapshots:"
/>
</p>
<ul>
{snapshotIds.map(({ snapshot, repository }) => (
<li key={`${repository}/${snapshot}`}>{snapshot}</li>
))}
</ul>
</Fragment>
) : null}
<p>
<FormattedMessage
id="xpack.snapshotRestore.deleteSnapshot.confirmModal.deleteMultipleDescription"
defaultMessage="Restore operations associated with {count, plural, one {this snapshot} other {these snapshots}} will stop."
values={{ count: snapshotIds.length }}
/>
</p>
{!isSingle && isDeleting ? (
<Fragment>
<EuiCallOut
color="warning"
title={
<Fragment>
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner />
</EuiFlexItem>
<EuiFlexItem>
<FormattedMessage
id="xpack.snapshotRestore.deleteSnapshot.confirmModal.deletingCalloutTitle"
defaultMessage="Deleting snapshots"
/>
</EuiFlexItem>
</EuiFlexGroup>
</Fragment>
}
>
<p>
<FormattedMessage
id="xpack.snapshotRestore.deleteSnapshot.confirmModal.deletingCalloutDescription"
defaultMessage="This may take a few minutes."
/>
</p>
</EuiCallOut>
</Fragment>
) : null}
</EuiConfirmModal>
</EuiOverlayMask>
);
};
return (
<Fragment>
{children(deleteSnapshotPrompt)}
{renderModal()}
</Fragment>
);
};

View file

@ -5,8 +5,8 @@
*/
export const BASE_PATH = '/management/elasticsearch/snapshot_restore';
export const DEFAULT_SECTION: Section = 'repositories';
export type Section = 'repositories' | 'snapshots';
export const DEFAULT_SECTION: Section = 'snapshots';
export type Section = 'repositories' | 'snapshots' | 'restore_status';
// Set a minimum request duration to avoid strange UI flickers
export const MINIMUM_TIMEOUT_MS = 300;
@ -31,6 +31,61 @@ export enum SNAPSHOT_STATE {
INCOMPATIBLE = 'INCOMPATIBLE',
}
const INDEX_SETTING_SUGGESTIONS: string[] = [
'index.number_of_shards',
'index.shard.check_on_startup',
'index.codec',
'index.routing_partition_size',
'index.load_fixed_bitset_filters_eagerly',
'index.number_of_replicas',
'index.auto_expand_replicas',
'index.search.idle.after',
'index.refresh_interval',
'index.max_result_window',
'index.max_inner_result_window',
'index.max_rescore_window',
'index.max_docvalue_fields_search',
'index.max_script_fields',
'index.max_ngram_diff',
'index.max_shingle_diff',
'index.blocks.read_only',
'index.blocks.read_only_allow_delete',
'index.blocks.read',
'index.blocks.write',
'index.blocks.metadata',
'index.max_refresh_listeners',
'index.analyze.max_token_count',
'index.highlight.max_analyzed_offset',
'index.max_terms_count',
'index.max_regex_length',
'index.routing.allocation.enable',
'index.routing.rebalance.enable',
'index.gc_deletes',
'index.default_pipeline',
];
export const UNMODIFIABLE_INDEX_SETTINGS: string[] = [
'index.number_of_shards',
'index.version.created',
'index.uuid',
'index.creation_date',
];
export const UNREMOVABLE_INDEX_SETTINGS: string[] = [
...UNMODIFIABLE_INDEX_SETTINGS,
'index.number_of_replicas',
'index.auto_expand_replicas',
'index.version.upgraded',
];
export const MODIFY_INDEX_SETTINGS_SUGGESTIONS: string[] = INDEX_SETTING_SUGGESTIONS.filter(
setting => !UNMODIFIABLE_INDEX_SETTINGS.includes(setting)
);
export const REMOVE_INDEX_SETTINGS_SUGGESTIONS: string[] = INDEX_SETTING_SUGGESTIONS.filter(
setting => !UNREMOVABLE_INDEX_SETTINGS.includes(setting)
);
// UI Metric constants
export const UIM_APP_NAME = 'snapshot_restore';
export const UIM_REPOSITORY_LIST_LOAD = 'repository_list_load';
@ -45,3 +100,8 @@ export const UIM_SNAPSHOT_SHOW_DETAILS_CLICK = 'snapshot_show_details_click';
export const UIM_SNAPSHOT_DETAIL_PANEL_SUMMARY_TAB = 'snapshot_detail_panel_summary_tab';
export const UIM_SNAPSHOT_DETAIL_PANEL_FAILED_INDICES_TAB =
'snapshot_detail_panel_failed_indices_tab';
export const UIM_SNAPSHOT_DELETE = 'snapshot_delete';
export const UIM_SNAPSHOT_DELETE_MANY = 'snapshot_delete_many';
export const UIM_RESTORE_CREATE = 'restore_create';
export const UIM_RESTORE_LIST_LOAD = 'restore_list_load';
export const UIM_RESTORE_LIST_EXPAND_INDEX = 'restore_list_expand_index';

View file

@ -0,0 +1,14 @@
// Import the EUI global scope so we can use EUI constants
@import 'src/legacy/ui/public/styles/_styling_constants';
// Snapshot and Restore plugin styles
// Prefix all styles with "snapshotRestore" to avoid conflicts.
// Examples
// snapshotRestore
// snapshotRestore__legend
// snapshotRestore__legend--small
// snapshotRestore__legend-isLoading
@import 'components/restore_snapshot_form/restore_snapshot_form';
@import 'sections/home/home';

View file

@ -0,0 +1,21 @@
/*
* 1. Allow child progress bar to expand to container size
* 2. Adjust table styling to differentiate from parent table
*/
.snapshotRestore__shardsTable {
.euiToolTipAnchor {
width: 100%; /* 1 */
}
margin: $euiSizeS 0 $euiSizeS $euiSizeL; /* 2 */
.euiTable {
background: none;
.euiTableRow:last-child > .euiTableRowCell {
border-bottom: none;
}
.euiTableRow:hover,
.euiTableRow:hover > .euiTableRowCell {
background: $euiColorLightestShade;
}
}
}

View file

@ -26,6 +26,7 @@ import { breadcrumbService } from '../../services/navigation';
import { RepositoryList } from './repository_list';
import { SnapshotList } from './snapshot_list';
import { RestoreList } from './restore_list';
import { documentationLinksService } from '../../services/documentation';
interface MatchParams {
@ -44,9 +45,12 @@ export const SnapshotRestoreHome: React.FunctionComponent<RouteComponentProps<Ma
},
} = useAppDependencies();
const tabs = [
const tabs: Array<{
id: Section;
name: React.ReactNode;
}> = [
{
id: 'snapshots' as Section,
id: 'snapshots',
name: (
<FormattedMessage
id="xpack.snapshotRestore.home.snapshotsTabTitle"
@ -55,7 +59,7 @@ export const SnapshotRestoreHome: React.FunctionComponent<RouteComponentProps<Ma
),
},
{
id: 'repositories' as Section,
id: 'repositories',
name: (
<FormattedMessage
id="xpack.snapshotRestore.home.repositoriesTabTitle"
@ -63,6 +67,15 @@ export const SnapshotRestoreHome: React.FunctionComponent<RouteComponentProps<Ma
/>
),
},
{
id: 'restore_status',
name: (
<FormattedMessage
id="xpack.snapshotRestore.home.restoreTabTitle"
defaultMessage="Restore Status"
/>
),
},
];
const onSectionChange = (newSection: Section) => {
@ -83,7 +96,7 @@ export const SnapshotRestoreHome: React.FunctionComponent<RouteComponentProps<Ma
<h1 data-test-subj="appTitle">
<FormattedMessage
id="xpack.snapshotRestore.home.snapshotRestoreTitle"
defaultMessage="Snapshot Repositories"
defaultMessage="Snapshot and Restore"
/>
</h1>
</EuiFlexItem>
@ -96,7 +109,7 @@ export const SnapshotRestoreHome: React.FunctionComponent<RouteComponentProps<Ma
>
<FormattedMessage
id="xpack.snapshotRestore.home.snapshotRestoreDocsLinkText"
defaultMessage="Snapshot docs"
defaultMessage="Snapshot and Restore docs"
/>
</EuiButtonEmpty>
</EuiFlexItem>
@ -107,7 +120,7 @@ export const SnapshotRestoreHome: React.FunctionComponent<RouteComponentProps<Ma
<EuiText color="subdued">
<FormattedMessage
id="xpack.snapshotRestore.home.snapshotRestoreDescription"
defaultMessage="Use repositories to store backups of your Elasticsearch indices and clusters."
defaultMessage="Use repositories to store and recover backups of your Elasticsearch indices and clusters."
/>
</EuiText>
</EuiTitle>
@ -144,6 +157,7 @@ export const SnapshotRestoreHome: React.FunctionComponent<RouteComponentProps<Ma
path={`${BASE_PATH}/snapshots/:repositoryName*/:snapshotId`}
component={SnapshotList}
/>
<Route exact path={`${BASE_PATH}/restore_status`} component={RestoreList} />
</Switch>
</EuiPageContent>
</EuiPageBody>

View file

@ -4,8 +4,6 @@
* 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,
@ -28,7 +26,7 @@ import 'brace/theme/textmate';
import { useAppDependencies } from '../../../../index';
import { documentationLinksService } from '../../../../services/documentation';
import {
loadRepository,
useLoadRepository,
verifyRepository as verifyRepositoryRequest,
} from '../../../../services/http';
import { textService } from '../../../../services/text';
@ -45,24 +43,23 @@ import {
import { BASE_PATH } from '../../../../constants';
import { TypeDetails } from './type_details';
interface Props extends RouteComponentProps {
interface Props {
repositoryName: Repository['name'];
onClose: () => void;
onRepositoryDeleted: (repositoriesDeleted: Array<Repository['name']>) => void;
}
const RepositoryDetailsUi: React.FunctionComponent<Props> = ({
export const RepositoryDetails: React.FunctionComponent<Props> = ({
repositoryName,
onClose,
onRepositoryDeleted,
history,
}) => {
const {
core: { i18n },
} = useAppDependencies();
const { FormattedMessage } = i18n;
const { error, data: repositoryDetails } = loadRepository(repositoryName);
const { error, data: repositoryDetails } = useLoadRepository(repositoryName);
const [verification, setVerification] = useState<RepositoryVerification | undefined>(undefined);
const [isLoadingVerification, setIsLoadingVerification] = useState<boolean>(false);
@ -378,9 +375,7 @@ const RepositoryDetailsUi: React.FunctionComponent<Props> = ({
<EuiFlexItem grow={false}>
<EuiButton
href={history.createHref({
pathname: `${BASE_PATH}/edit_repository/${repositoryName}`,
})}
href={`#${BASE_PATH}/edit_repository/${repositoryName}`}
fill
color="primary"
>
@ -419,5 +414,3 @@ const RepositoryDetailsUi: React.FunctionComponent<Props> = ({
</EuiFlyout>
);
};
export const RepositoryDetails = withRouter(RepositoryDetailsUi);

View file

@ -162,7 +162,7 @@ export const AzureDetails: React.FunctionComponent<Props> = ({ repository }) =>
<EuiSpacer size="s" />
<EuiDescriptionList listItems={listItems} />
<EuiDescriptionList textStyle="reverse" listItems={listItems} />
</Fragment>
);
};

View file

@ -117,7 +117,7 @@ export const FSDetails: React.FunctionComponent<Props> = ({ repository }) => {
<EuiSpacer size="s" />
<EuiDescriptionList listItems={listItems} />
<EuiDescriptionList textStyle="reverse" listItems={listItems} />
</Fragment>
);
};

View file

@ -143,7 +143,7 @@ export const GCSDetails: React.FunctionComponent<Props> = ({ repository }) => {
<EuiSpacer size="s" />
<EuiDescriptionList listItems={listItems} />
<EuiDescriptionList textStyle="reverse" listItems={listItems} />
</Fragment>
);
};

View file

@ -160,7 +160,7 @@ export const HDFSDetails: React.FunctionComponent<Props> = ({ repository }) => {
<EuiSpacer size="s" />
<EuiDescriptionList listItems={listItems} />
<EuiDescriptionList textStyle="reverse" listItems={listItems} />
</Fragment>
);
};

View file

@ -49,7 +49,7 @@ export const ReadonlyDetails: React.FunctionComponent<Props> = ({ repository })
<EuiSpacer size="s" />
<EuiDescriptionList listItems={listItems} />
<EuiDescriptionList textStyle="reverse" listItems={listItems} />
</Fragment>
);
};

View file

@ -195,7 +195,7 @@ export const S3Details: React.FunctionComponent<Props> = ({ repository }) => {
<EuiSpacer size="s" />
<EuiDescriptionList listItems={listItems} />
<EuiDescriptionList textStyle="reverse" listItems={listItems} />
</Fragment>
);
};

View file

@ -12,7 +12,7 @@ import { Repository } from '../../../../../common/types';
import { SectionError, SectionLoading } from '../../../components';
import { BASE_PATH, UIM_REPOSITORY_LIST_LOAD } from '../../../constants';
import { useAppDependencies } from '../../../index';
import { loadRepositories } from '../../../services/http';
import { useLoadRepositories } from '../../../services/http';
import { uiMetricService } from '../../../services/ui_metric';
import { RepositoryDetails } from './repository_details';
@ -42,7 +42,7 @@ export const RepositoryList: React.FunctionComponent<RouteComponentProps<MatchPa
managedRepository: undefined,
},
request: reload,
} = loadRepositories();
} = useLoadRepositories();
const openRepositoryDetailsUrl = (newRepositoryName: Repository['name']): string => {
return history.createHref({

View file

@ -5,8 +5,6 @@
*/
import React, { useState, Fragment } from 'react';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import {
EuiBadge,
EuiButton,
@ -26,7 +24,7 @@ import { useAppDependencies } from '../../../../index';
import { textService } from '../../../../services/text';
import { uiMetricService } from '../../../../services/ui_metric';
interface Props extends RouteComponentProps {
interface Props {
repositories: Repository[];
managedRepository?: string;
reload: () => Promise<void>;
@ -34,13 +32,12 @@ interface Props extends RouteComponentProps {
onRepositoryDeleted: (repositoriesDeleted: Array<Repository['name']>) => void;
}
const RepositoryTableUi: React.FunctionComponent<Props> = ({
export const RepositoryTable: React.FunctionComponent<Props> = ({
repositories,
managedRepository,
reload,
openRepositoryDetailsUrl,
onRepositoryDeleted,
history,
}) => {
const {
core: { i18n },
@ -107,7 +104,7 @@ const RepositoryTableUi: React.FunctionComponent<Props> = ({
);
return (
<EuiToolTip content={label} delay="long">
<EuiToolTip content={label}>
<EuiButtonIcon
aria-label={i18n.translate(
'xpack.snapshotRestore.repositoryList.table.actionEditAriaLabel',
@ -143,7 +140,7 @@ const RepositoryTableUi: React.FunctionComponent<Props> = ({
}
);
return (
<EuiToolTip content={label} delay="long">
<EuiToolTip content={label}>
<EuiButtonIcon
aria-label={i18n.translate(
'xpack.snapshotRestore.repositoryList.table.actionRemoveAriaLabel',
@ -252,9 +249,7 @@ const RepositoryTableUi: React.FunctionComponent<Props> = ({
</EuiFlexItem>
<EuiFlexItem>
<EuiButton
href={history.createHref({
pathname: `${BASE_PATH}/add_repository`,
})}
href={`#${BASE_PATH}/add_repository`}
fill
iconType="plusInCircle"
data-test-subj="registerRepositoryButton"
@ -312,5 +307,3 @@ const RepositoryTableUi: React.FunctionComponent<Props> = ({
/>
);
};
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 { RestoreList } from './restore_list';

View file

@ -0,0 +1,188 @@
/*
* 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, Fragment } from 'react';
import {
EuiEmptyPrompt,
EuiPopover,
EuiButtonEmpty,
EuiContextMenuPanel,
EuiContextMenuItem,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiLoadingSpinner,
} from '@elastic/eui';
import { SectionError, SectionLoading } from '../../../components';
import { UIM_RESTORE_LIST_LOAD } from '../../../constants';
import { useAppDependencies } from '../../../index';
import { useLoadRestores } from '../../../services/http';
import { uiMetricService } from '../../../services/ui_metric';
import { RestoreTable } from './restore_table';
const ONE_SECOND_MS = 1000;
const TEN_SECONDS_MS = 10 * 1000;
const THIRTY_SECONDS_MS = 30 * 1000;
const ONE_MINUTE_MS = 60 * 1000;
const FIVE_MINUTES_MS = 5 * 60 * 1000;
const INTERVAL_OPTIONS: number[] = [
TEN_SECONDS_MS,
THIRTY_SECONDS_MS,
ONE_MINUTE_MS,
FIVE_MINUTES_MS,
];
export const RestoreList: React.FunctionComponent = () => {
const {
core: {
i18n: { FormattedMessage },
},
} = useAppDependencies();
// State for tracking interval picker
const [isIntervalMenuOpen, setIsIntervalMenuOpen] = useState<boolean>(false);
const [currentInterval, setCurrentInterval] = useState<number>(INTERVAL_OPTIONS[1]);
// Load restores
const { error, loading, data: restores = [], polling, changeInterval } = useLoadRestores(
currentInterval
);
// Track component loaded
const { trackUiMetric } = uiMetricService;
useEffect(() => {
trackUiMetric(UIM_RESTORE_LIST_LOAD);
}, []);
let content;
if (loading) {
content = (
<SectionLoading>
<FormattedMessage
id="xpack.snapshotRestore.restoreList.loadingRestoresDescription"
defaultMessage="Loading restores…"
/>
</SectionLoading>
);
} else if (error) {
content = (
<SectionError
title={
<FormattedMessage
id="xpack.snapshotRestore.restoreList.LoadingRestoresErrorMessage"
defaultMessage="Error loading restores"
/>
}
error={error}
/>
);
} else if (restores && restores.length === 0) {
content = (
<EuiEmptyPrompt
iconType="managementApp"
title={
<h1>
<FormattedMessage
id="xpack.snapshotRestore.restoreList.emptyPromptTitle"
defaultMessage="You don't have any snapshot restores"
/>
</h1>
}
body={
<Fragment>
<p>
<FormattedMessage
id="xpack.snapshotRestore.restoreList.emptyPromptDescription"
defaultMessage="Track progress of indices that are restored from snapshots."
/>
</p>
</Fragment>
}
data-test-subj="emptyPrompt"
/>
);
} else {
content = (
<Fragment>
<EuiFlexGroup alignItems="center" justifyContent="flexStart" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiPopover
id="srRestoreListIntervalMenu"
button={
<EuiButtonEmpty
size="xs"
type="text"
iconType="arrowDown"
iconSide="right"
onClick={() => setIsIntervalMenuOpen(!isIntervalMenuOpen)}
>
<FormattedMessage
id="xpack.snapshotRestore.restoreList.intervalMenuButtonText"
defaultMessage="Refresh data every {interval}"
values={{
interval:
currentInterval >= ONE_MINUTE_MS ? (
<FormattedMessage
id="xpack.snapshotRestore.restoreList.intervalMenu.minutesIntervalValue"
defaultMessage="{minutes} {minutes, plural, one {minute} other {minutes}}"
values={{ minutes: Math.ceil(currentInterval / ONE_MINUTE_MS) }}
/>
) : (
<FormattedMessage
id="xpack.snapshotRestore.restoreList.intervalMenu.secondsIntervalValue"
defaultMessage="{seconds} {seconds, plural, one {second} other {seconds}}"
values={{ seconds: Math.ceil(currentInterval / ONE_SECOND_MS) }}
/>
),
}}
/>
</EuiButtonEmpty>
}
isOpen={isIntervalMenuOpen}
closePopover={() => setIsIntervalMenuOpen(false)}
panelPaddingSize="none"
anchorPosition="downLeft"
>
<EuiContextMenuPanel
items={INTERVAL_OPTIONS.map(interval => (
<EuiContextMenuItem
key={interval}
icon="empty"
onClick={() => {
changeInterval(interval);
setCurrentInterval(interval);
setIsIntervalMenuOpen(false);
}}
>
{interval >= ONE_MINUTE_MS ? (
<FormattedMessage
id="xpack.snapshotRestore.restoreList.intervalMenu.minutesIntervalValue"
defaultMessage="{minutes} {minutes, plural, one {minute} other {minutes}}"
values={{ minutes: Math.ceil(interval / ONE_MINUTE_MS) }}
/>
) : (
<FormattedMessage
id="xpack.snapshotRestore.restoreList.intervalMenu.secondsIntervalValue"
defaultMessage="{seconds} {seconds, plural, one {second} other {seconds}}"
values={{ seconds: Math.ceil(interval / ONE_SECOND_MS) }}
/>
)}
</EuiContextMenuItem>
))}
/>
</EuiPopover>
</EuiFlexItem>
<EuiFlexItem grow={false}>{polling ? <EuiLoadingSpinner size="m" /> : null}</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<RestoreTable restores={restores || []} />
</Fragment>
);
}
return <section data-test-subj="restoreList">{content}</section>;
};

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 { RestoreTable } from './restore_table';

View file

@ -0,0 +1,242 @@
/*
* 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 { sortByOrder } from 'lodash';
import { EuiBasicTable, EuiButtonIcon, EuiHealth } from '@elastic/eui';
import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services';
import { SnapshotRestore } from '../../../../../../common/types';
import { UIM_RESTORE_LIST_EXPAND_INDEX } from '../../../../constants';
import { useAppDependencies } from '../../../../index';
import { uiMetricService } from '../../../../services/ui_metric';
import { formatDate } from '../../../../services/text';
import { ShardsTable } from './shards_table';
interface Props {
restores: SnapshotRestore[];
}
export const RestoreTable: React.FunctionComponent<Props> = ({ restores }) => {
const {
core: { i18n },
} = useAppDependencies();
const { FormattedMessage } = i18n;
const { trackUiMetric } = uiMetricService;
// Track restores to show based on sort and pagination state
const [currentRestores, setCurrentRestores] = useState<SnapshotRestore[]>([]);
// Sort state
const [sorting, setSorting] = useState<{
sort: {
field: keyof SnapshotRestore;
direction: 'asc' | 'desc';
};
}>({
sort: {
field: 'isComplete',
direction: 'asc',
},
});
// Pagination state
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: 20,
totalItemCount: restores.length,
pageSizeOptions: [10, 20, 50],
});
// Track expanded indices
const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState<{
[key: string]: React.ReactNode;
}>({});
// On sorting and pagination change
const onTableChange = ({ page = {}, sort = {} }: any) => {
const { index: pageIndex, size: pageSize } = page;
const { field: sortField, direction: sortDirection } = sort;
setSorting({
sort: {
field: sortField,
direction: sortDirection,
},
});
setPagination({
...pagination,
pageIndex,
pageSize,
});
};
// Expand or collapse index details
const toggleIndexRestoreDetails = (restore: SnapshotRestore) => {
const { index, shards } = restore;
const newItemIdToExpandedRowMap = { ...itemIdToExpandedRowMap };
if (newItemIdToExpandedRowMap[index]) {
delete newItemIdToExpandedRowMap[index];
} else {
trackUiMetric(UIM_RESTORE_LIST_EXPAND_INDEX);
newItemIdToExpandedRowMap[index] = <ShardsTable shards={shards} />;
}
setItemIdToExpandedRowMap(newItemIdToExpandedRowMap);
};
// Refresh expanded index details
const refreshIndexRestoreDetails = () => {
const newItemIdToExpandedRowMap: typeof itemIdToExpandedRowMap = {};
restores.forEach(restore => {
const { index, shards } = restore;
if (!itemIdToExpandedRowMap[index]) {
return;
}
newItemIdToExpandedRowMap[index] = <ShardsTable shards={shards} />;
setItemIdToExpandedRowMap(newItemIdToExpandedRowMap);
});
};
// Get restores to show based on sort and pagination state
const getCurrentRestores = (): SnapshotRestore[] => {
const newRestoresList = [...restores];
const {
sort: { field, direction },
} = sorting;
const { pageIndex, pageSize } = pagination;
const sortedRestores = sortByOrder(newRestoresList, [field], [direction]);
return sortedRestores.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize);
};
// Update current restores to show if table changes
useEffect(
() => {
setCurrentRestores(getCurrentRestores());
},
[sorting, pagination]
);
// Update current restores to show if data changes
// as well as any expanded index details
useEffect(
() => {
setPagination({
...pagination,
totalItemCount: restores.length,
});
setCurrentRestores(getCurrentRestores());
refreshIndexRestoreDetails();
},
[restores]
);
const columns = [
{
field: 'index',
name: i18n.translate('xpack.snapshotRestore.restoreList.table.indexColumnTitle', {
defaultMessage: 'Index',
}),
truncateText: true,
sortable: true,
},
{
field: 'isComplete',
name: i18n.translate('xpack.snapshotRestore.restoreList.table.statusColumnTitle', {
defaultMessage: 'Status',
}),
truncateText: true,
sortable: true,
render: (isComplete: SnapshotRestore['isComplete']) =>
isComplete ? (
<EuiHealth color="success">
<FormattedMessage
id="xpack.snapshotRestore.restoreList.table.statusColumn.completeLabel"
defaultMessage="Complete"
/>
</EuiHealth>
) : (
<EuiHealth color="warning">
<FormattedMessage
id="xpack.snapshotRestore.restoreList.table.statusColumn.inProgressLabel"
defaultMessage="In progress"
/>
</EuiHealth>
),
},
{
field: 'latestActivityTimeInMillis',
name: i18n.translate('xpack.snapshotRestore.restoreList.table.lastActivityTitle', {
defaultMessage: 'Last activity',
}),
truncateText: true,
render: (
latestActivityTimeInMillis: SnapshotRestore['latestActivityTimeInMillis'],
{ isComplete }: SnapshotRestore
) => {
return isComplete ? (
formatDate(latestActivityTimeInMillis)
) : (
<FormattedMessage
id="xpack.snapshotRestore.restoreList.table.lastActivityColumn.nowLabel"
defaultMessage="now"
/>
);
},
},
{
field: 'shards',
name: i18n.translate('xpack.snapshotRestore.restoreList.table.shardsCompletedTitle', {
defaultMessage: 'Shards completed',
}),
truncateText: true,
render: (shards: SnapshotRestore['shards']) => {
return shards.filter(shard => Boolean(shard.stopTimeInMillis)).length;
},
},
{
field: 'shards',
name: i18n.translate('xpack.snapshotRestore.restoreList.table.shardsInProgressTitle', {
defaultMessage: 'Shards in progress',
}),
truncateText: true,
render: (shards: SnapshotRestore['shards']) => {
return shards.filter(shard => !Boolean(shard.stopTimeInMillis)).length;
},
},
{
align: RIGHT_ALIGNMENT,
width: '40px',
isExpander: true,
render: (item: SnapshotRestore) => (
<EuiButtonIcon
onClick={() => toggleIndexRestoreDetails(item)}
aria-label={itemIdToExpandedRowMap[item.index] ? 'Collapse' : 'Expand'}
iconType={itemIdToExpandedRowMap[item.index] ? 'arrowUp' : 'arrowDown'}
/>
),
},
];
return (
<EuiBasicTable
items={currentRestores}
itemId="index"
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
isExpandable={true}
columns={columns}
sorting={sorting}
pagination={pagination}
onChange={onTableChange}
rowProps={(restore: SnapshotRestore) => ({
'data-test-subj': 'row',
onClick: () => toggleIndexRestoreDetails(restore),
})}
cellProps={(item: any, column: any) => ({
'data-test-subj': `cell`,
})}
data-test-subj="restoresTable"
/>
);
};

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 from 'react';
import {
EuiBasicTable,
EuiProgress,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
EuiText,
EuiSpacer,
EuiToolTip,
} from '@elastic/eui';
import { SnapshotRestore, SnapshotRestoreShard } from '../../../../../../common/types';
import { useAppDependencies } from '../../../../index';
import { formatDate } from '../../../../services/text';
interface Props {
shards: SnapshotRestore['shards'];
}
export const ShardsTable: React.FunctionComponent<Props> = ({ shards }) => {
const {
core: { i18n },
} = useAppDependencies();
const { FormattedMessage } = i18n;
const Progress = ({
total,
restored,
percent,
}: {
total: number;
restored: number;
percent: string;
}) => (
<EuiToolTip
position="top"
content={i18n.translate('xpack.snapshotRestore.restoreList.shardTable.progressTooltipLabel', {
defaultMessage: '{restored} of {total} restored',
values: {
restored,
total,
},
})}
>
<EuiText size="xs" textAlign="center" style={{ width: '100%' }}>
<EuiProgress value={total === 0 ? 1 : restored} max={total === 0 ? 1 : total} size="xs" />
<EuiSpacer size="xs" />
{percent}
</EuiText>
</EuiToolTip>
);
const columns = [
{
field: 'id',
name: i18n.translate('xpack.snapshotRestore.restoreList.shardTable.indexColumnTitle', {
defaultMessage: 'ID',
}),
width: '40px',
render: (id: SnapshotRestoreShard['id'], shard: SnapshotRestoreShard) =>
shard.primary ? (
<EuiFlexGroup alignItems="center" gutterSize="xs">
<EuiFlexItem grow={false}>{id}</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip
position="right"
content={
<FormattedMessage
id="xpack.snapshotRestore.restoreList.shardTable.primaryTooltipLabel"
defaultMessage="Primary"
/>
}
>
<strong>
<FormattedMessage
id="xpack.snapshotRestore.restoreList.shardTable.primaryAbbreviationText"
defaultMessage="P"
/>
</strong>
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
) : (
id
),
},
{
field: 'stage',
name: i18n.translate('xpack.snapshotRestore.restoreList.shardTable.stageColumnTitle', {
defaultMessage: 'Stage',
}),
},
{
field: 'startTimeInMillis',
name: i18n.translate('xpack.snapshotRestore.restoreList.shardTable.startTimeColumnTitle', {
defaultMessage: 'Start time',
}),
render: (startTimeInMillis: SnapshotRestoreShard['startTimeInMillis']) =>
startTimeInMillis ? formatDate(startTimeInMillis) : <EuiLoadingSpinner size="m" />,
},
{
field: 'stopTimeInMillis',
name: i18n.translate('xpack.snapshotRestore.restoreList.shardTable.endTimeColumnTitle', {
defaultMessage: 'End time',
}),
render: (stopTimeInMillis: SnapshotRestoreShard['stopTimeInMillis']) =>
stopTimeInMillis ? formatDate(stopTimeInMillis) : <EuiLoadingSpinner size="m" />,
},
{
field: 'totalTimeInMillis',
name: i18n.translate('xpack.snapshotRestore.restoreList.shardTable.durationColumnTitle', {
defaultMessage: 'Duration',
}),
render: (totalTimeInMillis: SnapshotRestoreShard['totalTimeInMillis']) =>
totalTimeInMillis ? (
<FormattedMessage
id="xpack.snapshotRestore.restoreList.shardTable.durationValue"
defaultMessage="{seconds} {seconds, plural, one {second} other {seconds}}"
values={{ seconds: Math.ceil(totalTimeInMillis / 1000) }}
/>
) : (
<EuiLoadingSpinner size="m" />
),
},
{
field: 'repository',
name: i18n.translate('xpack.snapshotRestore.restoreList.shardTable.repositoryColumnTitle', {
defaultMessage: 'Repository',
}),
},
{
field: 'snapshot',
name: i18n.translate('xpack.snapshotRestore.restoreList.shardTable.snapshotColumnTitle', {
defaultMessage: 'Snapshot',
}),
},
{
field: 'version',
name: i18n.translate('xpack.snapshotRestore.restoreList.shardTable.versionColumnTitle', {
defaultMessage: 'Version',
}),
},
{
field: 'targetHost',
name: i18n.translate('xpack.snapshotRestore.restoreList.shardTable.targetHostColumnTitle', {
defaultMessage: 'Target host',
}),
},
{
field: 'targetNode',
name: i18n.translate('xpack.snapshotRestore.restoreList.shardTable.targetNodeColumnTitle', {
defaultMessage: 'Target node',
}),
},
{
field: 'bytesTotal',
name: i18n.translate('xpack.snapshotRestore.restoreList.shardTable.bytesColumnTitle', {
defaultMessage: 'Bytes',
}),
render: (
bytesTotal: SnapshotRestoreShard['bytesTotal'],
{ bytesRecovered, bytesPercent }: SnapshotRestoreShard
) => <Progress total={bytesTotal} restored={bytesRecovered} percent={bytesPercent} />,
},
{
field: 'filesTotal',
name: i18n.translate('xpack.snapshotRestore.restoreList.shardTable.filesColumnTitle', {
defaultMessage: 'Files',
}),
render: (
filesTotal: SnapshotRestoreShard['filesTotal'],
{ filesRecovered, filesPercent }: SnapshotRestoreShard
) => <Progress total={filesTotal} restored={filesRecovered} percent={filesPercent} />,
},
];
return (
<EuiBasicTable
className="snapshotRestore__shardsTable"
compressed={true}
items={shards}
columns={columns}
/>
);
};

View file

@ -5,6 +5,7 @@
*/
import {
EuiButton,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
@ -20,23 +21,25 @@ import {
EuiTitle,
} from '@elastic/eui';
import React, { Fragment, useState, useEffect } from 'react';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import { SectionError, SectionLoading } from '../../../../components';
import { SectionError, SectionLoading, SnapshotDeleteProvider } from '../../../../components';
import { useAppDependencies } from '../../../../index';
import {
BASE_PATH,
UIM_SNAPSHOT_DETAIL_PANEL_SUMMARY_TAB,
UIM_SNAPSHOT_DETAIL_PANEL_FAILED_INDICES_TAB,
SNAPSHOT_STATE,
} from '../../../../constants';
import { loadSnapshot } from '../../../../services/http';
import { useLoadSnapshot } from '../../../../services/http';
import { linkToRepository } from '../../../../services/navigation';
import { uiMetricService } from '../../../../services/ui_metric';
import { TabSummary, TabFailures } from './tabs';
interface Props extends RouteComponentProps {
interface Props {
repositoryName: string;
snapshotId: string;
onClose: () => void;
onSnapshotDeleted: (snapshotsDeleted: Array<{ snapshot: string; repository: string }>) => void;
}
const TAB_SUMMARY = 'summary';
@ -47,17 +50,18 @@ const panelTypeToUiMetricMap: { [key: string]: string } = {
[TAB_FAILURES]: UIM_SNAPSHOT_DETAIL_PANEL_FAILED_INDICES_TAB,
};
const SnapshotDetailsUi: React.FunctionComponent<Props> = ({
export const SnapshotDetails: React.FunctionComponent<Props> = ({
repositoryName,
snapshotId,
onClose,
onSnapshotDeleted,
}) => {
const {
core: { i18n },
} = useAppDependencies();
const { FormattedMessage } = i18n;
const { trackUiMetric } = uiMetricService;
const { error, data: snapshotDetails } = loadSnapshot(repositoryName, snapshotId);
const { error, data: snapshotDetails } = useLoadSnapshot(repositoryName, snapshotId);
const [activeTab, setActiveTab] = useState<string>(TAB_SUMMARY);
@ -178,6 +182,53 @@ const SnapshotDetailsUi: React.FunctionComponent<Props> = ({
/>
</EuiButtonEmpty>
</EuiFlexItem>
{snapshotDetails ? (
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<SnapshotDeleteProvider>
{deleteSnapshotPrompt => {
return (
<EuiButtonEmpty
color="danger"
data-test-subj="srSnapshotDetailsDeleteActionButton"
onClick={() =>
deleteSnapshotPrompt(
[{ repository: repositoryName, snapshot: snapshotId }],
onSnapshotDeleted
)
}
>
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.deleteButtonLabel"
defaultMessage="Delete"
/>
</EuiButtonEmpty>
);
}}
</SnapshotDeleteProvider>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
href={`#${BASE_PATH}/restore/${repositoryName}/${snapshotId}`}
fill
color="primary"
isDisabled={
snapshotDetails.state !== SNAPSHOT_STATE.SUCCESS &&
snapshotDetails.state !== SNAPSHOT_STATE.PARTIAL
}
>
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.restoreButtonLabel"
defaultMessage="Restore"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
);
};
@ -223,5 +274,3 @@ const SnapshotDetailsUi: React.FunctionComponent<Props> = ({
</EuiFlyout>
);
};
export const SnapshotDetails = withRouter(SnapshotDetailsUi);

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import React, { useState } from 'react';
import {
EuiDescriptionList,
@ -12,9 +12,11 @@ import {
EuiDescriptionListTitle,
EuiFlexGroup,
EuiFlexItem,
EuiLink,
EuiLoadingSpinner,
EuiText,
EuiTitle,
EuiIcon,
} from '@elastic/eui';
import { SNAPSHOT_STATE } from '../../../../../constants';
@ -34,21 +36,6 @@ export const TabSummary: React.SFC<Props> = ({ snapshotDetails }) => {
},
} = 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,
@ -63,15 +50,32 @@ export const TabSummary: React.SFC<Props> = ({ snapshotDetails }) => {
uuid,
} = snapshotDetails;
const indicesList = indices.length ? (
// Only show 10 indices initially
const [isShowingFullIndicesList, setIsShowingFullIndicesList] = useState<boolean>(false);
const hiddenIndicesCount = indices.length > 10 ? indices.length - 10 : 0;
const shortIndicesList = indices.length ? (
<ul>
{indices.map((index: string) => (
{[...indices].splice(0, 10).map((index: string) => (
<li key={index}>
<EuiTitle size="xs">
<span>{index}</span>
</EuiTitle>
</li>
))}
{hiddenIndicesCount ? (
<li key="hiddenIndicesCount">
<EuiTitle size="xs">
<EuiLink onClick={() => setIsShowingFullIndicesList(true)}>
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.itemIndicesShowAllLink"
defaultMessage="Show {count} more {count, plural, one {index} other {indices}}"
values={{ count: hiddenIndicesCount }}
/>{' '}
<EuiIcon type="arrowDown" />
</EuiLink>
</EuiTitle>
</li>
) : null}
</ul>
) : (
<FormattedMessage
@ -79,6 +83,32 @@ export const TabSummary: React.SFC<Props> = ({ snapshotDetails }) => {
defaultMessage="-"
/>
);
const fullIndicesList =
indices.length && indices.length > 10 ? (
<ul>
{indices.map((index: string) => (
<li key={index}>
<EuiTitle size="xs">
<span>{index}</span>
</EuiTitle>
</li>
))}
{hiddenIndicesCount ? (
<li key="hiddenIndicesCount">
<EuiTitle size="xs">
<EuiLink onClick={() => setIsShowingFullIndicesList(false)}>
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.itemIndicesCollapseAllLink"
defaultMessage="Hide {count, plural, one {# index} other {# indices}}"
values={{ count: hiddenIndicesCount }}
/>{' '}
<EuiIcon type="arrowUp" />
</EuiLink>
</EuiTitle>
</li>
) : null}
</ul>
) : null;
return (
<EuiDescriptionList textStyle="reverse">
@ -133,7 +163,17 @@ export const TabSummary: React.SFC<Props> = ({ snapshotDetails }) => {
</EuiDescriptionListTitle>
<EuiDescriptionListDescription className="eui-textBreakWord" data-test-subj="value">
{includeGlobalStateToHumanizedMap[includeGlobalState]}
{includeGlobalState ? (
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.itemIncludeGlobalStateYesLabel"
defaultMessage="Yes"
/>
) : (
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.itemIncludeGlobalStateNoLabel"
defaultMessage="No"
/>
)}
</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>
@ -149,7 +189,7 @@ export const TabSummary: React.SFC<Props> = ({ snapshotDetails }) => {
</EuiDescriptionListTitle>
<EuiDescriptionListDescription className="eui-textBreakWord" data-test-subj="value">
<EuiText>{indicesList}</EuiText>
<EuiText>{isShowingFullIndicesList ? fullIndicesList : shortIndicesList}</EuiText>
</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -14,7 +14,7 @@ import { SectionError, SectionLoading } from '../../../components';
import { BASE_PATH, UIM_SNAPSHOT_LIST_LOAD } from '../../../constants';
import { useAppDependencies } from '../../../index';
import { documentationLinksService } from '../../../services/documentation';
import { loadSnapshots } from '../../../services/http';
import { useLoadSnapshots } from '../../../services/http';
import { linkToRepositories } from '../../../services/navigation';
import { uiMetricService } from '../../../services/ui_metric';
@ -44,7 +44,7 @@ export const SnapshotList: React.FunctionComponent<RouteComponentProps<MatchPara
loading,
data: { snapshots = [], repositories = [], errors = {} },
request: reload,
} = loadSnapshots();
} = useLoadSnapshots();
const openSnapshotDetailsUrl = (
repositoryNameToOpen: string,
@ -61,6 +61,23 @@ export const SnapshotList: React.FunctionComponent<RouteComponentProps<MatchPara
history.push(`${BASE_PATH}/snapshots`);
};
const onSnapshotDeleted = (
snapshotsDeleted: Array<{ snapshot: string; repository: string }>
): void => {
if (
repositoryName &&
snapshotId &&
snapshotsDeleted.find(
({ snapshot, repository }) => snapshot === snapshotId && repository === repositoryName
)
) {
closeSnapshotDetails();
}
if (snapshotsDeleted.length) {
reload();
}
};
// Allow deeplinking to list pre-filtered by repository name
const [filteredRepository, setFilteredRepository] = useState<string | undefined>(undefined);
useEffect(() => {
@ -229,44 +246,46 @@ export const SnapshotList: React.FunctionComponent<RouteComponentProps<MatchPara
);
} else {
const repositoryErrorsWarning = Object.keys(errors).length ? (
<EuiCallOut
title={
<Fragment>
<EuiCallOut
title={
<FormattedMessage
id="xpack.snapshotRestore.repositoryWarningTitle"
defaultMessage="Some repositories contain errors"
/>
}
color="warning"
iconType="alert"
>
<FormattedMessage
id="xpack.snapshotRestore.repositoryWarningTitle"
defaultMessage="Some repositories contain errors"
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>
),
}}
/>
}
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>
</EuiCallOut>
<EuiSpacer />
</Fragment>
) : null;
content = (
<Fragment>
{repositoryErrorsWarning}
<EuiSpacer />
<SnapshotTable
snapshots={snapshots}
repositories={repositories}
reload={reload}
openSnapshotDetailsUrl={openSnapshotDetailsUrl}
onSnapshotDeleted={onSnapshotDeleted}
repositoryFilter={filteredRepository}
/>
</Fragment>
@ -280,6 +299,7 @@ export const SnapshotList: React.FunctionComponent<RouteComponentProps<MatchPara
repositoryName={repositoryName}
snapshotId={snapshotId}
onClose={closeSnapshotDetails}
onSnapshotDeleted={onSnapshotDeleted}
/>
) : null}
{content}

View file

@ -4,16 +4,24 @@
* 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 React, { useState } from 'react';
import {
EuiButton,
EuiInMemoryTable,
EuiLink,
Query,
EuiLoadingSpinner,
EuiToolTip,
EuiButtonIcon,
} from '@elastic/eui';
import { SnapshotDetails } from '../../../../../../common/types';
import { SNAPSHOT_STATE, UIM_SNAPSHOT_SHOW_DETAILS_CLICK } from '../../../../constants';
import { BASE_PATH, SNAPSHOT_STATE, UIM_SNAPSHOT_SHOW_DETAILS_CLICK } from '../../../../constants';
import { useAppDependencies } from '../../../../index';
import { formatDate } from '../../../../services/text';
import { linkToRepository } from '../../../../services/navigation';
import { uiMetricService } from '../../../../services/ui_metric';
import { DataPlaceholder } from '../../../../components';
import { DataPlaceholder, SnapshotDeleteProvider } from '../../../../components';
interface Props {
snapshots: SnapshotDetails[];
@ -21,6 +29,7 @@ interface Props {
reload: () => Promise<void>;
openSnapshotDetailsUrl: (repositoryName: string, snapshotId: string) => string;
repositoryFilter?: string;
onSnapshotDeleted: (snapshotsDeleted: Array<{ snapshot: string; repository: string }>) => void;
}
export const SnapshotTable: React.FunctionComponent<Props> = ({
@ -28,6 +37,7 @@ export const SnapshotTable: React.FunctionComponent<Props> = ({
repositories,
reload,
openSnapshotDetailsUrl,
onSnapshotDeleted,
repositoryFilter,
}) => {
const {
@ -35,6 +45,7 @@ export const SnapshotTable: React.FunctionComponent<Props> = ({
} = useAppDependencies();
const { FormattedMessage } = i18n;
const { trackUiMetric } = uiMetricService;
const [selectedItems, setSelectedItems] = useState<SnapshotDetails[]>([]);
const columns = [
{
@ -67,6 +78,36 @@ export const SnapshotTable: React.FunctionComponent<Props> = ({
</EuiLink>
),
},
{
field: 'indices',
name: i18n.translate('xpack.snapshotRestore.snapshotList.table.indicesColumnTitle', {
defaultMessage: 'Indices',
}),
truncateText: true,
sortable: true,
width: '100px',
render: (indices: string[]) => indices.length,
},
{
field: 'shards.total',
name: i18n.translate('xpack.snapshotRestore.snapshotList.table.shardsColumnTitle', {
defaultMessage: 'Shards',
}),
truncateText: true,
sortable: true,
width: '100px',
render: (totalShards: number) => totalShards,
},
{
field: 'shards.failed',
name: i18n.translate('xpack.snapshotRestore.snapshotList.table.failedShardsColumnTitle', {
defaultMessage: 'Failed shards',
}),
truncateText: true,
sortable: true,
width: '100px',
render: (failedShards: number) => failedShards,
},
{
field: 'startTimeInMillis',
name: i18n.translate('xpack.snapshotRestore.snapshotList.table.startTimeColumnTitle', {
@ -102,34 +143,85 @@ export const SnapshotTable: React.FunctionComponent<Props> = ({
},
},
{
field: 'indices',
name: i18n.translate('xpack.snapshotRestore.snapshotList.table.indicesColumnTitle', {
defaultMessage: 'Indices',
name: i18n.translate('xpack.snapshotRestore.snapshotList.table.actionsColumnTitle', {
defaultMessage: 'Actions',
}),
truncateText: true,
sortable: true,
actions: [
{
render: ({ snapshot, repository, state }: SnapshotDetails) => {
const canRestore = state === SNAPSHOT_STATE.SUCCESS || state === SNAPSHOT_STATE.PARTIAL;
const label = canRestore
? i18n.translate('xpack.snapshotRestore.snapshotList.table.actionRestoreTooltip', {
defaultMessage: 'Restore',
})
: state === SNAPSHOT_STATE.IN_PROGRESS
? i18n.translate(
'xpack.snapshotRestore.snapshotList.table.actionRestoreDisabledInProgressTooltip',
{
defaultMessage: `Can't restore in-progress snapshot`,
}
)
: i18n.translate(
'xpack.snapshotRestore.snapshotList.table.actionRestoreDisabledInvalidTooltip',
{
defaultMessage: `Can't restore invalid snapshot`,
}
);
return (
<EuiToolTip content={label}>
<EuiButtonIcon
aria-label={i18n.translate(
'xpack.snapshotRestore.snapshotList.table.actionRestoreAriaLabel',
{
defaultMessage: 'Store snapshot `{name}`',
values: { name: snapshot },
}
)}
iconType="importAction"
color="primary"
data-test-subj="srsnapshotListRestoreActionButton"
href={`#${BASE_PATH}/restore/${repository}/${snapshot}`}
isDisabled={!canRestore}
/>
</EuiToolTip>
);
},
},
{
render: ({ snapshot, repository }: SnapshotDetails) => {
return (
<SnapshotDeleteProvider>
{deleteSnapshotPrompt => {
const label = i18n.translate(
'xpack.snapshotRestore.snapshotList.table.actionDeleteTooltip',
{ defaultMessage: 'Delete' }
);
return (
<EuiToolTip content={label}>
<EuiButtonIcon
aria-label={i18n.translate(
'xpack.snapshotRestore.snapshotList.table.actionDeleteAriaLabel',
{
defaultMessage: 'Delete snapshot `{name}`',
values: { name: snapshot },
}
)}
iconType="trash"
color="danger"
data-test-subj="srsnapshotListDeleteActionButton"
onClick={() =>
deleteSnapshotPrompt([{ snapshot, repository }], onSnapshotDeleted)
}
/>
</EuiToolTip>
);
}}
</SnapshotDeleteProvider>
);
},
},
],
width: '100px',
render: (indices: string[]) => indices.length,
},
{
field: 'shards.total',
name: i18n.translate('xpack.snapshotRestore.snapshotList.table.shardsColumnTitle', {
defaultMessage: 'Shards',
}),
truncateText: true,
sortable: true,
width: '100px',
render: (totalShards: number) => totalShards,
},
{
field: 'shards.failed',
name: i18n.translate('xpack.snapshotRestore.snapshotList.table.failedShardsColumnTitle', {
defaultMessage: 'Failed shards',
}),
truncateText: true,
sortable: true,
width: '100px',
render: (failedShards: number) => failedShards,
},
];
@ -154,7 +246,44 @@ export const SnapshotTable: React.FunctionComponent<Props> = ({
},
};
const selection = {
onSelectionChange: (newSelectedItems: SnapshotDetails[]) => setSelectedItems(newSelectedItems),
};
const search = {
toolsLeft: selectedItems.length ? (
<SnapshotDeleteProvider>
{(
deleteSnapshotPrompt: (
ids: Array<{ snapshot: string; repository: string }>,
onSuccess?: (snapshotsDeleted: Array<{ snapshot: string; repository: string }>) => void
) => void
) => {
return (
<EuiButton
onClick={() =>
deleteSnapshotPrompt(
selectedItems.map(({ snapshot, repository }) => ({ snapshot, repository })),
onSnapshotDeleted
)
}
color="danger"
data-test-subj="srSnapshotListBulkDeleteActionButton"
>
<FormattedMessage
id="xpack.snapshotRestore.snapshotList.table.deleteSnapshotButton"
defaultMessage="Delete {count, plural, one {snapshot} other {snapshots}}"
values={{
count: selectedItems.length,
}}
/>
</EuiButton>
);
}}
</SnapshotDeleteProvider>
) : (
undefined
),
toolsRight: (
<EuiButton
color="secondary"
@ -197,10 +326,12 @@ export const SnapshotTable: React.FunctionComponent<Props> = ({
return (
<EuiInMemoryTable
items={snapshots}
itemId="name"
itemId="uuid"
columns={columns}
search={search}
sorting={sorting}
isSelectable={true}
selection={selection}
pagination={pagination}
rowProps={() => ({
'data-test-subj': 'row',

View file

@ -7,3 +7,4 @@
export { SnapshotRestoreHome } from './home';
export { RepositoryAdd } from './repository_add';
export { RepositoryEdit } from './repository_edit';
export { RestoreSnapshot } from './restore_snapshot';

View file

@ -13,7 +13,7 @@ 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';
import { editRepository, useLoadRepository } from '../../services/http';
interface MatchParams {
name: string;
@ -48,7 +48,7 @@ export const RepositoryEdit: React.FunctionComponent<RouteComponentProps<MatchPa
error: repositoryError,
loading: loadingRepository,
data: repositoryData,
} = loadRepository(name);
} = useLoadRepository(name);
// Update repository state when data is loaded
useEffect(

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 { RestoreSnapshot } from './restore_snapshot';

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 { SnapshotDetails, RestoreSettings } from '../../../../common/types';
import { BASE_PATH } from '../../constants';
import { SectionError, SectionLoading, RestoreSnapshotForm } from '../../components';
import { useAppDependencies } from '../../index';
import { breadcrumbService } from '../../services/navigation';
import { useLoadSnapshot, executeRestore } from '../../services/http';
interface MatchParams {
repositoryName: string;
snapshotId: string;
}
export const RestoreSnapshot: React.FunctionComponent<RouteComponentProps<MatchParams>> = ({
match: {
params: { repositoryName, snapshotId },
},
history,
}) => {
const {
core: { i18n },
} = useAppDependencies();
const { FormattedMessage } = i18n;
// Set breadcrumb
useEffect(() => {
breadcrumbService.setBreadcrumbs('restoreSnapshot');
}, []);
// Snapshot details state with default empty snapshot
const [snapshotDetails, setSnapshotDetails] = useState<SnapshotDetails | {}>({});
// Load snapshot
const { error: snapshotError, loading: loadingSnapshot, data: snapshotData } = useLoadSnapshot(
repositoryName,
snapshotId
);
// Update repository state when data is loaded
useEffect(
() => {
if (snapshotData) {
setSnapshotDetails(snapshotData);
}
},
[snapshotData]
);
// Saving repository states
const [isSaving, setIsSaving] = useState<boolean>(false);
const [saveError, setSaveError] = useState<any>(null);
// Execute restore
const onSave = async (restoreSettings: RestoreSettings) => {
setIsSaving(true);
setSaveError(null);
const { error } = await executeRestore(repositoryName, snapshotId, restoreSettings);
if (error) {
setIsSaving(false);
setSaveError(error);
} else {
// Wait a few seconds before redirecting so that restore information has time to
// populate into master node
setTimeout(() => {
setIsSaving(false);
history.push(`${BASE_PATH}/restore_status`);
}, 5 * 1000);
}
};
const renderLoading = () => {
return (
<SectionLoading>
<FormattedMessage
id="xpack.snapshotRestore.restoreSnapshot.loadingSnapshotDescription"
defaultMessage="Loading snapshot details…"
/>
</SectionLoading>
);
};
const renderError = () => {
const notFound = snapshotError.status === 404;
const errorObject = notFound
? {
data: {
error: i18n.translate(
'xpack.snapshotRestore.restoreSnapshot.snapshotNotFoundErrorMessage',
{
defaultMessage: `The snapshot '{snapshot}' does not exist in repository '{repository}'.`,
values: {
snapshot: snapshotId,
repository: repositoryName,
},
}
),
},
}
: snapshotError;
return (
<SectionError
title={
<FormattedMessage
id="xpack.snapshotRestore.restoreSnapshot.loadingSnapshotErrorTitle"
defaultMessage="Error loading snapshot details"
/>
}
error={errorObject}
/>
);
};
const renderSaveError = () => {
return saveError ? (
<SectionError
title={
<FormattedMessage
id="xpack.snapshotRestore.restoreSnapshot.executeRestoreErrorTitle"
defaultMessage="Unable to execute restore"
/>
}
error={saveError}
/>
) : null;
};
const clearSaveError = () => {
setSaveError(null);
};
const renderContent = () => {
if (loadingSnapshot) {
return renderLoading();
}
if (snapshotError) {
return renderError();
}
return (
<RestoreSnapshotForm
snapshotDetails={snapshotDetails as SnapshotDetails}
isSaving={isSaving}
saveError={renderSaveError()}
clearSaveError={clearSaveError}
onSave={onSave}
/>
);
};
return (
<EuiPageBody>
<EuiPageContent>
<EuiTitle size="m">
<h1>
<FormattedMessage
id="xpack.snapshotRestore.restoreSnapshotTitle"
defaultMessage="Restore '{snapshot}'"
values={{ snapshot: snapshotId }}
/>
</h1>
</EuiTitle>
<EuiSpacer size="l" />
{renderContent()}
</EuiPageContent>
</EuiPageBody>
);
};

View file

@ -44,6 +44,14 @@ class DocumentationLinksService {
public getSnapshotDocUrl() {
return `${this.esDocBasePath}/modules-snapshots.html#_snapshot`;
}
public getRestoreDocUrl() {
return `${this.esDocBasePath}/modules-snapshots.html#restore-snapshot`;
}
public getRestoreIndexSettingsUrl() {
return `${this.esDocBasePath}/modules-snapshots.html#_changing_index_settings_during_restore`;
}
}
export const documentationLinksService = new DocumentationLinksService();

View file

@ -7,7 +7,7 @@ import { API_BASE_PATH } from '../../../../common/constants';
import { httpService } from './http';
import { useRequest } from './use_request';
export const loadPermissions = () => {
export const useLoadPermissions = () => {
return useRequest({
path: httpService.addBasePath(`${API_BASE_PATH}permissions`),
method: 'get',

View file

@ -7,3 +7,4 @@ export { httpService } from './http';
export * from './app_requests';
export * from './repository_requests';
export * from './snapshot_requests';
export * from './restore_requests';

View file

@ -6,7 +6,6 @@
import { API_BASE_PATH } from '../../../../common/constants';
import { Repository, EmptyRepository } from '../../../../common/types';
import {
MINIMUM_TIMEOUT_MS,
UIM_REPOSITORY_CREATE,
UIM_REPOSITORY_UPDATE,
UIM_REPOSITORY_DELETE,
@ -16,16 +15,15 @@ import {
import { httpService } from './http';
import { sendRequest, useRequest } from './use_request';
export const loadRepositories = () => {
export const useLoadRepositories = () => {
return useRequest({
path: httpService.addBasePath(`${API_BASE_PATH}repositories`),
method: 'get',
initialData: [],
timeout: MINIMUM_TIMEOUT_MS,
});
};
export const loadRepository = (name: Repository['name']) => {
export const useLoadRepository = (name: Repository['name']) => {
return useRequest({
path: httpService.addBasePath(`${API_BASE_PATH}repositories/${encodeURIComponent(name)}`),
method: 'get',
@ -42,7 +40,7 @@ export const verifyRepository = (name: Repository['name']) => {
});
};
export const loadRepositoryTypes = () => {
export const useLoadRepositoryTypes = () => {
return useRequest({
path: httpService.addBasePath(`${API_BASE_PATH}repository_types`),
method: 'get',

View file

@ -0,0 +1,34 @@
/*
* 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 { RestoreSettings } from '../../../../common/types';
import { UIM_RESTORE_CREATE } from '../../constants';
import { httpService } from './http';
import { sendRequest, useRequest } from './use_request';
export const executeRestore = async (
repository: string,
snapshot: string,
restoreSettings: RestoreSettings
) => {
return sendRequest({
path: httpService.addBasePath(
`${API_BASE_PATH}restore/${encodeURIComponent(repository)}/${encodeURIComponent(snapshot)}`
),
method: 'post',
body: restoreSettings,
uimActionType: UIM_RESTORE_CREATE,
});
};
export const useLoadRestores = (interval?: number) => {
return useRequest({
path: httpService.addBasePath(`${API_BASE_PATH}restores`),
method: 'get',
initialData: [],
interval,
});
};

View file

@ -4,19 +4,18 @@
* 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 { UIM_SNAPSHOT_DELETE, UIM_SNAPSHOT_DELETE_MANY } from '../../constants';
import { httpService } from './http';
import { useRequest } from './use_request';
import { sendRequest, useRequest } from './use_request';
export const loadSnapshots = () =>
export const useLoadSnapshots = () =>
useRequest({
path: httpService.addBasePath(`${API_BASE_PATH}snapshots`),
method: 'get',
initialData: [],
timeout: MINIMUM_TIMEOUT_MS,
});
export const loadSnapshot = (repositoryName: string, snapshotId: string) =>
export const useLoadSnapshot = (repositoryName: string, snapshotId: string) =>
useRequest({
path: httpService.addBasePath(
`${API_BASE_PATH}snapshots/${encodeURIComponent(repositoryName)}/${encodeURIComponent(
@ -25,3 +24,17 @@ export const loadSnapshot = (repositoryName: string, snapshotId: string) =>
),
method: 'get',
});
export const deleteSnapshots = async (
snapshotIds: Array<{ snapshot: string; repository: string }>
) => {
return sendRequest({
path: httpService.addBasePath(
`${API_BASE_PATH}snapshots/${snapshotIds
.map(({ snapshot, repository }) => encodeURIComponent(`${repository}/${snapshot}`))
.join(',')}`
),
method: 'delete',
uimActionType: snapshotIds.length > 1 ? UIM_SNAPSHOT_DELETE_MANY : UIM_SNAPSHOT_DELETE,
});
};

View file

@ -3,7 +3,7 @@
* 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 { useEffect, useState, useRef } from 'react';
import { httpService } from './index';
import { uiMetricService } from '../ui_metric';
@ -52,7 +52,6 @@ export const sendRequest = async ({
interface UseRequest extends SendRequest {
interval?: number;
initialData?: any;
timeout?: number;
}
export const useRequest = ({
@ -61,20 +60,34 @@ export const useRequest = ({
body,
interval,
initialData,
timeout,
uimActionType,
}: UseRequest) => {
// Main states for tracking request status and data
const [error, setError] = useState<null | any>(null);
const [loading, setLoading] = useState<boolean>(true);
const [data, setData] = useState<any>(initialData);
// States for tracking polling
const [polling, setPolling] = useState<boolean>(false);
const [currentInterval, setCurrentInterval] = useState<UseRequest['interval']>(interval);
const intervalRequest = useRef<any>(null);
const isFirstRequest = useRef<boolean>(true);
// Tied to every render and bound to each request.
let isOutdatedRequest = false;
const request = async () => {
setError(null);
setData(initialData);
setLoading(true);
const isPollRequest = currentInterval && !isFirstRequest.current;
// Don't reset main error/loading states if we are doing polling
if (isPollRequest) {
setPolling(true);
} else {
setError(null);
setData(initialData);
setLoading(true);
setPolling(false);
}
const requestBody = {
path,
@ -90,31 +103,49 @@ export const useRequest = ({
return;
}
setError(response.error);
setData(response.data);
setLoading(false);
// Only set data if we are doing polling
if (isPollRequest) {
setPolling(false);
if (response.data) {
setData(response.data);
}
} else {
setError(response.error);
setData(response.data);
setLoading(false);
}
isFirstRequest.current = false;
};
const cancelOutdatedRequest = () => {
isOutdatedRequest = true;
};
useEffect(
() => {
function cancelOutdatedRequest() {
isOutdatedRequest = true;
}
// Perform request
request();
if (interval) {
const intervalRequest = setInterval(request, interval);
return () => {
cancelOutdatedRequest();
clearInterval(intervalRequest);
};
// Clear current interval
if (intervalRequest.current) {
clearInterval(intervalRequest.current);
}
// Called when a new render will trigger this effect.
return cancelOutdatedRequest;
// Set new interval
if (currentInterval) {
intervalRequest.current = setInterval(request, currentInterval);
}
// Cleanup intervals and inflight requests and corresponding state changes
return () => {
cancelOutdatedRequest();
if (intervalRequest.current) {
clearInterval(intervalRequest.current);
}
};
},
[path]
[path, currentInterval]
);
return {
@ -122,5 +153,13 @@ export const useRequest = ({
loading,
data,
request,
polling,
changeInterval: (newInterval: UseRequest['interval']) => {
// Allow changing polling interval if there was one set
if (!interval) {
return;
}
setCurrentInterval(newInterval);
},
};
};

View file

@ -14,6 +14,7 @@ class BreadcrumbService {
home: {},
repositoryAdd: {},
repositoryEdit: {},
restoreSnapshot: {},
};
public init(chrome: any, managementBreadcrumb: any): void {
@ -25,11 +26,13 @@ class BreadcrumbService {
};
this.breadcrumbs.repositoryAdd = {
text: textService.breadcrumbs.repositoryAdd,
href: `#${BASE_PATH}/add_repository`,
};
this.breadcrumbs.repositoryEdit = {
text: textService.breadcrumbs.repositoryEdit,
};
this.breadcrumbs.restoreSnapshot = {
text: textService.breadcrumbs.restoreSnapshot,
};
}
public setBreadcrumbs(type: string): void {

View file

@ -49,7 +49,7 @@ class TextService {
};
this.breadcrumbs = {
home: i18n.translate('xpack.snapshotRestore.home.breadcrumbTitle', {
defaultMessage: 'Snapshot Repositories',
defaultMessage: 'Snapshot and Restore',
}),
repositoryAdd: i18n.translate('xpack.snapshotRestore.addRepository.breadcrumbTitle', {
defaultMessage: 'Add repository',
@ -57,6 +57,9 @@ class TextService {
repositoryEdit: i18n.translate('xpack.snapshotRestore.editRepository.breadcrumbTitle', {
defaultMessage: 'Edit repository',
}),
restoreSnapshot: i18n.translate('xpack.snapshotRestore.restoreSnapshot.breadcrumbTitle', {
defaultMessage: 'Restore snapshot',
}),
};
}

View file

@ -9,3 +9,5 @@ export {
RepositorySettingsValidation,
validateRepository,
} from './validate_repository';
export { RestoreValidation, validateRestore } from './validate_restore';

View file

@ -0,0 +1,151 @@
/*
* 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 { RestoreSettings } from '../../../../common/types';
import { UNMODIFIABLE_INDEX_SETTINGS, UNREMOVABLE_INDEX_SETTINGS } from '../../../app/constants';
import { textService } from '../text';
export interface RestoreValidation {
isValid: boolean;
errors: { [key: string]: React.ReactNode[] };
}
const isStringEmpty = (str: string | null): boolean => {
return str ? !Boolean(str.trim()) : true;
};
export const validateRestore = (restoreSettings: RestoreSettings): RestoreValidation => {
const i18n = textService.i18n;
const {
indices,
renamePattern,
renameReplacement,
indexSettings,
ignoreIndexSettings,
} = restoreSettings;
const validation: RestoreValidation = {
isValid: true,
errors: {
indices: [],
renamePattern: [],
renameReplacement: [],
indexSettings: [],
ignoreIndexSettings: [],
},
};
if (Array.isArray(indices) && indices.length === 0) {
validation.errors.indices.push(
i18n.translate('xpack.snapshotRestore.restoreValidation.indicesRequiredError', {
defaultMessage: 'You must select at least one index.',
})
);
}
if (renamePattern !== undefined && isStringEmpty(renamePattern)) {
validation.errors.renamePattern.push(
i18n.translate('xpack.snapshotRestore.restoreValidation.renamePatternRequiredError', {
defaultMessage: 'Capture pattern is required.',
})
);
}
if (renameReplacement !== undefined && isStringEmpty(renameReplacement)) {
validation.errors.renameReplacement.push(
i18n.translate('xpack.snapshotRestore.restoreValidation.renameReplacementRequiredError', {
defaultMessage: 'Replacement pattern is required.',
})
);
}
if (typeof indexSettings === 'string') {
try {
const parsedIndexSettings = JSON.parse(indexSettings);
const modifiedSettings = Object.keys(parsedIndexSettings);
const modifiedSettingsCount = modifiedSettings.length;
const unmodifiableSettings =
modifiedSettingsCount > 0
? modifiedSettings.filter(setting => UNMODIFIABLE_INDEX_SETTINGS.includes(setting))
: null;
if (modifiedSettingsCount === 0) {
validation.errors.indexSettings.push(
i18n.translate('xpack.snapshotRestore.restoreValidation.indexSettingsRequiredError', {
defaultMessage: 'At least one setting is required.',
})
);
}
if (unmodifiableSettings && unmodifiableSettings.length > 0) {
validation.errors.indexSettings.push(
i18n.translate(
'xpack.snapshotRestore.restoreValidation.indexSettingsNotModifiableError',
{
defaultMessage: 'You cant modify: {settings}',
// @ts-ignore Bug filed: https://github.com/elastic/kibana/issues/39299
values: {
settings: unmodifiableSettings.map((setting: string, index: number) =>
index === 0 ? `${setting} ` : setting
),
},
}
)
);
}
} catch (e) {
validation.errors.indexSettings.push(
i18n.translate('xpack.snapshotRestore.restoreValidation.indexSettingsInvalidError', {
defaultMessage: 'Invalid JSON format',
})
);
}
}
if (Array.isArray(ignoreIndexSettings)) {
const ignoredSettingsCount = ignoreIndexSettings.length;
const unremovableSettings =
ignoredSettingsCount > 0
? ignoreIndexSettings.filter(setting => UNREMOVABLE_INDEX_SETTINGS.includes(setting))
: null;
if (ignoredSettingsCount === 0) {
validation.errors.ignoreIndexSettings.push(
i18n.translate('xpack.snapshotRestore.restoreValidation.ignoreIndexSettingsRequiredError', {
defaultMessage: 'At least one setting is required.',
})
);
}
if (unremovableSettings && unremovableSettings.length > 0) {
validation.errors.ignoreIndexSettings.push(
i18n.translate('xpack.snapshotRestore.restoreValidation.indexSettingsNotRemovableError', {
defaultMessage: 'You cant reset: {settings}',
// @ts-ignore Bug filed: https://github.com/elastic/kibana/issues/39299
values: {
settings: unremovableSettings.map((setting: string, index: number) =>
index === 0 ? `${setting} ` : setting
),
},
})
);
}
}
// Remove fields with no errors
validation.errors = Object.entries(validation.errors)
.filter(([key, value]) => value.length > 0)
.reduce((errs: RestoreValidation['errors'], [key, value]) => {
errs[key] = value;
return errs;
}, {});
// Set overall validations status
if (Object.keys(validation.errors).length > 0) {
validation.isValid = false;
}
return validation;
};

View file

@ -29,7 +29,7 @@ export class Plugin {
esSection.register(PLUGIN.ID, {
visible: true,
display: i18n.translate('xpack.snapshotRestore.appName', {
defaultMessage: 'Snapshot Repositories',
defaultMessage: 'Snapshot and Restore',
}),
order: 7,
url: `#${CLIENT_BASE_PATH}`,

View file

@ -8,5 +8,6 @@ export {
deserializeRepositorySettings,
serializeRepositorySettings,
} from './repository_serialization';
export { deserializeSnapshotDetails } from './snapshot_serialization';
export { cleanSettings } from './clean_settings';
export { deserializeSnapshotDetails } from './snapshot_serialization';
export { deserializeRestoreShard } from './restore_serialization';

View file

@ -0,0 +1,111 @@
/*
* 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 { deserializeRestoreShard } from './restore_serialization';
import { SnapshotRestoreShardEs } from '../../common/types';
describe('restore_serialization', () => {
describe('deserializeRestoreShard()', () => {
it('should deserialize a snapshot restore shard', () => {
expect(
deserializeRestoreShard({
id: 0,
type: 'SNAPSHOT',
stage: 'DONE',
primary: true,
start_time: '2019-06-24T20:40:10.583Z',
start_time_in_millis: 1561408810583,
stop_time: '2019-06-24T20:40:11.324Z',
stop_time_in_millis: 1561408811324,
total_time: '740ms',
total_time_in_millis: 740,
source: {
repository: 'my-backups',
snapshot: 'snapshot_1',
version: '8.0.0',
index: 'test_index',
restoreUUID: 'some_uuid',
},
target: {
id: 'some_node_id',
host: '127.0.0.1',
transport_address: '127.0.0.1:9300',
ip: '127.0.0.1',
name: 'some_node_name',
},
index: {
size: {
total: '4.7mb',
total_in_bytes: 4986706,
reused: '0b',
reused_in_bytes: 0,
recovered: '4.7mb',
recovered_in_bytes: 4986706,
percent: '100.0%',
},
files: {
total: 10,
reused: 0,
recovered: 10,
percent: '100.0%',
},
total_time: '624ms',
total_time_in_millis: 624,
source_throttle_time: '-1',
source_throttle_time_in_millis: 0,
target_throttle_time: '-1',
target_throttle_time_in_millis: 0,
},
translog: {
recovered: 0,
total: 0,
percent: '100.0%',
total_on_start: 0,
total_time: '87ms',
total_time_in_millis: 87,
},
verify_index: {
check_index_time: '0s',
check_index_time_in_millis: 0,
total_time: '0s',
total_time_in_millis: 0,
},
})
).toEqual({
bytesPercent: '100.0%',
bytesRecovered: 4986706,
bytesTotal: 4986706,
filesPercent: '100.0%',
filesRecovered: 10,
filesTotal: 10,
id: 0,
primary: true,
repository: 'my-backups',
snapshot: 'snapshot_1',
stage: 'DONE',
startTime: '2019-06-24T20:40:10.583Z',
startTimeInMillis: 1561408810583,
stopTime: '2019-06-24T20:40:11.324Z',
stopTimeInMillis: 1561408811324,
targetHost: '127.0.0.1',
targetNode: 'some_node_name',
totalTime: '740ms',
totalTimeInMillis: 740,
version: '8.0.0',
});
});
it('should remove undefined properties', () => {
expect(
deserializeRestoreShard({
type: 'SNAPSHOT',
source: {},
target: {},
index: { size: {}, files: {} },
} as SnapshotRestoreShardEs)
).toEqual({});
});
});
});

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 { SnapshotRestoreShard, SnapshotRestoreShardEs } from '../../common/types';
export const deserializeRestoreShard = (
esSnapshotRestoreShard: SnapshotRestoreShardEs
): Partial<SnapshotRestoreShard> => {
const {
id,
primary,
stage,
source: { repository, snapshot, version },
target: { host: targetHost, name: targetNode },
start_time: startTime,
start_time_in_millis: startTimeInMillis,
stop_time: stopTime,
stop_time_in_millis: stopTimeInMillis,
total_time: totalTime,
total_time_in_millis: totalTimeInMillis,
index: {
size: {
total_in_bytes: bytesTotal,
recovered_in_bytes: bytesRecovered,
percent: bytesPercent,
},
files: { total: filesTotal, recovered: filesRecovered, percent: filesPercent },
},
} = esSnapshotRestoreShard;
const snapshotRestoreShard = {
id,
primary,
stage,
snapshot,
repository,
version,
targetHost,
targetNode,
startTime,
startTimeInMillis,
stopTime,
stopTimeInMillis,
totalTime,
totalTimeInMillis,
bytesTotal,
bytesRecovered,
bytesPercent,
filesTotal,
filesRecovered,
filesPercent,
};
return Object.entries(snapshotRestoreShard).reduce((shard: any, [key, value]) => {
if (value !== undefined) {
shard[key] = value;
}
return shard;
}, {});
};

View file

@ -54,8 +54,7 @@ describe('deserializeSnapshotDetails', () => {
version: 'version',
// Indices are sorted.
indices: ['index1', 'index2', 'index3'],
// Converted from a boolean into 0 or 1.
includeGlobalState: 0,
includeGlobalState: false,
// Failures are grouped and sorted by index, and the failures themselves are sorted by shard.
indexFailures: [
{

View file

@ -66,7 +66,7 @@ export function deserializeSnapshotDetails(
versionId,
version,
indices: [...indices].sort(),
includeGlobalState: Boolean(includeGlobalState) ? 1 : 0,
includeGlobalState,
state,
startTime,
startTimeInMillis,

View file

@ -8,9 +8,11 @@ import { Plugins } from '../../../shim';
import { registerAppRoutes } from './app';
import { registerRepositoriesRoutes } from './repositories';
import { registerSnapshotsRoutes } from './snapshots';
import { registerRestoreRoutes } from './restore';
export const registerRoutes = (router: Router, plugins: Plugins): void => {
registerAppRoutes(router, plugins);
registerRepositoriesRoutes(router, plugins);
registerSnapshotsRoutes(router);
registerRestoreRoutes(router);
};

View file

@ -0,0 +1,98 @@
/*
* 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 { createHandler, getAllHandler } from './restore';
describe('[Snapshot and Restore API Routes] Restore', () => {
const mockRequest = {} as Request;
const mockResponseToolkit = {} as ResponseToolkit;
const mockEsShard = {
type: 'SNAPSHOT',
source: {},
target: {},
index: { size: {}, files: {} },
};
describe('createHandler()', () => {
const mockCreateRequest = ({
params: {
repository: 'foo',
snapshot: 'snapshot-1',
},
payload: {},
} as unknown) as Request;
it('should return successful response from ES', async () => {
const mockEsResponse = { acknowledged: true };
const callWithRequest = jest.fn().mockReturnValueOnce(mockEsResponse);
await expect(
createHandler(mockCreateRequest, callWithRequest, mockResponseToolkit)
).resolves.toEqual(mockEsResponse);
});
it('should throw if ES error', async () => {
const callWithRequest = jest.fn().mockRejectedValueOnce(new Error());
await expect(
createHandler(mockCreateRequest, callWithRequest, mockResponseToolkit)
).rejects.toThrow();
});
});
describe('getAllHandler()', () => {
it('should arrify and filter restore shards returned from ES', async () => {
const mockEsResponse = {
fooIndex: {
shards: [mockEsShard],
},
barIndex: {
shards: [mockEsShard, mockEsShard],
},
testIndex: {
shards: [
{
...mockEsShard,
type: 'EMPTY_STORE',
},
],
},
};
const callWithRequest = jest.fn().mockReturnValueOnce(mockEsResponse);
const expectedResponse = [
{
index: 'fooIndex',
shards: [{}],
isComplete: false,
latestActivityTimeInMillis: 0,
},
{
index: 'barIndex',
shards: [{}, {}],
isComplete: false,
latestActivityTimeInMillis: 0,
},
];
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: any[] = [];
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();
});
});
});

View file

@ -0,0 +1,80 @@
/*
* 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 { RestoreSettings, SnapshotRestore, SnapshotRestoreShardEs } from '../../../common/types';
import { serializeRestoreSettings } from '../../../common/lib';
import { deserializeRestoreShard } from '../../lib';
export function registerRestoreRoutes(router: Router) {
router.post('restore/{repository}/{snapshot}', createHandler);
router.get('restores', getAllHandler);
}
export const createHandler: RouterRouteHandler = async (req, callWithRequest) => {
const { repository, snapshot } = req.params;
const restoreSettings = req.payload as RestoreSettings;
return await callWithRequest('snapshot.restore', {
repository,
snapshot,
body: serializeRestoreSettings(restoreSettings),
});
};
export const getAllHandler: RouterRouteHandler = async (req, callWithRequest) => {
const snapshotRestores: SnapshotRestore[] = [];
const recoveryByIndexName: {
[key: string]: {
shards: SnapshotRestoreShardEs[];
};
} = await callWithRequest('indices.recovery', {
human: true,
});
// Filter to snapshot-recovered shards only
Object.keys(recoveryByIndexName).forEach(index => {
const recovery = recoveryByIndexName[index];
let latestActivityTimeInMillis: number = 0;
let latestEndTimeInMillis: number | null = null;
const snapshotShards = (recovery.shards || [])
.filter(shard => shard.type === 'SNAPSHOT')
.sort((a, b) => a.id - b.id)
.map(shard => {
const deserializedShard = deserializeRestoreShard(shard);
const { startTimeInMillis, stopTimeInMillis } = deserializedShard;
// Set overall latest activity time
latestActivityTimeInMillis = Math.max(
startTimeInMillis || 0,
stopTimeInMillis || 0,
latestActivityTimeInMillis
);
// Set overall end time
if (stopTimeInMillis === undefined) {
latestEndTimeInMillis = null;
} else if (latestEndTimeInMillis === null || stopTimeInMillis > latestEndTimeInMillis) {
latestEndTimeInMillis = stopTimeInMillis;
}
return deserializedShard;
});
if (snapshotShards.length > 0) {
snapshotRestores.push({
index,
latestActivityTimeInMillis,
shards: snapshotShards,
isComplete: latestEndTimeInMillis !== null,
});
}
});
// Sort by latest activity
snapshotRestores.sort((a, b) => b.latestActivityTimeInMillis - a.latestActivityTimeInMillis);
return snapshotRestores;
};

View file

@ -5,7 +5,7 @@
*/
import { Request, ResponseToolkit } from 'hapi';
import { getAllHandler, getOneHandler } from './snapshots';
import { getAllHandler, getOneHandler, deleteHandler } from './snapshots';
const defaultSnapshot = {
repository: undefined,
@ -14,7 +14,7 @@ const defaultSnapshot = {
versionId: undefined,
version: undefined,
indices: [],
includeGlobalState: 0,
includeGlobalState: undefined,
state: undefined,
startTime: undefined,
startTimeInMillis: undefined,
@ -128,4 +128,74 @@ describe('[Snapshot and Restore API Routes] Snapshots', () => {
).rejects.toThrow();
});
});
describe('deleteHandler()', () => {
const ids = ['fooRepository/snapshot-1', 'barRepository/snapshot-2'];
const mockCreateRequest = ({
params: {
ids: ids.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: [
{ snapshot: 'snapshot-1', repository: 'fooRepository' },
{ snapshot: 'snapshot-2', repository: 'barRepository' },
],
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: [
{ id: { snapshot: 'snapshot-1', repository: 'fooRepository' }, error: mockEsError },
{ id: { snapshot: 'snapshot-2', repository: 'barRepository' }, 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: [{ snapshot: 'snapshot-2', repository: 'barRepository' }],
errors: [
{
id: { snapshot: 'snapshot-1', repository: 'fooRepository' },
error: mockEsError,
},
],
};
await expect(
deleteHandler(mockCreateRequest, callWithRequest, mockResponseToolkit)
).resolves.toEqual(expectedResponse);
});
});
});

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { Router, RouterRouteHandler } from '../../../../../server/lib/create_router';
import { wrapEsError } from '../../../../../server/lib/create_router/error_wrappers';
import { SnapshotDetails } from '../../../common/types';
import { deserializeSnapshotDetails } from '../../lib';
import { SnapshotDetailsEs } from '../../types';
@ -11,6 +12,7 @@ import { SnapshotDetailsEs } from '../../types';
export function registerSnapshotsRoutes(router: Router) {
router.get('snapshots', getAllHandler);
router.get('snapshots/{repository}/{snapshot}', getOneHandler);
router.delete('snapshots/{ids}', deleteHandler);
}
export const getAllHandler: RouterRouteHandler = async (
@ -81,3 +83,37 @@ export const getOneHandler: RouterRouteHandler = async (
// If the snapshot is missing the endpoint will return a 404, so we'll never get to this point.
return deserializeSnapshotDetails(repository, snapshots[0]);
};
export const deleteHandler: RouterRouteHandler = async (req, callWithRequest) => {
const { ids } = req.params;
const snapshotIds = ids.split(',');
const response: {
itemsDeleted: Array<{ snapshot: string; repository: string }>;
errors: any[];
} = {
itemsDeleted: [],
errors: [],
};
// We intentially perform deletion requests sequentially (blocking) instead of in parallel (non-blocking)
// because there can only be one snapshot deletion task performed at a time (ES restriction).
for (let i = 0; i < snapshotIds.length; i++) {
// IDs come in the format of `repository-name/snapshot-name`
// Extract the two parts by splitting at last occurrence of `/` in case
// repository name contains '/` (from older versions)
const id = snapshotIds[i];
const indexOfDivider = id.lastIndexOf('/');
const snapshot = id.substring(indexOfDivider + 1);
const repository = id.substring(0, indexOfDivider);
await callWithRequest('snapshot.delete', { snapshot, repository })
.then(() => response.itemsDeleted.push({ snapshot, repository }))
.catch(e =>
response.errors.push({
id: { snapshot, repository },
error: wrapEsError(e),
})
);
}
return response;
};

View file

@ -13,6 +13,10 @@ declare module '@elastic/eui' {
export const EuiCard: any;
}
declare module '@elastic/eui/lib/services' {
export const RIGHT_ALIGNMENT: any;
}
declare module '@elastic/eui/lib/services/format' {
export const dateFormatAliases: any;
}