diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 327f691f486e8..82ada1663827e 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -31355,8 +31355,6 @@ "xpack.watcher.models.watchHistoryItem.idPropertyMissingBadRequestMessage": "L'argument JSON doit contenir une propriété {id}", "xpack.watcher.models.watchHistoryItem.watchHistoryItemJsonPropertyMissingBadRequestMessage": "L'argument JSON doit contenir une propriété {watchHistoryItemJson}", "xpack.watcher.models.watchHistoryItem.watchIdPropertyMissingBadRequestMessage": "L'argument JSON doit contenir une propriété {watchId}", - "xpack.watcher.models.watchStatus.idPropertyMissingBadRequestMessage": "L'argument JSON doit contenir une propriété {id}", - "xpack.watcher.models.watchStatus.watchStatusJsonPropertyMissingBadRequestMessage": "L'argument JSON doit contenir une propriété {watchStatusJson}", "xpack.watcher.models.webhookAction.selectMessageText": "Envoyer une requête à un service Web.", "xpack.watcher.models.webhookAction.simulateButtonLabel": "Envoyer la requête", "xpack.watcher.models.webhookAction.simulateFailMessage": "Impossible d'envoyer la requête vers {fullPath}.", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index c7ab1c1b006d6..f0b52ae8beea7 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -31431,8 +31431,6 @@ "xpack.watcher.models.watchHistoryItem.idPropertyMissingBadRequestMessage": "json 引数には {id} プロパティが含まれている必要があります", "xpack.watcher.models.watchHistoryItem.watchHistoryItemJsonPropertyMissingBadRequestMessage": "json 引数には {watchHistoryItemJson} プロパティが含まれている必要があります", "xpack.watcher.models.watchHistoryItem.watchIdPropertyMissingBadRequestMessage": "json 引数には {watchId} プロパティが含まれている必要があります", - "xpack.watcher.models.watchStatus.idPropertyMissingBadRequestMessage": "json 引数には {id} プロパティが含まれている必要があります", - "xpack.watcher.models.watchStatus.watchStatusJsonPropertyMissingBadRequestMessage": "json 引数には {watchStatusJson} プロパティが含まれている必要があります", "xpack.watcher.models.webhookAction.selectMessageText": "Web サービスにリクエストを送信してください。", "xpack.watcher.models.webhookAction.simulateButtonLabel": "リクエストの送信", "xpack.watcher.models.webhookAction.simulateFailMessage": "{fullPath} へのリクエストの送信に失敗しました。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index bc7dfd5a92adc..f90be8e7f1deb 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -31460,8 +31460,6 @@ "xpack.watcher.models.watchHistoryItem.idPropertyMissingBadRequestMessage": "JSON 参数必须包含 {id} 属性", "xpack.watcher.models.watchHistoryItem.watchHistoryItemJsonPropertyMissingBadRequestMessage": "JSON 参数必须包含 {watchHistoryItemJson} 属性", "xpack.watcher.models.watchHistoryItem.watchIdPropertyMissingBadRequestMessage": "JSON 参数必须包含 {watchId} 属性", - "xpack.watcher.models.watchStatus.idPropertyMissingBadRequestMessage": "JSON 参数必须包含 {id} 属性", - "xpack.watcher.models.watchStatus.watchStatusJsonPropertyMissingBadRequestMessage": "JSON 参数必须包含 {watchStatusJson} 属性", "xpack.watcher.models.webhookAction.selectMessageText": "将请求发送到 Web 服务。", "xpack.watcher.models.webhookAction.simulateButtonLabel": "发送请求", "xpack.watcher.models.webhookAction.simulateFailMessage": "无法将请求发送至 {fullPath}", diff --git a/x-pack/plugins/watcher/common/lib/get_moment/get_moment.test.js b/x-pack/plugins/watcher/common/lib/get_moment/get_moment.test.ts similarity index 72% rename from x-pack/plugins/watcher/common/lib/get_moment/get_moment.test.js rename to x-pack/plugins/watcher/common/lib/get_moment/get_moment.test.ts index 332e36dead5c9..1128587cb045d 100644 --- a/x-pack/plugins/watcher/common/lib/get_moment/get_moment.test.js +++ b/x-pack/plugins/watcher/common/lib/get_moment/get_moment.test.ts @@ -12,18 +12,11 @@ describe('get_moment', () => { it(`returns a moment object when passed a date`, () => { const moment = getMoment('2017-03-30T14:53:08.121Z'); - expect(moment.constructor.name).toBe('Moment'); + expect(moment?.constructor.name).toBe('Moment'); }); it(`returns null when passed falsy`, () => { - const results = [ - getMoment(false), - getMoment(0), - getMoment(''), - getMoment(null), - getMoment(undefined), - getMoment(NaN), - ]; + const results = [getMoment(''), getMoment(null), getMoment(undefined)]; results.forEach((result) => { expect(result).toBe(null); diff --git a/x-pack/plugins/watcher/common/lib/get_moment/get_moment.js b/x-pack/plugins/watcher/common/lib/get_moment/get_moment.ts similarity index 87% rename from x-pack/plugins/watcher/common/lib/get_moment/get_moment.js rename to x-pack/plugins/watcher/common/lib/get_moment/get_moment.ts index 66472187cd78c..e3f17d5fbfa9f 100644 --- a/x-pack/plugins/watcher/common/lib/get_moment/get_moment.js +++ b/x-pack/plugins/watcher/common/lib/get_moment/get_moment.ts @@ -7,7 +7,7 @@ import moment from 'moment'; -export function getMoment(date) { +export function getMoment(date?: string | null) { if (!date) { return null; } diff --git a/x-pack/plugins/watcher/server/models/watch_status_model/index.js b/x-pack/plugins/watcher/common/lib/get_moment/index.ts similarity index 81% rename from x-pack/plugins/watcher/server/models/watch_status_model/index.js rename to x-pack/plugins/watcher/common/lib/get_moment/index.ts index 8c5b9e305794b..5c352d754d6ed 100644 --- a/x-pack/plugins/watcher/server/models/watch_status_model/index.js +++ b/x-pack/plugins/watcher/common/lib/get_moment/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { WatchStatusModel } from './watch_status_model'; +export { getMoment } from './get_moment'; diff --git a/x-pack/plugins/watcher/common/types/index.ts b/x-pack/plugins/watcher/common/types/index.ts new file mode 100644 index 0000000000000..e3aa1f883698b --- /dev/null +++ b/x-pack/plugins/watcher/common/types/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type { + ActionStatusModelEs, + ServerActionStatusModel, + ClientActionStatusModel, + WatchStatusModelEs, + ServerWatchStatusModel, + ClientWatchStatusModel, +} from './status_types'; diff --git a/x-pack/plugins/watcher/common/types/status_types.ts b/x-pack/plugins/watcher/common/types/status_types.ts new file mode 100644 index 0000000000000..14e8260e703db --- /dev/null +++ b/x-pack/plugins/watcher/common/types/status_types.ts @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Moment } from 'moment'; +import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; + +import { ACTION_STATES, WATCH_STATES, WATCH_STATE_COMMENTS } from '../constants'; + +export interface ActionStatusModelEs { + id: string; + actionStatusJson: estypes.WatcherActionStatus; + errors?: any; // TODO: Type this more strictly. + lastCheckedRawFormat?: string; // Date e.g. '2017-03-01T20:55:49.679Z' +} + +export interface ServerActionStatusModel { + id: string; + actionStatusJson: estypes.WatcherActionStatus; + errors: any; // TODO: Type this more strictly. + lastCheckedRawFormat?: string; // Date e.g. '2017-03-01T20:55:49.679Z' + lastExecutionRawFormat?: string; // Date e.g. '2017-03-01T20:55:49.679Z' + isLastExecutionSuccessful?: boolean; + lastExecutionReason?: string; + lastAcknowledged: Moment | null; + lastExecution: Moment | null; + lastThrottled: Moment | null; + lastSuccessfulExecution: Moment | null; +} + +export interface ClientActionStatusModel { + id: string; + lastAcknowledged: Moment | null; + lastThrottled: Moment | null; + lastExecution: Moment | null; + isLastExecutionSuccessful?: boolean; + lastExecutionReason?: string; + lastSuccessfulExecution: Moment | null; + state: keyof typeof ACTION_STATES; + isAckable: boolean; +} + +interface SerializedWatchStatus extends estypes.WatcherActivationStatus { + // Inherited from estypes.WatcherActivationStatus: + // - actions: WatcherActions // Record + // - state: WatcherActivationState // { active, timestamp } + // - version: VersionNumber + last_checked?: string; // Timestamp TODO: Update ES JS client types with this. + last_met_condition?: string; // Timestamp TODO: Update ES JS client types with this. +} + +export interface WatchStatusModelEs { + id: string; + watchStatusJson: SerializedWatchStatus; + state?: estypes.WatcherExecutionStatus; // e.g. 'execution_not_needed' or 'failed' + watchErrors?: { + actions?: Record; // TODO: Type this more strictly. + }; +} + +export interface ServerWatchStatusModel { + id: string; + watchState?: estypes.WatcherExecutionStatus; // e.g. 'execution_not_needed' or 'failed' + watchStatusJson: SerializedWatchStatus; + watchErrors?: WatchStatusModelEs['watchErrors']; + isActive: boolean; + lastChecked: Moment | null; + lastMetCondition: Moment | null; + actionStatuses?: ServerActionStatusModel[]; +} + +export interface ClientWatchStatusModel { + id: string; + isActive: boolean; + lastChecked: Moment | null; + lastMetCondition: Moment | null; + state: keyof typeof WATCH_STATES; + comment: keyof typeof WATCH_STATE_COMMENTS; + lastFired?: Moment | null; + actionStatuses: ClientActionStatusModel[]; +} diff --git a/x-pack/plugins/watcher/public/application/models/action_status/action_status.js b/x-pack/plugins/watcher/public/application/models/action_status/action_status.js index 734e0cf7bdf17..f60b92dc1ab1b 100644 --- a/x-pack/plugins/watcher/public/application/models/action_status/action_status.js +++ b/x-pack/plugins/watcher/public/application/models/action_status/action_status.js @@ -16,7 +16,7 @@ export class ActionStatus { this.lastAcknowledged = getMoment(get(props, 'lastAcknowledged')); this.lastThrottled = getMoment(get(props, 'lastThrottled')); this.lastExecution = getMoment(get(props, 'lastExecution')); - this.lastExecutionSuccessful = get(props, 'lastExecutionSuccessful'); + this.isLastExecutionSuccessful = get(props, 'isLastExecutionSuccessful'); this.lastExecutionReason = get(props, 'lastExecutionReason'); this.lastSuccessfulExecution = getMoment(get(props, 'lastSuccessfulExecution')); diff --git a/x-pack/plugins/watcher/public/application/sections/watch_edit_page/components/threshold_watch_edit/threshold_watch_action_accordion.tsx b/x-pack/plugins/watcher/public/application/sections/watch_edit_page/components/threshold_watch_edit/threshold_watch_action_accordion.tsx index 69fa65ab48645..f162941461ce8 100644 --- a/x-pack/plugins/watcher/public/application/sections/watch_edit_page/components/threshold_watch_edit/threshold_watch_action_accordion.tsx +++ b/x-pack/plugins/watcher/public/application/sections/watch_edit_page/components/threshold_watch_edit/threshold_watch_action_accordion.tsx @@ -244,7 +244,7 @@ export const WatchActionsAccordion: React.FunctionComponent = ({ (actionItem: ActionType) => actionItem.id === action.id ); - if (actionStatus && actionStatus.lastExecutionSuccessful === false) { + if (actionStatus && actionStatus.isLastExecutionSuccessful === false) { const message = actionStatus.lastExecutionReason || action.simulateFailMessage; return toasts.addDanger(message); } diff --git a/x-pack/plugins/watcher/server/models/action_status_model/action_status_model.js b/x-pack/plugins/watcher/server/models/action_status_model/action_status_model.js deleted file mode 100644 index e4ef87ab07dd6..0000000000000 --- a/x-pack/plugins/watcher/server/models/action_status_model/action_status_model.js +++ /dev/null @@ -1,143 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { get } from 'lodash'; -import { badRequest } from '@hapi/boom'; -import { i18n } from '@kbn/i18n'; - -import { getMoment } from '../../../common/lib/get_moment'; -import { ACTION_STATES } from '../../../common/constants'; - -export class ActionStatusModel { - constructor(props) { - this.id = props.id; - this.actionStatusJson = props.actionStatusJson; - this.errors = props.errors; - this.lastCheckedRawFormat = props.lastCheckedRawFormat; - - this.lastExecutionRawFormat = get(this.actionStatusJson, 'last_execution.timestamp'); - this.lastAcknowledged = getMoment(get(this.actionStatusJson, 'ack.timestamp')); - this.lastExecution = getMoment(get(this.actionStatusJson, 'last_execution.timestamp')); - this.lastExecutionSuccessful = get(this.actionStatusJson, 'last_execution.successful'); - this.lastExecutionReason = get(this.actionStatusJson, 'last_execution.reason'); - this.lastThrottled = getMoment(get(this.actionStatusJson, 'last_throttle.timestamp')); - this.lastSuccessfulExecution = getMoment( - get(this.actionStatusJson, 'last_successful_execution.timestamp') - ); - } - - get state() { - const actionStatusJson = this.actionStatusJson; - const ackState = actionStatusJson.ack.state; - - if ( - this.lastExecutionSuccessful === false && - this.lastCheckedRawFormat === this.lastExecutionRawFormat - ) { - return ACTION_STATES.ERROR; - } - - if (this.errors) { - return ACTION_STATES.CONFIG_ERROR; - } - - if (ackState === 'awaits_successful_execution') { - return ACTION_STATES.OK; - } - - if (ackState === 'acked' && this.lastAcknowledged >= this.lastExecution) { - return ACTION_STATES.ACKNOWLEDGED; - } - - // A user could potentially land in this state if running on multiple nodes and timing is off - if (ackState === 'acked' && this.lastAcknowledged < this.lastExecution) { - return ACTION_STATES.ERROR; - } - - if (ackState === 'ackable' && this.lastThrottled >= this.lastExecution) { - return ACTION_STATES.THROTTLED; - } - - if (ackState === 'ackable' && this.lastSuccessfulExecution >= this.lastExecution) { - return ACTION_STATES.FIRING; - } - - if (ackState === 'ackable' && this.lastSuccessfulExecution < this.lastExecution) { - return ACTION_STATES.ERROR; - } - - // At this point, we cannot determine the action status so mark it as "unknown". - // We should never get to this point in the code. If we do, it means we are - // missing an action status and the logic to determine it. - return ACTION_STATES.UNKNOWN; - } - - get isAckable() { - if (this.state === ACTION_STATES.THROTTLED || this.state === ACTION_STATES.FIRING) { - return true; - } - - return false; - } - - // generate object to send to kibana - get downstreamJson() { - const json = { - id: this.id, - state: this.state, - isAckable: this.isAckable, - lastAcknowledged: this.lastAcknowledged, - lastThrottled: this.lastThrottled, - lastExecution: this.lastExecution, - lastExecutionSuccessful: this.lastExecutionSuccessful, - lastExecutionReason: this.lastExecutionReason, - lastSuccessfulExecution: this.lastSuccessfulExecution, - }; - - return json; - } - - // generate object from elasticsearch response - static fromUpstreamJson(json) { - const missingPropertyError = (missingProperty) => - i18n.translate( - 'xpack.watcher.models.actionStatus.actionStatusJsonPropertyMissingBadRequestMessage', - { - defaultMessage: 'JSON argument must contain an "{missingProperty}" property', - values: { missingProperty }, - } - ); - - if (!json.id) { - throw badRequest(missingPropertyError('id')); - } - - if (!json.actionStatusJson) { - throw badRequest(missingPropertyError('actionStatusJson')); - } - - return new ActionStatusModel(json); - } - - /* - json.actionStatusJson should have the following structure: - { - "ack": { - "timestamp": "2017-03-01T20:56:58.442Z", - "state": "acked" - }, - "last_execution": { - "timestamp": "2017-03-01T20:55:49.679Z", - "successful": true - }, - "last_successful_execution": { - "timestamp": "2017-03-01T20:55:49.679Z", - "successful": true - } - } - */ -} diff --git a/x-pack/plugins/watcher/server/models/action_status_model/action_status_model.state.test.ts b/x-pack/plugins/watcher/server/models/action_status_model/action_status_model.state.test.ts new file mode 100644 index 0000000000000..d5ee596461b0e --- /dev/null +++ b/x-pack/plugins/watcher/server/models/action_status_model/action_status_model.state.test.ts @@ -0,0 +1,319 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { mergeWith, isObject } from 'lodash'; + +import { ACTION_STATES } from '../../../common/constants'; +import { ActionStatusModelEs } from '../../../common/types'; +import { buildServerActionStatusModel, buildClientActionStatusModel } from './action_status_model'; + +// Treat all nested properties of type as optional. +type DeepPartial = T extends object + ? { + [P in keyof T]?: DeepPartial; + } + : T; + +const createModelWithActions = ( + customActionStatusJson?: DeepPartial, + hasErrors: boolean = false +) => { + // Set srcValue to {} to define an empty property. + const mergeFn = (destValue: any, srcValue: any) => { + if (isObject(srcValue) && Object.keys(srcValue).length === 0) { + return {}; + } + // Default merge behavior. + return undefined; + }; + + const actionStatusJson = mergeWith( + { + ack: { + timestamp: '2017-03-01T20:56:58.442Z', + state: 'acked', + }, + last_successful_execution: { + timestamp: '2017-03-01T20:55:49.679Z', + successful: true, + }, + last_execution: { + timestamp: '2017-03-01T20:55:49.679Z', + successful: true, + reason: 'reasons', + }, + last_throttle: { + timestamp: '2017-03-01T20:55:49.679Z', + reason: 'reasons', + }, + }, + customActionStatusJson, + mergeFn + ); + + const serverActionStatusModel = buildServerActionStatusModel({ + id: 'my-action', + lastCheckedRawFormat: '2017-03-01T20:55:49.679Z', + actionStatusJson, + errors: hasErrors ? { foo: 'bar' } : undefined, + }); + + return buildClientActionStatusModel(serverActionStatusModel); +}; + +// NOTE: It's easier to test states through ActionStatusModel instead of +// testing individual util functions because they require mocked timestamps +// to be Moment instances, whereas ActionStatusModel only requires strings. +describe('ActionStatusModel states', () => { + describe('ACTION_STATES.CONFIG_ERROR', () => { + it('is set when there are errors', () => { + const clientActionStatusModel = createModelWithActions(undefined, true); + expect(clientActionStatusModel.state).toBe(ACTION_STATES.CONFIG_ERROR); + }); + }); + + describe(`ACTION_STATES.ERROR`, () => { + it('is set when isLastExecutionSuccessful is equal to false and it is the most recent execution', () => { + const clientActionStatusModel = createModelWithActions({ + last_execution: { + successful: false, + }, + }); + expect(clientActionStatusModel.state).toBe(ACTION_STATES.ERROR); + }); + + it('is set when action is acked and lastAcknowledged is less than lastExecution', () => { + const clientActionStatusModel = createModelWithActions({ + ack: { + state: 'acked', + timestamp: '2017-03-01T00:00:00.000Z', + }, + last_execution: { + timestamp: '2017-03-02T00:00:00.000Z', + successful: true, + reason: 'reasons', + }, + }); + + expect(clientActionStatusModel.state).toBe(ACTION_STATES.ERROR); + }); + + it('is set when action is ackable and lastSuccessfulExecution is less than lastExecution', () => { + const clientActionStatusModel = createModelWithActions({ + ack: { + state: 'ackable', + }, + last_successful_execution: { + timestamp: '2017-03-01T00:00:00.000Z', + }, + last_execution: { + timestamp: '2017-03-02T00:00:00.000Z', + }, + last_throttle: {}, + }); + expect(clientActionStatusModel.state).toBe(ACTION_STATES.ERROR); + }); + + it(`isn't ackable`, () => { + const clientActionStatusModel = createModelWithActions({ + ack: { + state: 'ackable', + timestamp: '2017-03-01T00:00:00.000Z', + }, + last_successful_execution: { + timestamp: '2017-03-01T00:00:00.000Z', + }, + last_execution: { + timestamp: '2017-03-02T00:00:00.000Z', + }, + last_throttle: {}, + }); + + expect(clientActionStatusModel.state).toBe(ACTION_STATES.ERROR); + expect(clientActionStatusModel.isAckable).toBe(false); + }); + }); + + describe(`ACTION_STATES.OK`, () => { + it('is set when state is awaits_successful_execution', () => { + const clientActionStatusModel = createModelWithActions({ + ack: { + state: 'awaits_successful_execution', + }, + }); + expect(clientActionStatusModel.state).toBe(ACTION_STATES.OK); + }); + + it(`isn't ackable`, () => { + const clientActionStatusModel = createModelWithActions({ + ack: { state: 'awaits_successful_execution' }, + }); + + expect(clientActionStatusModel.state).toBe(ACTION_STATES.OK); + expect(clientActionStatusModel.isAckable).toBe(false); + }); + }); + + describe(`ACTION_STATES.ACKNOWLEDGED`, () => { + it(`is set when lastAcknowledged is equal to lastExecution`, () => { + const clientActionStatusModel = createModelWithActions({ + ack: { + state: 'acked', + timestamp: '2017-03-01T00:00:00.000Z', + }, + last_execution: { + timestamp: '2017-03-01T00:00:00.000Z', + }, + }); + expect(clientActionStatusModel.state).toBe(ACTION_STATES.ACKNOWLEDGED); + }); + + it(`is set when lastAcknowledged is greater than lastExecution`, () => { + const clientActionStatusModel = createModelWithActions({ + ack: { + state: 'acked', + timestamp: '2017-03-02T00:00:00.000Z', + }, + last_execution: { + timestamp: '2017-03-01T00:00:00.000Z', + }, + }); + expect(clientActionStatusModel.state).toBe(ACTION_STATES.ACKNOWLEDGED); + }); + + it(`isn't ackable`, () => { + const clientActionStatusModel = createModelWithActions({ + ack: { + state: 'acked', + timestamp: '2017-03-01T00:00:00.000Z', + }, + last_execution: { + timestamp: '2017-03-01T00:00:00.000Z', + }, + }); + + expect(clientActionStatusModel.state).toBe(ACTION_STATES.ACKNOWLEDGED); + expect(clientActionStatusModel.isAckable).toBe(false); + }); + }); + + describe(`ACTION_STATES.THROTTLED`, () => { + it(`is set when lastThrottled is equal to lastExecution`, () => { + const clientActionStatusModel = createModelWithActions({ + ack: { + state: 'ackable', + }, + last_execution: { + timestamp: '2017-03-01T00:00:00.000Z', + }, + last_throttle: { + timestamp: '2017-03-01T00:00:00.000Z', + }, + }); + expect(clientActionStatusModel.state).toBe(ACTION_STATES.THROTTLED); + }); + + it(`is set when lastThrottled is greater than lastExecution`, () => { + const clientActionStatusModel = createModelWithActions({ + ack: { + state: 'ackable', + }, + last_execution: { + timestamp: '2017-03-01T00:00:00.000Z', + }, + last_throttle: { + timestamp: '2017-03-02T00:00:00.000Z', + }, + }); + expect(clientActionStatusModel.state).toBe(ACTION_STATES.THROTTLED); + }); + + it(`is ackable`, () => { + const clientActionStatusModel = createModelWithActions({ + ack: { + state: 'ackable', + }, + last_throttle: { + timestamp: '2017-03-01T00:00:00.000Z', + }, + last_execution: { + timestamp: '2017-03-01T00:00:00.000Z', + }, + }); + + expect(clientActionStatusModel.state).toBe(ACTION_STATES.THROTTLED); + expect(clientActionStatusModel.isAckable).toBe(true); + }); + }); + + describe(`ACTION_STATES.FIRING`, () => { + it(`is set when lastSuccessfulExecution is equal to lastExecution`, () => { + const clientActionStatusModel = createModelWithActions({ + ack: { + state: 'ackable', + }, + last_successful_execution: { + timestamp: '2017-03-01T00:00:00.000Z', + }, + last_execution: { + timestamp: '2017-03-01T00:00:00.000Z', + }, + last_throttle: {}, + }); + expect(clientActionStatusModel.state).toBe(ACTION_STATES.FIRING); + }); + + it(`is set when lastSuccessfulExecution is greater than lastExecution`, () => { + const clientActionStatusModel = createModelWithActions({ + ack: { + state: 'ackable', + }, + last_successful_execution: { + timestamp: '2017-03-02T00:00:00.000Z', + }, + last_execution: { + timestamp: '2017-03-01T00:00:00.000Z', + }, + last_throttle: {}, + }); + expect(clientActionStatusModel.state).toBe(ACTION_STATES.FIRING); + }); + + it(`is ackable`, () => { + const clientActionStatusModel = createModelWithActions({ + ack: { + state: 'ackable', + }, + last_successful_execution: { + timestamp: '2017-03-01T00:00:00.000Z', + }, + last_execution: { + timestamp: '2017-03-01T00:00:00.000Z', + }, + last_throttle: {}, + }); + + expect(clientActionStatusModel.state).toBe(ACTION_STATES.FIRING); + expect(clientActionStatusModel.isAckable).toBe(true); + }); + }); + + describe(`ACTION_STATES.UNKNOWN`, () => { + it(`is set if it can't determine the state`, () => { + const clientActionStatusModel = createModelWithActions({ + ack: { + // @ts-ignore + state: 'foo', + }, + last_successful_execution: { + successful: true, + }, + }); + expect(clientActionStatusModel.state).toBe(ACTION_STATES.UNKNOWN); + }); + }); +}); diff --git a/x-pack/plugins/watcher/server/models/action_status_model/action_status_model.test.js b/x-pack/plugins/watcher/server/models/action_status_model/action_status_model.test.js deleted file mode 100644 index 08cd9e62093a8..0000000000000 --- a/x-pack/plugins/watcher/server/models/action_status_model/action_status_model.test.js +++ /dev/null @@ -1,359 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import moment from 'moment'; - -import { ACTION_STATES } from '../../../common/constants'; -import { ActionStatusModel } from './action_status_model'; - -describe('ActionStatusModel', () => { - describe('fromUpstreamJson factory method', () => { - let upstreamJson; - beforeEach(() => { - upstreamJson = { - id: 'my-action', - actionStatusJson: { - ack: { - timestamp: '2017-03-01T20:56:58.442Z', - state: 'acked', - }, - last_execution: { - timestamp: '2017-03-01T20:55:49.679Z', - successful: true, - reason: 'reasons', - }, - last_throttle: { - timestamp: '2017-03-01T20:55:49.679Z', - }, - last_successful_execution: { - timestamp: '2017-03-01T20:55:49.679Z', - successful: true, - }, - }, - }; - }); - - it(`throws an error if no 'id' property in json`, () => { - delete upstreamJson.id; - expect(() => { - ActionStatusModel.fromUpstreamJson(upstreamJson); - }).toThrow('JSON argument must contain an "id" property'); - }); - - it(`throws an error if no 'actionStatusJson' property in json`, () => { - delete upstreamJson.actionStatusJson; - expect(() => { - ActionStatusModel.fromUpstreamJson(upstreamJson); - }).toThrow('JSON argument must contain an "actionStatusJson" property'); - }); - - it('returns correct ActionStatus instance', () => { - const actionStatus = ActionStatusModel.fromUpstreamJson({ - ...upstreamJson, - errors: { foo: 'bar' }, - }); - - expect(actionStatus.id).toBe(upstreamJson.id); - expect(actionStatus.lastAcknowledged).toEqual( - moment(upstreamJson.actionStatusJson.ack.timestamp) - ); - expect(actionStatus.lastExecution).toEqual( - moment(upstreamJson.actionStatusJson.last_execution.timestamp) - ); - expect(actionStatus.lastExecutionSuccessful).toEqual( - upstreamJson.actionStatusJson.last_execution.successful - ); - expect(actionStatus.lastExecutionReason).toBe( - upstreamJson.actionStatusJson.last_execution.reason - ); - expect(actionStatus.lastThrottled).toEqual( - moment(upstreamJson.actionStatusJson.last_throttle.timestamp) - ); - expect(actionStatus.lastSuccessfulExecution).toEqual( - moment(upstreamJson.actionStatusJson.last_successful_execution.timestamp) - ); - expect(actionStatus.errors).toEqual({ foo: 'bar' }); - }); - }); - - describe('state getter method', () => { - let upstreamJson; - beforeEach(() => { - upstreamJson = { - id: 'my-action', - lastCheckedRawFormat: '2017-03-01T20:55:49.679Z', - actionStatusJson: { - ack: { - timestamp: '2017-03-01T20:56:58.442Z', - state: 'acked', - }, - last_execution: { - timestamp: '2017-03-01T20:55:49.679Z', - successful: true, - reason: 'reasons', - }, - last_throttle: { - timestamp: '2017-03-01T20:55:49.679Z', - }, - last_successful_execution: { - timestamp: '2017-03-01T20:55:49.679Z', - successful: true, - }, - }, - }; - }); - - describe(`correctly calculates ACTION_STATES.ERROR`, () => { - it('lastExecutionSuccessful is equal to false and it is the most recent execution', () => { - upstreamJson.actionStatusJson.last_execution.successful = false; - const actionStatus = ActionStatusModel.fromUpstreamJson(upstreamJson); - expect(actionStatus.state).toBe(ACTION_STATES.ERROR); - }); - - it('action is acked and lastAcknowledged is less than lastExecution', () => { - const actionStatus = ActionStatusModel.fromUpstreamJson({ - ...upstreamJson, - actionStatusJson: { - ack: { - state: 'acked', - timestamp: '2017-03-01T00:00:00.000Z', - }, - last_execution: { - timestamp: '2017-03-02T00:00:00.000Z', - }, - }, - }); - expect(actionStatus.state).toBe(ACTION_STATES.ERROR); - }); - - it('action is ackable and lastSuccessfulExecution is less than lastExecution', () => { - delete upstreamJson.actionStatusJson.last_throttle; - upstreamJson.actionStatusJson.ack.state = 'ackable'; - upstreamJson.actionStatusJson.last_successful_execution.timestamp = - '2017-03-01T00:00:00.000Z'; - upstreamJson.actionStatusJson.last_execution.timestamp = '2017-03-02T00:00:00.000Z'; - const actionStatus = ActionStatusModel.fromUpstreamJson(upstreamJson); - - expect(actionStatus.state).toBe(ACTION_STATES.ERROR); - }); - }); - - it('correctly calculates ACTION_STATES.CONFIG_ERROR', () => { - const actionStatus = ActionStatusModel.fromUpstreamJson({ - ...upstreamJson, - errors: { foo: 'bar' }, - }); - expect(actionStatus.state).toBe(ACTION_STATES.CONFIG_ERROR); - }); - - it(`correctly calculates ACTION_STATES.OK`, () => { - upstreamJson.actionStatusJson.ack.state = 'awaits_successful_execution'; - const actionStatus = ActionStatusModel.fromUpstreamJson(upstreamJson); - - expect(actionStatus.state).toBe(ACTION_STATES.OK); - }); - - describe(`correctly calculates ACTION_STATES.ACKNOWLEDGED`, () => { - it(`when lastAcknowledged is equal to lastExecution`, () => { - upstreamJson.actionStatusJson.ack.state = 'acked'; - upstreamJson.actionStatusJson.ack.timestamp = '2017-03-01T00:00:00.000Z'; - upstreamJson.actionStatusJson.last_execution.timestamp = '2017-03-01T00:00:00.000Z'; - const actionStatus = ActionStatusModel.fromUpstreamJson(upstreamJson); - - expect(actionStatus.state).toBe(ACTION_STATES.ACKNOWLEDGED); - }); - - it(`when lastAcknowledged is greater than lastExecution`, () => { - upstreamJson.actionStatusJson.ack.state = 'acked'; - upstreamJson.actionStatusJson.ack.timestamp = '2017-03-02T00:00:00.000Z'; - upstreamJson.actionStatusJson.last_execution.timestamp = '2017-03-01T00:00:00.000Z'; - const actionStatus = ActionStatusModel.fromUpstreamJson(upstreamJson); - - expect(actionStatus.state).toBe(ACTION_STATES.ACKNOWLEDGED); - }); - }); - - describe(`correctly calculates ACTION_STATES.THROTTLED`, () => { - it(`when lastThrottled is equal to lastExecution`, () => { - upstreamJson.actionStatusJson.ack.state = 'ackable'; - upstreamJson.actionStatusJson.last_throttle.timestamp = '2017-03-01T00:00:00.000Z'; - upstreamJson.actionStatusJson.last_execution.timestamp = '2017-03-01T00:00:00.000Z'; - const actionStatus = ActionStatusModel.fromUpstreamJson(upstreamJson); - - expect(actionStatus.state).toBe(ACTION_STATES.THROTTLED); - }); - - it(`when lastThrottled is greater than lastExecution`, () => { - upstreamJson.actionStatusJson.ack.state = 'ackable'; - upstreamJson.actionStatusJson.last_throttle.timestamp = '2017-03-02T00:00:00.000Z'; - upstreamJson.actionStatusJson.last_execution.timestamp = '2017-03-01T00:00:00.000Z'; - const actionStatus = ActionStatusModel.fromUpstreamJson(upstreamJson); - - expect(actionStatus.state).toBe(ACTION_STATES.THROTTLED); - }); - }); - - describe(`correctly calculates ACTION_STATES.FIRING`, () => { - it(`when lastSuccessfulExecution is equal to lastExecution`, () => { - delete upstreamJson.actionStatusJson.last_throttle; - upstreamJson.actionStatusJson.ack.state = 'ackable'; - upstreamJson.actionStatusJson.last_successful_execution.timestamp = - '2017-03-01T00:00:00.000Z'; - upstreamJson.actionStatusJson.last_execution.timestamp = '2017-03-01T00:00:00.000Z'; - const actionStatus = ActionStatusModel.fromUpstreamJson(upstreamJson); - - expect(actionStatus.state).toBe(ACTION_STATES.FIRING); - }); - - it(`when lastSuccessfulExecution is greater than lastExecution`, () => { - delete upstreamJson.actionStatusJson.last_throttle; - upstreamJson.actionStatusJson.ack.state = 'ackable'; - upstreamJson.actionStatusJson.last_successful_execution.timestamp = - '2017-03-02T00:00:00.000Z'; - upstreamJson.actionStatusJson.last_execution.timestamp = '2017-03-01T00:00:00.000Z'; - const actionStatus = ActionStatusModel.fromUpstreamJson(upstreamJson); - - expect(actionStatus.state).toBe(ACTION_STATES.FIRING); - }); - }); - - it(`correctly calculates ACTION_STATES.UNKNOWN if it can not determine state`, () => { - upstreamJson = { - id: 'my-action', - actionStatusJson: { - ack: { state: 'foo' }, - last_successful_execution: { successful: true }, - }, - }; - const actionStatus = ActionStatusModel.fromUpstreamJson(upstreamJson); - - expect(actionStatus.state).toBe(ACTION_STATES.UNKNOWN); - }); - }); - - describe('isAckable getter method', () => { - let upstreamJson; - beforeEach(() => { - upstreamJson = { - id: 'my-action', - actionStatusJson: { - ack: { - timestamp: '2017-03-01T20:56:58.442Z', - state: 'acked', - }, - last_execution: { - timestamp: '2017-03-01T20:55:49.679Z', - successful: true, - reason: 'reasons', - }, - last_throttle: { - timestamp: '2017-03-01T20:55:49.679Z', - }, - last_successful_execution: { - timestamp: '2017-03-01T20:55:49.679Z', - successful: true, - }, - }, - }; - }); - - it(`correctly calculated isAckable when in ACTION_STATES.OK`, () => { - upstreamJson.actionStatusJson.ack.state = 'awaits_successful_execution'; - const actionStatus = ActionStatusModel.fromUpstreamJson(upstreamJson); - - expect(actionStatus.state).toBe(ACTION_STATES.OK); - expect(actionStatus.isAckable).toBe(false); - }); - - it(`correctly calculated isAckable when in ACTION_STATES.ACKNOWLEDGED`, () => { - upstreamJson.actionStatusJson.ack.state = 'acked'; - upstreamJson.actionStatusJson.ack.timestamp = '2017-03-01T00:00:00.000Z'; - upstreamJson.actionStatusJson.last_execution.timestamp = '2017-03-01T00:00:00.000Z'; - const actionStatus = ActionStatusModel.fromUpstreamJson(upstreamJson); - - expect(actionStatus.state).toBe(ACTION_STATES.ACKNOWLEDGED); - expect(actionStatus.isAckable).toBe(false); - }); - - it(`correctly calculated isAckable when in ACTION_STATES.THROTTLED`, () => { - upstreamJson.actionStatusJson.ack.state = 'ackable'; - upstreamJson.actionStatusJson.last_throttle.timestamp = '2017-03-01T00:00:00.000Z'; - upstreamJson.actionStatusJson.last_execution.timestamp = '2017-03-01T00:00:00.000Z'; - const actionStatus = ActionStatusModel.fromUpstreamJson(upstreamJson); - - expect(actionStatus.state).toBe(ACTION_STATES.THROTTLED); - expect(actionStatus.isAckable).toBe(true); - }); - - it(`correctly calculated isAckable when in ACTION_STATES.FIRING`, () => { - delete upstreamJson.actionStatusJson.last_throttle; - upstreamJson.actionStatusJson.ack.state = 'ackable'; - upstreamJson.actionStatusJson.last_successful_execution.timestamp = - '2017-03-01T00:00:00.000Z'; - upstreamJson.actionStatusJson.last_execution.timestamp = '2017-03-01T00:00:00.000Z'; - const actionStatus = ActionStatusModel.fromUpstreamJson(upstreamJson); - - expect(actionStatus.state).toBe(ACTION_STATES.FIRING); - expect(actionStatus.isAckable).toBe(true); - }); - - it(`correctly calculated isAckable when in ACTION_STATES.ERROR`, () => { - delete upstreamJson.actionStatusJson.last_throttle; - upstreamJson.actionStatusJson.ack.state = 'ackable'; - upstreamJson.actionStatusJson.last_successful_execution.timestamp = - '2017-03-01T00:00:00.000Z'; - upstreamJson.actionStatusJson.last_execution.timestamp = '2017-03-02T00:00:00.000Z'; - const actionStatus = ActionStatusModel.fromUpstreamJson(upstreamJson); - - expect(actionStatus.state).toBe(ACTION_STATES.ERROR); - expect(actionStatus.isAckable).toBe(false); - }); - }); - - describe('downstreamJson getter method', () => { - let upstreamJson; - beforeEach(() => { - upstreamJson = { - id: 'my-action', - actionStatusJson: { - ack: { - timestamp: '2017-03-01T20:56:58.442Z', - state: 'acked', - }, - last_execution: { - timestamp: '2017-03-01T20:55:49.679Z', - successful: true, - reason: 'reasons', - }, - last_throttle: { - timestamp: '2017-03-01T20:55:49.679Z', - }, - last_successful_execution: { - timestamp: '2017-03-01T20:55:49.679Z', - successful: true, - }, - }, - }; - }); - - it('returns correct JSON for client', () => { - const actionStatus = ActionStatusModel.fromUpstreamJson(upstreamJson); - - const json = actionStatus.downstreamJson; - - expect(json.id).toBe(actionStatus.id); - expect(json.state).toBe(actionStatus.state); - expect(json.isAckable).toBe(actionStatus.isAckable); - expect(json.lastAcknowledged).toBe(actionStatus.lastAcknowledged); - expect(json.lastThrottled).toBe(actionStatus.lastThrottled); - expect(json.lastExecution).toBe(actionStatus.lastExecution); - expect(json.lastExecutionSuccessful).toBe(actionStatus.lastExecutionSuccessful); - expect(json.lastExecutionReason).toBe(actionStatus.lastExecutionReason); - expect(json.lastSuccessfulExecution).toBe(actionStatus.lastSuccessfulExecution); - }); - }); -}); diff --git a/x-pack/plugins/watcher/server/models/action_status_model/action_status_model.test.ts b/x-pack/plugins/watcher/server/models/action_status_model/action_status_model.test.ts new file mode 100644 index 0000000000000..1d61385c46dd5 --- /dev/null +++ b/x-pack/plugins/watcher/server/models/action_status_model/action_status_model.test.ts @@ -0,0 +1,142 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import moment from 'moment'; + +import { ACTION_STATES } from '../../../common/constants'; +import { ActionStatusModelEs } from '../../../common/types'; +import { buildServerActionStatusModel, buildClientActionStatusModel } from './action_status_model'; + +describe('ActionStatusModel', () => { + describe('buildServerActionStatusModel', () => { + let upstreamJson: ActionStatusModelEs; + beforeEach(() => { + upstreamJson = { + id: 'my-action', + lastCheckedRawFormat: '2017-03-01T20:55:49.679Z', + actionStatusJson: { + ack: { + timestamp: '2017-03-01T20:56:58.442Z', + state: 'acked', + }, + last_execution: { + timestamp: '2017-03-01T20:55:49.679Z', + successful: true, + reason: 'reasons', + }, + last_throttle: { + timestamp: '2017-03-01T20:55:49.679Z', + reason: 'reasons', + }, + last_successful_execution: { + timestamp: '2017-03-01T20:55:49.679Z', + successful: true, + }, + }, + }; + }); + + // TODO: Remove once all consumers and upstream dependencies are converted to TS. + it(`throws an error if no 'id' property in json`, () => { + expect(() => { + // @ts-ignore + buildServerActionStatusModel({}); + }).toThrow('JSON argument must contain an "id" property'); + }); + + // TODO: Remove once all consumers and upstream dependencies are converted to TS. + it(`throws an error if no 'actionStatusJson' property in json`, () => { + expect(() => { + // @ts-ignore + buildServerActionStatusModel({ id: 'test' }); + }).toThrow('JSON argument must contain an "actionStatusJson" property'); + }); + + it('returns correct ActionStatus instance', () => { + const serverActionStatusModel = buildServerActionStatusModel({ + ...upstreamJson, + errors: { foo: 'bar' }, + }); + + expect(serverActionStatusModel.id).toBe(upstreamJson.id); + expect(serverActionStatusModel.lastAcknowledged).toEqual( + moment(upstreamJson.actionStatusJson.ack.timestamp) + ); + expect(serverActionStatusModel.lastExecution).toEqual( + moment(upstreamJson.actionStatusJson.last_execution?.timestamp) + ); + expect(serverActionStatusModel.isLastExecutionSuccessful).toEqual( + upstreamJson.actionStatusJson.last_execution?.successful + ); + expect(serverActionStatusModel.lastExecutionReason).toBe( + upstreamJson.actionStatusJson.last_execution?.reason + ); + expect(serverActionStatusModel.lastThrottled).toEqual( + moment(upstreamJson.actionStatusJson.last_throttle?.timestamp) + ); + expect(serverActionStatusModel.lastSuccessfulExecution).toEqual( + moment(upstreamJson.actionStatusJson.last_successful_execution?.timestamp) + ); + expect(serverActionStatusModel.errors).toEqual({ foo: 'bar' }); + }); + }); + + describe('buildClientActionStatusModel', () => { + let upstreamJson: ActionStatusModelEs; + beforeEach(() => { + upstreamJson = { + id: 'my-action', + lastCheckedRawFormat: '2017-03-01T20:55:49.679Z', + actionStatusJson: { + ack: { + timestamp: '2017-03-01T20:56:58.442Z', + state: 'acked', + }, + last_execution: { + timestamp: '2017-03-01T20:55:49.679Z', + successful: true, + reason: 'reasons', + }, + last_throttle: { + timestamp: '2017-03-01T20:55:49.679Z', + reason: 'reasons', + }, + last_successful_execution: { + timestamp: '2017-03-01T20:55:49.679Z', + successful: true, + }, + }, + }; + }); + + it('returns correct JSON for client', () => { + const serverActionStatusModel = buildServerActionStatusModel(upstreamJson); + const clientActionStatusModel = buildClientActionStatusModel(serverActionStatusModel); + + // These properties should be transcribed 1:1. + expect(clientActionStatusModel.id).toBe(serverActionStatusModel.id); + expect(clientActionStatusModel.lastAcknowledged).toBe( + serverActionStatusModel.lastAcknowledged + ); + expect(clientActionStatusModel.lastThrottled).toBe(serverActionStatusModel.lastThrottled); + expect(clientActionStatusModel.lastExecution).toBe(serverActionStatusModel.lastExecution); + expect(clientActionStatusModel.isLastExecutionSuccessful).toBe( + serverActionStatusModel.isLastExecutionSuccessful + ); + expect(clientActionStatusModel.lastExecutionReason).toBe( + serverActionStatusModel.lastExecutionReason + ); + expect(clientActionStatusModel.lastSuccessfulExecution).toBe( + serverActionStatusModel.lastSuccessfulExecution + ); + + // These properties are derived when clientActionStatusModel is created. + expect(clientActionStatusModel.state).toBe(ACTION_STATES.ACKNOWLEDGED); + expect(clientActionStatusModel.isAckable).toBe(false); + }); + }); +}); diff --git a/x-pack/plugins/watcher/server/models/action_status_model/action_status_model.ts b/x-pack/plugins/watcher/server/models/action_status_model/action_status_model.ts new file mode 100644 index 0000000000000..da337e4b17748 --- /dev/null +++ b/x-pack/plugins/watcher/server/models/action_status_model/action_status_model.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { badRequest } from '@hapi/boom'; +import { i18n } from '@kbn/i18n'; + +import { ActionStatusModelEs, ServerActionStatusModel } from '../../../common/types'; +import { getMoment } from '../../../common/lib/get_moment'; +import { deriveState, deriveIsAckable } from './action_status_model_utils'; + +export const buildServerActionStatusModel = ( + actionStatusModelEs: ActionStatusModelEs +): ServerActionStatusModel => { + const { id, actionStatusJson, errors, lastCheckedRawFormat } = actionStatusModelEs; + + const missingPropertyError = (missingProperty: string) => + i18n.translate( + 'xpack.watcher.models.actionStatus.actionStatusJsonPropertyMissingBadRequestMessage', + { + defaultMessage: 'JSON argument must contain an "{missingProperty}" property', + values: { missingProperty }, + } + ); + + // TODO: Remove once all consumers and upstream dependencies are converted to TS. + if (!id) { + throw badRequest(missingPropertyError('id')); + } + + // TODO: Remove once all consumers and upstream dependencies are converted to TS. + if (!actionStatusJson) { + throw badRequest(missingPropertyError('actionStatusJson')); + } + + return { + id, + actionStatusJson, + errors, + lastCheckedRawFormat, + lastExecutionRawFormat: actionStatusJson.last_execution?.timestamp, + lastAcknowledged: getMoment(actionStatusJson.ack.timestamp), + lastExecution: getMoment(actionStatusJson.last_execution?.timestamp), + isLastExecutionSuccessful: actionStatusJson.last_execution?.successful, + lastExecutionReason: actionStatusJson.last_execution?.reason, + lastThrottled: getMoment(actionStatusJson.last_throttle?.timestamp), + lastSuccessfulExecution: getMoment(actionStatusJson.last_successful_execution?.timestamp), + }; +}; + +export const buildClientActionStatusModel = (serverActionStatusModel: ServerActionStatusModel) => { + const { + id, + lastAcknowledged, + lastThrottled, + lastExecution, + isLastExecutionSuccessful, + lastExecutionReason, + lastSuccessfulExecution, + } = serverActionStatusModel; + const state = deriveState(serverActionStatusModel); + const isAckable = deriveIsAckable(state); + + return { + id, + lastAcknowledged, + lastThrottled, + lastExecution, + isLastExecutionSuccessful, + lastExecutionReason, + lastSuccessfulExecution, + state, + isAckable, + }; +}; diff --git a/x-pack/plugins/watcher/server/models/action_status_model/action_status_model_utils.ts b/x-pack/plugins/watcher/server/models/action_status_model/action_status_model_utils.ts new file mode 100644 index 0000000000000..5c6089132d680 --- /dev/null +++ b/x-pack/plugins/watcher/server/models/action_status_model/action_status_model_utils.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ACTION_STATES } from '../../../common/constants'; +import { ServerActionStatusModel } from '../../../common/types'; + +export const deriveState = (serverActionStatusModel: ServerActionStatusModel) => { + const { + actionStatusJson, + isLastExecutionSuccessful, + lastCheckedRawFormat, + lastExecutionRawFormat, + errors, + lastAcknowledged, + lastExecution, + lastThrottled, + lastSuccessfulExecution, + } = serverActionStatusModel; + const ackState = actionStatusJson.ack.state; + + if (isLastExecutionSuccessful === false && lastCheckedRawFormat === lastExecutionRawFormat) { + return ACTION_STATES.ERROR; + } + + if (errors) { + return ACTION_STATES.CONFIG_ERROR; + } + + if (ackState === 'awaits_successful_execution') { + return ACTION_STATES.OK; + } + + if (lastExecution) { + // Might be null + if (lastAcknowledged) { + // Might be null + if (ackState === 'acked' && lastAcknowledged >= lastExecution) { + return ACTION_STATES.ACKNOWLEDGED; + } + + // A user could potentially land in this state if running on multiple nodes and timing is off + if (ackState === 'acked' && lastAcknowledged < lastExecution) { + return ACTION_STATES.ERROR; + } + } + + if (lastThrottled) { + // Might be null + if (ackState === 'ackable' && lastThrottled >= lastExecution) { + return ACTION_STATES.THROTTLED; + } + } + + if (lastSuccessfulExecution) { + // Might be null + if (ackState === 'ackable' && lastSuccessfulExecution >= lastExecution) { + return ACTION_STATES.FIRING; + } + + if (ackState === 'ackable' && lastSuccessfulExecution < lastExecution) { + return ACTION_STATES.ERROR; + } + } + } + + // At this point, we cannot determine the action status so mark it as "unknown". + // We should never get to this point in the code. If we do, it means we are + // missing an action status and the logic to determine it. + return ACTION_STATES.UNKNOWN; +}; + +export const deriveIsAckable = (state: keyof typeof ACTION_STATES) => { + if (state === ACTION_STATES.THROTTLED || state === ACTION_STATES.FIRING) { + return true; + } + + return false; +}; diff --git a/x-pack/plugins/watcher/server/models/action_status_model/index.ts b/x-pack/plugins/watcher/server/models/action_status_model/index.ts new file mode 100644 index 0000000000000..5fe626031b901 --- /dev/null +++ b/x-pack/plugins/watcher/server/models/action_status_model/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { buildServerActionStatusModel, buildClientActionStatusModel } from './action_status_model'; diff --git a/x-pack/plugins/watcher/server/models/watch/base_watch.js b/x-pack/plugins/watcher/server/models/watch/base_watch.js index 053b03e7f3931..3a2d09486cf8c 100644 --- a/x-pack/plugins/watcher/server/models/watch/base_watch.js +++ b/x-pack/plugins/watcher/server/models/watch/base_watch.js @@ -10,7 +10,7 @@ import { badRequest } from '@hapi/boom'; import { i18n } from '@kbn/i18n'; import { Action } from '../../../common/models/action'; -import { WatchStatusModel } from '../watch_status_model'; +import { buildServerWatchStatusModel, buildClientWatchStatusModel } from '../watch_status_model'; import { WatchErrors } from '../watch_errors'; export class BaseWatch { @@ -60,7 +60,7 @@ export class BaseWatch { name: this.name, type: this.type, isSystemWatch: this.isSystemWatch, - watchStatus: this.watchStatus ? this.watchStatus.downstreamJson : undefined, + watchStatus: this.watchStatus ? buildClientWatchStatusModel(this.watchStatus) : undefined, watchErrors: this.watchErrors ? this.watchErrors.downstreamJson : undefined, actions: map(this.actions, (action) => action.downstreamJson), }; @@ -138,7 +138,7 @@ export class BaseWatch { const watchErrors = WatchErrors.fromUpstreamJson(this.getWatchErrors(actions)); - const watchStatus = WatchStatusModel.fromUpstreamJson({ + const watchStatus = buildServerWatchStatusModel({ id, watchStatusJson, watchErrors, diff --git a/x-pack/plugins/watcher/server/models/watch/base_watch.test.js b/x-pack/plugins/watcher/server/models/watch/base_watch.test.js index 17ad6c5afb986..18837e6fc860c 100644 --- a/x-pack/plugins/watcher/server/models/watch/base_watch.test.js +++ b/x-pack/plugins/watcher/server/models/watch/base_watch.test.js @@ -5,6 +5,7 @@ * 2.0. */ +import { buildClientWatchStatusModel } from '../watch_status_model'; import { BaseWatch } from './base_watch'; describe('BaseWatch', () => { @@ -160,7 +161,7 @@ describe('BaseWatch', () => { name: props.name, type: props.type, isSystemWatch: false, - watchStatus: props.watchStatus.downstreamJson, + watchStatus: buildClientWatchStatusModel(props.watchStatus), watchErrors: props.watchErrors.downstreamJson, actions: props.actions.map((a) => a.downstreamJson), }; diff --git a/x-pack/plugins/watcher/server/models/watch/json_watch.test.js b/x-pack/plugins/watcher/server/models/watch/json_watch.test.js index 4ddc4faf7a511..397bfddb095ab 100644 --- a/x-pack/plugins/watcher/server/models/watch/json_watch.test.js +++ b/x-pack/plugins/watcher/server/models/watch/json_watch.test.js @@ -80,7 +80,7 @@ describe('JsonWatch', () => { beforeEach(() => { upstreamJson = { id: 'id', - watchStatusJson: {}, + watchStatusJson: { state: { active: true } }, watchJson: { trigger: 'trigger', input: 'input', diff --git a/x-pack/plugins/watcher/server/models/watch/monitoring_watch.test.js b/x-pack/plugins/watcher/server/models/watch/monitoring_watch.test.js index 0e31588279184..e642dab5a4d0c 100644 --- a/x-pack/plugins/watcher/server/models/watch/monitoring_watch.test.js +++ b/x-pack/plugins/watcher/server/models/watch/monitoring_watch.test.js @@ -89,7 +89,7 @@ describe('MonitoringWatch', () => { const actual = MonitoringWatch.fromUpstreamJson({ id: 'id', watchJson: {}, - watchStatusJson: {}, + watchStatusJson: { state: { active: true } }, }); const expected = { diff --git a/x-pack/plugins/watcher/server/models/watch/threshold_watch/threshold_watch.test.js b/x-pack/plugins/watcher/server/models/watch/threshold_watch/threshold_watch.test.js index f56d6624a8546..f581135b4decc 100644 --- a/x-pack/plugins/watcher/server/models/watch/threshold_watch/threshold_watch.test.js +++ b/x-pack/plugins/watcher/server/models/watch/threshold_watch/threshold_watch.test.js @@ -137,7 +137,7 @@ describe('ThresholdWatch', () => { beforeEach(() => { upstreamJson = { id: 'id', - watchStatusJson: {}, + watchStatusJson: { state: { active: true } }, watchJson: { foo: { bar: 'baz' }, metadata: { diff --git a/x-pack/plugins/watcher/server/models/watch/watch.test.js b/x-pack/plugins/watcher/server/models/watch/watch.test.js index 09cfeda338f37..999a3d3006e34 100644 --- a/x-pack/plugins/watcher/server/models/watch/watch.test.js +++ b/x-pack/plugins/watcher/server/models/watch/watch.test.js @@ -57,7 +57,7 @@ describe('Watch', () => { it('JsonWatch to be used when type is WATCH_TYPES.JSON', () => { const config = { id: 'id', - watchStatusJson: {}, + watchStatusJson: { state: { active: true } }, watchJson: { metadata: { xpack: { type: WATCH_TYPES.JSON } } }, }; expect(Watch.fromUpstreamJson(config)).toEqual(JsonWatch.fromUpstreamJson(config)); @@ -66,7 +66,7 @@ describe('Watch', () => { it('ThresholdWatch to be used when type is WATCH_TYPES.THRESHOLD', () => { const config = { id: 'id', - watchStatusJson: {}, + watchStatusJson: { state: { active: true } }, watchJson: { metadata: { watcherui: {}, xpack: { type: WATCH_TYPES.THRESHOLD } } }, }; expect(Watch.fromUpstreamJson(config)).toEqual(ThresholdWatch.fromUpstreamJson(config)); @@ -75,7 +75,7 @@ describe('Watch', () => { it('MonitoringWatch to be used when type is WATCH_TYPES.MONITORING', () => { const config = { id: 'id', - watchStatusJson: {}, + watchStatusJson: { state: { active: true } }, watchJson: { metadata: { xpack: { type: WATCH_TYPES.MONITORING } } }, }; expect(Watch.fromUpstreamJson(config)).toEqual(MonitoringWatch.fromUpstreamJson(config)); diff --git a/x-pack/plugins/watcher/server/models/watch_history_item/watch_history_item.js b/x-pack/plugins/watcher/server/models/watch_history_item/watch_history_item.js index a343dcfe72907..d1ddc12df2682 100644 --- a/x-pack/plugins/watcher/server/models/watch_history_item/watch_history_item.js +++ b/x-pack/plugins/watcher/server/models/watch_history_item/watch_history_item.js @@ -10,7 +10,7 @@ import { get, cloneDeep } from 'lodash'; import { i18n } from '@kbn/i18n'; import { getMoment } from '../../../common/lib/get_moment'; -import { WatchStatusModel } from '../watch_status_model'; +import { buildServerWatchStatusModel, buildClientWatchStatusModel } from '../watch_status_model'; export class WatchHistoryItem { constructor(props) { @@ -24,7 +24,7 @@ export class WatchHistoryItem { const watchStatusJson = get(this.watchHistoryItemJson, 'status'); const state = get(this.watchHistoryItemJson, 'state'); - this.watchStatus = WatchStatusModel.fromUpstreamJson({ + this.watchStatus = buildServerWatchStatusModel({ id: this.watchId, watchStatusJson, state, @@ -37,7 +37,7 @@ export class WatchHistoryItem { watchId: this.watchId, details: this.includeDetails ? this.details : null, startTime: this.startTime.toISOString(), - watchStatus: this.watchStatus.downstreamJson, + watchStatus: buildClientWatchStatusModel(this.watchStatus), }; } diff --git a/x-pack/plugins/watcher/server/models/action_status_model/index.js b/x-pack/plugins/watcher/server/models/watch_status_model/index.ts similarity index 72% rename from x-pack/plugins/watcher/server/models/action_status_model/index.js rename to x-pack/plugins/watcher/server/models/watch_status_model/index.ts index 18ceabb0c1dc8..e38fa42aeaeb8 100644 --- a/x-pack/plugins/watcher/server/models/action_status_model/index.js +++ b/x-pack/plugins/watcher/server/models/watch_status_model/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { ActionStatusModel } from './action_status_model'; +export { buildServerWatchStatusModel, buildClientWatchStatusModel } from './watch_status_model'; diff --git a/x-pack/plugins/watcher/server/models/watch_status_model/watch_status_model.js b/x-pack/plugins/watcher/server/models/watch_status_model/watch_status_model.js deleted file mode 100644 index c0b38c429a1f6..0000000000000 --- a/x-pack/plugins/watcher/server/models/watch_status_model/watch_status_model.js +++ /dev/null @@ -1,203 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { get, map, forEach, maxBy } from 'lodash'; -import { badRequest } from '@hapi/boom'; -import { i18n } from '@kbn/i18n'; - -import { ACTION_STATES, WATCH_STATES, WATCH_STATE_COMMENTS } from '../../../common/constants'; -import { getMoment } from '../../../common/lib/get_moment'; -import { ActionStatusModel } from '../action_status_model'; - -function getActionStatusTotals(watchStatus) { - const result = {}; - - forEach(ACTION_STATES, (state) => { - result[state] = 0; - }); - forEach(watchStatus.actionStatuses, (actionStatus) => { - result[actionStatus.state] = result[actionStatus.state] + 1; - }); - - return result; -} - -const WATCH_STATE_FAILED = 'failed'; - -export class WatchStatusModel { - constructor(props) { - this.id = props.id; - this.watchState = props.state; - this.watchStatusJson = props.watchStatusJson; - this.watchErrors = props.watchErrors || {}; - - this.isActive = Boolean(get(this.watchStatusJson, 'state.active')); - this.lastChecked = getMoment(get(this.watchStatusJson, 'last_checked')); - this.lastMetCondition = getMoment(get(this.watchStatusJson, 'last_met_condition')); - - const actionStatusesJson = get(this.watchStatusJson, 'actions', {}); - this.actionStatuses = map(actionStatusesJson, (actionStatusJson, id) => { - const json = { - id, - actionStatusJson, - errors: this.watchErrors.actions && this.watchErrors.actions[id], - lastCheckedRawFormat: get(this.watchStatusJson, 'last_checked'), - }; - return ActionStatusModel.fromUpstreamJson(json); - }); - } - - get state() { - if (!this.isActive) { - return WATCH_STATES.DISABLED; - } - - if (this.watchState === WATCH_STATE_FAILED) { - return WATCH_STATES.ERROR; - } - - const totals = getActionStatusTotals(this); - - if (totals[ACTION_STATES.ERROR] > 0) { - return WATCH_STATES.ERROR; - } - - if (totals[ACTION_STATES.CONFIG_ERROR] > 0) { - return WATCH_STATES.CONFIG_ERROR; - } - - const firingTotal = - totals[ACTION_STATES.FIRING] + - totals[ACTION_STATES.ACKNOWLEDGED] + - totals[ACTION_STATES.THROTTLED]; - - if (firingTotal > 0) { - return WATCH_STATES.FIRING; - } - - return WATCH_STATES.OK; - } - - get comment() { - const totals = getActionStatusTotals(this); - const totalActions = this.actionStatuses.length; - let result = WATCH_STATE_COMMENTS.OK; - - if (totals[ACTION_STATES.THROTTLED] > 0 && totals[ACTION_STATES.THROTTLED] < totalActions) { - result = WATCH_STATE_COMMENTS.PARTIALLY_THROTTLED; - } - - if (totals[ACTION_STATES.THROTTLED] > 0 && totals[ACTION_STATES.THROTTLED] === totalActions) { - result = WATCH_STATE_COMMENTS.THROTTLED; - } - - if ( - totals[ACTION_STATES.ACKNOWLEDGED] > 0 && - totals[ACTION_STATES.ACKNOWLEDGED] < totalActions - ) { - result = WATCH_STATE_COMMENTS.PARTIALLY_ACKNOWLEDGED; - } - - if ( - totals[ACTION_STATES.ACKNOWLEDGED] > 0 && - totals[ACTION_STATES.ACKNOWLEDGED] === totalActions - ) { - result = WATCH_STATE_COMMENTS.ACKNOWLEDGED; - } - - if (totals[ACTION_STATES.ERROR] > 0) { - result = WATCH_STATE_COMMENTS.FAILING; - } - - if (!this.isActive) { - result = WATCH_STATE_COMMENTS.OK; - } - - return result; - } - - get lastFired() { - const actionStatus = maxBy(this.actionStatuses, 'lastExecution'); - if (actionStatus) { - return actionStatus.lastExecution; - } - } - - // generate object to send to kibana - get downstreamJson() { - const json = { - id: this.id, - state: this.state, - comment: this.comment, - isActive: this.isActive, - lastChecked: this.lastChecked, - lastMetCondition: this.lastMetCondition, - lastFired: this.lastFired, - actionStatuses: map(this.actionStatuses, (actionStatus) => actionStatus.downstreamJson), - }; - - return json; - } - - // generate object from elasticsearch response - static fromUpstreamJson(json) { - if (!json.id) { - throw badRequest( - i18n.translate('xpack.watcher.models.watchStatus.idPropertyMissingBadRequestMessage', { - defaultMessage: 'JSON argument must contain an {id} property', - values: { - id: 'id', - }, - }) - ); - } - if (!json.watchStatusJson) { - throw badRequest( - i18n.translate( - 'xpack.watcher.models.watchStatus.watchStatusJsonPropertyMissingBadRequestMessage', - { - defaultMessage: 'JSON argument must contain a {watchStatusJson} property', - values: { - watchStatusJson: 'watchStatusJson', - }, - } - ) - ); - } - - return new WatchStatusModel(json); - } - - /* - json.watchStatusJson should have the following structure: - { - "state": { - "active": true, - "timestamp": "2017-03-01T19:05:49.400Z" - }, - "actions": { - "log-me-something": { - "ack": { - "timestamp": "2017-03-01T20:56:58.442Z", - "state": "acked" - }, - "last_execution": { - "timestamp": "2017-03-01T20:55:49.679Z", - "successful": true - }, - "last_successful_execution": { - "timestamp": "2017-03-01T20:55:49.679Z", - "successful": true - } - } - }, - "version": 15, - "last_checked": "2017-03-02T14:25:31.139Z", - "last_met_condition": "2017-03-02T14:25:31.139Z" - } - */ -} diff --git a/x-pack/plugins/watcher/server/models/watch_status_model/watch_status_model.test.js b/x-pack/plugins/watcher/server/models/watch_status_model/watch_status_model.test.js deleted file mode 100644 index 5dbb3a58d2740..0000000000000 --- a/x-pack/plugins/watcher/server/models/watch_status_model/watch_status_model.test.js +++ /dev/null @@ -1,313 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import moment from 'moment'; - -import { ACTION_STATES, WATCH_STATES, WATCH_STATE_COMMENTS } from '../../../common/constants'; -import { WatchStatusModel } from './watch_status_model'; - -describe('WatchStatusModel', () => { - describe('fromUpstreamJson factory method', () => { - let upstreamJson; - beforeEach(() => { - upstreamJson = { - id: 'my-watch', - watchStatusJson: { - state: { - active: true, - }, - last_checked: '2017-03-02T14:25:31.139Z', - last_met_condition: '2017-07-05T14:25:31.139Z', - actions: { - foo: {}, - bar: {}, - }, - }, - }; - }); - - it(`throws an error if no 'id' property in json`, () => { - delete upstreamJson.id; - expect(() => { - WatchStatusModel.fromUpstreamJson(upstreamJson); - }).toThrow(/must contain an id property/i); - }); - - it(`throws an error if no 'watchStatusJson' property in json`, () => { - delete upstreamJson.watchStatusJson; - expect(() => { - WatchStatusModel.fromUpstreamJson(upstreamJson); - }).toThrow(/must contain a watchStatusJson property/i); - }); - - it('returns correct WatchStatus instance', () => { - const watchStatus = WatchStatusModel.fromUpstreamJson(upstreamJson); - - expect(watchStatus.id).toBe(upstreamJson.id); - expect(watchStatus.watchStatusJson).toEqual(upstreamJson.watchStatusJson); - expect(watchStatus.isActive).toEqual(true); - expect(watchStatus.lastChecked).toEqual(moment(upstreamJson.watchStatusJson.last_checked)); - expect(watchStatus.lastMetCondition).toEqual( - moment(upstreamJson.watchStatusJson.last_met_condition) - ); - expect(watchStatus.actionStatuses.length).toBe(2); - - expect(watchStatus.actionStatuses[0].constructor.name).toBe('ActionStatusModel'); - expect(watchStatus.actionStatuses[1].constructor.name).toBe('ActionStatusModel'); - }); - }); - - describe('lastFired getter method', () => { - let upstreamJson; - beforeEach(() => { - upstreamJson = { - id: 'my-watch', - watchStatusJson: { - actions: { - foo: { - last_execution: { - timestamp: '2017-07-05T00:00:00.000Z', - }, - }, - bar: { - last_execution: { - timestamp: '2025-07-05T00:00:00.000Z', - }, - }, - baz: {}, - }, - }, - }; - }); - - it(`returns the latest lastExecution from it's actions`, () => { - const watchStatus = WatchStatusModel.fromUpstreamJson(upstreamJson); - expect(watchStatus.lastFired).toEqual( - moment(upstreamJson.watchStatusJson.actions.bar.last_execution.timestamp) - ); - }); - }); - - describe('comment getter method', () => { - let upstreamJson; - beforeEach(() => { - upstreamJson = { - id: 'my-watch', - watchStatusJson: { - state: { - active: true, - }, - }, - }; - }); - - it(`correctly calculates WATCH_STATE_COMMENTS.OK there are no actions`, () => { - const watchStatus = WatchStatusModel.fromUpstreamJson(upstreamJson); - watchStatus.isActive = true; - expect(watchStatus.comment).toBe(WATCH_STATE_COMMENTS.OK); - }); - - it(`correctly calculates WATCH_STATE_COMMENTS.PARTIALLY_THROTTLED`, () => { - const watchStatus = WatchStatusModel.fromUpstreamJson(upstreamJson); - - watchStatus.actionStatuses = [ - { state: ACTION_STATES.THROTTLED }, - { state: ACTION_STATES.FIRING }, - { state: ACTION_STATES.OK }, - ]; - - expect(watchStatus.comment).toBe(WATCH_STATE_COMMENTS.PARTIALLY_THROTTLED); - }); - - it(`correctly calculates WATCH_STATE_COMMENTS.THROTTLED`, () => { - const watchStatus = WatchStatusModel.fromUpstreamJson(upstreamJson); - - watchStatus.actionStatuses = [ - { state: ACTION_STATES.THROTTLED }, - { state: ACTION_STATES.THROTTLED }, - { state: ACTION_STATES.THROTTLED }, - ]; - - expect(watchStatus.comment).toBe(WATCH_STATE_COMMENTS.THROTTLED); - }); - - it(`correctly calculates WATCH_STATE_COMMENTS.PARTIALLY_ACKNOWLEDGED`, () => { - const watchStatus = WatchStatusModel.fromUpstreamJson(upstreamJson); - - watchStatus.actionStatuses = [ - { state: ACTION_STATES.ACKNOWLEDGED }, - { state: ACTION_STATES.OK }, - { state: ACTION_STATES.THROTTLED }, - { state: ACTION_STATES.FIRING }, - ]; - - expect(watchStatus.comment).toBe(WATCH_STATE_COMMENTS.PARTIALLY_ACKNOWLEDGED); - }); - - it(`correctly calculates WATCH_STATE_COMMENTS.ACKNOWLEDGED`, () => { - const watchStatus = WatchStatusModel.fromUpstreamJson(upstreamJson); - - watchStatus.actionStatuses = [ - { state: ACTION_STATES.ACKNOWLEDGED }, - { state: ACTION_STATES.ACKNOWLEDGED }, - { state: ACTION_STATES.ACKNOWLEDGED }, - ]; - - expect(watchStatus.comment).toBe(WATCH_STATE_COMMENTS.ACKNOWLEDGED); - }); - - it(`correctly calculates WATCH_STATE_COMMENTS.FAILING`, () => { - const watchStatus = WatchStatusModel.fromUpstreamJson(upstreamJson); - - watchStatus.actionStatuses = [ - { state: ACTION_STATES.OK }, - { state: ACTION_STATES.ACKNOWLEDGED }, - { state: ACTION_STATES.THROTTLED }, - { state: ACTION_STATES.FIRING }, - { state: ACTION_STATES.ERROR }, - ]; - - expect(watchStatus.comment).toBe(WATCH_STATE_COMMENTS.FAILING); - }); - - it(`correctly calculates WATCH_STATE_COMMENTS.OK when watch is inactive`, () => { - const watchStatus = WatchStatusModel.fromUpstreamJson(upstreamJson); - watchStatus.isActive = false; - - watchStatus.actionStatuses = [ - { state: ACTION_STATES.OK }, - { state: ACTION_STATES.ACKNOWLEDGED }, - { state: ACTION_STATES.THROTTLED }, - { state: ACTION_STATES.FIRING }, - { state: ACTION_STATES.ERROR }, - ]; - - expect(watchStatus.comment).toBe(WATCH_STATE_COMMENTS.OK); - }); - }); - - describe('state getter method', () => { - let upstreamJson; - beforeEach(() => { - upstreamJson = { - id: 'my-watch', - watchStatusJson: { - state: { - active: true, - }, - }, - }; - }); - - it(`correctly calculates WATCH_STATES.OK there are no actions`, () => { - const watchStatus = WatchStatusModel.fromUpstreamJson(upstreamJson); - watchStatus.isActive = true; - expect(watchStatus.state).toBe(WATCH_STATES.OK); - }); - - it(`correctly calculates WATCH_STATES.FIRING`, () => { - const watchStatus = WatchStatusModel.fromUpstreamJson(upstreamJson); - - watchStatus.actionStatuses = [{ state: ACTION_STATES.OK }, { state: ACTION_STATES.FIRING }]; - expect(watchStatus.state).toBe(WATCH_STATES.FIRING); - - watchStatus.actionStatuses = [ - { state: ACTION_STATES.OK }, - { state: ACTION_STATES.FIRING }, - { state: ACTION_STATES.THROTTLED }, - ]; - expect(watchStatus.state).toBe(WATCH_STATES.FIRING); - - watchStatus.actionStatuses = [ - { state: ACTION_STATES.OK }, - { state: ACTION_STATES.FIRING }, - { state: ACTION_STATES.THROTTLED }, - { state: ACTION_STATES.ACKNOWLEDGED }, - ]; - expect(watchStatus.state).toBe(WATCH_STATES.FIRING); - }); - - it(`correctly calculates WATCH_STATES.ERROR`, () => { - const watchStatus = WatchStatusModel.fromUpstreamJson(upstreamJson); - - watchStatus.actionStatuses = [ - { state: ACTION_STATES.OK }, - { state: ACTION_STATES.FIRING }, - { state: ACTION_STATES.THROTTLED }, - { state: ACTION_STATES.ACKNOWLEDGED }, - { state: ACTION_STATES.ERROR }, - ]; - - expect(watchStatus.state).toBe(WATCH_STATES.ERROR); - }); - - it('correctly calculates WATCH_STATE.CONFIG_ERROR', () => { - const watchStatus = WatchStatusModel.fromUpstreamJson(upstreamJson); - - watchStatus.actionStatuses = [ - { state: ACTION_STATES.OK }, - { state: ACTION_STATES.CONFIG_ERROR }, - ]; - - expect(watchStatus.state).toBe(WATCH_STATES.CONFIG_ERROR); - }); - - it(`correctly calculates WATCH_STATES.DISABLED when watch is inactive`, () => { - const watchStatus = WatchStatusModel.fromUpstreamJson(upstreamJson); - watchStatus.isActive = false; - - watchStatus.actionStatuses = [ - { state: ACTION_STATES.OK }, - { state: ACTION_STATES.FIRING }, - { state: ACTION_STATES.THROTTLED }, - { state: ACTION_STATES.ACKNOWLEDGED }, - { state: ACTION_STATES.ERROR }, - ]; - - expect(watchStatus.state).toBe(WATCH_STATES.DISABLED); - }); - }); - - describe('downstreamJson getter method', () => { - let upstreamJson; - beforeEach(() => { - upstreamJson = { - id: 'my-watch', - watchStatusJson: { - state: { - active: true, - }, - last_checked: '2017-03-02T14:25:31.139Z', - last_met_condition: '2017-07-05T14:25:31.139Z', - actions: { - foo: {}, - bar: {}, - }, - }, - }; - }); - - it('returns correct downstream JSON object', () => { - const watchStatus = WatchStatusModel.fromUpstreamJson(upstreamJson); - watchStatus.actionStatuses = [ - { id: 'foo', state: ACTION_STATES.OK }, - { id: 'bar', state: ACTION_STATES.OK }, - ]; - - const actual = watchStatus.downstreamJson; - - expect(actual.id).toBe(watchStatus.id); - expect(actual.state).toBe(watchStatus.state); - expect(actual.comment).toBe(watchStatus.comment); - expect(actual.isActive).toBe(watchStatus.isActive); - expect(actual.lastChecked).toBe(watchStatus.lastChecked); - expect(actual.lastMetCondition).toBe(watchStatus.lastMetCondition); - expect(actual.lastFired).toBe(watchStatus.lastFired); - expect(actual.actionStatuses.length).toBe(2); - }); - }); -}); diff --git a/x-pack/plugins/watcher/server/models/watch_status_model/watch_status_model.test.ts b/x-pack/plugins/watcher/server/models/watch_status_model/watch_status_model.test.ts new file mode 100644 index 0000000000000..2c7ba197cfdfb --- /dev/null +++ b/x-pack/plugins/watcher/server/models/watch_status_model/watch_status_model.test.ts @@ -0,0 +1,145 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import moment from 'moment'; + +import { WatchStatusModelEs } from '../../../common/types'; +import { WATCH_STATES, WATCH_STATE_COMMENTS } from '../../../common/constants'; +import { buildServerWatchStatusModel, buildClientWatchStatusModel } from './watch_status_model'; + +const upstreamJson: WatchStatusModelEs = { + id: 'my-watch', + watchStatusJson: { + version: 1, + state: { + active: true, + timestamp: '2017-03-02T14:25:31.139Z', + }, + last_checked: '2017-03-02T14:25:31.139Z', + last_met_condition: '2017-07-05T14:25:31.139Z', + actions: { + foo: { + ack: { + timestamp: '2015-05-26T18:21:08.630Z', + state: 'awaits_successful_execution', + }, + last_execution: { + timestamp: '2017-07-05T00:00:00.000Z', + successful: true, + }, + }, + bar: { + ack: { + timestamp: '2015-05-26T18:21:08.630Z', + state: 'awaits_successful_execution', + }, + last_execution: { + timestamp: '2017-07-05T00:00:00.000Z', + successful: true, + }, + }, + }, + }, +}; + +describe('WatchStatusModel', () => { + describe('buildServerWatchStatusModel', () => { + // TODO: Remove once all consumers and upstream dependencies are converted to TS. + it(`throws an error if no 'id' property in json`, () => { + expect(() => { + // @ts-ignore + buildServerWatchStatusModel({}); + }).toThrow(/must contain an id property/i); + }); + + // TODO: Remove once all consumers and upstream dependencies are converted to TS. + it(`throws an error if no 'watchStatusJson' property in json`, () => { + expect(() => { + // @ts-ignore + buildServerWatchStatusModel({ id: 'test ' }); + }).toThrow(/must contain a watchStatusJson property/i); + }); + + it('returns correct object for use by Kibana server', () => { + const serverWatchStatusModel = buildServerWatchStatusModel(upstreamJson); + + expect(serverWatchStatusModel.id).toBe(upstreamJson.id); + expect(serverWatchStatusModel.watchStatusJson).toEqual(upstreamJson.watchStatusJson); + expect(serverWatchStatusModel.isActive).toEqual(true); + expect(serverWatchStatusModel.lastChecked).toEqual( + moment(upstreamJson.watchStatusJson.last_checked) + ); + expect(serverWatchStatusModel.lastMetCondition).toEqual( + moment(upstreamJson.watchStatusJson.last_met_condition) + ); + + expect(serverWatchStatusModel.actionStatuses!.length).toBe(2); + + expect(serverWatchStatusModel.actionStatuses![0]).toMatchObject({ + id: 'foo', + actionStatusJson: { + ack: { + state: 'awaits_successful_execution', + timestamp: '2015-05-26T18:21:08.630Z', + }, + last_execution: { + successful: true, + timestamp: '2017-07-05T00:00:00.000Z', + }, + }, + errors: undefined, + isLastExecutionSuccessful: true, + lastExecutionReason: undefined, + lastThrottled: null, + lastSuccessfulExecution: null, + lastExecutionRawFormat: '2017-07-05T00:00:00.000Z', + lastCheckedRawFormat: '2017-03-02T14:25:31.139Z', + lastAcknowledged: moment('2015-05-26T18:21:08.630Z'), + lastExecution: moment('2017-07-05T00:00:00.000Z'), + }); + + expect(serverWatchStatusModel.actionStatuses![1]).toMatchObject({ + id: 'bar', + actionStatusJson: { + ack: { + state: 'awaits_successful_execution', + timestamp: '2015-05-26T18:21:08.630Z', + }, + last_execution: { + successful: true, + timestamp: '2017-07-05T00:00:00.000Z', + }, + }, + errors: undefined, + isLastExecutionSuccessful: true, + lastExecutionReason: undefined, + lastThrottled: null, + lastSuccessfulExecution: null, + lastExecutionRawFormat: '2017-07-05T00:00:00.000Z', + lastCheckedRawFormat: '2017-03-02T14:25:31.139Z', + lastAcknowledged: moment('2015-05-26T18:21:08.630Z'), + lastExecution: moment('2017-07-05T00:00:00.000Z'), + }); + }); + }); + + describe('buildClientWatchStatusModel', () => { + it('returns correct object for use by Kibana client', () => { + const serverWatchStatusModel = buildServerWatchStatusModel(upstreamJson); + const clientWatchStatusModel = buildClientWatchStatusModel(serverWatchStatusModel); + expect(serverWatchStatusModel.id).toBe(clientWatchStatusModel.id); + expect(serverWatchStatusModel.isActive).toBe(clientWatchStatusModel.isActive); + expect(serverWatchStatusModel.lastChecked).toBe(clientWatchStatusModel.lastChecked); + expect(serverWatchStatusModel.lastMetCondition).toBe(clientWatchStatusModel.lastMetCondition); + expect(clientWatchStatusModel.state).toBe(WATCH_STATES.OK); + expect(clientWatchStatusModel.comment).toBe(WATCH_STATE_COMMENTS.OK); + expect( + clientWatchStatusModel.actionStatuses && clientWatchStatusModel.actionStatuses.length + ).toBe(2); + }); + }); +}); diff --git a/x-pack/plugins/watcher/server/models/watch_status_model/watch_status_model.ts b/x-pack/plugins/watcher/server/models/watch_status_model/watch_status_model.ts new file mode 100644 index 0000000000000..0ef86a3a5eec9 --- /dev/null +++ b/x-pack/plugins/watcher/server/models/watch_status_model/watch_status_model.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { badRequest } from '@hapi/boom'; +import { i18n } from '@kbn/i18n'; + +import { + WatchStatusModelEs, + ServerWatchStatusModel, + ClientWatchStatusModel, +} from '../../../common/types'; +import { getMoment } from '../../../common/lib/get_moment'; +import { buildServerActionStatusModel, buildClientActionStatusModel } from '../action_status_model'; +import { deriveState, deriveComment, deriveLastFired } from './watch_status_model_utils'; + +export const buildServerWatchStatusModel = ( + watchStatusModelEs: WatchStatusModelEs +): ServerWatchStatusModel => { + const { id, watchStatusJson, state, watchErrors } = watchStatusModelEs; + + // TODO: Remove once all consumers and upstream dependencies are converted to TS. + if (!id) { + throw badRequest( + i18n.translate('xpack.watcher.models.watchStatus.idPropertyMissingBadRequestMessage', { + defaultMessage: 'JSON argument must contain an id property', + }) + ); + } + + // TODO: Remove once all consumers and upstream dependencies are converted to TS. + if (!watchStatusJson) { + throw badRequest( + i18n.translate( + 'xpack.watcher.models.watchStatus.watchStatusJsonPropertyMissingBadRequestMessage', + { + defaultMessage: 'JSON argument must contain a watchStatusJson property', + } + ) + ); + } + + const actionStatuses = Object.keys(watchStatusJson.actions ?? {}).map((actionStatusId) => { + const actionStatusJson = watchStatusJson.actions![actionStatusId]; + return buildServerActionStatusModel({ + id: actionStatusId, + actionStatusJson, + errors: watchErrors?.actions && watchErrors.actions[actionStatusId], + lastCheckedRawFormat: watchStatusJson.last_checked, + }); + }); + + return { + id, + watchState: state, + watchStatusJson, + watchErrors: watchErrors ?? {}, + isActive: Boolean(watchStatusJson.state.active), + lastChecked: getMoment(watchStatusJson.last_checked), + lastMetCondition: getMoment(watchStatusJson.last_met_condition), + actionStatuses, + }; +}; + +export const buildClientWatchStatusModel = ( + serverWatchStatusModel: ServerWatchStatusModel +): ClientWatchStatusModel => { + const { id, isActive, watchState, lastChecked, lastMetCondition, actionStatuses } = + serverWatchStatusModel; + const clientActionStatuses = + actionStatuses?.map((actionStatus) => buildClientActionStatusModel(actionStatus)) ?? []; + + return { + id, + isActive, + lastChecked, + lastMetCondition, + state: deriveState(isActive, watchState, clientActionStatuses), + comment: deriveComment(isActive, clientActionStatuses), + lastFired: deriveLastFired(clientActionStatuses), + actionStatuses: clientActionStatuses, + }; +}; diff --git a/x-pack/plugins/watcher/server/models/watch_status_model/watch_status_model_utils.test.ts b/x-pack/plugins/watcher/server/models/watch_status_model/watch_status_model_utils.test.ts new file mode 100644 index 0000000000000..e66a592951d90 --- /dev/null +++ b/x-pack/plugins/watcher/server/models/watch_status_model/watch_status_model_utils.test.ts @@ -0,0 +1,183 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import moment from 'moment'; + +import { ACTION_STATES, WATCH_STATES, WATCH_STATE_COMMENTS } from '../../../common/constants'; +import { ClientActionStatusModel } from '../../../common/types'; +import { deriveState, deriveComment, deriveLastFired } from './watch_status_model_utils'; + +const mockActionStatus = (opts: Partial): ClientActionStatusModel => ({ + state: ACTION_STATES.OK, + id: 'no-id', + isAckable: false, + lastAcknowledged: null, + lastThrottled: null, + lastExecution: null, + isLastExecutionSuccessful: true, + lastExecutionReason: '', + lastSuccessfulExecution: null, + ...opts, +}); + +describe('WatchStatusModel utils', () => { + describe('deriveLastFired', () => { + it(`is the latest lastExecution from the client action statuses`, () => { + const actionStatuses = [ + mockActionStatus({ lastExecution: moment('2017-07-05T00:00:00.000Z') }), + mockActionStatus({ lastExecution: moment('2015-05-26T18:21:08.630Z') }), + ]; + expect(deriveLastFired(actionStatuses)).toEqual(moment('2017-07-05T00:00:00.000Z')); + }); + }); + + describe('deriveComment', () => { + it(`is OK when there are no actions`, () => { + const isActive = true; + expect(deriveComment(isActive, [])).toBe(WATCH_STATE_COMMENTS.OK); + }); + + it(`is PARTIALLY_THROTTLED when some action states are throttled and others aren't`, () => { + const isActive = true; + const actionStatuses = [ + mockActionStatus({ state: ACTION_STATES.THROTTLED }), + mockActionStatus({ state: ACTION_STATES.FIRING }), + mockActionStatus({ state: ACTION_STATES.OK }), + ]; + expect(deriveComment(isActive, actionStatuses)).toBe( + WATCH_STATE_COMMENTS.PARTIALLY_THROTTLED + ); + }); + + it(`is THROTTLED when all action states are throttled`, () => { + const isActive = true; + const actionStatuses = [ + mockActionStatus({ state: ACTION_STATES.THROTTLED }), + mockActionStatus({ state: ACTION_STATES.THROTTLED }), + mockActionStatus({ state: ACTION_STATES.THROTTLED }), + ]; + expect(deriveComment(isActive, actionStatuses)).toBe(WATCH_STATE_COMMENTS.THROTTLED); + }); + + it(`is PARTIALLY_ACKNOWLEDGED when some action states are acknowledged and others arne't`, () => { + const isActive = true; + const actionStatuses = [ + mockActionStatus({ state: ACTION_STATES.ACKNOWLEDGED }), + mockActionStatus({ state: ACTION_STATES.OK }), + mockActionStatus({ state: ACTION_STATES.THROTTLED }), + mockActionStatus({ state: ACTION_STATES.FIRING }), + ]; + expect(deriveComment(isActive, actionStatuses)).toBe( + WATCH_STATE_COMMENTS.PARTIALLY_ACKNOWLEDGED + ); + }); + + it(`is ACKNOWLEDGED when all action states are acknowledged`, () => { + const isActive = true; + const actionStatuses = [ + mockActionStatus({ state: ACTION_STATES.ACKNOWLEDGED }), + mockActionStatus({ state: ACTION_STATES.ACKNOWLEDGED }), + mockActionStatus({ state: ACTION_STATES.ACKNOWLEDGED }), + ]; + expect(deriveComment(isActive, actionStatuses)).toBe(WATCH_STATE_COMMENTS.ACKNOWLEDGED); + }); + + it(`is FAILING when one action state is failing`, () => { + const isActive = true; + const actionStatuses = [ + mockActionStatus({ state: ACTION_STATES.OK }), + mockActionStatus({ state: ACTION_STATES.ACKNOWLEDGED }), + mockActionStatus({ state: ACTION_STATES.THROTTLED }), + mockActionStatus({ state: ACTION_STATES.FIRING }), + mockActionStatus({ state: ACTION_STATES.ERROR }), + ]; + expect(deriveComment(isActive, actionStatuses)).toBe(WATCH_STATE_COMMENTS.FAILING); + }); + + it(`is OK when watch is inactive`, () => { + const isActive = false; + const actionStatuses = [ + mockActionStatus({ state: ACTION_STATES.OK }), + mockActionStatus({ state: ACTION_STATES.ACKNOWLEDGED }), + mockActionStatus({ state: ACTION_STATES.THROTTLED }), + mockActionStatus({ state: ACTION_STATES.FIRING }), + mockActionStatus({ state: ACTION_STATES.ERROR }), + ]; + expect(deriveComment(isActive, actionStatuses)).toBe(WATCH_STATE_COMMENTS.OK); + }); + }); + + describe('deriveState', () => { + it(`is OK there are no actions`, () => { + const isActive = true; + const watchState = 'awaits_execution'; + expect(deriveState(isActive, watchState, [])).toBe(WATCH_STATES.OK); + }); + + it(`is FIRING when at least one action state is firing`, () => { + const isActive = true; + const watchState = 'awaits_execution'; + let actionStatuses = [ + mockActionStatus({ state: ACTION_STATES.OK }), + mockActionStatus({ state: ACTION_STATES.FIRING }), + ]; + expect(deriveState(isActive, watchState, actionStatuses)).toBe(WATCH_STATES.FIRING); + + actionStatuses = [ + mockActionStatus({ state: ACTION_STATES.OK }), + mockActionStatus({ state: ACTION_STATES.FIRING }), + mockActionStatus({ state: ACTION_STATES.THROTTLED }), + ]; + expect(deriveState(isActive, watchState, actionStatuses)).toBe(WATCH_STATES.FIRING); + + actionStatuses = [ + mockActionStatus({ state: ACTION_STATES.OK }), + mockActionStatus({ state: ACTION_STATES.FIRING }), + mockActionStatus({ state: ACTION_STATES.THROTTLED }), + mockActionStatus({ state: ACTION_STATES.ACKNOWLEDGED }), + ]; + expect(deriveState(isActive, watchState, actionStatuses)).toBe(WATCH_STATES.FIRING); + }); + + it(`is ERROR when at least one action state is error`, () => { + const isActive = true; + const watchState = 'awaits_execution'; + const actionStatuses = [ + mockActionStatus({ state: ACTION_STATES.OK }), + mockActionStatus({ state: ACTION_STATES.FIRING }), + mockActionStatus({ state: ACTION_STATES.THROTTLED }), + mockActionStatus({ state: ACTION_STATES.ACKNOWLEDGED }), + mockActionStatus({ state: ACTION_STATES.ERROR }), + mockActionStatus({ state: ACTION_STATES.CONFIG_ERROR }), + ]; + expect(deriveState(isActive, watchState, actionStatuses)).toBe(WATCH_STATES.ERROR); + }); + + it('is CONFIG_ERROR when at least one action state is config error', () => { + const isActive = true; + const watchState = 'awaits_execution'; + const actionStatuses = [ + mockActionStatus({ state: ACTION_STATES.OK }), + mockActionStatus({ state: ACTION_STATES.CONFIG_ERROR }), + ]; + expect(deriveState(isActive, watchState, actionStatuses)).toBe(WATCH_STATES.CONFIG_ERROR); + }); + + it(`is DISABLED when watch is inactive`, () => { + const isActive = false; + const watchState = 'awaits_execution'; + const actionStatuses = [ + mockActionStatus({ state: ACTION_STATES.OK }), + mockActionStatus({ state: ACTION_STATES.FIRING }), + mockActionStatus({ state: ACTION_STATES.THROTTLED }), + mockActionStatus({ state: ACTION_STATES.ACKNOWLEDGED }), + mockActionStatus({ state: ACTION_STATES.ERROR }), + ]; + expect(deriveState(isActive, watchState, actionStatuses)).toBe(WATCH_STATES.DISABLED); + }); + }); +}); diff --git a/x-pack/plugins/watcher/server/models/watch_status_model/watch_status_model_utils.ts b/x-pack/plugins/watcher/server/models/watch_status_model/watch_status_model_utils.ts new file mode 100644 index 0000000000000..7beff6e91e8fd --- /dev/null +++ b/x-pack/plugins/watcher/server/models/watch_status_model/watch_status_model_utils.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { forEach, maxBy } from 'lodash'; +import { ACTION_STATES, WATCH_STATES, WATCH_STATE_COMMENTS } from '../../../common/constants'; +import { ServerWatchStatusModel, ClientWatchStatusModel } from '../../../common/types'; + +// Export for unit tests. +export const deriveActionStatusTotals = ( + actionStatuses?: ClientWatchStatusModel['actionStatuses'] +) => { + const result: { [key: string]: number } = {}; + + forEach(ACTION_STATES, (state: keyof typeof ACTION_STATES) => { + result[state] = 0; + }); + + if (actionStatuses) { + actionStatuses.forEach((actionStatus) => { + result[actionStatus.state] = result[actionStatus.state] + 1; + }); + } + + return result; +}; + +export const deriveLastFired = (actionStatuses: ClientWatchStatusModel['actionStatuses']) => { + const actionStatus = maxBy(actionStatuses, 'lastExecution'); + if (actionStatus) { + return actionStatus.lastExecution; + } +}; + +export const deriveState = ( + isActive: ServerWatchStatusModel['isActive'], + watchState: ServerWatchStatusModel['watchState'], + actionStatuses: ClientWatchStatusModel['actionStatuses'] +) => { + if (!isActive) { + return WATCH_STATES.DISABLED; + } + + if (watchState === 'failed') { + return WATCH_STATES.ERROR; + } + + const totals = deriveActionStatusTotals(actionStatuses); + + if (totals[ACTION_STATES.ERROR] > 0) { + return WATCH_STATES.ERROR; + } + + if (totals[ACTION_STATES.CONFIG_ERROR] > 0) { + return WATCH_STATES.CONFIG_ERROR; + } + + const firingTotal = + totals[ACTION_STATES.FIRING] + + totals[ACTION_STATES.ACKNOWLEDGED] + + totals[ACTION_STATES.THROTTLED]; + + if (firingTotal > 0) { + return WATCH_STATES.FIRING; + } + + return WATCH_STATES.OK; +}; + +export const deriveComment = ( + isActive: ServerWatchStatusModel['isActive'], + actionStatuses: ClientWatchStatusModel['actionStatuses'] +) => { + const totals = deriveActionStatusTotals(actionStatuses); + const totalActions = actionStatuses ? actionStatuses.length : 0; + let result = WATCH_STATE_COMMENTS.OK; + + if (totals[ACTION_STATES.THROTTLED] > 0 && totals[ACTION_STATES.THROTTLED] < totalActions) { + result = WATCH_STATE_COMMENTS.PARTIALLY_THROTTLED; + } + + if (totals[ACTION_STATES.THROTTLED] > 0 && totals[ACTION_STATES.THROTTLED] === totalActions) { + result = WATCH_STATE_COMMENTS.THROTTLED; + } + + if (totals[ACTION_STATES.ACKNOWLEDGED] > 0 && totals[ACTION_STATES.ACKNOWLEDGED] < totalActions) { + result = WATCH_STATE_COMMENTS.PARTIALLY_ACKNOWLEDGED; + } + + if ( + totals[ACTION_STATES.ACKNOWLEDGED] > 0 && + totals[ACTION_STATES.ACKNOWLEDGED] === totalActions + ) { + result = WATCH_STATE_COMMENTS.ACKNOWLEDGED; + } + + if (totals[ACTION_STATES.ERROR] > 0) { + result = WATCH_STATE_COMMENTS.FAILING; + } + + if (!isActive) { + result = WATCH_STATE_COMMENTS.OK; + } + + return result; +}; diff --git a/x-pack/plugins/watcher/server/routes/api/watch/action/register_acknowledge_route.ts b/x-pack/plugins/watcher/server/routes/api/watch/action/register_acknowledge_route.ts index 2facea38a4317..60c466d53fa88 100644 --- a/x-pack/plugins/watcher/server/routes/api/watch/action/register_acknowledge_route.ts +++ b/x-pack/plugins/watcher/server/routes/api/watch/action/register_acknowledge_route.ts @@ -8,8 +8,11 @@ import { schema } from '@kbn/config-schema'; import { get } from 'lodash'; import { IScopedClusterClient } from '@kbn/core/server'; -// @ts-ignore -import { WatchStatusModel } from '../../../../models/watch_status_model'; + +import { + buildServerWatchStatusModel, + buildClientWatchStatusModel, +} from '../../../../models/watch_status_model'; import { RouteDependencies } from '../../../../types'; const paramsSchema = schema.object({ @@ -48,9 +51,9 @@ export function registerAcknowledgeRoute({ watchStatusJson, }; - const watchStatus = WatchStatusModel.fromUpstreamJson(json); + const watchStatus = buildServerWatchStatusModel(json); return response.ok({ - body: { watchStatus: watchStatus.downstreamJson }, + body: { watchStatus: buildClientWatchStatusModel(watchStatus) }, }); } catch (e) { if (e?.statusCode === 404 && e.meta?.body?.error) { diff --git a/x-pack/plugins/watcher/server/routes/api/watch/register_activate_route.ts b/x-pack/plugins/watcher/server/routes/api/watch/register_activate_route.ts index bde5e1f88a68b..54a135d2ff895 100644 --- a/x-pack/plugins/watcher/server/routes/api/watch/register_activate_route.ts +++ b/x-pack/plugins/watcher/server/routes/api/watch/register_activate_route.ts @@ -8,9 +8,12 @@ import { schema } from '@kbn/config-schema'; import { IScopedClusterClient } from '@kbn/core/server'; import { get } from 'lodash'; + import { RouteDependencies } from '../../../types'; -// @ts-ignore -import { WatchStatusModel } from '../../../models/watch_status_model'; +import { + buildServerWatchStatusModel, + buildClientWatchStatusModel, +} from '../../../models/watch_status_model'; function activateWatch(dataClient: IScopedClusterClient, watchId: string) { return dataClient.asCurrentUser.watcher.activateWatch({ @@ -46,10 +49,10 @@ export function registerActivateRoute({ watchStatusJson, }; - const watchStatus = WatchStatusModel.fromUpstreamJson(json); + const watchStatus = buildServerWatchStatusModel(json); return response.ok({ body: { - watchStatus: watchStatus.downstreamJson, + watchStatus: buildClientWatchStatusModel(watchStatus), }, }); } catch (e) { diff --git a/x-pack/plugins/watcher/server/routes/api/watch/register_deactivate_route.ts b/x-pack/plugins/watcher/server/routes/api/watch/register_deactivate_route.ts index 21bb73e2b6067..e5565bc4ee7af 100644 --- a/x-pack/plugins/watcher/server/routes/api/watch/register_deactivate_route.ts +++ b/x-pack/plugins/watcher/server/routes/api/watch/register_deactivate_route.ts @@ -9,8 +9,10 @@ import { schema } from '@kbn/config-schema'; import { IScopedClusterClient } from '@kbn/core/server'; import { get } from 'lodash'; import { RouteDependencies } from '../../../types'; -// @ts-ignore -import { WatchStatusModel } from '../../../models/watch_status_model'; +import { + buildServerWatchStatusModel, + buildClientWatchStatusModel, +} from '../../../models/watch_status_model'; const paramsSchema = schema.object({ watchId: schema.string(), @@ -46,10 +48,10 @@ export function registerDeactivateRoute({ watchStatusJson, }; - const watchStatus = WatchStatusModel.fromUpstreamJson(json); + const watchStatus = buildServerWatchStatusModel(json); return response.ok({ body: { - watchStatus: watchStatus.downstreamJson, + watchStatus: buildClientWatchStatusModel(watchStatus), }, }); } catch (e) {