diff --git a/x-pack/plugins/watcher/common/constants/index.ts b/x-pack/plugins/watcher/common/constants/index.ts index 4260688c6eb451..d9156e132c038a 100644 --- a/x-pack/plugins/watcher/common/constants/index.ts +++ b/x-pack/plugins/watcher/common/constants/index.ts @@ -23,3 +23,4 @@ export { WATCH_HISTORY } from './watch_history'; export { WATCH_STATES } from './watch_states'; export { WATCH_TYPES } from './watch_types'; export { ERROR_CODES } from './error_codes'; +export { WATCH_TABS, WATCH_TAB_ID_EDIT, WATCH_TAB_ID_SIMULATE } from './watch_tabs'; diff --git a/x-pack/plugins/watcher/common/constants/time_units.ts b/x-pack/plugins/watcher/common/constants/time_units.ts index c861d47416a804..9b79f163e5831a 100644 --- a/x-pack/plugins/watcher/common/constants/time_units.ts +++ b/x-pack/plugins/watcher/common/constants/time_units.ts @@ -5,6 +5,7 @@ */ export const TIME_UNITS: { [key: string]: string } = { + MILLISECOND: 'ms', SECOND: 's', MINUTE: 'm', HOUR: 'h', diff --git a/x-pack/plugins/watcher/common/constants/watch_tabs.ts b/x-pack/plugins/watcher/common/constants/watch_tabs.ts new file mode 100644 index 00000000000000..1d741b9610663c --- /dev/null +++ b/x-pack/plugins/watcher/common/constants/watch_tabs.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const WATCH_TAB_ID_EDIT = 'watchEditTab'; +export const WATCH_TAB_ID_SIMULATE = 'watchSimulateTab'; + +interface WatchTab { + id: string; + name: string; +} + +export const WATCH_TABS: WatchTab[] = [ + { + id: WATCH_TAB_ID_EDIT, + name: i18n.translate('xpack.watcher.sections.watchEdit.json.editTabLabel', { + defaultMessage: 'Edit', + }), + }, + { + id: WATCH_TAB_ID_SIMULATE, + name: i18n.translate('xpack.watcher.sections.watchEdit.json.simulateTabLabel', { + defaultMessage: 'Simulate', + }), + }, +]; diff --git a/x-pack/plugins/watcher/common/lib/get_action_type/get_action_type.js b/x-pack/plugins/watcher/common/lib/get_action_type/get_action_type.ts similarity index 57% rename from x-pack/plugins/watcher/common/lib/get_action_type/get_action_type.js rename to x-pack/plugins/watcher/common/lib/get_action_type/get_action_type.ts index 6fdaa81c64260e..95aaef71cd2bef 100644 --- a/x-pack/plugins/watcher/common/lib/get_action_type/get_action_type.js +++ b/x-pack/plugins/watcher/common/lib/get_action_type/get_action_type.ts @@ -4,14 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { keys, values, intersection } from 'lodash'; +import { intersection, keys, values } from 'lodash'; import { ACTION_TYPES } from '../../constants'; -export function getActionType(action) { - const type = intersection( - keys(action), - values(ACTION_TYPES) - )[0] || ACTION_TYPES.UNKNOWN; +export function getActionType(action: { [key: string]: { [key: string]: any } }) { + const type = intersection(keys(action), values(ACTION_TYPES))[0] || ACTION_TYPES.UNKNOWN; return type; } diff --git a/x-pack/plugins/watcher/common/lib/get_action_type/index.js b/x-pack/plugins/watcher/common/lib/get_action_type/index.ts similarity index 100% rename from x-pack/plugins/watcher/common/lib/get_action_type/index.js rename to x-pack/plugins/watcher/common/lib/get_action_type/index.ts diff --git a/x-pack/plugins/watcher/common/types/watch_types.ts b/x-pack/plugins/watcher/common/types/watch_types.ts new file mode 100644 index 00000000000000..545920496bc3a6 --- /dev/null +++ b/x-pack/plugins/watcher/common/types/watch_types.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface ExecutedWatchResults { + id: string; + watchId: string; + details: any; + startTime: Date; + watchStatus: { + state: string; + actionStatuses: Array<{ state: string; lastExecutionReason: string }>; + }; +} + +export interface ExecutedWatchDetails { + triggerData: { + triggeredTime: Date; + scheduledTime: Date; + }; + ignoreCondition: boolean; + alternativeInput: any; + actionModes: { + [key: string]: string; + }; + recordExecution: boolean; + upstreamJson: any; +} + +export interface BaseWatch { + id: string; + type: string; + isNew: boolean; + name: string; + isSystemWatch: boolean; + watchStatus: any; + watchErrors: any; + typeName: string; + displayName: string; + upstreamJson: any; + resetActions: () => void; + createAction: (type: string, actionProps: {}) => void; + validate: () => { warning: { message: string } }; + actions: [ + { + id: string; + type: string; + } + ]; + watch: { + actions: { + [key: string]: { [key: string]: any }; + }; + }; +} diff --git a/x-pack/plugins/watcher/public/components/confirm_watches_modal.tsx b/x-pack/plugins/watcher/public/components/confirm_watches_modal.tsx new file mode 100644 index 00000000000000..61adccb45ebb24 --- /dev/null +++ b/x-pack/plugins/watcher/public/components/confirm_watches_modal.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; + +export const ConfirmWatchesModal = ({ + modalOptions, + callback, +}: { + modalOptions: { message: string } | null; + callback: (isConfirmed?: boolean) => void; +}) => { + if (!modalOptions) { + return null; + } + return ( + + callback()} + onConfirm={() => { + callback(true); + }} + cancelButtonText={i18n.translate( + 'xpack.watcher.sections.watchEdit.json.saveConfirmModal.cancelButtonLabel', + { defaultMessage: 'Cancel' } + )} + confirmButtonText={i18n.translate( + 'xpack.watcher.sections.watchEdit.json.saveConfirmModal.saveButtonLabel', + { defaultMessage: 'Save' } + )} + > + {modalOptions.message} + + + ); +}; diff --git a/x-pack/plugins/watcher/public/lib/api.ts b/x-pack/plugins/watcher/public/lib/api.ts index 4b58d9c9c27c61..2f4056d2712cb9 100644 --- a/x-pack/plugins/watcher/public/lib/api.ts +++ b/x-pack/plugins/watcher/public/lib/api.ts @@ -7,6 +7,8 @@ import { Watch } from 'plugins/watcher/models/watch'; import { __await } from 'tslib'; import chrome from 'ui/chrome'; import { ROUTES } from '../../common/constants'; +import { BaseWatch, ExecutedWatchDetails } from '../../common/types/watch_types'; + let httpClient: ng.IHttpService; export const setHttpClient = (anHttpClient: ng.IHttpService) => { httpClient = anHttpClient; @@ -63,3 +65,14 @@ export const fetchFields = async (indexes: string[]) => { } = await getHttpClient().post(`${basePath}/fields`, { indexes }); return fields; }; +export const createWatch = async (watch: BaseWatch) => { + const { data } = await getHttpClient().put(`${basePath}/watch/${watch.id}`, watch.upstreamJson); + return data; +}; +export const executeWatch = async (executeWatchDetails: ExecutedWatchDetails, watch: BaseWatch) => { + const { data } = await getHttpClient().put(`${basePath}/watch/execute`, { + executeDetails: executeWatchDetails.upstreamJson, + watch: watch.upstreamJson, + }); + return data; +}; diff --git a/x-pack/plugins/watcher/public/lib/documentation_links/documentation_links.js b/x-pack/plugins/watcher/public/lib/documentation_links/documentation_links.ts similarity index 71% rename from x-pack/plugins/watcher/public/lib/documentation_links/documentation_links.js rename to x-pack/plugins/watcher/public/lib/documentation_links/documentation_links.ts index 4a807d37493947..1868a4f372ae0d 100644 --- a/x-pack/plugins/watcher/public/lib/documentation_links/documentation_links.js +++ b/x-pack/plugins/watcher/public/lib/documentation_links/documentation_links.ts @@ -8,6 +8,8 @@ import { makeDocumentationLink } from './make_documentation_link'; export const documentationLinks = { watcher: { - putWatchApi: makeDocumentationLink('{baseUrl}guide/en/elasticsearch/reference/{urlVersion}/watcher-api-put-watch.html') - } + putWatchApi: makeDocumentationLink( + '{baseUrl}guide/en/elasticsearch/reference/{urlVersion}/watcher-api-put-watch.html' + ), + }, }; diff --git a/x-pack/plugins/watcher/public/lib/documentation_links/index.js b/x-pack/plugins/watcher/public/lib/documentation_links/index.ts similarity index 79% rename from x-pack/plugins/watcher/public/lib/documentation_links/index.js rename to x-pack/plugins/watcher/public/lib/documentation_links/index.ts index 2972d0ca806038..98a45e81f4e70a 100644 --- a/x-pack/plugins/watcher/public/lib/documentation_links/index.js +++ b/x-pack/plugins/watcher/public/lib/documentation_links/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { documentationLinks } from './documentation_links.js'; +export { documentationLinks } from './documentation_links'; diff --git a/x-pack/plugins/watcher/public/lib/documentation_links/make_documentation_link.js b/x-pack/plugins/watcher/public/lib/documentation_links/make_documentation_link.ts similarity index 59% rename from x-pack/plugins/watcher/public/lib/documentation_links/make_documentation_link.js rename to x-pack/plugins/watcher/public/lib/documentation_links/make_documentation_link.ts index ebe81ae0f3a0f3..bcd0880a7324cb 100644 --- a/x-pack/plugins/watcher/public/lib/documentation_links/make_documentation_link.js +++ b/x-pack/plugins/watcher/public/lib/documentation_links/make_documentation_link.ts @@ -13,13 +13,6 @@ const minor = semver.minor(metadata.version); const urlVersion = `${major}.${minor}`; const baseUrl = 'https://www.elastic.co/'; -/** - * - * @param {string} linkTemplate Link template containing {baseUrl} and {urlVersion} placeholders - * @return {string} Actual link, with placeholders in template replaced - */ -export function makeDocumentationLink(linkTemplate) { - return linkTemplate - .replace('{baseUrl}', baseUrl) - .replace('{urlVersion}', urlVersion); +export function makeDocumentationLink(linkTemplate: string) { + return linkTemplate.replace('{baseUrl}', baseUrl).replace('{urlVersion}', urlVersion); } diff --git a/x-pack/plugins/watcher/public/models/execute_details/execute_details.js b/x-pack/plugins/watcher/public/models/execute_details/execute_details.js index c6c27d18b77e25..855f805349f599 100644 --- a/x-pack/plugins/watcher/public/models/execute_details/execute_details.js +++ b/x-pack/plugins/watcher/public/models/execute_details/execute_details.js @@ -4,9 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ +import { TIME_UNITS } from '../../../common/constants'; +import moment from 'moment'; + export class ExecuteDetails { constructor(props = {}) { - this.triggeredTime = props.triggeredTime; + this.triggeredTimeValue = props.triggeredTimeValue; + this.triggeredTimeUnit = props.triggeredTimeUnit; + this.scheduledTimeValue = props.scheduledTimeValue; + this.scheduledTimeUnit = props.scheduledTimeUnit; this.scheduledTime = props.scheduledTime; this.ignoreCondition = props.ignoreCondition; this.alternativeInput = props.alternativeInput; @@ -14,14 +20,36 @@ export class ExecuteDetails { this.recordExecution = props.recordExecution; } + formatTime(timeUnit, value) { + let timeValue = moment(); + switch (timeUnit) { + case TIME_UNITS.SECOND: + timeValue = timeValue.add(value, 'seconds'); + break; + case TIME_UNITS.MINUTE: + timeValue = timeValue.add(value, 'minutes'); + break; + case TIME_UNITS.HOUR: + timeValue = timeValue.add(value, 'hours'); + break; + case TIME_UNITS.MILLISECOND: + timeValue = timeValue.add(value, 'milliseconds'); + break; + } + return timeValue.format(); + } + get upstreamJson() { + const hasTriggerTime = this.triggeredTimeValue !== ''; + const hasScheduleTime = this.scheduledTimeValue !== ''; + const formattedTriggerTime = hasTriggerTime ? this.formatTime(this.triggeredTimeUnit, this.triggeredTimeValue) : undefined; + const formattedScheduleTime = hasScheduleTime ? this.formatTime(this.scheduledTimeUnit, this.scheduledTimeValue) : undefined; const triggerData = { - triggeredTime: this.triggeredTime, - scheduledTime: this.scheduledTime, + triggeredTime: formattedTriggerTime, + scheduledTime: formattedScheduleTime, }; - return { - triggerData: triggerData, + triggerData, ignoreCondition: this.ignoreCondition, alternativeInput: this.alternativeInput, actionModes: this.actionModes, diff --git a/x-pack/plugins/watcher/public/models/index.d.ts b/x-pack/plugins/watcher/public/models/index.d.ts index 94668b900d358c..61ed512248a497 100644 --- a/x-pack/plugins/watcher/public/models/index.d.ts +++ b/x-pack/plugins/watcher/public/models/index.d.ts @@ -9,3 +9,20 @@ declare module 'plugins/watcher/models/watch' { declare module 'plugins/watcher/models/watch/threshold_watch' { export const ThresholdWatch: any; } +declare module 'plugins/watcher/models/watch/json_watch' { + export const JsonWatch: any; +} + +declare module 'plugins/watcher/models/execute_details/execute_details' { + export const ExecuteDetails: any; +} + +declare module 'plugins/watcher/models/watch_history_item' { + export const WatchHistoryItem: any; +} + +// TODO: Remove once typescript definitions are in EUI +declare module '@elastic/eui' { + export const EuiCodeEditor: React.SFC; + export const EuiDescribedFormGroup: React.SFC; +} diff --git a/x-pack/plugins/watcher/public/models/watch/base_watch.js b/x-pack/plugins/watcher/public/models/watch/base_watch.js index 8363ebaf05cc34..5cd96dce91690c 100644 --- a/x-pack/plugins/watcher/public/models/watch/base_watch.js +++ b/x-pack/plugins/watcher/public/models/watch/base_watch.js @@ -25,7 +25,7 @@ export class BaseWatch { * @param {array} props.actions Action definitions */ constructor(props = {}) { - this.id = get(props, 'id'); + this.id = get(props, 'id', ''); this.type = get(props, 'type'); this.isNew = get(props, 'isNew', true); diff --git a/x-pack/plugins/watcher/public/sections/watch_edit/components/json_watch_edit_component.tsx b/x-pack/plugins/watcher/public/sections/watch_edit/components/json_watch_edit_component.tsx new file mode 100644 index 00000000000000..47e1b26dc366e0 --- /dev/null +++ b/x-pack/plugins/watcher/public/sections/watch_edit/components/json_watch_edit_component.tsx @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext, useState } from 'react'; + +import { + EuiFlexGroup, + EuiFlexItem, + EuiPageContent, + EuiSpacer, + EuiTab, + EuiTabs, + EuiTitle, +} from '@elastic/eui'; +import { injectI18n } from '@kbn/i18n/react'; +import { WATCH_TAB_ID_EDIT, WATCH_TAB_ID_SIMULATE, WATCH_TABS } from '../../../../common/constants'; +import { JsonWatchEditForm } from './json_watch_edit_form'; +import { JsonWatchEditSimulate } from './json_watch_edit_simulate'; +import { WatchContext } from './watch_context'; + +const JsonWatchEditUi = ({ + pageTitle, + kbnUrl, + licenseService, +}: { + pageTitle: string; + kbnUrl: any; + licenseService: any; +}) => { + const { watch } = useContext(WatchContext); + // hooks + const [selectedTab, setSelectedTab] = useState(WATCH_TAB_ID_EDIT); + const [watchErrors, setWatchErrors] = useState<{ [key: string]: string[] }>({ + watchId: [], + watchJson: [], + }); + const [isShowingWatchErrors, setIsShowingWatchErrors] = useState(false); + const [executeWatchJsonString, setExecuteWatchJsonString] = useState(''); + const [isShowingExecuteWatchErrors, setIsShowingExecuteWatchErrors] = useState(false); + const [executeWatchErrors, setExecuteWatchErrors] = useState<{ [key: string]: string[] }>({ + simulateExecutionInputOverride: [], + }); + // ace editor requires json to be in string format + const [watchJsonString, setWatchJsonString] = useState( + JSON.stringify(watch.watch, null, 2) + ); + return ( + + + + +

{pageTitle}

+
+
+
+ + {WATCH_TABS.map((tab, index) => ( + { + setSelectedTab(tab.id); + }} + isSelected={tab.id === selectedTab} + key={index} + > + {tab.name} + + ))} + + + {selectedTab === WATCH_TAB_ID_SIMULATE && ( + setExecuteWatchJsonString(json)} + errors={executeWatchErrors} + setErrors={(errors: { [key: string]: string[] }) => setExecuteWatchErrors(errors)} + isShowingErrors={isShowingExecuteWatchErrors} + setIsShowingErrors={(isShowingErrors: boolean) => + setIsShowingExecuteWatchErrors(isShowingErrors) + } + isDisabled={isShowingExecuteWatchErrors || isShowingWatchErrors} + /> + )} + {selectedTab === WATCH_TAB_ID_EDIT && ( + setWatchJsonString(json)} + errors={watchErrors} + setErrors={(errors: { [key: string]: string[] }) => setWatchErrors(errors)} + isShowingErrors={isShowingWatchErrors} + setIsShowingErrors={(isShowingErrors: boolean) => + setIsShowingWatchErrors(isShowingErrors) + } + /> + )} +
+ ); +}; + +export const JsonWatchEdit = injectI18n(JsonWatchEditUi); diff --git a/x-pack/plugins/watcher/public/sections/watch_edit/components/json_watch_edit_form.tsx b/x-pack/plugins/watcher/public/sections/watch_edit/components/json_watch_edit_form.tsx new file mode 100644 index 00000000000000..d2e77c938cfadd --- /dev/null +++ b/x-pack/plugins/watcher/public/sections/watch_edit/components/json_watch_edit_form.tsx @@ -0,0 +1,225 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, useContext, useState } from 'react'; + +import { + EuiButton, + EuiButtonEmpty, + EuiCodeEditor, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiLink, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { JsonWatch } from 'plugins/watcher/models/watch/json_watch'; +import { ConfirmWatchesModal } from '../../../components/confirm_watches_modal'; +import { ErrableFormRow } from '../../../components/form_errors'; +import { documentationLinks } from '../../../lib/documentation_links'; +import { onWatchSave, saveWatch } from '../json_watch_edit_actions'; +import { WatchContext } from './watch_context'; + +const JSON_WATCH_IDS = { + ID: 'watchId', + NAME: 'watchName', + JSON: 'watchJson', +}; + +function validateId(id: string) { + const regex = /^[A-Za-z0-9\-\_]+$/; + if (!id) { + return i18n.translate('xpack.watcher.sections.watchEdit.json.error.requiredIdText', { + defaultMessage: 'ID is required', + }); + } else if (!regex.test(id)) { + return i18n.translate('xpack.watcher.sections.watchEdit.json.error.invalidIdText', { + defaultMessage: 'ID must only letters, underscores, dashes, and numbers.', + }); + } + return false; +} + +export const JsonWatchEditForm = ({ + kbnUrl, + licenseService, + watchJsonString, + setWatchJsonString, + errors, + setErrors, + isShowingErrors, + setIsShowingErrors, +}: { + kbnUrl: any; + licenseService: any; + watchJsonString: string; + setWatchJsonString: (json: string) => void; + errors: { [key: string]: string[] }; + setErrors: (errors: { [key: string]: string[] }) => void; + isShowingErrors: boolean; + setIsShowingErrors: (isShowingErrors: boolean) => void; +}) => { + const { watch, setWatch } = useContext(WatchContext); + // hooks + const [modal, setModal] = useState<{ message: string } | null>(null); + return ( + + { + if (isConfirmed) { + saveWatch(watch, kbnUrl, licenseService); + } + setModal(null); + }} + /> + + + ) => { + const id = e.target.value; + const error = validateId(id); + const newErrors = { ...errors, [JSON_WATCH_IDS.ID]: error ? [error] : [] }; + const isInvalidForm = !!Object.keys(newErrors).find( + errorKey => newErrors[errorKey].length >= 1 + ); + setErrors(newErrors); + setIsShowingErrors(isInvalidForm); + setWatch(new JsonWatch({ ...watch, id })); + }} + /> + + + ) => { + setWatch(new JsonWatch({ ...watch, name: e.target.value })); + }} + /> + + + {i18n.translate('xpack.watcher.sections.watchEdit.json.form.watchJsonLabel', { + defaultMessage: 'Watch JSON', + })}{' '} + ( + + {i18n.translate('xpack.watcher.sections.watchEdit.json.form.watchJsonDocLink', { + defaultMessage: 'Syntax', + })} + + ) + + } + errorKey={JSON_WATCH_IDS.JSON} + isShowingErrors={isShowingErrors} + fullWidth + errors={errors} + > + { + setWatchJsonString(json); + try { + const watchJson = JSON.parse(json); + if (watchJson && typeof watchJson === 'object') { + setWatch( + new JsonWatch({ + ...watch, + watch: watchJson, + }) + ); + const newErrors = { ...errors, [JSON_WATCH_IDS.JSON]: [] }; + const isInvalidForm = !!Object.keys(newErrors).find( + errorKey => newErrors[errorKey].length >= 1 + ); + setErrors(newErrors); + setIsShowingErrors(isInvalidForm); + } + } catch (e) { + setErrors({ + ...errors, + [JSON_WATCH_IDS.JSON]: [ + i18n.translate('xpack.watcher.sections.watchEdit.json.error.invalidJsonText', { + defaultMessage: 'Invalid JSON', + }), + ], + }); + setIsShowingErrors(true); + } + }} + /> + + + + { + const error = validateId(watch.id); + setErrors({ ...errors, [JSON_WATCH_IDS.ID]: error ? [error] : [] }); + setIsShowingErrors(!!error); + if (!error) { + const savedWatch = await onWatchSave(watch, kbnUrl, licenseService); + if (savedWatch && savedWatch.error) { + return setModal(savedWatch.error); + } + } + }} + > + {i18n.translate('xpack.watcher.sections.watchEdit.json.saveButtonLabel', { + defaultMessage: 'Save', + })} + + + + + {i18n.translate('xpack.watcher.sections.watchEdit.json.cancelButtonLabel', { + defaultMessage: 'Cancel', + })} + + + + + + ); +}; diff --git a/x-pack/plugins/watcher/public/sections/watch_edit/components/json_watch_edit_simulate.tsx b/x-pack/plugins/watcher/public/sections/watch_edit/components/json_watch_edit_simulate.tsx new file mode 100644 index 00000000000000..51977ed724c755 --- /dev/null +++ b/x-pack/plugins/watcher/public/sections/watch_edit/components/json_watch_edit_simulate.tsx @@ -0,0 +1,484 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, useContext, useState } from 'react'; + +import { + EuiBasicTable, + EuiButton, + EuiCodeEditor, + EuiDescribedFormGroup, + EuiFieldNumber, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiSelect, + EuiSpacer, + EuiSwitch, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { map } from 'lodash'; +import { ExecuteDetails } from 'plugins/watcher/models/execute_details/execute_details'; +import { WatchHistoryItem } from 'plugins/watcher/models/watch_history_item'; +import { toastNotifications } from 'ui/notify'; +import { ACTION_MODES, TIME_UNITS } from '../../../../common/constants'; +import { getActionType } from '../../../../common/lib/get_action_type'; +import { BaseWatch, ExecutedWatchResults } from '../../../../common/types/watch_types'; +import { ErrableFormRow } from '../../../components/form_errors'; +import { executeWatch } from '../../../lib/api'; +import { WatchContext } from '../../../sections/watch_edit/components/watch_context'; +import { timeUnits } from '../time_units'; +import { JsonWatchEditSimulateResults } from './json_watch_edit_simulate_results'; + +interface TableDataRow { + actionId: string | undefined; + actionMode: string | undefined; + type: string; +} + +interface TableData extends Array {} + +const EXECUTE_DETAILS_INITIAL_STATE = { + triggeredTimeValue: 0, + triggeredTimeUnit: TIME_UNITS.MILLISECOND, + scheduledTimeValue: 0, + scheduledTimeUnit: TIME_UNITS.SECOND, + ignoreCondition: false, +}; + +const INPUT_OVERRIDE_ID = 'simulateExecutionInputOverride'; + +function getTableData(watch: BaseWatch) { + const actions = watch.watch && watch.watch.actions; + return map(actions, (action, actionId) => { + const type = getActionType(action); + return { + actionId, + type, + actionMode: ACTION_MODES.SIMULATE, + }; + }); +} + +function getActionModes(items: TableData) { + const result = items.reduce((itemsAccum: any, item) => { + if (item.actionId) { + itemsAccum[item && item.actionId] = item.actionMode; + } + return itemsAccum; + }, {}); + return result; +} + +export const JsonWatchEditSimulate = ({ + executeWatchJsonString, + setExecuteWatchJsonString, + errors, + setErrors, + isShowingErrors, + setIsShowingErrors, + isDisabled, +}: { + executeWatchJsonString: string; + setExecuteWatchJsonString: (json: string) => void; + errors: { [key: string]: string[] }; + setErrors: (errors: { [key: string]: string[] }) => void; + isShowingErrors: boolean; + setIsShowingErrors: (isShowingErrors: boolean) => void; + isDisabled: boolean; +}) => { + const { watch } = useContext(WatchContext); + const tableData = getTableData(watch); + + // hooks + const [executeDetails, setExecuteDetails] = useState( + new ExecuteDetails({ + ...EXECUTE_DETAILS_INITIAL_STATE, + actionModes: getActionModes(tableData), + }) + ); + const [executeResults, setExecuteResults] = useState(null); + + const columns = [ + { + field: 'actionId', + name: i18n.translate('xpack.watcher.sections.watchEdit.simulate.table.idColumnLabel', { + defaultMessage: 'ID', + }), + sortable: true, + truncateText: true, + }, + { + field: 'type', + name: i18n.translate('xpack.watcher.sections.watchEdit.simulate.table.typeColumnLabel', { + defaultMessage: 'Type', + }), + truncateText: true, + }, + { + field: 'actionMode', + name: i18n.translate('xpack.watcher.sections.watchEdit.simulate.table.modeColumnLabel', { + defaultMessage: 'Mode', + }), + render: ({}, row: { actionId: string }) => ( + { + setExecuteDetails( + new ExecuteDetails({ + ...executeDetails, + actionModes: { ...executeDetails.actionModes, [row.actionId]: e.target.value }, + }) + ); + }} + aria-label={i18n.translate( + 'xpack.watcher.sections.watchEdit.simulate.table.modeSelectLabel', + { + defaultMessage: 'Action modes', + } + )} + /> + ), + }, + ]; + + return ( + + {executeResults && ( + setExecuteResults(null)} + /> + )} + +

+ {i18n.translate('xpack.watcher.sections.watchEdit.simulate.pageDescription', { + defaultMessage: 'Modify the fields below to simulate a watch execution.', + })} +

+
+ + + + {i18n.translate( + 'xpack.watcher.sections.watchEdit.simulate.form.triggerOverridesTitle', + { defaultMessage: 'Trigger overrides' } + )} + + } + description={i18n.translate( + 'xpack.watcher.sections.watchEdit.simulate.form.triggerOverridesDescription', + { + defaultMessage: + 'These fields are parsed as the data of the trigger event that will be used during the watch execution.', + } + )} + > + + + + { + const value = e.target.value; + setExecuteDetails( + new ExecuteDetails({ + ...executeDetails, + scheduledTimeValue: value === '' ? value : parseInt(value, 10), + }) + ); + }} + /> + + + { + setExecuteDetails( + new ExecuteDetails({ + ...executeDetails, + scheduledTimeUnit: e.target.value, + }) + ); + }} + /> + + + + + + + { + const value = e.target.value; + setExecuteDetails( + new ExecuteDetails({ + ...executeDetails, + triggeredTimeValue: value === '' ? value : parseInt(value, 10), + }) + ); + }} + /> + + + { + setExecuteDetails( + new ExecuteDetails({ + ...executeDetails, + triggeredTimeUnit: e.target.value, + }) + ); + }} + /> + + + + + + {i18n.translate( + 'xpack.watcher.sections.watchEdit.simulate.form.inputOverridesTitle', + { defaultMessage: 'Input overrides' } + )} + + } + description={i18n.translate( + 'xpack.watcher.sections.watchEdit.simulate.form.inputOverridesDescription', + { + defaultMessage: + 'When present, the watch uses this object as a payload instead of executing its own input.', + } + )} + > + + { + setExecuteWatchJsonString(json); + try { + const alternativeInput = json === '' ? undefined : JSON.parse(json); + if ( + typeof alternativeInput === 'undefined' || + (alternativeInput && typeof alternativeInput === 'object') + ) { + setExecuteDetails( + new ExecuteDetails({ + ...executeDetails, + alternativeInput, + }) + ); + setIsShowingErrors(false); + setErrors({ ...errors, [INPUT_OVERRIDE_ID]: [] }); + } + } catch (e) { + setErrors({ + ...errors, + [INPUT_OVERRIDE_ID]: [ + i18n.translate( + 'xpack.watcher.sections.watchEdit.simulate.form.alternativeInputFieldError', + { + defaultMessage: 'Invalid JSON', + } + ), + ], + }); + setIsShowingErrors(true); + } + }} + /> + + + + {i18n.translate( + 'xpack.watcher.sections.watchEdit.simulate.form.conditionOverridesTitle', + { defaultMessage: 'Condition overrides' } + )} + + } + description={i18n.translate( + 'xpack.watcher.sections.watchEdit.simulate.form.conditionOverridesDescription', + { + defaultMessage: 'When enabled, the watch execution uses the Always Condition.', + } + )} + > + { + setExecuteDetails( + new ExecuteDetails({ ...executeDetails, ignoreCondition: e.target.checked }) + ); + }} + /> + + + {i18n.translate( + 'xpack.watcher.sections.watchEdit.simulate.form.actionOverridesTitle', + { defaultMessage: 'Action overrides' } + )} + + } + description={i18n.translate( + 'xpack.watcher.sections.watchEdit.simulate.form.actionOverridesDescription', + { + defaultMessage: + 'The action modes determine how to handle the watch actions as part of the watch execution.', + } + )} + > + + + + + { + try { + const executedWatch = await executeWatch(executeDetails, watch); + const formattedResults = WatchHistoryItem.fromUpstreamJson( + executedWatch.watchHistoryItem + ); + setExecuteResults(formattedResults); + } catch (e) { + return toastNotifications.addDanger(e.data.message); + } + }} + > + {i18n.translate('xpack.watcher.sections.watchEdit.simulate.form.saveButtonLabel', { + defaultMessage: 'Simulate watch', + })} + + +
+ ); +}; diff --git a/x-pack/plugins/watcher/public/sections/watch_edit/components/json_watch_edit_simulate_results.tsx b/x-pack/plugins/watcher/public/sections/watch_edit/components/json_watch_edit_simulate_results.tsx new file mode 100644 index 00000000000000..cc67aefe98650e --- /dev/null +++ b/x-pack/plugins/watcher/public/sections/watch_edit/components/json_watch_edit_simulate_results.tsx @@ -0,0 +1,174 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { + EuiBasicTable, + EuiCodeBlock, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiHealth, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { find } from 'lodash'; +import { WATCH_STATES } from '../../../../common/constants'; +import { + BaseWatch, + ExecutedWatchDetails, + ExecutedWatchResults, +} from '../../../../common/types/watch_types'; + +const WATCH_ICON_COLORS = { + [WATCH_STATES.DISABLED]: 'subdued', + [WATCH_STATES.OK]: 'success', + [WATCH_STATES.FIRING]: 'warning', + [WATCH_STATES.ERROR]: 'danger', + [WATCH_STATES.CONFIG_ERROR]: 'danger', +}; + +export const JsonWatchEditSimulateResults = ({ + executeDetails, + executeResults, + onCloseFlyout, + watch, +}: { + executeDetails: ExecutedWatchDetails; + executeResults: ExecutedWatchResults; + onCloseFlyout: () => void; + watch: BaseWatch; +}) => { + const getTableData = () => { + const actions = watch.actions; + const actionStatuses = executeResults.watchStatus.actionStatuses; + const actionModes = executeDetails.actionModes; + const actionDetails = actions.map(action => { + const actionMode = actionModes[action.id]; + const actionStatus = find(actionStatuses, { id: action.id }); + + return { + actionId: action.id, + actionType: action.type, + actionMode, + actionState: actionStatus && actionStatus.state, + actionReason: actionStatus && actionStatus.lastExecutionReason, + }; + }); + return actionDetails; + }; + + const tableData = getTableData(); + + const columns = [ + { + field: 'actionId', + name: i18n.translate( + 'xpack.watcher.sections.watchEdit.simulateResults.table.actionColumnLabel', + { + defaultMessage: 'ID', + } + ), + sortable: true, + truncateText: true, + }, + { + field: 'actionType', + name: i18n.translate( + 'xpack.watcher.sections.watchEdit.simulateResults.table.typeColumnLabel', + { + defaultMessage: 'Type', + } + ), + truncateText: true, + }, + { + field: 'actionMode', + name: i18n.translate( + 'xpack.watcher.sections.watchEdit.simulateResults.table.modeColumnLabel', + { + defaultMessage: 'Mode', + } + ), + }, + { + field: 'actionState', + name: i18n.translate( + 'xpack.watcher.sections.watchEdit.simulateResults.table.stateColumnLabel', + { + defaultMessage: 'State', + } + ), + dataType: 'string', + render: (actionState: string) => { + return {actionState}; + }, + }, + { + field: 'actionReason', + name: i18n.translate( + 'xpack.watcher.sections.watchEdit.simulateResults.table.reasonColumnLabel', + { + defaultMessage: 'Reason', + } + ), + }, + ]; + + return ( + { + onCloseFlyout(); + }} + aria-labelledby="simulateResultsFlyOutTitle" + > + + +

+ {i18n.translate('xpack.watcher.sections.watchEdit.simulateResults.title', { + defaultMessage: 'Simulation results', + })}{' '} + + {executeResults.watchStatus.state} + +

+
+
+ + +
+ {i18n.translate( + 'xpack.watcher.sections.watchEdit.simulateResults.actionsSectionTitle', + { + defaultMessage: 'Actions', + } + )} +
+
+ + + + +
+ {i18n.translate( + 'xpack.watcher.sections.watchEdit.simulateResults.simulationOutputSectionTitle', + { + defaultMessage: 'Simulation output', + } + )} +
+
+ + + {JSON.stringify(executeResults.details, null, 2)} + +
+
+ ); +}; diff --git a/x-pack/plugins/watcher/public/sections/watch_edit/components/threshold_watch_edit_component.tsx b/x-pack/plugins/watcher/public/sections/watch_edit/components/threshold_watch_edit_component.tsx index df4f97ca7e88ab..883800e57ea453 100644 --- a/x-pack/plugins/watcher/public/sections/watch_edit/components/threshold_watch_edit_component.tsx +++ b/x-pack/plugins/watcher/public/sections/watch_edit/components/threshold_watch_edit_component.tsx @@ -39,17 +39,6 @@ const firstFieldOption = { }), value: '', }; -const getTitle = (watch: any) => { - if (watch.isNew) { - const typeName = watch.typeName.toLowerCase(); - return i18n.translate('xpack.watcher.sections.watchEdit.titlePanel.createNewTypeOfWatchTitle', { - defaultMessage: 'Create a new {typeName}', - values: { typeName }, - }); - } else { - return watch.name; - } -}; const getFields = async (indices: string[]) => { return await fetchFields(indices); }; @@ -125,9 +114,11 @@ const getIndexOptions = async (patternString: string, indexPatterns: string[]) = const ThresholdWatchEditUi = ({ intl, savedObjectsClient, + pageTitle, }: { intl: InjectedIntl; savedObjectsClient: any; + pageTitle: string; }) => { // hooks const [indexPatterns, setIndexPatterns] = useState([]); @@ -164,7 +155,7 @@ const ThresholdWatchEditUi = ({ -

{getTitle(watch)}

+

{pageTitle}

@@ -229,7 +220,7 @@ const ThresholdWatchEditUi = ({ setWatch(new ThresholdWatch(watch)); const indices = selected.map(s => s.value as string); const theFields = await getFields(indices); - setFields(theFieldsO); + setFields(theFields); setTimeFieldOptions(getTimeFieldOptions(fields)); }} diff --git a/x-pack/plugins/watcher/public/sections/watch_edit/components/watch_edit.tsx b/x-pack/plugins/watcher/public/sections/watch_edit/components/watch_edit.tsx index 45a46076fc9030..51a8e692e49300 100644 --- a/x-pack/plugins/watcher/public/sections/watch_edit/components/watch_edit.tsx +++ b/x-pack/plugins/watcher/public/sections/watch_edit/components/watch_edit.tsx @@ -4,22 +4,47 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiLoadingSpinner } from '@elastic/eui'; -import { EuiSpacer } from '@elastic/eui'; +import { EuiLoadingSpinner, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { Watch } from 'plugins/watcher/models/watch'; import React, { useEffect, useState } from 'react'; +import { WATCH_TYPES } from '../../../../common/constants'; +import { BaseWatch } from '../../../../common/types/watch_types'; import { loadWatch } from '../../../lib/api'; +import { JsonWatchEdit } from './json_watch_edit_component'; import { ThresholdWatchEdit } from './threshold_watch_edit_component'; import { WatchContext } from './watch_context'; +const getTitle = (watch: BaseWatch) => { + if (watch.isNew) { + const typeName = watch.typeName.toLowerCase(); + return i18n.translate( + 'xpack.watcher.sections.watchEdit.json.titlePanel.createNewTypeOfWatchTitle', + { + defaultMessage: 'Create a new {typeName}', + values: { typeName }, + } + ); + } else { + return i18n.translate('xpack.watcher.sections.watchEdit.json.titlePanel.editWatchTitle', { + defaultMessage: 'Edit {watchName}', + values: { watchName: watch.name ? watch.name : watch.id }, + }); + } +}; + export const WatchEdit = ({ watchId, watchType, savedObjectsClient, + kbnUrl, + licenseService, }: { watchId: string; watchType: string; savedObjectsClient: any; + kbnUrl: any; + licenseService: any; }) => { // hooks const [watch, setWatch] = useState(null); @@ -41,15 +66,24 @@ export const WatchEdit = ({ if (!watch) { return ; } + const pageTitle = getTitle(watch); let EditComponent = null; - if (watch.type === 'threshold') { + if (watch.type === WATCH_TYPES.THRESHOLD) { EditComponent = ThresholdWatchEdit; } else { EditComponent = EuiSpacer; } + if (watch.type === WATCH_TYPES.JSON) { + EditComponent = JsonWatchEdit; + } return ( - + ); }; diff --git a/x-pack/plugins/watcher/public/sections/watch_edit/json_watch_edit_actions.ts b/x-pack/plugins/watcher/public/sections/watch_edit/json_watch_edit_actions.ts new file mode 100644 index 00000000000000..2eec64dab2404c --- /dev/null +++ b/x-pack/plugins/watcher/public/sections/watch_edit/json_watch_edit_actions.ts @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { toastNotifications } from 'ui/notify'; +import { ACTION_TYPES } from '../../../common/constants'; +import { BaseWatch } from '../../../common/types/watch_types'; +import { createWatch, loadWatch } from '../../lib/api'; + +/** + * Get the type from an action where a key defines its type. + * eg: { email: { ... } } | { slack: { ... } } + */ +function getTypeFromAction(action: { [key: string]: any }) { + const actionKeys = Object.keys(action); + let type; + Object.keys(ACTION_TYPES).forEach(k => { + if (actionKeys.includes(ACTION_TYPES[k])) { + type = ACTION_TYPES[k]; + } + }); + + return type ? type : ACTION_TYPES.UNKNOWN; +} + +function getPropsFromAction(type: string, action: { [key: string]: any }) { + if (type === ACTION_TYPES.SLACK) { + // Slack action has its props inside the "message" object + return action[type].message; + } + return action[type]; +} + +/** + * Actions instances are not automatically added to the Watch _actions_ Array + * when we add them in the Json editor. This method takes takes care of it. + */ +function createActionsForWatch(watchInstance: BaseWatch) { + watchInstance.resetActions(); + + let action; + let type; + let actionProps; + + Object.keys(watchInstance.watch.actions).forEach(k => { + action = watchInstance.watch.actions[k]; + type = getTypeFromAction(action); + actionProps = getPropsFromAction(type, action); + watchInstance.createAction(type, actionProps); + }); + return watchInstance; +} + +export async function saveWatch(watch: BaseWatch, kbnUrl: any, licenseService: any) { + try { + await createWatch(watch); + toastNotifications.addSuccess( + i18n.translate('xpack.watcher.sections.watchEdit.json.saveSuccessNotificationText', { + defaultMessage: "Saved '{watchDisplayName}'", + values: { + watchDisplayName: watch.displayName, + }, + }) + ); + // TODO: Not correctly redirecting back to /watches route + kbnUrl.change('/management/elasticsearch/watcher/watches', {}); + } catch (error) { + return licenseService + .checkValidity() + .then(() => toastNotifications.addDanger(error.data.message)); + } +} + +export async function validateActionsAndSaveWatch( + watch: BaseWatch, + kbnUrl: any, + licenseService: any +) { + const { warning } = watch.validate(); + if (warning) { + return { + error: { + message: warning.message, + }, + }; + } + // client validation passed, make request to create watch + saveWatch(watch, kbnUrl, licenseService); +} + +export async function onWatchSave( + watch: BaseWatch, + kbnUrl: any, + licenseService: any +): Promise { + const watchActions = watch.watch && watch.watch.actions; + const watchData = watchActions ? createActionsForWatch(watch) : watch; + + if (!watchData.isNew) { + return validateActionsAndSaveWatch(watch, kbnUrl, licenseService); + } + + try { + const existingWatch = await loadWatch(watchData.id); + if (existingWatch) { + return { + error: { + message: i18n.translate( + 'xpack.watcher.sections.watchEdit.json.saveConfirmModal.descriptionText', + { + defaultMessage: + 'Watch with ID "{watchId}" {watchNameMessageFragment} already exists. Do you want to overwrite it?', + values: { + watchId: existingWatch.id, + watchNameMessageFragment: existingWatch.name + ? i18n.translate( + 'xpack.watcher.sections.watchEdit.json.saveConfirmModal.descriptionFragmentText', + { + defaultMessage: '(name: "{existingWatchName}")', + values: { + existingWatchName: existingWatch.name, + }, + } + ) + : '', + }, + } + ), + }, + }; + } + } catch (error) { + // Confirms watcher does not already exist + return licenseService.checkValidity().then(() => { + if (error.status === 404) { + return validateActionsAndSaveWatch(watchData, kbnUrl, licenseService); + } + return toastNotifications.addDanger(error.data.message); + }); + } +} diff --git a/x-pack/plugins/watcher/public/sections/watch_edit/time_units.ts b/x-pack/plugins/watcher/public/sections/watch_edit/time_units.ts index a4fd9e3fddbcf2..3b5ed35044f48b 100644 --- a/x-pack/plugins/watcher/public/sections/watch_edit/time_units.ts +++ b/x-pack/plugins/watcher/public/sections/watch_edit/time_units.ts @@ -12,6 +12,14 @@ interface TimeUnit { labelSingular: string; } export const timeUnits: { [key: string]: TimeUnit } = { + [COMMON_TIME_UNITS.MILLISECOND]: { + labelPlural: i18n.translate('xpack.watcher.timeUnits.millisecondPluralLabel', { + defaultMessage: 'milliseconds', + }), + labelSingular: i18n.translate('xpack.watcher.timeUnits.millisecondSingularLabel', { + defaultMessage: 'millisecond', + }), + }, [COMMON_TIME_UNITS.SECOND]: { labelPlural: i18n.translate('xpack.watcher.timeUnits.secondPluralLabel', { defaultMessage: 'seconds', diff --git a/x-pack/plugins/watcher/public/sections/watch_edit/watch_edit_route.js b/x-pack/plugins/watcher/public/sections/watch_edit/watch_edit_route.js index bb155ee91d1072..b6f3b51d8471be 100644 --- a/x-pack/plugins/watcher/public/sections/watch_edit/watch_edit_route.js +++ b/x-pack/plugins/watcher/public/sections/watch_edit/watch_edit_route.js @@ -23,10 +23,16 @@ import { manageAngularLifecycle } from '../../lib/manage_angular_lifecycle'; import { WatchEdit } from './components/watch_edit'; let elem; -const renderReact = async (elem, watchType, watchId, savedObjectsClient) => { +const renderReact = async (elem, watchType, watchId, savedObjectsClient, kbnUrl, licenseService) => { render( - + , elem ); @@ -42,6 +48,8 @@ routes controller: class WatchEditRouteController { constructor($injector, $scope, $http, Private) { const $route = $injector.get('$route'); + const kbnUrl = $injector.get('kbnUrl'); + const licenseService = $injector.get('xpackWatcherLicenseService'); this.watch = $route.current.locals.xpackWatch; this.WATCH_TYPES = WATCH_TYPES; const watchId = $route.current.params.id; @@ -56,7 +64,7 @@ routes elem = document.getElementById('watchEditReactRoot'); const savedObjectsClient = Private(SavedObjectsClientProvider); - renderReact(elem, watchType, watchId, savedObjectsClient); + renderReact(elem, watchType, watchId, savedObjectsClient, kbnUrl, licenseService); manageAngularLifecycle($scope, $route, elem); }); } diff --git a/x-pack/plugins/watcher/server/models/execute_details/execute_details.js b/x-pack/plugins/watcher/server/models/execute_details/execute_details.js index 2c67c4896cbcdc..229bd29f07e2d4 100644 --- a/x-pack/plugins/watcher/server/models/execute_details/execute_details.js +++ b/x-pack/plugins/watcher/server/models/execute_details/execute_details.js @@ -18,7 +18,7 @@ export class ExecuteDetails { get upstreamJson() { const triggerData = { triggered_time: this.triggerData.triggeredTime, - scheduled_time: this.triggerData.scheduledTime + scheduled_time: this.triggerData.scheduledTime, }; const result = {