From b6714213f48888909a7e8fe5825c255f9dc13e25 Mon Sep 17 00:00:00 2001 From: bowenlan-amzn Date: Thu, 23 Jun 2022 16:24:20 -0700 Subject: [PATCH 1/2] Merge snapshot management into main branch Signed-off-by: bowenlan-amzn --- models/interfaces.ts | 98 ++ public/components/CustomLabel/CustomLabel.tsx | 39 + public/components/CustomLabel/index.ts | 8 + public/index_management_app.tsx | 12 +- public/models/interfaces.ts | 44 +- .../components/CronSchedule/CronSchedule.tsx | 208 +++++ .../components/CronSchedule/constants.ts | 18 + .../components/CronSchedule/helper.ts | 128 +++ .../components/CronSchedule/index.ts | 8 + .../SnapshotAdvancedSettings.tsx | 62 ++ .../SnapshotAdvancedSettings/index.ts | 8 + .../SnapshotIndicesRepoInput.tsx | 106 +++ .../SnapshotIndicesRepoInput/index.ts | 8 + .../pages/CreateSnapshotPolicy/constants.ts | 43 + .../CreateSnapshotPolicy.tsx | 858 ++++++++++++++++++ .../containers/CreateSnapshotPolicy/index.ts | 8 + .../CreateSnapshotPolicy/containers/helper.ts | 30 + public/pages/CreateSnapshotPolicy/index.ts | 8 + .../CreateTransformForm.tsx | 1 - public/pages/Main/Main.tsx | 96 +- .../CreateRepositoryFlyout.tsx | 271 ++++++ .../CreateRepositoryFlyout/constants.ts | 41 + .../CreateRepositoryFlyout/index.ts | 8 + .../components/DeleteModal/DeleteModal.tsx | 71 ++ .../components/DeleteModal/index.ts | 7 + .../containers/Repositories/Repositories.tsx | 317 +++++++ .../containers/Repositories/index.ts | 8 + public/pages/Repositories/index.ts | 8 + public/pages/SnapshotPolicies/constants.tsx | 20 + .../SnapshotPolicies/SnapshotPolicies.tsx | 470 ++++++++++ .../containers/SnapshotPolicies/index.ts | 8 + public/pages/SnapshotPolicies/helpers.ts | 40 + public/pages/SnapshotPolicies/index.ts | 8 + .../components/InfoModal/InfoModal.tsx | 45 + .../components/InfoModal/index.ts | 8 + .../SnapshotPolicyDetails.tsx | 417 +++++++++ .../containers/SnapshotPolicyDetails/index.ts | 8 + public/pages/SnapshotPolicyDetails/index.ts | 8 + .../CreateSnapshotFlyout.tsx | 252 +++++ .../CreateSnapshotFlyout/constants.ts | 13 + .../components/CreateSnapshotFlyout/index.ts | 8 + .../SnapshotFlyout/SnapshotFlyout.tsx | 149 +++ .../components/SnapshotFlyout/index.ts | 8 + .../containers/Snapshots/Snapshots.tsx | 341 +++++++ .../Snapshots/containers/Snapshots/index.ts | 8 + public/pages/Snapshots/helper.tsx | 30 + public/pages/Snapshots/index.ts | 8 + .../ChannelNotification.tsx | 4 +- .../components/FlyoutFooter/FlyoutFooter.tsx | 5 +- public/services/SnapshotManagementService.ts | 132 +++ public/services/index.ts | 2 + public/utils/constants.ts | 30 + server/clusters/ism/ismPlugin.ts | 108 +++ server/models/interfaces.ts | 88 +- server/models/types.ts | 3 +- server/plugin.ts | 6 +- server/routes/index.ts | 3 +- server/routes/snapshotManagement.ts | 207 +++++ server/services/SnapshotManagementService.ts | 628 +++++++++++++ server/services/index.ts | 12 +- server/utils/constants.ts | 2 + utils/constants.ts | 3 + 62 files changed, 5590 insertions(+), 14 deletions(-) create mode 100644 public/components/CustomLabel/CustomLabel.tsx create mode 100644 public/components/CustomLabel/index.ts create mode 100644 public/pages/CreateSnapshotPolicy/components/CronSchedule/CronSchedule.tsx create mode 100644 public/pages/CreateSnapshotPolicy/components/CronSchedule/constants.ts create mode 100644 public/pages/CreateSnapshotPolicy/components/CronSchedule/helper.ts create mode 100644 public/pages/CreateSnapshotPolicy/components/CronSchedule/index.ts create mode 100644 public/pages/CreateSnapshotPolicy/components/SnapshotAdvancedSettings/SnapshotAdvancedSettings.tsx create mode 100644 public/pages/CreateSnapshotPolicy/components/SnapshotAdvancedSettings/index.ts create mode 100644 public/pages/CreateSnapshotPolicy/components/SnapshotIndicesRepoInput/SnapshotIndicesRepoInput.tsx create mode 100644 public/pages/CreateSnapshotPolicy/components/SnapshotIndicesRepoInput/index.ts create mode 100644 public/pages/CreateSnapshotPolicy/constants.ts create mode 100644 public/pages/CreateSnapshotPolicy/containers/CreateSnapshotPolicy/CreateSnapshotPolicy.tsx create mode 100644 public/pages/CreateSnapshotPolicy/containers/CreateSnapshotPolicy/index.ts create mode 100644 public/pages/CreateSnapshotPolicy/containers/helper.ts create mode 100644 public/pages/CreateSnapshotPolicy/index.ts create mode 100644 public/pages/Repositories/components/CreateRepositoryFlyout/CreateRepositoryFlyout.tsx create mode 100644 public/pages/Repositories/components/CreateRepositoryFlyout/constants.ts create mode 100644 public/pages/Repositories/components/CreateRepositoryFlyout/index.ts create mode 100644 public/pages/Repositories/components/DeleteModal/DeleteModal.tsx create mode 100644 public/pages/Repositories/components/DeleteModal/index.ts create mode 100644 public/pages/Repositories/containers/Repositories/Repositories.tsx create mode 100644 public/pages/Repositories/containers/Repositories/index.ts create mode 100644 public/pages/Repositories/index.ts create mode 100644 public/pages/SnapshotPolicies/constants.tsx create mode 100644 public/pages/SnapshotPolicies/containers/SnapshotPolicies/SnapshotPolicies.tsx create mode 100644 public/pages/SnapshotPolicies/containers/SnapshotPolicies/index.ts create mode 100644 public/pages/SnapshotPolicies/helpers.ts create mode 100644 public/pages/SnapshotPolicies/index.ts create mode 100644 public/pages/SnapshotPolicyDetails/components/InfoModal/InfoModal.tsx create mode 100644 public/pages/SnapshotPolicyDetails/components/InfoModal/index.ts create mode 100644 public/pages/SnapshotPolicyDetails/containers/SnapshotPolicyDetails/SnapshotPolicyDetails.tsx create mode 100644 public/pages/SnapshotPolicyDetails/containers/SnapshotPolicyDetails/index.ts create mode 100644 public/pages/SnapshotPolicyDetails/index.ts create mode 100644 public/pages/Snapshots/components/CreateSnapshotFlyout/CreateSnapshotFlyout.tsx create mode 100644 public/pages/Snapshots/components/CreateSnapshotFlyout/constants.ts create mode 100644 public/pages/Snapshots/components/CreateSnapshotFlyout/index.ts create mode 100644 public/pages/Snapshots/components/SnapshotFlyout/SnapshotFlyout.tsx create mode 100644 public/pages/Snapshots/components/SnapshotFlyout/index.ts create mode 100644 public/pages/Snapshots/containers/Snapshots/Snapshots.tsx create mode 100644 public/pages/Snapshots/containers/Snapshots/index.ts create mode 100644 public/pages/Snapshots/helper.tsx create mode 100644 public/pages/Snapshots/index.ts create mode 100644 public/services/SnapshotManagementService.ts create mode 100644 server/routes/snapshotManagement.ts create mode 100644 server/services/SnapshotManagementService.ts diff --git a/models/interfaces.ts b/models/interfaces.ts index bbd3083e5..3ca383060 100644 --- a/models/interfaces.ts +++ b/models/interfaces.ts @@ -5,6 +5,7 @@ // TODO: Backend has PR out to change this model, this needs to be updated once that goes through +import { long } from "@opensearch-project/opensearch/api/types"; import { ActionType } from "../public/pages/VisualCreatePolicy/utils/constants"; export interface ManagedIndexMetaData { @@ -78,16 +79,113 @@ export interface Policy { schema_version?: number; } +export interface DocumentSMPolicy { + id: string; + seqNo: number; + primaryTerm: number; + policy: SMPolicy; +} + +export interface DocumentSMPolicyWithMetadata { + id: string; + seqNo: number; + primaryTerm: number; + policy: SMPolicy; + metadata: SMMetadata; +} + +export interface SMMetadata { + name: string; + creation?: SMWorkflowMetadata; + deletion?: SMWorkflowMetadata; + policy_seq_no?: number; + policy_primary_term?: number; + enabled: boolean; +} + +export interface SMWorkflowMetadata { + trigger: { + time: number; + }; + started: string[]; + latest_execution: { + status: string; + start_time: long; + end_time?: long; + info?: { + message?: string; + cause?: string; + }; + }; +} + +export interface SMPolicy { + name: string; + description: string; + creation: SMCreation; + deletion?: SMDeletion; + snapshot_config: SMSnapshotConfig; + enabled: boolean; + last_updated_time?: number; + notification?: Notification; +} + +export interface Snapshot { + indices: string; + ignore_unavailable: boolean; + include_global_state: boolean; + partial: boolean; + metadata?: object; +} + +export interface SMSnapshotConfig { + repository: string; + indices?: string; + ignore_unavailable?: boolean; + include_global_state?: boolean; + partial?: boolean; + date_expression?: string; +} + +export interface SMCreation { + schedule: Cron; + time_limit?: string; +} + +export interface SMDeletion { + schedule?: Cron; + condition?: SMDeleteCondition; + time_limit?: string; +} + +export interface SMDeleteCondition { + max_count?: number; + max_age?: string; + min_count?: number; +} + export interface ErrorNotification { destination?: Destination; channel?: Channel; message_template: MessageTemplate; } +export interface Notification { + channel: Channel; + conditions?: SMNotificationCondition; +} + export interface Channel { id: string; } +export interface SMNotificationCondition { + creation?: boolean; + deletion?: boolean; + failure?: boolean; + time_limit_exceeded?: boolean; +} + export interface Destination { chime?: { url: string; diff --git a/public/components/CustomLabel/CustomLabel.tsx b/public/components/CustomLabel/CustomLabel.tsx new file mode 100644 index 000000000..8d0658b74 --- /dev/null +++ b/public/components/CustomLabel/CustomLabel.tsx @@ -0,0 +1,39 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from "@elastic/eui"; +import React from "react"; + +interface CustomLabelProps { + title: string; + isOptional?: boolean; + helpText?: string | JSX.Element; +} + +const CustomLabel = ({ title, isOptional = false, helpText }: CustomLabelProps) => ( + <> + + + +

{title}

+
+
+ + {isOptional ? ( + + + - optional + + + ) : null} +
+ + {helpText && typeof helpText === "string" ? {helpText} : helpText} + + + +); + +export default CustomLabel; diff --git a/public/components/CustomLabel/index.ts b/public/components/CustomLabel/index.ts new file mode 100644 index 000000000..5188f6e70 --- /dev/null +++ b/public/components/CustomLabel/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import CustomLabel from "./CustomLabel"; + +export default CustomLabel; diff --git a/public/index_management_app.tsx b/public/index_management_app.tsx index 5a0052bd6..35c6ce38e 100644 --- a/public/index_management_app.tsx +++ b/public/index_management_app.tsx @@ -15,6 +15,7 @@ import { TransformService, NotificationService, ServicesContext, + SnapshotManagementService, } from "./services"; import { DarkModeContext } from "./components/DarkMode"; import Main from "./pages/Main"; @@ -30,7 +31,16 @@ export function renderApp(coreStart: CoreStart, params: AppMountParameters) { const rollupService = new RollupService(http); const transformService = new TransformService(http); const notificationService = new NotificationService(http); - const services = { indexService, managedIndexService, policyService, rollupService, transformService, notificationService }; + const snapshotManagementService = new SnapshotManagementService(http); + const services = { + indexService, + managedIndexService, + policyService, + rollupService, + transformService, + notificationService, + snapshotManagementService, + }; const isDarkMode = coreStart.uiSettings.get("theme:darkMode") || false; diff --git a/public/models/interfaces.ts b/public/models/interfaces.ts index c1323065d..5a645fecc 100644 --- a/public/models/interfaces.ts +++ b/public/models/interfaces.ts @@ -3,7 +3,17 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { IndexService, ManagedIndexService, PolicyService, RollupService, TransformService, NotificationService } from "../services"; +import { Direction, Query } from "@elastic/eui"; +import { SMPolicy } from "../../models/interfaces"; +import { + IndexService, + ManagedIndexService, + PolicyService, + RollupService, + TransformService, + NotificationService, + SnapshotManagementService, +} from "../services"; export interface BrowserServices { indexService: IndexService; @@ -12,4 +22,36 @@ export interface BrowserServices { rollupService: RollupService; transformService: TransformService; notificationService: NotificationService; + snapshotManagementService: SnapshotManagementService; +} + +export interface SMPoliciesQueryParams { + from: number; + size: number; + sortField: keyof SMPolicy; + sortOrder: Direction; +} + +interface ArgsWithQuery { + query: Query; + queryText: string; + error: null; +} +interface ArgsWithError { + query: null; + queryText: string; + error: Error; +} +export type OnSearchChangeArgs = ArgsWithQuery | ArgsWithError; + +export interface LatestActivities { + activityType: "Creation" | "Deletion"; + status?: string; + snapshot?: string; + start_time?: number; + end_time?: number; + info?: { + message?: string; + cause?: string; + }; } diff --git a/public/pages/CreateSnapshotPolicy/components/CronSchedule/CronSchedule.tsx b/public/pages/CreateSnapshotPolicy/components/CronSchedule/CronSchedule.tsx new file mode 100644 index 000000000..327fed0da --- /dev/null +++ b/public/pages/CreateSnapshotPolicy/components/CronSchedule/CronSchedule.tsx @@ -0,0 +1,208 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import _ from "lodash"; +import React, { ChangeEvent, useEffect, useState } from "react"; +import { + EuiCheckbox, + EuiComboBox, + EuiDatePicker, + EuiFieldNumber, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiLink, + EuiSelect, + EuiSpacer, + EuiText, +} from "@elastic/eui"; +import CustomLabel from "../../../../components/CustomLabel"; +import { WEEK_DAYS, CRON_SCHEDULE_FREQUENCY_TYPE, TIMEZONES } from "./constants"; +import { buildCronExpression, parseCronExpression, startTime } from "./helper"; +import moment from "moment-timezone"; +import { CRON_EXPRESSION_DOCUMENTATION_URL } from "../../../../utils/constants"; + +interface CronScheduleProps { + frequencyType: string; + onChangeFrequencyType: (e: ChangeEvent) => void; + cronExpression: string; + onCronExpressionChange: (expression: string) => void; + showTimezone?: boolean; + timezone?: string; + onChangeTimezone?: (timezone: string) => void; + timezoneError?: string; +} + +const CronSchedule = ({ + frequencyType, + onChangeFrequencyType, + cronExpression, + onCronExpressionChange, + showTimezone, + timezone, + onChangeTimezone, + timezoneError, +}: CronScheduleProps) => { + const { minute: initMin, hour: initHour, dayOfWeek: initWeek, dayOfMonth: initMonth } = parseCronExpression(cronExpression); + + const [minute, setMinute] = useState(initMin); + const [hour, setHour] = useState(initHour); + const [dayOfWeek, setWeek] = useState(initWeek); + const [dayOfMonth, setMonth] = useState(initMonth); + + useEffect(() => { + changeCron(); + }, [minute, hour, dayOfWeek, dayOfMonth]); + + const changeCron = (input?: any) => { + let cronParts = { hour, minute, dayOfWeek, dayOfMonth, frequencyType }; + cronParts = { ...cronParts, ...input }; + const expression = buildCronExpression(cronParts, cronExpression); + // console.log(`sm dev built expression ${expression}`); + onCronExpressionChange(expression); + }; + + function onDayOfWeekChange(dayOfWeek: string) { + setWeek(dayOfWeek); + // changeCron({ dayOfWeek }); + } + + function onDayOfMonthChange(dayOfMonth: number) { + setMonth(dayOfMonth); + // changeCron({ dayOfMonth }); + } + + function onStartTimeChange(date: moment.Moment) { + const minute = date.minute(); + const hour = date.hour(); + setMinute(minute); + setHour(hour); + // changeCron({ minute, hour }); + } + + function onTypeChange(e: ChangeEvent) { + const frequencyType = e.target.value; + onChangeFrequencyType(e); + if (frequencyType === "hourly") setMinute(0); + changeCron({ frequencyType }); + } + + const dayOfWeekCheckbox = (day: string, checkedDay: string) => ( + + onDayOfWeekChange(day)} compressed /> + + ); + + let startTimeContent; + startTimeContent = ( + + ); + if (frequencyType === "hourly") { + startTimeContent = Starts at the beginning of the hour.; + } + + let additionalContent; + if (frequencyType === "weekly") { + // TODO SM if use dayOfWeek not initWeek, somehow it would be SUN + additionalContent = {WEEK_DAYS.map((d) => dayOfWeekCheckbox(d, initWeek))}; + } + if (frequencyType === "monthly") { + additionalContent = ( + <> + + + + + + + { + onDayOfMonthChange(parseInt(e.target.value)); + }} + min={1} + max={31} + /> + + + + ); + } + + const cronExpressionHelpText = ( + +

+ Use Cron expression to define complex schedule.{" "} + + Learn more + +

+
+ ); + + return ( + <> + + + + + + + + {frequencyType === "custom" ? ( + <> + + { + onCronExpressionChange(e.target.value); + }} + /> + + ) : ( + <> + + {startTimeContent} + + + + {additionalContent} + + )} + + + {showTimezone ? ( + + + + `${tz} (${moment.tz(tz).format("Z")})`} + selectedOptions={[{ label: timezone ?? "" }]} + onChange={(options) => { + if (onChangeTimezone) { + onChangeTimezone(_.first(options)?.label ?? ""); + } + }} + /> + + + ) : null} + + + ); +}; + +export default CronSchedule; diff --git a/public/pages/CreateSnapshotPolicy/components/CronSchedule/constants.ts b/public/pages/CreateSnapshotPolicy/components/CronSchedule/constants.ts new file mode 100644 index 000000000..5aa5ddfe0 --- /dev/null +++ b/public/pages/CreateSnapshotPolicy/components/CronSchedule/constants.ts @@ -0,0 +1,18 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import moment from "moment-timezone"; + +export const CRON_SCHEDULE_FREQUENCY_TYPE = [ + { value: "hourly", text: "Hourly" }, + { value: "daily", text: "Daily" }, + { value: "weekly", text: "Weekly" }, + { value: "monthly", text: "Monthly" }, + { value: "custom", text: "Custom (Cron expression)" }, +]; + +export const TIMEZONES = moment.tz.names().map((tz) => ({ label: tz, text: tz })); + +export const WEEK_DAYS = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"]; diff --git a/public/pages/CreateSnapshotPolicy/components/CronSchedule/helper.ts b/public/pages/CreateSnapshotPolicy/components/CronSchedule/helper.ts new file mode 100644 index 000000000..7087ff3bd --- /dev/null +++ b/public/pages/CreateSnapshotPolicy/components/CronSchedule/helper.ts @@ -0,0 +1,128 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import moment from "moment-timezone"; +import { WEEK_DAYS } from "./constants"; + +export interface CronUIParts { + hour: number; + minute: number; + dayOfWeek: string; + dayOfMonth: number; + frequencyType: "custom" | "monthly" | "weekly" | "daily" | "hourly" | string; +} + +export const DEFAULT_CRON_DAY_OF_WEEK = "SUN"; +export const DEFAULT_CRON_DAY_OF_MONTH = 1; +export const DEFAULT_CRON_MINUTE = 0; +export const DEFAULT_CRON_HOUR = 20; + +export function parseCronExpression(expression: string): CronUIParts { + let minute = DEFAULT_CRON_MINUTE; + let hour = DEFAULT_CRON_HOUR; + let frequencyType = "custom"; + let dayOfWeek = DEFAULT_CRON_DAY_OF_WEEK; + let dayOfMonth = DEFAULT_CRON_DAY_OF_MONTH; + + if (!expression) { + return { + hour, + minute, + dayOfWeek, + dayOfMonth, + frequencyType, + }; + } + const expArr = expression.split(" "); + + if (isNumber(expArr[0]) && isNumber(expArr[1])) { + minute = parseInt(expArr[0]); + hour = parseInt(expArr[1]); + if (isNumber(expArr[2]) && expArr[3] == "*" && expArr[4] == "*") { + frequencyType = "monthly"; + dayOfMonth = parseInt(expArr[2]); + } + if (expArr[2] == "*" && expArr[3] == "*" && (isNumber(expArr[4]) || WEEK_DAYS.includes(expArr[4]))) { + frequencyType = "weekly"; + if (isNumber(expArr[4])) { + dayOfWeek = ["SUN", ...WEEK_DAYS][parseInt(expArr[4])]; + } else { + dayOfWeek = expArr[4]; + } + } + if (expArr[2] == "*" && expArr[3] == "*" && expArr[4] == "*") { + frequencyType = "daily"; + } + } + if (isNumber(expArr[0])) { + minute = parseInt(expArr[0]); + if (expArr[1] == "*" && expArr[2] == "*" && expArr[3] == "*" && expArr[4] == "*") { + frequencyType = "hourly"; + } + } + + return { + hour, + minute, + dayOfWeek, + dayOfMonth, + frequencyType, + }; +} + +export function buildCronExpression(cronParts: CronUIParts, expression: string): string { + const minute = cronParts.minute; + const hour = cronParts.hour; + + switch (cronParts.frequencyType) { + case "hourly": { + return `${minute} * * * *`; + } + case "daily": { + return `${minute} ${hour} * * *`; + } + case "weekly": { + return `${minute} ${hour} * * ${cronParts.dayOfWeek}`; + } + case "monthly": { + return `${minute} ${hour} ${cronParts.dayOfMonth} * *`; + } + case "custom": { + return expression; + } + } + throw new Error(`Unknown schedule frequency type ${cronParts.frequencyType}.`); +} + +export function humanCronExpression( + { hour, minute, dayOfWeek, dayOfMonth, frequencyType }: CronUIParts, + expression: string, + timezone: string +): string { + if (frequencyType == "custom") { + return expression + ` (${timezone})`; + } + let humanCron = `${startTime(hour, minute).format("h:mm a z")} (${timezone})`; + if (frequencyType == "monthly") { + humanCron = `Day ${dayOfMonth}, ` + humanCron; + } + if (frequencyType == "weekly") { + humanCron = `${dayOfWeek}, ` + humanCron; + } + return humanCron; +} + +function isNumber(value: any): boolean { + return !isNaN(parseInt(value)); +} + +export function formatNumberToHourMin(timeNumber: number) { + return ("0" + timeNumber).slice(-2); +} + +export function startTime(hourNumber: number, minuteNumber: number): moment.Moment { + const timeMoment = moment().hours(hourNumber).minutes(minuteNumber); + return timeMoment; +} diff --git a/public/pages/CreateSnapshotPolicy/components/CronSchedule/index.ts b/public/pages/CreateSnapshotPolicy/components/CronSchedule/index.ts new file mode 100644 index 000000000..7a3495d8c --- /dev/null +++ b/public/pages/CreateSnapshotPolicy/components/CronSchedule/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import CronSchedule from "./CronSchedule"; + +export default CronSchedule; diff --git a/public/pages/CreateSnapshotPolicy/components/SnapshotAdvancedSettings/SnapshotAdvancedSettings.tsx b/public/pages/CreateSnapshotPolicy/components/SnapshotAdvancedSettings/SnapshotAdvancedSettings.tsx new file mode 100644 index 000000000..1bdd8ac79 --- /dev/null +++ b/public/pages/CreateSnapshotPolicy/components/SnapshotAdvancedSettings/SnapshotAdvancedSettings.tsx @@ -0,0 +1,62 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiCheckbox, EuiSpacer } from "@elastic/eui"; +import CustomLabel from "../../../../components/CustomLabel"; +import React, { ChangeEvent } from "react"; + +interface SnapshotAdvancedSettingsProps { + includeGlobalState: boolean; + onIncludeGlobalStateToggle: (e: ChangeEvent) => void; + ignoreUnavailable: boolean; + onIgnoreUnavailableToggle: (e: ChangeEvent) => void; + partial: boolean; + onPartialToggle: (e: ChangeEvent) => void; + width?: string; +} + +const SnapshotAdvancedSettings = ({ + includeGlobalState, + onIncludeGlobalStateToggle, + ignoreUnavailable, + onIgnoreUnavailableToggle, + partial, + onPartialToggle, + width, +}: SnapshotAdvancedSettingsProps) => ( +
+ } + checked={includeGlobalState} + onChange={onIncludeGlobalStateToggle} + /> + + + + + } + checked={ignoreUnavailable} + onChange={onIgnoreUnavailableToggle} + /> + + + + } + checked={partial} + onChange={onPartialToggle} + /> +
+); + +export default SnapshotAdvancedSettings; diff --git a/public/pages/CreateSnapshotPolicy/components/SnapshotAdvancedSettings/index.ts b/public/pages/CreateSnapshotPolicy/components/SnapshotAdvancedSettings/index.ts new file mode 100644 index 000000000..89b6bb3b3 --- /dev/null +++ b/public/pages/CreateSnapshotPolicy/components/SnapshotAdvancedSettings/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import SnapshotAdvancedSettings from "./SnapshotAdvancedSettings"; + +export default SnapshotAdvancedSettings; diff --git a/public/pages/CreateSnapshotPolicy/components/SnapshotIndicesRepoInput/SnapshotIndicesRepoInput.tsx b/public/pages/CreateSnapshotPolicy/components/SnapshotIndicesRepoInput/SnapshotIndicesRepoInput.tsx new file mode 100644 index 000000000..9c63bbec6 --- /dev/null +++ b/public/pages/CreateSnapshotPolicy/components/SnapshotIndicesRepoInput/SnapshotIndicesRepoInput.tsx @@ -0,0 +1,106 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiButton, + EuiComboBox, + EuiComboBoxOptionOption, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiSelect, + EuiSelectOption, + EuiSpacer, +} from "@elastic/eui"; +import { CreateRepositorySettings } from "../../../../../server/models/interfaces"; +import React from "react"; +import { IndexItem } from "../../../../../models/interfaces"; +import CustomLabel from "../../../../components/CustomLabel"; +import { SnapshotManagementService } from "../../../../services"; +import CreateRepositoryFlyout from "../../../Repositories/components/CreateRepositoryFlyout/CreateRepositoryFlyout"; + +interface SnapshotIndicesProps { + indexOptions: EuiComboBoxOptionOption[]; + selectedIndexOptions: EuiComboBoxOptionOption[]; + onIndicesSelectionChange: (selectedOptions: EuiComboBoxOptionOption[]) => void; + getIndexOptions: (searchValue: string) => void; + onCreateOption: (searchValue: string, options: Array>) => void; + repoOptions: EuiSelectOption[]; + selectedRepoValue: string; + onRepoSelectionChange: (e: React.ChangeEvent) => void; + // create repository flyout + showFlyout?: boolean; + openFlyout?: () => void; + closeFlyout?: () => void; + createRepo?: (repoName: string, type: string, settings: CreateRepositorySettings) => void; + snapshotManagementService?: SnapshotManagementService; + repoError: string; +} + +const SnapshotIndicesRepoInput = ({ + indexOptions, + selectedIndexOptions, + onIndicesSelectionChange, + getIndexOptions, + onCreateOption, + repoOptions, + selectedRepoValue, + onRepoSelectionChange, + showFlyout, + openFlyout, + closeFlyout, + createRepo, + snapshotManagementService, + repoError, +}: SnapshotIndicesProps) => { + let createRepoFlyout; + if (snapshotManagementService != null && createRepo != null && closeFlyout != null) { + createRepoFlyout = ( + + ); + } + + return ( + <> + + + + + + + + + + + + + + {showFlyout != null && ( + + Create repository + + )} + + + {showFlyout && createRepoFlyout} + + ); +}; + +export default SnapshotIndicesRepoInput; diff --git a/public/pages/CreateSnapshotPolicy/components/SnapshotIndicesRepoInput/index.ts b/public/pages/CreateSnapshotPolicy/components/SnapshotIndicesRepoInput/index.ts new file mode 100644 index 000000000..89f2d88ec --- /dev/null +++ b/public/pages/CreateSnapshotPolicy/components/SnapshotIndicesRepoInput/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import SnapshotIndicesRepoInput from "./SnapshotIndicesRepoInput"; + +export default SnapshotIndicesRepoInput; diff --git a/public/pages/CreateSnapshotPolicy/constants.ts b/public/pages/CreateSnapshotPolicy/constants.ts new file mode 100644 index 000000000..c808ad004 --- /dev/null +++ b/public/pages/CreateSnapshotPolicy/constants.ts @@ -0,0 +1,43 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SMPolicy } from "../../../models/interfaces"; + +/** + * Every time Component init we want to give a different default object + */ +export const getDefaultSMPolicy = (): SMPolicy => ({ + name: "", + description: "", + enabled: true, + creation: { + schedule: { + cron: { + expression: "0 20 * * *", + timezone: "America/Los_Angeles", + }, + }, + }, + snapshot_config: { + repository: "", + // ignore_unavailable: false, + // include_global_state: false, + // partial: false, + // date_expression: "yyyy-MM-dd-HH:mm", + }, +}); + +export const maxAgeUnitOptions = [ + { value: "d", text: "Days" }, + { value: "h", text: "Hours" }, +]; + +export const DEFAULT_INDEX_OPTIONS = [{ label: "*" }, { label: "-.opendistro_security" }]; + +export const ERROR_PROMPT = { + NAME: "Name must be provided.", + REPO: "Repository must be provided.", + TIMEZONE: "Time zone must be provided.", +}; diff --git a/public/pages/CreateSnapshotPolicy/containers/CreateSnapshotPolicy/CreateSnapshotPolicy.tsx b/public/pages/CreateSnapshotPolicy/containers/CreateSnapshotPolicy/CreateSnapshotPolicy.tsx new file mode 100644 index 000000000..fba981fdb --- /dev/null +++ b/public/pages/CreateSnapshotPolicy/containers/CreateSnapshotPolicy/CreateSnapshotPolicy.tsx @@ -0,0 +1,858 @@ +/* + * 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, + EuiComboBox, +} from "@elastic/eui"; +import moment from "moment-timezone"; +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 SnapshotIndicesRepoInput from "../../components/SnapshotIndicesRepoInput/SnapshotIndicesRepoInput"; +import CronSchedule from "../../components/CronSchedule/CronSchedule"; +import SnapshotAdvancedSettings from "../../components/SnapshotAdvancedSettings/SnapshotAdvancedSettings"; +import CustomLabel from "../../../../components/CustomLabel"; +import ChannelNotification from "../../../VisualCreatePolicy/components/ChannelNotification"; +import { DEFAULT_INDEX_OPTIONS, ERROR_PROMPT, getDefaultSMPolicy, maxAgeUnitOptions as MAX_AGE_UNIT_OPTIONS } from "../../constants"; +import { + getIncludeGlobalState, + getIgnoreUnavailabel, + getAllowPartial, + getNotifyCreation, + getNotifyDeletion, + getNotifyFailure, +} from "../helper"; +import { parseCronExpression } from "../../components/CronSchedule/helper"; +import { TIMEZONES } from "../../components/CronSchedule/constants"; + +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; + + 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(); + } + + getPolicy = async (policyId: string): Promise => { + try { + const { snapshotManagementService } = this.props; + const response = await snapshotManagementService.getPolicy(policyId); + + if (response.ok) { + this.populatePolicyToState(response.response.policy); + + 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", ""); + + // TODO SM parse frequency type + const { frequencyType: creationScheduleFrequencyType } = parseCronExpression(_.get(policy, "creation.schedule.cron.expression")); + const { frequencyType: deletionScheduleFrequencyType } = parseCronExpression(_.get(policy, "deletion.schedule.cron.expression")); + + this.setState({ + policy, + policyId: response.response.id, + policySeqNo: response.response.seqNo, + policyPrimaryTerm: response.response.primaryTerm, + selectedIndexOptions, + selectedRepoValue, + creationScheduleFrequencyType, + deletionScheduleFrequencyType, + }); + } 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); + } + } catch (err) { + this.context.notifications.toasts.addDanger("Invalid Policy"); + console.error(err); + this.setState({ isSubmitting: false }); + } + 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; + } + + 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 => { + console.log(`sm dev get channels`); + 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 snapshotconfig ${JSON.stringify(this.state.policy.snapshot_config)}`); + + 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 notifyOnCreation = getNotifyCreation(policy); + const notifyOnDeletion = getNotifyDeletion(policy); + const notifyOnFailure = getNotifyFailure(policy); + + let showNotificationChannel = false; + if (notifyOnCreation || notifyOnDeletion || notifyOnFailure) { + showNotificationChannel = true; + } + + return ( +
+ +

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

+
+ {subTitleText} + + + + + + + + + + + + + + + + + + + + + { + this.setState({ showCreateRepoFlyout: true }); + }} + closeFlyout={() => { + this.setState({ showCreateRepoFlyout: false }); + }} + createRepo={this.createRepo} + snapshotManagementService={this.props.snapshotManagementService} + repoError={repoError} + /> + + + + + + { + this.setState({ creationScheduleFrequencyType: e.target.value }); + }} + 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 schedule + + Delete snapshots that are outside the retention period + + + + { + 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 && ( + <> + + + +
+ +

Snapshot naming settings

+
+ Customize the naming format of snapshots. + + + + + { + this.setState({ policy: this.setPolicyHelper("snapshot_config.date_format", e.target.value) }); + }} + /> + + + + + `${tz} (${moment.tz(tz).format("Z")})`} + selectedOptions={[{ label: _.get(policy, "snapshot_config.date_format_timezone") ?? "" }]} + onChange={(options) => { + const timezone = _.first(options)?.label; + this.setState({ policy: this.setPolicyHelper("snapshot_config.date_format_timezone", timezone) }); + }} + /> +
+ + )} +
+ + + + + + Cancel + + + + + {isEdit ? "Update" : "Create"} + + + +
+ ); + } + + populatePolicyToState = (policy: SMPolicy) => { + const maxAge = policy.deletion?.condition?.max_age; + if (maxAge) { + this.setState({ + maxAgeNum: parseInt(maxAge.substring(0, maxAge.length - 1)), + maxAgeUnit: maxAge[maxAge.length - 1], + }); + } + }; + + 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); + }; +} diff --git a/public/pages/CreateSnapshotPolicy/containers/CreateSnapshotPolicy/index.ts b/public/pages/CreateSnapshotPolicy/containers/CreateSnapshotPolicy/index.ts new file mode 100644 index 000000000..78c872cde --- /dev/null +++ b/public/pages/CreateSnapshotPolicy/containers/CreateSnapshotPolicy/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import CreateSnapshotPolicy from "./CreateSnapshotPolicy"; + +export default CreateSnapshotPolicy; diff --git a/public/pages/CreateSnapshotPolicy/containers/helper.ts b/public/pages/CreateSnapshotPolicy/containers/helper.ts new file mode 100644 index 000000000..af0f7650c --- /dev/null +++ b/public/pages/CreateSnapshotPolicy/containers/helper.ts @@ -0,0 +1,30 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import _ from "lodash"; +import { SMPolicy } from "../../../../models/interfaces"; + +export const getIncludeGlobalState = (policy: SMPolicy) => { + return String(_.get(policy, "snapshot_config.include_global_state", false)) == "true"; +}; + +export const getIgnoreUnavailabel = (policy: SMPolicy) => { + return String(_.get(policy, "snapshot_config.ignore_unavailable", false)) == "true"; +}; + +export const getAllowPartial = (policy: SMPolicy) => { + return String(_.get(policy, "snapshot_config.partial", false)) == "true"; +}; + +export const getNotifyCreation = (policy: SMPolicy) => { + return String(_.get(policy, "notification.conditions.creation", false)) == "true"; +}; + +export const getNotifyDeletion = (policy: SMPolicy) => { + return String(_.get(policy, "notification.conditions.deletion", false)) == "true"; +}; + +export const getNotifyFailure = (policy: SMPolicy) => { + return String(_.get(policy, "notification.conditions.failure", false)) == "true"; +}; diff --git a/public/pages/CreateSnapshotPolicy/index.ts b/public/pages/CreateSnapshotPolicy/index.ts new file mode 100644 index 000000000..1f84801b9 --- /dev/null +++ b/public/pages/CreateSnapshotPolicy/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import CreateSnapshotPolicy from "./containers/CreateSnapshotPolicy"; + +export default CreateSnapshotPolicy; diff --git a/public/pages/CreateTransform/containers/CreateTransformForm/CreateTransformForm.tsx b/public/pages/CreateTransform/containers/CreateTransformForm/CreateTransformForm.tsx index 39dd6cb6c..4a5df05c3 100644 --- a/public/pages/CreateTransform/containers/CreateTransformForm/CreateTransformForm.tsx +++ b/public/pages/CreateTransform/containers/CreateTransformForm/CreateTransformForm.tsx @@ -32,7 +32,6 @@ interface CreateTransformFormProps extends RouteComponentProps { rollupService: RollupService; transformService: TransformService; indexService: IndexService; - beenWarned: boolean; } interface CreateTransformFormState { diff --git a/public/pages/Main/Main.tsx b/public/pages/Main/Main.tsx index 5a3091295..d8a111ee7 100644 --- a/public/pages/Main/Main.tsx +++ b/public/pages/Main/Main.tsx @@ -28,6 +28,11 @@ import RollupDetails from "../RollupDetails/containers/RollupDetails"; import { EditTransform, Transforms } from "../Transforms"; import TransformDetails from "../Transforms/containers/Transforms/TransformDetails"; import queryString from "query-string"; +import CreateSnapshotPolicy from "../CreateSnapshotPolicy"; +import Repositories from "../Repositories"; +import SnapshotPolicies from "../SnapshotPolicies"; +import SnapshotPolicyDetails from "../SnapshotPolicyDetails"; +import Snapshots from "../Snapshots"; enum Navigation { IndexManagement = "Index Management", @@ -36,6 +41,10 @@ enum Navigation { Indices = "Indices", Rollups = "Rollup Jobs", Transforms = "Transform Jobs", + SnapshotManagement = "Snapshot Management", + Snapshots = "Snapshots", + SnapshotPolicies = "Snapshot Policies", + Repositories = "Repositories", } enum Pathname { @@ -44,6 +53,9 @@ enum Pathname { Indices = "/indices", Rollups = "/rollups", Transforms = "/transforms", + Snapshots = "/snapshots", + SnapshotPolicies = "/snapshot-policies", + Repositories = "/repositories", } const HIDDEN_NAV_ROUTES = [ @@ -57,6 +69,9 @@ const HIDDEN_NAV_ROUTES = [ ROUTES.EDIT_POLICY, ROUTES.POLICY_DETAILS, ROUTES.CHANGE_POLICY, + ROUTES.SNAPSHOT_POLICY_DETAILS, + ROUTES.CREATE_SNAPSHOT_POLICY, + ROUTES.EDIT_SNAPSHOT_POLICY, ]; interface MainProps extends RouteComponentProps {} @@ -104,6 +119,31 @@ export default class Main extends Component { }, ], }, + { + name: Navigation.SnapshotManagement, + id: 1, + href: `#${Pathname.SnapshotPolicies}`, + items: [ + { + name: Navigation.SnapshotPolicies, + id: 1, + href: `#${Pathname.SnapshotPolicies}`, + isSelected: pathname === Pathname.SnapshotPolicies, + }, + { + name: Navigation.Snapshots, + id: 2, + href: `#${Pathname.Snapshots}`, + isSelected: pathname === Pathname.Snapshots, + }, + { + name: Navigation.Repositories, + id: 3, + href: `#${Pathname.Repositories}`, + isSelected: pathname === Pathname.Repositories, + }, + ], + }, ]; return ( @@ -117,12 +157,64 @@ export default class Main extends Component { {/*Hide side navigation bar when creating or editing rollup job*/} {!HIDDEN_NAV_ROUTES.includes(pathname) && ( - - + + )} + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> ( diff --git a/public/pages/Repositories/components/CreateRepositoryFlyout/CreateRepositoryFlyout.tsx b/public/pages/Repositories/components/CreateRepositoryFlyout/CreateRepositoryFlyout.tsx new file mode 100644 index 000000000..bdc680295 --- /dev/null +++ b/public/pages/Repositories/components/CreateRepositoryFlyout/CreateRepositoryFlyout.tsx @@ -0,0 +1,271 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiAccordion, + EuiCodeEditor, + EuiComboBox, + EuiComboBoxOptionOption, + EuiFieldText, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiFormRow, + EuiLink, + EuiSelect, + EuiSpacer, + EuiText, + EuiTitle, +} from "@elastic/eui"; +import _ from "lodash"; +import { CreateRepositorySettings } from "../../../../../server/models/interfaces"; +import React, { Component } from "react"; +import FlyoutFooter from "../../../VisualCreatePolicy/components/FlyoutFooter"; +import { CoreServicesContext } from "../../../../components/core_services"; +import { SnapshotManagementService } from "../../../../services"; +import { getErrorMessage } from "../../../../utils/helpers"; +import CustomLabel from "../../../../components/CustomLabel"; +import { CUSTOM_CONFIGURATION, FS_ADVANCED_SETTINGS, REPO_SELECT_OPTIONS, REPO_TYPES } from "./constants"; +import { + FS_REPOSITORY_DOCUMENTATION_URL, + REPOSITORY_DOCUMENTATION_URL, + S3_REPOSITORY_DOCUMENTATION_URL, + SNAPSHOT_MANAGEMENT_DOCUMENTATION_URL, +} from "../../../../utils/constants"; + +interface CreateRepositoryProps { + service: SnapshotManagementService; + editRepo: string | null; + onCloseFlyout: () => void; + createRepo: (repoName: string, repoType: string, settings: CreateRepositorySettings) => void; +} + +interface CreateRepositoryState { + repoName: string; + location: string; + // repoTypeOptions: EuiComboBoxOptionOption[]; + // selectedRepoTypeOption: EuiComboBoxOptionOption[]; + + selectedRepoTypeOption: string; + fsSettingsJsonString: string; + customSettingsJsonString: string; + + repoNameError: string; + repoTypeError: string; + locationError: string; +} + +export default class CreateRepositoryFlyout extends Component { + static contextType = CoreServicesContext; + + constructor(props: CreateRepositoryProps) { + super(props); + + this.state = { + repoName: "", + location: "", + // repoTypeOptions: [], + selectedRepoTypeOption: REPO_SELECT_OPTIONS[0].value as string, + fsSettingsJsonString: JSON.stringify(FS_ADVANCED_SETTINGS, null, 4), + customSettingsJsonString: JSON.stringify(CUSTOM_CONFIGURATION, null, 4), + repoNameError: "", + repoTypeError: "", + locationError: "", + }; + } + + async componentDidMount() { + const { editRepo } = this.props; + if (!!editRepo) { + await this.getRepo(editRepo); + } + } + + getRepo = async (repoName: string) => { + const { service } = this.props; + try { + const response = await service.getRepository(repoName); + if (response.ok) { + const repoName = Object.keys(response.response)[0]; + const repoBody = response.response[repoName]; + const type = repoBody.type; + const settings = repoBody.settings; + const location = settings.location; + delete settings.location; + const settingsJsonString = JSON.stringify(settings); + this.setState({ repoName, location }); + } else { + this.context.notifications.toasts.addDanger(response.error); + } + } catch (err) { + this.context.notifications.toasts.addDanger(getErrorMessage(err, "There was a problem loading the editing repository.")); + } + }; + + onClickAction = () => { + const { createRepo } = this.props; + const { repoName, selectedRepoTypeOption, location, fsSettingsJsonString, customSettingsJsonString } = this.state; + + if (!repoName.trim()) { + this.setState({ repoNameError: "Required." }); + return; + } + if (!location.trim()) { + this.setState({ location: "Required." }); + return; + } + if (selectedRepoTypeOption == "fs") { + let settings; + try { + settings = JSON.parse(fsSettingsJsonString); + settings.location = location; + } catch (err) { + this.context.notifications.toasts.addDanger("Invalid Policy JSON"); + } + createRepo(repoName, selectedRepoTypeOption, settings); + } else if (selectedRepoTypeOption == "custom") { + let repoType; + let settings; + try { + const parsed = JSON.parse(customSettingsJsonString); + repoType = parsed.type; + settings = parsed.settings; + createRepo(repoName, repoType, settings); + } catch (err) { + this.context.notifications.toasts.addDanger("Invalid Policy JSON"); + } + } + }; + + render() { + const { editRepo, onCloseFlyout } = this.props; + const { + repoName, + location, + selectedRepoTypeOption, + fsSettingsJsonString, + customSettingsJsonString, + repoNameError, + repoTypeError, + locationError, + } = this.state; + + let configuration; + if (selectedRepoTypeOption == "fs") { + configuration = ( + <> + + + this.setState({ location: e.target.value })} + /> + + + + + + + + +

+ Define additional settings for this repository.{" "} + + Learn more + +

+
+ { + this.setState({ fsSettingsJsonString: str }); + }} + setOptions={{ fontSize: "14px" }} + /> +
+ + ); + } + if (selectedRepoTypeOption == "custom") { + configuration = ( + <> + +

+ Define a repository by custom type and settings.{" "} + + View sample configurations + +

+
+ { + this.setState({ customSettingsJsonString: str }); + }} + setOptions={{ fontSize: "14px" }} + /> + + ); + } + + const repoTypeHelpText = ( + +

+ Select a supported repository type. For additional types, install the latest repository plugins and choose Custom configuration.{" "} + + Learn more + +

+
+ ); + + return ( + + + +

{!!editRepo ? "Edit" : "Create"} repository

+
+
+ + + + + this.setState({ repoName: e.target.value })} /> + + + + + + + this.setState({ selectedRepoTypeOption: e.target.value })} + /> + + + + + {configuration} + + + + + + + +
+ ); + } +} diff --git a/public/pages/Repositories/components/CreateRepositoryFlyout/constants.ts b/public/pages/Repositories/components/CreateRepositoryFlyout/constants.ts new file mode 100644 index 000000000..6b4befcd7 --- /dev/null +++ b/public/pages/Repositories/components/CreateRepositoryFlyout/constants.ts @@ -0,0 +1,41 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiSelectOption } from "@elastic/eui"; + +// "fs", "url", "repository-s3", "repository-hdfs", "repository-azure", "repository-gcs" +export const REPO_TYPES = [ + { + label: "File system (fs)", + value: "fs", + }, +]; + +export const REPO_SELECT_OPTIONS: EuiSelectOption[] = [ + { + text: "Shared file system", + value: "fs", + }, + { + text: "Custom configuration", + value: "custom", + }, +]; + +export const FS_ADVANCED_SETTINGS = { + chunk_size: null, + compress: false, + max_restore_bytes_per_sec: "40m", + max_snapshot_bytes_per_sec: "40m", + readonly: false, +}; + +export const CUSTOM_CONFIGURATION = { + type: "s3", + settings: { + bucket: "", + base_path: "", + }, +}; diff --git a/public/pages/Repositories/components/CreateRepositoryFlyout/index.ts b/public/pages/Repositories/components/CreateRepositoryFlyout/index.ts new file mode 100644 index 000000000..8c17bfd97 --- /dev/null +++ b/public/pages/Repositories/components/CreateRepositoryFlyout/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import CreateRepositoryFlyout from "./CreateRepositoryFlyout"; + +export default CreateRepositoryFlyout; diff --git a/public/pages/Repositories/components/DeleteModal/DeleteModal.tsx b/public/pages/Repositories/components/DeleteModal/DeleteModal.tsx new file mode 100644 index 000000000..d8a6a7f9b --- /dev/null +++ b/public/pages/Repositories/components/DeleteModal/DeleteModal.tsx @@ -0,0 +1,71 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { ChangeEvent, Component } from "react"; +import { EuiConfirmModal, EuiFieldText, EuiForm, EuiFormRow, EuiOverlayMask, EuiSpacer } from "@elastic/eui"; + +interface DeleteModalProps { + closeDeleteModal: (event?: any) => void; + onClickDelete: (event?: any) => void; + type: string; + ids: string; + addtionalWarning?: string; + confirmation?: boolean; +} + +interface DeleteModalState { + confirmDeleteText: string; +} + +export default class DeleteModal extends Component { + constructor(props: DeleteModalProps) { + super(props); + + let confirmDeleteText = "delete"; + if (props.confirmation) confirmDeleteText = ""; + this.state = { + confirmDeleteText, + }; + } + + onChange = (e: ChangeEvent): void => { + this.setState({ confirmDeleteText: e.target.value }); + }; + + render() { + const { type, ids, closeDeleteModal, onClickDelete, addtionalWarning, confirmation } = this.props; + const { confirmDeleteText } = this.state; + + return ( + + { + onClickDelete(); + closeDeleteModal(); + }} + cancelButtonText="Cancel" + confirmButtonText={`Delete ${type}`} + buttonColor="danger" + defaultFocusedButton="confirm" + confirmButtonDisabled={confirmDeleteText != "delete"} + > + +

+ Delete "{ids}" permanently? {addtionalWarning} +

+ + {!!confirmation && ( + + + + )} +
+
+
+ ); + } +} diff --git a/public/pages/Repositories/components/DeleteModal/index.ts b/public/pages/Repositories/components/DeleteModal/index.ts new file mode 100644 index 000000000..979fbaad3 --- /dev/null +++ b/public/pages/Repositories/components/DeleteModal/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import DeleteModal from "./DeleteModal"; +export default DeleteModal; diff --git a/public/pages/Repositories/containers/Repositories/Repositories.tsx b/public/pages/Repositories/containers/Repositories/Repositories.tsx new file mode 100644 index 000000000..9acf4fb5c --- /dev/null +++ b/public/pages/Repositories/containers/Repositories/Repositories.tsx @@ -0,0 +1,317 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { + EuiButton, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiInMemoryTable, + EuiPopover, + EuiTableFieldDataColumnType, + EuiText, + EuiTextColor, +} from "@elastic/eui"; +import { getErrorMessage } from "../../../../utils/helpers"; +import React, { Component } from "react"; +import { RouteComponentProps } from "react-router-dom"; +import { CatRepository, CreateRepositoryBody, CreateRepositorySettings } from "../../../../../server/models/interfaces"; +import { CoreServicesContext } from "../../../../components/core_services"; +import { SnapshotManagementService } from "../../../../services"; +import { ContentPanel } from "../../../../components/ContentPanel"; +import CreateRepositoryFlyout from "../../components/CreateRepositoryFlyout"; +import { FieldValueSelectionFilterConfigType } from "@elastic/eui/src/components/search_bar/filters/field_value_selection_filter"; +import { BREADCRUMBS } from "../../../../utils/constants"; +import DeleteModal from "../../components/DeleteModal"; + +interface RepositoriesProps extends RouteComponentProps { + snapshotManagementService: SnapshotManagementService; +} + +interface RepositoriesState { + repositories: CatRepository[]; + loading: boolean; + selectedItems: CatRepository[]; + + showFlyout: boolean; + + isPopoverOpen: boolean; + + editRepo: string | null; + + isDeleteModalVisible: boolean; +} + +export default class Repositories extends Component { + static contextType = CoreServicesContext; + columns: EuiTableFieldDataColumnType[]; + + constructor(props: RepositoriesProps) { + super(props); + + this.state = { + repositories: [], + loading: false, + selectedItems: [], + showFlyout: false, + isPopoverOpen: false, + editRepo: null, + isDeleteModalVisible: false, + }; + + this.columns = [ + { + field: "id", + name: "Repository name", + sortable: true, + dataType: "string", + width: "15%", + align: "left", + }, + { + field: "type", + name: "Type", + sortable: true, + dataType: "string", + width: "10%", + }, + { + field: "snapshotCount", + name: "Snapshot count", + sortable: true, + width: "80%", + }, + ]; + } + + async componentDidMount() { + this.context.chrome.setBreadcrumbs([BREADCRUMBS.SNAPSHOT_MANAGEMENT, BREADCRUMBS.REPOSITORIES]); + await this.getRepos(); + } + + getRepos = async () => { + this.setState({ loading: true }); + + try { + const { snapshotManagementService } = this.props; + const response = await snapshotManagementService.catRepositories(); + if (response.ok) { + this.setState({ loading: false }); + this.setState({ repositories: response.response }); + } else { + this.setState({ loading: false }); + this.context.notifications.toasts.addDanger(response.error); + } + } catch (err) { + this.setState({ loading: false }); + this.context.notifications.toasts.addDanger(getErrorMessage(err, "There was a problem loading the repositories.")); + } + }; + + createRepo = async (repoName: string, repoType: string, settings: CreateRepositorySettings) => { + try { + const { snapshotManagementService } = this.props; + + const createRepoBody: CreateRepositoryBody = { + type: repoType, + settings: settings, + }; + const response = await snapshotManagementService.createRepository(repoName, createRepoBody); + if (response.ok) { + this.setState({ showFlyout: false }); + let toastMsgPre = "Created"; + if (!!this.state.editRepo) { + toastMsgPre = "Edited"; + } + this.context.notifications.toasts.addSuccess(`${toastMsgPre} 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.")); + } + }; + + deleteRepo = async (repoName: string) => { + try { + const { snapshotManagementService } = this.props; + const response = await snapshotManagementService.deleteRepository(repoName); + if (response.ok) { + this.context.notifications.toasts.addSuccess(`Deleted repository ${repoName}.`); + } else { + this.context.notifications.toasts.addDanger(response.error); + } + } catch (err) { + this.context.notifications.toasts.addDanger(getErrorMessage(err, "There was a problem deleting the repository.")); + } + }; + + onClickDelete = async () => { + const { selectedItems } = this.state; + for (let item of selectedItems) { + const repoName = item.id; + await this.deleteRepo(repoName); + } + await this.getRepos(); + }; + + onClickCreate = () => { + this.setState({ showFlyout: true, editRepo: null }); + }; + + onClickEdit = () => { + const { + selectedItems: [{ id }], + } = this.state; + this.setState({ showFlyout: true, editRepo: id }); + }; + + closePopover = () => { + this.setState({ isPopoverOpen: false }); + }; + + render() { + const { repositories, loading, selectedItems, showFlyout, isPopoverOpen, editRepo, isDeleteModalVisible } = this.state; + + const popoverActionItems = [ + { + this.closePopover(); + this.onClickEdit(); + }} + > + Edit + , + { + this.closePopover(); + this.showDeleteModal(); + }} + > + Delete + , + ]; + const popoverButton = ( + { + this.setState({ isPopoverOpen: !this.state.isPopoverOpen }); + }} + data-test-subj="actionButton" + > + Actions + + ); + const actions = [ + + Refresh + , + + Delete + , + + Create repository + , + ]; + + const search = { + box: { + placeholder: "Search repository", + }, + filters: [ + { + type: "field_value_selection", + field: "type", + name: "Type", + options: repositories.map((repo) => ({ value: repo.type })), + multiSelect: "or", + } as FieldValueSelectionFilterConfigType, + ], + }; + + const subTitleText = ( + +

Repositories are remote storage locations used to store snapshots.

+
+ ); + + let additionalWarning = `You have ${this.getSelectedSnapshotCounts()} snapshots`; + if (selectedItems.length > 1) { + additionalWarning += " in these repositories respectively."; + } else { + additionalWarning += " in the repository."; + } + return ( + <> + + this.setState({ selectedItems }) }} + search={search} + loading={loading} + /> + + + {showFlyout && ( + { + this.setState({ showFlyout: false }); + }} + /> + )} + + {isDeleteModalVisible && ( + + )} + + ); + } + + showDeleteModal = () => { + this.setState({ isDeleteModalVisible: true }); + }; + closeDeleteModal = () => { + this.setState({ isDeleteModalVisible: false }); + }; + + getSelectedIds = () => { + return this.state.selectedItems + .map((item: CatRepository) => { + return item.id; + }) + .join(", "); + }; + + getSelectedSnapshotCounts = () => { + return this.state.selectedItems + .map((item: CatRepository) => { + return item.snapshotCount; + }) + .join(", "); + }; +} diff --git a/public/pages/Repositories/containers/Repositories/index.ts b/public/pages/Repositories/containers/Repositories/index.ts new file mode 100644 index 000000000..75d816732 --- /dev/null +++ b/public/pages/Repositories/containers/Repositories/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import RollupDetails from "./Repositories"; + +export default RollupDetails; diff --git a/public/pages/Repositories/index.ts b/public/pages/Repositories/index.ts new file mode 100644 index 000000000..400de99a8 --- /dev/null +++ b/public/pages/Repositories/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import Repositories from "./containers/Repositories"; + +export default Repositories; diff --git a/public/pages/SnapshotPolicies/constants.tsx b/public/pages/SnapshotPolicies/constants.tsx new file mode 100644 index 000000000..bd0f147bc --- /dev/null +++ b/public/pages/SnapshotPolicies/constants.tsx @@ -0,0 +1,20 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SortDirection } from "../../utils/constants"; + +export const DEFAULT_PAGE_SIZE_OPTIONS = [5, 10, 20, 50]; + +export const DEFAULT_QUERY_PARAMS = { + from: 0, + size: 20, + sortOrder: SortDirection.DESC, + search: "", +}; + +export const PROMPT_TEXT = { + NO_POLICIES: "There are no existing policies.", + LOADING: "Loading policies...", +}; diff --git a/public/pages/SnapshotPolicies/containers/SnapshotPolicies/SnapshotPolicies.tsx b/public/pages/SnapshotPolicies/containers/SnapshotPolicies/SnapshotPolicies.tsx new file mode 100644 index 000000000..a3cc31cb6 --- /dev/null +++ b/public/pages/SnapshotPolicies/containers/SnapshotPolicies/SnapshotPolicies.tsx @@ -0,0 +1,470 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Component } from "react"; +import _ from "lodash"; +import queryString from "query-string"; +import { RouteComponentProps } from "react-router-dom"; +import { CoreServicesContext } from "../../../../components/core_services"; +import { + Criteria, + Direction, + EuiBasicTable, + EuiButton, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiEmptyPrompt, + EuiHealth, + EuiLink, + EuiPopover, + EuiSearchBar, + EuiTableFieldDataColumnType, + EuiTableSelectionType, + EuiTableSortingType, + EuiText, + EuiTextColor, + Pagination, + Query, +} from "@elastic/eui"; +import { BREADCRUMBS, ROUTES, SNAPSHOT_MANAGEMENT_DOCUMENTATION_URL } from "../../../../utils/constants"; +import { getMessagePrompt, getSMPoliciesQueryParamsFromURL, renderTimestampMillis } from "../../helpers"; +import { SMPolicy } from "../../../../../models/interfaces"; +import { SnapshotManagementService } from "../../../../services"; +import { getErrorMessage } from "../../../../utils/helpers"; +import { DEFAULT_PAGE_SIZE_OPTIONS } from "../../constants"; +import { ContentPanel } from "../../../../components/ContentPanel"; +import DeleteModal from "../../../PolicyDetails/components/DeleteModal"; +import { OnSearchChangeArgs } from "../../../../models/interfaces"; +import { humanCronExpression, parseCronExpression } from "../../../CreateSnapshotPolicy/components/CronSchedule/helper"; +import { truncateSpan } from "../../../Snapshots/helper"; + +interface SnapshotPoliciesProps extends RouteComponentProps { + snapshotManagementService: SnapshotManagementService; +} + +interface SnapshotPoliciesState { + policies: SMPolicy[]; + totalPolicies: number; + loadingPolicies: boolean; + + query: Query | null; + queryString: string; + + from: number; + size: number; + sortOrder: Direction; + sortField: keyof SMPolicy; + + selectedItems: SMPolicy[]; + + showFlyout: boolean; + policyClicked: SMPolicy | null; + + isPopoverOpen: boolean; + isDeleteModalVisible: boolean; +} + +export default class SnapshotPolicies extends Component { + static contextType = CoreServicesContext; + columns: EuiTableFieldDataColumnType[]; + + constructor(props: SnapshotPoliciesProps) { + super(props); + + const { from, size, sortField, sortOrder } = getSMPoliciesQueryParamsFromURL(this.props.location); + this.state = { + policies: [], + totalPolicies: 0, + loadingPolicies: false, + query: null, + queryString: "", + from: from, + size: size, + sortField: sortField, + sortOrder: sortOrder, + selectedItems: [], + showFlyout: false, + policyClicked: null, + isPopoverOpen: false, + isDeleteModalVisible: false, + }; + + this.columns = [ + { + field: "name", + name: "Policy name", + sortable: true, + dataType: "string", + width: "180px", + render: (name: string, item: SMPolicy) => { + const showSymbol = _.truncate(name, { length: 20 }); + return ( + this.props.history.push(`${ROUTES.SNAPSHOT_POLICY_DETAILS}?id=${name}`)}> + {showSymbol} + + ); + }, + }, + { + field: "enabled", + name: "Status", + sortable: true, + dataType: "boolean", + width: "100px", + render: (name: string, item: SMPolicy) => { + if (item.enabled) { + return Enabled; + } else { + return Disabled; + } + }, + }, + { + field: "snapshot_config.indices", + name: "Indices", + sortable: false, + dataType: "string", + render: (value: string, item: SMPolicy) => { + return truncateSpan(value); + }, + }, + { + field: "creation.schedule.cron.expression", + name: "Snapshot schedule", + sortable: false, + dataType: "string", + render: (name: string, item: SMPolicy) => { + const expression = name; + const timezone = item.creation.schedule.cron.timezone; + return `${humanCronExpression(parseCronExpression(expression), expression, timezone)}`; + }, + }, + { + field: "last_updated_time", + name: "Time last updated", + sortable: true, + dataType: "date", + render: renderTimestampMillis, + }, + { + field: "description", + name: "Description", + sortable: false, + dataType: "string", + render: (value: string, item: SMPolicy) => { + return truncateSpan(value); + }, + }, + ]; + } + + async componentDidMount() { + this.context.chrome.setBreadcrumbs([BREADCRUMBS.SNAPSHOT_MANAGEMENT, BREADCRUMBS.SNAPSHOT_POLICIES]); + await this.getPolicies(); + } + + async componentDidUpdate(prevProps: SnapshotPoliciesProps, prevState: SnapshotPoliciesState) { + const prevQuery = SnapshotPolicies.getQueryObjectFromState(prevState); + const currQuery = SnapshotPolicies.getQueryObjectFromState(this.state); + if (!_.isEqual(prevQuery, currQuery)) { + await this.getPolicies(); + } + } + + getPolicies = async () => { + this.setState({ loadingPolicies: true }); + try { + const { snapshotManagementService, history } = this.props; + const queryParamsObject = SnapshotPolicies.getQueryObjectFromState(this.state); + const queryParamsString = queryString.stringify(queryParamsObject); + history.replace({ ...this.props.location, search: queryParamsString }); + + const response = await snapshotManagementService.getPolicies({ ...queryParamsObject }); + if (response.ok) { + const { policies, totalPolicies } = response.response; + this.setState({ policies: policies.map((p) => p.policy), totalPolicies }); + } else { + this.context.notifications.toasts.addDanger(response.error); + } + } catch (err) { + this.context.notifications.toasts.addDanger(getErrorMessage(err, "There was a problem loading the snapshot policies.")); + } + this.setState({ loadingPolicies: false }); + }; + + static getQueryObjectFromState({ from, size, sortField, sortOrder, queryString }: SnapshotPoliciesState) { + return { from, size, sortField, sortOrder, queryString }; + } + + onSelectionChange = (selectedItems: SMPolicy[]): void => { + this.setState({ selectedItems }); + }; + + onTableChange = (criteria: Criteria): void => { + const { from: prevFrom, size: prevSize, sortField, sortOrder } = this.state; + const { page: { index, size } = {}, sort: { field, direction } = {} } = criteria; + + // index could be 0, so need to explicitly check if it's undefined + const from = index !== undefined ? (size ? index * size : prevFrom) : prevFrom; + this.setState({ + from: from, + size: size ?? prevSize, + sortField: field ?? sortField, + sortOrder: direction ?? sortOrder, + }); + }; + + onClickCreate = () => { + this.props.history.push(ROUTES.CREATE_SNAPSHOT_POLICY); + }; + + onSearchChange = ({ query, queryText, error }: OnSearchChangeArgs) => { + if (error) { + return; + } + + this.setState({ from: 0, queryString: queryText, query }); + }; + + closePopover = () => { + this.setState({ isPopoverOpen: false }); + }; + + onClickEdit = () => { + const { + selectedItems: [{ name }], + } = this.state; + if (name) this.props.history.push(`${ROUTES.EDIT_SNAPSHOT_POLICY}?id=${name}`); + }; + + showDeleteModal = () => { + this.setState({ isDeleteModalVisible: true }); + }; + + closeDeleteModal = () => { + this.setState({ isDeleteModalVisible: false }); + }; + + onActionButtonClick = () => { + this.setState({ isPopoverOpen: !this.state.isPopoverOpen }); + }; + + onClickDelete = async () => { + const { snapshotManagementService } = this.props; + const { selectedItems } = this.state; + for (let item of selectedItems) { + const policyId = item.name; + try { + const response = await snapshotManagementService.deletePolicy(policyId); + if (response.ok) { + this.context.notifications.toasts.addSuccess(`"${policyId}" successfully deleted!`); + } else { + this.context.notifications.toasts.addDanger(`Could not delete policy "${policyId}" : ${response.error}`); + } + } catch (err) { + this.context.notifications.toasts.addDanger(getErrorMessage(err, "Could not delete the policy")); + } + } + this.closeDeleteModal(); + await this.getPolicies(); + }; + + onClickStart = async () => { + const { snapshotManagementService } = this.props; + const { selectedItems } = this.state; + for (let item of selectedItems) { + const policyId = item.name; + try { + const response = await snapshotManagementService.startPolicy(policyId); + if (response.ok) { + this.context.notifications.toasts.addSuccess(`"${policyId}" successfully started!`); + } else { + this.context.notifications.toasts.addDanger(`Could not start policy "${policyId}" : ${response.error}`); + } + } catch (err) { + this.context.notifications.toasts.addDanger(getErrorMessage(err, "Could not start the policy")); + } + } + }; + + onClickStop = async () => { + const { snapshotManagementService } = this.props; + const { selectedItems } = this.state; + for (let item of selectedItems) { + const policyId = item.name; + try { + const response = await snapshotManagementService.stopPolicy(policyId); + if (response.ok) { + this.context.notifications.toasts.addSuccess(`"${policyId}" successfully stopped!`); + } else { + this.context.notifications.toasts.addDanger(`Could not stop policy "${policyId}" : ${response.error}`); + } + } catch (err) { + this.context.notifications.toasts.addDanger(getErrorMessage(err, "Could not stop the policy")); + } + } + }; + + getSelectedPolicyIds = () => { + return this.state.selectedItems + .map((item: SMPolicy) => { + return item.name; + }) + .join(", "); + }; + + render() { + const { + policies, + totalPolicies, + loadingPolicies, + from, + size, + sortField, + sortOrder, + selectedItems, + isPopoverOpen, + isDeleteModalVisible, + } = this.state; + + const page = Math.floor(from / size); + const pagination: Pagination = { + pageIndex: page, + pageSize: size, + pageSizeOptions: DEFAULT_PAGE_SIZE_OPTIONS, + totalItemCount: totalPolicies, + }; + + const sorting: EuiTableSortingType = { + sort: { + direction: sortOrder, + field: sortField, + }, + }; + + const selection: EuiTableSelectionType = { + onSelectionChange: this.onSelectionChange, + }; + + const popoverActionItems = [ + { + this.closePopover(); + this.onClickEdit(); + }} + > + Edit + , + { + this.closePopover(); + this.showDeleteModal(); + }} + > + Delete + , + ]; + const actionsButton = ( + + Actions + + ); + const actions = [ + + Refresh + , + + Disable + , + + Enable + , + + + , + + Create policy + , + ]; + + const subTitleText = ( + +

+ Define an automated snapshot schedule and retention period with a snapshot policy.{" "} + + Learn more + +

+
+ ); + + const promptMessage = ( + +

{getMessagePrompt(loadingPolicies)}

+ + } + actions={ + + Create policy + + } + /> + ); + + return ( + <> + + + + + + {isDeleteModalVisible && ( + + )} + + ); + } +} diff --git a/public/pages/SnapshotPolicies/containers/SnapshotPolicies/index.ts b/public/pages/SnapshotPolicies/containers/SnapshotPolicies/index.ts new file mode 100644 index 000000000..bd6da392e --- /dev/null +++ b/public/pages/SnapshotPolicies/containers/SnapshotPolicies/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import SnapshotPolicies from "./SnapshotPolicies"; + +export default SnapshotPolicies; diff --git a/public/pages/SnapshotPolicies/helpers.ts b/public/pages/SnapshotPolicies/helpers.ts new file mode 100644 index 000000000..218817f6b --- /dev/null +++ b/public/pages/SnapshotPolicies/helpers.ts @@ -0,0 +1,40 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import queryString from "query-string"; +import { SMPoliciesQueryParams } from "../../models/interfaces"; +import { DEFAULT_QUERY_PARAMS, PROMPT_TEXT } from "./constants"; +import moment from "moment"; + +export function getSMPoliciesQueryParamsFromURL(location: { search: string }): SMPoliciesQueryParams { + const { from, size, sortField, sortOrder, search } = queryString.parse(location.search); + return { + // @ts-ignore + from: isNaN(parseInt(from, 10)) ? DEFAULT_QUERY_PARAMS.from : parseInt(from, 10), + // @ts-ignore + size: isNaN(parseInt(size, 10)) ? DEFAULT_QUERY_PARAMS.size : parseInt(size, 10), + search: typeof search !== "string" ? DEFAULT_QUERY_PARAMS.search : search, + sortField: typeof sortField !== "string" ? "name" : sortField, + sortOrder: typeof sortOrder !== "string" ? DEFAULT_QUERY_PARAMS.sortOrder : sortOrder, + }; +} + +export const getMessagePrompt = (loading: boolean) => { + if (loading) return PROMPT_TEXT.LOADING; + return PROMPT_TEXT.NO_POLICIES; +}; + +export const renderTimestampMillis = (time?: number): string => { + if (time == null) return "-"; + const momentTime = moment(time).local(); + if (time && momentTime.isValid()) return momentTime.format("MM/DD/YY h:mm a"); + return "-"; +}; + +export const renderTimestampSecond = (time: number): string => { + const momentTime = moment.unix(time).local(); + if (time && momentTime.isValid()) return momentTime.format("MM/DD/YY h:mm a"); + return "-"; +}; diff --git a/public/pages/SnapshotPolicies/index.ts b/public/pages/SnapshotPolicies/index.ts new file mode 100644 index 000000000..47859f3c7 --- /dev/null +++ b/public/pages/SnapshotPolicies/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import SnapshotPolicies from "./containers/SnapshotPolicies"; + +export default SnapshotPolicies; diff --git a/public/pages/SnapshotPolicyDetails/components/InfoModal/InfoModal.tsx b/public/pages/SnapshotPolicyDetails/components/InfoModal/InfoModal.tsx new file mode 100644 index 000000000..9bda2208c --- /dev/null +++ b/public/pages/SnapshotPolicyDetails/components/InfoModal/InfoModal.tsx @@ -0,0 +1,45 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react"; +import { + EuiButton, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiOverlayMask, + EuiCodeBlock, +} from "@elastic/eui"; + +interface InfoModalProps { + info: object; + onClose: () => void; +} + +const InfoModal = ({ info, onClose }: InfoModalProps) => ( + + + + + + + + + {JSON.stringify(info, null, 4)} + + + + + + Close + + + + +); + +export default InfoModal; diff --git a/public/pages/SnapshotPolicyDetails/components/InfoModal/index.ts b/public/pages/SnapshotPolicyDetails/components/InfoModal/index.ts new file mode 100644 index 000000000..031031495 --- /dev/null +++ b/public/pages/SnapshotPolicyDetails/components/InfoModal/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import InfoModal from "./InfoModal"; + +export default InfoModal; diff --git a/public/pages/SnapshotPolicyDetails/containers/SnapshotPolicyDetails/SnapshotPolicyDetails.tsx b/public/pages/SnapshotPolicyDetails/containers/SnapshotPolicyDetails/SnapshotPolicyDetails.tsx new file mode 100644 index 000000000..53f667840 --- /dev/null +++ b/public/pages/SnapshotPolicyDetails/containers/SnapshotPolicyDetails/SnapshotPolicyDetails.tsx @@ -0,0 +1,417 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import _ from "lodash"; +import { RouteComponentProps } from "react-router-dom"; +import React, { Component } from "react"; +import queryString from "query-string"; +import { + EuiAccordion, + EuiBasicTable, + EuiButton, + EuiFlexGrid, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiLoadingSpinner, + EuiSpacer, + EuiTableFieldDataColumnType, + EuiText, + EuiTitle, +} from "@elastic/eui"; +import { SnapshotManagementService } from "../../../../services"; +import { SMMetadata, SMPolicy } from "../../../../../models/interfaces"; +import { CoreServicesContext } from "../../../../components/core_services"; +import { BREADCRUMBS, ROUTES } from "../../../../utils/constants"; +import { getErrorMessage } from "../../../../utils/helpers"; +import { ContentPanel } from "../../../../components/ContentPanel"; +import DeleteModal from "../../../PolicyDetails/components/DeleteModal"; +import { LatestActivities } from "../../../../models/interfaces"; +import { renderTimestampMillis } from "../../../SnapshotPolicies/helpers"; +import { humanCronExpression, parseCronExpression } from "../../../CreateSnapshotPolicy/components/CronSchedule/helper"; +import { ModalConsumer } from "../../../../components/Modal"; +import InfoModal from "../../components/InfoModal"; +import { getAllowPartial, getIgnoreUnavailabel, getIncludeGlobalState } from "../../../CreateSnapshotPolicy/containers/helper"; +import { truncateSpan } from "../../../Snapshots/helper"; + +interface SnapshotPolicyDetailsProps extends RouteComponentProps { + snapshotManagementService: SnapshotManagementService; +} + +interface SnapshotPolicyDetailsState { + policyId: string; + policy: SMPolicy | null; + + metadata: SMMetadata | null; + + isDeleteModalVisible: boolean; +} + +export default class SnapshotPolicyDetails extends Component { + static contextType = CoreServicesContext; + columns: EuiTableFieldDataColumnType[]; + + constructor(props: SnapshotPolicyDetailsProps) { + super(props); + + this.state = { + policyId: "", + policy: null, + metadata: null, + isDeleteModalVisible: false, + }; + + this.columns = [ + { + field: "activityType", + name: "Activity type", + dataType: "string", + }, + { + field: "status", + name: "Status", + dataType: "string", + }, + { + field: "start_time", + name: "Time started", + sortable: true, + dataType: "date", + render: renderTimestampMillis, + }, + { + field: "end_time", + name: "Time completed", + sortable: true, + dataType: "date", + render: renderTimestampMillis, + }, + { + field: "info", + name: "Info", + dataType: "auto", + render: (info: object) => { + const message = _.get(info, "message", null); + const cause = _.get(info, "cause", null); + let showSymbol = "-"; + if (!!message) showSymbol = "message"; + if (!!cause) showSymbol = "cause"; + return ( + {({ onShow }) => onShow(InfoModal, { info })}>{showSymbol}} + ); + }, + }, + ]; + } + + async componentDidMount() { + this.context.chrome.setBreadcrumbs([BREADCRUMBS.SNAPSHOT_MANAGEMENT, BREADCRUMBS.SNAPSHOT_POLICIES]); + const { id } = queryString.parse(this.props.location.search); + if (typeof id === "string") { + this.context.chrome.setBreadcrumbs([BREADCRUMBS.SNAPSHOT_MANAGEMENT, BREADCRUMBS.SNAPSHOT_POLICIES, { text: id }]); + await this.getPolicy(id); + } else { + this.context.notifications.toasts.addDanger(`Invalid policy id: ${id}`); + this.props.history.push(ROUTES.SNAPSHOT_POLICIES); + } + } + + getPolicy = async (policyId: string): Promise => { + try { + const { snapshotManagementService } = this.props; + const response = await snapshotManagementService.getPolicy(policyId); + + if (response.ok && !response.response) { + let errorMessage = "policy doesn't exist"; + this.context.notifications.toasts.addDanger(`Could not load the policy: ${errorMessage}`); + this.props.history.push(ROUTES.SNAPSHOT_POLICIES); + return; + } + if (response.ok && !!response.response.policy) { + this.setState({ + policy: response.response.policy, + policyId: response.response.id, + metadata: response.response.metadata, + }); + } else { + let 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); + } + }; + + onEdit = () => { + const { policyId } = this.state; + if (policyId) { + this.props.history.push(`${ROUTES.EDIT_SNAPSHOT_POLICY}?id=${policyId}`); + } + }; + + closeDeleteModal = () => { + this.setState({ isDeleteModalVisible: false }); + }; + + showDeleteModal = (): void => { + this.setState({ isDeleteModalVisible: true }); + }; + + onClickDelete = async (): Promise => { + const { snapshotManagementService } = this.props; + const { policyId } = this.state; + + try { + const response = await snapshotManagementService.deletePolicy(policyId); + + if (response.ok) { + this.closeDeleteModal(); + this.context.notifications.toasts.addSuccess(`"Policy ${policyId}" successfully deleted`); + this.props.history.push(ROUTES.SNAPSHOT_POLICIES); + } else { + this.context.notifications.toasts.addDanger(`Could not delete the policy "${policyId}" : ${response.error}`); + } + } catch (err) { + this.context.notifications.toasts.addDanger(getErrorMessage(err, "Could not delete the policy")); + } + }; + + renderEnabledField = (enabled: boolean) => { + if (enabled) { + return "Enabled"; + } + return "Disabled"; + }; + + render() { + const { policyId, policy, metadata, isDeleteModalVisible } = this.state; + + if (!policy) { + return ( + + + + ); + } + + // console.log(`sm dev policy ${JSON.stringify(policy)}`); + + const policySettingItems = [ + { term: "Policy name", value: truncateSpan(policyId, 30) }, + { term: "Status", value: this.renderEnabledField(policy.enabled) }, + { term: "Last updated time", value: policy.last_updated_time }, + { term: "Indices", value: policy.snapshot_config.indices }, + { term: "Repository", value: policy.snapshot_config.repository }, + { term: "Description", value: truncateSpan(policy.description, 30) }, + ]; + + const advancedSettingItems = [ + { term: "Include cluster state", value: `${getIncludeGlobalState(policy)}` }, + { term: "Ignore unavailable indices", value: `${getIgnoreUnavailabel(policy)}` }, + { term: "Allow partial snapshots", value: `${getAllowPartial(policy)}` }, + { term: "Timestamp format", value: `${_.get(policy, "snapshot_config.date_format")}` }, + { term: "Time zone of timestamp", value: `${_.get(policy, "snapshot_config.date_format_timezone")}` }, + ]; + + const createCronExpression = policy.creation.schedule.cron.expression; + const { minute, hour, dayOfWeek, dayOfMonth, frequencyType } = parseCronExpression(createCronExpression); + const humanCron = humanCronExpression( + { minute, hour, dayOfWeek, dayOfMonth, frequencyType }, + createCronExpression, + policy.creation.schedule.cron.timezone + ); + const snapshotScheduleItems = [ + { term: "Frequency", value: _.capitalize(frequencyType) }, + { term: "Cron schedule", value: humanCron }, + { term: "Next snapshot time", value: renderTimestampMillis(metadata?.creation?.trigger.time) }, + ]; + + let retentionItems = [{ term: "Retention period", value: "Keep all snapshots" }]; + let deletionScheduleItems; + if (policy.deletion != null) { + retentionItems = [ + { term: "Maximum age of snapshots", value: policy.deletion?.condition?.max_age ?? "-" }, + { term: "Minimum of snapshots retained", value: `${policy.deletion?.condition?.min_count}` ?? "-" }, + { term: "Maximum of snapshots retained", value: `${policy.deletion?.condition?.max_count}` ?? "-" }, + ]; + const deleteCronExpression = policy.deletion?.schedule?.cron.expression; + if (deleteCronExpression != null) { + const { minute, hour, dayOfWeek, dayOfMonth, frequencyType } = parseCronExpression(deleteCronExpression); + const humanCron = humanCronExpression( + { minute, hour, dayOfWeek, dayOfMonth, frequencyType }, + deleteCronExpression, + policy.deletion.schedule?.cron.timezone ?? "-" + ); + + deletionScheduleItems = [ + { term: "Frequency", value: _.capitalize(frequencyType) }, + { term: "Cron schedule", value: humanCron }, + { term: "Next retention time", value: renderTimestampMillis(metadata?.deletion?.trigger.time) }, + ]; + } + } + + interface NotiConditions { + [condition: string]: boolean; + } + const notiConditions: NotiConditions = _.get(policy, "notification.conditions"); + // _.get(policy, "notification.conditions") + let notiActivities = "None"; + if (notiConditions) { + notiActivities = Object.keys(notiConditions) + .filter((key) => notiConditions[key]) + .join(", "); + } + console.log(`sm dev notification ${notiActivities}`); + + const notificationItems = [ + { term: "Notify on snapshot activities", value: notiActivities }, + { term: "Channels", value: _.get(policy, "notification.channel.id") }, + ]; + + let creationLatestActivity: LatestActivities = { activityType: "Creation" }; + creationLatestActivity = { ...creationLatestActivity, ...metadata?.creation?.latest_execution }; + let latestActivities: LatestActivities[] = [creationLatestActivity]; + if (policy.deletion != null) { + let deletionLatestActivity: LatestActivities = { activityType: "Deletion" }; + deletionLatestActivity = { ...deletionLatestActivity, ...metadata?.deletion?.latest_execution }; + latestActivities = [...latestActivities, deletionLatestActivity]; + } + + return ( +
+ + + +

{policyId}

+
+
+ + + + + Edit + + + + Delete + + + + +
+ + + + + + {policySettingItems.map((item) => ( + + +
{item.term}
+
{item.value}
+
+
+ ))} +
+ + + + + + + {advancedSettingItems.map((item) => ( + + +
{item.term}
+
{item.value}
+
+
+ ))} +
+
+
+ + + + + + {snapshotScheduleItems.map((item) => ( + + +
{item.term}
+
{item.value}
+
+
+ ))} +
+
+ + + + + + {retentionItems.map((item) => ( + + +
{item.term}
+
{item.value}
+
+
+ ))} +
+ + {deletionScheduleItems != undefined && ( + + {deletionScheduleItems.map((item) => ( + + +
{item.term}
+
{item.value}
+
+
+ ))} +
+ )} +
+ + + + + + {notificationItems.map((item) => ( + + +
{item.term}
+
{item.value}
+
+
+ ))} +
+
+ + + + this.getPolicy(policyId)} data-test-subj="refreshButton"> + Refresh + + } + > + + + + {isDeleteModalVisible && ( + + )} +
+ ); + } +} diff --git a/public/pages/SnapshotPolicyDetails/containers/SnapshotPolicyDetails/index.ts b/public/pages/SnapshotPolicyDetails/containers/SnapshotPolicyDetails/index.ts new file mode 100644 index 000000000..f1e576d7c --- /dev/null +++ b/public/pages/SnapshotPolicyDetails/containers/SnapshotPolicyDetails/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import SnapshotPolicyDetails from "./SnapshotPolicyDetails"; + +export default SnapshotPolicyDetails; diff --git a/public/pages/SnapshotPolicyDetails/index.ts b/public/pages/SnapshotPolicyDetails/index.ts new file mode 100644 index 000000000..fd4eaedcc --- /dev/null +++ b/public/pages/SnapshotPolicyDetails/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import SnapshotPolicyDetails from "./containers/SnapshotPolicyDetails"; + +export default SnapshotPolicyDetails; diff --git a/public/pages/Snapshots/components/CreateSnapshotFlyout/CreateSnapshotFlyout.tsx b/public/pages/Snapshots/components/CreateSnapshotFlyout/CreateSnapshotFlyout.tsx new file mode 100644 index 000000000..54a1d12ba --- /dev/null +++ b/public/pages/Snapshots/components/CreateSnapshotFlyout/CreateSnapshotFlyout.tsx @@ -0,0 +1,252 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiAccordion, + EuiComboBoxOptionOption, + EuiFieldText, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiFormRow, + EuiSpacer, + EuiTitle, +} from "@elastic/eui"; +import _ from "lodash"; + +import React, { Component } from "react"; +import FlyoutFooter from "../../../VisualCreatePolicy/components/FlyoutFooter"; +import { CoreServicesContext } from "../../../../components/core_services"; +import { IndexService, SnapshotManagementService } from "../../../../services"; +import { getErrorMessage, wildcardOption } from "../../../../utils/helpers"; +import { IndexItem, Snapshot } from "../../../../../models/interfaces"; +import { CatRepository } from "../../../../../server/models/interfaces"; +import CustomLabel from "../../../../components/CustomLabel"; +import SnapshotAdvancedSettings from "../../../CreateSnapshotPolicy/components/SnapshotAdvancedSettings"; +import SnapshotIndicesRepoInput from "../../../CreateSnapshotPolicy/components/SnapshotIndicesRepoInput"; +import { ChangeEvent } from "react"; +import { getEmptySnapshot } from "./constants"; +import { ERROR_PROMPT } from "../../../CreateSnapshotPolicy/constants"; + +interface CreateSnapshotProps { + snapshotManagementService: SnapshotManagementService; + indexService: IndexService; + onCloseFlyout: () => void; + createSnapshot: (snapshotId: string, repository: string, snapshot: Snapshot) => void; +} + +interface CreateSnapshotState { + indexOptions: EuiComboBoxOptionOption[]; + selectedIndexOptions: EuiComboBoxOptionOption[]; + + repositories: CatRepository[]; + selectedRepoValue: string; + + snapshot: Snapshot; + snapshotId: string; + + repoError: string; + snapshotIdError: string; +} + +export default class CreateSnapshotFlyout extends Component { + static contextType = CoreServicesContext; + constructor(props: CreateSnapshotProps) { + super(props); + + this.state = { + indexOptions: [], + selectedIndexOptions: [], + repositories: [], + selectedRepoValue: "", + snapshot: getEmptySnapshot(), + snapshotId: "", + repoError: "", + snapshotIdError: "", + }; + } + + async componentDidMount() { + await this.getIndexOptions(""); + await this.getRepos(); + } + + onClickAction = () => { + const { createSnapshot } = this.props; + const { snapshotId, selectedRepoValue, snapshot } = this.state; + let repoError = ""; + if (!snapshotId.trim()) { + this.setState({ snapshotIdError: "Required" }); + return; + } + if (!selectedRepoValue) { + repoError = ERROR_PROMPT.REPO; + this.setState({ repoError }); + return; + } + // console.log(`sm dev snapshot body ${JSON.stringify(snapshot)}`); + createSnapshot(snapshotId, selectedRepoValue, snapshot); + }; + + onIndicesSelectionChange = (selectedOptions: EuiComboBoxOptionOption[]) => { + const selectedIndexOptions = selectedOptions.map((o) => o.label); + let newJSON = this.state.snapshot; + newJSON.indices = selectedIndexOptions.toString(); + this.setState({ snapshot: newJSON, selectedIndexOptions: selectedOptions }); + }; + + getIndexOptions = async (searchValue: string) => { + const { indexService } = this.props; + this.setState({ indexOptions: [] }); + 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 dataStreams = optionsResponse.response.dataStreams.map((label) => ({ label })); + const indices = optionsResponse.response.indices.map((label) => ({ label })); + // this.setState({ indexOptions: options.concat(dataStreams, indices)}); + this.setState({ 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.")); + } + }; + + 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 }); + }; + + getRepos = async () => { + try { + const { snapshotManagementService } = this.props; + const response = await snapshotManagementService.catRepositories(); + if (response.ok) { + const selectedRepoValue = response.response.length > 0 ? response.response[0].id : ""; + this.setState({ repositories: response.response, selectedRepoValue }); + } else { + this.context.notifications.toasts.addDanger(response.error); + } + } catch (err) { + this.context.notifications.toasts.addDanger(getErrorMessage(err, "There was a problem loading the snapshots.")); + } + }; + + onRepoSelectionChange = (e: React.ChangeEvent) => { + const selectedRepo = e.target.value; + let repoError = ""; + if (!selectedRepo) { + repoError = ERROR_PROMPT.REPO; + } + this.setState({ selectedRepoValue: selectedRepo, repoError }); + }; + + onIncludeGlobalStateToggle = (e: ChangeEvent) => { + this.setState({ snapshot: _.set(this.state.snapshot, "include_global_state", e.target.checked) }); + }; + + onIgnoreUnavailableToggle = (e: ChangeEvent) => { + this.setState({ snapshot: _.set(this.state.snapshot, "ignore_unavailable", e.target.checked) }); + }; + + onPartialToggle = (e: ChangeEvent) => { + const { checked } = e.target; + let newJSON = this.state.snapshot; + newJSON.partial = checked; + this.setState({ snapshot: newJSON }); + }; + + render() { + const { onCloseFlyout } = this.props; + const { + indexOptions, + selectedIndexOptions, + repositories, + selectedRepoValue, + snapshot, + snapshotId, + repoError, + snapshotIdError, + } = this.state; + + const repoOptions = repositories.map((r) => ({ value: r.id, text: r.id })); + + return ( + + + +

Create snapshot

+
+
+ + + + + { + this.setState({ snapshotId: e.target.value }); + }} + /> + + + + + + + + + + + + + + + + + + +
+ ); + } +} diff --git a/public/pages/Snapshots/components/CreateSnapshotFlyout/constants.ts b/public/pages/Snapshots/components/CreateSnapshotFlyout/constants.ts new file mode 100644 index 000000000..60dde4d69 --- /dev/null +++ b/public/pages/Snapshots/components/CreateSnapshotFlyout/constants.ts @@ -0,0 +1,13 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Snapshot } from "../../../../../models/interfaces"; + +export const getEmptySnapshot = (): Snapshot => ({ + indices: "", + ignore_unavailable: false, + include_global_state: false, + partial: false, +}); diff --git a/public/pages/Snapshots/components/CreateSnapshotFlyout/index.ts b/public/pages/Snapshots/components/CreateSnapshotFlyout/index.ts new file mode 100644 index 000000000..6782e6c32 --- /dev/null +++ b/public/pages/Snapshots/components/CreateSnapshotFlyout/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import CreateSnapshotFlyout from "./CreateSnapshotFlyout"; + +export default CreateSnapshotFlyout; diff --git a/public/pages/Snapshots/components/SnapshotFlyout/SnapshotFlyout.tsx b/public/pages/Snapshots/components/SnapshotFlyout/SnapshotFlyout.tsx new file mode 100644 index 000000000..e24773b98 --- /dev/null +++ b/public/pages/Snapshots/components/SnapshotFlyout/SnapshotFlyout.tsx @@ -0,0 +1,149 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Component } from "react"; +import { + EuiButtonEmpty, + EuiFlexGrid, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiLink, + EuiSpacer, + EuiText, + EuiTitle, +} from "@elastic/eui"; +import { SnapshotManagementService } from "../../../../services"; +import { GetSnapshot } from "../../../../../server/models/interfaces"; +import { CoreServicesContext } from "../../../../components/core_services"; +import { getErrorMessage } from "../../../../utils/helpers"; +import * as H from "history"; +import { ROUTES } from "../../../../utils/constants"; + +interface SnapshotFlyoutProps { + snapshotId: string; + repository: string; + snapshotManagementService: SnapshotManagementService; + onCloseFlyout: () => void; + history: H.History; +} + +interface SnapshotFlyoutState { + snapshot: GetSnapshot | null; +} + +export default class SnapshotFlyout extends Component { + static contextType = CoreServicesContext; + + constructor(props: SnapshotFlyoutProps) { + super(props); + + this.state = { + snapshot: null, + }; + } + + async componentDidMount() { + const { snapshotId, repository } = this.props; + await this.getSnapshot(snapshotId, repository); + } + + getSnapshot = async (snapshotId: string, repository: string) => { + const { snapshotManagementService } = this.props; + try { + const response = await snapshotManagementService.getSnapshot(snapshotId, repository); + if (response.ok) { + this.setState({ snapshot: response.response }); + } + } catch (err) { + this.context.notifications.toasts.addDanger(getErrorMessage(err, "There was a problem loading the snapshot.")); + } + }; + + render() { + const { onCloseFlyout } = this.props; + const { snapshot } = this.state; + + const items1 = [ + { term: "Snapshot name", value: snapshot?.snapshot }, + { term: "Status", value: snapshot?.state }, + ]; + + const items2 = [ + { term: "Start time", value: snapshot?.start_time }, + { term: "End time", value: snapshot?.end_time }, + { term: "Repository", value: snapshot?.snapshot }, + { + term: "Policy", + value: ( + this.props.history.push(`${ROUTES.SNAPSHOT_POLICY_DETAILS}?id=${snapshot?.metadata?.sm_policy}`)}> + {snapshot?.metadata?.sm_policy} + + ), + }, + ]; + + let error; + if (snapshot?.state === "PARTIAL" || snapshot?.state === "FAILED") { + error = ( + +
Error details
+
{snapshot?.failures}
+
+ ); + } + + return ( + + + +

{snapshot?.snapshot}

+
+
+ + + + {items1.map((item) => ( + + +
{item.term}
+
{item.value}
+
+
+ ))} +
+ + + {error} + + + + {items2.map((item) => ( + + +
{item.term}
+
{item.value}
+
+
+ ))} +
+ + + + +
Indices
+
{snapshot?.indices.join(", ")}
+
+
+ + + Close + +
+ ); + } +} diff --git a/public/pages/Snapshots/components/SnapshotFlyout/index.ts b/public/pages/Snapshots/components/SnapshotFlyout/index.ts new file mode 100644 index 000000000..908db7068 --- /dev/null +++ b/public/pages/Snapshots/components/SnapshotFlyout/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import SnapshotFlyout from "./SnapshotFlyout"; + +export default SnapshotFlyout; diff --git a/public/pages/Snapshots/containers/Snapshots/Snapshots.tsx b/public/pages/Snapshots/containers/Snapshots/Snapshots.tsx new file mode 100644 index 000000000..97ed94fdf --- /dev/null +++ b/public/pages/Snapshots/containers/Snapshots/Snapshots.tsx @@ -0,0 +1,341 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Component } from "react"; +import _ from "lodash"; +import { RouteComponentProps } from "react-router-dom"; +import { EuiButton, EuiInMemoryTable, EuiLink, EuiTableFieldDataColumnType, EuiText } from "@elastic/eui"; +import { FieldValueSelectionFilterConfigType } from "@elastic/eui/src/components/search_bar/filters/field_value_selection_filter"; +import { CoreServicesContext } from "../../../../components/core_services"; +import { SnapshotManagementService, IndexService } from "../../../../services"; +import { getErrorMessage } from "../../../../utils/helpers"; +import { CatSnapshotWithRepoAndPolicy as SnapshotsWithRepoAndPolicy } from "../../../../../server/models/interfaces"; +import { ContentPanel } from "../../../../components/ContentPanel"; +import SnapshotFlyout from "../../components/SnapshotFlyout/SnapshotFlyout"; +import CreateSnapshotFlyout from "../../components/CreateSnapshotFlyout/CreateSnapshotFlyout"; +import { Snapshot } from "../../../../../models/interfaces"; +import { BREADCRUMBS, ROUTES } from "../../../../utils/constants"; +import { renderTimestampMillis } from "../../../SnapshotPolicies/helpers"; +import DeleteModal from "../../../Repositories/components/DeleteModal/DeleteModal"; +import { snapshotStatusRender } from "../../helper"; + +interface SnapshotsProps extends RouteComponentProps { + snapshotManagementService: SnapshotManagementService; + indexService: IndexService; +} + +interface SnapshotsState { + snapshots: SnapshotsWithRepoAndPolicy[]; + existingPolicyNames: string[]; + loadingSnapshots: boolean; + + selectedItems: SnapshotsWithRepoAndPolicy[]; + + showFlyout: boolean; // show snapshot details flyout + flyoutSnapshotId: string; + flyoutSnapshotRepo: string; + + showCreateFlyout: boolean; + + message?: React.ReactNode; + + isDeleteModalVisible: boolean; +} + +export default class Snapshots extends Component { + static contextType = CoreServicesContext; + columns: EuiTableFieldDataColumnType[]; + + constructor(props: SnapshotsProps) { + super(props); + + this.state = { + snapshots: [], + existingPolicyNames: [], + loadingSnapshots: false, + selectedItems: [], + showFlyout: false, + flyoutSnapshotId: "", + flyoutSnapshotRepo: "", + showCreateFlyout: false, + message: null, + isDeleteModalVisible: false, + }; + + this.columns = [ + { + field: "id", + name: "Name", + sortable: true, + dataType: "string", + render: (name: string, item: SnapshotsWithRepoAndPolicy) => { + const truncated = _.truncate(name, { length: 20 }); + return ( + this.setState({ showFlyout: true, flyoutSnapshotId: name, flyoutSnapshotRepo: item.repository })}> + {truncated} + + ); + }, + }, + { + field: "status", + name: "Status", + sortable: true, + dataType: "string", + width: "150px", + render: (value: string) => { + return snapshotStatusRender(value); + }, + }, + { + field: "policy", + name: "Policy", + sortable: false, + dataType: "string", + render: (name: string, item: SnapshotsWithRepoAndPolicy) => { + const truncated = _.truncate(name, { length: 20 }); + if (!!item.policy) { + return this.props.history.push(`${ROUTES.SNAPSHOT_POLICY_DETAILS}?id=${name}`)}>{truncated}; + } + return "-"; + }, + }, + { + field: "repository", + name: "Repository", + sortable: false, + dataType: "string", + }, + { + field: "start_epoch", + name: "Start time", + sortable: true, + dataType: "date", + render: renderTimestampMillis, + }, + { + field: "end_epoch", + name: "End time", + sortable: true, + dataType: "date", + render: renderTimestampMillis, + }, + ]; + + this.getSnapshots = _.debounce(this.getSnapshots, 500, { leading: true }); + } + + async componentDidMount() { + this.context.chrome.setBreadcrumbs([BREADCRUMBS.SNAPSHOT_MANAGEMENT, BREADCRUMBS.SNAPSHOTS]); + await this.getSnapshots(); + } + + getSnapshots = async () => { + this.setState({ loadingSnapshots: true, message: "Loading snapshots..." }); + + try { + const { snapshotManagementService } = this.props; + const response = await snapshotManagementService.getAllSnapshotsWithPolicy(); + if (response.ok) { + const { snapshots } = response.response; + const existingPolicyNames = [ + ...new Set(snapshots.filter((snapshot) => !!snapshot.policy).map((snapshot) => snapshot.policy)), + ] as string[]; + this.setState({ snapshots, existingPolicyNames }); + } else { + this.context.notifications.toasts.addDanger(response.error); + } + } catch (err) { + this.context.notifications.toasts.addDanger(getErrorMessage(err, "There was a problem loading the snapshots.")); + } finally { + this.setState({ loadingSnapshots: false, message: null }); + } + }; + + onSelectionChange = (selectedItems: SnapshotsWithRepoAndPolicy[]): void => { + this.setState({ selectedItems }); + }; + + onCloseFlyout = () => { + this.setState({ showFlyout: false }); + }; + + onClickDelete = async () => { + const { selectedItems } = this.state; + for (let item of selectedItems) { + await this.deleteSnapshot(item.id, item.repository); + } + await this.getSnapshots(); + }; + + deleteSnapshot = async (snapshotId: string, repository: string) => { + try { + const { snapshotManagementService } = this.props; + const response = await snapshotManagementService.deleteSnapshot(snapshotId, repository); + if (response.ok) { + this.context.notifications.toasts.addSuccess(`Deleted snapshot ${snapshotId} from repository ${repository}.`); + } else { + this.context.notifications.toasts.addDanger(response.error); + } + } catch (err) { + this.context.notifications.toasts.addDanger(getErrorMessage(err, "There was a problem deleting the snapshot.")); + } + }; + + onClickCreate = () => { + this.setState({ showCreateFlyout: true }); + }; + + onCloseCreateFlyout = () => { + this.setState({ showCreateFlyout: false }); + }; + + createSnapshot = async (snapshotId: string, repository: string, snapshot: Snapshot) => { + try { + const { snapshotManagementService } = this.props; + const response = await snapshotManagementService.createSnapshot(snapshotId, repository, snapshot); + if (response.ok) { + this.setState({ showCreateFlyout: false }); + this.context.notifications.toasts.addSuccess(`Created snapshot ${snapshotId} in repository ${repository}.`); + await this.getSnapshots(); + } else { + this.context.notifications.toasts.addDanger(response.error); + } + } catch (err) { + this.context.notifications.toasts.addDanger(getErrorMessage(err, "There was a problem creating the snapshot.")); + } + }; + + render() { + const { + snapshots, + existingPolicyNames, + selectedItems, + loadingSnapshots, + showFlyout, + flyoutSnapshotId, + flyoutSnapshotRepo, + showCreateFlyout, + isDeleteModalVisible, + } = this.state; + + const repos = [...new Set(snapshots.map((snapshot) => snapshot.repository))]; + const status = [...new Set(snapshots.map((snapshot) => snapshot.status))]; + const search = { + box: { + placeholder: "Search snapshot", + }, + filters: [ + { + type: "field_value_selection", + field: "repository", + name: "Repository", + options: repos.map((repo) => ({ value: repo })), + multiSelect: "or", + } as FieldValueSelectionFilterConfigType, + { + type: "field_value_selection", + field: "status", + name: "Status", + options: status.map((s) => ({ value: s })), + multiSelect: "or", + } as FieldValueSelectionFilterConfigType, + { + type: "field_value_selection", + field: "policy", + name: "Policy", + options: existingPolicyNames.map((p) => ({ value: p })), + multiSelect: "or", + } as FieldValueSelectionFilterConfigType, + ], + }; + + const actions = [ + + Refresh + , + + Delete + , + + Take snapshot + , + ]; + + const subTitleText = ( + +

+ Snapshots are taken automatically from snapshot policies, or you can initiate manual snapshots to save to a repository. +

+
+ ); + + return ( + <> + + `${item.repository}:${item.id}`} + columns={this.columns} + pagination={true} + sorting={{ + sort: { + field: "end_epoch", + direction: "desc", + }, + }} + isSelectable={true} + selection={{ onSelectionChange: this.onSelectionChange }} + search={search} + loading={loadingSnapshots} + /> + + + {showFlyout && ( + + )} + + {showCreateFlyout && ( + + )} + + {isDeleteModalVisible && ( + + )} + + ); + } + + showDeleteModal = () => { + this.setState({ isDeleteModalVisible: true }); + }; + closeDeleteModal = () => { + this.setState({ isDeleteModalVisible: false }); + }; + + getSelectedIds = () => { + return this.state.selectedItems + .map((item: SnapshotsWithRepoAndPolicy) => { + return item.id; + }) + .join(", "); + }; +} diff --git a/public/pages/Snapshots/containers/Snapshots/index.ts b/public/pages/Snapshots/containers/Snapshots/index.ts new file mode 100644 index 000000000..ea84d1d11 --- /dev/null +++ b/public/pages/Snapshots/containers/Snapshots/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import Snapshots from "./Snapshots"; + +export default Snapshots; diff --git a/public/pages/Snapshots/helper.tsx b/public/pages/Snapshots/helper.tsx new file mode 100644 index 000000000..7cc12680b --- /dev/null +++ b/public/pages/Snapshots/helper.tsx @@ -0,0 +1,30 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react"; +import _ from "lodash"; +import { EuiHealth } from "@elastic/eui"; + +export function truncateLongText(text: string, truncateLen: number = 20): string { + if (text.length > truncateLen) { + return text.slice(0, truncateLen) + "..."; + } + return text; +} + +export function truncateSpan(value: string, length: number = 20): React.ReactElement { + const truncated = _.truncate(value, { length }); + return {truncated}; +} + +export function snapshotStatusRender(value: string): React.ReactElement { + const capital = _.capitalize(value); + let color = "success"; + if (capital == "In_progress") color = "primary"; + if (capital == "Failed") color = "warning"; + if (capital == "Partial") color = "danger"; + + return {capital}; +} diff --git a/public/pages/Snapshots/index.ts b/public/pages/Snapshots/index.ts new file mode 100644 index 000000000..61cb52b11 --- /dev/null +++ b/public/pages/Snapshots/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import Snapshots from "./containers/Snapshots"; + +export default Snapshots; diff --git a/public/pages/VisualCreatePolicy/components/ChannelNotification/ChannelNotification.tsx b/public/pages/VisualCreatePolicy/components/ChannelNotification/ChannelNotification.tsx index 86eb49c7d..0f8dc80e4 100644 --- a/public/pages/VisualCreatePolicy/components/ChannelNotification/ChannelNotification.tsx +++ b/public/pages/VisualCreatePolicy/components/ChannelNotification/ChannelNotification.tsx @@ -14,9 +14,9 @@ interface ChannelNotificationProps { channelId: string; channels: FeatureChannelList[]; loadingChannels: boolean; - message: string; + message?: string; onChangeChannelId: (value: ChangeEvent) => void; - onChangeMessage: (value: ChangeEvent) => void; + onChangeMessage?: (value: ChangeEvent) => void; getChannels: () => void; actionNotification?: boolean; // to tell if this is rendering in actions or in error notification as they both show up on page together } diff --git a/public/pages/VisualCreatePolicy/components/FlyoutFooter/FlyoutFooter.tsx b/public/pages/VisualCreatePolicy/components/FlyoutFooter/FlyoutFooter.tsx index bff357da7..3186668f9 100644 --- a/public/pages/VisualCreatePolicy/components/FlyoutFooter/FlyoutFooter.tsx +++ b/public/pages/VisualCreatePolicy/components/FlyoutFooter/FlyoutFooter.tsx @@ -12,9 +12,10 @@ interface FlyoutFooterProps { disabledAction?: boolean; onClickCancel: () => void; onClickAction: () => void; + save?: boolean; } -const FlyoutFooter = ({ edit, action, disabledAction = false, onClickCancel, onClickAction }: FlyoutFooterProps) => ( +const FlyoutFooter = ({ edit, action, disabledAction = false, onClickCancel, onClickAction, save }: FlyoutFooterProps) => ( @@ -23,7 +24,7 @@ const FlyoutFooter = ({ edit, action, disabledAction = false, onClickCancel, onC - {`${edit ? "Edit" : "Add"} ${action}`} + {!save ? `${edit ? "Edit" : "Add"} ${action}` : save ? "Save" : "Create"} diff --git a/public/services/SnapshotManagementService.ts b/public/services/SnapshotManagementService.ts new file mode 100644 index 000000000..00fb6911a --- /dev/null +++ b/public/services/SnapshotManagementService.ts @@ -0,0 +1,132 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { HttpFetchQuery, HttpSetup } from "opensearch-dashboards/public"; +import { NODE_API } from "../../utils/constants"; +import { + GetSnapshotsResponse, + GetSMPoliciesResponse, + GetSnapshot, + CatRepository, + CreateRepositoryBody, + AcknowledgedResponse, + CreateSnapshotResponse, +} from "../../server/models/interfaces"; +import { ServerResponse } from "../../server/models/types"; +import { DocumentSMPolicy, DocumentSMPolicyWithMetadata, SMPolicy, Snapshot } from "../../models/interfaces"; + +export default class SnapshotManagementService { + httpClient: HttpSetup; + + constructor(httpClient: HttpSetup) { + this.httpClient = httpClient; + } + + getAllSnapshotsWithPolicy = async (): Promise> => { + let url = `..${NODE_API._SNAPSHOTS}`; + const response = (await this.httpClient.get(url)) as ServerResponse; + return response; + }; + + getSnapshot = async (snapshotId: string, repository: string): Promise> => { + let url = `..${NODE_API._SNAPSHOTS}/${snapshotId}`; + const response = (await this.httpClient.get(url, { query: { repository } })) as ServerResponse; + return response; + }; + + deleteSnapshot = async (snapshotId: string, repository: string): Promise> => { + let url = `..${NODE_API._SNAPSHOTS}/${snapshotId}`; + const response = (await this.httpClient.delete(url, { query: { repository } })) as ServerResponse; + return response; + }; + + createSnapshot = async (snapshotId: string, repository: string, snapshot: Snapshot): Promise> => { + let url = `..${NODE_API._SNAPSHOTS}/${snapshotId}`; + const response = (await this.httpClient.put(url, { query: { repository }, body: JSON.stringify(snapshot) })) as ServerResponse< + CreateSnapshotResponse + >; + return response; + }; + + createPolicy = async (policyId: string, policy: SMPolicy): Promise> => { + let url = `..${NODE_API.SMPolicies}/${policyId}`; + const response = (await this.httpClient.post(url, { body: JSON.stringify(policy) })) as ServerResponse; + console.log(`sm dev public create sm policy response ${JSON.stringify(response)}`); + return response; + }; + + updatePolicy = async ( + policyId: string, + policy: SMPolicy, + seqNo: number, + primaryTerm: number + ): Promise> => { + let url = `..${NODE_API.SMPolicies}/${policyId}`; + const response = (await this.httpClient.put(url, { query: { seqNo, primaryTerm }, body: JSON.stringify(policy) })) as ServerResponse< + DocumentSMPolicy + >; + console.log(`sm dev public update sm policy response ${JSON.stringify(response)}`); + return response; + }; + + getPolicies = async (queryObject: HttpFetchQuery): Promise> => { + let url = `..${NODE_API.SMPolicies}`; + const response = (await this.httpClient.get(url, { query: queryObject })) as ServerResponse; + console.log(`sm dev public get sm policies response ${JSON.stringify(response)}`); + return response; + }; + + getPolicy = async (policyId: string): Promise> => { + const url = `..${NODE_API.SMPolicies}/${policyId}`; + const response = (await this.httpClient.get(url)) as ServerResponse; + return response; + }; + + deletePolicy = async (policyId: string): Promise> => { + const url = `..${NODE_API.SMPolicies}/${policyId}`; + const response = (await this.httpClient.delete(url)) as ServerResponse; + return response; + }; + + startPolicy = async (policyId: string): Promise> => { + const url = `..${NODE_API.SMPolicies}/${policyId}/_start`; + const response = (await this.httpClient.post(url)) as ServerResponse; + return response; + }; + + stopPolicy = async (policyId: string): Promise> => { + const url = `..${NODE_API.SMPolicies}/${policyId}/_stop`; + const response = (await this.httpClient.post(url)) as ServerResponse; + return response; + }; + + catRepositories = async (): Promise> => { + const url = `..${NODE_API._REPOSITORIES}`; + const response = (await this.httpClient.get(url)) as ServerResponse; + console.log(`sm dev get repositories ${JSON.stringify(response)}`); + return response; + }; + + getRepository = async (repo: string): Promise> => { + const url = `..${NODE_API._REPOSITORIES}/${repo}`; + const response = (await this.httpClient.get(url)) as ServerResponse; + console.log(`sm dev get repository ${JSON.stringify(response)}`); + return response; + }; + + createRepository = async (repo: string, createRepoBody: CreateRepositoryBody): Promise> => { + const url = `..${NODE_API._REPOSITORIES}/${repo}`; + const response = (await this.httpClient.put(url, { body: JSON.stringify(createRepoBody) })) as ServerResponse; + console.log(`sm dev create repository ${JSON.stringify(response)}`); + return response; + }; + + deleteRepository = async (repo: string): Promise> => { + const url = `..${NODE_API._REPOSITORIES}/${repo}`; + const response = (await this.httpClient.delete(url)) as ServerResponse; + console.log(`sm dev delete repository ${JSON.stringify(response)}`); + return response; + }; +} diff --git a/public/services/index.ts b/public/services/index.ts index e066a365c..28eee9f41 100644 --- a/public/services/index.ts +++ b/public/services/index.ts @@ -10,6 +10,7 @@ import PolicyService from "./PolicyService"; import RollupService from "./RollupService"; import TransformService from "./TransformService"; import NotificationService from "./NotificationService"; +import SnapshotManagementService from "./SnapshotManagementService"; export { ServicesConsumer, @@ -20,4 +21,5 @@ export { RollupService, TransformService, NotificationService, + SnapshotManagementService, }; diff --git a/public/utils/constants.ts b/public/utils/constants.ts index 175a24c7f..b697cd372 100644 --- a/public/utils/constants.ts +++ b/public/utils/constants.ts @@ -13,6 +13,11 @@ export const ACTIONS_DOCUMENTATION_URL = "https://opensearch.org/docs/im-plugin/ export const STATES_DOCUMENTATION_URL = "https://opensearch.org/docs/im-plugin/ism/policies/#states"; export const ERROR_NOTIFICATION_DOCUMENTATION_URL = "https://opensearch.org/docs/im-plugin/ism/policies/#error-notifications"; export const TRANSITION_DOCUMENTATION_URL = "https://opensearch.org/docs/im-plugin/ism/policies/#transitions"; +export const CRON_EXPRESSION_DOCUMENTATION_URL = "https://opensearch.org/docs/latest/monitoring-plugins/alerting/cron/"; +export const SNAPSHOT_MANAGEMENT_DOCUMENTATION_URL = "https://opensearch.org/docs/im-plugin/ism/index"; +export const REPOSITORY_DOCUMENTATION_URL = "https://opensearch.org/docs/latest/opensearch/snapshot-restore/#register-repository"; +export const FS_REPOSITORY_DOCUMENTATION_URL = "https://opensearch.org/docs/latest/opensearch/snapshot-restore/#shared-file-system"; +export const S3_REPOSITORY_DOCUMENTATION_URL = "https://opensearch.org/docs/latest/opensearch/snapshot-restore/#amazon-s3"; export const ROUTES = Object.freeze({ CHANGE_POLICY: "/change-policy", @@ -30,6 +35,16 @@ export const ROUTES = Object.freeze({ CREATE_TRANSFORM: "/create-transform", EDIT_TRANSFORM: "/edit-transform", TRANSFORM_DETAILS: "/transform-details", + SNAPSHOT_POLICIES: "/snapshot-policies", + SNAPSHOT_POLICY_DETAILS: "/snapshot-policy-details", + CREATE_SNAPSHOT_POLICY: "/create-snapshot-policy", + EDIT_SNAPSHOT_POLICY: "/edit-snapshot-policy", + SNAPSHOTS: "/snapshots", + CREATE_SNAPSHOT: "/create-snapshot", + EDIT_SNAPSHOT: "/edit-snapshot", + REPOSITORIES: "/repositories", + CREATE_REPOSITORY: "/create-repository", + EDIT_REPOSITORY: "/edit-repository", }); export const BREADCRUMBS = Object.freeze({ @@ -49,6 +64,21 @@ export const BREADCRUMBS = Object.freeze({ CREATE_TRANSFORM: { text: "Create transform job" }, EDIT_TRANSFORM: { text: "Edit transform job" }, TRANSFORM_DETAILS: { text: "Transform details" }, + + SNAPSHOT_MANAGEMENT: { text: "Snapshot Management", href: `#${ROUTES.SNAPSHOT_POLICIES}` }, + + SNAPSHOT_POLICIES: { text: "Snapshot policies", href: `#${ROUTES.SNAPSHOT_POLICIES}` }, + SNAPSHOT_POLICY_DETAILS: { text: "Snapshot policy details" }, + CREATE_SNAPSHOT_POLICY: { text: "Create snapshot policy" }, + EDIT_SNAPSHOT_POLICY: { text: "Edit snapshot policy" }, + + SNAPSHOTS: { text: "Snapshots", href: `#${ROUTES.SNAPSHOTS}` }, + CREATE_SNAPSHOT: { text: "Create repository", href: `#${ROUTES.CREATE_REPOSITORY}` }, + EDIT_SNAPSHOT: { text: "Edit repository", href: `#${ROUTES.EDIT_REPOSITORY}` }, + + REPOSITORIES: { text: "Repositories", href: `#${ROUTES.REPOSITORIES}` }, + CREATE_REPOSITORY: { text: "Create repository", href: `#${ROUTES.CREATE_REPOSITORY}` }, + EDIT_REPOSITORY: { text: "Edit repository", href: `#${ROUTES.EDIT_REPOSITORY}` }, }); // TODO: EUI has a SortDirection already diff --git a/server/clusters/ism/ismPlugin.ts b/server/clusters/ism/ismPlugin.ts index 680d3c94e..e79a6c7e1 100644 --- a/server/clusters/ism/ismPlugin.ts +++ b/server/clusters/ism/ismPlugin.ts @@ -375,4 +375,112 @@ export default function ismPlugin(Client: any, config: any, components: any) { }, method: "GET", }); + + ism.getSMPolicy = ca({ + url: { + fmt: `${API.SM_POLICY_BASE}/<%=id%>`, + req: { + id: { + type: "string", + required: true, + }, + }, + }, + method: "GET", + }); + + ism.getSMPolicies = ca({ + url: { + fmt: `${API.SM_POLICY_BASE}`, + }, + method: "GET", + }); + + ism.createSMPolicy = ca({ + url: { + fmt: `${API.SM_POLICY_BASE}/<%=policyId%>?refresh=wait_for`, + req: { + policyId: { + type: "string", + required: true, + }, + }, + }, + needBody: true, + method: "POST", + }); + + ism.updateSMPolicy = ca({ + url: { + fmt: `${API.SM_POLICY_BASE}/<%=policyId%>?if_seq_no=<%=ifSeqNo%>&if_primary_term=<%=ifPrimaryTerm%>&refresh=wait_for`, + req: { + policyId: { + type: "string", + required: true, + }, + ifSeqNo: { + type: "string", + required: true, + }, + ifPrimaryTerm: { + type: "string", + required: true, + }, + }, + }, + needBody: true, + method: "PUT", + }); + + ism.deleteSMPolicy = ca({ + url: { + fmt: `${API.SM_POLICY_BASE}/<%=policyId%>?refresh=wait_for`, + req: { + policyId: { + type: "string", + required: true, + }, + }, + }, + method: "DELETE", + }); + + ism.explainSnapshotPolicy = ca({ + url: { + fmt: `${API.SM_POLICY_BASE}/<%=id%>/_explain`, + req: { + id: { + type: "string", + required: true, + }, + }, + }, + method: "GET", + }); + + ism.startSnapshotPolicy = ca({ + url: { + fmt: `${API.SM_POLICY_BASE}/<%=id%>/_start`, + req: { + id: { + type: "string", + required: true, + }, + }, + }, + method: "POST", + }); + + ism.stopSnapshotPolicy = ca({ + url: { + fmt: `${API.SM_POLICY_BASE}/<%=id%>/_stop`, + req: { + id: { + type: "string", + required: true, + }, + }, + }, + method: "POST", + }); } diff --git a/server/models/interfaces.ts b/server/models/interfaces.ts index 7873612ca..4960d9967 100644 --- a/server/models/interfaces.ts +++ b/server/models/interfaces.ts @@ -11,8 +11,17 @@ import { RollupService, TransformService, NotificationService, + SnapshotManagementService, } from "../services"; -import { DocumentPolicy, DocumentRollup, DocumentTransform, ManagedIndexItem, Rollup, Transform } from "../../models/interfaces"; +import { + DocumentPolicy, + DocumentRollup, + DocumentSMPolicy, + DocumentTransform, + ManagedIndexItem, + Rollup, + Transform, +} from "../../models/interfaces"; export interface NodeServices { indexService: IndexService; @@ -22,6 +31,7 @@ export interface NodeServices { rollupService: RollupService; transformService: TransformService; notificationService: NotificationService; + snapshotManagementService: SnapshotManagementService; } export interface SearchResponse { @@ -272,6 +282,7 @@ export interface IndexManagementApi { readonly ROLLUP_JOBS_BASE: string; readonly TRANSFORM_BASE: string; readonly CHANNELS_BASE: string; + readonly SM_POLICY_BASE: string; } export interface DefaultHeaders { @@ -327,3 +338,78 @@ export interface DataStreamIndex { export interface IndexToDataStream { [indexName: string]: string; } + +export interface GetSnapshotsResponse { + snapshots: CatSnapshotWithRepoAndPolicy[]; + totalSnapshots: number; +} + +export interface CatSnapshotWithRepoAndPolicy { + id: string; + status: string; + start_epoch: number; + end_epoch: number; + duration: number; + indices: number; + successful_shards: number; + failed_shards: number; + total_shards: number; + repository: string; + policy?: string; +} + +export interface GetSnapshotResponse { + snapshots: GetSnapshot[]; +} + +export interface CreateSnapshotResponse { + snapshot: GetSnapshot; +} + +export interface GetSnapshot { + snapshot: string; + uuid: string; + version: number; + state: string; + indices: string[]; + data_streams: string[]; + failures: any[]; + include_global_state: boolean; + start_time: string; + start_time_in_millis: number; + end_time: string; + end_time_in_millis: number; + duration_in_millis: number; + shards: { + total: number; + successful: number; + failed: number; + }; + metadata?: { + sm_policy?: string; + }; +} + +export interface CatRepository { + id: string; + type: string; + snapshotCount?: number; +} + +export interface GetRepositoryResponse { + [repoName: string]: CreateRepositoryBody; +} + +export interface CreateRepositorySettings { + location?: string; +} + +export interface CreateRepositoryBody { + type: string; + settings: CreateRepositorySettings; +} + +export interface GetSMPoliciesResponse { + policies: DocumentSMPolicy[]; + totalPolicies: number; +} diff --git a/server/models/types.ts b/server/models/types.ts index a3e698c43..cbb7566b6 100644 --- a/server/models/types.ts +++ b/server/models/types.ts @@ -25,4 +25,5 @@ export type RollupsSort = { "rollup.rollup.last_updated_time": "rollup.last_updated_time"; }; -export type ServerResponse = { ok: false; error: string } | { ok: true; response: T }; +export type ServerResponse = FailedServerResponse | { ok: true; response: T }; +export type FailedServerResponse = { ok: false; error: string }; diff --git a/server/plugin.ts b/server/plugin.ts index 25ccc00f2..27943917d 100644 --- a/server/plugin.ts +++ b/server/plugin.ts @@ -14,8 +14,9 @@ import { TransformService, DataStreamService, NotificationService, + SnapshotManagementService, } from "./services"; -import { indices, policies, managedIndices, rollups, transforms, notifications } from "../server/routes"; +import { indices, policies, managedIndices, rollups, transforms, notifications, snapshotManagement } from "../server/routes"; import dataStreams from "./routes/dataStreams"; export class IndexPatternManagementPlugin implements Plugin { @@ -33,6 +34,7 @@ export class IndexPatternManagementPlugin implements Plugin>> => { + try { + // if no repository input, we need to first get back all repositories + const getRepositoryRes = await this.catRepositories(context, request, response); + let repositories: string[]; + if (getRepositoryRes.payload?.ok) { + repositories = getRepositoryRes.payload?.response.map((repo) => repo.id); + console.log(`sm dev get repositories ${JSON.stringify(repositories)}`); + } else { + return response.custom({ + statusCode: 200, + body: { + ok: false, + error: getRepositoryRes.payload?.error as string, + }, + }); + } + + const { callAsCurrentUser: callWithRequest } = this.osDriver.asScoped(request); + let snapshots: CatSnapshotWithRepoAndPolicy[] = []; + for (let i = 0; i < repositories.length; i++) { + const res: GetSnapshotResponse = await callWithRequest("snapshot.get", { + repository: repositories[i], + snapshot: "_all", + ignore_unavailable: true, + }); + const snapshotWithPolicy: CatSnapshotWithRepoAndPolicy[] = res.snapshots.map((s: GetSnapshot) => ({ + id: s.snapshot, + status: s.state, + start_epoch: s.start_time_in_millis, + end_epoch: s.end_time_in_millis, + duration: s.duration_in_millis, + indices: s.indices.length, + successful_shards: s.shards.successful, + failed_shards: s.shards.failed, + total_shards: s.shards.total, + repository: repositories[i], + policy: s.metadata?.sm_policy, + })); + // TODO SM try catch the missing snapshot exception + // const catSnapshotsRes: CatSnapshotWithRepoAndPolicy[] = await callWithRequest("snapshot.get", params); + // const snapshotsWithRepo = catSnapshotsRes.map((item) => ({ ...item, repository: repositories[i] })); + // console.log(`sm dev cat snapshot response: ${JSON.stringify(snapshotWithPolicy)}`); + snapshots = [...snapshots, ...snapshotWithPolicy]; + } + + // populate policy field for snapshot + // const getSMPoliciesRes = await this.getPolicies(context, request, response); + // if (getSMPoliciesRes.payload?.ok) { + // const policyNames = getSMPoliciesRes.payload?.response.policies + // .map((policy) => policy.policy.name) + // .sort((a, b) => b.length - a.length); + // console.log(`sm dev get snapshot policies ${policyNames}`); + // function addPolicyField(snapshot: CatSnapshotWithRepoAndPolicy) { + // for (let i = 0; i < policyNames.length; i++) { + // if (snapshot.id.startsWith(policyNames[i])) { + // return { ...snapshot, policy: policyNames[i] }; + // } + // } + // return snapshot; + // } + // snapshots = snapshots.map(addPolicyField); + // } + + return response.custom({ + statusCode: 200, + body: { + ok: true, + response: { + snapshots: snapshots, + totalSnapshots: snapshots.length, + }, + }, + }); + } catch (err) { + // TODO SM handle missing snapshot exception, return empty + return this.errorResponse(response, err, "getAllSnapshotsWithPolicy"); + } + }; + + getSnapshot = async ( + context: RequestHandlerContext, + request: OpenSearchDashboardsRequest, + response: OpenSearchDashboardsResponseFactory + ): Promise>> => { + try { + const { id } = request.params as { + id: string; + }; + const { repository } = request.query as { + repository: string; + }; + const { callAsCurrentUser: callWithRequest } = this.osDriver.asScoped(request); + const res: GetSnapshotResponse = await callWithRequest("snapshot.get", { + repository: repository, + snapshot: `${id}`, + ignore_unavailable: true, + }); + + console.log(`sm dev get snapshot response: ${JSON.stringify(res)}`); + return response.custom({ + statusCode: 200, + body: { + ok: true, + response: res.snapshots[0], + }, + }); + } catch (err) { + return this.errorResponse(response, err, "getSnapshot"); + } + }; + + deleteSnapshot = async ( + context: RequestHandlerContext, + request: OpenSearchDashboardsRequest, + response: OpenSearchDashboardsResponseFactory + ): Promise>> => { + try { + const { id } = request.params as { + id: string; + }; + const { repository } = request.query as { + repository: string; + }; + const { callAsCurrentUser: callWithRequest } = this.osDriver.asScoped(request); + const resp: AcknowledgedResponse = await callWithRequest("snapshot.delete", { + repository: repository, + snapshot: `${id}`, + }); + + console.log(`sm dev delete snapshot response: ${JSON.stringify(resp)}`); + return response.custom({ + statusCode: 200, + body: { + ok: true, + response: resp, + }, + }); + } catch (err) { + return this.errorResponse(response, err, "deleteSnapshot"); + } + }; + + createSnapshot = async ( + context: RequestHandlerContext, + request: OpenSearchDashboardsRequest, + response: OpenSearchDashboardsResponseFactory + ): Promise>> => { + try { + const { id } = request.params as { + id: string; + }; + const { repository } = request.query as { + repository: string; + }; + const params = { + repository: repository, + snapshot: id, + body: JSON.stringify(request.body), + }; + // TODO SM body indices, ignore_unavailable, include_global_state, partial + const { callAsCurrentUser: callWithRequest } = this.osDriver.asScoped(request); + const resp: CreateSnapshotResponse = await callWithRequest("snapshot.create", params); + + console.log(`sm dev createSnapshot response: ${JSON.stringify(resp)}`); + return response.custom({ + statusCode: 200, + body: { + ok: true, + response: resp, + }, + }); + } catch (err) { + return this.errorResponse(response, err, "createSnapshot"); + } + }; + + createPolicy = async ( + context: RequestHandlerContext, + request: OpenSearchDashboardsRequest, + response: OpenSearchDashboardsResponseFactory + ): Promise>> => { + try { + const { id } = request.params as { id: string }; + const params = { + policyId: id, + body: JSON.stringify(request.body), + }; + + console.log(`sm dev create policy ${JSON.stringify(request.body)}`); + + const { callAsCurrentUser: callWithRequest } = this.osDriver.asScoped(request); + const rawRes = await callWithRequest("ism.createSMPolicy", params); + const res: DocumentSMPolicy = { + seqNo: rawRes._seq_no, + primaryTerm: rawRes._primary_term, + id: rawRes._id, + policy: rawRes.sm_policy, + }; + + console.log(`sm dev server create policy response: ${JSON.stringify(res)}`); + + return response.custom({ + statusCode: 200, + body: { + ok: true, + response: res, + }, + }); + } catch (err) { + return this.errorResponse(response, err, "createPolicy"); + } + }; + + updatePolicy = async ( + context: RequestHandlerContext, + request: OpenSearchDashboardsRequest, + response: OpenSearchDashboardsResponseFactory + ): Promise>> => { + try { + const { id } = request.params as { id: string }; + const { seqNo, primaryTerm } = request.query as { seqNo?: string; primaryTerm?: string }; + const params = { + policyId: id, + ifSeqNo: seqNo, + ifPrimaryTerm: primaryTerm, + body: JSON.stringify(request.body), + }; + + console.log(`sm dev update policy ${JSON.stringify(request.body)}`); + + const { callAsCurrentUser: callWithRequest } = this.osDriver.asScoped(request); + const rawRes = await callWithRequest("ism.updateSMPolicy", params); + const res: DocumentSMPolicy = { + seqNo: rawRes._seq_no, + primaryTerm: rawRes._primary_term, + id: rawRes._id, + policy: rawRes.sm_policy, + }; + console.log(`sm dev server update policy response: ${JSON.stringify(res)}`); + + return response.custom({ + statusCode: 200, + body: { + ok: true, + response: res, + }, + }); + } catch (err) { + return this.errorResponse(response, err, "updatePolicy"); + } + }; + + getPolicies = async ( + context: RequestHandlerContext, + request: OpenSearchDashboardsRequest, + response: OpenSearchDashboardsResponseFactory + ): Promise>> => { + try { + const { from, size, sortField, sortOrder, queryString } = request.query as { + from: string; + size: string; + sortField: string; + sortOrder: string; + queryString: string; + }; + + const { callAsCurrentUser: callWithRequest } = this.osDriver.asScoped(request); + let params = { + from, + size, + sortField: `sm_policy.${sortField}`, + sortOrder, + queryString: queryString.trim() ? `${queryString.trim()}` : "*", + }; + console.log(`sm dev get policies ${JSON.stringify(params)}`); + const res = await callWithRequest("ism.getSMPolicies", params); + + const policies: DocumentSMPolicy[] = res.policies.map( + (p: { _id: string; _seq_no: number; _primary_term: number; sm_policy: SMPolicy }) => ({ + seqNo: p._seq_no, + primaryTerm: p._primary_term, + id: p._id, + policy: p.sm_policy, + }) + ); + return response.custom({ + statusCode: 200, + body: { + ok: true, + response: { policies, totalPolicies: res.total_policies as number }, + }, + }); + } catch (err: any) { + if (err.statusCode === 404 && err.body.error.reason === "Snapshot management config index not found") { + return response.custom({ + statusCode: 200, + body: { + ok: true, + response: { policies: [], totalPolicies: 0 }, + }, + }); + } + return this.errorResponse(response, err, "getPolicies"); + } + }; + + getPolicy = async ( + context: RequestHandlerContext, + request: OpenSearchDashboardsRequest, + response: OpenSearchDashboardsResponseFactory + ): Promise>> => { + try { + const { id } = request.params as { id: string }; + const params = { id: id }; + const { callAsCurrentUser: callWithRequest } = this.osDriver.asScoped(request); + const getResponse = await callWithRequest("ism.getSMPolicy", params); + const metadata = await callWithRequest("ism.explainSnapshotPolicy", params); + console.log(`sm dev metadata ${JSON.stringify(metadata)}`); + const documentPolicy = { + id: id, + seqNo: getResponse._seq_no, + primaryTerm: getResponse._primary_term, + policy: getResponse.sm_policy, + metadata: metadata.policies[0], + }; + return response.custom({ + statusCode: 200, + body: { + ok: true, + response: documentPolicy, + }, + }); + } catch (err: any) { + if (err.statusCode === 404 && err.body.error.reason === "Snapshot management config index not found") { + return response.custom({ + statusCode: 200, + body: { + ok: true, + response: null, + }, + }); + } + return this.errorResponse(response, err, "getPolicy"); + } + }; + + deletePolicy = async ( + context: RequestHandlerContext, + request: OpenSearchDashboardsRequest, + response: OpenSearchDashboardsResponseFactory + ): Promise>> => { + try { + const { id } = request.params as { id: string }; + const params = { policyId: id }; + const { callAsCurrentUser: callWithRequest } = this.osDriver.asScoped(request); + const deletePolicyResponse: DeletePolicyResponse = await callWithRequest("ism.deleteSMPolicy", params); + if (deletePolicyResponse.result !== "deleted") { + return response.custom({ + statusCode: 200, + body: { + ok: false, + error: deletePolicyResponse.result, + }, + }); + } + return response.custom({ + statusCode: 200, + body: { + ok: true, + response: true, + }, + }); + } catch (err) { + return this.errorResponse(response, err, "deletePolicy"); + } + }; + + startPolicy = async ( + context: RequestHandlerContext, + request: OpenSearchDashboardsRequest, + response: OpenSearchDashboardsResponseFactory + ): Promise>> => { + try { + const { id } = request.params as { id: string }; + const params = { id: id }; + const { callAsCurrentUser: callWithRequest } = this.osDriver.asScoped(request); + const resp: AcknowledgedResponse = await callWithRequest("ism.startSnapshotPolicy", params); + if (resp.acknowledged) { + return response.custom({ + statusCode: 200, + body: { ok: true, response: true }, + }); + } else { + return response.custom({ + statusCode: 200, + body: { ok: false, error: "Failed to start snapshot policy." }, + }); + } + } catch (err) { + return this.errorResponse(response, err, "startPolicy"); + } + }; + + stopPolicy = async ( + context: RequestHandlerContext, + request: OpenSearchDashboardsRequest, + response: OpenSearchDashboardsResponseFactory + ): Promise>> => { + try { + const { id } = request.params as { id: string }; + const params = { id: id }; + const { callAsCurrentUser: callWithRequest } = this.osDriver.asScoped(request); + const resp: AcknowledgedResponse = await callWithRequest("ism.stopSnapshotPolicy", params); + if (resp.acknowledged) { + return response.custom({ + statusCode: 200, + body: { ok: true, response: true }, + }); + } else { + return response.custom({ + statusCode: 200, + body: { ok: false, error: "Failed to stop snapshot policy." }, + }); + } + } catch (err) { + return this.errorResponse(response, err, "stopPolicy"); + } + }; + + catRepositories = async ( + context: RequestHandlerContext, + request: OpenSearchDashboardsRequest, + response: OpenSearchDashboardsResponseFactory + ): Promise>> => { + try { + const { callAsCurrentUser: callWithRequest } = this.osDriver.asScoped(request); + const res: CatRepository[] = await callWithRequest("cat.repositories", { + format: "json", + }); + console.log(`sm dev cat repositories response: ${JSON.stringify(res)}`); + return response.custom({ + statusCode: 200, + body: { + ok: true, + response: res, + }, + }); + } catch (err) { + return this.errorResponse(response, err, "catRepositories"); + } + }; + + catRepositoriesWithSnapshotCount = async ( + context: RequestHandlerContext, + request: OpenSearchDashboardsRequest, + response: OpenSearchDashboardsResponseFactory + ): Promise>> => { + try { + const { callAsCurrentUser: callWithRequest } = this.osDriver.asScoped(request); + const res: CatRepository[] = await callWithRequest("cat.repositories", { + format: "json", + }); + + for (let i = 0; i < res.length; i++) { + const getSnapshotRes: GetSnapshotResponse = await callWithRequest("snapshot.get", { + repository: res[i].id, + snapshot: "_all", + ignore_unavailable: true, + }); + res[i].snapshotCount = getSnapshotRes.snapshots.length; + } + + console.log(`sm dev cat repositories with snapshot count response: ${JSON.stringify(res)}`); + return response.custom({ + statusCode: 200, + body: { + ok: true, + response: res, + }, + }); + } catch (err) { + return this.errorResponse(response, err, "catRepositoriesWithSnapshotCount"); + } + }; + + deleteRepository = async ( + context: RequestHandlerContext, + request: OpenSearchDashboardsRequest, + response: OpenSearchDashboardsResponseFactory + ): Promise>> => { + try { + const { id } = request.params as { id: string }; + const { callAsCurrentUser: callWithRequest } = this.osDriver.asScoped(request); + const res: AcknowledgedResponse = await callWithRequest("snapshot.deleteRepository", { + repository: id, + }); + console.log(`sm dev delete repository response: ${JSON.stringify(res)}`); + return response.custom({ + statusCode: 200, + body: { + ok: true, + response: res, + }, + }); + } catch (err) { + return this.errorResponse(response, err, "deleteRepository"); + } + }; + + getRepository = async ( + context: RequestHandlerContext, + request: OpenSearchDashboardsRequest, + response: OpenSearchDashboardsResponseFactory + ): Promise>> => { + try { + const { id } = request.params as { id: string }; + const { callAsCurrentUser: callWithRequest } = this.osDriver.asScoped(request); + const res: GetRepositoryResponse = await callWithRequest("snapshot.getRepository", { + repository: id, + }); + console.log(`sm dev get repository response: ${JSON.stringify(res)}`); + return response.custom({ + statusCode: 200, + body: { + ok: true, + response: res, + }, + }); + } catch (err) { + return this.errorResponse(response, err, "getRepository"); + } + }; + + createRepository = async ( + context: RequestHandlerContext, + request: OpenSearchDashboardsRequest, + response: OpenSearchDashboardsResponseFactory + ): Promise>> => { + try { + const { id } = request.params as { id: string }; + const params = { + repository: id, + body: JSON.stringify(request.body), + }; + console.log(`sm dev create repo params ${JSON.stringify(params)}`); + const { callAsCurrentUser: callWithRequest } = this.osDriver.asScoped(request); + const res: AcknowledgedResponse = await callWithRequest("snapshot.createRepository", params); + console.log(`sm dev create repository response: ${JSON.stringify(res)}`); + return response.custom({ + statusCode: 200, + body: { + ok: true, + response: res, + }, + }); + } catch (err) { + return this.errorResponse(response, err, "createRepository"); + } + }; + + errorResponse = ( + response: OpenSearchDashboardsResponseFactory, + error: any, + methodName: string + ): IOpenSearchDashboardsResponse => { + console.error(`Index Management - SnapshotManagementService - ${methodName}:`, error); + + return response.custom({ + statusCode: 200, // error?.statusCode || 500, + body: { + ok: false, + error: this.parseEsErrorResponse(error), + }, + }); + }; + + parseEsErrorResponse = (error: any): string => { + if (error.response) { + try { + const esErrorResponse = JSON.parse(error.response); + return esErrorResponse.reason || error.response; + } catch (parsingError) { + return error.response; + } + } + return error.message; + }; +} diff --git a/server/services/index.ts b/server/services/index.ts index 2dec8d549..7f74da407 100644 --- a/server/services/index.ts +++ b/server/services/index.ts @@ -10,5 +10,15 @@ import ManagedIndexService from "./ManagedIndexService"; import RollupService from "./RollupService"; import TransformService from "./TransformService"; import NotificationService from "./NotificationService"; +import SnapshotManagementService from "./SnapshotManagementService"; -export { IndexService, DataStreamService, PolicyService, ManagedIndexService, RollupService, TransformService, NotificationService }; +export { + IndexService, + DataStreamService, + PolicyService, + ManagedIndexService, + RollupService, + TransformService, + NotificationService, + SnapshotManagementService, +}; diff --git a/server/utils/constants.ts b/server/utils/constants.ts index 8848b56ab..8196ce0b2 100644 --- a/server/utils/constants.ts +++ b/server/utils/constants.ts @@ -10,6 +10,7 @@ export const API_ROUTE_PREFIX_ROLLUP = "/_plugins/_rollup"; export const TRANSFORM_ROUTE_PREFIX = "/_plugins/_transform"; export const NOTIFICATIONS_API_ROUTE_PREFIX = "/_plugins/_notifications"; export const CHANNELS_ROUTE = `${NOTIFICATIONS_API_ROUTE_PREFIX}/channels`; +export const SM_ROUTE_PREFIX = "/_plugins/_sm"; export const API: IndexManagementApi = { POLICY_BASE: `${API_ROUTE_PREFIX}/policies`, @@ -21,6 +22,7 @@ export const API: IndexManagementApi = { ROLLUP_JOBS_BASE: `${API_ROUTE_PREFIX_ROLLUP}/jobs`, TRANSFORM_BASE: `${TRANSFORM_ROUTE_PREFIX}`, CHANNELS_BASE: `${CHANNELS_ROUTE}`, + SM_POLICY_BASE: `${SM_ROUTE_PREFIX}/policies`, }; export const DEFAULT_HEADERS: DefaultHeaders = { diff --git a/utils/constants.ts b/utils/constants.ts index eb4b10778..d6d16c8f5 100644 --- a/utils/constants.ts +++ b/utils/constants.ts @@ -20,6 +20,9 @@ export const NODE_API = Object.freeze({ RETRY: `${BASE_API_PATH}/retry`, CHANGE_POLICY: `${BASE_API_PATH}/changePolicy`, REMOVE_POLICY: `${BASE_API_PATH}/removePolicy`, + SMPolicies: `${BASE_API_PATH}/smPolicies`, + _SNAPSHOTS: `${BASE_API_PATH}/_snapshots`, + _REPOSITORIES: `${BASE_API_PATH}/_repositores`, }); export const REQUEST = Object.freeze({ From 27afeca8c4ac520d0278367175db5441507519d9 Mon Sep 17 00:00:00 2001 From: bowenlan-amzn Date: Thu, 23 Jun 2022 16:55:21 -0700 Subject: [PATCH 2/2] Change dashboard version to 2.0.0 for cypress to run Signed-off-by: bowenlan-amzn --- .github/workflows/cypress-workflow.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cypress-workflow.yml b/.github/workflows/cypress-workflow.yml index 8769373db..17b0629b6 100644 --- a/.github/workflows/cypress-workflow.yml +++ b/.github/workflows/cypress-workflow.yml @@ -8,7 +8,7 @@ on: - main - development-* env: - OPENSEARCH_DASHBOARDS_VERSION: '2.0' + OPENSEARCH_DASHBOARDS_VERSION: '2.0.0' OPENSEARCH_VERSION: '2.0.0-SNAPSHOT' jobs: tests: