/* * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ import _ from "lodash"; import queryString from "query-string"; import { EuiFormRow, EuiTextArea, EuiSelect, EuiFieldText, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle, EuiButtonEmpty, EuiButton, EuiComboBoxOptionOption, EuiFieldNumber, EuiAccordion, EuiRadioGroup, EuiText, EuiCheckbox, EuiPanel, EuiHorizontalRule, EuiButtonIcon, EuiLink, } from "@elastic/eui"; import React, { ChangeEvent, Component } from "react"; import { RouteComponentProps } from "react-router-dom"; import { CoreServicesContext } from "../../../../components/core_services"; import { CatRepository, CreateRepositoryBody, CreateRepositorySettings, FeatureChannelList } from "../../../../../server/models/interfaces"; import { IndexItem, SMPolicy } from "../../../../../models/interfaces"; import { BREADCRUMBS, ROUTES, SNAPSHOT_MANAGEMENT_DOCUMENTATION_URL } from "../../../../utils/constants"; import { ContentPanel } from "../../../../components/ContentPanel"; import { IndexService, NotificationService, SnapshotManagementService } from "../../../../services"; import { getErrorMessage, wildcardOption } from "../../../../utils/helpers"; import CustomLabel from "../../../../components/CustomLabel"; import { DEFAULT_INDEX_OPTIONS, ERROR_PROMPT, getDefaultSMPolicy, maxAgeUnitOptions as MAX_AGE_UNIT_OPTIONS } from "../../constants"; import { getIncludeGlobalState, getIgnoreUnavailabel, getAllowPartial, showNotification, getNotifyCreation, getNotifyDeletion, getNotifyFailure, } from "../helper"; import { parseCronExpression } from "../../components/CronSchedule/helper"; import SnapshotIndicesRepoInput from "../../components/SnapshotIndicesRepoInput"; import CronSchedule from "../../components/CronSchedule"; import SnapshotAdvancedSettings from "../../components/SnapshotAdvancedSettings"; import Notification from "../../components/Notification"; interface CreateSMPolicyProps extends RouteComponentProps { snapshotManagementService: SnapshotManagementService; isEdit: boolean; notificationService: NotificationService; indexService: IndexService; } interface CreateSMPolicyState { policy: SMPolicy; policyId: string; policySeqNo: number | undefined; policyPrimaryTerm: number | undefined; isSubmitting: boolean; channels: FeatureChannelList[]; loadingChannels: boolean; indexOptions: EuiComboBoxOptionOption[]; selectedIndexOptions: EuiComboBoxOptionOption[]; repositories: CatRepository[]; selectedRepoValue: string; maxAgeNum: number; maxAgeUnit: string; creationScheduleFrequencyType: string; deletionScheduleFrequencyType: string; deleteConditionEnabled: boolean; deletionScheduleEnabled: boolean; // whether to use the same schedule as creation advancedSettingsOpen: boolean; showCreateRepoFlyout: boolean; policyIdError: string; minCountError: string; repoError: string; timezoneError: string; } export default class CreateSnapshotPolicy extends Component { static contextType = CoreServicesContext; constructor(props: CreateSMPolicyProps) { super(props); this.state = { policy: getDefaultSMPolicy(), policyId: "", policySeqNo: undefined, policyPrimaryTerm: undefined, isSubmitting: false, channels: [], loadingChannels: false, indexOptions: DEFAULT_INDEX_OPTIONS, selectedIndexOptions: [], repositories: [], selectedRepoValue: "", maxAgeNum: 1, maxAgeUnit: "d", creationScheduleFrequencyType: "daily", deletionScheduleFrequencyType: "daily", deleteConditionEnabled: false, deletionScheduleEnabled: false, advancedSettingsOpen: false, showCreateRepoFlyout: false, policyIdError: "", repoError: "", minCountError: "", timezoneError: "", }; } async componentDidMount() { if (this.props.isEdit) { const { id } = queryString.parse(this.props.location.search); if (typeof id === "string" && !!id) { this.context.chrome.setBreadcrumbs([ BREADCRUMBS.SNAPSHOT_MANAGEMENT, BREADCRUMBS.SNAPSHOT_POLICIES, BREADCRUMBS.EDIT_SNAPSHOT_POLICY, { text: id }, ]); await this.getPolicy(id); } else { this.context.notifications.toasts.addDanger(`Invalid policy id: ${id}`); this.props.history.push(ROUTES.SNAPSHOT_POLICIES); } } else { this.context.chrome.setBreadcrumbs([ BREADCRUMBS.SNAPSHOT_MANAGEMENT, BREADCRUMBS.SNAPSHOT_POLICIES, BREADCRUMBS.CREATE_SNAPSHOT_POLICY, ]); } await this.getIndexOptions(""); await this.getRepos(); await this.getChannels(); } getPolicy = async (policyId: string): Promise => { try { const { snapshotManagementService } = this.props; const response = await snapshotManagementService.getPolicy(policyId); if (response.ok) { // Populate policy into state const policy = response.response.policy; const indices = _.get(policy, "snapshot_config.indices", ""); const selectedIndexOptions = indices .split(",") .filter((index: string) => !!index) .map((label: string) => ({ label })); const selectedRepoValue = _.get(policy, "snapshot_config.repository", ""); const { frequencyType: creationScheduleFrequencyType } = parseCronExpression(_.get(policy, "creation.schedule.cron.expression")); const { frequencyType: deletionScheduleFrequencyType } = parseCronExpression(_.get(policy, "deletion.schedule.cron.expression")); let deleteConditionEnabled = false; let deletionScheduleEnabled = false; if (!!_.get(policy, "deletion")) { deleteConditionEnabled = true; const creationScheduleExpression = _.get(policy, "creation.schedule.cron.expression"); const deletionScheduleExpression = _.get(policy, "deletion.schedule.cron.expression"); if (creationScheduleExpression !== deletionScheduleExpression) deletionScheduleEnabled = true; } const maxAge = policy.deletion?.condition?.max_age; let maxAgeNum = 1; let maxAgeUnit = "d"; if (maxAge) { maxAgeNum = parseInt(maxAge.substring(0, maxAge.length - 1)); maxAgeUnit = maxAge[maxAge.length - 1]; } this.setState({ policy, policyId: response.response.id, policySeqNo: response.response.seqNo, policyPrimaryTerm: response.response.primaryTerm, selectedIndexOptions, selectedRepoValue, creationScheduleFrequencyType, deletionScheduleFrequencyType, deleteConditionEnabled, deletionScheduleEnabled, maxAgeNum, maxAgeUnit, }); } else { const errorMessage = response.ok ? "Policy was empty" : response.error; this.context.notifications.toasts.addDanger(`Could not load the policy: ${errorMessage}`); this.props.history.push(ROUTES.SNAPSHOT_POLICIES); } } catch (err) { this.context.notifications.toasts.addDanger(`Could not load the policy`); this.props.history.push(ROUTES.SNAPSHOT_POLICIES); } }; getIndexOptions = async (searchValue: string) => { const { indexService } = this.props; this.setState({ indexOptions: DEFAULT_INDEX_OPTIONS }); try { const optionsResponse = await indexService.getDataStreamsAndIndicesNames(searchValue); if (optionsResponse.ok) { // Adding wildcard to search value const options = searchValue.trim() ? [{ label: wildcardOption(searchValue) }, { label: searchValue }] : []; const indices = optionsResponse.response.indices.map((label) => ({ label })); this.setState({ indexOptions: [...this.state.indexOptions, ...options.concat(indices)] }); } else { if (optionsResponse.error.startsWith("[index_not_found_exception]")) { this.context.notifications.toasts.addDanger("No index available"); } else { this.context.notifications.toasts.addDanger(optionsResponse.error); } } } catch (err) { this.context.notifications.toasts.addDanger(getErrorMessage(err, "There was a problem fetching index options.")); } }; getRepos = async () => { try { const { snapshotManagementService } = this.props; const response = await snapshotManagementService.catRepositories(); if (response.ok) { if (!this.props.isEdit) { const selectedRepoValue = response.response.length > 0 ? response.response[0].id : ""; this.setState({ repositories: response.response, selectedRepoValue, policy: this.setPolicyHelper("snapshot_config.repository", selectedRepoValue), }); } else { this.setState({ repositories: response.response, }); } } else { this.context.notifications.toasts.addDanger(response.error); } } catch (err) { this.context.notifications.toasts.addDanger(getErrorMessage(err, "There was a problem loading the snapshots.")); } }; createRepo = async (repoName: string, type: string, settings: CreateRepositorySettings) => { try { const { snapshotManagementService } = this.props; const createRepoBody: CreateRepositoryBody = { type: type, settings: settings, }; const response = await snapshotManagementService.createRepository(repoName, createRepoBody); if (response.ok) { this.setState({ showCreateRepoFlyout: false }); this.context.notifications.toasts.addSuccess(`Created repository ${repoName}.`); await this.getRepos(); } else { this.context.notifications.toasts.addDanger(response.error); } } catch (err) { this.context.notifications.toasts.addDanger(getErrorMessage(err, "There was a problem creating the repository.")); } }; createPolicy = async (policyId: string, policy: SMPolicy) => { const { snapshotManagementService } = this.props; try { const response = await snapshotManagementService.createPolicy(policyId, policy); this.setState({ isSubmitting: false }); if (response.ok) { this.context.notifications.toasts.addSuccess(`Created policy: ${response.response.policy.name}`); this.props.history.push(ROUTES.SNAPSHOT_POLICIES); } else { this.context.notifications.toasts.addDanger(`Failed to create snapshot policy: ${response.error}`); } } catch (err) { this.setState({ isSubmitting: false }); this.context.notifications.toasts.addDanger( `Failed to create snapshot policy: ${getErrorMessage(err, "There was a problem creating the snapshot policy.")}` ); } }; updatePolicy = async (policyId: string, policy: SMPolicy): Promise => { try { const { snapshotManagementService } = this.props; const { policyPrimaryTerm, policySeqNo } = this.state; if (policySeqNo == null || policyPrimaryTerm == null) { this.context.notifications.toasts.addDanger("Could not update policy without seqNo and primaryTerm"); return; } const response = await snapshotManagementService.updatePolicy(policyId, policy, policySeqNo, policyPrimaryTerm); this.setState({ isSubmitting: false }); if (response.ok) { this.context.notifications.toasts.addSuccess(`Updated policy: ${response.response.policy.name}`); this.props.history.push(ROUTES.SNAPSHOT_POLICIES); } else { this.context.notifications.toasts.addDanger(`Failed to update policy: ${response.error}`); } } catch (err) { this.setState({ isSubmitting: false }); this.context.notifications.toasts.addDanger(getErrorMessage(err, "There was a problem updating the policy")); } }; onClickCancel = (): void => { this.props.history.push(ROUTES.SNAPSHOT_POLICIES); }; onClickSubmit = async () => { this.setState({ isSubmitting: true }); const { isEdit } = this.props; const { policyId, policy } = this.state; try { if (!policyId.trim()) { this.setState({ policyIdError: ERROR_PROMPT.NAME }); } else if (!_.get(policy, "snapshot_config.repository")) { this.setState({ repoError: ERROR_PROMPT.REPO }); } else if (!_.get(policy, "creation.schedule.cron.timezone")) { this.setState({ timezoneError: ERROR_PROMPT.TIMEZONE }); } else { const policyFromState = this.buildPolicyFromState(policy); // console.log(`sm dev policy from state ${JSON.stringify(policyFromState)}`); if (isEdit) await this.updatePolicy(policyId, policyFromState); else await this.createPolicy(policyId, policyFromState); } this.setState({ isSubmitting: false }); } catch (err) { this.context.notifications.toasts.addDanger("Invalid Policy"); console.error(err); this.setState({ isSubmitting: false }); } }; buildPolicyFromState = (policy: SMPolicy): SMPolicy => { const { deletionScheduleEnabled, maxAgeNum, maxAgeUnit, deleteConditionEnabled } = this.state; if (deleteConditionEnabled) { _.set(policy, "deletion.condition.max_age", maxAgeNum + maxAgeUnit); } else { delete policy.deletion; } if (deletionScheduleEnabled) { _.set(policy, "deletion.schedule.cron.timezone", _.get(policy, "creation.schedule.cron.timezone")); } else { delete policy.deletion?.schedule; } if (!showNotification(policy)) { delete policy.notification; } return policy; }; onIndicesSelectionChange = (selectedOptions: EuiComboBoxOptionOption[]) => { const selectedIndexOptions = selectedOptions.map((o) => o.label); this.setState({ policy: this.setPolicyHelper("snapshot_config.indices", selectedIndexOptions.toString()), selectedIndexOptions: selectedOptions, }); }; onRepoSelectionChange = (e: React.ChangeEvent) => { const selectedRepo = e.target.value; let repoError = ""; if (!selectedRepo) { repoError = ERROR_PROMPT.REPO; } this.setState({ policy: this.setPolicyHelper("snapshot_config.repository", selectedRepo), selectedRepoValue: selectedRepo, repoError }); }; onCreateOption = (searchValue: string, options: Array>) => { const normalizedSearchValue = searchValue.trim().toLowerCase(); if (!normalizedSearchValue) { return; } const newOption = { label: searchValue, }; // Create the option if it doesn't exist. if (options.findIndex((option) => option.label.trim().toLowerCase() === normalizedSearchValue) === -1) { this.setState({ indexOptions: [...this.state.indexOptions, newOption] }); } const selectedIndexOptions = [...this.state.selectedIndexOptions, newOption]; this.setState({ selectedIndexOptions: selectedIndexOptions, policy: this.setPolicyHelper("snapshot_config.indices", selectedIndexOptions.toString()), }); }; getChannels = async (): Promise => { this.setState({ loadingChannels: true }); try { const { notificationService } = this.props; const response = await notificationService.getChannels(); if (response.ok) { this.setState({ channels: response.response.channel_list }); } else { this.context.notifications.toasts.addDanger(`Could not load notification channels: ${response.error}`); } } catch (err) { this.context.notifications.toasts.addDanger(getErrorMessage(err, "Could not load the notification channels")); } this.setState({ loadingChannels: false }); }; onChangeChannelId = (e: ChangeEvent): void => { const channelId = e.target.value; this.setState({ policy: this.setPolicyHelper("notification.channel.id", channelId) }); }; render() { // console.log(`sm dev render state policy ${JSON.stringify(this.state.policy)}`); const { isEdit } = this.props; const { policy, policyId, isSubmitting, channels, loadingChannels, indexOptions, selectedIndexOptions, repositories, selectedRepoValue, maxAgeNum, maxAgeUnit, creationScheduleFrequencyType, deletionScheduleFrequencyType, deleteConditionEnabled, deletionScheduleEnabled, advancedSettingsOpen, showCreateRepoFlyout, policyIdError, repoError, minCountError, timezoneError, } = this.state; const repoOptions = repositories.map((r) => ({ value: r.id, text: r.id })); const rententionEnableRadios = [ { id: "retention_disabled", label: "Retain all snapshots", }, { id: "retention_enabled", label: "Specify retention conditions", }, ]; const subTitleText = (

Snapshot policies allow you to define an automated snapshot schedule and retention period.{" "} Learn more

); const showNotificationChannel = showNotification(policy); return (

{isEdit ? "Edit" : "Create"} policy

{subTitleText} { this.setState({ showCreateRepoFlyout: true }); }} closeFlyout={() => { this.setState({ showCreateRepoFlyout: false }); }} createRepo={this.createRepo} snapshotManagementService={this.props.snapshotManagementService} repoError={repoError} /> { const frequencyType = e.target.value; let maxAgeUnitToChange = maxAgeUnit; if (frequencyType == "hourly" && !deleteConditionEnabled) { maxAgeUnitToChange = "h"; } this.setState({ creationScheduleFrequencyType: e.target.value, maxAgeUnit: maxAgeUnitToChange }); }} showTimezone={true} timezone={_.get(policy, "creation.schedule.cron.timezone")} onChangeTimezone={(timezone: string) => { this.setState({ policy: this.setPolicyHelper("creation.schedule.cron.timezone", timezone) }); }} timezoneError={timezoneError} cronExpression={_.get(policy, "creation.schedule.cron.expression", "")} onCronExpressionChange={(expression: string) => { this.setState({ policy: this.setPolicyHelper("creation.schedule.cron.expression", expression) }); }} /> { this.setState({ deleteConditionEnabled: id === "retention_enabled" }); }} /> {deleteConditionEnabled ? ( <> { this.setState({ maxAgeNum: parseInt(e.target.value) }); }} /> { this.setState({ maxAgeUnit: e.target.value }); }} /> Number of snapshots retained Deletion frequency Configure when to check retention conditions and delete snapshots. { this.setState({ deletionScheduleEnabled: !deletionScheduleEnabled }); }} /> {deletionScheduleEnabled ? ( { this.setState({ deletionScheduleFrequencyType: e.target.value }); }} timezone={undefined} cronExpression={_.get(policy, "deletion.schedule.cron.expression", "")} onCronExpressionChange={(expression: string) => { this.setState({ policy: this.setPolicyHelper("deletion.schedule.cron.expression", expression) }); }} /> ) : null} ) : null}
Notify on snapshot activities ) => { this.setState({ policy: this.setPolicyHelper("notification.conditions.creation", e.target.checked) }); }} /> ) => { this.setState({ policy: this.setPolicyHelper("notification.conditions.deletion", e.target.checked) }); }} /> ) => { this.setState({ policy: this.setPolicyHelper("notification.conditions.failure", e.target.checked) }); }} />
{showNotificationChannel ? ( ) : null}
{/* Advanced settings */} { this.setState({ advancedSettingsOpen: !this.state.advancedSettingsOpen }); }} aria-label="drop down icon" />

Advanced settings – optional

{advancedSettingsOpen && ( <> {/* TODO SM Haven't fininalized the design for this before 2.1 release */} {/*

Snapshot naming settings

Customize the naming format of snapshots. { let dateFormat = e.target.value; if (!dateFormat) { dateFormat = DEFAULT_DATE_FORMAT; } this.setState({ dateFormat }); }} /> `${tz} (${moment.tz(tz).format("Z")})`} selectedOptions={[{ label: _.get(policy, "snapshot_config.date_format_timezone") ?? DEFAULT_DATE_FORMAT_TIMEZONE }]} onChange={(options) => { let timezone = _.first(options)?.label; if (!timezone) timezone = DEFAULT_DATE_FORMAT_TIMEZONE; this.setState({ policy: this.setPolicyHelper("snapshot_config.date_format_timezone", timezone) }); }} />
*/} )}
Cancel {isEdit ? "Update" : "Create"}
); } onChangeMaxCount = (e: ChangeEvent) => { // Received NaN for the `value` attribute. If this is expected, cast the value to a string. const maxCount = isNaN(parseInt(e.target.value)) ? undefined : parseInt(e.target.value); this.setState({ policy: this.setPolicyHelper("deletion.condition.max_count", maxCount) }); }; onChangeMinCount = (e: ChangeEvent) => { const minCount = isNaN(parseInt(e.target.value)) ? undefined : parseInt(e.target.value); let isMinCountValid = ""; if (!minCount || minCount < 1) { isMinCountValid = "Min count should be bigger than 0."; } this.setState({ policy: this.setPolicyHelper("deletion.condition.min_count", minCount), minCountError: isMinCountValid }); }; onChangePolicyName = (e: ChangeEvent) => { this.setState({ policyId: e.target.value }); }; onChangeDescription = (e: ChangeEvent): void => { this.setState({ policy: this.setPolicyHelper("description", e.target.value) }); }; onChangeCreationExpression = (e: ChangeEvent) => { this.setState({ policy: this.setPolicyHelper("creation.schedule.cron.expression", e.target.value) }); }; onChangeDeletionExpression = (e: ChangeEvent) => { this.setState({ policy: this.setPolicyHelper("deletion.schedule.cron.expression", e.target.value) }); }; onChangeCreationTimezone = (e: ChangeEvent) => { this.setState({ policy: this.setPolicyHelper("creation.schedule.cron.timezone", e.target.value) }); }; onChangeDeletionTimezone = (e: ChangeEvent) => { this.setState({ policy: this.setPolicyHelper("deletion.schedule.cron.timezone", e.target.value) }); }; onChangeIndices = (e: ChangeEvent) => { this.setState({ policy: this.setPolicyHelper("snapshot_config.indices", e.target.value) }); }; onChangeRepository = (e: ChangeEvent) => { this.setState({ policy: this.setPolicyHelper("snapshot_config.repository", e.target.value) }); }; onIncludeGlobalStateToggle = (e: ChangeEvent) => { this.setState({ policy: this.setPolicyHelper("snapshot_config.include_global_state", e.target.checked) }); }; onIgnoreUnavailableToggle = (e: ChangeEvent) => { this.setState({ policy: this.setPolicyHelper("snapshot_config.ignore_unavailable", e.target.checked) }); }; onPartialToggle = (e: ChangeEvent) => { this.setState({ policy: this.setPolicyHelper("snapshot_config.partial", e.target.checked) }); }; setPolicyHelper = (path: string, newValue: any) => { return _.set(this.state.policy, path, newValue); }; }