[SR] SLM create and edit policies (#43390) (#44172)

* add buttons and links to create/edit policy

* set up add policy form

* start create policy form, including loading/error states and redirect for repository select field. add inline option to SectionLoading. add actions prop to SectionError

* add snapshot name field

* Change page title upon app navigation, improve breadcrumbs

* Add on cancel to policy form, reorder fields

* Add simple cron field

* First pass at create/edit policy functionality

* Adjust permissions for SLM tab

* Adjust no snapshots prompt based on if policies exist or not

* Add selectable indices to policy form

* Move cron editor from rollup jobs to ES UI shared folder

* Used shared cron editor for slm policy create/edit

* Adjust copies; add duplicate schedule warning callout

* Surface in progress information

* Fix doc link for 7.x

* Fix rollup tests

* Copy edits from review

* Add ES endpoint to request review

* Remove unused imports

* Fix i18n by cleaning up typo'd text

* Remove unused import

* Fix permissions and i18n

* Revert change to Logistics copy

* Fix bugs and PR feedback

* Add cancel button to form and add comment for list

* Adjust timeout comment

* Fix bug with list of indices in detail panel when clicking through table

* Add comment about EUI bug
This commit is contained in:
Jen Huang 2019-08-27 16:17:07 -07:00 committed by GitHub
parent 9194883c0d
commit 925f21600d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
84 changed files with 3696 additions and 708 deletions

View file

@ -27,7 +27,8 @@
"tsvb": "src/legacy/core_plugins/metrics",
"kbnESQuery": "packages/kbn-es-query",
"inspector": "src/plugins/inspector",
"kibana-react": "src/plugins/kibana_react"
"kibana-react": "src/plugins/kibana_react",
"esUi": "src/plugins/es_ui_shared"
},
"exclude": ["src/legacy/ui/ui_render/ui_render_mixin.js"],
"translations": []

View file

@ -1,7 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { Fragment } from 'react';
@ -27,12 +40,12 @@ export const CronDaily = ({
<EuiFormRow
label={(
<FormattedMessage
id="xpack.rollupJobs.cronEditor.cronDaily.fieldTimeLabel"
id="esUi.cronEditor.cronDaily.fieldTimeLabel"
defaultMessage="Time"
/>
)}
fullWidth
data-test-subj="rollupCronFrequencyConfiguration"
data-test-subj="cronFrequencyConfiguration"
>
<EuiFlexGroup gutterSize="xs">
<EuiFlexItem grow={false}>
@ -45,13 +58,13 @@ export const CronDaily = ({
<EuiText size="xs">
<strong>
<FormattedMessage
id="xpack.rollupJobs.cronEditor.cronDaily.fieldHour.textAtLabel"
id="esUi.cronEditor.cronDaily.fieldHour.textAtLabel"
defaultMessage="At"
/>
</strong>
</EuiText>
)}
data-test-subj="rollupJobCreateFrequencyDailyHourSelect"
data-test-subj="cronFrequencyDailyHourSelect"
/>
</EuiFlexItem>
@ -68,7 +81,7 @@ export const CronDaily = ({
</strong>
</EuiText>
)}
data-test-subj="rollupJobCreateFrequencyDailyMinuteSelect"
data-test-subj="cronFrequencyDailyMinuteSelect"
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -1,7 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { Component, Fragment } from 'react';
@ -27,7 +40,7 @@ import {
WEEK,
MONTH,
YEAR,
} from '../../../../../services';
} from './services';
import { CronHourly } from './cron_hourly';
import { CronDaily } from './cron_daily';
@ -331,7 +344,7 @@ export class CronEditor extends Component {
<EuiFormRow
label={(
<FormattedMessage
id="xpack.rollupJobs.cronEditor.fieldFrequencyLabel"
id="esUi.cronEditor.fieldFrequencyLabel"
defaultMessage="Frequency"
/>
)}
@ -346,13 +359,13 @@ export class CronEditor extends Component {
<EuiText size="xs">
<strong>
<FormattedMessage
id="xpack.rollupJobs.cronEditor.textEveryLabel"
id="esUi.cronEditor.textEveryLabel"
defaultMessage="Every"
/>
</strong>
</EuiText>
)}
data-test-subj="rollupJobCreateFrequencySelect"
data-test-subj="cronFrequencySelect"
/>
</EuiFormRow>

View file

@ -0,0 +1,71 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiFormRow,
EuiSelect,
EuiText,
} from '@elastic/eui';
export const CronHourly = ({
minute,
minuteOptions,
onChange,
}) => (
<Fragment>
<EuiFormRow
label={(
<FormattedMessage
id="esUi.cronEditor.cronHourly.fieldTimeLabel"
defaultMessage="Minute"
/>
)}
fullWidth
data-test-subj="cronFrequencyConfiguration"
>
<EuiSelect
options={minuteOptions}
value={minute}
onChange={e => onChange({ minute: e.target.value })}
fullWidth
prepend={(
<EuiText size="xs">
<strong>
<FormattedMessage
id="esUi.cronEditor.cronHourly.fieldMinute.textAtLabel"
defaultMessage="At"
/>
</strong>
</EuiText>
)}
data-test-subj="cronFrequencyHourlyMinuteSelect"
/>
</EuiFormRow>
</Fragment>
);
CronHourly.propTypes = {
minute: PropTypes.string.isRequired,
minuteOptions: PropTypes.array.isRequired,
onChange: PropTypes.func.isRequired,
};

View file

@ -1,7 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { Fragment } from 'react';
@ -29,12 +42,12 @@ export const CronMonthly = ({
<EuiFormRow
label={(
<FormattedMessage
id="xpack.rollupJobs.cronEditor.cronMonthly.fieldDateLabel"
id="esUi.cronEditor.cronMonthly.fieldDateLabel"
defaultMessage="Date"
/>
)}
fullWidth
data-test-subj="rollupCronFrequencyConfiguration"
data-test-subj="cronFrequencyConfiguration"
>
<EuiSelect
options={dateOptions}
@ -45,25 +58,25 @@ export const CronMonthly = ({
<EuiText size="xs">
<strong>
<FormattedMessage
id="xpack.rollupJobs.cronEditor.cronMonthly.textOnTheLabel"
id="esUi.cronEditor.cronMonthly.textOnTheLabel"
defaultMessage="On the"
/>
</strong>
</EuiText>
)}
data-test-subj="rollupJobCreateFrequencyMonthlyDateSelect"
data-test-subj="cronFrequencyMonthlyDateSelect"
/>
</EuiFormRow>
<EuiFormRow
label={(
<FormattedMessage
id="xpack.rollupJobs.cronEditor.cronMonthly.fieldTimeLabel"
id="esUi.cronEditor.cronMonthly.fieldTimeLabel"
defaultMessage="Time"
/>
)}
fullWidth
data-test-subj="rollupCronFrequencyConfiguration"
data-test-subj="cronFrequencyConfiguration"
>
<EuiFlexGroup gutterSize="xs">
<EuiFlexItem grow={false}>
@ -76,13 +89,13 @@ export const CronMonthly = ({
<EuiText size="xs">
<strong>
<FormattedMessage
id="xpack.rollupJobs.cronEditor.cronMonthly.fieldHour.textAtLabel"
id="esUi.cronEditor.cronMonthly.fieldHour.textAtLabel"
defaultMessage="At"
/>
</strong>
</EuiText>
)}
data-test-subj="rollupJobCreateFrequencyMonthlyHourSelect"
data-test-subj="cronFrequencyMonthlyHourSelect"
/>
</EuiFlexItem>
@ -99,7 +112,7 @@ export const CronMonthly = ({
</strong>
</EuiText>
)}
data-test-subj="rollupJobCreateFrequencyMonthlyMinuteSelect"
data-test-subj="cronFrequencyMonthlyMinuteSelect"
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -1,7 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { Fragment } from 'react';
@ -29,12 +42,12 @@ export const CronWeekly = ({
<EuiFormRow
label={(
<FormattedMessage
id="xpack.rollupJobs.cronEditor.cronWeekly.fieldDateLabel"
id="esUi.cronEditor.cronWeekly.fieldDateLabel"
defaultMessage="Day"
/>
)}
fullWidth
data-test-subj="rollupCronFrequencyConfiguration"
data-test-subj="cronFrequencyConfiguration"
>
<EuiSelect
options={dayOptions}
@ -45,25 +58,25 @@ export const CronWeekly = ({
<EuiText size="xs">
<strong>
<FormattedMessage
id="xpack.rollupJobs.cronEditor.cronWeekly.textOnLabel"
id="esUi.cronEditor.cronWeekly.textOnLabel"
defaultMessage="On"
/>
</strong>
</EuiText>
)}
data-test-subj="rollupJobCreateFrequencyWeeklyDaySelect"
data-test-subj="cronFrequencyWeeklyDaySelect"
/>
</EuiFormRow>
<EuiFormRow
label={(
<FormattedMessage
id="xpack.rollupJobs.cronEditor.cronWeekly.fieldTimeLabel"
id="esUi.cronEditor.cronWeekly.fieldTimeLabel"
defaultMessage="Time"
/>
)}
fullWidth
data-test-subj="rollupCronFrequencyConfiguration"
data-test-subj="cronFrequencyConfiguration"
>
<EuiFlexGroup gutterSize="xs">
<EuiFlexItem grow={false}>
@ -76,13 +89,13 @@ export const CronWeekly = ({
<EuiText size="xs">
<strong>
<FormattedMessage
id="xpack.rollupJobs.cronEditor.cronWeekly.fieldHour.textAtLabel"
id="esUi.cronEditor.cronWeekly.fieldHour.textAtLabel"
defaultMessage="At"
/>
</strong>
</EuiText>
)}
data-test-subj="rollupJobCreateFrequencyWeeklyHourSelect"
data-test-subj="cronFrequencyWeeklyHourSelect"
/>
</EuiFlexItem>
@ -99,7 +112,7 @@ export const CronWeekly = ({
</strong>
</EuiText>
)}
data-test-subj="rollupJobCreateFrequencyWeeklyMinuteSelect"
data-test-subj="cronFrequencyWeeklyMinuteSelect"
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -1,7 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { Fragment } from 'react';
@ -31,12 +44,12 @@ export const CronYearly = ({
<EuiFormRow
label={(
<FormattedMessage
id="xpack.rollupJobs.cronEditor.cronYearly.fieldMonthLabel"
id="esUi.cronEditor.cronYearly.fieldMonthLabel"
defaultMessage="Month"
/>
)}
fullWidth
data-test-subj="rollupCronFrequencyConfiguration"
data-test-subj="cronFrequencyConfiguration"
>
<EuiSelect
options={monthOptions}
@ -47,25 +60,25 @@ export const CronYearly = ({
<EuiText size="xs">
<strong>
<FormattedMessage
id="xpack.rollupJobs.cronEditor.cronYearly.fieldMonth.textInLabel"
id="esUi.cronEditor.cronYearly.fieldMonth.textInLabel"
defaultMessage="In"
/>
</strong>
</EuiText>
)}
data-test-subj="rollupJobCreateFrequencyYearlyMonthSelect"
data-test-subj="cronFrequencyYearlyMonthSelect"
/>
</EuiFormRow>
<EuiFormRow
label={(
<FormattedMessage
id="xpack.rollupJobs.cronEditor.cronYearly.fieldDateLabel"
id="esUi.cronEditor.cronYearly.fieldDateLabel"
defaultMessage="Date"
/>
)}
fullWidth
data-test-subj="rollupCronFrequencyConfiguration"
data-test-subj="cronFrequencyConfiguration"
>
<EuiSelect
options={dateOptions}
@ -76,25 +89,25 @@ export const CronYearly = ({
<EuiText size="xs">
<strong>
<FormattedMessage
id="xpack.rollupJobs.cronEditor.cronYearly.fieldDate.textOnTheLabel"
id="esUi.cronEditor.cronYearly.fieldDate.textOnTheLabel"
defaultMessage="On the"
/>
</strong>
</EuiText>
)}
data-test-subj="rollupJobCreateFrequencyYearlyDateSelect"
data-test-subj="cronFrequencyYearlyDateSelect"
/>
</EuiFormRow>
<EuiFormRow
label={(
<FormattedMessage
id="xpack.rollupJobs.cronEditor.cronYearly.fieldTimeLabel"
id="esUi.cronEditor.cronYearly.fieldTimeLabel"
defaultMessage="Time"
/>
)}
fullWidth
data-test-subj="rollupCronFrequencyConfiguration"
data-test-subj="cronFrequencyConfiguration"
>
<EuiFlexGroup gutterSize="xs">
<EuiFlexItem grow={false}>
@ -107,13 +120,13 @@ export const CronYearly = ({
<EuiText size="xs">
<strong>
<FormattedMessage
id="xpack.rollupJobs.cronEditor.cronYearly.fieldHour.textAtLabel"
id="esUi.cronEditor.cronYearly.fieldHour.textAtLabel"
defaultMessage="At"
/>
</strong>
</EuiText>
)}
data-test-subj="rollupJobCreateFrequencyYearlyHourSelect"
data-test-subj="cronFrequencyYearlyHourSelect"
/>
</EuiFlexItem>
@ -130,7 +143,7 @@ export const CronYearly = ({
</strong>
</EuiText>
)}
data-test-subj="rollupJobCreateFrequencyYearlyMinuteSelect"
data-test-subj="cronFrequencyYearlyMinuteSelect"
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -0,0 +1,26 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export declare const MINUTE: string;
export declare const HOUR: string;
export declare const DAY: string;
export declare const WEEK: string;
export declare const MONTH: string;
export declare const YEAR: string;
export declare const CronEditor: any;

View file

@ -0,0 +1,21 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export { CronEditor } from './cron_editor';
export { MINUTE, HOUR, DAY, WEEK, MONTH, YEAR } from './services';

View file

@ -1,7 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export const MINUTE = 'MINUTE';

View file

@ -0,0 +1,91 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { i18n } from '@kbn/i18n';
// The international ISO standard dictates Monday as the first day of the week, but cron patterns
// use Sunday as the first day, so we're going with the cron way.
const dayOrdinalToDayNameMap = {
0: i18n.translate('esUi.cronEditor.day.sunday', { defaultMessage: 'Sunday' }),
1: i18n.translate('esUi.cronEditor.day.monday', { defaultMessage: 'Monday' }),
2: i18n.translate('esUi.cronEditor.day.tuesday', { defaultMessage: 'Tuesday' }),
3: i18n.translate('esUi.cronEditor.day.wednesday', { defaultMessage: 'Wednesday' }),
4: i18n.translate('esUi.cronEditor.day.thursday', { defaultMessage: 'Thursday' }),
5: i18n.translate('esUi.cronEditor.day.friday', { defaultMessage: 'Friday' }),
6: i18n.translate('esUi.cronEditor.day.saturday', { defaultMessage: 'Saturday' }),
};
const monthOrdinalToMonthNameMap = {
0: i18n.translate('esUi.cronEditor.month.january', { defaultMessage: 'January' }),
1: i18n.translate('esUi.cronEditor.month.february', { defaultMessage: 'February' }),
2: i18n.translate('esUi.cronEditor.month.march', { defaultMessage: 'March' }),
3: i18n.translate('esUi.cronEditor.month.april', { defaultMessage: 'April' }),
4: i18n.translate('esUi.cronEditor.month.may', { defaultMessage: 'May' }),
5: i18n.translate('esUi.cronEditor.month.june', { defaultMessage: 'June' }),
6: i18n.translate('esUi.cronEditor.month.july', { defaultMessage: 'July' }),
7: i18n.translate('esUi.cronEditor.month.august', { defaultMessage: 'August' }),
8: i18n.translate('esUi.cronEditor.month.september', { defaultMessage: 'September' }),
9: i18n.translate('esUi.cronEditor.month.october', { defaultMessage: 'October' }),
10: i18n.translate('esUi.cronEditor.month.november', { defaultMessage: 'November' }),
11: i18n.translate('esUi.cronEditor.month.december', { defaultMessage: 'December' }),
};
export function getOrdinalValue(number) {
// TODO: This is breaking reporting pdf generation. Possibly due to phantom not setting locale,
// which is needed by i18n (formatjs). Need to verify, fix, and restore i18n in place of static stings.
// return i18n.translate('esUi.cronEditor.number.ordinal', {
// defaultMessage: '{number, selectordinal, one{#st} two{#nd} few{#rd} other{#th}}',
// values: { number },
// });
// TODO: https://github.com/elastic/kibana/issues/27136
// Protects against falsey (including 0) values
const num = number && number.toString();
let lastDigit = num && num.substr(-1);
let ordinal;
if(!lastDigit) {
return number;
}
lastDigit = parseFloat(lastDigit);
switch(lastDigit) {
case 1:
ordinal = 'st';
break;
case 2:
ordinal = 'nd';
break;
case 3:
ordinal = 'rd';
break;
default:
ordinal = 'th';
}
return `${num}${ordinal}`;
}
export function getDayName(dayOrdinal) {
return dayOrdinalToDayNameMap[dayOrdinal];
}
export function getMonthName(monthOrdinal) {
return monthOrdinalToMonthNameMap[monthOrdinal];
}

View file

@ -0,0 +1,21 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export * from './cron';
export * from './humanized_numbers';

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { MINUTE, HOUR, DAY, WEEK, MONTH, YEAR } from '../../public/crud_app/services';
import { MINUTE, HOUR, DAY, WEEK, MONTH, YEAR } from '../../../../../../src/plugins/es_ui_shared/public/components/cron_editor';
import { INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE } from '../../../../../../src/legacy/ui/public/index_patterns';
import { setupEnvironment, pageHelpers } from './helpers';
@ -162,7 +162,7 @@ describe('Create Rollup Job, step 1: Logistics', () => {
describe('rollup cron', () => {
const changeFrequency = (value) => {
find('rollupJobCreateFrequencySelect').simulate('change', { target: { value } });
find('cronFrequencySelect').simulate('change', { target: { value } });
};
const generateStringSequenceOfNumbers = (total) => (
@ -171,7 +171,7 @@ describe('Create Rollup Job, step 1: Logistics', () => {
describe('frequency', () => {
it('should allow "minute", "hour", "day", "week", "month", "year"', () => {
const frequencySelect = find('rollupJobCreateFrequencySelect');
const frequencySelect = find('cronFrequencySelect');
const options = frequencySelect.find('option').map(option => option.text());
expect(options).toEqual(['minute', 'hour', 'day', 'week', 'month', 'year']);
});
@ -179,7 +179,7 @@ describe('Create Rollup Job, step 1: Logistics', () => {
describe('every minute', () => {
it('should not have any additional configuration', () => {
changeFrequency(MINUTE);
expect(find('rollupCronFrequencyConfiguration').length).toBe(0);
expect(find('cronFrequencyConfiguration').length).toBe(0);
});
});
@ -189,12 +189,12 @@ describe('Create Rollup Job, step 1: Logistics', () => {
});
it('should have 1 additional configuration', () => {
expect(find('rollupCronFrequencyConfiguration').length).toBe(1);
expect(exists('rollupJobCreateFrequencyHourlyMinuteSelect')).toBe(true);
expect(find('cronFrequencyConfiguration').length).toBe(1);
expect(exists('cronFrequencyHourlyMinuteSelect')).toBe(true);
});
it('should allow to select any minute from 00 -> 59', () => {
const minutSelect = find('rollupJobCreateFrequencyHourlyMinuteSelect');
const minutSelect = find('cronFrequencyHourlyMinuteSelect');
const options = minutSelect.find('option').map(option => option.text());
expect(options).toEqual(generateStringSequenceOfNumbers(60));
});
@ -206,19 +206,19 @@ describe('Create Rollup Job, step 1: Logistics', () => {
});
it('should have 1 additional configuration with hour and minute selects', () => {
expect(find('rollupCronFrequencyConfiguration').length).toBe(1);
expect(exists('rollupJobCreateFrequencyDailyHourSelect')).toBe(true);
expect(exists('rollupJobCreateFrequencyDailyMinuteSelect')).toBe(true);
expect(find('cronFrequencyConfiguration').length).toBe(1);
expect(exists('cronFrequencyDailyHourSelect')).toBe(true);
expect(exists('cronFrequencyDailyMinuteSelect')).toBe(true);
});
it('should allow to select any hour from 00 -> 23', () => {
const hourSelect = find('rollupJobCreateFrequencyDailyHourSelect');
const hourSelect = find('cronFrequencyDailyHourSelect');
const options = hourSelect.find('option').map(option => option.text());
expect(options).toEqual(generateStringSequenceOfNumbers(24));
});
it('should allow to select any miute from 00 -> 59', () => {
const minutSelect = find('rollupJobCreateFrequencyDailyMinuteSelect');
const minutSelect = find('cronFrequencyDailyMinuteSelect');
const options = minutSelect.find('option').map(option => option.text());
expect(options).toEqual(generateStringSequenceOfNumbers(60));
});
@ -230,14 +230,14 @@ describe('Create Rollup Job, step 1: Logistics', () => {
});
it('should have 2 additional configurations with day, hour and minute selects', () => {
expect(find('rollupCronFrequencyConfiguration').length).toBe(2);
expect(exists('rollupJobCreateFrequencyWeeklyDaySelect')).toBe(true);
expect(exists('rollupJobCreateFrequencyWeeklyHourSelect')).toBe(true);
expect(exists('rollupJobCreateFrequencyWeeklyMinuteSelect')).toBe(true);
expect(find('cronFrequencyConfiguration').length).toBe(2);
expect(exists('cronFrequencyWeeklyDaySelect')).toBe(true);
expect(exists('cronFrequencyWeeklyHourSelect')).toBe(true);
expect(exists('cronFrequencyWeeklyMinuteSelect')).toBe(true);
});
it('should allow to select any day of the week', () => {
const hourSelect = find('rollupJobCreateFrequencyWeeklyDaySelect');
const hourSelect = find('cronFrequencyWeeklyDaySelect');
const options = hourSelect.find('option').map(option => option.text());
expect(options).toEqual([
'Sunday',
@ -251,13 +251,13 @@ describe('Create Rollup Job, step 1: Logistics', () => {
});
it('should allow to select any hour from 00 -> 23', () => {
const hourSelect = find('rollupJobCreateFrequencyWeeklyHourSelect');
const hourSelect = find('cronFrequencyWeeklyHourSelect');
const options = hourSelect.find('option').map(option => option.text());
expect(options).toEqual(generateStringSequenceOfNumbers(24));
});
it('should allow to select any miute from 00 -> 59', () => {
const minutSelect = find('rollupJobCreateFrequencyWeeklyMinuteSelect');
const minutSelect = find('cronFrequencyWeeklyMinuteSelect');
const options = minutSelect.find('option').map(option => option.text());
expect(options).toEqual(generateStringSequenceOfNumbers(60));
});
@ -269,26 +269,26 @@ describe('Create Rollup Job, step 1: Logistics', () => {
});
it('should have 2 additional configurations with date, hour and minute selects', () => {
expect(find('rollupCronFrequencyConfiguration').length).toBe(2);
expect(exists('rollupJobCreateFrequencyMonthlyDateSelect')).toBe(true);
expect(exists('rollupJobCreateFrequencyMonthlyHourSelect')).toBe(true);
expect(exists('rollupJobCreateFrequencyMonthlyMinuteSelect')).toBe(true);
expect(find('cronFrequencyConfiguration').length).toBe(2);
expect(exists('cronFrequencyMonthlyDateSelect')).toBe(true);
expect(exists('cronFrequencyMonthlyHourSelect')).toBe(true);
expect(exists('cronFrequencyMonthlyMinuteSelect')).toBe(true);
});
it('should allow to select any date of the month from 1st to 31st', () => {
const dateSelect = find('rollupJobCreateFrequencyMonthlyDateSelect');
const dateSelect = find('cronFrequencyMonthlyDateSelect');
const options = dateSelect.find('option').map(option => option.text());
expect(options.length).toEqual(31);
});
it('should allow to select any hour from 00 -> 23', () => {
const hourSelect = find('rollupJobCreateFrequencyMonthlyHourSelect');
const hourSelect = find('cronFrequencyMonthlyHourSelect');
const options = hourSelect.find('option').map(option => option.text());
expect(options).toEqual(generateStringSequenceOfNumbers(24));
});
it('should allow to select any miute from 00 -> 59', () => {
const minutSelect = find('rollupJobCreateFrequencyMonthlyMinuteSelect');
const minutSelect = find('cronFrequencyMonthlyMinuteSelect');
const options = minutSelect.find('option').map(option => option.text());
expect(options).toEqual(generateStringSequenceOfNumbers(60));
});
@ -300,15 +300,15 @@ describe('Create Rollup Job, step 1: Logistics', () => {
});
it('should have 3 additional configurations with month, date, hour and minute selects', () => {
expect(find('rollupCronFrequencyConfiguration').length).toBe(3);
expect(exists('rollupJobCreateFrequencyYearlyMonthSelect')).toBe(true);
expect(exists('rollupJobCreateFrequencyYearlyDateSelect')).toBe(true);
expect(exists('rollupJobCreateFrequencyYearlyHourSelect')).toBe(true);
expect(exists('rollupJobCreateFrequencyYearlyMinuteSelect')).toBe(true);
expect(find('cronFrequencyConfiguration').length).toBe(3);
expect(exists('cronFrequencyYearlyMonthSelect')).toBe(true);
expect(exists('cronFrequencyYearlyDateSelect')).toBe(true);
expect(exists('cronFrequencyYearlyHourSelect')).toBe(true);
expect(exists('cronFrequencyYearlyMinuteSelect')).toBe(true);
});
it('should allow to select any month of the year', () => {
const monthSelect = find('rollupJobCreateFrequencyYearlyMonthSelect');
const monthSelect = find('cronFrequencyYearlyMonthSelect');
const options = monthSelect.find('option').map(option => option.text());
expect(options).toEqual([
'January',
@ -327,19 +327,19 @@ describe('Create Rollup Job, step 1: Logistics', () => {
});
it('should allow to select any date of the month from 1st to 31st', () => {
const dateSelect = find('rollupJobCreateFrequencyYearlyDateSelect');
const dateSelect = find('cronFrequencyYearlyDateSelect');
const options = dateSelect.find('option').map(option => option.text());
expect(options.length).toEqual(31);
});
it('should allow to select any hour from 00 -> 23', () => {
const hourSelect = find('rollupJobCreateFrequencyYearlyHourSelect');
const hourSelect = find('cronFrequencyYearlyHourSelect');
const options = hourSelect.find('option').map(option => option.text());
expect(options).toEqual(generateStringSequenceOfNumbers(24));
});
it('should allow to select any miute from 00 -> 59', () => {
const minutSelect = find('rollupJobCreateFrequencyYearlyMinuteSelect');
const minutSelect = find('cronFrequencyYearlyMinuteSelect');
const options = minutSelect.find('option').map(option => option.text());
expect(options).toEqual(generateStringSequenceOfNumbers(60));
});

View file

@ -1,58 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiFormRow,
EuiSelect,
EuiText,
} from '@elastic/eui';
export const CronHourly = ({
minute,
minuteOptions,
onChange,
}) => (
<Fragment>
<EuiFormRow
label={(
<FormattedMessage
id="xpack.rollupJobs.cronEditor.cronHourly.fieldTimeLabel"
defaultMessage="Minute"
/>
)}
fullWidth
data-test-subj="rollupCronFrequencyConfiguration"
>
<EuiSelect
options={minuteOptions}
value={minute}
onChange={e => onChange({ minute: e.target.value })}
fullWidth
prepend={(
<EuiText size="xs">
<strong>
<FormattedMessage
id="xpack.rollupJobs.cronEditor.cronHourly.fieldMinute.textAtLabel"
defaultMessage="At"
/>
</strong>
</EuiText>
)}
data-test-subj="rollupJobCreateFrequencyHourlyMinuteSelect"
/>
</EuiFormRow>
</Fragment>
);
CronHourly.propTypes = {
minute: PropTypes.string.isRequired,
minuteOptions: PropTypes.array.isRequired,
onChange: PropTypes.func.isRequired,
};

View file

@ -5,5 +5,4 @@
*/
export { FieldChooser } from './field_chooser';
export { CronEditor } from './cron_editor';
export { StepError } from './step_error';

View file

@ -24,10 +24,12 @@ import {
EuiTitle,
} from '@elastic/eui';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { CronEditor } from '../../../../../../../../../src/plugins/es_ui_shared/public/components/cron_editor';
import { INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE } from 'ui/index_patterns';
import { INDEX_ILLEGAL_CHARACTERS_VISIBLE } from 'ui/indices';
import { logisticalDetailsUrl, cronUrl } from '../../../services';
import { CronEditor, StepError } from './components';
import { StepError } from './components';
const indexPatternIllegalCharacters = INDEX_PATTERN_ILLEGAL_CHARACTERS_VISIBLE.join(' ');
const indexIllegalCharacters = INDEX_ILLEGAL_CHARACTERS_VISIBLE.join(' ');

View file

@ -8,7 +8,8 @@ import cloneDeep from 'lodash/lang/cloneDeep';
import get from 'lodash/object/get';
import pick from 'lodash/object/pick';
import { WEEK } from '../../../services';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { WEEK } from '../../../../../../../../../src/plugins/es_ui_shared/public/components/cron_editor';
import { validateId } from './validate_id';
import { validateIndexPattern } from './validate_index_pattern';

View file

@ -1,78 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
// The international ISO standard dictates Monday as the first day of the week, but cron patterns
// use Sunday as the first day, so we're going with the cron way.
const dayOrdinalToDayNameMap = {
0: i18n.translate('xpack.rollupJobs.util.day.sunday', { defaultMessage: 'Sunday' }),
1: i18n.translate('xpack.rollupJobs.util.day.monday', { defaultMessage: 'Monday' }),
2: i18n.translate('xpack.rollupJobs.util.day.tuesday', { defaultMessage: 'Tuesday' }),
3: i18n.translate('xpack.rollupJobs.util.day.wednesday', { defaultMessage: 'Wednesday' }),
4: i18n.translate('xpack.rollupJobs.util.day.thursday', { defaultMessage: 'Thursday' }),
5: i18n.translate('xpack.rollupJobs.util.day.friday', { defaultMessage: 'Friday' }),
6: i18n.translate('xpack.rollupJobs.util.day.saturday', { defaultMessage: 'Saturday' }),
};
const monthOrdinalToMonthNameMap = {
0: i18n.translate('xpack.rollupJobs.util.month.january', { defaultMessage: 'January' }),
1: i18n.translate('xpack.rollupJobs.util.month.february', { defaultMessage: 'February' }),
2: i18n.translate('xpack.rollupJobs.util.month.march', { defaultMessage: 'March' }),
3: i18n.translate('xpack.rollupJobs.util.month.april', { defaultMessage: 'April' }),
4: i18n.translate('xpack.rollupJobs.util.month.may', { defaultMessage: 'May' }),
5: i18n.translate('xpack.rollupJobs.util.month.june', { defaultMessage: 'June' }),
6: i18n.translate('xpack.rollupJobs.util.month.july', { defaultMessage: 'July' }),
7: i18n.translate('xpack.rollupJobs.util.month.august', { defaultMessage: 'August' }),
8: i18n.translate('xpack.rollupJobs.util.month.september', { defaultMessage: 'September' }),
9: i18n.translate('xpack.rollupJobs.util.month.october', { defaultMessage: 'October' }),
10: i18n.translate('xpack.rollupJobs.util.month.november', { defaultMessage: 'November' }),
11: i18n.translate('xpack.rollupJobs.util.month.december', { defaultMessage: 'December' }),
};
export function getOrdinalValue(number) {
// TODO: This is breaking reporting pdf generation. Possibly due to phantom not setting locale,
// which is needed by i18n (formatjs). Need to verify, fix, and restore i18n in place of static stings.
// return i18n.translate('xpack.rollupJobs.util.number.ordinal', {
// defaultMessage: '{number, selectordinal, one{#st} two{#nd} few{#rd} other{#th}}',
// values: { number },
// });
// TODO: https://github.com/elastic/kibana/issues/27136
// Protects against falsey (including 0) values
const num = number && number.toString();
let lastDigit = num && num.substr(-1);
let ordinal;
if(!lastDigit) {
return number;
}
lastDigit = parseFloat(lastDigit);
switch(lastDigit) {
case 1:
ordinal = 'st';
break;
case 2:
ordinal = 'nd';
break;
case 3:
ordinal = 'rd';
break;
default:
ordinal = 'th';
}
return `${num}${ordinal}`;
}
export function getDayName(dayOrdinal) {
return dayOrdinalToDayNameMap[dayOrdinal];
}
export function getMonthName(monthOrdinal) {
return monthOrdinalToMonthNameMap[monthOrdinal];
}

View file

@ -23,17 +23,6 @@ export {
createBreadcrumb,
} from './breadcrumbs';
export {
cronExpressionToParts,
cronPartsToExpression,
MINUTE,
HOUR,
DAY,
WEEK,
MONTH,
YEAR,
} from './cron';
export {
logisticalDetailsUrl,
dateHistogramDetailsUrl,
@ -61,12 +50,6 @@ export {
getHttp,
} from './http_provider';
export {
getOrdinalValue,
getDayName,
getMonthName,
} from './humanized_numbers';
export {
serializeJob,
deserializeJob,

View file

@ -53,3 +53,4 @@ export const APP_REQUIRED_CLUSTER_PRIVILEGES = [
'cluster:admin/repository',
];
export const APP_RESTORE_INDEX_PRIVILEGES = ['monitor'];
export const APP_SLM_CLUSTER_PRIVILEGES = ['manage_slm'];

View file

@ -8,3 +8,9 @@ export {
deserializeRestoreSettings,
serializeRestoreSettings,
} from './restore_settings_serialization';
export {
deserializeSnapshotDetails,
deserializeSnapshotConfig,
serializeSnapshotConfig,
} from './snapshot_serialization';
export { deserializePolicy, serializePolicy } from './policy_serialization';

View file

@ -3,8 +3,8 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { SlmPolicy, SlmPolicyEs } from '../../common/types';
import { deserializeSnapshotConfig } from './';
import { SlmPolicy, SlmPolicyEs, SlmPolicyPayload } from '../types';
import { deserializeSnapshotConfig, serializeSnapshotConfig } from './';
export const deserializePolicy = (name: string, esPolicy: SlmPolicyEs): SlmPolicy => {
const {
@ -16,6 +16,7 @@ export const deserializePolicy = (name: string, esPolicy: SlmPolicyEs): SlmPolic
next_execution_millis: nextExecutionMillis,
last_failure: lastFailure,
last_success: lastSuccess,
in_progress: inProgress,
} = esPolicy;
const policy: SlmPolicy = {
@ -26,11 +27,14 @@ export const deserializePolicy = (name: string, esPolicy: SlmPolicyEs): SlmPolic
snapshotName,
schedule,
repository,
config: deserializeSnapshotConfig(config),
nextExecution,
nextExecutionMillis,
};
if (config) {
policy.config = deserializeSnapshotConfig(config);
}
if (lastFailure) {
const {
snapshot_name: failureSnapshotName,
@ -70,5 +74,28 @@ export const deserializePolicy = (name: string, esPolicy: SlmPolicyEs): SlmPolic
};
}
if (inProgress) {
const { name: inProgressSnapshotName } = inProgress;
policy.inProgress = {
snapshotName: inProgressSnapshotName,
};
}
return policy;
};
export const serializePolicy = (policy: SlmPolicyPayload): SlmPolicyEs['policy'] => {
const { snapshotName: name, schedule, repository, config } = policy;
const policyEs: SlmPolicyEs['policy'] = {
name,
schedule,
repository,
};
if (config) {
policyEs.config = serializeSnapshotConfig(config);
}
return policyEs;
};

View file

@ -6,12 +6,7 @@
import { sortBy } from 'lodash';
import {
SnapshotDetails,
SnapshotDetailsEs,
SnapshotConfig,
SnapshotConfigEs,
} from '../../common/types';
import { SnapshotDetails, SnapshotDetailsEs, SnapshotConfig, SnapshotConfigEs } from '../types';
export function deserializeSnapshotDetails(
repository: string,
@ -114,3 +109,22 @@ export function deserializeSnapshotConfig(snapshotConfigEs: SnapshotConfigEs): S
return config;
}, {});
}
export function serializeSnapshotConfig(snapshotConfig: SnapshotConfig): SnapshotConfigEs {
const { indices, ignoreUnavailable, includeGlobalState, partial, metadata } = snapshotConfig;
const snapshotConfigEs: SnapshotConfigEs = {
indices,
ignore_unavailable: ignoreUnavailable,
include_global_state: includeGlobalState,
partial,
metadata,
};
return Object.entries(snapshotConfigEs).reduce((config: any, [key, value]) => {
if (value !== undefined) {
config[key] = value;
}
return config;
}, {});
}

View file

@ -6,15 +6,18 @@
import { SnapshotConfig, SnapshotConfigEs } from './snapshot';
export interface SlmPolicy {
export interface SlmPolicyPayload {
name: string;
version: number;
modifiedDate: string;
modifiedDateMillis: number;
snapshotName: string;
schedule: string;
repository: string;
config: SnapshotConfig;
config?: SnapshotConfig;
}
export interface SlmPolicy extends SlmPolicyPayload {
version: number;
modifiedDate: string;
modifiedDateMillis: number;
nextExecution: string;
nextExecutionMillis: number;
lastSuccess?: {
@ -28,6 +31,9 @@ export interface SlmPolicy {
time: number;
details: object | string;
};
inProgress?: {
snapshotName: string;
};
}
export interface SlmPolicyEs {
@ -38,7 +44,7 @@ export interface SlmPolicyEs {
name: string;
schedule: string;
repository: string;
config: SnapshotConfigEs;
config?: SnapshotConfigEs;
};
next_execution: string;
next_execution_millis: number;
@ -53,4 +59,11 @@ export interface SlmPolicyEs {
time: number;
details: string;
};
in_progress?: {
name: string;
uuid: string;
state: string;
start_time: string;
start_time_millis: number;
};
}

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
export interface SnapshotConfig {
indices?: string[];
indices?: string | string[];
ignoreUnavailable?: boolean;
includeGlobalState?: boolean;
partial?: boolean;
@ -14,7 +14,7 @@ export interface SnapshotConfig {
}
export interface SnapshotConfigEs {
indices?: string[];
indices?: string | string[];
ignore_unavailable?: boolean;
include_global_state?: boolean;
partial?: boolean;

View file

@ -8,9 +8,17 @@ import React, { useContext } from 'react';
import { Redirect, Route, Switch } from 'react-router-dom';
import { EuiPageContent } from '@elastic/eui';
import { APP_REQUIRED_CLUSTER_PRIVILEGES } from '../../common/constants';
import { SectionLoading, SectionError } from './components';
import { BASE_PATH, DEFAULT_SECTION, Section } from './constants';
import { RepositoryAdd, RepositoryEdit, RestoreSnapshot, SnapshotRestoreHome } from './sections';
import {
RepositoryAdd,
RepositoryEdit,
RestoreSnapshot,
SnapshotRestoreHome,
PolicyAdd,
PolicyEdit,
} from './sections';
import { useAppDependencies } from './index';
import { AuthorizationContext, WithPrivileges, NotAuthorizedSection } from './lib/authorization';
@ -36,7 +44,7 @@ export const App: React.FunctionComponent = () => {
error={apiError}
/>
) : (
<WithPrivileges privileges="cluster.*">
<WithPrivileges privileges={APP_REQUIRED_CLUSTER_PRIVILEGES.map(name => `cluster.${name}`)}>
{({ isLoading, hasPrivileges, privilegesMissing }) =>
isLoading ? (
<SectionLoading>
@ -69,6 +77,8 @@ export const App: React.FunctionComponent = () => {
path={`${BASE_PATH}/restore/:repositoryName/:snapshotId*`}
component={RestoreSnapshot}
/>
<Route exact path={`${BASE_PATH}/add_policy`} component={PolicyAdd} />
<Route exact path={`${BASE_PATH}/edit_policy/:name*`} component={PolicyEdit} />
<Redirect from={`${BASE_PATH}`} to={`${BASE_PATH}/${DEFAULT_SECTION}`} />
</Switch>
</div>

View file

@ -16,3 +16,4 @@ export { SnapshotDeleteProvider } from './snapshot_delete_provider';
export { RestoreSnapshotForm } from './restore_snapshot_form';
export { PolicyExecuteProvider } from './policy_execute_provider';
export { PolicyDeleteProvider } from './policy_delete_provider';
export { PolicyForm } from './policy_form';

View file

@ -87,7 +87,7 @@ export const PolicyExecuteProvider: React.FunctionComponent<Props> = ({ children
title={
<FormattedMessage
id="xpack.snapshotRestore.executePolicy.confirmModal.executePolicyTitle"
defaultMessage="Run policy '{name}'?"
defaultMessage="Run '{name}' now?"
values={{ name: policyName }}
/>
}
@ -102,18 +102,11 @@ export const PolicyExecuteProvider: React.FunctionComponent<Props> = ({ children
confirmButtonText={
<FormattedMessage
id="xpack.snapshotRestore.executePolicy.confirmModal.confirmButtonLabel"
defaultMessage="Run"
defaultMessage="Run policy"
/>
}
data-test-subj="srExecutePolicyConfirmationModal"
>
<p>
<FormattedMessage
id="xpack.snapshotRestore.executePolicy.confirmModal.executeDescription"
defaultMessage="A snapshot will be taken immediately using this policy configuration."
/>
</p>
</EuiConfirmModal>
/>
</EuiOverlayMask>
);
};

View file

@ -0,0 +1,16 @@
/*
* Prevent switch controls from moving around when toggling content
*/
.snapshotRestore__policyForm__stepSettings {
.euiFormRow--hasEmptyLabelSpace {
min-height: auto;
margin-top: $euiFontSizeXS + $euiSizeS + ($euiSizeXXL / 4);
}
}
/*
* Allow toggle mode link in indices field label to be flushed right
*/
.snapshotRestore__policyForm__stepSettings__indicesFieldWrapper .euiFormLabel {
width: 100%;
}

View file

@ -0,0 +1,6 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { PolicyForm } from './policy_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 PolicyNavigation: React.FunctionComponent<Props> = ({
currentStep,
maxCompletedStep,
updateCurrentStep,
}) => {
const {
core: { i18n },
} = useAppDependencies();
const steps = [
{
title: i18n.translate('xpack.snapshotRestore.policyForm.navigation.stepLogisticsName', {
defaultMessage: 'Logistics',
}),
isComplete: maxCompletedStep >= 1,
isSelected: currentStep === 1,
onClick: () => updateCurrentStep(1),
},
{
title: i18n.translate('xpack.snapshotRestore.policyForm.navigation.stepSettingsName', {
defaultMessage: 'Snapshot settings',
}),
isComplete: maxCompletedStep >= 2,
isSelected: currentStep === 2,
disabled: maxCompletedStep < 1,
onClick: () => updateCurrentStep(2),
},
{
title: i18n.translate('xpack.snapshotRestore.policyForm.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,217 @@
/*
* 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 { SlmPolicyPayload } from '../../../../common/types';
import { PolicyValidation, validatePolicy } from '../../services/validation';
import { useAppDependencies } from '../../index';
import { PolicyStepLogistics, PolicyStepSettings, PolicyStepReview } from './steps';
import { PolicyNavigation } from './navigation';
interface Props {
policy: SlmPolicyPayload;
indices: string[];
currentUrl: string;
isEditing?: boolean;
isSaving: boolean;
saveError?: React.ReactNode;
clearSaveError: () => void;
onCancel: () => void;
onSave: (policy: SlmPolicyPayload) => void;
}
export const PolicyForm: React.FunctionComponent<Props> = ({
policy: originalPolicy,
indices,
currentUrl,
isEditing,
isSaving,
saveError,
clearSaveError,
onCancel,
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: PolicyStepLogistics,
2: PolicyStepSettings,
3: PolicyStepReview,
};
const CurrentStepForm = stepMap[currentStep];
// Policy state
const [policy, setPolicy] = useState<SlmPolicyPayload>({
...originalPolicy,
config: {
...(originalPolicy.config || {}),
},
});
// Policy validation state
const [validation, setValidation] = useState<PolicyValidation>({
isValid: true,
errors: {},
});
const updatePolicy = (updatedFields: any): void => {
const newPolicy = { ...policy, ...updatedFields };
const newValidation = validatePolicy(newPolicy);
setPolicy(newPolicy);
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 savePolicy = () => {
if (validation.isValid) {
onSave(policy);
}
};
const lastStep = Object.keys(stepMap).length;
return (
<Fragment>
<PolicyNavigation
currentStep={currentStep}
maxCompletedStep={maxCompletedStep}
updateCurrentStep={updateCurrentStep}
/>
<EuiSpacer size="l" />
<EuiForm>
<CurrentStepForm
policy={policy}
indices={indices}
updatePolicy={updatePolicy}
isEditing={isEditing}
currentUrl={currentUrl}
errors={validation.errors}
updateCurrentStep={updateCurrentStep}
/>
<EuiSpacer size="l" />
{saveError ? (
<Fragment>
{saveError}
<EuiSpacer size="m" />
</Fragment>
) : null}
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiFlexGroup>
{currentStep > 1 ? (
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="arrowLeft"
onClick={() => onBack()}
disabled={!validation.isValid}
>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.backButtonLabel"
defaultMessage="Back"
/>
</EuiButtonEmpty>
</EuiFlexItem>
) : null}
{currentStep < lastStep ? (
<EuiFlexItem grow={false}>
<EuiButton
fill
iconType="arrowRight"
onClick={() => onNext()}
disabled={!validation.isValid}
>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.nextButtonLabel"
defaultMessage="Next"
/>
</EuiButton>
</EuiFlexItem>
) : null}
{currentStep === lastStep ? (
<EuiFlexItem grow={false}>
<EuiButton
fill
color="secondary"
iconType="check"
onClick={() => savePolicy()}
isLoading={isSaving}
>
{isSaving ? (
<FormattedMessage
id="xpack.snapshotRestore.policyForm.savingButtonLabel"
defaultMessage="Saving…"
/>
) : isEditing ? (
<FormattedMessage
id="xpack.snapshotRestore.policyForm.saveButtonLabel"
defaultMessage="Save policy"
/>
) : (
<FormattedMessage
id="xpack.snapshotRestore.policyForm.createButtonLabel"
defaultMessage="Create policy"
/>
)}
</EuiButton>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={() => onCancel()}>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.cancelButtonLabel"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</EuiForm>
<EuiSpacer size="m" />
</Fragment>
);
};

View file

@ -0,0 +1,22 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { SlmPolicyPayload } from '../../../../../common/types';
import { PolicyValidation } from '../../../services/validation';
export interface StepProps {
policy: SlmPolicyPayload;
indices: string[];
updatePolicy: (updatedSettings: Partial<SlmPolicyPayload>) => void;
isEditing: boolean;
currentUrl: string;
errors: PolicyValidation['errors'];
updateCurrentStep: (step: number) => void;
}
export { PolicyStepLogistics } from './step_logistics';
export { PolicyStepSettings } from './step_settings';
export { PolicyStepReview } from './step_review';

View file

@ -0,0 +1,507 @@
/*
* 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 {
EuiDescribedFormGroup,
EuiTitle,
EuiFormRow,
EuiFieldText,
EuiSelect,
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiButtonEmpty,
EuiLink,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import { Repository } from '../../../../../common/types';
import { CronEditor } from '../../../../shared_imports';
import { DEFAULT_POLICY_SCHEDULE, DEFAULT_POLICY_FREQUENCY } from '../../../constants';
import { useLoadRepositories } from '../../../services/http';
import { linkToAddRepository } from '../../../services/navigation';
import { documentationLinksService } from '../../../services/documentation';
import { useAppDependencies } from '../../../index';
import { SectionLoading, SectionError } from '../../';
import { StepProps } from './';
export const PolicyStepLogistics: React.FunctionComponent<StepProps> = ({
policy,
updatePolicy,
isEditing,
currentUrl,
errors,
}) => {
const {
core: { i18n },
} = useAppDependencies();
const { FormattedMessage } = i18n;
// Load repositories for repository dropdown field
const {
error: errorLoadingRepositories,
isLoading: isLoadingRepositories,
data: { repositories } = {
repositories: [],
},
sendRequest: reloadRepositories,
} = useLoadRepositories();
// State for touched inputs
const [touched, setTouched] = useState({
name: false,
snapshotName: false,
repository: false,
schedule: false,
});
// State for cron editor
const [simpleCron, setSimpleCron] = useState<{
expression: string;
frequency: string;
}>({
expression: DEFAULT_POLICY_SCHEDULE,
frequency: DEFAULT_POLICY_FREQUENCY,
});
const [isAdvancedCronVisible, setIsAdvancedCronVisible] = useState<boolean>(
Boolean(policy.schedule && policy.schedule !== DEFAULT_POLICY_SCHEDULE)
);
const [fieldToPreferredValueMap, setFieldToPreferredValueMap] = useState<any>({});
const renderNameField = () => (
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepLogistics.nameDescriptionTitle"
defaultMessage="Policy name"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepLogistics.nameDescription"
defaultMessage="A unique identifier for this policy."
/>
}
idAria="nameDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepLogistics.nameLabel"
defaultMessage="Name"
/>
}
describedByIds={['nameDescription']}
isInvalid={touched.name && Boolean(errors.name)}
error={errors.name}
fullWidth
>
<EuiFieldText
defaultValue={policy.name}
fullWidth
onBlur={() => setTouched({ ...touched, name: true })}
onChange={e => {
updatePolicy({
name: e.target.value,
});
}}
placeholder={i18n.translate(
'xpack.snapshotRestore.policyForm.stepLogistics.namePlaceholder',
{
defaultMessage: 'daily-snapshots',
description:
'Example SLM policy name. Similar to index names, do not use spaces in translation.',
}
)}
data-test-subj="nameInput"
disabled={isEditing}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
);
const renderRepositoryField = () => (
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepLogistics.repositoryDescriptionTitle"
defaultMessage="Repository"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepLogistics.repositoryDescription"
defaultMessage="The repository where you want to store the snapshots."
/>
}
idAria="policyRepositoryDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepLogistics.policyRepositoryLabel"
defaultMessage="Repository"
/>
}
describedByIds={['policyRepositoryDescription']}
isInvalid={touched.repository && Boolean(errors.repository)}
error={errors.repository}
fullWidth
>
{renderRepositorySelect()}
</EuiFormRow>
</EuiDescribedFormGroup>
);
const renderRepositorySelect = () => {
if (isLoadingRepositories) {
return (
<SectionLoading inline={true}>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.loadingRepositoriesDescription"
defaultMessage="Loading repositories…"
/>
</SectionLoading>
);
}
if (errorLoadingRepositories) {
return (
<SectionError
title={
<FormattedMessage
id="xpack.snapshotRestore.policyForm.loadingRepositoriesErrorMessage"
defaultMessage="Error loading repositories"
/>
}
error={{ data: { error: 'test' } } || errorLoadingRepositories}
actions={
<EuiButton
onClick={() => reloadRepositories()}
color="danger"
iconType="refresh"
data-test-subj="reloadRepositoriesButton"
>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.reloadRepositoriesButtonLabel"
defaultMessage="Reload repositories"
/>
</EuiButton>
}
/>
);
}
if (repositories.length === 0) {
return (
<SectionError
title={
<FormattedMessage
id="xpack.snapshotRestore.policyForm.noRepositoriesErrorTitle"
defaultMessage="You don't have any repositories"
/>
}
error={{
data: {
error: i18n.translate('xpack.snapshotRestore.policyForm.noRepositoriesErrorMessage', {
defaultMessage: 'You must register a repository to store your snapshots.',
}),
},
}}
actions={
<EuiButton
href={linkToAddRepository(currentUrl)}
color="danger"
iconType="plusInCircle"
data-test-subj="addRepositoryButton"
>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.addRepositoryButtonLabel"
defaultMessage="Register a repository"
/>
</EuiButton>
}
/>
);
} else {
if (!policy.repository) {
updatePolicy({
repository: repositories[0].name,
});
}
}
return (
<EuiSelect
options={repositories.map(({ name }: Repository) => ({
value: name,
text: name,
}))}
value={policy.repository || repositories[0].name}
onBlur={() => setTouched({ ...touched, repository: true })}
onChange={e => {
updatePolicy({
repository: e.target.value,
});
}}
fullWidth
data-test-subj="repositorySelect"
/>
);
};
const renderSnapshotNameField = () => (
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepLogistics.snapshotNameDescriptionTitle"
defaultMessage="Snapshot name"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepLogistics.snapshotNameDescription"
defaultMessage="The name for the snapshots. A unique identifier is automatically added to each name."
/>
}
idAria="policySnapshotNameDescription"
fullWidth
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepLogistics.policySnapshotNameLabel"
defaultMessage="Snapshot name"
/>
}
describedByIds={['policySnapshotNameDescription']}
isInvalid={touched.snapshotName && Boolean(errors.snapshotName)}
error={errors.snapshotName}
helpText={
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepLogistics.policySnapshotNameHelpText"
defaultMessage="Supports date math expressions. {docLink}"
values={{
docLink: (
<EuiLink
href={documentationLinksService.getDateMathIndexNamesUrl()}
target="_blank"
>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepLogistics.policySnapshotNameHelpTextDocLink"
defaultMessage="Learn more"
/>
</EuiLink>
),
}}
/>
}
fullWidth
>
<EuiFieldText
defaultValue={policy.snapshotName}
fullWidth
onChange={e => {
updatePolicy({
snapshotName: e.target.value.toLowerCase(),
});
}}
onBlur={() => setTouched({ ...touched, snapshotName: true })}
placeholder={i18n.translate(
'xpack.snapshotRestore.policyForm.stepLogistics.policySnapshotNamePlaceholder',
{
defaultMessage: '<daily-snap-\\{now/d\\}>',
description:
'Example date math snapshot name. Keeping the same syntax is important: <SOME-TRANSLATION-{now/d}>',
}
)}
data-test-subj="snapshotNameInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
);
const renderScheduleField = () => (
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepLogistics.scheduleDescriptionTitle"
defaultMessage="Schedule"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepLogistics.scheduleDescription"
defaultMessage="The frequency at which to take the snapshots."
/>
}
idAria="policyScheduleDescription"
fullWidth
>
{isAdvancedCronVisible ? (
<Fragment>
<EuiFormRow
label={
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepLogistics.policyScheduleLabel"
defaultMessage="Schedule"
/>
}
describedByIds={['policyScheduleDescription']}
isInvalid={touched.schedule && Boolean(errors.schedule)}
error={errors.schedule}
helpText={
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepLogistics.policyScheduleHelpText"
defaultMessage="Use cron expression. {docLink}"
values={{
docLink: (
<EuiLink href={documentationLinksService.getCronUrl()} target="_blank">
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepLogistics.policyScheduleHelpTextDocLink"
defaultMessage="Learn more"
/>
</EuiLink>
),
}}
/>
}
fullWidth
>
<EuiFieldText
defaultValue={policy.schedule}
fullWidth
onChange={e => {
updatePolicy({
schedule: e.target.value,
});
}}
onBlur={() => setTouched({ ...touched, schedule: true })}
placeholder={DEFAULT_POLICY_SCHEDULE}
data-test-subj="snapshotNameInput"
/>
</EuiFormRow>
<EuiText size="s">
<EuiLink
onClick={() => {
setIsAdvancedCronVisible(false);
updatePolicy({
schedule: simpleCron.expression,
});
}}
data-test-subj="showBasicCronLink"
>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepLogistics.policyScheduleButtonBasicLabel"
defaultMessage="Create basic interval"
/>
</EuiLink>
</EuiText>
</Fragment>
) : (
<Fragment>
<CronEditor
fieldToPreferredValueMap={fieldToPreferredValueMap}
cronExpression={simpleCron.expression}
frequency={simpleCron.frequency}
onChange={({
cronExpression: expression,
frequency,
fieldToPreferredValueMap: newFieldToPreferredValueMap,
}: {
cronExpression: string;
frequency: string;
fieldToPreferredValueMap: any;
}) => {
setSimpleCron({
expression,
frequency,
});
setFieldToPreferredValueMap(newFieldToPreferredValueMap);
updatePolicy({
schedule: expression,
});
}}
/>
<EuiText size="s">
<EuiLink
onClick={() => {
setIsAdvancedCronVisible(true);
}}
data-test-subj="showAdvancedCronLink"
>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepLogistics.policyScheduleButtonAdvancedLabel"
defaultMessage="Create cron expression"
/>
</EuiLink>
</EuiText>
</Fragment>
)}
</EuiDescribedFormGroup>
);
return (
<Fragment>
{/* Step title and doc link */}
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiTitle>
<h3>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepLogisticsTitle"
defaultMessage="Logistics"
/>
</h3>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="s"
flush="right"
href={documentationLinksService.getSlmUrl()}
target="_blank"
iconType="help"
>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepLogistics.docsButtonLabel"
defaultMessage="Logistics docs"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="l" />
{renderNameField()}
{renderSnapshotNameField()}
{renderRepositoryField()}
{renderScheduleField()}
</Fragment>
);
};

View file

@ -0,0 +1,329 @@
/*
* 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 {
EuiCodeBlock,
EuiFlexGroup,
EuiFlexItem,
EuiDescriptionList,
EuiDescriptionListTitle,
EuiDescriptionListDescription,
EuiSpacer,
EuiTabbedContent,
EuiTitle,
EuiLink,
EuiIcon,
EuiToolTip,
EuiText,
} from '@elastic/eui';
import { serializePolicy } from '../../../../../common/lib';
import { useAppDependencies } from '../../../index';
import { StepProps } from './';
export const PolicyStepReview: React.FunctionComponent<StepProps> = ({
policy,
updateCurrentStep,
}) => {
const {
core: { i18n },
} = useAppDependencies();
const { FormattedMessage } = i18n;
const { name, snapshotName, schedule, repository, config } = policy;
const { indices, includeGlobalState, ignoreUnavailable, partial } = config || {
indices: undefined,
includeGlobalState: undefined,
ignoreUnavailable: undefined,
partial: undefined,
};
const [isShowingFullIndicesList, setIsShowingFullIndicesList] = useState<boolean>(false);
const displayIndices = indices
? typeof indices === 'string'
? indices.split(',')
: indices
: undefined;
const hiddenIndicesCount =
displayIndices && displayIndices.length > 10 ? displayIndices.length - 10 : 0;
const renderSummaryTab = () => (
<Fragment>
<EuiSpacer size="m" />
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepReview.summaryTab.sectionLogisticsTitle"
defaultMessage="Logistics"
/>{' '}
<EuiToolTip
content={
<FormattedMessage
id="xpack.snapshotRestore.policyForm.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.policyForm.stepReview.summaryTab.nameLabel"
defaultMessage="Policy name"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>{name}</EuiDescriptionListDescription>
</EuiDescriptionList>
</EuiFlexItem>
<EuiFlexItem>
<EuiDescriptionList textStyle="reverse">
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepReview.summaryTab.snapshotNameLabel"
defaultMessage="Snapshot name"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>{snapshotName}</EuiDescriptionListDescription>
</EuiDescriptionList>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiDescriptionList textStyle="reverse">
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepReview.summaryTab.repositoryLabel"
defaultMessage="Repository"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>{repository}</EuiDescriptionListDescription>
</EuiDescriptionList>
</EuiFlexItem>
<EuiFlexItem>
<EuiDescriptionList textStyle="reverse">
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepReview.summaryTab.scheduleLabel"
defaultMessage="Schedule"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>{schedule}</EuiDescriptionListDescription>
</EuiDescriptionList>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepReview.summaryTab.sectionSettingsTitle"
defaultMessage="Snapshot settings"
/>{' '}
<EuiToolTip
content={
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepReview.summaryTab.editStepTooltip"
defaultMessage="Edit"
/>
}
>
<EuiLink onClick={() => updateCurrentStep(2)}>
<EuiIcon type="pencil" />
</EuiLink>
</EuiToolTip>
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiDescriptionList textStyle="reverse">
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepReview.summaryTab.indicesLabel"
defaultMessage="Indices"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{displayIndices ? (
<EuiText>
<ul>
{(isShowingFullIndicesList
? displayIndices
: [...displayIndices].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.policyForm.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.policyForm.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.policyForm.stepReview.summaryTab.allIndicesValue"
defaultMessage="All indices"
/>
)}
</EuiDescriptionListDescription>
</EuiDescriptionList>
</EuiFlexItem>
<EuiFlexItem>
<EuiDescriptionList textStyle="reverse">
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepReview.summaryTab.ignoreUnavailableLabel"
defaultMessage="Ignore unavailable indices"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{ignoreUnavailable ? (
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepReview.summaryTab.ignoreUnavailableTrueLabel"
defaultMessage="Yes"
/>
) : (
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepReview.summaryTab.ignoreUnavailableFalseLabel"
defaultMessage="No"
/>
)}
</EuiDescriptionListDescription>
</EuiDescriptionList>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiDescriptionList textStyle="reverse">
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepReview.summaryTab.partialLabel"
defaultMessage="Allow partial shards"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{partial ? (
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepReview.summaryTab.partialTrueLabel"
defaultMessage="Yes"
/>
) : (
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepReview.summaryTab.partialFalseLabel"
defaultMessage="No"
/>
)}
</EuiDescriptionListDescription>
</EuiDescriptionList>
</EuiFlexItem>
<EuiFlexItem>
<EuiDescriptionList textStyle="reverse">
<EuiDescriptionListTitle>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepReview.summaryTab.includeGlobalStateLabel"
defaultMessage="Include global state"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription>
{includeGlobalState === false ? (
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepReview.summaryTab.includeGlobalStateFalseLabel"
defaultMessage="No"
/>
) : (
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepReview.summaryTab.includeGlobalStateTrueLabel"
defaultMessage="Yes"
/>
)}
</EuiDescriptionListDescription>
</EuiDescriptionList>
</EuiFlexItem>
</EuiFlexGroup>
</Fragment>
);
const renderRequestTab = () => {
const endpoint = `PUT _slm/policy/${name}`;
const json = JSON.stringify(serializePolicy(policy), null, 2);
return (
<Fragment>
<EuiSpacer size="m" />
<EuiCodeBlock language="json" isCopyable>
{`${endpoint}\n${json}`}
</EuiCodeBlock>
</Fragment>
);
};
return (
<Fragment>
<EuiTitle>
<h3>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepReviewTitle"
defaultMessage="Review policy"
/>
</h3>
</EuiTitle>
<EuiSpacer size="m" />
<EuiTabbedContent
tabs={[
{
id: 'summary',
name: i18n.translate('xpack.snapshotRestore.policyForm.stepReview.summaryTabTitle', {
defaultMessage: 'Summary',
}),
content: renderSummaryTab(),
},
{
id: 'json',
name: i18n.translate('xpack.snapshotRestore.policyForm.stepReview.requestTabTitle', {
defaultMessage: 'Request',
}),
content: renderRequestTab(),
},
]}
/>
</Fragment>
);
};

View file

@ -0,0 +1,454 @@
/*
* 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 {
EuiDescribedFormGroup,
EuiTitle,
EuiFormRow,
EuiFlexGroup,
EuiFlexItem,
EuiButtonEmpty,
EuiSpacer,
EuiSwitch,
EuiLink,
EuiSelectable,
EuiPanel,
EuiComboBox,
} from '@elastic/eui';
import { Option } from '@elastic/eui/src/components/selectable/types';
import { SlmPolicyPayload, SnapshotConfig } from '../../../../../common/types';
import { documentationLinksService } from '../../../services/documentation';
import { useAppDependencies } from '../../../index';
import { StepProps } from './';
export const PolicyStepSettings: React.FunctionComponent<StepProps> = ({
policy,
indices,
updatePolicy,
errors,
}) => {
const {
core: { i18n },
} = useAppDependencies();
const { FormattedMessage } = i18n;
const { config = {} } = policy;
const updatePolicyConfig = (updatedFields: Partial<SlmPolicyPayload['config']>): void => {
const newConfig = { ...config, ...updatedFields };
updatePolicy({
config: newConfig,
});
};
// States for choosing all indices, or a subset, including caching previously chosen subset list
const [isAllIndices, setIsAllIndices] = useState<boolean>(!Boolean(config.indices));
const [indicesSelection, setIndicesSelection] = useState<SnapshotConfig['indices']>([...indices]);
const [indicesOptions, setIndicesOptions] = useState<Option[]>(
indices.map(
(index): Option => ({
label: index,
checked:
isAllIndices ||
// If indices is a string, we default to custom input mode, so we mark individual indices
// as selected if user goes back to list mode
typeof config.indices === 'string' ||
(Array.isArray(config.indices) && config.indices.includes(index))
? 'on'
: undefined,
})
)
);
// State for using selectable indices list or custom patterns
// Users with more than 100 indices will probably want to use an index pattern to select
// them instead, so we'll default to showing them the index pattern input.
const [selectIndicesMode, setSelectIndicesMode] = useState<'list' | 'custom'>(
typeof config.indices === 'string' ||
(Array.isArray(config.indices) && config.indices.length > 100)
? 'custom'
: 'list'
);
// State for custom patterns
const [indexPatterns, setIndexPatterns] = useState<string[]>(
typeof config.indices === 'string' ? config.indices.split(',') : []
);
const renderIndicesField = () => (
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepSettings.indicesTitle"
defaultMessage="Indices"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepSettings.indicesDescription"
defaultMessage="Indices to back up."
/>
}
idAria="indicesDescription"
fullWidth
>
<EuiFormRow hasEmptyLabelSpace fullWidth describedByIds={['indicesDescription']}>
<Fragment>
<EuiSwitch
label={
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepSettings.allIndicesLabel"
defaultMessage="All indices, including system indices"
/>
}
checked={isAllIndices}
onChange={e => {
const isChecked = e.target.checked;
setIsAllIndices(isChecked);
if (isChecked) {
updatePolicyConfig({ indices: undefined });
} else {
updatePolicyConfig({
indices:
selectIndicesMode === 'custom'
? indexPatterns.join(',')
: [...(indicesSelection || [])],
});
}
}}
/>
{isAllIndices ? null : (
<Fragment>
<EuiSpacer size="m" />
<EuiFormRow
className="snapshotRestore__policyForm__stepSettings__indicesFieldWrapper"
label={
selectIndicesMode === 'list' ? (
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepSettings.selectIndicesLabel"
defaultMessage="Select indices"
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiLink
onClick={() => {
setSelectIndicesMode('custom');
updatePolicyConfig({ indices: indexPatterns.join(',') });
}}
>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepSettings.indicesToggleCustomLink"
defaultMessage="Use index patterns"
/>
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
) : (
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepSettings.indicesPatternLabel"
defaultMessage="Index patterns"
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiLink
onClick={() => {
setSelectIndicesMode('list');
updatePolicyConfig({ indices: indicesSelection });
}}
>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepSettings.indicesToggleListLink"
defaultMessage="Select indices"
/>
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
)
}
helpText={
selectIndicesMode === 'list' ? (
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepSettings.selectIndicesHelpText"
defaultMessage="{count} {count, plural, one {index} other {indices}} will be backed up. {selectOrDeselectAllLink}"
values={{
count: config.indices && config.indices.length,
selectOrDeselectAllLink:
config.indices && config.indices.length > 0 ? (
<EuiLink
onClick={() => {
// TODO: Change this to setIndicesOptions() when https://github.com/elastic/eui/issues/2071 is fixed
indicesOptions.forEach((option: Option) => {
option.checked = undefined;
});
updatePolicyConfig({ indices: [] });
setIndicesSelection([]);
}}
>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepSettings.deselectAllIndicesLink"
defaultMessage="Deselect all"
/>
</EuiLink>
) : (
<EuiLink
onClick={() => {
// TODO: Change this to setIndicesOptions() when https://github.com/elastic/eui/issues/2071 is fixed
indicesOptions.forEach((option: Option) => {
option.checked = 'on';
});
updatePolicyConfig({ indices: [...indices] });
setIndicesSelection([...indices]);
}}
>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepSettings.selectAllIndicesLink"
defaultMessage="Select all"
/>
</EuiLink>
),
}}
/>
) : null
}
isInvalid={Boolean(errors.indices)}
error={errors.indices}
>
{selectIndicesMode === 'list' ? (
<EuiSelectable
allowExclusions={false}
options={indicesOptions}
onChange={options => {
const newSelectedIndices: string[] = [];
options.forEach(({ label, checked }) => {
if (checked === 'on') {
newSelectedIndices.push(label);
}
});
setIndicesOptions(options);
updatePolicyConfig({ indices: newSelectedIndices });
setIndicesSelection(newSelectedIndices);
}}
searchable
height={300}
>
{(list, search) => (
<EuiPanel paddingSize="s" hasShadow={false}>
{search}
{list}
</EuiPanel>
)}
</EuiSelectable>
) : (
<EuiComboBox
options={indices.map(index => ({ label: index }))}
placeholder={i18n.translate(
'xpack.snapshotRestore.policyForm.stepSettings.indicesPatternPlaceholder',
{
defaultMessage: 'Enter index patterns, i.e. logstash-*',
}
)}
selectedOptions={indexPatterns.map(pattern => ({ label: pattern }))}
onCreateOption={(pattern: string) => {
if (!pattern.trim().length) {
return;
}
const newPatterns = [...indexPatterns, pattern];
setIndexPatterns(newPatterns);
updatePolicyConfig({
indices: newPatterns.join(','),
});
}}
onChange={(patterns: Array<{ label: string }>) => {
const newPatterns = patterns.map(({ label }) => label);
setIndexPatterns(newPatterns);
updatePolicyConfig({
indices: newPatterns.join(','),
});
}}
/>
)}
</EuiFormRow>
</Fragment>
)}
</Fragment>
</EuiFormRow>
</EuiDescribedFormGroup>
);
const renderIgnoreUnavailableField = () => (
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepSettings.ignoreUnavailableDescriptionTitle"
defaultMessage="Ignore unavailable indices"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepSettings.ignoreUnavailableDescription"
defaultMessage="Ignores indices that are unavailable when taking the snapshot. Otherwise, the entire snapshot will fail."
/>
}
idAria="policyIgnoreUnavailableDescription"
fullWidth
>
<EuiFormRow
hasEmptyLabelSpace
describedByIds={['policyIgnoreUnavailableDescription']}
fullWidth
>
<EuiSwitch
label={
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepSettings.ignoreUnavailableLabel"
defaultMessage="Ignore unavailable indices"
/>
}
checked={Boolean(config.ignoreUnavailable)}
onChange={e => {
updatePolicyConfig({
ignoreUnavailable: e.target.checked,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
);
const renderPartialField = () => (
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepSettings.partialDescriptionTitle"
defaultMessage="Allow partial indices"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepSettings.partialDescription"
defaultMessage="Allows snapshots of indices with primary shards that are unavailable. Otherwise, the entire snapshot will fail."
/>
}
idAria="policyPartialDescription"
fullWidth
>
<EuiFormRow hasEmptyLabelSpace describedByIds={['policyPartialDescription']} fullWidth>
<EuiSwitch
label={
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepSettings.partialLabel"
defaultMessage="Allow partial indices"
/>
}
checked={Boolean(config.partial)}
onChange={e => {
updatePolicyConfig({
partial: e.target.checked,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
);
const renderIncludeGlobalStateField = () => (
<EuiDescribedFormGroup
title={
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepSettings.includeGlobalStateDescriptionTitle"
defaultMessage="Include global state"
/>
</h3>
</EuiTitle>
}
description={
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepSettings.includeGlobalStateDescription"
defaultMessage="Stores the global state of the cluster as part of the snapshot."
/>
}
idAria="policyIncludeGlobalStateDescription"
fullWidth
>
<EuiFormRow
hasEmptyLabelSpace
describedByIds={['policyIncludeGlobalStateDescription']}
fullWidth
>
<EuiSwitch
label={
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepSettings.policyIncludeGlobalStateLabel"
defaultMessage="Include global state"
/>
}
checked={config.includeGlobalState === undefined || config.includeGlobalState}
onChange={e => {
updatePolicyConfig({
includeGlobalState: e.target.checked,
});
}}
/>
</EuiFormRow>
</EuiDescribedFormGroup>
);
return (
<div className="snapshotRestore__policyForm__stepSettings">
{/* Step title and doc link */}
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiTitle>
<h3>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepSettingsTitle"
defaultMessage="Snapshot settings"
/>
</h3>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="s"
flush="right"
href={documentationLinksService.getSnapshotDocUrl()}
target="_blank"
iconType="help"
>
<FormattedMessage
id="xpack.snapshotRestore.policyForm.stepSettings.docsButtonLabel"
defaultMessage="Snapshot settings docs"
/>
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="l" />
{renderIndicesField()}
{renderIgnoreUnavailableField()}
{renderPartialField()}
{renderIncludeGlobalStateField()}
</div>
);
};

View file

@ -155,7 +155,8 @@ export const RepositoryDeleteProvider: React.FunctionComponent<Props> = ({ child
<p>
<FormattedMessage
id="xpack.snapshotRestore.deleteRepository.confirmModal.deleteSingleDescription"
defaultMessage="The snapshots in this repository will still exist, but Elasticsearch wont have access to them."
defaultMessage="The snapshots in this repository will still exist, but Elasticsearch wont have access to them.
Adjust policies that use this repository to prevent scheduled snapshots from failing."
/>
</p>
) : (
@ -174,7 +175,8 @@ export const RepositoryDeleteProvider: React.FunctionComponent<Props> = ({ child
<p>
<FormattedMessage
id="xpack.snapshotRestore.deleteRepository.confirmModal.deleteMultipleDescription"
defaultMessage="The snapshots in these repositories will still exist, but Elasticsearch won't have access to them."
defaultMessage="The snapshots in these repositories will still exist, but Elasticsearch won't have access to them.
Adjust policies that use these repositories to prevent scheduled snapshots from failing."
/>
</p>
</Fragment>

View file

@ -56,6 +56,8 @@ export const RestoreSnapshotStepLogistics: React.FunctionComponent<StepProps> =
label: index,
checked:
isAllIndices ||
// If indices is a string, we default to custom input mode, so we mark individual indices
// as selected if user goes back to list mode
typeof restoreIndices === 'string' ||
(Array.isArray(restoreIndices) && restoreIndices.includes(index))
? 'on'
@ -97,7 +99,7 @@ export const RestoreSnapshotStepLogistics: React.FunctionComponent<StepProps> =
<h3>
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepLogisticsTitle"
defaultMessage="Logistics"
defaultMessage="Restore details"
/>
</h3>
</EuiTitle>
@ -113,7 +115,7 @@ export const RestoreSnapshotStepLogistics: React.FunctionComponent<StepProps> =
>
<FormattedMessage
id="xpack.snapshotRestore.restoreForm.stepLogistics.docsButtonLabel"
defaultMessage="Logistics docs"
defaultMessage="Snapshot and Restore docs"
/>
</EuiButtonEmpty>
</EuiFlexItem>
@ -234,6 +236,7 @@ export const RestoreSnapshotStepLogistics: React.FunctionComponent<StepProps> =
restoreIndices && restoreIndices.length > 0 ? (
<EuiLink
onClick={() => {
// TODO: Change this to setIndicesOptions() when https://github.com/elastic/eui/issues/2071 is fixed
indicesOptions.forEach((option: Option) => {
option.checked = undefined;
});
@ -252,6 +255,7 @@ export const RestoreSnapshotStepLogistics: React.FunctionComponent<StepProps> =
) : (
<EuiLink
onClick={() => {
// TODO: Change this to setIndicesOptions() when https://github.com/elastic/eui/issues/2071 is fixed
indicesOptions.forEach((option: Option) => {
option.checked = 'on';
});

View file

@ -16,9 +16,15 @@ interface Props {
message?: string;
};
};
actions?: JSX.Element;
}
export const SectionError: React.FunctionComponent<Props> = ({ title, error, ...rest }) => {
export const SectionError: React.FunctionComponent<Props> = ({
title,
error,
actions,
...rest
}) => {
const {
error: errorString,
cause, // wrapEsError() on the server adds a "cause" array
@ -27,10 +33,10 @@ export const SectionError: React.FunctionComponent<Props> = ({ title, error, ...
return (
<EuiCallOut title={title} color="danger" iconType="alert" {...rest}>
<div>{message || errorString}</div>
{cause ? message || errorString : <p>{message || errorString}</p>}
{cause && (
<Fragment>
<EuiSpacer size="m" />
<EuiSpacer size="s" />
<ul>
{cause.map((causeMsg, i) => (
<li key={i}>{causeMsg}</li>
@ -38,6 +44,7 @@ export const SectionError: React.FunctionComponent<Props> = ({ title, error, ...
</ul>
</Fragment>
)}
{actions ? actions : null}
</EuiCallOut>
);
};

View file

@ -6,13 +6,37 @@
import React from 'react';
import { EuiEmptyPrompt, EuiLoadingSpinner, EuiText } from '@elastic/eui';
import {
EuiEmptyPrompt,
EuiLoadingSpinner,
EuiText,
EuiFlexGroup,
EuiFlexItem,
EuiTextColor,
} from '@elastic/eui';
interface Props {
inline?: boolean;
children: React.ReactNode;
[key: string]: any;
}
export const SectionLoading: React.FunctionComponent<Props> = ({ children }) => {
export const SectionLoading: React.FunctionComponent<Props> = ({ inline, children, ...rest }) => {
if (inline) {
return (
<EuiFlexGroup justifyContent="flexStart" alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="m" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText {...rest}>
<EuiTextColor color="subdued">{children}</EuiTextColor>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
);
}
return (
<EuiEmptyPrompt
title={<EuiLoadingSpinner size="xl" />}

View file

@ -4,6 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { DAY } from '../../shared_imports';
export const BASE_PATH = '/management/elasticsearch/snapshot_restore';
export const DEFAULT_SECTION: Section = 'snapshots';
export type Section = 'repositories' | 'snapshots' | 'restore_status' | 'policies';
@ -86,6 +88,9 @@ export const REMOVE_INDEX_SETTINGS_SUGGESTIONS: string[] = INDEX_SETTING_SUGGEST
setting => !UNREMOVABLE_INDEX_SETTINGS.includes(setting)
);
export const DEFAULT_POLICY_SCHEDULE = '0 30 1 * * ?';
export const DEFAULT_POLICY_FREQUENCY = DAY;
// UI Metric constants
export const UIM_APP_NAME = 'snapshot_restore';
export const UIM_REPOSITORY_LIST_LOAD = 'repository_list_load';
@ -112,3 +117,5 @@ export const UIM_POLICY_DETAIL_PANEL_HISTORY_TAB = 'policy_detail_panel_last_suc
export const UIM_POLICY_EXECUTE = 'policy_execute';
export const UIM_POLICY_DELETE = 'policy_delete';
export const UIM_POLICY_DELETE_MANY = 'policy_delete_many';
export const UIM_POLICY_CREATE = 'policy_create';
export const UIM_POLICY_UPDATE = 'policy_update';

View file

@ -11,4 +11,5 @@
// snapshotRestore__legend-isLoading
@import 'components/restore_snapshot_form/restore_snapshot_form';
@import 'components/policy_form/policy_form';
@import 'sections/home/home';

View file

@ -18,4 +18,14 @@
background: $euiColorLightestShade;
}
}
}
/*
* 1. Make in progress snapshot loading indicator be centered vertically
* when it is inside tooltip wrapper
*/
.snapshotRestore__policyTable {
.euiToolTipAnchor {
display: flex;
}
}

View file

@ -22,7 +22,7 @@ import {
import { BASE_PATH, Section } from '../../constants';
import { useAppDependencies } from '../../index';
import { breadcrumbService } from '../../services/navigation';
import { breadcrumbService, docTitleService } from '../../services/navigation';
import { RepositoryList } from './repository_list';
import { SnapshotList } from './snapshot_list';
@ -92,10 +92,11 @@ export const SnapshotRestoreHome: React.FunctionComponent<RouteComponentProps<Ma
history.push(`${BASE_PATH}/${newSection}`);
};
// Set breadcrumb
// Set breadcrumb and page title
useEffect(() => {
breadcrumbService.setBreadcrumbs('home');
}, []);
breadcrumbService.setBreadcrumbs(section || 'home');
docTitleService.setTitle(section || 'home');
}, [section]);
return (
<EuiPageBody>

View file

@ -16,6 +16,10 @@ import {
EuiTabs,
EuiTab,
EuiButton,
EuiPopover,
EuiContextMenu,
EuiButtonIcon,
EuiLink,
} from '@elastic/eui';
import { SlmPolicy } from '../../../../../../common/types';
@ -26,6 +30,7 @@ import {
} from '../../../../constants';
import { useLoadPolicy } from '../../../../services/http';
import { uiMetricService } from '../../../../services/ui_metric';
import { linkToEditPolicy, linkToSnapshot } from '../../../../services/navigation';
import {
SectionError,
@ -64,6 +69,7 @@ export const PolicyDetails: React.FunctionComponent<Props> = ({
const { trackUiMetric } = uiMetricService;
const { error, data: policyDetails, sendRequest: reload } = useLoadPolicy(policyName);
const [activeTab, setActiveTab] = useState<string>(TAB_SUMMARY);
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
// Reset tab when we look at a different policy
useEffect(() => {
@ -183,53 +189,103 @@ export const PolicyDetails: React.FunctionComponent<Props> = ({
/>
</EuiButtonEmpty>
</EuiFlexItem>
{policyDetails ? (
<EuiFlexItem grow={false}>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={false}>
<PolicyDeleteProvider>
{deletePolicyPrompt => {
return (
<EuiButtonEmpty
color="danger"
data-test-subj="srPolicyDetailsDeleteActionButton"
onClick={() => deletePolicyPrompt([policyName], onPolicyDeleted)}
>
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.deleteButtonLabel"
defaultMessage="Delete"
/>
</EuiButtonEmpty>
);
}}
</PolicyDeleteProvider>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<PolicyExecuteProvider>
{executePolicyPrompt => {
return (
<EuiButton
onClick={() =>
executePolicyPrompt(policyName, () => {
onPolicyExecuted();
reload();
})
}
fill
color="primary"
data-test-subj="srPolicyDetailsExecuteActionButton"
>
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.executeButtonLabel"
defaultMessage="Run policy"
/>
</EuiButton>
);
}}
</PolicyExecuteProvider>
</EuiFlexItem>
</EuiFlexGroup>
<PolicyExecuteProvider>
{executePolicyPrompt => {
return (
<PolicyDeleteProvider>
{deletePolicyPrompt => {
return (
<EuiPopover
id="policyActionMenu"
button={
<EuiButton
data-test-subj="policyActionMenuButton"
iconSide="right"
onClick={() => setIsPopoverOpen(!isPopoverOpen)}
iconType="arrowDown"
fill
>
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.manageButtonLabel"
defaultMessage="Manage policy"
/>
</EuiButton>
}
isOpen={isPopoverOpen}
closePopover={() => setIsPopoverOpen(false)}
panelPaddingSize="none"
withTitle
anchorPosition="rightUp"
repositionOnScroll
>
<EuiContextMenu
data-test-subj="policyActionContextMenu"
initialPanelId={0}
panels={[
{
id: 0,
title: i18n.translate(
'xpack.snapshotRestore.policyDetails.managePanelTitle',
{
defaultMessage: 'Policy options',
}
),
items: [
{
name: i18n.translate(
'xpack.snapshotRestore.policyDetails.executeButtonLabel',
{
defaultMessage: 'Run now',
}
),
icon: 'play',
onClick: () => {
executePolicyPrompt(policyName, () =>
// Wait a little bit for policy to execute before reloading policy table
// and policy details so that History tab information is updated with
// results of the execution
setTimeout(() => {
onPolicyExecuted();
reload();
}, 2000)
);
},
disabled: Boolean(policyDetails.policy.inProgress),
},
{
name: i18n.translate(
'xpack.snapshotRestore.policyDetails.editButtonLabel',
{
defaultMessage: 'Edit',
}
),
icon: 'pencil',
href: linkToEditPolicy(policyName),
},
{
name: i18n.translate(
'xpack.snapshotRestore.policyDetails.deleteButtonLabel',
{
defaultMessage: 'Delete',
}
),
icon: 'trash',
onClick: () =>
deletePolicyPrompt([policyName], onPolicyDeleted),
},
],
},
]}
/>
</EuiPopover>
);
}}
</PolicyDeleteProvider>
);
}}
</PolicyExecuteProvider>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
@ -245,11 +301,49 @@ export const PolicyDetails: React.FunctionComponent<Props> = ({
maxWidth={550}
>
<EuiFlyoutHeader>
<EuiTitle size="m">
<h2 id="srPolicyDetailsFlyoutTitle" data-test-subj="title">
{policyName}
</h2>
</EuiTitle>
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem>
<EuiTitle size="m">
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
<h2 id="srPolicyDetailsFlyoutTitle" data-test-subj="title">
{policyName}
</h2>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType="refresh"
color="subdued"
aria-label={i18n.translate(
'xpack.snapshotRestore.policyDetails.reloadButtonAriaLabel',
{ defaultMessage: 'Reload' }
)}
onClick={() => reload()}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiTitle>
</EuiFlexItem>
{policyDetails && policyDetails.policy && policyDetails.policy.inProgress ? (
<EuiFlexItem>
<SectionLoading inline={true} size="s">
<EuiLink
href={linkToSnapshot(
policyDetails.policy.repository,
policyDetails.policy.inProgress.snapshotName
)}
data-test-subj="inProgressSnapshotLink"
>
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.inProgressSnapshotLinkText"
defaultMessage="'{snapshotName}' in progress"
values={{ snapshotName: policyDetails.policy.inProgress.snapshotName }}
/>
</EuiLink>
</SectionLoading>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
{renderTabs()}
</EuiFlyoutHeader>

View file

@ -55,12 +55,11 @@ export const TabHistory: React.FunctionComponent<Props> = ({ policy }) => {
<EuiDescriptionList textStyle="reverse">
<EuiFlexGroup>
<EuiFlexItem data-test-subj="successTime">
<EuiFlexItem data-test-subj="successDate">
<EuiDescriptionListTitle data-test-subj="title">
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.lastSuccess.timeLabel"
defaultMessage="Succeeded on"
description="Title for date time. Example: Succeeded on Jul 16, 2019 6:30 PM PDT"
id="xpack.snapshotRestore.policyDetails.lastSuccess.dateLabel"
defaultMessage="Date"
/>
</EuiDescriptionListTitle>
@ -107,12 +106,11 @@ export const TabHistory: React.FunctionComponent<Props> = ({ policy }) => {
<EuiDescriptionList textStyle="reverse">
<EuiFlexGroup>
<EuiFlexItem data-test-subj="failureTime">
<EuiFlexItem data-test-subj="failureDate">
<EuiDescriptionListTitle data-test-subj="title">
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.lastFailure.timeLabel"
defaultMessage="Failed on"
description="Title for date time. Example: Failed on Jul 16, 2019 6:30 PM PDT"
id="xpack.snapshotRestore.policyDetails.lastFailure.dateLabel"
defaultMessage="Date"
/>
</EuiDescriptionListTitle>
@ -140,7 +138,7 @@ export const TabHistory: React.FunctionComponent<Props> = ({ policy }) => {
<EuiDescriptionListTitle data-test-subj="title">
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.lastFailure.detailsLabel"
defaultMessage="Failure details"
defaultMessage="Details"
/>
</EuiDescriptionListTitle>
<EuiSpacer size="s" />
@ -154,13 +152,13 @@ export const TabHistory: React.FunctionComponent<Props> = ({ policy }) => {
setOptions={{
showLineNumbers: false,
tabSize: 2,
maxLines: Infinity,
}}
editorProps={{
$blockScrolling: Infinity,
}}
minLines={6}
maxLines={6}
maxLines={12}
wrapEnabled={true}
showGutter={false}
aria-label={
<FormattedMessage
@ -191,7 +189,7 @@ export const TabHistory: React.FunctionComponent<Props> = ({ policy }) => {
<p>
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.noHistoryMessage"
defaultMessage="This policy has not been executed yet. It will automatically run on {date} at {time}."
defaultMessage="This policy will run on {date} at {time}."
values={{
date: <FormattedDateTime epochMs={nextExecutionMillis} type="date" />,
time: <FormattedDateTime epochMs={nextExecutionMillis} type="time" />,

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 React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
@ -41,36 +41,45 @@ export const TabSummary: React.FunctionComponent<Props> = ({ policy }) => {
nextExecutionMillis,
config,
} = policy;
const { includeGlobalState, ignoreUnavailable, indices, partial } = config;
const { includeGlobalState, ignoreUnavailable, indices, partial } = config || {
includeGlobalState: undefined,
ignoreUnavailable: undefined,
indices: undefined,
partial: undefined,
};
// Only show 10 indices initially
const [isShowingFullIndicesList, setIsShowingFullIndicesList] = useState<boolean>(false);
const hiddenIndicesCount = indices && indices.length > 10 ? indices.length - 10 : 0;
const displayIndices = typeof indices === 'string' ? indices.split(',') : indices;
const hiddenIndicesCount =
displayIndices && displayIndices.length > 10 ? displayIndices.length - 10 : 0;
const shortIndicesList =
indices && indices.length ? (
<ul>
{[...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.policyDetails.indicesShowAllLink"
defaultMessage="Show {count} more {count, plural, one {index} other {indices}}"
values={{ count: hiddenIndicesCount }}
/>{' '}
<EuiIcon type="arrowDown" />
</EuiLink>
</EuiTitle>
</li>
) : null}
</ul>
displayIndices && displayIndices.length ? (
<EuiText size="m">
<ul>
{[...displayIndices].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.policyDetails.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.policyDetails.allIndicesLabel"
@ -78,32 +87,41 @@ export const TabSummary: React.FunctionComponent<Props> = ({ policy }) => {
/>
);
const fullIndicesList =
indices && 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.policyDetails.indicesCollapseAllLink"
defaultMessage="Hide {count, plural, one {# index} other {# indices}}"
values={{ count: hiddenIndicesCount }}
/>{' '}
<EuiIcon type="arrowUp" />
</EuiLink>
</EuiTitle>
</li>
) : null}
</ul>
displayIndices && displayIndices.length && displayIndices.length > 10 ? (
<EuiText size="m">
<ul>
{displayIndices.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.policyDetails.indicesCollapseAllLink"
defaultMessage="Hide {count, plural, one {# index} other {# indices}}"
values={{ count: hiddenIndicesCount }}
/>{' '}
<EuiIcon type="arrowUp" />
</EuiLink>
</EuiTitle>
</li>
) : null}
</ul>
</EuiText>
) : null;
// Reset indices list state when clicking through different policies
useEffect(() => {
return () => {
setIsShowingFullIndicesList(false);
};
}, []);
return (
<EuiDescriptionList textStyle="reverse">
<EuiFlexGroup>
@ -180,7 +198,7 @@ export const TabSummary: React.FunctionComponent<Props> = ({ policy }) => {
<EuiDescriptionListTitle data-test-subj="title">
<FormattedMessage
id="xpack.snapshotRestore.policyDetails.nextExecutionLabel"
defaultMessage="Next execution"
defaultMessage="Next snapshot"
/>
</EuiDescriptionListTitle>
@ -200,7 +218,7 @@ export const TabSummary: React.FunctionComponent<Props> = ({ policy }) => {
</EuiDescriptionListTitle>
<EuiDescriptionListDescription className="eui-textBreakWord" data-test-subj="value">
<EuiText>{isShowingFullIndicesList ? fullIndicesList : shortIndicesList}</EuiText>
{isShowingFullIndicesList ? fullIndicesList : shortIndicesList}
</EuiDescriptionListDescription>
</EuiFlexItem>

View file

@ -7,13 +7,16 @@
import React, { Fragment, useEffect } from 'react';
import { RouteComponentProps } from 'react-router-dom';
import { EuiEmptyPrompt } from '@elastic/eui';
import { EuiEmptyPrompt, EuiButton, EuiCallOut, EuiSpacer } from '@elastic/eui';
import { SlmPolicy } from '../../../../../common/types';
import { APP_SLM_CLUSTER_PRIVILEGES } from '../../../../../common/constants';
import { SectionError, SectionLoading } from '../../../components';
import { BASE_PATH, UIM_POLICY_LIST_LOAD } from '../../../constants';
import { useAppDependencies } from '../../../index';
import { useLoadPolicies } from '../../../services/http';
import { uiMetricService } from '../../../services/ui_metric';
import { linkToAddPolicy, linkToPolicy } from '../../../services/navigation';
import { WithPrivileges, NotAuthorizedSection } from '../../../lib/authorization';
import { PolicyDetails } from './policy_details';
import { PolicyTable } from './policy_table';
@ -44,9 +47,7 @@ export const PolicyList: React.FunctionComponent<RouteComponentProps<MatchParams
} = useLoadPolicies();
const openPolicyDetailsUrl = (newPolicyName: SlmPolicy['name']): string => {
return history.createHref({
pathname: `${BASE_PATH}/policies/${newPolicyName}`,
});
return linkToPolicy(newPolicyName);
};
const closePolicyDetails = () => {
@ -72,7 +73,7 @@ export const PolicyList: React.FunctionComponent<RouteComponentProps<MatchParams
trackUiMetric(UIM_POLICY_LIST_LOAD);
}, []);
let content;
let content: JSX.Element;
if (isLoading) {
content = (
@ -112,37 +113,100 @@ export const PolicyList: React.FunctionComponent<RouteComponentProps<MatchParams
<p>
<FormattedMessage
id="xpack.snapshotRestore.policyList.emptyPromptDescription"
defaultMessage="Use policies to schedule automatic backups of your cluster."
defaultMessage="Create a policy to automatically back up your cluster."
/>
</p>
</Fragment>
}
actions={
<EuiButton
href={linkToAddPolicy()}
fill
iconType="plusInCircle"
data-test-subj="createPolicyButton"
>
<FormattedMessage
id="xpack.snapshotRestore.createPolicyButton"
defaultMessage="Create a policy"
/>
</EuiButton>
}
data-test-subj="emptyPrompt"
/>
);
} else {
const policySchedules = policies.map((policy: SlmPolicy) => policy.schedule);
const hasDuplicateSchedules = policySchedules.length > new Set(policySchedules).size;
content = (
<PolicyTable
policies={policies || []}
reload={reload}
openPolicyDetailsUrl={openPolicyDetailsUrl}
onPolicyDeleted={onPolicyDeleted}
onPolicyExecuted={onPolicyExecuted}
/>
<Fragment>
{hasDuplicateSchedules ? (
<Fragment>
<EuiCallOut
title={
<FormattedMessage
id="xpack.snapshotRestore.policyScheduleWarningTitle"
defaultMessage="Two or more policies have the same schedule"
/>
}
color="warning"
iconType="alert"
>
<FormattedMessage
id="xpack.snapshotRestore.policyScheduleWarningDescription"
defaultMessage="Only one snapshot can be taken at a time. To avoid snapshot failures, edit or delete the policies."
/>
</EuiCallOut>
<EuiSpacer />
</Fragment>
) : null}
<PolicyTable
policies={policies || []}
reload={reload}
openPolicyDetailsUrl={openPolicyDetailsUrl}
onPolicyDeleted={onPolicyDeleted}
onPolicyExecuted={onPolicyExecuted}
/>
</Fragment>
);
}
return (
<section data-test-subj="policyList">
{policyName ? (
<PolicyDetails
policyName={policyName}
onClose={closePolicyDetails}
onPolicyDeleted={onPolicyDeleted}
onPolicyExecuted={onPolicyExecuted}
/>
) : null}
{content}
</section>
<WithPrivileges privileges={APP_SLM_CLUSTER_PRIVILEGES.map(name => `cluster.${name}`)}>
{({ hasPrivileges, privilegesMissing }) =>
hasPrivileges ? (
<section data-test-subj="policyList">
{policyName ? (
<PolicyDetails
policyName={policyName}
onClose={closePolicyDetails}
onPolicyDeleted={onPolicyDeleted}
onPolicyExecuted={onPolicyExecuted}
/>
) : null}
{content}
</section>
) : (
<NotAuthorizedSection
title={
<FormattedMessage
id="xpack.snapshotRestore.policyList.deniedPrivilegeTitle"
defaultMessage="You're missing cluster privileges"
/>
}
message={
<FormattedMessage
id="xpack.snapshotRestore.policyList.deniedPrivilegeDescription"
defaultMessage="To manage Snapshot Lifecycle Policies, you must have {privilegesCount,
plural, one {this cluster privilege} other {these cluster privileges}}: {missingPrivileges}."
values={{
missingPrivileges: privilegesMissing.cluster!.join(', '),
privilegesCount: privilegesMissing.cluster!.length,
}}
/>
}
/>
)
}
</WithPrivileges>
);
};

View file

@ -13,6 +13,7 @@ import {
EuiLink,
EuiToolTip,
EuiButtonIcon,
EuiLoadingSpinner,
} from '@elastic/eui';
import { SlmPolicy } from '../../../../../../common/types';
@ -24,6 +25,7 @@ import {
PolicyDeleteProvider,
} from '../../../../components';
import { uiMetricService } from '../../../../services/ui_metric';
import { linkToAddPolicy, linkToEditPolicy } from '../../../../services/navigation';
interface Props {
policies: SlmPolicy[];
@ -55,16 +57,34 @@ export const PolicyTable: React.FunctionComponent<Props> = ({
}),
truncateText: true,
sortable: true,
render: (name: SlmPolicy['name']) => {
render: (name: SlmPolicy['name'], { inProgress }: SlmPolicy) => {
return (
/* eslint-disable-next-line @elastic/eui/href-or-on-click */
<EuiLink
onClick={() => trackUiMetric(UIM_POLICY_SHOW_DETAILS_CLICK)}
href={openPolicyDetailsUrl(name)}
data-test-subj="policyLink"
>
{name}
</EuiLink>
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
{/* eslint-disable-next-line @elastic/eui/href-or-on-click */}
<EuiLink
onClick={() => trackUiMetric(UIM_POLICY_SHOW_DETAILS_CLICK)}
href={openPolicyDetailsUrl(name)}
data-test-subj="policyLink"
>
{name}
</EuiLink>
</EuiFlexItem>
{inProgress ? (
<EuiFlexItem grow={false}>
<EuiToolTip
content={i18n.translate(
'xpack.snapshotRestore.policyList.table.inProgressTooltip',
{
defaultMessage: 'Snapshot in progress',
}
)}
>
<EuiLoadingSpinner size="m" />
</EuiToolTip>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
);
},
},
@ -95,7 +115,7 @@ export const PolicyTable: React.FunctionComponent<Props> = ({
{
field: 'nextExecutionMillis',
name: i18n.translate('xpack.snapshotRestore.policyList.table.nextExecutionColumnTitle', {
defaultMessage: 'Next execution',
defaultMessage: 'Next snapshot',
}),
truncateText: true,
sortable: true,
@ -109,64 +129,96 @@ export const PolicyTable: React.FunctionComponent<Props> = ({
}),
actions: [
{
render: ({ name }: SlmPolicy) => {
render: ({ name, inProgress }: SlmPolicy) => {
return (
<PolicyExecuteProvider>
{executePolicyPrompt => {
const label = i18n.translate(
'xpack.snapshotRestore.policyList.table.actionExecuteTooltip',
{ defaultMessage: 'Run policy' }
);
return (
<EuiToolTip content={label}>
<EuiButtonIcon
aria-label={i18n.translate(
'xpack.snapshotRestore.policyList.table.actionExecuteAriaLabel',
{
defaultMessage: `Run policy '{name}'`,
values: { name },
<EuiFlexGroup gutterSize="s">
<EuiFlexItem>
<PolicyExecuteProvider>
{executePolicyPrompt => {
return (
<EuiToolTip
content={
Boolean(inProgress)
? i18n.translate(
'xpack.snapshotRestore.policyList.table.actionExecuteDisabledTooltip',
{ defaultMessage: 'Policy is running' }
)
: i18n.translate(
'xpack.snapshotRestore.policyList.table.actionExecuteTooltip',
{ defaultMessage: 'Run now' }
)
}
)}
iconType="play"
color="primary"
data-test-subj="executePolicyButton"
onClick={() => executePolicyPrompt(name, onPolicyExecuted)}
/>
</EuiToolTip>
);
}}
</PolicyExecuteProvider>
);
},
},
{
render: ({ name }: SlmPolicy) => {
return (
<PolicyDeleteProvider>
{deletePolicyPrompt => {
const label = i18n.translate(
'xpack.snapshotRestore.policyList.table.actionDeleteTooltip',
{ defaultMessage: 'Delete' }
);
return (
<EuiToolTip content={label}>
<EuiButtonIcon
aria-label={i18n.translate(
'xpack.snapshotRestore.policyList.table.actionDeleteAriaLabel',
{
defaultMessage: `Delete policy '{name}'`,
values: { name },
}
)}
iconType="trash"
color="danger"
data-test-subj="deletePolicyButton"
onClick={() => deletePolicyPrompt([name], onPolicyDeleted)}
/>
</EuiToolTip>
);
}}
</PolicyDeleteProvider>
>
<EuiButtonIcon
aria-label={i18n.translate(
'xpack.snapshotRestore.policyList.table.actionExecuteAriaLabel',
{
defaultMessage: `Run '{name}' immediately`,
values: { name },
}
)}
iconType="play"
color="primary"
data-test-subj="executePolicyButton"
onClick={() => executePolicyPrompt(name, onPolicyExecuted)}
disabled={Boolean(inProgress)}
/>
</EuiToolTip>
);
}}
</PolicyExecuteProvider>
</EuiFlexItem>
<EuiFlexItem>
<EuiToolTip
content={i18n.translate(
'xpack.snapshotRestore.policyList.table.actionEditTooltip',
{ defaultMessage: 'Edit' }
)}
>
<EuiButtonIcon
aria-label={i18n.translate(
'xpack.snapshotRestore.policyList.table.actionEditAriaLabel',
{
defaultMessage: 'Edit poicy `{name}`',
values: { name },
}
)}
iconType="pencil"
color="primary"
href={linkToEditPolicy(name)}
data-test-subj="editPolicyButton"
/>
</EuiToolTip>
</EuiFlexItem>
<EuiFlexItem>
<PolicyDeleteProvider>
{deletePolicyPrompt => {
return (
<EuiToolTip
content={i18n.translate(
'xpack.snapshotRestore.policyList.table.actionDeleteTooltip',
{ defaultMessage: 'Delete' }
)}
>
<EuiButtonIcon
aria-label={i18n.translate(
'xpack.snapshotRestore.policyList.table.actionDeleteAriaLabel',
{
defaultMessage: `Delete policy '{name}'`,
values: { name },
}
)}
iconType="trash"
color="danger"
data-test-subj="deletePolicyButton"
onClick={() => deletePolicyPrompt([name], onPolicyDeleted)}
/>
</EuiToolTip>
);
}}
</PolicyDeleteProvider>
</EuiFlexItem>
</EuiFlexGroup>
);
},
},
@ -237,6 +289,19 @@ export const PolicyTable: React.FunctionComponent<Props> = ({
/>
</EuiButton>
</EuiFlexItem>
<EuiFlexItem>
<EuiButton
href={linkToAddPolicy()}
fill
iconType="plusInCircle"
data-test-subj="createPolicyButton"
>
<FormattedMessage
id="xpack.snapshotRestore.policyList.table.addPolicyButton"
defaultMessage="Create a policy"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
),
box: {
@ -268,6 +333,7 @@ export const PolicyTable: React.FunctionComponent<Props> = ({
return (
<EuiInMemoryTable
className="snapshotRestore__policyTable"
items={policies}
itemId="name"
columns={columns}

View file

@ -14,6 +14,7 @@ import { BASE_PATH, UIM_REPOSITORY_LIST_LOAD } from '../../../constants';
import { useAppDependencies } from '../../../index';
import { useLoadRepositories } from '../../../services/http';
import { uiMetricService } from '../../../services/ui_metric';
import { linkToAddRepository, linkToRepository } from '../../../services/navigation';
import { RepositoryDetails } from './repository_details';
import { RepositoryTable } from './repository_table';
@ -45,9 +46,7 @@ export const RepositoryList: React.FunctionComponent<RouteComponentProps<MatchPa
} = useLoadRepositories();
const openRepositoryDetailsUrl = (newRepositoryName: Repository['name']): string => {
return history.createHref({
pathname: `${BASE_PATH}/repositories/${newRepositoryName}`,
});
return linkToRepository(newRepositoryName);
};
const closeRepositoryDetails = () => {
@ -116,9 +115,7 @@ export const RepositoryList: React.FunctionComponent<RouteComponentProps<MatchPa
}
actions={
<EuiButton
href={history.createHref({
pathname: `${BASE_PATH}/add_repository`,
})}
href={linkToAddRepository()}
fill
iconType="plusInCircle"
data-test-subj="registerRepositoryButton"

View file

@ -17,6 +17,7 @@ import {
EuiLoadingSpinner,
EuiLink,
} from '@elastic/eui';
import { APP_RESTORE_INDEX_PRIVILEGES } from '../../../../../common/constants';
import { SectionError, SectionLoading } from '../../../components';
import { UIM_RESTORE_LIST_LOAD } from '../../../constants';
import { useAppDependencies } from '../../../index';
@ -207,7 +208,7 @@ export const RestoreList: React.FunctionComponent = () => {
}
return (
<WithPrivileges privileges="index.*">
<WithPrivileges privileges={APP_RESTORE_INDEX_PRIVILEGES.map(name => `index.${name}`)}>
{({ hasPrivileges, privilegesMissing }) =>
hasPrivileges ? (
<section data-test-subj="restoreList">{content}</section>

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import {
EuiDescriptionList,
@ -112,6 +112,13 @@ export const TabSummary: React.SFC<Props> = ({ snapshotDetails }) => {
</ul>
) : null;
// Reset indices list state when clicking through different snapshots
useEffect(() => {
return () => {
setIsShowingFullIndicesList(false);
};
}, []);
return (
<EuiDescriptionList textStyle="reverse">
<EuiFlexGroup>

View file

@ -7,15 +7,22 @@
import React, { Fragment, useState, useEffect } from 'react';
import { RouteComponentProps } from 'react-router-dom';
import { parse } from 'querystring';
import { EuiButton, EuiCallOut, EuiLink, EuiEmptyPrompt, EuiSpacer, EuiIcon } from '@elastic/eui';
import { EuiButton, EuiCallOut, EuiIcon, EuiLink, EuiEmptyPrompt, EuiSpacer } from '@elastic/eui';
import { APP_SLM_CLUSTER_PRIVILEGES } from '../../../../../common/constants';
import { SectionError, SectionLoading } from '../../../components';
import { BASE_PATH, UIM_SNAPSHOT_LIST_LOAD } from '../../../constants';
import { WithPrivileges } from '../../../lib/authorization';
import { useAppDependencies } from '../../../index';
import { documentationLinksService } from '../../../services/documentation';
import { useLoadSnapshots } from '../../../services/http';
import { linkToRepositories } from '../../../services/navigation';
import {
linkToRepositories,
linkToAddRepository,
linkToPolicies,
linkToAddPolicy,
linkToSnapshot,
} from '../../../services/navigation';
import { uiMetricService } from '../../../services/ui_metric';
import { SnapshotDetails } from './snapshot_details';
@ -42,7 +49,7 @@ export const SnapshotList: React.FunctionComponent<RouteComponentProps<MatchPara
const {
error,
isLoading,
data: { snapshots = [], repositories = [], errors = {} },
data: { snapshots = [], repositories = [], policies = [], errors = {} },
sendRequest: reload,
} = useLoadSnapshots();
@ -50,11 +57,7 @@ export const SnapshotList: React.FunctionComponent<RouteComponentProps<MatchPara
repositoryNameToOpen: string,
snapshotIdToOpen: string
): string => {
return history.createHref({
pathname: `${BASE_PATH}/snapshots/${encodeURIComponent(
repositoryNameToOpen
)}/${encodeURIComponent(snapshotIdToOpen)}`,
});
return linkToSnapshot(repositoryNameToOpen, snapshotIdToOpen);
};
const closeSnapshotDetails = () => {
@ -138,37 +141,22 @@ export const SnapshotList: React.FunctionComponent<RouteComponentProps<MatchPara
</h1>
}
body={
<Fragment>
<p>
<FormattedMessage
id="xpack.snapshotRestore.snapshotList.emptyPrompt.repositoryWarningDescription"
defaultMessage="Go to {repositoryLink} to fix the errors."
values={{
repositoryLink: (
<EuiLink href={linkToRepositories()}>
<FormattedMessage
id="xpack.snapshotRestore.repositoryWarningLinkText"
defaultMessage="Repositories"
/>
</EuiLink>
),
}}
/>
</p>
<p>
<EuiLink
href={documentationLinksService.getSnapshotDocUrl()}
target="_blank"
data-test-subj="srSnapshotsEmptyPromptDocLink"
>
<FormattedMessage
id="xpack.snapshotRestore.emptyPrompt.noSnapshotsDocLinkText"
defaultMessage="Learn how to create a snapshot"
/>{' '}
<EuiIcon type="link" />
</EuiLink>
</p>
</Fragment>
<p>
<FormattedMessage
id="xpack.snapshotRestore.snapshotList.emptyPrompt.repositoryWarningDescription"
defaultMessage="Go to {repositoryLink} to fix the errors."
values={{
repositoryLink: (
<EuiLink href={linkToRepositories()}>
<FormattedMessage
id="xpack.snapshotRestore.repositoryWarningLinkText"
defaultMessage="Repositories"
/>
</EuiLink>
),
}}
/>
</p>
}
/>
);
@ -194,9 +182,7 @@ export const SnapshotList: React.FunctionComponent<RouteComponentProps<MatchPara
</p>
<p>
<EuiButton
href={history.createHref({
pathname: `${BASE_PATH}/add_repository`,
})}
href={linkToAddRepository()}
fill
iconType="plusInCircle"
data-test-subj="registerRepositoryButton"
@ -225,27 +211,84 @@ export const SnapshotList: React.FunctionComponent<RouteComponentProps<MatchPara
</h1>
}
body={
<Fragment>
<p>
<FormattedMessage
id="xpack.snapshotRestore.snapshotList.emptyPrompt.noSnapshotsDescription"
defaultMessage="Create a snapshot using the Elasticsearch API."
/>
</p>
<p>
<EuiLink
href={documentationLinksService.getSnapshotDocUrl()}
target="_blank"
data-test-subj="documentationLink"
>
<FormattedMessage
id="xpack.snapshotRestore.emptyPrompt.noSnapshotsDocLinkText"
defaultMessage="Learn how to create a snapshot"
/>{' '}
<EuiIcon type="link" />
</EuiLink>
</p>
</Fragment>
<WithPrivileges privileges={APP_SLM_CLUSTER_PRIVILEGES.map(name => `cluster.${name}`)}>
{({ hasPrivileges }) =>
hasPrivileges ? (
<Fragment>
<p>
<FormattedMessage
id="xpack.snapshotRestore.snapshotList.emptyPrompt.usePolicyDescription"
defaultMessage="Run a Snapshot Lifecycle Policy to create a snapshot.
Snapshots can also be created by using {docLink}."
values={{
docLink: (
<EuiLink
href={documentationLinksService.getSnapshotDocUrl()}
target="_blank"
data-test-subj="documentationLink"
>
<FormattedMessage
id="xpack.snapshotRestore.emptyPrompt.usePolicyDocLinkText"
defaultMessage="the Elasticsearch API"
/>
</EuiLink>
),
}}
/>
</p>
<p>
{policies.length === 0 ? (
<EuiButton
href={linkToAddPolicy()}
fill
iconType="plusInCircle"
data-test-subj="addPolicyButton"
>
<FormattedMessage
id="xpack.snapshotRestore.snapshotList.emptyPrompt.addPolicyText"
defaultMessage="Create a policy"
/>
</EuiButton>
) : (
<EuiButton
href={linkToPolicies()}
fill
iconType="list"
data-test-subj="goToPoliciesButton"
>
<FormattedMessage
id="xpack.snapshotRestore.snapshotList.emptyPrompt.goToPoliciesText"
defaultMessage="View policies"
/>
</EuiButton>
)}
</p>
</Fragment>
) : (
<Fragment>
<p>
<FormattedMessage
id="xpack.snapshotRestore.snapshotList.emptyPrompt.noSnapshotsDescription"
defaultMessage="Create a snapshot using the Elasticsearch API."
/>
</p>
<p>
<EuiLink
href={documentationLinksService.getSnapshotDocUrl()}
target="_blank"
data-test-subj="documentationLink"
>
<FormattedMessage
id="xpack.snapshotRestore.emptyPrompt.noSnapshotsDocLinkText"
defaultMessage="Learn how to create a snapshot"
/>{' '}
<EuiIcon type="link" />
</EuiLink>
</p>
</Fragment>
)
}
</WithPrivileges>
}
data-test-subj="emptyPrompt"
/>

View file

@ -8,3 +8,5 @@ export { SnapshotRestoreHome } from './home';
export { RepositoryAdd } from './repository_add';
export { RepositoryEdit } from './repository_edit';
export { RestoreSnapshot } from './restore_snapshot';
export { PolicyAdd } from './policy_add';
export { PolicyEdit } from './policy_edit';

View file

@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { CronEditor } from './cron_editor';
export { PolicyAdd } from './policy_add';

View file

@ -0,0 +1,132 @@
/*
* 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 { SlmPolicyPayload } from '../../../../common/types';
import { PolicyForm, SectionError, SectionLoading } from '../../components';
import { useAppDependencies } from '../../index';
import { BASE_PATH, DEFAULT_POLICY_SCHEDULE } from '../../constants';
import { breadcrumbService, docTitleService } from '../../services/navigation';
import { addPolicy, useLoadIndicies } from '../../services/http';
export const PolicyAdd: React.FunctionComponent<RouteComponentProps> = ({
history,
location: { pathname },
}) => {
const {
core: {
i18n: { FormattedMessage },
},
} = useAppDependencies();
const [isSaving, setIsSaving] = useState<boolean>(false);
const [saveError, setSaveError] = useState<any>(null);
const {
error: errorLoadingIndices,
isLoading: isLoadingIndices,
data: { indices } = {
indices: [],
},
} = useLoadIndicies();
// Set breadcrumb and page title
useEffect(() => {
breadcrumbService.setBreadcrumbs('policyAdd');
docTitleService.setTitle('policyAdd');
}, []);
const onSave = async (newPolicy: SlmPolicyPayload) => {
setIsSaving(true);
setSaveError(null);
const { name } = newPolicy;
const { error } = await addPolicy(newPolicy);
setIsSaving(false);
if (error) {
setSaveError(error);
} else {
history.push(`${BASE_PATH}/policies/${name}`);
}
};
const onCancel = () => {
history.push(`${BASE_PATH}/policies`);
};
const emptyPolicy: SlmPolicyPayload = {
name: '',
snapshotName: '',
schedule: DEFAULT_POLICY_SCHEDULE,
repository: '',
config: {},
};
const renderSaveError = () => {
return saveError ? (
<SectionError
title={
<FormattedMessage
id="xpack.snapshotRestore.addPolicy.savingPolicyErrorTitle"
defaultMessage="Cannot create new policy"
/>
}
error={saveError}
data-test-subj="savePolicyApiError"
/>
) : null;
};
const clearSaveError = () => {
setSaveError(null);
};
return (
<EuiPageBody>
<EuiPageContent>
<EuiTitle size="l">
<h1 data-test-subj="pageTitle">
<FormattedMessage
id="xpack.snapshotRestore.addPolicyTitle"
defaultMessage="Create policy"
/>
</h1>
</EuiTitle>
<EuiSpacer size="l" />
{isLoadingIndices ? (
<SectionLoading>
<FormattedMessage
id="xpack.snapshotRestore.addPolicy.loadingIndicesDescription"
defaultMessage="Loading available indices…"
/>
</SectionLoading>
) : errorLoadingIndices ? (
<SectionError
title={
<FormattedMessage
id="xpack.snapshotRestore.addPolicy.LoadingIndicesErrorMessage"
defaultMessage="Error loading available indices"
/>
}
error={errorLoadingIndices}
/>
) : (
<PolicyForm
policy={emptyPolicy}
indices={indices}
currentUrl={pathname}
isSaving={isSaving}
saveError={renderSaveError()}
clearSaveError={clearSaveError}
onSave={onSave}
onCancel={onCancel}
/>
)}
</EuiPageContent>
</EuiPageBody>
);
};

View file

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

View file

@ -0,0 +1,210 @@
/*
* 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 { RouteComponentProps } from 'react-router-dom';
import { EuiPageBody, EuiPageContent, EuiSpacer, EuiTitle } from '@elastic/eui';
import { SlmPolicyPayload } from '../../../../common/types';
import { SectionError, SectionLoading, PolicyForm } from '../../components';
import { BASE_PATH } from '../../constants';
import { useAppDependencies } from '../../index';
import { breadcrumbService, docTitleService } from '../../services/navigation';
import { editPolicy, useLoadPolicy, useLoadIndicies } from '../../services/http';
interface MatchParams {
name: string;
}
export const PolicyEdit: React.FunctionComponent<RouteComponentProps<MatchParams>> = ({
match: {
params: { name },
},
history,
location: { pathname },
}) => {
const {
core: { i18n },
} = useAppDependencies();
const { FormattedMessage } = i18n;
// Set breadcrumb and page title
useEffect(() => {
breadcrumbService.setBreadcrumbs('policyEdit');
docTitleService.setTitle('policyEdit');
}, []);
// Policy state with default empty policy
const [policy, setPolicy] = useState<SlmPolicyPayload>({
name: '',
snapshotName: '',
schedule: '',
repository: '',
config: {},
});
const {
error: errorLoadingIndices,
isLoading: isLoadingIndices,
data: { indices } = {
indices: [],
},
} = useLoadIndicies();
// Load policy
const { error: errorLoadingPolicy, isLoading: isLoadingPolicy, data: policyData } = useLoadPolicy(
name
);
// Update policy state when data is loaded
useEffect(() => {
if (policyData && policyData.policy) {
setPolicy(policyData.policy);
}
}, [policyData]);
// Saving policy states
const [isSaving, setIsSaving] = useState<boolean>(false);
const [saveError, setSaveError] = useState<any>(null);
// Save policy
const onSave = async (editedPolicy: SlmPolicyPayload) => {
setIsSaving(true);
setSaveError(null);
const { error } = await editPolicy(editedPolicy);
setIsSaving(false);
if (error) {
setSaveError(error);
} else {
history.push(`${BASE_PATH}/policies/${name}`);
}
};
const onCancel = () => {
history.push(`${BASE_PATH}/policies/${name}`);
};
const renderLoading = () => {
return errorLoadingPolicy ? (
<SectionLoading>
<FormattedMessage
id="xpack.snapshotRestore.editPolicy.loadingPolicyDescription"
defaultMessage="Loading policy details…"
/>
</SectionLoading>
) : (
<SectionLoading>
<FormattedMessage
id="xpack.snapshotRestore.editPolicy.loadingIndicesDescription"
defaultMessage="Loading available indices…"
/>
</SectionLoading>
);
};
const renderError = () => {
if (errorLoadingPolicy) {
const notFound = errorLoadingPolicy.status === 404;
const errorObject = notFound
? {
data: {
error: i18n.translate('xpack.snapshotRestore.editPolicy.policyNotFoundErrorMessage', {
defaultMessage: `The policy '{name}' does not exist.`,
values: {
name,
},
}),
},
}
: errorLoadingPolicy;
return (
<SectionError
title={
<FormattedMessage
id="xpack.snapshotRestore.editPolicy.loadingPolicyErrorTitle"
defaultMessage="Error loading policy details"
/>
}
error={errorObject}
/>
);
}
if (errorLoadingIndices) {
return (
<SectionError
title={
<FormattedMessage
id="xpack.snapshotRestore.editPolicy.LoadingIndicesErrorMessage"
defaultMessage="Error loading available indices"
/>
}
error={errorLoadingIndices}
/>
);
}
};
const renderSaveError = () => {
return saveError ? (
<SectionError
title={
<FormattedMessage
id="xpack.snapshotRestore.editPolicy.savingPolicyErrorTitle"
defaultMessage="Cannot save policy"
/>
}
error={saveError}
/>
) : null;
};
const clearSaveError = () => {
setSaveError(null);
};
const renderContent = () => {
if (isLoadingPolicy || isLoadingIndices) {
return renderLoading();
}
if (errorLoadingPolicy || errorLoadingIndices) {
return renderError();
}
return (
<Fragment>
<PolicyForm
policy={policy}
indices={indices}
currentUrl={pathname}
isEditing={true}
isSaving={isSaving}
saveError={renderSaveError()}
clearSaveError={clearSaveError}
onSave={onSave}
onCancel={onCancel}
/>
</Fragment>
);
};
return (
<EuiPageBody>
<EuiPageContent>
<EuiTitle size="l">
<h1>
<FormattedMessage
id="xpack.snapshotRestore.editPolicyTitle"
defaultMessage="Edit policy"
/>
</h1>
</EuiTitle>
<EuiSpacer size="l" />
{renderContent()}
</EuiPageContent>
</EuiPageBody>
);
};

View file

@ -5,6 +5,7 @@
*/
import React, { useEffect, useState } from 'react';
import { RouteComponentProps } from 'react-router-dom';
import { parse } from 'querystring';
import { EuiPageBody, EuiPageContent, EuiSpacer, EuiTitle } from '@elastic/eui';
import { Repository, EmptyRepository } from '../../../../common/types';
@ -12,10 +13,13 @@ import { Repository, EmptyRepository } from '../../../../common/types';
import { RepositoryForm, SectionError } from '../../components';
import { BASE_PATH, Section } from '../../constants';
import { useAppDependencies } from '../../index';
import { breadcrumbService } from '../../services/navigation';
import { breadcrumbService, docTitleService } from '../../services/navigation';
import { addRepository } from '../../services/http';
export const RepositoryAdd: React.FunctionComponent<RouteComponentProps> = ({ history }) => {
export const RepositoryAdd: React.FunctionComponent<RouteComponentProps> = ({
history,
location: { search },
}) => {
const {
core: {
i18n: { FormattedMessage },
@ -25,9 +29,10 @@ export const RepositoryAdd: React.FunctionComponent<RouteComponentProps> = ({ hi
const [isSaving, setIsSaving] = useState<boolean>(false);
const [saveError, setSaveError] = useState<any>(null);
// Set breadcrumb
// Set breadcrumb and page title
useEffect(() => {
breadcrumbService.setBreadcrumbs('repositoryAdd');
docTitleService.setTitle('repositoryAdd');
}, []);
const onSave = async (newRepository: Repository | EmptyRepository) => {
@ -39,7 +44,8 @@ export const RepositoryAdd: React.FunctionComponent<RouteComponentProps> = ({ hi
if (error) {
setSaveError(error);
} else {
history.push(`${BASE_PATH}/${section}/${name}`);
const { redirect } = parse(search.replace(/^\?/, ''));
history.push(redirect ? (redirect as string) : `${BASE_PATH}/${section}/${name}`);
}
};

View file

@ -12,7 +12,7 @@ import { Repository, EmptyRepository } from '../../../../common/types';
import { RepositoryForm, SectionError, SectionLoading } from '../../components';
import { BASE_PATH, Section } from '../../constants';
import { useAppDependencies } from '../../index';
import { breadcrumbService } from '../../services/navigation';
import { breadcrumbService, docTitleService } from '../../services/navigation';
import { editRepository, useLoadRepository } from '../../services/http';
interface MatchParams {
@ -31,9 +31,10 @@ export const RepositoryEdit: React.FunctionComponent<RouteComponentProps<MatchPa
const { FormattedMessage } = i18n;
const section = 'repositories' as Section;
// Set breadcrumb
// Set breadcrumb and page title
useEffect(() => {
breadcrumbService.setBreadcrumbs('repositoryEdit');
docTitleService.setTitle('repositoryEdit');
}, []);
// Repository state with default empty repository

View file

@ -11,7 +11,7 @@ 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 { breadcrumbService, docTitleService } from '../../services/navigation';
import { useLoadSnapshot, executeRestore } from '../../services/http';
interface MatchParams {
@ -30,9 +30,10 @@ export const RestoreSnapshot: React.FunctionComponent<RouteComponentProps<MatchP
} = useAppDependencies();
const { FormattedMessage } = i18n;
// Set breadcrumb
// Set breadcrumb and page title
useEffect(() => {
breadcrumbService.setBreadcrumbs('restoreSnapshot');
docTitleService.setTitle('restoreSnapshot');
}, []);
// Snapshot details state with default empty snapshot

View file

@ -10,10 +10,16 @@ import { REPOSITORY_DOC_PATHS } from '../../constants';
class DocumentationLinksService {
private esDocBasePath: string = '';
private esPluginDocBasePath: string = '';
private esStackOverviewDocBasePath: string = '';
public init(esDocBasePath: string, esPluginDocBasePath: string): void {
public init(
esDocBasePath: string,
esPluginDocBasePath: string,
esStackOverviewDocBasePath: string
): void {
this.esDocBasePath = esDocBasePath;
this.esPluginDocBasePath = esPluginDocBasePath;
this.esStackOverviewDocBasePath = esStackOverviewDocBasePath;
}
public getRepositoryPluginDocUrl() {
@ -42,7 +48,7 @@ class DocumentationLinksService {
}
public getSnapshotDocUrl() {
return `${this.esDocBasePath}/modules-snapshots.html#_snapshot`;
return `${this.esDocBasePath}/modules-snapshots.html#snapshots-take-snapshot`;
}
public getRestoreDocUrl() {
@ -56,6 +62,18 @@ class DocumentationLinksService {
public getIndexSettingsUrl() {
return `${this.esDocBasePath}/index-modules.html`;
}
public getDateMathIndexNamesUrl() {
return `${this.esDocBasePath}/date-math-index-names.html`;
}
public getSlmUrl() {
return `${this.esDocBasePath}/slm-api-put.html`;
}
public getCronUrl() {
return `${this.esStackOverviewDocBasePath}/trigger-schedule.html#schedule-cron`;
}
}
export const documentationLinksService = new DocumentationLinksService();

View file

@ -4,8 +4,14 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { API_BASE_PATH } from '../../../../common/constants';
import { SlmPolicy } from '../../../../common/types';
import { UIM_POLICY_EXECUTE, UIM_POLICY_DELETE, UIM_POLICY_DELETE_MANY } from '../../constants';
import { SlmPolicy, SlmPolicyPayload } from '../../../../common/types';
import {
UIM_POLICY_EXECUTE,
UIM_POLICY_DELETE,
UIM_POLICY_DELETE_MANY,
UIM_POLICY_CREATE,
UIM_POLICY_UPDATE,
} from '../../constants';
import { uiMetricService } from '../ui_metric';
import { httpService } from './http';
import { useRequest, sendRequest } from './use_request';
@ -24,6 +30,13 @@ export const useLoadPolicy = (name: SlmPolicy['name']) => {
});
};
export const useLoadIndicies = () => {
return useRequest({
path: httpService.addBasePath(`${API_BASE_PATH}policies/indices`),
method: 'get',
});
};
export const executePolicy = async (name: SlmPolicy['name']) => {
const result = sendRequest({
path: httpService.addBasePath(`${API_BASE_PATH}policy/${encodeURIComponent(name)}/run`),
@ -47,3 +60,29 @@ export const deletePolicies = async (names: Array<SlmPolicy['name']>) => {
trackUiMetric(names.length > 1 ? UIM_POLICY_DELETE_MANY : UIM_POLICY_DELETE);
return result;
};
export const addPolicy = async (newPolicy: SlmPolicyPayload) => {
const result = sendRequest({
path: httpService.addBasePath(`${API_BASE_PATH}policies`),
method: 'put',
body: newPolicy,
});
const { trackUiMetric } = uiMetricService;
trackUiMetric(UIM_POLICY_CREATE);
return result;
};
export const editPolicy = async (editedPolicy: SlmPolicyPayload) => {
const result = await sendRequest({
path: httpService.addBasePath(
`${API_BASE_PATH}policies/${encodeURIComponent(editedPolicy.name)}`
),
method: 'put',
body: editedPolicy,
});
const { trackUiMetric } = uiMetricService;
trackUiMetric(UIM_POLICY_UPDATE);
return result;
};

View file

@ -4,50 +4,128 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { BASE_PATH } from '../../constants';
import { textService } from '../text';
import {
linkToHome,
linkToSnapshots,
linkToRepositories,
linkToPolicies,
linkToRestoreStatus,
} from './';
class BreadcrumbService {
private chrome: any;
private breadcrumbs: any = {
management: {},
home: {},
repositoryAdd: {},
repositoryEdit: {},
restoreSnapshot: {},
private breadcrumbs: {
[key: string]: Array<{
text: string;
href?: string;
}>;
} = {
management: [],
home: [],
snapshots: [],
repositories: [],
policies: [],
restore_status: [],
repositoryAdd: [],
repositoryEdit: [],
restoreSnapshot: [],
policyAdd: [],
policyEdit: [],
};
public init(chrome: any, managementBreadcrumb: any): void {
this.chrome = chrome;
this.breadcrumbs.management = managementBreadcrumb;
this.breadcrumbs.home = {
text: textService.breadcrumbs.home,
href: `#${BASE_PATH}`,
};
this.breadcrumbs.repositoryAdd = {
text: textService.breadcrumbs.repositoryAdd,
};
this.breadcrumbs.repositoryEdit = {
text: textService.breadcrumbs.repositoryEdit,
};
this.breadcrumbs.restoreSnapshot = {
text: textService.breadcrumbs.restoreSnapshot,
};
this.breadcrumbs.management = [managementBreadcrumb];
// Home and sections
this.breadcrumbs.home = [
...this.breadcrumbs.management,
{
text: textService.breadcrumbs.home,
href: linkToHome(),
},
];
this.breadcrumbs.snapshots = [
...this.breadcrumbs.home,
{
text: textService.breadcrumbs.snapshots,
href: linkToSnapshots(),
},
];
this.breadcrumbs.repositories = [
...this.breadcrumbs.home,
{
text: textService.breadcrumbs.repositories,
href: linkToRepositories(),
},
];
this.breadcrumbs.policies = [
...this.breadcrumbs.home,
{
text: textService.breadcrumbs.policies,
href: linkToPolicies(),
},
];
this.breadcrumbs.restore_status = [
...this.breadcrumbs.home,
{
text: textService.breadcrumbs.restore_status,
href: linkToRestoreStatus(),
},
];
// Inner pages
this.breadcrumbs.repositoryAdd = [
...this.breadcrumbs.repositories,
{
text: textService.breadcrumbs.repositoryAdd,
},
];
this.breadcrumbs.repositoryEdit = [
...this.breadcrumbs.repositories,
{
text: textService.breadcrumbs.repositoryEdit,
},
];
this.breadcrumbs.restoreSnapshot = [
...this.breadcrumbs.snapshots,
{
text: textService.breadcrumbs.restoreSnapshot,
},
];
this.breadcrumbs.policyAdd = [
...this.breadcrumbs.policies,
{
text: textService.breadcrumbs.policyAdd,
},
];
this.breadcrumbs.policyEdit = [
...this.breadcrumbs.policies,
{
text: textService.breadcrumbs.policyEdit,
},
];
}
public setBreadcrumbs(type: string): void {
if (!this.breadcrumbs[type]) {
return;
}
if (type === 'home') {
this.chrome.breadcrumbs.set([this.breadcrumbs.management, this.breadcrumbs.home]);
} else {
this.chrome.breadcrumbs.set([
this.breadcrumbs.management,
this.breadcrumbs.home,
this.breadcrumbs[type],
]);
}
const newBreadcrumbs = this.breadcrumbs[type]
? [...this.breadcrumbs[type]]
: [...this.breadcrumbs.home];
// Pop off last breadcrumb
const lastBreadcrumb = newBreadcrumbs.pop() as {
text: string;
href?: string;
};
// Put last breadcrumb back without href
newBreadcrumbs.push({
...lastBreadcrumb,
href: undefined,
});
this.chrome.breadcrumbs.set(newBreadcrumbs);
}
}

View file

@ -0,0 +1,24 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { textService } from '../text';
class DocTitleService {
private changeDocTitle: any = () => {};
public init(changeDocTitle: any): void {
this.changeDocTitle = changeDocTitle;
}
public setTitle(page?: string): void {
if (!page || page === 'home') {
this.changeDocTitle(`${textService.breadcrumbs.home}`);
} else if (textService.breadcrumbs[page]) {
this.changeDocTitle(`${textService.breadcrumbs[page]} - ${textService.breadcrumbs.home}`);
}
}
}
export const docTitleService = new DocTitleService();

View file

@ -5,4 +5,5 @@
*/
export { breadcrumbService } from './breadcrumb';
export { docTitleService } from './doc_title';
export * from './links';

View file

@ -6,6 +6,10 @@
import { BASE_PATH } from '../../constants';
export function linkToHome() {
return `#${BASE_PATH}`;
}
export function linkToRepositories() {
return `#${BASE_PATH}/repositories`;
}
@ -18,8 +22,10 @@ export function linkToEditRepository(repositoryName: string) {
return `#${BASE_PATH}/edit_repository/${encodeURIComponent(repositoryName)}`;
}
export function linkToAddRepository() {
return `#${BASE_PATH}/add_repository`;
export function linkToAddRepository(redirect?: string) {
return `#${BASE_PATH}/add_repository${
redirect ? `?redirect=${encodeURIComponent(redirect)}` : ''
}`;
}
export function linkToSnapshots(repositoryName?: string, policyName?: string) {
@ -44,6 +50,22 @@ export function linkToRestoreSnapshot(repositoryName: string, snapshotName: stri
)}`;
}
export function linkToPolicies() {
return `#${BASE_PATH}/policies`;
}
export function linkToPolicy(policyName: string) {
return `#${BASE_PATH}/policies/${encodeURIComponent(policyName)}`;
}
export function linkToEditPolicy(policyName: string) {
return `#${BASE_PATH}/edit_policy/${encodeURIComponent(policyName)}`;
}
export function linkToAddPolicy() {
return `#${BASE_PATH}/add_policy`;
}
export function linkToRestoreStatus() {
return `#${BASE_PATH}/restore_status`;
}

View file

@ -51,6 +51,18 @@ class TextService {
home: i18n.translate('xpack.snapshotRestore.home.breadcrumbTitle', {
defaultMessage: 'Snapshot and Restore',
}),
snapshots: i18n.translate('xpack.snapshotRestore.snapshots.breadcrumbTitle', {
defaultMessage: 'Snapshots',
}),
repositories: i18n.translate('xpack.snapshotRestore.repositories.breadcrumbTitle', {
defaultMessage: 'Repositories',
}),
policies: i18n.translate('xpack.snapshotRestore.policies.breadcrumbTitle', {
defaultMessage: 'Policies',
}),
restore_status: i18n.translate('xpack.snapshotRestore.restoreStatus.breadcrumbTitle', {
defaultMessage: 'Restore Status',
}),
repositoryAdd: i18n.translate('xpack.snapshotRestore.addRepository.breadcrumbTitle', {
defaultMessage: 'Add repository',
}),
@ -60,6 +72,12 @@ class TextService {
restoreSnapshot: i18n.translate('xpack.snapshotRestore.restoreSnapshot.breadcrumbTitle', {
defaultMessage: 'Restore snapshot',
}),
policyAdd: i18n.translate('xpack.snapshotRestore.addPolicy.breadcrumbTitle', {
defaultMessage: 'Add policy',
}),
policyEdit: i18n.translate('xpack.snapshotRestore.editPolicy.breadcrumbTitle', {
defaultMessage: 'Edit policy',
}),
};
}

View file

@ -11,3 +11,5 @@ export {
} from './validate_repository';
export { RestoreValidation, validateRestore } from './validate_restore';
export { PolicyValidation, validatePolicy } from './validate_policy';

View file

@ -0,0 +1,96 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { SlmPolicyPayload } from '../../../../common/types';
import { textService } from '../text';
export interface PolicyValidation {
isValid: boolean;
errors: { [key: string]: React.ReactNode[] };
}
const isStringEmpty = (str: string | null): boolean => {
return str ? !Boolean(str.trim()) : true;
};
export const validatePolicy = (policy: SlmPolicyPayload): PolicyValidation => {
const i18n = textService.i18n;
const { name, snapshotName, schedule, repository, config } = policy;
const validation: PolicyValidation = {
isValid: true,
errors: {
name: [],
snapshotName: [],
schedule: [],
repository: [],
indices: [],
},
};
if (isStringEmpty(name)) {
validation.errors.name.push(
i18n.translate('xpack.snapshotRestore.policyValidation.nameRequiredError', {
defaultMessage: 'Policy name is required.',
})
);
}
if (isStringEmpty(snapshotName)) {
validation.errors.snapshotName.push(
i18n.translate('xpack.snapshotRestore.policyValidation.snapshotNameRequiredError', {
defaultMessage: 'Snapshot name is required.',
})
);
}
if (isStringEmpty(schedule)) {
validation.errors.schedule.push(
i18n.translate('xpack.snapshotRestore.policyValidation.scheduleRequiredError', {
defaultMessage: 'Schedule is required.',
})
);
}
if (isStringEmpty(repository)) {
validation.errors.repository.push(
i18n.translate('xpack.snapshotRestore.policyValidation.repositoryRequiredError', {
defaultMessage: 'Repository is required.',
})
);
}
if (config && typeof config.indices === 'string' && config.indices.trim().length === 0) {
validation.errors.indices.push(
i18n.translate('xpack.snapshotRestore.policyValidation.indexPatternRequiredError', {
defaultMessage: 'At least one index pattern is required.',
})
);
}
if (config && Array.isArray(config.indices) && config.indices.length === 0) {
validation.errors.indices.push(
i18n.translate('xpack.snapshotRestore.policyValidation.indicesRequiredError', {
defaultMessage: 'You must select at least one index.',
})
);
}
// Remove fields with no errors
validation.errors = Object.entries(validation.errors)
.filter(([key, value]) => value.length > 0)
.reduce((errs: PolicyValidation['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

@ -11,7 +11,7 @@ import { AppCore, AppPlugins } from './app/types';
import template from './index.html';
import { Core, Plugins } from './shim';
import { breadcrumbService } from './app/services/navigation';
import { breadcrumbService, docTitleService } from './app/services/navigation';
import { documentationLinksService } from './app/services/documentation';
import { httpService } from './app/services/http';
import { textService } from './app/services/text';
@ -21,7 +21,7 @@ const REACT_ROOT_ID = 'snapshotRestoreReactRoot';
export class Plugin {
public start(core: Core, plugins: Plugins): void {
const { i18n, routing, http, chrome, notification, documentation } = core;
const { i18n, routing, http, chrome, notification, documentation, docTitle } = core;
const { management, uiMetric } = plugins;
// Register management section
@ -38,8 +38,13 @@ export class Plugin {
// Initialize services
textService.init(i18n);
breadcrumbService.init(chrome, management.constants.BREADCRUMB);
documentationLinksService.init(documentation.esDocBasePath, documentation.esPluginDocBasePath);
uiMetricService.init(uiMetric.createUiStatsReporter);
documentationLinksService.init(
documentation.esDocBasePath,
documentation.esPluginDocBasePath,
documentation.esStackOverviewDocBasePath
);
docTitleService.init(docTitle.change);
const unmountReactApp = (): void => {
const elem = document.getElementById(REACT_ROOT_ID);

View file

@ -11,3 +11,8 @@ export {
sendRequest,
useRequest,
} from '../../../../../src/plugins/es_ui_shared/public/request';
export {
CronEditor,
DAY,
} from '../../../../../src/plugins/es_ui_shared/public/components/cron_editor';

View file

@ -12,6 +12,7 @@ import { DOC_LINK_VERSION, ELASTIC_WEBSITE_URL } from 'ui/documentation_links';
import { management, MANAGEMENT_BREADCRUMB } from 'ui/management';
import { fatalError, toastNotifications } from 'ui/notify';
import routes from 'ui/routes';
import { docTitle } from 'ui/doc_title/doc_title';
import { HashRouter } from 'react-router-dom';
@ -52,6 +53,10 @@ export interface Core extends AppCore {
documentation: {
esDocBasePath: string;
esPluginDocBasePath: string;
esStackOverviewDocBasePath: string;
};
docTitle: {
change: typeof docTitle.change;
};
}
@ -108,6 +113,10 @@ export function createShim(): { core: Core; plugins: Plugins } {
documentation: {
esDocBasePath: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/`,
esPluginDocBasePath: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/plugins/${DOC_LINK_VERSION}/`,
esStackOverviewDocBasePath: `${ELASTIC_WEBSITE_URL}guide/en/elastic-stack-overview/${DOC_LINK_VERSION}/`,
},
docTitle: {
change: docTitle.change,
},
},
plugins: {

View file

@ -60,4 +60,18 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any)
],
method: 'PUT',
});
slm.updatePolicy = ca({
urls: [
{
fmt: '/_slm/policy/<%=name%>',
req: {
name: {
type: 'string',
},
},
},
],
method: 'PUT',
});
};

View file

@ -9,7 +9,5 @@ export {
serializeRepositorySettings,
} from './repository_serialization';
export { cleanSettings } from './clean_settings';
export { deserializeSnapshotDetails, deserializeSnapshotConfig } from './snapshot_serialization';
export { deserializeRestoreShard } from './restore_serialization';
export { getManagedRepositoryName } from './get_managed_repository_name';
export { deserializePolicy } from './policy_serialization';
export { deserializeRestoreShard } from './restore_serialization';

View file

@ -8,6 +8,7 @@ import { wrapCustomError } from '../../../../../server/lib/create_router/error_w
import {
APP_REQUIRED_CLUSTER_PRIVILEGES,
APP_RESTORE_INDEX_PRIVILEGES,
APP_SLM_CLUSTER_PRIVILEGES,
} from '../../../common/constants';
// NOTE: now we import it from our "public" folder, but when the Authorisation lib
// will move to the "es_ui_shared" plugin, it will be imported from its "static" folder
@ -65,7 +66,7 @@ export const getPrivilegesHandler: RouterRouteHandler = async (
path: '/_security/user/_has_privileges',
method: 'POST',
body: {
cluster: APP_REQUIRED_CLUSTER_PRIVILEGES,
cluster: [...APP_REQUIRED_CLUSTER_PRIVILEGES, ...APP_SLM_CLUSTER_PRIVILEGES],
},
}
);

View file

@ -4,7 +4,15 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { Request, ResponseToolkit } from 'hapi';
import { getAllHandler, getOneHandler, executeHandler, deleteHandler } from './policy';
import {
getAllHandler,
getOneHandler,
executeHandler,
deleteHandler,
createHandler,
updateHandler,
getIndicesHandler,
} from './policy';
describe('[Snapshot and Restore API Routes] Restore', () => {
const mockRequest = {} as Request;
@ -209,4 +217,110 @@ describe('[Snapshot and Restore API Routes] Restore', () => {
).resolves.toEqual(expectedResponse);
});
});
describe('createHandler()', () => {
const name = 'fooPolicy';
const mockCreateRequest = ({
payload: {
name,
},
} as unknown) as Request;
it('should return successful ES response', async () => {
const mockEsResponse = { acknowledged: true };
const callWithRequest = jest
.fn()
.mockReturnValueOnce({})
.mockReturnValueOnce(mockEsResponse);
const expectedResponse = { ...mockEsResponse };
await expect(
createHandler(mockCreateRequest, callWithRequest, mockResponseToolkit)
).resolves.toEqual(expectedResponse);
});
it('should return error if policy with the same name already exists', async () => {
const mockEsResponse = { [name]: {} };
const callWithRequest = jest.fn().mockReturnValue(mockEsResponse);
await expect(
createHandler(mockCreateRequest, callWithRequest, mockResponseToolkit)
).rejects.toThrow();
});
it('should throw if ES error', async () => {
const callWithRequest = jest
.fn()
.mockReturnValueOnce({})
.mockRejectedValueOnce(new Error());
await expect(
createHandler(mockCreateRequest, callWithRequest, mockResponseToolkit)
).rejects.toThrow();
});
});
describe('updateHandler()', () => {
const name = 'fooPolicy';
const mockCreateRequest = ({
params: {
name,
},
payload: {
name,
},
} as unknown) as Request;
it('should return successful ES response', async () => {
const mockEsResponse = { acknowledged: true };
const callWithRequest = jest
.fn()
.mockReturnValueOnce({ [name]: {} })
.mockReturnValueOnce(mockEsResponse);
const expectedResponse = { ...mockEsResponse };
await expect(
updateHandler(mockCreateRequest, callWithRequest, mockResponseToolkit)
).resolves.toEqual(expectedResponse);
});
it('should throw if ES error', async () => {
const callWithRequest = jest.fn().mockRejectedValueOnce(new Error());
await expect(
updateHandler(mockCreateRequest, callWithRequest, mockResponseToolkit)
).rejects.toThrow();
});
});
describe('getIndicesHandler()', () => {
it('should arrify and sort index names returned from ES', async () => {
const mockEsResponse = [
{
index: 'fooIndex',
},
{
index: 'barIndex',
},
];
const callWithRequest = jest.fn().mockReturnValueOnce(mockEsResponse);
const expectedResponse = {
indices: ['barIndex', 'fooIndex'],
};
await expect(
getIndicesHandler(mockRequest, callWithRequest, mockResponseToolkit)
).resolves.toEqual(expectedResponse);
});
it('should return empty array if no indices returned from ES', async () => {
const mockEsResponse: any[] = [];
const callWithRequest = jest.fn().mockReturnValueOnce(mockEsResponse);
const expectedResponse = { indices: [] };
await expect(
getIndicesHandler(mockRequest, callWithRequest, mockResponseToolkit)
).resolves.toEqual(expectedResponse);
});
it('should throw if ES error', async () => {
const callWithRequest = jest.fn().mockRejectedValueOnce(new Error());
await expect(
getIndicesHandler(mockRequest, callWithRequest, mockResponseToolkit)
).rejects.toThrow();
});
});
});

View file

@ -8,14 +8,17 @@ import {
wrapCustomError,
wrapEsError,
} from '../../../../../server/lib/create_router/error_wrappers';
import { SlmPolicyEs, SlmPolicy } from '../../../common/types';
import { deserializePolicy } from '../../lib';
import { SlmPolicyEs, SlmPolicy, SlmPolicyPayload } from '../../../common/types';
import { deserializePolicy, serializePolicy } from '../../../common/lib';
export function registerPolicyRoutes(router: Router) {
router.get('policies', getAllHandler);
router.get('policy/{name}', getOneHandler);
router.post('policy/{name}/run', executeHandler);
router.delete('policies/{names}', deleteHandler);
router.put('policies', createHandler);
router.put('policies/{name}', updateHandler);
router.get('policies/indices', getIndicesHandler);
}
export const getAllHandler: RouterRouteHandler = async (
@ -96,3 +99,65 @@ export const deleteHandler: RouterRouteHandler = async (req, callWithRequest) =>
return response;
};
export const createHandler: RouterRouteHandler = async (req, callWithRequest) => {
const policy = req.payload as SlmPolicyPayload;
const { name } = policy;
const conflictError = wrapCustomError(
new Error('There is already a policy with that name.'),
409
);
// Check that policy with the same name doesn't already exist
try {
const policyByName = await callWithRequest('slm.policy', { name });
if (policyByName[name]) {
throw conflictError;
}
} catch (e) {
// Rethrow conflict error but silently swallow all others
if (e === conflictError) {
throw e;
}
}
// Otherwise create new policy
return await callWithRequest('slm.updatePolicy', {
name,
body: serializePolicy(policy),
});
};
export const updateHandler: RouterRouteHandler = async (req, callWithRequest) => {
const { name } = req.params;
const policy = req.payload as SlmPolicyPayload;
// Check that policy with the given name exists
// If it doesn't exist, 404 will be thrown by ES and will be returned
await callWithRequest('slm.policy', { name });
// Otherwise update policy
return await callWithRequest('slm.updatePolicy', {
name,
body: serializePolicy(policy),
});
};
export const getIndicesHandler: RouterRouteHandler = async (
req,
callWithRequest
): Promise<{
indices: string[];
}> => {
// Get indices
const indices: Array<{
index: string;
}> = await callWithRequest('cat.indices', {
format: 'json',
h: 'index',
});
return {
indices: indices.map(({ index }) => index).sort(),
};
};

View file

@ -55,6 +55,10 @@ describe('[Snapshot and Restore API Routes] Snapshots', () => {
const mockRequest = {} as Request;
test('combines snapshots and their repositories returned from ES', async () => {
const mockSnapshotGetPolicyEsResponse = {
fooPolicy: {},
};
const mockSnapshotGetRepositoryEsResponse = {
fooRepository: {},
barRepository: {},
@ -78,6 +82,7 @@ describe('[Snapshot and Restore API Routes] Snapshots', () => {
const callWithRequest = jest
.fn()
.mockReturnValueOnce(mockSnapshotGetPolicyEsResponse)
.mockReturnValueOnce(mockSnapshotGetRepositoryEsResponse)
.mockReturnValueOnce(mockGetSnapshotsFooResponse)
.mockReturnValueOnce(mockGetSnapshotsBarResponse);
@ -85,6 +90,7 @@ describe('[Snapshot and Restore API Routes] Snapshots', () => {
const expectedResponse = {
errors: {},
repositories: ['fooRepository', 'barRepository'],
policies: ['fooPolicy'],
snapshots: [
{
...defaultSnapshot,
@ -106,12 +112,17 @@ describe('[Snapshot and Restore API Routes] Snapshots', () => {
});
test('returns empty arrays if no snapshots returned from ES', async () => {
const mockSnapshotGetPolicyEsResponse = {};
const mockSnapshotGetRepositoryEsResponse = {};
const callWithRequest = jest.fn().mockReturnValue(mockSnapshotGetRepositoryEsResponse);
const callWithRequest = jest
.fn()
.mockReturnValue(mockSnapshotGetPolicyEsResponse)
.mockReturnValue(mockSnapshotGetRepositoryEsResponse);
const expectedResponse = {
errors: [],
snapshots: [],
repositories: [],
policies: [],
};
const response = await getAllHandler(mockRequest, callWithRequest, mockResponseToolkit);

View file

@ -6,8 +6,9 @@
import { Router, RouterRouteHandler } from '../../../../../server/lib/create_router';
import { wrapEsError } from '../../../../../server/lib/create_router/error_wrappers';
import { SnapshotDetails, SnapshotDetailsEs } from '../../../common/types';
import { deserializeSnapshotDetails } from '../../../common/lib';
import { Plugins } from '../../../shim';
import { deserializeSnapshotDetails, getManagedRepositoryName } from '../../lib';
import { getManagedRepositoryName } from '../../lib';
let callWithInternalUser: any;
@ -24,10 +25,22 @@ export const getAllHandler: RouterRouteHandler = async (
): Promise<{
snapshots: SnapshotDetails[];
errors: any[];
policies: string[];
repositories: string[];
managedRepository?: string;
}> => {
const managedRepository = await getManagedRepositoryName(callWithInternalUser);
let policies: string[] = [];
// Attempt to retrieve policies
// This could fail if user doesn't have access to read SLM policies
try {
const policiesByName = await callWithRequest('slm.policies');
policies = Object.keys(policiesByName);
} catch (e) {
// Silently swallow error as policy names aren't required in UI
}
const repositoriesByName = await callWithRequest('snapshot.getRepository', {
repository: '_all',
});
@ -35,7 +48,7 @@ export const getAllHandler: RouterRouteHandler = async (
const repositoryNames = Object.keys(repositoriesByName);
if (repositoryNames.length === 0) {
return { snapshots: [], errors: [], repositories: [] };
return { snapshots: [], errors: [], repositories: [], policies };
}
const snapshots: SnapshotDetails[] = [];
@ -70,6 +83,7 @@ export const getAllHandler: RouterRouteHandler = async (
return {
snapshots,
policies,
repositories,
errors,
};

View file

@ -8712,26 +8712,6 @@
"xpack.rollupJobs.createAction.jobIdAlreadyExistsErrorMessage": "ID「{jobConfigId}」のジョブが既に存在します。",
"xpack.rollupJobs.createBreadcrumbTitle": "作成",
"xpack.rollupJobs.createTitle": "ロールアップジョブを作成",
"xpack.rollupJobs.cronEditor.cronDaily.fieldHour.textAtLabel": "時点で",
"xpack.rollupJobs.cronEditor.cronDaily.fieldTimeLabel": "時間",
"xpack.rollupJobs.cronEditor.cronHourly.fieldMinute.textAtLabel": "時点で",
"xpack.rollupJobs.cronEditor.cronHourly.fieldTimeLabel": "分",
"xpack.rollupJobs.cronEditor.cronMonthly.fieldDateLabel": "日付",
"xpack.rollupJobs.cronEditor.cronMonthly.fieldHour.textAtLabel": "時点で",
"xpack.rollupJobs.cronEditor.cronMonthly.fieldTimeLabel": "時間",
"xpack.rollupJobs.cronEditor.cronMonthly.textOnTheLabel": "On the",
"xpack.rollupJobs.cronEditor.cronWeekly.fieldDateLabel": "日",
"xpack.rollupJobs.cronEditor.cronWeekly.fieldHour.textAtLabel": "時点で",
"xpack.rollupJobs.cronEditor.cronWeekly.fieldTimeLabel": "時間",
"xpack.rollupJobs.cronEditor.cronWeekly.textOnLabel": "オン",
"xpack.rollupJobs.cronEditor.cronYearly.fieldDate.textOnTheLabel": "On the",
"xpack.rollupJobs.cronEditor.cronYearly.fieldDateLabel": "日付",
"xpack.rollupJobs.cronEditor.cronYearly.fieldHour.textAtLabel": "時点で",
"xpack.rollupJobs.cronEditor.cronYearly.fieldMonth.textInLabel": "In",
"xpack.rollupJobs.cronEditor.cronYearly.fieldMonthLabel": "月",
"xpack.rollupJobs.cronEditor.cronYearly.fieldTimeLabel": "時間",
"xpack.rollupJobs.cronEditor.fieldFrequencyLabel": "頻度",
"xpack.rollupJobs.cronEditor.textEveryLabel": "毎",
"xpack.rollupJobs.deleteAction.errorTitle": "ロールアップジョブの削除中にエラーが発生",
"xpack.rollupJobs.deleteAction.successMultipleNotificationTitle": "{count} 件のロールアップジョブが削除されました",
"xpack.rollupJobs.deleteAction.successSingleNotificationTitle": "ロールアップジョブ「{jobId}」が削除されました",
@ -8815,25 +8795,6 @@
"xpack.rollupJobs.rollupIndexPatternsTitle": "ロールアップインデックスパターンを有効にする",
"xpack.rollupJobs.startJobsAction.errorTitle": "ロールアップジョブの開始中にエラーが発生",
"xpack.rollupJobs.stopJobsAction.errorTitle": "ロールアップジョブの停止中にエラーが発生",
"xpack.rollupJobs.util.day.friday": "金曜日",
"xpack.rollupJobs.util.day.monday": "月曜日",
"xpack.rollupJobs.util.day.saturday": "土曜日",
"xpack.rollupJobs.util.day.sunday": "日曜日",
"xpack.rollupJobs.util.day.thursday": "木曜日",
"xpack.rollupJobs.util.day.tuesday": "火曜日",
"xpack.rollupJobs.util.day.wednesday": "水曜日",
"xpack.rollupJobs.util.month.april": "4 月",
"xpack.rollupJobs.util.month.august": "8 月",
"xpack.rollupJobs.util.month.december": "12 月",
"xpack.rollupJobs.util.month.february": "2 月",
"xpack.rollupJobs.util.month.january": "1 月",
"xpack.rollupJobs.util.month.july": "7 月",
"xpack.rollupJobs.util.month.june": "6 月",
"xpack.rollupJobs.util.month.march": "3 月",
"xpack.rollupJobs.util.month.may": "5 月",
"xpack.rollupJobs.util.month.november": "11 月",
"xpack.rollupJobs.util.month.october": "10 月",
"xpack.rollupJobs.util.month.september": "9 月",
"xpack.searchProfiler.aggregationProfileTabTitle": "集約プロフィール",
"xpack.searchProfiler.basicLicenseTitle": "ベーシック",
"xpack.searchProfiler.formIndexLabel": "インデックス",

View file

@ -8855,26 +8855,6 @@
"xpack.rollupJobs.createAction.jobIdAlreadyExistsErrorMessage": "ID 为 “{jobConfigId}” 的作业已存在。",
"xpack.rollupJobs.createBreadcrumbTitle": "创建",
"xpack.rollupJobs.createTitle": "创建汇总/打包作业",
"xpack.rollupJobs.cronEditor.cronDaily.fieldHour.textAtLabel": "在",
"xpack.rollupJobs.cronEditor.cronDaily.fieldTimeLabel": "时间",
"xpack.rollupJobs.cronEditor.cronHourly.fieldMinute.textAtLabel": "在",
"xpack.rollupJobs.cronEditor.cronHourly.fieldTimeLabel": "分钟",
"xpack.rollupJobs.cronEditor.cronMonthly.fieldDateLabel": "日期",
"xpack.rollupJobs.cronEditor.cronMonthly.fieldHour.textAtLabel": "在",
"xpack.rollupJobs.cronEditor.cronMonthly.fieldTimeLabel": "时间",
"xpack.rollupJobs.cronEditor.cronMonthly.textOnTheLabel": "处于",
"xpack.rollupJobs.cronEditor.cronWeekly.fieldDateLabel": "天",
"xpack.rollupJobs.cronEditor.cronWeekly.fieldHour.textAtLabel": "在",
"xpack.rollupJobs.cronEditor.cronWeekly.fieldTimeLabel": "时间",
"xpack.rollupJobs.cronEditor.cronWeekly.textOnLabel": "开启",
"xpack.rollupJobs.cronEditor.cronYearly.fieldDate.textOnTheLabel": "处于",
"xpack.rollupJobs.cronEditor.cronYearly.fieldDateLabel": "日期",
"xpack.rollupJobs.cronEditor.cronYearly.fieldHour.textAtLabel": "在",
"xpack.rollupJobs.cronEditor.cronYearly.fieldMonth.textInLabel": "于",
"xpack.rollupJobs.cronEditor.cronYearly.fieldMonthLabel": "月",
"xpack.rollupJobs.cronEditor.cronYearly.fieldTimeLabel": "时间",
"xpack.rollupJobs.cronEditor.fieldFrequencyLabel": "频率",
"xpack.rollupJobs.cronEditor.textEveryLabel": "所有",
"xpack.rollupJobs.deleteAction.errorTitle": "删除汇总/打包作业时出错",
"xpack.rollupJobs.deleteAction.successMultipleNotificationTitle": "已删除 {count} 个汇总/打包作业",
"xpack.rollupJobs.deleteAction.successSingleNotificationTitle": "已删除汇总/打包作业“{jobId}”",
@ -8958,25 +8938,6 @@
"xpack.rollupJobs.rollupIndexPatternsTitle": "启用汇总索引模式",
"xpack.rollupJobs.startJobsAction.errorTitle": "启动汇总/打包作业时出错",
"xpack.rollupJobs.stopJobsAction.errorTitle": "停止汇总/打包作业时出错",
"xpack.rollupJobs.util.day.friday": "星期五",
"xpack.rollupJobs.util.day.monday": "星期一",
"xpack.rollupJobs.util.day.saturday": "星期六",
"xpack.rollupJobs.util.day.sunday": "星期日",
"xpack.rollupJobs.util.day.thursday": "星期四",
"xpack.rollupJobs.util.day.tuesday": "星期二",
"xpack.rollupJobs.util.day.wednesday": "星期三",
"xpack.rollupJobs.util.month.april": "四月",
"xpack.rollupJobs.util.month.august": "八月",
"xpack.rollupJobs.util.month.december": "十二月",
"xpack.rollupJobs.util.month.february": "二月",
"xpack.rollupJobs.util.month.january": "一月",
"xpack.rollupJobs.util.month.july": "七月",
"xpack.rollupJobs.util.month.june": "六月",
"xpack.rollupJobs.util.month.march": "三月",
"xpack.rollupJobs.util.month.may": "五月",
"xpack.rollupJobs.util.month.november": "十一月",
"xpack.rollupJobs.util.month.october": "十月",
"xpack.rollupJobs.util.month.september": "九月",
"xpack.searchProfiler.aggregationProfileTabTitle": "聚合配置文件",
"xpack.searchProfiler.basicLicenseTitle": "基础级",
"xpack.searchProfiler.formIndexLabel": "索引",