[Synthetics] Initial monitor status alert (#147672)

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Fixes https://github.com/elastic/kibana/issues/145980
This commit is contained in:
Shahzad 2022-12-29 13:46:53 +01:00 committed by GitHub
parent 7875f0c348
commit 86527753a2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
157 changed files with 4196 additions and 898 deletions

1
.gitignore vendored
View file

@ -112,3 +112,4 @@ elastic-agent.yml
fleet-server.yml
/packages/kbn-package-map/package-map.json
/packages/kbn-synthetic-package-map/
**/.synthetics/

View file

@ -725,7 +725,7 @@
"@cypress/webpack-preprocessor": "^5.12.2",
"@elastic/eslint-plugin-eui": "0.0.2",
"@elastic/makelogs": "^6.1.1",
"@elastic/synthetics": "^1.0.0-beta.23",
"@elastic/synthetics": "^1.0.0-beta.39",
"@emotion/babel-preset-css-prop": "^11.10.0",
"@emotion/jest": "^11.10.0",
"@istanbuljs/nyc-config-typescript": "^1.0.2",

View file

@ -18,6 +18,11 @@ const { argv } = yargs(process.argv.slice(2))
type: 'boolean',
description: 'Pause on error',
})
.option('watch', {
default: false,
type: 'boolean',
description: 'Runs the server in watch mode, restarting on changes',
})
.option('grep', {
default: undefined,
type: 'string',

View file

@ -62,10 +62,7 @@ export class SyntheticsRunner {
const promises = dataArchives.map((archive) => esArchiver.loadIfNeeded(e2eDir + archive));
await Promise.all([
...promises,
esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'),
]);
await Promise.all([...promises]);
} catch (e) {
console.log(e);
}
@ -98,10 +95,18 @@ export class SyntheticsRunner {
const { headless, match, pauseOnError } = this.params;
const results = await syntheticsRun({
params: { kibanaUrl: this.kibanaUrl, getService: this.getService },
playwrightOptions: { headless, chromiumSandbox: false, timeout: 60 * 1000 },
playwrightOptions: {
headless,
chromiumSandbox: false,
timeout: 60 * 1000,
viewport: {
height: 900,
width: 1600,
},
},
match: match === 'undefined' ? '' : match,
pauseOnError,
screenshots: 'off',
screenshots: 'only-on-failure',
});
await this.assertResults(results);

View file

@ -344,6 +344,7 @@ export class Plugin
});
return {
observabilityRuleTypeRegistry: this.observabilityRuleTypeRegistry,
navigation: {
PageTemplate,
},

View file

@ -34,6 +34,7 @@ export const DEFAULT_COMMON_FIELDS: CommonFields = {
[ConfigKey.MONITOR_TYPE]: DataStream.HTTP,
[ConfigKey.FORM_MONITOR_TYPE]: FormMonitorType.MULTISTEP,
[ConfigKey.ENABLED]: true,
[ConfigKey.ALERT_CONFIG]: { status: { enabled: true } },
[ConfigKey.SCHEDULE]: {
number: '3',
unit: ScheduleUnit.MINUTES,

View file

@ -5,8 +5,14 @@
* 2.0.
*/
export enum AlertConfigKey {
STATUS_ENABLED = 'alert.status.enabled',
TLS_ENABLED = 'alert.tls.enabled',
}
// values must match keys in the integration package
export enum ConfigKey {
ALERT_CONFIG = 'alert',
APM_SERVICE_NAME = 'service.name',
CUSTOM_HEARTBEAT_ID = 'custom_heartbeat_id',
CONFIG_ID = 'config_id',

View file

@ -13,4 +13,5 @@ export enum SYNTHETICS_API_URLS {
INDEX_SIZE = `/internal/synthetics/index_size`,
PARAMS = `/synthetics/params`,
SYNC_GLOBAL_PARAMS = `/synthetics/sync_global_params`,
ENABLE_DEFAULT_ALERTING = `/synthetics/enable_default_alerting`,
}

View file

@ -0,0 +1,33 @@
/*
* 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 { ActionGroup } from '@kbn/alerting-plugin/common';
import { i18n } from '@kbn/i18n';
export type MonitorStatusActionGroup =
ActionGroup<'xpack.synthetics.alerts.actionGroups.monitorStatus'>;
export const MONITOR_STATUS: MonitorStatusActionGroup = {
id: 'xpack.synthetics.alerts.actionGroups.monitorStatus',
name: i18n.translate('xpack.synthetics.alertRules.actionGroups.monitorStatus', {
defaultMessage: 'Synthetics monitor status',
}),
};
export const ACTION_GROUP_DEFINITIONS: {
MONITOR_STATUS: MonitorStatusActionGroup;
} = {
MONITOR_STATUS,
};
export const SYNTHETICS_STATUS_RULE = 'xpack.synthetics.alerts.monitorStatus';
export const SYNTHETICS_ALERT_RULE_TYPES = {
MONITOR_STATUS: SYNTHETICS_STATUS_RULE,
};
export const SYNTHETICS_RULE_TYPES = [SYNTHETICS_STATUS_RULE];

View file

@ -0,0 +1,19 @@
/*
* 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 const AGENT_NAME = 'agent.name';
export const MONITOR_ID = 'monitor.id';
export const MONITOR_NAME = 'monitor.name';
export const MONITOR_TYPE = 'monitor.type';
export const URL_FULL = 'url.full';
export const URL_PORT = 'url.port';
export const OBSERVER_GEO_NAME = 'observer.geo.name';
export const ERROR_MESSAGE = 'error.message';

View file

@ -16,6 +16,7 @@ export const commonFormatters: CommonFormatMap = {
[ConfigKey.LOCATIONS]: null,
[ConfigKey.MONITOR_TYPE]: null,
[ConfigKey.ENABLED]: null,
[ConfigKey.ALERT_CONFIG]: null,
[ConfigKey.CONFIG_ID]: null,
[ConfigKey.SCHEDULE]: (fields) =>
JSON.stringify(

View file

@ -6,41 +6,14 @@
*/
import { populateAlertActions } from './alert_actions';
import { ActionConnector } from '../alerts/alerts';
const selectedMonitor = {
docId: 'X5dkPncBy0xTcvZ347hy',
timestamp: '2021-01-26T11:12:14.519Z',
'@timestamp': '2021-01-26T11:12:14.519Z',
url: { scheme: 'tcp', domain: 'localhost', port: 18278, full: 'tcp://localhost:18278' },
error: { type: 'io', message: 'dial tcp 127.0.0.1:18278: connect: connection refused' },
ecs: { version: '1.7.0' },
resolve: { ip: '127.0.0.1', rtt: { us: 410 } },
summary: { down: 1, up: 0 },
monitor: {
ip: '127.0.0.1',
name: 'Always Down Local Port',
type: 'tcp',
timespan: { gte: '2021-01-26T11:12:14.519Z', lt: '2021-01-26T11:17:14.519Z' },
id: 'always-down',
status: 'down',
duration: { us: 695 },
check_group: 'a53b0003-5fc6-11eb-9241-42010a84000f',
},
event: { dataset: 'uptime' },
agent: {
ephemeral_id: '7d86e765-9f29-46e6-b1ec-047b09b4074e',
id: '7c9d2825-614f-4906-a13e-c9db1c6e5585',
name: 'gke-edge-oblt-edge-oblt-pool-c9faf257-m1ci',
type: 'heartbeat',
version: '8.0.0',
},
};
import { ActionConnector } from './types';
import { MONITOR_STATUS } from '../constants/uptime_alerts';
import { MonitorStatusTranslations } from '../translations';
describe('Alert Actions factory', () => {
it('generate expected action for pager duty', async () => {
const resp = populateAlertActions({
selectedMonitor,
groupId: MONITOR_STATUS.id,
defaultActions: [
{
actionTypeId: '.pagerduty',
@ -49,33 +22,36 @@ describe('Alert Actions factory', () => {
dedupKey: 'always-downxpack.uptime.alerts.actionGroups.monitorStatus',
eventAction: 'trigger',
severity: 'error',
summary:
'Monitor {{context.monitorName}} with url {{{context.monitorUrl}}} from {{context.observerLocation}} {{{context.statusMessage}}} The latest error message is {{{context.latestErrorMessage}}}',
summary: MonitorStatusTranslations.defaultActionMessage,
},
id: 'f2a3b195-ed76-499a-805d-82d24d4eeba9',
},
] as unknown as ActionConnector[],
translations: {
defaultActionMessage: MonitorStatusTranslations.defaultActionMessage,
defaultRecoveryMessage: MonitorStatusTranslations.defaultRecoveryMessage,
defaultSubjectMessage: MonitorStatusTranslations.defaultSubjectMessage,
},
});
expect(resp).toEqual([
{
group: 'recovered',
id: 'f2a3b195-ed76-499a-805d-82d24d4eeba9',
params: {
dedupKey: 'always-downxpack.uptime.alerts.actionGroups.monitorStatus',
dedupKey: expect.any(String),
eventAction: 'resolve',
summary:
'Monitor Always Down Local Port with url tcp://localhost:18278 has recovered with status Up',
'Alert for monitor {{context.monitorName}} with url {{{context.monitorUrl}}} from {{context.observerLocation}} has recovered',
},
},
{
group: 'xpack.uptime.alerts.actionGroups.monitorStatus',
id: 'f2a3b195-ed76-499a-805d-82d24d4eeba9',
params: {
dedupKey: 'always-downxpack.uptime.alerts.actionGroups.monitorStatus',
dedupKey: expect.any(String),
eventAction: 'trigger',
severity: 'error',
summary:
'Monitor {{context.monitorName}} with url {{{context.monitorUrl}}} from {{context.observerLocation}} {{{context.statusMessage}}} The latest error message is {{{context.latestErrorMessage}}}',
summary: MonitorStatusTranslations.defaultActionMessage,
},
},
]);
@ -83,7 +59,7 @@ describe('Alert Actions factory', () => {
it('generate expected action for slack action connector', async () => {
const resp = populateAlertActions({
selectedMonitor,
groupId: MONITOR_STATUS.id,
defaultActions: [
{
actionTypeId: '.pagerduty',
@ -98,27 +74,31 @@ describe('Alert Actions factory', () => {
id: 'f2a3b195-ed76-499a-805d-82d24d4eeba9',
},
] as unknown as ActionConnector[],
translations: {
defaultActionMessage: MonitorStatusTranslations.defaultActionMessage,
defaultRecoveryMessage: MonitorStatusTranslations.defaultRecoveryMessage,
defaultSubjectMessage: MonitorStatusTranslations.defaultSubjectMessage,
},
});
expect(resp).toEqual([
{
group: 'recovered',
id: 'f2a3b195-ed76-499a-805d-82d24d4eeba9',
params: {
dedupKey: 'always-downxpack.uptime.alerts.actionGroups.monitorStatus',
dedupKey: expect.any(String),
eventAction: 'resolve',
summary:
'Monitor Always Down Local Port with url tcp://localhost:18278 has recovered with status Up',
'Alert for monitor {{context.monitorName}} with url {{{context.monitorUrl}}} from {{context.observerLocation}} has recovered',
},
},
{
group: 'xpack.uptime.alerts.actionGroups.monitorStatus',
id: 'f2a3b195-ed76-499a-805d-82d24d4eeba9',
params: {
dedupKey: 'always-downxpack.uptime.alerts.actionGroups.monitorStatus',
dedupKey: expect.any(String),
eventAction: 'trigger',
severity: 'error',
summary:
'Monitor {{context.monitorName}} with url {{{context.monitorUrl}}} from {{context.observerLocation}} {{{context.statusMessage}}} The latest error message is {{{context.latestErrorMessage}}}',
summary: MonitorStatusTranslations.defaultActionMessage,
},
},
]);

View file

@ -5,8 +5,6 @@
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { RuleAction as RuleActionOrig } from '@kbn/triggers-actions-ui-plugin/public';
import type {
IndexActionParams,
PagerDutyActionParams,
@ -16,12 +14,11 @@ import type {
WebhookActionParams,
EmailActionParams,
} from '@kbn/stack-connectors-plugin/server/connector_types';
import { NewAlertParams } from './alerts';
import { ACTION_GROUP_DEFINITIONS } from '../../../../common/constants/alerts';
import { MonitorStatusTranslations } from '../../../../common/translations';
import { ActionTypeId } from '../../components/settings/types';
import { Ping } from '../../../../common/runtime_types/ping';
import { DefaultEmail } from '../../../../common/runtime_types';
import { RuleAction as RuleActionOrig } from '@kbn/alerting-plugin/common';
import uuid from 'uuid';
import { ActionConnector, ActionTypeId } from './types';
import { DefaultEmail } from '../runtime_types';
export const SLACK_ACTION_ID: ActionTypeId = '.slack';
export const PAGER_DUTY_ACTION_ID: ActionTypeId = '.pagerduty';
@ -33,30 +30,30 @@ export const JIRA_ACTION_ID: ActionTypeId = '.jira';
export const WEBHOOK_ACTION_ID: ActionTypeId = '.webhook';
export const EMAIL_ACTION_ID: ActionTypeId = '.email';
const { MONITOR_STATUS } = ACTION_GROUP_DEFINITIONS;
export type RuleAction = Omit<RuleActionOrig, 'actionTypeId'>;
const getRecoveryMessage = (selectedMonitor: Ping) => {
return i18n.translate('xpack.synthetics.alerts.monitorStatus.recoveryMessage', {
defaultMessage: 'Monitor {monitor} with url {url} has recovered with status Up',
values: {
monitor: selectedMonitor?.monitor?.name || selectedMonitor?.monitor?.id,
url: selectedMonitor?.url?.full,
},
});
};
interface Translations {
defaultActionMessage: string;
defaultRecoveryMessage: string;
defaultSubjectMessage: string;
}
export function populateAlertActions({
defaultActions,
selectedMonitor,
defaultEmail,
}: NewAlertParams) {
groupId,
translations,
}: {
groupId: string;
defaultActions: ActionConnector[];
defaultEmail?: DefaultEmail;
translations: Translations;
}) {
const actions: RuleAction[] = [];
defaultActions.forEach((aId) => {
const action: RuleAction = {
id: aId.id,
group: MONITOR_STATUS.id,
group: groupId,
params: {},
};
@ -64,54 +61,55 @@ export function populateAlertActions({
id: aId.id,
group: 'recovered',
params: {
message: getRecoveryMessage(selectedMonitor),
message: translations.defaultRecoveryMessage,
},
};
switch (aId.actionTypeId) {
case PAGER_DUTY_ACTION_ID:
action.params = getPagerDutyActionParams(selectedMonitor);
recoveredAction.params = getPagerDutyActionParams(selectedMonitor, true);
const dedupKey = uuid.v4();
action.params = getPagerDutyActionParams(translations, dedupKey);
recoveredAction.params = getPagerDutyActionParams(translations, dedupKey, true);
actions.push(recoveredAction);
break;
case SERVER_LOG_ACTION_ID:
action.params = getServerLogActionParams(selectedMonitor);
recoveredAction.params = getServerLogActionParams(selectedMonitor, true);
action.params = getServerLogActionParams(translations);
recoveredAction.params = getServerLogActionParams(translations, true);
actions.push(recoveredAction);
break;
case INDEX_ACTION_ID:
action.params = getIndexActionParams(selectedMonitor);
recoveredAction.params = getIndexActionParams(selectedMonitor, true);
action.params = getIndexActionParams(translations);
recoveredAction.params = getIndexActionParams(translations, true);
actions.push(recoveredAction);
break;
case SERVICE_NOW_ACTION_ID:
action.params = getServiceNowActionParams();
action.params = getServiceNowActionParams(translations);
// Recovery action for service now is not implemented yet
break;
case JIRA_ACTION_ID:
action.params = getJiraActionParams();
action.params = getJiraActionParams(translations);
// Recovery action for Jira is not implemented yet
break;
case WEBHOOK_ACTION_ID:
action.params = getWebhookActionParams(selectedMonitor);
recoveredAction.params = getWebhookActionParams(selectedMonitor, true);
action.params = getWebhookActionParams(translations);
recoveredAction.params = getWebhookActionParams(translations, true);
actions.push(recoveredAction);
break;
case SLACK_ACTION_ID:
case TEAMS_ACTION_ID:
action.params = {
message: MonitorStatusTranslations.defaultActionMessage,
message: translations.defaultActionMessage,
};
actions.push(recoveredAction);
break;
case EMAIL_ACTION_ID:
if (defaultEmail) {
action.params = getEmailActionParams(defaultEmail, selectedMonitor);
action.params = getEmailActionParams(translations, defaultEmail);
}
break;
default:
action.params = {
message: MonitorStatusTranslations.defaultActionMessage,
message: translations.defaultActionMessage,
};
}
@ -121,14 +119,14 @@ export function populateAlertActions({
return actions;
}
function getIndexActionParams(selectedMonitor: Ping, recovery = false): IndexActionParams {
function getIndexActionParams(translations: Translations, recovery = false): IndexActionParams {
if (recovery) {
return {
documents: [
{
monitorName: '{{context.monitorName}}',
monitorUrl: '{{{context.monitorUrl}}}',
statusMessage: getRecoveryMessage(selectedMonitor),
statusMessage: translations.defaultRecoveryMessage,
latestErrorMessage: '',
observerLocation: '{{context.observerLocation}}',
},
@ -150,50 +148,58 @@ function getIndexActionParams(selectedMonitor: Ping, recovery = false): IndexAct
};
}
function getServerLogActionParams(selectedMonitor: Ping, recovery = false): ServerLogActionParams {
function getServerLogActionParams(
{ defaultActionMessage, defaultRecoveryMessage }: Translations,
recovery = false
): ServerLogActionParams {
if (recovery) {
return {
level: 'info',
message: getRecoveryMessage(selectedMonitor),
message: defaultRecoveryMessage,
};
}
return {
level: 'warn',
message: MonitorStatusTranslations.defaultActionMessage,
message: defaultActionMessage,
};
}
function getWebhookActionParams(selectedMonitor: Ping, recovery = false): WebhookActionParams {
function getWebhookActionParams(
{ defaultActionMessage, defaultRecoveryMessage }: Translations,
recovery = false
): WebhookActionParams {
return {
body: recovery
? getRecoveryMessage(selectedMonitor)
: MonitorStatusTranslations.defaultActionMessage,
body: recovery ? defaultRecoveryMessage : defaultActionMessage,
};
}
function getPagerDutyActionParams(selectedMonitor: Ping, recovery = false): PagerDutyActionParams {
function getPagerDutyActionParams(
{ defaultActionMessage, defaultRecoveryMessage }: Translations,
dedupKey: string,
recovery = false
): PagerDutyActionParams {
if (recovery) {
return {
dedupKey: selectedMonitor.monitor.id + MONITOR_STATUS.id,
dedupKey,
eventAction: 'resolve',
summary: getRecoveryMessage(selectedMonitor),
summary: defaultRecoveryMessage,
};
}
return {
dedupKey: selectedMonitor.monitor.id + MONITOR_STATUS.id,
dedupKey,
eventAction: 'trigger',
severity: 'error',
summary: MonitorStatusTranslations.defaultActionMessage,
summary: defaultActionMessage,
};
}
function getServiceNowActionParams(): ServiceNowActionParams {
function getServiceNowActionParams({ defaultActionMessage }: Translations): ServiceNowActionParams {
return {
subAction: 'pushToService',
subActionParams: {
incident: {
short_description: MonitorStatusTranslations.defaultActionMessage,
description: MonitorStatusTranslations.defaultActionMessage,
short_description: defaultActionMessage,
description: defaultActionMessage,
impact: '2',
severity: '2',
urgency: '2',
@ -208,14 +214,14 @@ function getServiceNowActionParams(): ServiceNowActionParams {
};
}
function getJiraActionParams(): JiraActionParams {
function getJiraActionParams({ defaultActionMessage }: Translations): JiraActionParams {
return {
subAction: 'pushToService',
subActionParams: {
incident: {
summary: MonitorStatusTranslations.defaultActionMessage,
summary: defaultActionMessage,
externalId: null,
description: MonitorStatusTranslations.defaultActionMessage,
description: defaultActionMessage,
issueType: null,
priority: '2',
labels: null,
@ -227,19 +233,13 @@ function getJiraActionParams(): JiraActionParams {
}
function getEmailActionParams(
defaultEmail: DefaultEmail,
selectedMonitor: Ping
{ defaultActionMessage, defaultSubjectMessage }: Translations,
defaultEmail: DefaultEmail
): EmailActionParams {
return {
to: defaultEmail.to,
subject: i18n.translate('xpack.synthetics.monitor.simpleStatusAlert.email.subject', {
defaultMessage: 'Monitor {monitor} with url {url} is down',
values: {
monitor: selectedMonitor?.monitor?.name || selectedMonitor?.monitor?.id,
url: selectedMonitor?.url?.full,
},
}),
message: MonitorStatusTranslations.defaultActionMessage,
subject: defaultSubjectMessage,
message: defaultActionMessage,
cc: defaultEmail.cc ?? [],
bcc: defaultEmail.bcc ?? [],
kibanaFooterLink: {

View file

@ -0,0 +1,12 @@
/*
* 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 { schema, TypeOf } from '@kbn/config-schema';
export const StatusRulePramsSchema = schema.object({});
export type StatusRuleParams = TypeOf<typeof StatusRulePramsSchema>;

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const SyntheticsMonitorStatusTranslations = {
defaultActionMessage: i18n.translate(
'xpack.synthetics.alerts.syntheticsMonitorStatus.defaultActionMessage',
{
defaultMessage:
'The monitor {monitorName} checking {monitorUrl} from {locationName} last ran at {checkedAt} and is {status}. The last error received is: {lastErrorMessage}.',
values: {
monitorName: '{{context.monitorName}}',
monitorUrl: '{{{context.monitorUrl}}}',
status: '{{{context.status}}}',
lastErrorMessage: '{{{context.lastErrorMessage}}}',
locationName: '{{context.locationName}}',
checkedAt: '{{context.checkedAt}}',
},
}
),
defaultSubjectMessage: i18n.translate(
'xpack.synthetics.alerts.syntheticsMonitorStatus.defaultSubjectMessage',
{
defaultMessage: 'The monitor {monitorName} checking {monitorUrl} is down.',
values: {
monitorName: '{{context.monitorName}}',
monitorUrl: '{{{context.monitorUrl}}}',
},
}
),
defaultRecoveryMessage: i18n.translate(
'xpack.synthetics.alerts.syntheticsMonitorStatus.defaultRecoveryMessage',
{
defaultMessage:
'The alert for the monitor {monitorName} checking {monitorUrl} from {locationName} is no longer active: {recoveryReason}.',
values: {
monitorName: '{{context.monitorName}}',
monitorUrl: '{{{context.monitorUrl}}}',
locationName: '{{context.locationName}}',
recoveryReason: '{{context.recoveryReason}}',
},
}
),
name: i18n.translate('xpack.synthetics.alerts.syntheticsMonitorStatus.clientName', {
defaultMessage: 'Monitor status',
}),
description: i18n.translate('xpack.synthetics.alerts.syntheticsMonitorStatus.description', {
defaultMessage: 'Alert when a monitor is down.',
}),
};

View file

@ -17,6 +17,8 @@ import type {
EmailConnectorTypeId,
} from '@kbn/stack-connectors-plugin/server/connector_types';
import type { ActionConnector as RawActionConnector } from '@kbn/triggers-actions-ui-plugin/public';
export type ActionTypeId =
| typeof SlackConnectorTypeId
| typeof PagerDutyConnectorTypeId
@ -27,3 +29,5 @@ export type ActionTypeId =
| typeof JiraConnectorTypeId
| typeof WebhookConnectorTypeId
| typeof EmailConnectorTypeId;
export type ActionConnector = Omit<RawActionConnector, 'secrets'>;

View file

@ -0,0 +1,25 @@
/*
* 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 * as t from 'io-ts';
export const SyntheticsCommonStateType = t.intersection([
t.partial({
firstTriggeredAt: t.string,
lastTriggeredAt: t.string,
lastResolvedAt: t.string,
meta: t.record(t.string, t.unknown),
idWithLocation: t.string,
}),
t.type({
firstCheckedAt: t.string,
lastCheckedAt: t.string,
isTriggered: t.boolean,
}),
]);
export type SyntheticsCommonState = t.TypeOf<typeof SyntheticsCommonStateType>;

View file

@ -0,0 +1,47 @@
/*
* 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 * as t from 'io-ts';
export const AlertConfigCodec = t.intersection([
t.interface({
enabled: t.boolean,
}),
t.partial({
groupBy: t.string,
}),
]);
export const AlertConfigsCodec = t.partial({
tls: AlertConfigCodec,
status: AlertConfigCodec,
});
export type AlertConfig = t.TypeOf<typeof AlertConfigCodec>;
export type AlertConfigs = t.TypeOf<typeof AlertConfigsCodec>;
export const toggleStatusAlert = (configs: AlertConfigs = {}): AlertConfigs => {
if (configs.status?.enabled) {
return {
...configs,
status: {
...configs.status,
enabled: false,
},
};
}
return {
...configs,
status: {
enabled: true,
},
};
};
export const isStatusEnabled = (configs: AlertConfigs = {}): boolean => {
return configs.status?.enabled ?? false;
};

View file

@ -6,6 +6,7 @@
*/
import * as t from 'io-ts';
import { AlertConfigsCodec } from './alert_config';
import { secretKeys } from '../../constants/monitor_management';
import { ConfigKey } from './config_key';
import { MonitorServiceLocationCodec, ServiceLocationErrors } from './locations';
@ -88,6 +89,7 @@ export const CommonFieldsCodec = t.intersection([
[ConfigKey.PROJECT_ID]: t.string,
[ConfigKey.ORIGINAL_SPACE]: t.string,
[ConfigKey.CUSTOM_HEARTBEAT_ID]: t.string,
[ConfigKey.ALERT_CONFIG]: AlertConfigsCodec,
}),
]);
@ -373,12 +375,16 @@ export type MonitorDefaults = t.TypeOf<typeof MonitorDefaultsCodec>;
export const MonitorManagementListResultCodec = t.type({
monitors: t.array(
t.interface({
id: t.string,
attributes: EncryptedSyntheticsMonitorCodec,
updated_at: t.string,
created_at: t.string,
})
t.intersection([
t.interface({
id: t.string,
attributes: EncryptedSyntheticsMonitorCodec,
}),
t.partial({
updated_at: t.string,
created_at: t.string,
}),
])
),
page: t.number,
perPage: t.number,
@ -395,6 +401,7 @@ export const MonitorOverviewItemCodec = t.interface({
configId: t.string,
location: MonitorServiceLocationCodec,
isEnabled: t.boolean,
isStatusAlertEnabled: t.boolean,
});
export type MonitorOverviewItem = t.TypeOf<typeof MonitorOverviewItemCodec>;

View file

@ -6,6 +6,7 @@
*/
import * as t from 'io-ts';
import { AlertConfigsCodec } from './alert_config';
import { ScreenshotOptionCodec } from './monitor_configs';
export const ProjectMonitorThrottlingConfigCodec = t.interface({
@ -36,6 +37,7 @@ export const ProjectMonitorCodec = t.intersection([
}),
params: t.record(t.string, t.unknown),
enabled: t.boolean,
alert: AlertConfigsCodec,
urls: t.union([t.string, t.array(t.string)]),
hosts: t.union([t.string, t.array(t.string)]),
max_redirects: t.string,

View file

@ -6,12 +6,14 @@
*/
import * as t from 'io-ts';
import { PingType } from '..';
export const OverviewStatusMetaDataCodec = t.interface({
monitorQueryId: t.string,
configId: t.string,
location: t.string,
status: t.string,
ping: PingType,
});
export const OverviewStatusCodec = t.interface({

View file

@ -26,13 +26,24 @@ export const MonitorStatusTranslations = {
'xpack.synthetics.alerts.monitorStatus.defaultActionMessage',
{
defaultMessage:
'Monitor {monitorName} with url {monitorUrl} from {observerLocation} {statusMessage} The latest error message is {latestErrorMessage}',
'Monitor {monitorName} with url {monitorUrl} from {observerLocation} {statusMessage} The latest error message is {latestErrorMessage}, checked at {checkedAt}',
values: {
monitorName: '{{context.monitorName}}',
monitorUrl: '{{{context.monitorUrl}}}',
statusMessage: '{{{context.statusMessage}}}',
latestErrorMessage: '{{{context.latestErrorMessage}}}',
observerLocation: '{{context.observerLocation}}',
checkedAt: '{{context.checkedAt}}',
},
}
),
defaultSubjectMessage: i18n.translate(
'xpack.synthetics.alerts.monitorStatus.defaultSubjectMessage',
{
defaultMessage: 'Monitor {monitorName} with url {monitorUrl} is down',
values: {
monitorName: '{{context.monitorName}}',
monitorUrl: '{{{context.monitorUrl}}}',
},
}
),

View file

@ -0,0 +1,28 @@
/*
* 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 { format } from './get_monitor_url';
export const getSyntheticsMonitorRouteFromMonitorId = ({
configId,
dateRangeStart,
dateRangeEnd,
locationId,
}: {
configId: string;
dateRangeStart: string;
dateRangeEnd: string;
locationId: string;
}) =>
format({
pathname: `/app/synthetics/monitor/${configId}/history`,
query: {
dateRangeEnd,
dateRangeStart,
locationId,
},
});

View file

@ -6,10 +6,13 @@
*/
import { FtrConfigProviderContext } from '@kbn/test';
import { argv } from '@kbn/observability-plugin/e2e/parse_args_params';
import { CA_CERT_PATH } from '@kbn/dev-utils';
import { readKibanaConfig } from './tasks/read_kibana_config';
const { watch } = argv;
const MANIFEST_KEY = 'xpack.uptime.service.manifestUrl';
const SERVICE_PASSWORD = 'xpack.uptime.service.password';
const SERVICE_USERNAME = 'xpack.uptime.service.username';
@ -43,7 +46,9 @@ async function config({ readConfigFile }: FtrConfigProviderContext) {
kbnTestServer: {
...xpackFunctionalTestsConfig.get('kbnTestServer'),
sourceArgs: [...xpackFunctionalTestsConfig.get('kbnTestServer.sourceArgs'), '--no-watch'],
sourceArgs: watch
? []
: [...xpackFunctionalTestsConfig.get('kbnTestServer.sourceArgs'), '--no-watch'],
serverArgs: [
...xpackFunctionalTestsConfig.get('kbnTestServer.serverArgs'),
'--csp.strict=false',

View file

@ -0,0 +1,191 @@
/*
* 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 { journey, step, before, after, expect } from '@elastic/synthetics';
import { byTestId } from '@kbn/ux-plugin/e2e/journeys/utils';
import { RetryService } from '@kbn/ftr-common-functional-services';
import uuid from 'uuid';
import { getReasonMessage } from '../../../../server/legacy_uptime/lib/alerts/status_check';
import { syntheticsAppPageProvider } from '../../../page_objects/synthetics/synthetics_app';
import { SyntheticsServices } from '../services/synthetics_services';
journey(`DefaultStatusAlert`, async ({ page, params }) => {
page.setDefaultTimeout(60 * 1000);
const syntheticsApp = syntheticsAppPageProvider({ page, kibanaUrl: params.kibanaUrl });
const services = new SyntheticsServices(params);
const getService = params.getService;
const retry: RetryService = getService('retry');
const firstCheckTime = new Date(Date.now()).toISOString();
let downCheckTime = new Date(Date.now()).toISOString();
before(async () => {
await services.cleaUpRules();
await services.cleaUpAlerts();
await services.cleanTestMonitors();
await services.enableMonitorManagedViaApi();
await services.addTestMonitor('Test Monitor', {
type: 'http',
urls: 'https://www.google.com',
custom_heartbeat_id: 'b9d9e146-746f-427f-bbf5-6e786b5b4e73',
locations: [
{ id: 'Test private location', label: 'Test private location', isServiceManaged: true },
],
});
await services.addTestSummaryDocument({ timestamp: firstCheckTime });
});
after(async () => {
await services.cleaUpRules();
await services.cleaUpAlerts();
await services.cleanTestMonitors();
});
step('Go to monitors page', async () => {
await syntheticsApp.navigateToOverview(true);
});
step('should create default status alert', async () => {
await page.click(byTestId('xpack.synthetics.alertsPopover.toggleButton'));
await page.isDisabled(byTestId('xpack.synthetics.toggleAlertFlyout'));
await page.click(byTestId('xpack.synthetics.toggleAlertFlyout'));
await page.waitForSelector('text=Edit rule');
await page.selectOption(byTestId('intervalInputUnit'), { label: 'second' });
await page.fill(byTestId('intervalInput'), '10');
await page.click(byTestId('saveEditedRuleButton'));
await page.waitForSelector("text=Updated 'Synthetics internal alert'");
});
step('Monitor is as up in overview page', async () => {
await retry.tryForTime(60 * 1000, async () => {
const totalDown = await page.textContent(
byTestId('xpack.uptime.synthetics.overview.status.up')
);
expect(totalDown).toBe('1Up');
});
await page.hover('text=Test Monitor');
await page.click('[aria-label="Open actions menu"]');
});
step('Disable default alert for monitor', async () => {
await page.click('text=Disable status alert');
await page.waitForSelector(`text=Alerts are now disabled for the monitor "Test Monitor".`);
await page.click('text=Enable status alert');
});
step('set the monitor status as down', async () => {
downCheckTime = new Date(Date.now()).toISOString();
await services.addTestSummaryDocument({
isDown: true,
timestamp: downCheckTime,
});
await page.waitForTimeout(5 * 1000);
await page.click(byTestId('syntheticsMonitorManagementTab'));
await page.click(byTestId('syntheticsMonitorOverviewTab'));
const totalDown = await page.textContent(
byTestId('xpack.uptime.synthetics.overview.status.down')
);
expect(totalDown).toBe('1Down');
});
step('verified that it generates an alert', async () => {
await page.click(byTestId('observability-nav-observability-overview-alerts'));
const reasonMessage = getReasonMessage({
name: 'Test Monitor',
location: 'Test private location',
timestamp: downCheckTime,
status: 'is down.',
});
await retry.tryForTime(2 * 60 * 1000, async () => {
await page.click(byTestId('querySubmitButton'));
const text = await page.textContent(`${byTestId('dataGridRowCell')} .euiLink`, {
timeout: 5 * 1000,
});
expect(text).toBe(reasonMessage);
expect(await page.isVisible(`text=1 Alert`)).toBe(true);
});
});
step('set monitor status to up and verify that alert recovers', async () => {
await services.addTestSummaryDocument();
await retry.tryForTime(2 * 60 * 1000, async () => {
await page.click(byTestId('querySubmitButton'));
await page.isVisible(`text=Recovered`, { timeout: 5 * 1000 });
await page.isVisible(`text=1 Alert`, { timeout: 5 * 1000 });
});
});
step('set the status down again to generate another alert', async () => {
await services.addTestSummaryDocument({ isDown: true });
await retry.tryForTime(2 * 60 * 1000, async () => {
await page.click(byTestId('querySubmitButton'));
await page.isVisible(`text=Active`, { timeout: 5 * 1000 });
await page.isVisible(`text=1 Alert`);
});
});
step('Adds another down monitor and it auto adds the alert', async () => {
const monitorId = uuid.v4();
const name = `Test Monitor 2`;
await services.addTestMonitor(name, {
type: 'http',
urls: 'https://www.google.com',
custom_heartbeat_id: monitorId,
locations: [
{ id: 'Test private location', label: 'Test private location', isServiceManaged: true },
],
});
downCheckTime = new Date(Date.now()).toISOString();
await services.addTestSummaryDocument({
timestamp: downCheckTime,
monitorId,
isDown: true,
name,
});
const reasonMessage = getReasonMessage({
name,
location: 'Test private location',
timestamp: downCheckTime,
status: 'is down.',
});
await retry.tryForTime(2 * 60 * 1000, async () => {
await page.click(byTestId('querySubmitButton'));
await page.isVisible(`text=2 Alerts`, { timeout: 5 * 1000 });
const alertReasonElem = await page.waitForSelector(`text=${reasonMessage}`, {
timeout: 5 * 1000,
});
expect(await alertReasonElem?.innerText()).toBe(reasonMessage);
});
});
step('Deleting the monitor recovers the alert', async () => {
await services.deleteTestMonitorByQuery('"Test Monitor 2"');
await page.click(byTestId('alert-status-filter-recovered-button'));
await retry.tryForTime(3 * 60 * 1000, async () => {
await page.click(byTestId('querySubmitButton'));
expect(await page.isVisible(`text=1 Alert`)).toBe(true);
});
await page.click('[aria-label="View in app"]');
await page.click(byTestId('syntheticsMonitorOverviewTab'));
await page.waitForSelector('text=Monitor details');
});
});

View file

@ -0,0 +1,416 @@
/*
* 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 uuid from 'uuid';
export const firstUpHit = {
summary: {
up: 1,
down: 0,
},
tcp: {
rtt: {
connect: {
us: 22245,
},
},
},
agent: {
name: 'docker-fleet-server',
id: 'dd39a87d-a1e5-45a1-8dd9-e78d6a1391c6',
type: 'heartbeat',
ephemeral_id: '264bb432-93f6-4aa6-a14d-266c53b9e7c7',
version: '8.7.0',
},
resolve: {
rtt: {
us: 3101,
},
ip: '142.250.181.196',
},
elastic_agent: {
id: 'dd39a87d-a1e5-45a1-8dd9-e78d6a1391c6',
version: '8.7.0',
snapshot: true,
},
monitor: {
duration: {
us: 155239,
},
ip: '142.250.181.196',
origin: 'ui',
name: 'Test Monitor',
timespan: {
lt: '2022-12-18T09:55:04.211Z',
gte: '2022-12-18T09:52:04.211Z',
},
fleet_managed: true,
id: 'b9d9e146-746f-427f-bbf5-6e786b5b4e73',
check_group: 'a039fd21-7eb9-11ed-8949-0242ac120006',
type: 'http',
status: 'up',
},
url: {
scheme: 'https',
port: 443,
domain: 'www.google.com',
full: 'https://www.google.com',
},
observer: {
geo: {
name: 'Test private location',
},
name: 'Test private location',
},
'@timestamp': '2022-12-18T09:52:04.056Z',
ecs: {
version: '8.0.0',
},
config_id: 'b9d9e146-746f-427f-bbf5-6e786b5b4e73',
data_stream: {
namespace: 'default',
type: 'synthetics',
dataset: 'http',
},
tls: {
cipher: 'TLS-AES-128-GCM-SHA256',
certificate_not_valid_before: '2022-11-28T08:19:01.000Z',
established: true,
server: {
x509: {
not_after: '2023-02-20T08:19:00.000Z',
subject: {
distinguished_name: 'CN=www.google.com',
common_name: 'www.google.com',
},
not_before: '2022-11-28T08:19:01.000Z',
public_key_curve: 'P-256',
public_key_algorithm: 'ECDSA',
signature_algorithm: 'SHA256-RSA',
serial_number: '173037077033925240295268439311466214245',
issuer: {
distinguished_name: 'CN=GTS CA 1C3,O=Google Trust Services LLC,C=US',
common_name: 'GTS CA 1C3',
},
},
hash: {
sha1: 'ea1b44061b864526c45619230b3299117d11bf4e',
sha256: 'a5686448de09cc82b9cdad1e96357f919552ab14244da7948dd412ec0fc37d2b',
},
},
rtt: {
handshake: {
us: 35023,
},
},
version: '1.3',
certificate_not_valid_after: '2023-02-20T08:19:00.000Z',
version_protocol: 'tls',
},
state: {
duration_ms: 0,
checks: 1,
ends: null,
started_at: '2022-12-18T09:52:10.30502451Z',
up: 1,
id: 'Test private location-18524a5e641-0',
down: 0,
flap_history: [],
status: 'up',
},
event: {
agent_id_status: 'verified',
ingested: '2022-12-18T09:52:11Z',
dataset: 'http',
},
};
export const firstDownHit = ({
name,
timestamp,
monitorId,
}: { timestamp?: string; monitorId?: string; name?: string } = {}) => ({
summary: {
up: 0,
down: 1,
},
tcp: {
rtt: {
connect: {
us: 20482,
},
},
},
agent: {
name: 'docker-fleet-server',
id: 'dd39a87d-a1e5-45a1-8dd9-e78d6a1391c6',
type: 'heartbeat',
ephemeral_id: '264bb432-93f6-4aa6-a14d-266c53b9e7c7',
version: '8.7.0',
},
resolve: {
rtt: {
us: 3234,
},
ip: '142.250.181.196',
},
elastic_agent: {
id: 'dd39a87d-a1e5-45a1-8dd9-e78d6a1391c6',
version: '8.7.0',
snapshot: true,
},
monitor: {
duration: {
us: 152459,
},
origin: 'ui',
ip: '142.250.181.196',
name: name ?? 'Test Monitor',
fleet_managed: true,
check_group: uuid.v4(),
timespan: {
lt: '2022-12-18T09:52:50.128Z',
gte: '2022-12-18T09:49:50.128Z',
},
id: monitorId ?? 'b9d9e146-746f-427f-bbf5-6e786b5b4e73',
type: 'http',
status: 'down',
},
error: {
message: 'received status code 200 expecting [500]',
type: 'validate',
},
url: {
scheme: 'https',
port: 443,
domain: 'www.google.com',
full: 'https://www.google.com',
},
observer: {
geo: {
name: 'Test private location',
},
name: 'Test private location',
},
'@timestamp': timestamp ?? '2022-12-18T09:49:49.976Z',
ecs: {
version: '8.0.0',
},
config_id: monitorId ?? 'b9d9e146-746f-427f-bbf5-6e786b5b4e73',
data_stream: {
namespace: 'default',
type: 'synthetics',
dataset: 'http',
},
tls: {
established: true,
cipher: 'TLS-AES-128-GCM-SHA256',
certificate_not_valid_before: '2022-11-28T08:19:01.000Z',
server: {
x509: {
not_after: '2023-02-20T08:19:00.000Z',
subject: {
distinguished_name: 'CN=www.google.com',
common_name: 'www.google.com',
},
not_before: '2022-11-28T08:19:01.000Z',
public_key_algorithm: 'ECDSA',
public_key_curve: 'P-256',
signature_algorithm: 'SHA256-RSA',
serial_number: '173037077033925240295268439311466214245',
issuer: {
distinguished_name: 'CN=GTS CA 1C3,O=Google Trust Services LLC,C=US',
common_name: 'GTS CA 1C3',
},
},
hash: {
sha1: 'ea1b44061b864526c45619230b3299117d11bf4e',
sha256: 'a5686448de09cc82b9cdad1e96357f919552ab14244da7948dd412ec0fc37d2b',
},
},
rtt: {
handshake: {
us: 28468,
},
},
version: '1.3',
certificate_not_valid_after: '2023-02-20T08:19:00.000Z',
version_protocol: 'tls',
},
state: {
duration_ms: 0,
checks: 1,
ends: null,
started_at: '2022-12-18T09:49:56.007551998Z',
id: 'Test private location-18524a3d9a7-0',
up: 0,
down: 1,
flap_history: [],
status: 'down',
},
event: {
agent_id_status: 'verified',
ingested: '2022-12-18T09:49:57Z',
dataset: 'http',
},
});
const sampleHits = {
took: 1,
timed_out: false,
_shards: {
total: 1,
successful: 1,
skipped: 0,
failed: 0,
},
hits: {
total: {
value: 3,
relation: 'eq',
},
max_score: null,
hits: [
firstUpHit,
{
_index: '.ds-synthetics-http-default-2022.12.18-000001',
_id: 'TfCjJIUB1hhCUz-n3bWY',
_score: null,
_ignored: ['http.response.body.content'],
_source: firstDownHit,
sort: [1671356989976],
},
{
_index: '.ds-synthetics-http-default-2022.12.18-000001',
_id: 'fPCiJIUB1hhCUz-nVbMK',
_score: null,
_source: {
summary: {
up: 1,
down: 0,
},
tcp: {
rtt: {
connect: {
us: 26843,
},
},
},
agent: {
name: 'docker-fleet-server',
id: 'dd39a87d-a1e5-45a1-8dd9-e78d6a1391c6',
ephemeral_id: '264bb432-93f6-4aa6-a14d-266c53b9e7c7',
type: 'heartbeat',
version: '8.7.0',
},
resolve: {
rtt: {
us: 18153,
},
ip: '142.250.181.196',
},
elastic_agent: {
id: 'dd39a87d-a1e5-45a1-8dd9-e78d6a1391c6',
version: '8.7.0',
snapshot: true,
},
monitor: {
duration: {
us: 179022,
},
ip: '142.250.181.196',
origin: 'ui',
name: 'Test Monitor',
id: 'b9d9e146-746f-427f-bbf5-6e786b5b4e73',
timespan: {
lt: '2022-12-18T09:51:09.403Z',
gte: '2022-12-18T09:48:09.403Z',
},
check_group: '144186b2-7eb9-11ed-8949-0242ac120006',
fleet_managed: true,
type: 'http',
status: 'up',
},
url: {
scheme: 'https',
port: 443,
domain: 'www.google.com',
full: 'https://www.google.com',
},
observer: {
geo: {
name: 'Test private location',
},
name: 'Test private location',
},
'@timestamp': '2022-12-18T09:48:09.224Z',
ecs: {
version: '8.0.0',
},
config_id: 'b9d9e146-746f-427f-bbf5-6e786b5b4e73',
data_stream: {
namespace: 'default',
type: 'synthetics',
dataset: 'http',
},
tls: {
cipher: 'TLS-AES-128-GCM-SHA256',
certificate_not_valid_before: '2022-11-28T08:19:01.000Z',
established: true,
server: {
x509: {
not_after: '2023-02-20T08:19:00.000Z',
subject: {
distinguished_name: 'CN=www.google.com',
common_name: 'www.google.com',
},
not_before: '2022-11-28T08:19:01.000Z',
public_key_curve: 'P-256',
public_key_algorithm: 'ECDSA',
signature_algorithm: 'SHA256-RSA',
serial_number: '173037077033925240295268439311466214245',
issuer: {
distinguished_name: 'CN=GTS CA 1C3,O=Google Trust Services LLC,C=US',
common_name: 'GTS CA 1C3',
},
},
hash: {
sha1: 'ea1b44061b864526c45619230b3299117d11bf4e',
sha256: 'a5686448de09cc82b9cdad1e96357f919552ab14244da7948dd412ec0fc37d2b',
},
},
rtt: {
handshake: {
us: 34230,
},
},
version: '1.3',
certificate_not_valid_after: '2023-02-20T08:19:00.000Z',
version_protocol: 'tls',
},
state: {
duration_ms: 0,
checks: 1,
ends: null,
started_at: '2022-12-18T09:48:15.335017193Z',
id: 'Test private location-18524a25067-0',
up: 1,
down: 0,
flap_history: [],
status: 'up',
},
event: {
agent_id_status: 'verified',
ingested: '2022-12-18T09:48:16Z',
dataset: 'http',
},
},
sort: [1671356889224],
},
],
},
};

View file

@ -17,3 +17,4 @@ export * from './private_locations.journey';
export * from './alerting_default.journey';
export * from './global_parameters.journey';
export * from './detail_flyout';
export * from './alert_rules/default_status_alert.journey';

View file

@ -30,7 +30,7 @@ export const addTestMonitor = async (
) => {
const testData = {
locations: [{ id: 'us_central', isServiceManaged: true }],
...(params?.type !== 'browser' ? {} : data),
...(params?.type !== 'browser' ? {} : testDataMonitor),
...(params || {}),
name,
};
@ -98,8 +98,9 @@ export const cleanTestParams = async (params: Record<string, any>) => {
}
};
const data = {
export const testDataMonitor = {
type: 'browser',
alert: { status: { enabled: true } },
form_monitor_type: 'single',
enabled: true,
schedule: { unit: 'm', number: '10' },

View file

@ -0,0 +1,170 @@
/*
* 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 axios from 'axios';
import type { Client } from '@elastic/elasticsearch';
import { KbnClient, uriencode } from '@kbn/test';
import pMap from 'p-map';
import { SYNTHETICS_API_URLS } from '../../../../common/constants';
import { firstDownHit, firstUpHit } from '../alert_rules/sample_docs/sample_docs';
export class SyntheticsServices {
kibanaUrl: string;
params: Record<string, any>;
requester: KbnClient['requester'];
constructor(params: Record<string, any>) {
this.kibanaUrl = params.kibanaUrl;
this.requester = params.getService('kibanaServer').requester;
this.params = params;
}
async enableMonitorManagedViaApi() {
try {
await axios.post(this.kibanaUrl + '/internal/uptime/service/enablement', undefined, {
auth: { username: 'elastic', password: 'changeme' },
headers: { 'kbn-xsrf': 'true' },
});
} catch (e) {
// eslint-disable-next-line no-console
console.log(e);
}
}
async addTestMonitor(name: string, data: Record<string, any> = { type: 'browser' }) {
const testData = {
alert: { status: { enabled: true } },
locations: [{ id: 'us_central', isServiceManaged: true }],
...(data?.type !== 'browser' ? {} : data),
...(data || {}),
name,
};
try {
await axios.post(this.kibanaUrl + '/internal/uptime/service/monitors', testData, {
auth: { username: 'elastic', password: 'changeme' },
headers: { 'kbn-xsrf': 'true' },
});
} catch (e) {
// eslint-disable-next-line no-console
console.log(JSON.stringify(e));
}
}
async deleteTestMonitorByQuery(query: string) {
const { data } = await this.requester.request({
description: 'get monitors by name',
path: uriencode`/internal/uptime/service/monitors`,
query: {
perPage: 10,
page: 1,
sortOrder: 'asc',
sortField: 'name.keyword',
query,
},
method: 'GET',
});
const { monitors = [] } = data as any;
await pMap(
monitors,
async (monitor: Record<string, any>) => {
await this.requester.request({
description: 'delete monitor',
path: uriencode`/internal/uptime/service/monitors/${monitor.id}`,
method: 'DELETE',
});
},
{ concurrency: 10 }
);
}
async enableDefaultAlertingViaApi() {
try {
await axios.post(
this.kibanaUrl + SYNTHETICS_API_URLS.ENABLE_DEFAULT_ALERTING,
{ isDisabled: false },
{
auth: { username: 'elastic', password: 'changeme' },
headers: { 'kbn-xsrf': 'true' },
}
);
} catch (e) {
// eslint-disable-next-line no-console
console.log(e);
}
}
async addTestSummaryDocument({
isDown = false,
timestamp = new Date(Date.now()).toISOString(),
monitorId,
name,
}: { monitorId?: string; isDown?: boolean; timestamp?: string; name?: string } = {}) {
const getService = this.params.getService;
const es: Client = getService('es');
await es.index({
index: 'synthetics-http-default',
document: {
...(isDown ? firstDownHit({ timestamp, monitorId, name }) : firstUpHit),
'@timestamp': timestamp,
},
});
}
async cleaUpAlerts() {
const getService = this.params.getService;
const es: Client = getService('es');
const listOfIndices = await es.cat.indices({ format: 'json' });
for (const index of listOfIndices) {
if (index.index?.startsWith('.internal.alerts-observability.uptime.alerts')) {
await es.deleteByQuery({ index: index.index, query: { match_all: {} } });
}
}
}
async cleaUpRules() {
try {
const { data: response } = await this.requester.request({
description: 'get monitors by name',
path: `/internal/alerting/rules/_find`,
query: {
per_page: 10,
page: 1,
},
method: 'GET',
});
const { data = [] } = response as any;
if (data.length > 0) {
// eslint-disable-next-line no-console
console.log(`Deleting ${data.length} rules`);
await axios.patch(
this.kibanaUrl + '/internal/alerting/rules/_bulk_delete',
{
ids: data.map((rule: any) => rule.id),
},
{ auth: { username: 'elastic', password: 'changeme' }, headers: { 'kbn-xsrf': 'true' } }
);
}
} catch (e) {
// eslint-disable-next-line no-console
console.log(e);
}
}
async cleanTestMonitors() {
const getService = this.params.getService;
const server = getService('kibanaServer');
try {
await server.savedObjects.clean({ types: ['synthetics-monitor'] });
} catch (e) {
// eslint-disable-next-line no-console
console.log(e);
}
}
}

View file

@ -7,6 +7,7 @@
import { journey, step, expect, before } from '@elastic/synthetics';
import { assertText, byTestId, waitForLoadingToFinish } from '@kbn/observability-plugin/e2e/utils';
import { RetryService } from '@kbn/ftr-common-functional-services';
import { loginPageProvider } from '../../../page_objects/login';
journey('StatusFlyoutInAlertingApp', async ({ page, params }) => {
@ -14,7 +15,8 @@ journey('StatusFlyoutInAlertingApp', async ({ page, params }) => {
before(async () => {
await waitForLoadingToFinish({ page });
});
const getService = params.getService;
const retry: RetryService = getService('retry');
const baseUrl = `${params.kibanaUrl}/app/management/insightsAndAlerting/triggersActions/rules`;
step('Go to Alerting app', async () => {
@ -25,11 +27,17 @@ journey('StatusFlyoutInAlertingApp', async ({ page, params }) => {
});
step('Open monitor status flyout', async () => {
await page.click(byTestId('createFirstRuleButton'));
await page.click('text=Create rule');
await waitForLoadingToFinish({ page });
await page.click(byTestId('"xpack.uptime.alerts.monitorStatus-SelectOption"'));
await waitForLoadingToFinish({ page });
await assertText({ page, text: 'This alert will apply to approximately 0 monitors.' });
await retry.tryForTime(60 * 1000, async () => {
const text = await page.textContent(byTestId('alertSnapShotCount'));
expect(text).toContain('This alert will apply to approximately');
const getNUmber = text?.split('This alert will apply to approximately ')[1][0];
expect(Number(getNUmber)).toBeGreaterThan(0);
});
});
step('can add filters', async () => {
@ -59,7 +67,7 @@ journey('StatusFlyoutInAlertingApp', async ({ page, params }) => {
});
step('Open tls alert flyout', async () => {
await page.click(byTestId('createFirstRuleButton'));
await page.click('text=Create rule');
await waitForLoadingToFinish({ page });
await page.click(byTestId('"xpack.uptime.alerts.tlsCertificate-SelectOption"'));
await waitForLoadingToFinish({ page });

View file

@ -25,7 +25,7 @@ journey('TlsFlyoutInAlertingApp', async ({ page, params }) => {
});
step('Open tls alert flyout', async () => {
await page.click(byTestId('createFirstRuleButton'));
await page.click('text=Create rule');
await waitForLoadingToFinish({ page });
await page.click(byTestId('"xpack.uptime.alerts.tlsCertificate-SelectOption"'));
await waitForLoadingToFinish({ page });

View file

@ -89,12 +89,14 @@ export function syntheticsAppPageProvider({ page, kibanaUrl }: { page: Page; kib
async deleteMonitors() {
let isSuccessful: boolean = false;
while (true) {
if ((await page.$(this.byTestId('syntheticsMonitorListActions'))) === null) {
if ((await page.$(this.byTestId('euiCollapsedItemActionsButton'))) === null) {
isSuccessful = true;
break;
}
await page.click(this.byTestId('syntheticsMonitorListActions'), { delay: 800 });
await page.click('text=delete', { delay: 800 });
await page.click(this.byTestId('euiCollapsedItemActionsButton'), { delay: 800 });
await page.click(`.euiContextMenuPanel ${this.byTestId('syntheticsMonitorDeleteAction')}`, {
delay: 800,
});
await page.waitForSelector('[data-test-subj="confirmModalTitleText"]');
await this.clickByTestSubj('confirmModalConfirmButton');
isSuccessful = Boolean(await this.findByTestSubj('uptimeDeleteMonitorSuccess'));
@ -106,8 +108,11 @@ export function syntheticsAppPageProvider({ page, kibanaUrl }: { page: Page; kib
async navigateToEditMonitor() {
await page.waitForSelector('text=Showing');
await this.clickByTestSubj('syntheticsMonitorListActions');
await page.click('text=Edit', { timeout: 2 * 60 * 1000, delay: 800 });
await this.clickByTestSubj('euiCollapsedItemActionsButton');
await page.click(`.euiContextMenuPanel ${this.byTestId('syntheticsMonitorEditAction')}`, {
timeout: 2 * 60 * 1000,
delay: 800,
});
await this.findByText('Edit monitor');
},

View file

@ -15,5 +15,6 @@
"@kbn/dev-utils",
"@kbn/observability-plugin",
"@kbn/ux-plugin",
"@kbn/ftr-common-functional-services",
]
}

View file

@ -4,6 +4,7 @@
"kibanaVersion": "kibana",
"optionalPlugins": ["cloud", "data", "fleet", "home", "ml", "telemetry"],
"requiredPlugins": [
"actions",
"alerting",
"cases",
"embeddable",

View file

@ -0,0 +1,36 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const ToggleFlyoutTranslations = {
toggleButtonAriaLabel: i18n.translate(
'xpack.synthetics.alertsRulesPopover.toggleButton.ariaLabel',
{
defaultMessage: 'Open alerts and rules menu',
}
),
toggleMonitorStatusAriaLabel: i18n.translate('xpack.synthetics.toggleAlertFlyout.ariaLabel', {
defaultMessage: 'Open add rule flyout',
}),
toggleMonitorStatusContent: i18n.translate('xpack.synthetics.toggleAlertButton.content', {
defaultMessage: 'Monitor status rule',
}),
navigateToAlertingUIAriaLabel: i18n.translate('xpack.synthetics.app.navigateToAlertingUi', {
defaultMessage: 'Leave Synthetics and go to Alerting Management page',
}),
navigateToAlertingButtonContent: i18n.translate(
'xpack.synthetics.app.navigateToAlertingButton.content',
{
defaultMessage: 'Manage rules',
}
),
alertsAndRules: i18n.translate('xpack.synthetics.alerts.toggleAlertFlyoutButtonText', {
defaultMessage: 'Alerts and rules',
}),
};

View file

@ -0,0 +1,47 @@
/*
* 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 { useFetcher } from '@kbn/observability-plugin/public';
import { useDispatch } from 'react-redux';
import { useEffect, useMemo, useState } from 'react';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { Rule } from '@kbn/triggers-actions-ui-plugin/public';
import { setAlertFlyoutVisible } from '../../../state';
import { enableDefaultAlertingAPI } from '../../../state/alert_rules/api';
import { ClientPluginsStart } from '../../../../../plugin';
export const useSyntheticsAlert = (isOpen: boolean) => {
const dispatch = useDispatch();
const [alert, setAlert] = useState<Rule | null>(null);
const { data, loading } = useFetcher(() => {
if (isOpen) {
return enableDefaultAlertingAPI();
}
}, [isOpen]);
useEffect(() => {
if (data) {
setAlert(data);
}
}, [data]);
const { triggersActionsUi } = useKibana<ClientPluginsStart>().services;
const EditAlertFlyout = useMemo(() => {
if (!alert) {
return null;
}
return triggersActionsUi.getEditAlertFlyout({
onClose: () => dispatch(setAlertFlyoutVisible(false)),
initialRule: alert,
});
}, [alert, dispatch, triggersActionsUi]);
return useMemo(() => ({ loading, EditAlertFlyout }), [EditAlertFlyout, loading]);
};

View file

@ -0,0 +1,107 @@
/*
* 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 React, { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import {
EuiContextMenu,
EuiContextMenuPanelDescriptor,
EuiContextMenuPanelItemDescriptor,
EuiFlexGroup,
EuiFlexItem,
EuiHeaderLink,
EuiLoadingSpinner,
EuiPopover,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ManageRulesLink } from '../common/links/manage_rules_link';
import { ClientPluginsStart } from '../../../../plugin';
import { ToggleFlyoutTranslations } from './hooks/translations';
import { useSyntheticsAlert } from './hooks/use_synthetics_alert';
import { selectAlertFlyoutVisibility, setAlertFlyoutVisible } from '../../state';
export const ToggleAlertFlyoutButton = () => {
const dispatch = useDispatch();
const [isOpen, setIsOpen] = useState<boolean>(false);
const { application } = useKibana<ClientPluginsStart>().services;
const hasUptimeWrite = application?.capabilities.uptime?.save ?? false;
const { EditAlertFlyout, loading } = useSyntheticsAlert(isOpen);
const monitorStatusAlertContextMenuItem: EuiContextMenuPanelItemDescriptor = {
'aria-label': ToggleFlyoutTranslations.toggleMonitorStatusAriaLabel,
'data-test-subj': 'xpack.synthetics.toggleAlertFlyout',
name: (
<EuiFlexGroup alignItems="center">
<EuiFlexItem>{ToggleFlyoutTranslations.toggleMonitorStatusContent}</EuiFlexItem>
{loading && (
<EuiFlexItem grow={false}>
<EuiLoadingSpinner />
</EuiFlexItem>
)}
</EuiFlexGroup>
),
onClick: () => {
dispatch(setAlertFlyoutVisible(true));
setIsOpen(false);
},
toolTipContent: !hasUptimeWrite ? noWritePermissionsTooltipContent : null,
disabled: !hasUptimeWrite || loading,
icon: 'bell',
};
const managementContextItem: EuiContextMenuPanelItemDescriptor = {
'aria-label': ToggleFlyoutTranslations.navigateToAlertingUIAriaLabel,
'data-test-subj': 'xpack.synthetics.navigateToAlertingUi',
name: <ManageRulesLink />,
icon: 'tableOfContents',
};
const panels: EuiContextMenuPanelDescriptor[] = [
{
id: 0,
items: [monitorStatusAlertContextMenuItem, managementContextItem],
},
];
const alertFlyoutVisible = useSelector(selectAlertFlyoutVisibility);
return (
<>
<EuiPopover
button={
<EuiHeaderLink
color="text"
aria-label={ToggleFlyoutTranslations.toggleButtonAriaLabel}
data-test-subj="xpack.synthetics.alertsPopover.toggleButton"
iconType="arrowDown"
iconSide="right"
onClick={() => setIsOpen(!isOpen)}
>
{ToggleFlyoutTranslations.alertsAndRules}
</EuiHeaderLink>
}
closePopover={() => setIsOpen(false)}
isOpen={isOpen}
ownFocus
panelPaddingSize="none"
>
<EuiContextMenu initialPanelId={0} panels={panels} />
</EuiPopover>
{alertFlyoutVisible && EditAlertFlyout}
</>
);
};
const noWritePermissionsTooltipContent = i18n.translate(
'xpack.synthetics.alertDropdown.noWritePermissions',
{
defaultMessage: 'You need read-write access to Uptime to create alerts in this app.',
}
);

View file

@ -28,15 +28,4 @@ describe('ActionMenuContent', () => {
expect(analyzeAnchor.getAttribute('href')).toContain('/app/observability/exploratory-view');
expect(getByText('Explore data'));
});
it('renders Add Data link', () => {
const { getByLabelText, getByText } = render(<ActionMenuContent />);
const addDataAnchor = getByLabelText('Navigate to a tutorial about adding Uptime data');
// this href value is mocked, so it doesn't correspond to the real link
// that Kibana core services will provide
expect(addDataAnchor.getAttribute('href')).toBe('/home#/tutorial/uptimeMonitors');
expect(getByText('Add data'));
});
});

View file

@ -11,16 +11,12 @@ import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { useHistory, useRouteMatch } from 'react-router-dom';
import { createExploratoryViewUrl } from '@kbn/observability-plugin/public';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { useSyntheticsSettingsContext } from '../../../contexts';
import { useGetUrlParams } from '../../../hooks';
import { MONITOR_ROUTE, SETTINGS_ROUTE } from '../../../../../../common/constants';
import { stringifyUrlParams } from '../../../utils/url_params';
import { InspectorHeaderLink } from './inspector_header_link';
const ADD_DATA_LABEL = i18n.translate('xpack.synthetics.addDataButtonLabel', {
defaultMessage: 'Add data',
});
import { ToggleAlertFlyoutButton } from '../../alerts/toggle_alert_flyout_button';
const ANALYZE_DATA = i18n.translate('xpack.synthetics.analyzeDataButtonLabel', {
defaultMessage: 'Explore data',
@ -32,7 +28,6 @@ const ANALYZE_MESSAGE = i18n.translate('xpack.synthetics.analyzeDataButtonLabel.
});
export function ActionMenuContent(): React.ReactElement {
const kibana = useKibana();
const { basePath } = useSyntheticsSettingsContext();
const params = useGetUrlParams();
const { dateRangeStart, dateRangeEnd } = params;
@ -71,7 +66,7 @@ export function ActionMenuContent(): React.ReactElement {
return (
<EuiHeaderLinks gutterSize="xs">
{/* <ManageMonitorsBtn /> TODO: See if it's needed for new Synthetics App */}
<ToggleAlertFlyoutButton />
<EuiHeaderLink
aria-label={i18n.translate('xpack.synthetics.page_header.settingsLink.label', {
@ -104,16 +99,6 @@ export function ActionMenuContent(): React.ReactElement {
</EuiHeaderLink>
</EuiToolTip>
<EuiHeaderLink
aria-label={i18n.translate('xpack.synthetics.page_header.addDataLink.label', {
defaultMessage: 'Navigate to a tutorial about adding Uptime data',
})}
href={kibana.services?.application?.getUrlForApp('/home#/tutorial/uptimeMonitors')}
color="primary"
iconType="indexOpen"
>
{ADD_DATA_LABEL}
</EuiHeaderLink>
<InspectorHeaderLink />
</EuiHeaderLinks>
);

View file

@ -0,0 +1,24 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiLink } from '@elastic/eui';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { ClientPluginsStart } from '../../../../../plugin';
import { ToggleFlyoutTranslations } from '../../alerts/hooks/translations';
export const ManageRulesLink = () => {
const { observability } = useKibana<ClientPluginsStart>().services;
const manageRulesUrl = observability.useRulesLink();
return (
<EuiLink color="text" href={manageRulesUrl.href}>
{ToggleFlyoutTranslations.navigateToAlertingButtonContent}
</EuiLink>
);
};

View file

@ -47,7 +47,7 @@ import {
VerificationMode,
FieldMeta,
} from '../types';
import { DEFAULT_BROWSER_ADVANCED_FIELDS } from '../constants';
import { AlertConfigKey, DEFAULT_BROWSER_ADVANCED_FIELDS } from '../constants';
import { HeaderField } from '../fields/header_field';
import { RequestBodyField } from '../fields/request_body_field';
import { ResponseBodyIndexField } from '../fields/index_response_body_field';
@ -437,6 +437,27 @@ export const FIELD: Record<string, FieldMeta> = {
},
}),
},
[ConfigKey.ALERT_CONFIG]: {
fieldKey: AlertConfigKey.STATUS_ENABLED,
component: EuiSwitch,
label: i18n.translate('xpack.synthetics.monitorConfig.enabledAlerting.label', {
defaultMessage: 'Enable status alerts',
}),
controlled: true,
props: ({ isEdit, setValue, field }) => ({
id: 'syntheticsMonitorConfigIsAlertEnabled',
label: isEdit
? i18n.translate('xpack.synthetics.monitorConfig.edit.alertEnabled.label', {
defaultMessage: 'Disabling will stop alerting on this monitor.',
})
: i18n.translate('xpack.synthetics.monitorConfig.create.alertEnabled.label', {
defaultMessage: 'Enable status alerts on this monitor.',
}),
onChange: (event: React.ChangeEvent<HTMLInputElement>) => {
setValue(AlertConfigKey.STATUS_ENABLED, !!event.target.checked);
},
}),
},
[ConfigKey.TAGS]: {
fieldKey: ConfigKey.TAGS,
component: ComboBox,
@ -497,7 +518,7 @@ export const FIELD: Record<string, FieldMeta> = {
}),
helpText: i18n.translate('xpack.synthetics.monitorConfig.apmServiceName.helpText', {
defaultMessage:
'Corrseponds to the service.name ECS field from APM. Set this to enable integrations between APM and Synthetics data.',
'Corresponds to the service.name ECS field from APM. Set this to enable integrations between APM and Synthetics data.',
}),
controlled: true,
props: ({ field }) => ({

View file

@ -169,6 +169,7 @@ export const FORM_CONFIG: FieldConfig = {
FIELD[ConfigKey.MAX_REDIRECTS],
FIELD[ConfigKey.TIMEOUT],
FIELD[ConfigKey.ENABLED],
FIELD[ConfigKey.ALERT_CONFIG],
],
advanced: [
DEFAULT_DATA_OPTIONS,
@ -187,6 +188,7 @@ export const FORM_CONFIG: FieldConfig = {
FIELD[ConfigKey.SCHEDULE],
FIELD[ConfigKey.TIMEOUT],
FIELD[ConfigKey.ENABLED],
FIELD[ConfigKey.ALERT_CONFIG],
],
advanced: [
DEFAULT_DATA_OPTIONS,
@ -203,6 +205,7 @@ export const FORM_CONFIG: FieldConfig = {
FIELD[ConfigKey.SCHEDULE],
FIELD[ConfigKey.THROTTLING_CONFIG],
FIELD[ConfigKey.ENABLED],
FIELD[ConfigKey.ALERT_CONFIG],
],
step3: [FIELD[ConfigKey.SOURCE_INLINE], FIELD[ConfigKey.PARAMS]],
scriptEdit: [FIELD[ConfigKey.SOURCE_INLINE]],
@ -229,6 +232,7 @@ export const FORM_CONFIG: FieldConfig = {
FIELD[ConfigKey.SCHEDULE],
FIELD[ConfigKey.THROTTLING_CONFIG],
FIELD[ConfigKey.ENABLED],
FIELD[ConfigKey.ALERT_CONFIG],
],
advanced: [
{

View file

@ -12,7 +12,7 @@ import { i18n } from '@kbn/i18n';
import { useFormContext } from 'react-hook-form';
import { useFetcher, FETCH_STATUS } from '@kbn/observability-plugin/public';
import { DeleteMonitor } from '../../monitors_page/management/monitor_list_table/delete_monitor';
import { ConfigKey, SourceType, SyntheticsMonitor } from '../types';
import { SyntheticsMonitor } from '../types';
import { format } from './formatter';
import {
createMonitorAPI,
@ -33,7 +33,9 @@ export const ActionBar = () => {
formState: { errors },
} = useFormContext();
const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
const [monitorPendingDeletion, setMonitorPendingDeletion] = useState<SyntheticsMonitor | null>(
null
);
const [monitorData, setMonitorData] = useState<SyntheticsMonitor | undefined>(undefined);
@ -78,7 +80,7 @@ export const ActionBar = () => {
const formSubmitter = (formData: Record<string, any>) => {
if (!Object.keys(errors).length) {
setMonitorData(format(formData) as SyntheticsMonitor);
setMonitorData(format(formData));
}
};
@ -88,12 +90,12 @@ export const ActionBar = () => {
<>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={true}>
{isEdit && (
{isEdit && monitorObject && (
<div>
<EuiButton
color="danger"
onClick={() => {
setIsDeleteModalVisible(true);
setMonitorPendingDeletion(monitorObject?.attributes);
}}
>
{DELETE_MONITOR_LABEL}
@ -116,17 +118,13 @@ export const ActionBar = () => {
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
{isDeleteModalVisible && (
{monitorPendingDeletion && monitorObject && (
<DeleteMonitor
configId={monitorId}
name={monitorObject?.attributes?.[ConfigKey.NAME] ?? ''}
fields={monitorObject?.attributes}
reloadPage={() => {
history.push(MONITORS_ROUTE);
}}
isProjectMonitor={
monitorObject?.attributes?.[ConfigKey.MONITOR_SOURCE_TYPE] === SourceType.PROJECT
}
setIsDeleteModalVisible={setIsDeleteModalVisible}
setMonitorPendingDeletion={setMonitorPendingDeletion}
/>
)}
</>

View file

@ -18,8 +18,12 @@ import {
selectorError,
} from '../../../state';
export const useSelectedMonitor = () => {
const { monitorId } = useParams<{ monitorId: string }>();
export const useSelectedMonitor = (monId?: string) => {
let monitorId = monId;
const { monitorId: urlMonitorId } = useParams<{ monitorId: string }>();
if (!monitorId) {
monitorId = urlMonitorId;
}
const monitorsList = useSelector(selectEncryptedSyntheticsSavedMonitors);
const { loading: monitorListLoading } = useSelector(selectMonitorListState);

View file

@ -10,7 +10,6 @@ import { EuiSpacer } from '@elastic/eui';
import type { useMonitorList } from '../hooks/use_monitor_list';
import { MonitorAsyncError } from './monitor_errors/monitor_async_error';
import { useInlineErrors } from '../hooks/use_inline_errors';
import { useOverviewStatus } from '../hooks/use_overview_status';
import { ListFilters } from './list_filters/list_filters';
import { MonitorList } from './monitor_list_table/monitor_list';
@ -33,11 +32,13 @@ export const MonitorListContainer = ({
reloadPage,
} = monitorListProps;
const { errorSummaries, loading: errorsLoading } = useInlineErrors({
onlyInvalidMonitors: false,
sortField: pageState.sortField,
sortOrder: pageState.sortOrder,
});
// TODO: Display inline errors in the management table
// const { errorSummaries, loading: errorsLoading } = useInlineErrors({
// onlyInvalidMonitors: false,
// sortField: pageState.sortField,
// sortOrder: pageState.sortOrder,
// });
const overviewStatusArgs = useMemo(() => {
return {
@ -61,11 +62,10 @@ export const MonitorListContainer = ({
total={total}
pageState={pageState}
error={error}
loading={monitorsLoading || errorsLoading}
status={status}
errorSummaries={errorSummaries}
loading={monitorsLoading}
loadPage={loadPage}
reloadPage={reloadPage}
status={status}
/>
</>
);

View file

@ -1,139 +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 React, { useContext, useState } from 'react';
import { EuiThemeComputed } from '@elastic/eui/src/services/theme/types';
import { EuiContextMenuPanel, EuiContextMenuItem, EuiPopover, EuiButtonEmpty } from '@elastic/eui';
import { DeleteMonitor } from './delete_monitor';
import { SyntheticsSettingsContext } from '../../../../contexts/synthetics_settings_context';
import * as labels from './labels';
interface Props {
euiTheme: EuiThemeComputed;
configId: string;
name: string;
canEditSynthetics: boolean;
isProjectMonitor?: boolean;
reloadPage: () => void;
}
export const Actions = ({
euiTheme,
configId,
name,
reloadPage,
canEditSynthetics,
isProjectMonitor,
}: Props) => {
const { basePath } = useContext(SyntheticsSettingsContext);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
// TODO: Move deletion logic to redux state
const openPopover = () => {
setIsPopoverOpen(true);
};
const closePopover = () => {
setIsPopoverOpen(false);
};
const handleDeleteMonitor = () => {
setIsDeleteModalVisible(true);
closePopover();
};
const menuButton = (
<EuiButtonEmpty
iconType="boxesHorizontal"
color="primary"
iconSide="right"
data-test-subj="syntheticsMonitorListActions"
onClick={openPopover}
/>
);
/*
TODO: Implement duplication functionality
const duplicateMenuItem = (
<EuiContextMenuItem key="xpack.synthetics.duplicateMonitor" icon="copy" onClick={closePopover}>
{labels.DUPLICATE_LABEL}
</EuiContextMenuItem>
);
*/
/*
TODO: See if disable enabled is needed as an action menu item
const disableEnableMenuItem = (
isDisabled ? (
<EuiContextMenuItem
key="xpack.synthetics.enableMonitor"
icon="play"
onClick={handleEnableMonitor}
>
{labels.ENABLE_LABEL}
</EuiContextMenuItem>
) : (
<EuiContextMenuItem
key="xpack.synthetics.disableMonitor"
icon="pause"
onClick={handleDisableMonitor}
>
{labels.DISABLE_LABEL}
</EuiContextMenuItem>
)
);
*/
const menuItems = [
<EuiContextMenuItem
key="xpack.synthetics.editMonitor"
icon="pencil"
onClick={closePopover}
href={`${basePath}/app/synthetics/edit-monitor/${configId}`}
disabled={!canEditSynthetics}
>
{labels.EDIT_LABEL}
</EuiContextMenuItem>,
<EuiContextMenuItem
css={{ color: euiTheme.colors.danger }}
key="xpack.synthetics.deleteMonitor"
icon="trash"
disabled={!canEditSynthetics}
onClick={handleDeleteMonitor}
>
{labels.DELETE_LABEL}
</EuiContextMenuItem>,
];
return (
<>
<EuiPopover
id={`xpack.synthetics.${configId}`}
button={menuButton}
isOpen={isPopoverOpen}
closePopover={closePopover}
panelPaddingSize="s"
anchorPosition="downLeft"
>
<EuiContextMenuPanel size="s" items={menuItems} />
</EuiPopover>
{isDeleteModalVisible && (
<DeleteMonitor
configId={configId}
name={name}
reloadPage={reloadPage}
isProjectMonitor={isProjectMonitor}
setIsDeleteModalVisible={setIsDeleteModalVisible}
/>
)}
</>
);
};

View file

@ -5,11 +5,18 @@
* 2.0.
*/
import { EuiBadge, EuiBasicTableColumn, EuiThemeComputed } from '@elastic/eui';
import { EuiBadge, EuiBasicTableColumn } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { useHistory } from 'react-router-dom';
import { FETCH_STATUS } from '@kbn/observability-plugin/public';
import {
isStatusEnabled,
toggleStatusAlert,
} from '../../../../../../../common/runtime_types/monitor_management/alert_config';
import { TagsBadges } from '../../../common/components/tag_badges';
import { useMonitorAlertEnable } from '../../../../hooks/use_monitor_alert_enable';
import * as labels from './labels';
import { MonitorDetailsLink } from './monitor_details_link';
import {
@ -17,37 +24,35 @@ import {
DataStream,
EncryptedSyntheticsSavedMonitor,
OverviewStatusState,
Ping,
ServiceLocations,
SourceType,
SyntheticsMonitorSchedule,
} from '../../../../../../../common/runtime_types';
import { getFrequencyLabel } from './labels';
import { Actions } from './actions';
import { MonitorEnabled } from './monitor_enabled';
import { MonitorLocations } from './monitor_locations';
export function useMonitorListColumns({
basePath,
euiTheme,
canEditSynthetics,
reloadPage,
loading,
status,
setMonitorPendingDeletion,
}: {
basePath: string;
euiTheme: EuiThemeComputed;
errorSummaries?: Ping[];
errorSummariesById: Map<string, Ping>;
canEditSynthetics: boolean;
syntheticsMonitors: EncryptedSyntheticsSavedMonitor[];
loading: boolean;
status: OverviewStatusState | null;
reloadPage: () => void;
}) {
setMonitorPendingDeletion: (config: EncryptedSyntheticsSavedMonitor) => void;
}): Array<EuiBasicTableColumn<EncryptedSyntheticsSavedMonitor>> {
const history = useHistory();
const { alertStatus, updateAlertEnabledState } = useMonitorAlertEnable();
const isActionLoading = (fields: EncryptedSyntheticsSavedMonitor) => {
return alertStatus(fields[ConfigKey.CONFIG_ID]) === FETCH_STATUS.LOADING;
};
return [
{
align: 'left' as const,
@ -57,7 +62,7 @@ export function useMonitorListColumns({
}),
sortable: true,
render: (_: string, monitor: EncryptedSyntheticsSavedMonitor) => (
<MonitorDetailsLink basePath={basePath} monitor={monitor} />
<MonitorDetailsLink monitor={monitor} />
),
},
{
@ -131,16 +136,64 @@ export function useMonitorListColumns({
name: i18n.translate('xpack.synthetics.management.monitorList.actions', {
defaultMessage: 'Actions',
}),
render: (fields: EncryptedSyntheticsSavedMonitor) => (
<Actions
euiTheme={euiTheme}
configId={fields[ConfigKey.CONFIG_ID]}
name={fields[ConfigKey.NAME]}
canEditSynthetics={canEditSynthetics}
reloadPage={reloadPage}
isProjectMonitor={fields[ConfigKey.MONITOR_SOURCE_TYPE] === SourceType.PROJECT}
/>
),
actions: [
{
'data-test-subj': 'syntheticsMonitorEditAction',
isPrimary: true,
name: labels.EDIT_LABEL,
description: labels.EDIT_LABEL,
icon: 'pencil',
type: 'icon',
enabled: (fields) => canEditSynthetics && !isActionLoading(fields),
onClick: (fields) => {
history.push({
pathname: `/edit-monitor/${fields[ConfigKey.CONFIG_ID]}`,
});
},
},
{
'data-test-subj': 'syntheticsMonitorDeleteAction',
isPrimary: true,
name: labels.DELETE_LABEL,
description: labels.DELETE_LABEL,
icon: 'trash',
type: 'icon',
color: 'danger',
enabled: (fields) => canEditSynthetics && !isActionLoading(fields),
onClick: (fields) => {
setMonitorPendingDeletion(fields);
},
},
{
description: labels.DISABLE_STATUS_ALERT,
name: (fields) =>
isStatusEnabled(fields[ConfigKey.ALERT_CONFIG])
? labels.DISABLE_STATUS_ALERT
: labels.ENABLE_STATUS_ALERT,
icon: (fields) =>
isStatusEnabled(fields[ConfigKey.ALERT_CONFIG]) ? 'bellSlash' : 'bell',
type: 'icon',
color: 'danger',
enabled: (fields) => canEditSynthetics && !isActionLoading(fields),
onClick: (fields) => {
updateAlertEnabledState({
monitor: {
[ConfigKey.ALERT_CONFIG]: toggleStatusAlert(fields[ConfigKey.ALERT_CONFIG]),
},
name: fields[ConfigKey.NAME],
configId: fields[ConfigKey.CONFIG_ID],
});
},
},
/*
TODO: Implement duplication functionality
const duplicateMenuItem = (
<EuiContextMenuItem key="xpack.synthetics.duplicateMonitor" icon="copy" onClick={closePopover}>
{labels.DUPLICATE_LABEL}
</EuiContextMenuItem>
);
*/
],
},
] as Array<EuiBasicTableColumn<EncryptedSyntheticsSavedMonitor>>;
];
}

View file

@ -12,23 +12,29 @@ import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import {
ConfigKey,
EncryptedSyntheticsSavedMonitor,
SourceType,
SyntheticsMonitor,
} from '../../../../../../../common/runtime_types';
import { fetchDeleteMonitor } from '../../../../state';
import { kibanaService } from '../../../../../../utils/kibana_service';
import * as labels from './labels';
export const DeleteMonitor = ({
configId,
name,
fields,
reloadPage,
isProjectMonitor,
setIsDeleteModalVisible,
setMonitorPendingDeletion,
}: {
configId: string;
name: string;
fields: SyntheticsMonitor | EncryptedSyntheticsSavedMonitor;
reloadPage: () => void;
isProjectMonitor?: boolean;
setIsDeleteModalVisible: React.Dispatch<React.SetStateAction<boolean>>;
setMonitorPendingDeletion: (val: null) => void;
}) => {
const configId = fields[ConfigKey.CONFIG_ID];
const name = fields[ConfigKey.NAME];
const isProjectMonitor = fields[ConfigKey.MONITOR_SOURCE_TYPE] === SourceType.PROJECT;
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const handleConfirmDelete = () => {
@ -37,7 +43,7 @@ export const DeleteMonitor = ({
const { status: monitorDeleteStatus } = useFetcher(() => {
if (isDeleting) {
return fetchDeleteMonitor({ id: configId });
return fetchDeleteMonitor({ configId });
}
}, [configId, isDeleting]);
@ -78,9 +84,9 @@ export const DeleteMonitor = ({
monitorDeleteStatus === FETCH_STATUS.FAILURE
) {
setIsDeleting(false);
setIsDeleteModalVisible(false);
setMonitorPendingDeletion(null);
}
}, [setIsDeleting, isDeleting, reloadPage, monitorDeleteStatus, setIsDeleteModalVisible, name]);
}, [setIsDeleting, isDeleting, reloadPage, monitorDeleteStatus, setMonitorPendingDeletion, name]);
return (
<EuiConfirmModal
@ -88,7 +94,7 @@ export const DeleteMonitor = ({
defaultMessage: 'Delete "{name}" monitor?',
values: { name },
})}
onCancel={() => setIsDeleteModalVisible(false)}
onCancel={() => setMonitorPendingDeletion(null)}
onConfirm={handleConfirmDelete}
cancelButtonText={labels.NO_LABEL}
confirmButtonText={labels.YES_LABEL}

View file

@ -52,6 +52,17 @@ export const EDIT_LABEL = i18n.translate('xpack.synthetics.management.editLabel'
defaultMessage: 'Edit',
});
export const ENABLE_STATUS_ALERT = i18n.translate('xpack.synthetics.management.enableStatusAlert', {
defaultMessage: 'Enable status alerts',
});
export const DISABLE_STATUS_ALERT = i18n.translate(
'xpack.synthetics.management.disableStatusAlert',
{
defaultMessage: 'Disable status alerts',
}
);
export const DUPLICATE_LABEL = i18n.translate('xpack.synthetics.management.duplicateLabel', {
defaultMessage: 'Duplicate',
});

View file

@ -17,13 +17,7 @@ import {
import { useMonitorDetailLocator } from '../../hooks/use_monitor_detail_locator';
import * as labels from './labels';
export const MonitorDetailsLink = ({
basePath,
monitor,
}: {
basePath: string;
monitor: EncryptedSyntheticsSavedMonitor;
}) => {
export const MonitorDetailsLink = ({ monitor }: { monitor: EncryptedSyntheticsSavedMonitor }) => {
const lastSelectedLocationId = useSelector(selectSelectedLocationId);
const monitorHasLocation = monitor[ConfigKey.LOCATIONS]?.find(
(loc) => loc.id === lastSelectedLocationId

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useCallback, useContext, useMemo } from 'react';
import React, { useCallback, useState } from 'react';
import {
Criteria,
EuiBasicTable,
@ -16,16 +16,15 @@ import {
useIsWithinMinBreakpoint,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { DeleteMonitor } from './delete_monitor';
import { IHttpSerializedFetchError } from '../../../../state/utils/http_error';
import { MonitorListPageState } from '../../../../state';
import { useCanEditSynthetics } from '../../../../../../hooks/use_capabilities';
import {
ConfigKey,
Ping,
EncryptedSyntheticsSavedMonitor,
OverviewStatusState,
} from '../../../../../../../common/runtime_types';
import { SyntheticsSettingsContext } from '../../../../contexts/synthetics_settings_context';
import { useMonitorListColumns } from './columns';
import * as labels from './labels';
@ -37,7 +36,6 @@ interface Props {
loading: boolean;
loadPage: (state: MonitorListPageState) => void;
reloadPage: () => void;
errorSummaries?: Ping[];
status: OverviewStatusState | null;
}
@ -50,23 +48,13 @@ export const MonitorList = ({
status,
loadPage,
reloadPage,
errorSummaries,
}: Props) => {
const { euiTheme } = useEuiTheme();
const { basePath } = useContext(SyntheticsSettingsContext);
const isXl = useIsWithinMinBreakpoint('xxl');
const canEditSynthetics = useCanEditSynthetics();
const errorSummariesById = useMemo(
() =>
(errorSummaries ?? []).reduce((acc, cur) => {
if (cur.config_id) {
acc.set(cur.config_id, cur);
}
return acc;
}, new Map<string, Ping>()),
[errorSummaries]
);
const [monitorPendingDeletion, setMonitorPendingDeletion] =
useState<EncryptedSyntheticsSavedMonitor | null>(null);
const handleOnChange = useCallback(
({
@ -107,39 +95,44 @@ export const MonitorList = ({
});
const columns = useMonitorListColumns({
basePath,
euiTheme,
errorSummaries,
errorSummariesById,
canEditSynthetics,
syntheticsMonitors,
loading,
reloadPage,
status,
setMonitorPendingDeletion,
});
return (
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="none">
{recordRangeLabel}
<EuiSpacer size="s" />
<hr style={{ border: `1px solid ${euiTheme.colors.lightShade}` }} />
<EuiBasicTable
aria-label={i18n.translate('xpack.synthetics.management.monitorList.title', {
defaultMessage: 'Synthetics monitors list',
})}
error={error?.body?.message}
loading={loading}
isExpandable={true}
hasActions={true}
itemId="monitor_id"
items={syntheticsMonitors}
columns={columns}
tableLayout={isXl ? 'auto' : 'fixed'}
pagination={pagination}
sorting={sorting}
onChange={handleOnChange}
noItemsMessage={loading ? labels.LOADING : labels.NO_DATA_MESSAGE}
/>
</EuiPanel>
<>
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="none">
{recordRangeLabel}
<EuiSpacer size="s" />
<hr style={{ border: `1px solid ${euiTheme.colors.lightShade}` }} />
<EuiBasicTable
aria-label={i18n.translate('xpack.synthetics.management.monitorList.title', {
defaultMessage: 'Synthetics monitors list',
})}
error={error?.body?.message}
loading={loading}
isExpandable={true}
hasActions={true}
itemId="monitor_id"
items={syntheticsMonitors}
columns={columns}
tableLayout={isXl ? 'auto' : 'fixed'}
pagination={pagination}
sorting={sorting}
onChange={handleOnChange}
noItemsMessage={loading ? labels.LOADING : labels.NO_DATA_MESSAGE}
/>
</EuiPanel>
{monitorPendingDeletion && (
<DeleteMonitor
fields={monitorPendingDeletion}
reloadPage={reloadPage}
setMonitorPendingDeletion={setMonitorPendingDeletion}
/>
)}
</>
);
};

View file

@ -25,6 +25,7 @@ describe('ActionsPopover', () => {
isServiceManaged: true,
},
isEnabled: true,
isStatusAlertEnabled: true,
name: 'Monitor 1',
id: 'somelongstring',
configId: '1lkjelre',

View file

@ -6,11 +6,21 @@
*/
import React, { useEffect, useMemo, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiPopover, EuiButtonIcon, EuiContextMenu, useEuiShadow, EuiPanel } from '@elastic/eui';
import {
EuiPopover,
EuiButtonIcon,
EuiContextMenu,
useEuiShadow,
EuiPanel,
EuiLoadingSpinner,
} from '@elastic/eui';
import { FETCH_STATUS } from '@kbn/observability-plugin/public';
import { useDispatch } from 'react-redux';
import styled from 'styled-components';
import { MonitorOverviewItem } from '../../../../../../../common/runtime_types';
import { toggleStatusAlert } from '../../../../../../../common/runtime_types/monitor_management/alert_config';
import { useSelectedMonitor } from '../../../monitor_details/hooks/use_selected_monitor';
import { useMonitorAlertEnable } from '../../../../hooks/use_monitor_alert_enable';
import { ConfigKey, MonitorOverviewItem } from '../../../../../../../common/runtime_types';
import { useMonitorEnableHandler } from '../../../../hooks/use_monitor_enable_handler';
import { setFlyoutConfig } from '../../../../state/overview/actions';
import { useEditMonitorLocator } from '../../hooks/use_edit_monitor_locator';
@ -92,6 +102,8 @@ export function ActionsPopover({
});
const editUrl = useEditMonitorLocator({ configId: monitor.configId });
const { monitor: monitorFields } = useSelectedMonitor(monitor.configId);
const labels = useMemo(
() => ({
enabledSuccessLabel: enabledSuccessLabel(monitor.name),
@ -106,6 +118,8 @@ export function ActionsPopover({
labels,
});
const { alertStatus, updateAlertEnabledState } = useMonitorAlertEnable();
const [enableLabel, setEnableLabel] = useState(
monitor.isEnabled ? disableMonitorLabel : enableMonitorLabel
);
@ -133,6 +147,8 @@ export function ActionsPopover({
},
};
const alertLoading = alertStatus(monitor.configId) === FETCH_STATUS.LOADING;
let popoverItems = [
{
name: actionsMenuGoToMonitorName,
@ -160,6 +176,27 @@ export function ActionsPopover({
}
},
},
{
name: monitor.isStatusAlertEnabled ? disableAlertLabel : enableMonitorAlertLabel,
icon: alertLoading ? (
<EuiLoadingSpinner size="s" />
) : monitor.isStatusAlertEnabled ? (
'bellSlash'
) : (
'bell'
),
onClick: () => {
if (!alertLoading) {
updateAlertEnabledState({
monitor: {
[ConfigKey.ALERT_CONFIG]: toggleStatusAlert(monitorFields?.[ConfigKey.ALERT_CONFIG]),
},
configId: monitor.configId,
name: monitor.name,
});
}
},
},
];
if (isInspectView) popoverItems = popoverItems.filter((i) => i !== quickInspectPopoverItem);
@ -256,6 +293,20 @@ const disableMonitorLabel = i18n.translate(
}
);
const disableAlertLabel = i18n.translate(
'xpack.synthetics.overview.actions.disableLabelDisableAlert',
{
defaultMessage: 'Disable status alerts',
}
);
const enableMonitorAlertLabel = i18n.translate(
'xpack.synthetics.overview.actions.enableLabelDisableAlert',
{
defaultMessage: 'Enable status alerts',
}
);
const enabledSuccessLabel = (name: string) =>
i18n.translate('xpack.synthetics.overview.actions.enabledSuccessLabel', {
defaultMessage: 'Monitor "{name}" enabled successfully',

View file

@ -29,6 +29,7 @@ describe('Overview Grid', () => {
},
name: `Monitor ${i}`,
isEnabled: true,
isStatusAlertEnabled: true,
});
data.push({
id: `${i}`,
@ -39,6 +40,7 @@ describe('Overview Grid', () => {
},
name: `Monitor ${i}`,
isEnabled: true,
isStatusAlertEnabled: true,
});
}
return data;

View file

@ -0,0 +1,81 @@
/*
* 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 { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { i18n } from '@kbn/i18n';
import { isStatusEnabled } from '../../../../common/runtime_types/monitor_management/alert_config';
import { ConfigKey, EncryptedSyntheticsMonitor } from '../components/monitors_page/overview/types';
import { enableMonitorAlertAction, selectMonitorUpsertStatuses } from '../state';
export interface EnableStateMonitorLabels {
failureLabel: string;
enabledSuccessLabel: string;
disabledSuccessLabel: string;
}
export function useMonitorAlertEnable() {
const dispatch = useDispatch();
const upsertStatuses = useSelector(selectMonitorUpsertStatuses);
const alertStatus = useCallback(
(configId: string) => upsertStatuses[configId]?.alertStatus,
[upsertStatuses]
);
const updateAlertEnabledState = useCallback(
({
monitor,
name,
configId,
}: {
monitor: Partial<EncryptedSyntheticsMonitor>;
configId: string;
name: string;
}) => {
dispatch(
enableMonitorAlertAction.get({
configId,
monitor,
success: {
message: isStatusEnabled(monitor[ConfigKey.ALERT_CONFIG])
? enabledSuccessLabel(name)
: disabledSuccessLabel(name),
lifetimeMs: 3000,
testAttribute: 'uptimeMonitorAlertUpdateSuccess',
},
error: {
message: {
title: enabledFailLabel(name),
},
lifetimeMs: 10000,
testAttribute: 'uptimeMonitorAlertEnabledUpdateFailure',
},
})
);
},
[dispatch]
);
return { updateAlertEnabledState, alertStatus };
}
const enabledSuccessLabel = (name: string) =>
i18n.translate('xpack.synthetics.overview.actions.enabledSuccessLabel.alert', {
defaultMessage: 'Alerts are now enabled for the monitor "{name}".',
values: { name },
});
const disabledSuccessLabel = (name: string) =>
i18n.translate('xpack.synthetics.overview.actions.disabledSuccessLabel.alert', {
defaultMessage: 'Alerts are now disabled for the monitor "{name}".',
values: { name },
});
const enabledFailLabel = (name: string) =>
i18n.translate('xpack.synthetics.overview.actions.enabledFailLabel.alert', {
defaultMessage: 'Unable to enable status alerts for monitor "{name}".',
values: { name },
});

View file

@ -42,7 +42,7 @@ export function useMonitorEnableHandler({
(enabled: boolean) => {
dispatch(
fetchUpsertMonitorAction({
id: configId,
configId,
monitor: { [ConfigKey.ENABLED]: enabled },
success: {
message: enabled ? labels.enabledSuccessLabel : labels.disabledSuccessLabel,

View file

@ -0,0 +1,18 @@
/*
* 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 { CoreStart } from '@kbn/core/public';
import { ObservabilityRuleTypeModel } from '@kbn/observability-plugin/public';
import { ClientPluginsStart } from '../../../../plugin';
import { initMonitorStatusAlertType } from './monitor_status';
export type AlertTypeInitializer<TAlertTypeModel = ObservabilityRuleTypeModel> = (dependencies: {
core: CoreStart;
plugins: ClientPluginsStart;
}) => TAlertTypeModel;
export const syntheticsAlertTypeInitializers: AlertTypeInitializer[] = [initMonitorStatusAlertType];

View file

@ -0,0 +1,43 @@
/*
* 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 React from 'react';
import { Provider as ReduxProvider } from 'react-redux';
import { CoreStart } from '@kbn/core/public';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { RuleTypeParamsExpressionProps } from '@kbn/triggers-actions-ui-plugin/public';
import { EuiSpacer, EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { kibanaService } from '../../../../../utils/kibana_service';
import { ClientPluginsStart } from '../../../../../plugin';
import { store } from '../../../state';
import { StatusRuleParams } from '../../../../../../common/rules/status_rule';
interface Props {
core: CoreStart;
plugins: ClientPluginsStart;
params: RuleTypeParamsExpressionProps<StatusRuleParams>;
}
// eslint-disable-next-line import/no-default-export
export default function MonitorStatusAlert({ core, plugins, params }: Props) {
kibanaService.core = core;
return (
<ReduxProvider store={store}>
<KibanaContextProvider services={{ ...core, ...plugins }}>
<EuiText>
<FormattedMessage
id="xpack.synthetics.alertRule.monitorStatus.description"
defaultMessage="Manage synthetics monitor status rule actions."
/>
</EuiText>
<EuiSpacer size="m" />
</KibanaContextProvider>
</ReduxProvider>
);
}

View file

@ -0,0 +1,59 @@
/*
* 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 React from 'react';
import moment from 'moment';
import {
ALERT_END,
ALERT_START,
ALERT_STATUS,
ALERT_STATUS_ACTIVE,
ALERT_REASON,
} from '@kbn/rule-data-utils';
import { ObservabilityRuleTypeModel } from '@kbn/observability-plugin/public';
import { RuleTypeParamsExpressionProps } from '@kbn/triggers-actions-ui-plugin/public';
import { getSyntheticsMonitorRouteFromMonitorId } from '../../../../../common/utils/get_synthetics_monitor_url';
import { SyntheticsMonitorStatusTranslations } from '../../../../../common/rules/synthetics/translations';
import { StatusRuleParams } from '../../../../../common/rules/status_rule';
import { SYNTHETICS_ALERT_RULE_TYPES } from '../../../../../common/constants/synthetics_alerts';
import { AlertTypeInitializer } from '.';
const { defaultActionMessage, defaultRecoveryMessage, description } =
SyntheticsMonitorStatusTranslations;
const MonitorStatusAlert = React.lazy(() => import('./lazy_wrapper/monitor_status'));
export const initMonitorStatusAlertType: AlertTypeInitializer = ({
core,
plugins,
}): ObservabilityRuleTypeModel => ({
id: SYNTHETICS_ALERT_RULE_TYPES.MONITOR_STATUS,
description,
iconClass: 'uptimeApp',
documentationUrl(docLinks) {
return `${docLinks.links.observability.monitorStatus}`;
},
ruleParamsExpression: (paramProps: RuleTypeParamsExpressionProps<StatusRuleParams>) => (
<MonitorStatusAlert core={core} plugins={plugins} params={paramProps} />
),
validate: (ruleParams: StatusRuleParams) => {
return { errors: {} };
},
defaultActionMessage,
defaultRecoveryMessage,
requiresAppContext: true,
format: ({ fields }) => ({
reason: fields[ALERT_REASON] || '',
link: getSyntheticsMonitorRouteFromMonitorId({
configId: fields.configId,
dateRangeEnd: fields[ALERT_STATUS] === ALERT_STATUS_ACTIVE ? 'now' : fields[ALERT_END]!,
dateRangeStart: moment(new Date(fields[ALERT_START]!)).subtract('5', 'm').toISOString(),
locationId: fields['location.id'],
}),
}),
});

View file

@ -159,6 +159,7 @@ const getRoutes = (
/>
),
isSelected: true,
'data-test-subj': 'syntheticsMonitorOverviewTab',
},
{
label: (
@ -168,6 +169,7 @@ const getRoutes = (
/>
),
href: `${syntheticsPath}${MONITORS_ROUTE}`,
'data-test-subj': 'syntheticsMonitorManagementTab',
},
],
},
@ -192,6 +194,7 @@ const getRoutes = (
/>
),
href: `${syntheticsPath}${OVERVIEW_ROUTE}`,
'data-test-subj': 'syntheticsMonitorOverviewTab',
},
{
label: (
@ -201,6 +204,7 @@ const getRoutes = (
/>
),
isSelected: true,
'data-test-subj': 'syntheticsMonitorManagementTab',
},
],
},
@ -364,6 +368,7 @@ const getMonitorSummaryHeader = (
}),
isSelected: selectedTab === 'overview',
href: `${syntheticsPath}${MONITOR_ROUTE.replace(':monitorId?', monitorId)}${search}`,
'data-test-subj': 'syntheticsMonitorOverviewTab',
},
{
label: i18n.translate('xpack.synthetics.monitorHistoryTab.title', {
@ -371,6 +376,7 @@ const getMonitorSummaryHeader = (
}),
isSelected: selectedTab === 'history',
href: `${syntheticsPath}${MONITOR_HISTORY_ROUTE.replace(':monitorId', monitorId)}${search}`,
'data-test-subj': 'syntheticsMonitorHistoryTab',
},
{
label: i18n.translate('xpack.synthetics.monitorErrorsTab.title', {
@ -379,6 +385,7 @@ const getMonitorSummaryHeader = (
prepend: <EuiIcon type="alert" color="danger" />,
isSelected: selectedTab === 'errors',
href: `${syntheticsPath}${MONITOR_ERRORS_ROUTE.replace(':monitorId', monitorId)}${search}`,
'data-test-subj': 'syntheticsMonitorErrorsTab',
},
],
};

View file

@ -0,0 +1,17 @@
/*
* 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 { Rule } from '@kbn/triggers-actions-ui-plugin/public';
import { createAsyncAction } from '../utils/actions';
export const enableDefaultAlertingAction = createAsyncAction<void, Rule>(
'enableDefaultAlertingAction'
);
export const updateDefaultAlertingAction = createAsyncAction<void, Rule>(
'updateDefaultAlertingAction'
);

View file

@ -0,0 +1,18 @@
/*
* 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 { Rule } from '@kbn/triggers-actions-ui-plugin/public';
import { SYNTHETICS_API_URLS } from '../../../../../common/constants';
import { apiService } from '../../../../utils/api_service';
export async function enableDefaultAlertingAPI(): Promise<Rule> {
return apiService.post(SYNTHETICS_API_URLS.ENABLE_DEFAULT_ALERTING);
}
export async function updateDefaultAlertingAPI(): Promise<Rule> {
return apiService.put(SYNTHETICS_API_URLS.ENABLE_DEFAULT_ALERTING);
}

View file

@ -0,0 +1,47 @@
/*
* 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 { takeLeading } from 'redux-saga/effects';
import { i18n } from '@kbn/i18n';
import { enableDefaultAlertingAction, updateDefaultAlertingAction } from './actions';
import { fetchEffectFactory } from '../utils/fetch_effect';
import { enableDefaultAlertingAPI, updateDefaultAlertingAPI } from './api';
export function* enableDefaultAlertingEffect() {
yield takeLeading(
enableDefaultAlertingAction.get,
fetchEffectFactory(
enableDefaultAlertingAPI,
enableDefaultAlertingAction.success,
enableDefaultAlertingAction.fail,
successMessage,
failureMessage
)
);
}
export function* updateDefaultAlertingEffect() {
yield takeLeading(
updateDefaultAlertingAction.get,
fetchEffectFactory(
updateDefaultAlertingAPI,
updateDefaultAlertingAction.success,
updateDefaultAlertingAction.fail,
successMessage,
failureMessage
)
);
}
const successMessage = i18n.translate('xpack.synthetics.settings.enableAlerting', {
defaultMessage:
'Monitor status rule type successfully updated. Next rule alerts will take the changes into account.',
});
const failureMessage = i18n.translate('xpack.synthetics.settings.enabledAlert.fail', {
defaultMessage: 'Failed to update monitor status rule type.',
});

View file

@ -0,0 +1,53 @@
/*
* 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 { createReducer } from '@reduxjs/toolkit';
import { IHttpSerializedFetchError } from '..';
import { enableDefaultAlertingAction, updateDefaultAlertingAction } from './actions';
export interface DefaultAlertingState {
success: boolean | null;
loading: boolean;
error: IHttpSerializedFetchError | null;
}
const initialSettingState: DefaultAlertingState = {
success: null,
loading: false,
error: null,
};
export const defaultAlertingReducer = createReducer(initialSettingState, (builder) => {
builder
.addCase(enableDefaultAlertingAction.get, (state) => {
state.loading = true;
})
.addCase(enableDefaultAlertingAction.success, (state, action) => {
state.success = Boolean(action.payload);
state.loading = false;
})
.addCase(enableDefaultAlertingAction.fail, (state, action) => {
state.error = action.payload;
state.loading = false;
state.success = false;
})
.addCase(updateDefaultAlertingAction.get, (state) => {
state.loading = true;
})
.addCase(updateDefaultAlertingAction.success, (state, action) => {
state.success = Boolean(action.payload);
state.loading = false;
})
.addCase(updateDefaultAlertingAction.fail, (state, action) => {
state.error = action.payload;
state.loading = false;
state.success = false;
});
});
export * from './actions';
export * from './effects';

View file

@ -7,9 +7,11 @@
import { ErrorToastOptions } from '@kbn/core-notifications-browser';
import { createAction } from '@reduxjs/toolkit';
import { UpsertMonitorResponse } from '..';
import {
EncryptedSyntheticsMonitor,
MonitorManagementListResult,
SyntheticsMonitor,
} from '../../../../../common/runtime_types';
import { createAsyncAction } from '../utils/actions';
import { IHttpSerializedFetchError } from '../utils/http_error';
@ -28,8 +30,8 @@ interface ToastParams<MessageType> {
}
export interface UpsertMonitorRequest {
id: string;
monitor: Partial<EncryptedSyntheticsMonitor>;
configId: string;
monitor: Partial<SyntheticsMonitor> | Partial<EncryptedSyntheticsMonitor>;
success: ToastParams<string>;
error: ToastParams<ErrorToastOptions>;
/**
@ -39,13 +41,24 @@ export interface UpsertMonitorRequest {
shouldQuietFetchAfterSuccess?: boolean;
}
interface UpsertMonitorError {
configId: string;
error: IHttpSerializedFetchError;
}
export const fetchUpsertMonitorAction = createAction<UpsertMonitorRequest>('fetchUpsertMonitor');
export const fetchUpsertSuccessAction = createAction<{
id: string;
attributes: { enabled: boolean };
}>('fetchUpsertMonitorSuccess');
export const fetchUpsertFailureAction = createAction<{
id: string;
error: IHttpSerializedFetchError;
}>('fetchUpsertMonitorFailure');
export const fetchUpsertFailureAction = createAction<UpsertMonitorError>(
'fetchUpsertMonitorFailure'
);
export const enableMonitorAlertAction = createAsyncAction<
UpsertMonitorRequest,
UpsertMonitorResponse,
UpsertMonitorError
>('enableMonitorAlertAction');
export const clearMonitorUpsertStatus = createAction<string>('clearMonitorUpsertStatus');

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import { SavedObject } from '@kbn/core-saved-objects-common';
import { UpsertMonitorRequest } from '..';
import { API_URLS } from '../../../../../common/constants';
import {
EncryptedSyntheticsMonitor,
@ -46,19 +48,20 @@ export const fetchMonitorManagementList = async (
);
};
export const fetchDeleteMonitor = async ({ id }: { id: string }): Promise<void> => {
return await apiService.delete(`${API_URLS.SYNTHETICS_MONITORS}/${id}`);
export const fetchDeleteMonitor = async ({ configId }: { configId: string }): Promise<void> => {
return await apiService.delete(`${API_URLS.SYNTHETICS_MONITORS}/${configId}`);
};
export type UpsertMonitorResponse =
| { attributes: { errors: ServiceLocationErrors }; id: string }
| SavedObject<SyntheticsMonitor>;
export const fetchUpsertMonitor = async ({
monitor,
id,
}: {
monitor: Partial<SyntheticsMonitor> | Partial<EncryptedSyntheticsMonitor>;
id?: string;
}): Promise<{ attributes: { errors: ServiceLocationErrors } } | SyntheticsMonitor> => {
if (id) {
return await apiService.put(`${API_URLS.SYNTHETICS_MONITORS}/${id}`, monitor);
configId,
}: UpsertMonitorRequest): Promise<UpsertMonitorResponse> => {
if (configId) {
return await apiService.put(`${API_URLS.SYNTHETICS_MONITORS}/${configId}`, monitor);
} else {
return await apiService.post(API_URLS.SYNTHETICS_MONITORS, monitor);
}

View file

@ -7,14 +7,17 @@
import { PayloadAction } from '@reduxjs/toolkit';
import { call, put, takeEvery, takeLeading, select } from 'redux-saga/effects';
import { SavedObject } from '@kbn/core-saved-objects-common';
import { enableDefaultAlertingAction } from '../alert_rules';
import { kibanaService } from '../../../../utils/kibana_service';
import { MonitorOverviewPageState } from '../overview';
import { quietFetchOverviewAction } from '../overview/actions';
import { selectOverviewState } from '../overview/selectors';
import { fetchEffectFactory } from '../utils/fetch_effect';
import { fetchEffectFactory, sendErrorToast, sendSuccessToast } from '../utils/fetch_effect';
import { serializeHttpFetchError } from '../utils/http_error';
import {
clearMonitorUpsertStatus,
enableMonitorAlertAction,
fetchMonitorListAction,
fetchUpsertFailureAction,
fetchUpsertMonitorAction,
@ -23,6 +26,7 @@ import {
} from './actions';
import { fetchMonitorManagementList, fetchUpsertMonitor } from './api';
import { toastTitle } from './toast_title';
import { ConfigKey, SyntheticsMonitor } from '../../../../../common/runtime_types';
export function* fetchMonitorListEffect() {
yield takeLeading(
@ -35,6 +39,33 @@ export function* fetchMonitorListEffect() {
);
}
export function* enableMonitorAlertEffect() {
yield takeEvery(
enableMonitorAlertAction.get,
function* (action: PayloadAction<UpsertMonitorRequest>): Generator {
try {
const response = yield call(fetchUpsertMonitor, action.payload);
yield put(enableMonitorAlertAction.success(response as SavedObject<SyntheticsMonitor>));
sendSuccessToast(action.payload.success);
if (
(response as SavedObject<SyntheticsMonitor>).attributes[ConfigKey.ALERT_CONFIG]?.status
?.enabled
) {
yield put(enableDefaultAlertingAction.get());
}
} catch (error) {
sendErrorToast(action.payload.error, error);
yield put(
enableMonitorAlertAction.fail({
configId: action.payload.configId,
error: serializeHttpFetchError(error),
})
);
}
}
);
}
export function* upsertMonitorEffect() {
yield takeEvery(
fetchUpsertMonitorAction,
@ -57,7 +88,10 @@ export function* upsertMonitorEffect() {
toastLifeTimeMs: action.payload.error.lifetimeMs,
});
yield put(
fetchUpsertFailureAction({ id: action.payload.id, error: serializeHttpFetchError(error) })
fetchUpsertFailureAction({
configId: action.payload.configId,
error: serializeHttpFetchError(error),
})
);
} finally {
if (action.payload.shouldQuietFetchAfterSuccess !== false) {
@ -68,7 +102,7 @@ export function* upsertMonitorEffect() {
);
}
}
yield put(clearMonitorUpsertStatus(action.payload.id));
yield put(clearMonitorUpsertStatus(action.payload.configId));
}
}
);

View file

@ -9,13 +9,19 @@ import { isEqual } from 'lodash';
import { createReducer } from '@reduxjs/toolkit';
import { FETCH_STATUS } from '@kbn/observability-plugin/public';
import { ConfigKey, MonitorManagementListResult } from '../../../../../common/runtime_types';
import { SavedObject } from '@kbn/core-saved-objects-common';
import {
ConfigKey,
MonitorManagementListResult,
SyntheticsMonitor,
} from '../../../../../common/runtime_types';
import { IHttpSerializedFetchError } from '../utils/http_error';
import { MonitorListPageState } from './models';
import {
clearMonitorUpsertStatus,
enableMonitorAlertAction,
fetchMonitorListAction,
fetchUpsertFailureAction,
fetchUpsertMonitorAction,
@ -24,7 +30,10 @@ import {
export interface MonitorListState {
data: MonitorManagementListResult;
monitorUpsertStatuses: Record<string, { status: FETCH_STATUS; enabled?: boolean }>;
monitorUpsertStatuses: Record<
string,
{ status: FETCH_STATUS; enabled?: boolean; alertStatus?: FETCH_STATUS }
>;
pageState: MonitorListPageState;
loading: boolean;
loaded: boolean;
@ -64,7 +73,7 @@ export const monitorListReducer = createReducer(initialState, (builder) => {
state.error = action.payload;
})
.addCase(fetchUpsertMonitorAction, (state, action) => {
state.monitorUpsertStatuses[action.payload.id] = {
state.monitorUpsertStatuses[action.payload.configId] = {
status: FETCH_STATUS.LOADING,
};
})
@ -75,7 +84,33 @@ export const monitorListReducer = createReducer(initialState, (builder) => {
};
})
.addCase(fetchUpsertFailureAction, (state, action) => {
state.monitorUpsertStatuses[action.payload.id] = { status: FETCH_STATUS.FAILURE };
state.monitorUpsertStatuses[action.payload.configId] = { status: FETCH_STATUS.FAILURE };
})
.addCase(enableMonitorAlertAction.get, (state, action) => {
state.monitorUpsertStatuses[action.payload.configId] = {
...state.monitorUpsertStatuses[action.payload.configId],
alertStatus: FETCH_STATUS.LOADING,
};
})
.addCase(enableMonitorAlertAction.success, (state, action) => {
state.monitorUpsertStatuses[action.payload.id] = {
...state.monitorUpsertStatuses[action.payload.id],
alertStatus: FETCH_STATUS.SUCCESS,
};
if ('updated_at' in action.payload) {
state.data.monitors = state.data.monitors.map((monitor) => {
if (monitor.id === action.payload.id) {
return action.payload as SavedObject<SyntheticsMonitor>;
}
return monitor;
});
}
})
.addCase(enableMonitorAlertAction.fail, (state, action) => {
state.monitorUpsertStatuses[action.payload.configId] = {
...state.monitorUpsertStatuses[action.payload.configId],
alertStatus: FETCH_STATUS.FAILURE,
};
})
.addCase(clearMonitorUpsertStatus, (state, action) => {
if (state.monitorUpsertStatuses[action.payload]) {

View file

@ -14,7 +14,7 @@ export const selectMonitorListState = (state: SyntheticsAppState) => state.monit
export const selectEncryptedSyntheticsSavedMonitors = createSelector(
selectMonitorListState,
(state) =>
state.data.monitors.map((monitor) => ({
state?.data.monitors.map((monitor) => ({
...monitor.attributes,
id: monitor.attributes[ConfigKey.MONITOR_QUERY_ID],
updated_at: monitor.updated_at,

View file

@ -6,7 +6,7 @@
*/
import { createReducer } from '@reduxjs/toolkit';
import { isStatusEnabled } from '../../../../../common/runtime_types/monitor_management/alert_config';
import { MonitorOverviewState } from './models';
import {
@ -17,6 +17,8 @@ import {
setFlyoutConfig,
setOverviewPageStateAction,
} from './actions';
import { enableMonitorAlertAction } from '../monitor_list/actions';
import { ConfigKey } from '../../components/monitor_add_edit/types';
const initialState: MonitorOverviewState = {
data: {
@ -77,6 +79,24 @@ export const monitorOverviewReducer = createReducer(initialState, (builder) => {
allConfigs: { ...action.payload.upConfigs, ...action.payload.downConfigs },
};
})
.addCase(enableMonitorAlertAction.success, (state, action) => {
const attrs = action.payload.attributes;
if (!('errors' in attrs)) {
const isStatusAlertEnabled = isStatusEnabled(attrs[ConfigKey.ALERT_CONFIG]);
state.data.monitors = state.data.monitors.map((monitor) => {
if (
monitor.id === action.payload.id ||
attrs[ConfigKey.MONITOR_QUERY_ID] === monitor.id
) {
return {
...monitor,
isStatusAlertEnabled,
};
}
return monitor;
});
}
})
.addCase(fetchOverviewStatusAction.fail, (state, action) => {
state.statusError = action.payload;
})

View file

@ -6,6 +6,7 @@
*/
import { all, fork } from 'redux-saga/effects';
import { enableDefaultAlertingEffect, updateDefaultAlertingEffect } from './alert_rules/effects';
import {
fetchAlertConnectorsEffect,
fetchDynamicSettingsEffect,
@ -17,7 +18,11 @@ import { fetchNetworkEventsEffect } from './network_events/effects';
import { fetchSyntheticsMonitorEffect } from './monitor_details';
import { fetchIndexStatusEffect } from './index_status';
import { fetchSyntheticsEnablementEffect } from './synthetics_enablement';
import { fetchMonitorListEffect, upsertMonitorEffect } from './monitor_list';
import {
enableMonitorAlertEffect,
fetchMonitorListEffect,
upsertMonitorEffect,
} from './monitor_list';
import { fetchMonitorOverviewEffect, fetchOverviewStatusEffect } from './overview';
import { fetchServiceLocationsEffect } from './service_locations';
import { browserJourneyEffects } from './browser_journey';
@ -42,5 +47,8 @@ export const rootEffect = function* root(): Generator {
fork(fetchAgentPoliciesEffect),
fork(fetchAlertConnectorsEffect),
fork(syncGlobalParamsEffect),
fork(enableDefaultAlertingEffect),
fork(enableMonitorAlertEffect),
fork(updateDefaultAlertingEffect),
]);
};

View file

@ -7,8 +7,10 @@
import { combineReducers } from '@reduxjs/toolkit';
import { dynamicSettingsReducer, DynamicSettingsState } from './settings';
import { settingsReducer, SettingsState } from './settings';
import { browserJourneyReducer } from './browser_journey';
import { defaultAlertingReducer, DefaultAlertingState } from './alert_rules';
import { dynamicSettingsReducer, DynamicSettingsState, settingsReducer } from './settings';
import { SettingsState } from './settings';
import { agentPoliciesReducer, AgentPoliciesState } from './private_locations';
import { networkEventsReducer, NetworkEventsState } from './network_events';
import { monitorDetailsReducer, MonitorDetailsState } from './monitor_details';
@ -19,8 +21,7 @@ import { monitorListReducer, MonitorListState } from './monitor_list';
import { serviceLocationsReducer, ServiceLocationsState } from './service_locations';
import { monitorOverviewReducer, MonitorOverviewState } from './overview';
import { BrowserJourneyState } from './browser_journey/models';
import { browserJourneyReducer } from './browser_journey';
import { PingStatusState, pingStatusReducer } from './ping_status';
import { pingStatusReducer, PingStatusState } from './ping_status';
export interface SyntheticsAppState {
ui: UiState;
@ -34,22 +35,24 @@ export interface SyntheticsAppState {
monitorDetails: MonitorDetailsState;
browserJourney: BrowserJourneyState;
serviceLocations: ServiceLocationsState;
syntheticsEnablement: SyntheticsEnablementState;
dynamicSettings: DynamicSettingsState;
defaultAlerting: DefaultAlertingState;
syntheticsEnablement: SyntheticsEnablementState;
}
export const rootReducer = combineReducers<SyntheticsAppState>({
ui: uiReducer,
indexStatus: indexStatusReducer,
syntheticsEnablement: syntheticsEnablementReducer,
monitorList: monitorListReducer,
serviceLocations: serviceLocationsReducer,
monitorDetails: monitorDetailsReducer,
overview: monitorOverviewReducer,
browserJourney: browserJourneyReducer,
networkEvents: networkEventsReducer,
pingStatus: pingStatusReducer,
agentPolicies: agentPoliciesReducer,
dynamicSettings: dynamicSettingsReducer,
settings: settingsReducer,
pingStatus: pingStatusReducer,
monitorList: monitorListReducer,
indexStatus: indexStatusReducer,
overview: monitorOverviewReducer,
networkEvents: networkEventsReducer,
agentPolicies: agentPoliciesReducer,
monitorDetails: monitorDetailsReducer,
browserJourney: browserJourneyReducer,
defaultAlerting: defaultAlertingReducer,
dynamicSettings: dynamicSettingsReducer,
serviceLocations: serviceLocationsReducer,
syntheticsEnablement: syntheticsEnablementReducer,
});

View file

@ -17,8 +17,7 @@ import {
DynamicSettingsSaveType,
DynamicSettingsType,
} from '../../../../../common/runtime_types';
import { API_URLS } from '../../../../../common/constants';
import { SYNTHETICS_API_URLS } from '../../../../../common/constants';
import { API_URLS, SYNTHETICS_API_URLS } from '../../../../../common/constants';
const apiPath = API_URLS.DYNAMIC_SETTINGS;

View file

@ -8,6 +8,7 @@
import { takeLeading, put, call, takeLatest } from 'redux-saga/effects';
import { Action } from 'redux-actions';
import { i18n } from '@kbn/i18n';
import { updateDefaultAlertingAction } from '../alert_rules';
import { DynamicSettings } from '../../../../../common/runtime_types';
import { kibanaService } from '../../../../utils/kibana_service';
import { getConnectorsAction, setDynamicSettingsAction, getDynamicSettingsAction } from './actions';
@ -61,6 +62,7 @@ export function* setDynamicSettingsEffect() {
function* (action: Action<DynamicSettings>) {
try {
yield call(setDynamicSettings, { settings: action.payload });
yield put(updateDefaultAlertingAction.get());
yield put(setDynamicSettingsAction.success(action.payload));
kibanaService.core.notifications.toasts.addSuccess(
i18n.translate('xpack.synthetics.settings.saveSuccess', {

View file

@ -8,11 +8,15 @@
import { createAction } from '@reduxjs/toolkit';
import type { IHttpSerializedFetchError } from './http_error';
export function createAsyncAction<Payload, SuccessPayload>(actionStr: string) {
export function createAsyncAction<
Payload,
SuccessPayload,
FailurePayload = IHttpSerializedFetchError
>(actionStr: string) {
return {
get: createAction(actionStr, (payload: Payload) => prepareForTimestamp(payload)),
success: createAction<SuccessPayload>(`${actionStr}_SUCCESS`),
fail: createAction<IHttpSerializedFetchError>(`${actionStr}_FAIL`),
fail: createAction<FailurePayload>(`${actionStr}_FAIL`),
};
}

View file

@ -8,9 +8,39 @@
import { call, put } from 'redux-saga/effects';
import { PayloadAction } from '@reduxjs/toolkit';
import type { IHttpFetchError } from '@kbn/core-http-browser';
import { ErrorToastOptions } from '@kbn/core-notifications-browser';
import { toastTitle } from '../monitor_list/toast_title';
import { kibanaService } from '../../../../utils/kibana_service';
import { IHttpSerializedFetchError, serializeHttpFetchError } from './http_error';
interface ToastParams<MessageType> {
message: MessageType;
lifetimeMs?: number;
testAttribute?: string;
}
interface ActionMessages {
success: ToastParams<string>;
error: ToastParams<ErrorToastOptions>;
}
export const sendSuccessToast = (payload: ToastParams<string>) => {
kibanaService.toasts.addSuccess({
title: toastTitle({
title: payload.message,
testAttribute: payload.testAttribute,
}),
toastLifeTimeMs: payload.lifetimeMs,
});
};
export const sendErrorToast = (payload: ToastParams<ErrorToastOptions>, error: Error) => {
kibanaService.toasts.addError(error, {
...payload.message,
toastLifeTimeMs: payload.lifetimeMs,
});
};
/**
* Factory function for a fetch effect. It expects three action creators,
* one to call for a fetch, one to call for success, and one to handle failures.
@ -48,6 +78,17 @@ export function fetchEffectFactory<T, R, S, F>(
}
} else {
yield put(success(response as R));
const successMessage = (action.payload as unknown as ActionMessages)?.success;
if (successMessage?.message) {
kibanaService.toasts.addSuccess({
title: toastTitle({
title: successMessage.message,
testAttribute: successMessage.testAttribute,
}),
toastLifeTimeMs: successMessage.lifetimeMs,
});
}
if (typeof onSuccess === 'function') {
onSuccess?.(response as R);
} else if (typeof onSuccess === 'string') {
@ -57,6 +98,15 @@ export function fetchEffectFactory<T, R, S, F>(
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
const errorMessage = (action.payload as unknown as ActionMessages)?.error;
if (errorMessage?.message) {
kibanaService.toasts.addError(error, {
...errorMessage.message,
toastLifeTimeMs: errorMessage.lifetimeMs,
});
}
yield put(fail(serializeHttpFetchError(error)));
if (typeof onFailure === 'function') {
onFailure?.(error);

View file

@ -31,7 +31,6 @@ import { store, storage, setBasePath } from './state';
import { kibanaService } from '../../utils/kibana_service';
import { ActionMenu } from './components/common/header/action_menu';
// added a comment to trigger test
const Application = (props: SyntheticsAppProps) => {
const {
basePath,

View file

@ -124,6 +124,11 @@ export const mockState: SyntheticsAppState = {
dynamicSettings: {
loading: false,
},
defaultAlerting: {
loading: false,
error: null,
success: null,
},
};
function getBrowserJourneyMockSlice() {

View file

@ -65,6 +65,7 @@ export const commonNormalizers: CommonNormalizerMap = {
[ConfigKey.NAME]: (fields) => fields?.[ConfigKey.NAME]?.value ?? '',
[ConfigKey.LOCATIONS]: getCommonNormalizer(ConfigKey.LOCATIONS),
[ConfigKey.ENABLED]: getCommonNormalizer(ConfigKey.ENABLED),
[ConfigKey.ALERT_CONFIG]: getCommonNormalizer(ConfigKey.ENABLED),
[ConfigKey.MONITOR_TYPE]: getCommonNormalizer(ConfigKey.MONITOR_TYPE),
[ConfigKey.LOCATIONS]: getCommonNormalizer(ConfigKey.LOCATIONS),
[ConfigKey.SCHEDULE]: (fields) => {

View file

@ -10,7 +10,7 @@ import React, { useCallback, useContext, useState } from 'react';
import { EuiButton, EuiContextMenu, EuiIcon, EuiPopover } from '@elastic/eui';
import { useSelector, useDispatch } from 'react-redux';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { CLIENT_ALERT_TYPES } from '../../../../../common/constants/alerts';
import { CLIENT_ALERT_TYPES } from '../../../../../common/constants/uptime_alerts';
import {
canDeleteMLJobSelector,
hasMLJobSelector,

View file

@ -31,7 +31,7 @@ import { useGetUrlParams } from '../../../hooks';
import { getDynamicSettings } from '../../../state/actions/dynamic_settings';
import { useMonitorId } from '../../../hooks';
import { kibanaService } from '../../../state/kibana_service';
import { CLIENT_ALERT_TYPES } from '../../../../../common/constants/alerts';
import { CLIENT_ALERT_TYPES } from '../../../../../common/constants/uptime_alerts';
interface Props {
onClose: () => void;

View file

@ -13,9 +13,7 @@ import { selectAlertFlyoutVisibility, selectAlertFlyoutType } from '../../../../
export const UptimeAlertsFlyoutWrapper: React.FC = () => {
const dispatch = useDispatch();
const setAddFlyoutVisibility = (value: React.SetStateAction<boolean>) =>
// @ts-ignore the value here is a boolean, and it works with the action creator function
dispatch(setAlertFlyoutVisible(value));
const setAddFlyoutVisibility = (value: boolean) => dispatch(setAlertFlyoutVisible(value));
const alertFlyoutVisible = useSelector(selectAlertFlyoutVisibility);
const alertTypeId = useSelector(selectAlertFlyoutType);

View file

@ -69,7 +69,7 @@ export const AlertMonitorStatusComponent: React.FC<AlertMonitorStatusProps> = (p
<EuiCallOut
size="s"
title={
<span>
<span data-test-subj="alertSnapShotCount">
<FormattedMessage
id="xpack.synthetics.alerts.monitorStatus.monitorCallOut.title"
defaultMessage="This alert will apply to approximately {snapshotCount} monitors."

View file

@ -17,7 +17,7 @@ import React, { useState } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { CLIENT_ALERT_TYPES } from '../../../../../common/constants/alerts';
import { CLIENT_ALERT_TYPES } from '../../../../../common/constants/uptime_alerts';
import { ClientPluginsStart } from '../../../../plugin';
import { ToggleFlyoutTranslations } from './translations';

View file

@ -12,7 +12,7 @@ import { TriggersAndActionsUIPublicPluginStart } from '@kbn/triggers-actions-ui-
interface Props {
alertFlyoutVisible: boolean;
alertTypeId?: string;
setAlertFlyoutVisibility: React.Dispatch<React.SetStateAction<boolean>>;
setAlertFlyoutVisibility: (value: boolean) => void;
}
interface KibanaDeps {

View file

@ -13,7 +13,7 @@ import type { CoreTheme } from '@kbn/core/public';
import { EuiLink, EuiSpacer, EuiText } from '@elastic/eui';
import { RedirectAppLinks, toMountPoint } from '@kbn/kibana-react-plugin/public';
import type { Rule } from '@kbn/triggers-actions-ui-plugin/public';
import { ActionConnector } from '../../state/alerts/alerts';
import { ActionConnector } from '../../../../common/rules/types';
import { kibanaService } from '../../state/kibana_service';
import { getUrlForAlert } from './common';

View file

@ -13,7 +13,7 @@ import { ALERT_END, ALERT_STATUS, ALERT_STATUS_ACTIVE, ALERT_REASON } from '@kbn
import { ObservabilityRuleTypeModel } from '@kbn/observability-plugin/public';
import { AlertTypeInitializer } from '.';
import { getMonitorRouteFromMonitorId } from '../../../../common/utils/get_monitor_url';
import { CLIENT_ALERT_TYPES } from '../../../../common/constants/alerts';
import { CLIENT_ALERT_TYPES } from '../../../../common/constants/uptime_alerts';
import { DurationAnomalyTranslations } from '../../../../common/translations';
const { defaultActionMessage, defaultRecoveryMessage, description } = DurationAnomalyTranslations;

View file

@ -14,12 +14,12 @@ import { initTlsLegacyAlertType } from './tls_legacy';
import { ClientPluginsStart } from '../../../plugin';
import { initDurationAnomalyAlertType } from './duration_anomaly';
export type AlertTypeInitializer<TAlertTypeModel = ObservabilityRuleTypeModel> = (dependenies: {
export type AlertTypeInitializer<TAlertTypeModel = ObservabilityRuleTypeModel> = (dependencies: {
core: CoreStart;
plugins: ClientPluginsStart;
}) => TAlertTypeModel;
export const alertTypeInitializers: AlertTypeInitializer[] = [
export const uptimeAlertTypeInitializers: AlertTypeInitializer[] = [
initMonitorStatusAlertType,
initTlsAlertType,
initDurationAnomalyAlertType,

View file

@ -202,7 +202,7 @@ describe('monitor status alert type', () => {
})
).toMatchInlineSnapshot(`
Object {
"defaultActionMessage": "Monitor {{context.monitorName}} with url {{{context.monitorUrl}}} from {{context.observerLocation}} {{{context.statusMessage}}} The latest error message is {{{context.latestErrorMessage}}}",
"defaultActionMessage": "Monitor {{context.monitorName}} with url {{{context.monitorUrl}}} from {{context.observerLocation}} {{{context.statusMessage}}} The latest error message is {{{context.latestErrorMessage}}}, checked at {{context.checkedAt}}",
"defaultRecoveryMessage": "Alert for monitor {{context.monitorName}} with url {{{context.monitorUrl}}} from {{context.observerLocation}} has recovered",
"description": "Alert when a monitor is down or an availability threshold is breached.",
"documentationUrl": [Function],

View file

@ -21,7 +21,7 @@ import { ValidationResult } from '@kbn/triggers-actions-ui-plugin/public';
import { AlertTypeInitializer } from '.';
import { getMonitorRouteFromMonitorId } from '../../../../common/utils/get_monitor_url';
import { MonitorStatusTranslations } from '../../../../common/translations';
import { CLIENT_ALERT_TYPES } from '../../../../common/constants/alerts';
import { CLIENT_ALERT_TYPES } from '../../../../common/constants/uptime_alerts';
const { defaultActionMessage, defaultRecoveryMessage, description } = MonitorStatusTranslations;

View file

@ -8,7 +8,7 @@
import React from 'react';
import { ALERT_REASON } from '@kbn/rule-data-utils';
import { ObservabilityRuleTypeModel } from '@kbn/observability-plugin/public';
import { CLIENT_ALERT_TYPES } from '../../../../common/constants/alerts';
import { CLIENT_ALERT_TYPES } from '../../../../common/constants/uptime_alerts';
import { TlsTranslations } from '../../../../common/translations';
import { AlertTypeInitializer } from '.';

View file

@ -7,7 +7,7 @@
import React from 'react';
import { RuleTypeModel } from '@kbn/triggers-actions-ui-plugin/public';
import { CLIENT_ALERT_TYPES } from '../../../../common/constants/alerts';
import { CLIENT_ALERT_TYPES } from '../../../../common/constants/uptime_alerts';
import { TlsTranslationsLegacy } from '../../../../common/translations';
import { AlertTypeInitializer } from '.';

View file

@ -8,10 +8,8 @@
import { i18n } from '@kbn/i18n';
import { handleActions, Action } from 'redux-actions';
import { call, put, select, takeLatest } from 'redux-saga/effects';
import type {
ActionConnector as RawActionConnector,
Rule,
} from '@kbn/triggers-actions-ui-plugin/public';
import type { Rule } from '@kbn/triggers-actions-ui-plugin/public';
import { ActionConnector } from '../../../../common/rules/types';
import { createAsyncAction } from '../actions/utils';
import { asyncInitState, handleAsyncAction } from '../reducers/utils';
import type { AppState } from '..';
@ -30,8 +28,6 @@ import { monitorIdSelector } from '../selectors';
import { AlertsResult, MonitorIdParam } from '../actions/types';
import { simpleAlertEnabled } from '../../lib/alert_types/alert_messages';
export type ActionConnector = Omit<RawActionConnector, 'secrets'>;
/**
* TODO: Use actual AlertType Params type that's specific to Uptime instead of `any`
*/

View file

@ -7,15 +7,16 @@
import type { ActionType, AsApiContract, Rule } from '@kbn/triggers-actions-ui-plugin/public';
import { RuleTypeParams } from '@kbn/alerting-plugin/common';
import { CLIENT_ALERT_TYPES } from '../../../../common/constants/alerts';
import { MonitorStatusTranslations } from '../../../../common/translations';
import { ActionConnector } from '../../../../common/rules/types';
import { CLIENT_ALERT_TYPES, MONITOR_STATUS } from '../../../../common/constants/uptime_alerts';
import { apiService } from './utils';
import { ActionConnector } from '../alerts/alerts';
import { AlertsResult, MonitorIdParam } from '../actions/types';
import { API_URLS } from '../../../../common/constants';
import { AtomicStatusCheckParams } from '../../../../common/runtime_types/alerts';
import { populateAlertActions, RuleAction } from './alert_actions';
import { populateAlertActions, RuleAction } from '../../../../common/rules/alert_actions';
import { Ping } from '../../../../common/runtime_types/ping';
import { DefaultEmail } from '../../../../common/runtime_types';
@ -79,8 +80,13 @@ export const createAlert = async ({
}: NewAlertParams): Promise<Rule> => {
const actions: RuleAction[] = populateAlertActions({
defaultActions,
selectedMonitor,
defaultEmail,
groupId: MONITOR_STATUS.id,
translations: {
defaultActionMessage: MonitorStatusTranslations.defaultActionMessage,
defaultRecoveryMessage: MonitorStatusTranslations.defaultRecoveryMessage,
defaultSubjectMessage: MonitorStatusTranslations.defaultSubjectMessage,
},
});
const data: NewMonitorStatusAlert = {

View file

@ -52,12 +52,13 @@ import {
import { LazySyntheticsCustomAssetsExtension } from './legacy_uptime/components/fleet_package/lazy_synthetics_custom_assets_extension';
import { uptimeOverviewNavigatorParams } from './apps/locators/overview';
import {
alertTypeInitializers,
uptimeAlertTypeInitializers,
legacyAlertTypeInitializers,
} from './legacy_uptime/lib/alert_types';
import { monitorDetailNavigatorParams } from './apps/locators/monitor_detail';
import { editMonitorNavigatorParams } from './apps/locators/edit_monitor';
import { setStartServices } from './kibana_services';
import { syntheticsAlertTypeInitializers } from './apps/synthetics/lib/alert_types';
export interface ClientPluginsSetup {
home?: HomePublicPluginSetup;
@ -69,7 +70,7 @@ export interface ClientPluginsSetup {
}
export interface ClientPluginsStart {
fleet?: FleetStart;
fleet: FleetStart;
data: DataPublicPluginStart;
unifiedSearch: UnifiedSearchPublicPluginStart;
discover: DiscoverStart;
@ -145,35 +146,7 @@ export class UptimePlugin
registerUptimeRoutesWithNavigation(core, plugins);
const { observabilityRuleTypeRegistry } = plugins.observability;
core.getStartServices().then(([coreStart, clientPluginsStart]) => {
alertTypeInitializers.forEach((init) => {
const alertInitializer = init({
core: coreStart,
plugins: clientPluginsStart,
});
if (
clientPluginsStart.triggersActionsUi &&
!clientPluginsStart.triggersActionsUi.ruleTypeRegistry.has(alertInitializer.id)
) {
observabilityRuleTypeRegistry.register(alertInitializer);
}
});
legacyAlertTypeInitializers.forEach((init) => {
const alertInitializer = init({
core: coreStart,
plugins: clientPluginsStart,
});
if (
clientPluginsStart.triggersActionsUi &&
!clientPluginsStart.triggersActionsUi.ruleTypeRegistry.has(alertInitializer.id)
) {
plugins.triggersActionsUi.ruleTypeRegistry.register(alertInitializer);
}
});
});
core.getStartServices().then(([coreStart, clientPluginsStart]) => {});
const appKeywords = [
'Synthetics',
@ -236,30 +209,46 @@ export class UptimePlugin
}
}
public start(start: CoreStart, plugins: ClientPluginsStart): void {
if (plugins.fleet) {
const { registerExtension } = plugins.fleet;
setStartServices(start);
public start(coreStart: CoreStart, pluginsStart: ClientPluginsStart): void {
const { triggersActionsUi } = pluginsStart;
registerExtension({
package: 'synthetics',
view: 'package-policy-create',
Component: LazySyntheticsPolicyCreateExtension,
});
const { registerExtension } = pluginsStart.fleet;
setStartServices(coreStart);
registerUptimeFleetExtensions(registerExtension);
registerExtension({
package: 'synthetics',
view: 'package-policy-edit',
useLatestPackageVersion: true,
Component: LazySyntheticsPolicyEditExtension,
});
syntheticsAlertTypeInitializers.forEach((init) => {
const { observabilityRuleTypeRegistry } = pluginsStart.observability;
registerExtension({
package: 'synthetics',
view: 'package-detail-assets',
Component: LazySyntheticsCustomAssetsExtension,
const alertInitializer = init({
core: coreStart,
plugins: pluginsStart,
});
}
if (!triggersActionsUi.ruleTypeRegistry.has(alertInitializer.id)) {
observabilityRuleTypeRegistry.register(alertInitializer);
}
});
uptimeAlertTypeInitializers.forEach((init) => {
const { observabilityRuleTypeRegistry } = pluginsStart.observability;
const alertInitializer = init({
core: coreStart,
plugins: pluginsStart,
});
if (!triggersActionsUi.ruleTypeRegistry.has(alertInitializer.id)) {
observabilityRuleTypeRegistry.register(alertInitializer);
}
});
legacyAlertTypeInitializers.forEach((init) => {
const alertInitializer = init({
core: coreStart,
plugins: pluginsStart,
});
if (!triggersActionsUi.ruleTypeRegistry.has(alertInitializer.id)) {
triggersActionsUi.ruleTypeRegistry.register(alertInitializer);
}
});
}
public stop(): void {}
@ -338,3 +327,24 @@ function registerUptimeRoutesWithNavigation(
)
);
}
function registerUptimeFleetExtensions(registerExtension: FleetStart['registerExtension']) {
registerExtension({
package: 'synthetics',
view: 'package-policy-create',
Component: LazySyntheticsPolicyCreateExtension,
});
registerExtension({
package: 'synthetics',
view: 'package-policy-edit',
useLatestPackageVersion: true,
Component: LazySyntheticsPolicyEditExtension,
});
registerExtension({
package: 'synthetics',
view: 'package-detail-assets',
Component: LazySyntheticsCustomAssetsExtension,
});
}

View file

@ -27,6 +27,11 @@ const { argv } = yargs(process.argv.slice(2))
type: 'boolean',
description: 'Opens the Synthetics Test Runner',
})
.option('watch', {
default: false,
type: 'boolean',
description: 'Runs the server in watch mode, restarting on changes',
})
.option('pauseOnError', {
default: false,
type: 'boolean',

View file

@ -0,0 +1,80 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { UptimeCorePluginsSetup } from '../legacy_uptime/lib/adapters';
import { commonMonitorStateI18, commonStateTranslations } from './translations';
export const MESSAGE = 'message';
export const ALERT_REASON_MSG = 'reason';
export const ALERT_DETAILS_URL = 'alertDetailsUrl';
export const VIEW_IN_APP_URL = 'viewInAppUrl';
export const RECOVERY_REASON = 'recoveryReason';
export const getActionVariables = ({ plugins }: { plugins: UptimeCorePluginsSetup }) => {
return {
context: [
ACTION_VARIABLES[MESSAGE],
...(plugins.observability.getAlertDetailsConfig()?.uptime.enabled
? [ACTION_VARIABLES[ALERT_DETAILS_URL]]
: []),
ACTION_VARIABLES[ALERT_REASON_MSG],
ACTION_VARIABLES[VIEW_IN_APP_URL],
ACTION_VARIABLES[RECOVERY_REASON],
...commonMonitorStateI18,
],
state: [...commonStateTranslations],
};
};
export const ACTION_VARIABLES = {
[MESSAGE]: {
name: MESSAGE,
description: i18n.translate(
'xpack.synthetics.alertRules.monitorStatus.actionVariables.context.message.description',
{
defaultMessage: 'A generated message summarizing the status of monitors currently down',
}
),
},
[ALERT_REASON_MSG]: {
name: ALERT_REASON_MSG,
description: i18n.translate(
'xpack.synthetics.alertRules.monitorStatus.actionVariables.context.alertReasonMessage.description',
{
defaultMessage: 'A concise description of the reason for the alert',
}
),
},
[ALERT_DETAILS_URL]: {
name: ALERT_DETAILS_URL,
description: i18n.translate(
'xpack.synthetics.alertRules.monitorStatus.actionVariables.context.alertDetailUrl.description',
{
defaultMessage: 'Link to a view showing further details and context on this alert',
}
),
},
[VIEW_IN_APP_URL]: {
name: VIEW_IN_APP_URL,
description: i18n.translate(
'xpack.synthetics.alertRules.monitorStatus.actionVariables.context.viewInAppUrl.description',
{
defaultMessage: 'Open alert details and context in Synthetics app.',
}
),
},
[RECOVERY_REASON]: {
name: RECOVERY_REASON,
description: i18n.translate(
'xpack.synthetics.alertRules.monitorStatus.actionVariables.context.recoveryReason.description',
{
defaultMessage: 'A concise description of the reason for the recovery',
}
),
},
};

View file

@ -0,0 +1,182 @@
/*
* 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 { updateState } from './common';
import { SyntheticsCommonState } from '../../common/runtime_types/alert_rules/common';
describe('updateState', () => {
let spy: jest.SpyInstance<string, []>;
beforeEach(() => {
spy = jest.spyOn(Date.prototype, 'toISOString');
});
afterEach(() => {
jest.clearAllMocks();
});
it('sets initial state values', () => {
spy.mockImplementation(() => 'foo date string');
const result = updateState({} as SyntheticsCommonState, false);
expect(spy).toHaveBeenCalledTimes(1);
expect(result).toMatchInlineSnapshot(`
Object {
"firstCheckedAt": "foo date string",
"firstTriggeredAt": undefined,
"isTriggered": false,
"lastCheckedAt": "foo date string",
"lastResolvedAt": undefined,
"lastTriggeredAt": undefined,
"meta": Object {},
}
`);
});
it('updates the correct field in subsequent calls', () => {
spy
.mockImplementationOnce(() => 'first date string')
.mockImplementationOnce(() => 'second date string');
const firstState = updateState({} as SyntheticsCommonState, false);
const secondState = updateState(firstState, true);
expect(spy).toHaveBeenCalledTimes(2);
expect(firstState).toMatchInlineSnapshot(`
Object {
"firstCheckedAt": "first date string",
"firstTriggeredAt": undefined,
"isTriggered": false,
"lastCheckedAt": "first date string",
"lastResolvedAt": undefined,
"lastTriggeredAt": undefined,
"meta": Object {},
}
`);
expect(secondState).toMatchInlineSnapshot(`
Object {
"firstCheckedAt": "first date string",
"firstTriggeredAt": "second date string",
"isTriggered": true,
"lastCheckedAt": "second date string",
"lastResolvedAt": undefined,
"lastTriggeredAt": "second date string",
"meta": undefined,
}
`);
});
it('correctly marks resolution times', () => {
spy
.mockImplementationOnce(() => 'first date string')
.mockImplementationOnce(() => 'second date string')
.mockImplementationOnce(() => 'third date string');
const firstState = updateState({} as SyntheticsCommonState, true);
const secondState = updateState(firstState, true);
const thirdState = updateState(secondState, false);
expect(spy).toHaveBeenCalledTimes(3);
expect(firstState).toMatchInlineSnapshot(`
Object {
"firstCheckedAt": "first date string",
"firstTriggeredAt": "first date string",
"isTriggered": true,
"lastCheckedAt": "first date string",
"lastResolvedAt": undefined,
"lastTriggeredAt": "first date string",
"meta": Object {},
}
`);
expect(secondState).toMatchInlineSnapshot(`
Object {
"firstCheckedAt": "first date string",
"firstTriggeredAt": "first date string",
"isTriggered": true,
"lastCheckedAt": "second date string",
"lastResolvedAt": undefined,
"lastTriggeredAt": "second date string",
"meta": undefined,
}
`);
expect(thirdState).toMatchInlineSnapshot(`
Object {
"firstCheckedAt": "first date string",
"firstTriggeredAt": "first date string",
"isTriggered": false,
"lastCheckedAt": "third date string",
"lastResolvedAt": "third date string",
"lastTriggeredAt": "second date string",
"meta": undefined,
}
`);
});
it('correctly marks state fields across multiple triggers/resolutions', () => {
spy
.mockImplementationOnce(() => 'first date string')
.mockImplementationOnce(() => 'second date string')
.mockImplementationOnce(() => 'third date string')
.mockImplementationOnce(() => 'fourth date string')
.mockImplementationOnce(() => 'fifth date string');
const firstState = updateState({} as SyntheticsCommonState, false);
const secondState = updateState(firstState, true);
const thirdState = updateState(secondState, false);
const fourthState = updateState(thirdState, true);
const fifthState = updateState(fourthState, false);
expect(spy).toHaveBeenCalledTimes(5);
expect(firstState).toMatchInlineSnapshot(`
Object {
"firstCheckedAt": "first date string",
"firstTriggeredAt": undefined,
"isTriggered": false,
"lastCheckedAt": "first date string",
"lastResolvedAt": undefined,
"lastTriggeredAt": undefined,
"meta": Object {},
}
`);
expect(secondState).toMatchInlineSnapshot(`
Object {
"firstCheckedAt": "first date string",
"firstTriggeredAt": "second date string",
"isTriggered": true,
"lastCheckedAt": "second date string",
"lastResolvedAt": undefined,
"lastTriggeredAt": "second date string",
"meta": undefined,
}
`);
expect(thirdState).toMatchInlineSnapshot(`
Object {
"firstCheckedAt": "first date string",
"firstTriggeredAt": "second date string",
"isTriggered": false,
"lastCheckedAt": "third date string",
"lastResolvedAt": "third date string",
"lastTriggeredAt": "second date string",
"meta": undefined,
}
`);
expect(fourthState).toMatchInlineSnapshot(`
Object {
"firstCheckedAt": "first date string",
"firstTriggeredAt": "second date string",
"isTriggered": true,
"lastCheckedAt": "fourth date string",
"lastResolvedAt": "third date string",
"lastTriggeredAt": "fourth date string",
"meta": undefined,
}
`);
expect(fifthState).toMatchInlineSnapshot(`
Object {
"firstCheckedAt": "first date string",
"firstTriggeredAt": "second date string",
"isTriggered": false,
"lastCheckedAt": "fifth date string",
"lastResolvedAt": "fifth date string",
"lastTriggeredAt": "fourth date string",
"meta": undefined,
}
`);
});
});

View file

@ -0,0 +1,121 @@
/*
* 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 { isRight } from 'fp-ts/lib/Either';
import Mustache from 'mustache';
import { IBasePath } from '@kbn/core/server';
import { RuleExecutorServices } from '@kbn/alerting-plugin/server';
import { addSpaceIdToPath } from '@kbn/spaces-plugin/common';
import { i18n } from '@kbn/i18n';
import {
SyntheticsCommonState,
SyntheticsCommonStateType,
} from '../../common/runtime_types/alert_rules/common';
import { ALERT_DETAILS_URL, RECOVERY_REASON } from './action_variables';
import { AlertOverviewStatus } from './status_rule/status_rule_executor';
export const updateState = (
state: SyntheticsCommonState,
isTriggeredNow: boolean,
meta?: SyntheticsCommonState['meta']
): SyntheticsCommonState => {
const now = new Date().toISOString();
const decoded = SyntheticsCommonStateType.decode(state);
if (!isRight(decoded)) {
const triggerVal = isTriggeredNow ? now : undefined;
return {
firstCheckedAt: now,
firstTriggeredAt: triggerVal,
isTriggered: isTriggeredNow,
lastTriggeredAt: triggerVal,
lastCheckedAt: now,
lastResolvedAt: undefined,
meta: {},
};
}
const {
firstCheckedAt,
firstTriggeredAt,
lastTriggeredAt,
// this is the stale trigger status, we're naming it `wasTriggered`
// to differentiate it from the `isTriggeredNow` param
isTriggered: wasTriggered,
lastResolvedAt,
} = decoded.right;
return {
meta,
firstCheckedAt: firstCheckedAt ?? now,
firstTriggeredAt: isTriggeredNow && !firstTriggeredAt ? now : firstTriggeredAt,
lastCheckedAt: now,
lastTriggeredAt: isTriggeredNow ? now : lastTriggeredAt,
lastResolvedAt: !isTriggeredNow && wasTriggered ? now : lastResolvedAt,
isTriggered: isTriggeredNow,
};
};
export const generateAlertMessage = (messageTemplate: string, fields: Record<string, any>) => {
return Mustache.render(messageTemplate, { context: { ...fields }, state: { ...fields } });
};
export const getViewInAppUrl = (
basePath: IBasePath,
spaceId: string,
relativeViewInAppUrl: string
) => addSpaceIdToPath(basePath.publicBaseUrl, spaceId, relativeViewInAppUrl);
export const getAlertDetailsUrl = (
basePath: IBasePath,
spaceId: string,
alertUuid: string | null
) => addSpaceIdToPath(basePath.publicBaseUrl, spaceId, `/app/observability/alerts/${alertUuid}`);
export const setRecoveredAlertsContext = ({
alertFactory,
basePath,
getAlertUuid,
spaceId,
staleDownConfigs,
}: {
alertFactory: RuleExecutorServices['alertFactory'];
basePath?: IBasePath;
getAlertUuid?: (alertId: string) => string | null;
spaceId?: string;
staleDownConfigs: AlertOverviewStatus['staleDownConfigs'];
}) => {
const { getRecoveredAlerts } = alertFactory.done();
for (const alert of getRecoveredAlerts()) {
const recoveredAlertId = alert.getId();
const alertUuid = getAlertUuid?.(recoveredAlertId) || undefined;
const state = alert.getState() as SyntheticsCommonState;
let recoveryReason = '';
if (state?.idWithLocation && staleDownConfigs[state.idWithLocation]) {
const { idWithLocation } = state;
const downConfig = staleDownConfigs[idWithLocation];
if (downConfig.isDeleted) {
recoveryReason = i18n.translate('xpack.synthetics.alerts.monitorStatus.deleteMonitor', {
defaultMessage: `Monitor has been deleted`,
});
} else if (downConfig.isLocationRemoved) {
recoveryReason = i18n.translate('xpack.synthetics.alerts.monitorStatus.removedLocation', {
defaultMessage: `Location has been removed from the monitor`,
});
}
}
alert.setContext({
...state,
...(recoveryReason ? { [RECOVERY_REASON]: recoveryReason } : {}),
...(basePath && spaceId && alertUuid
? { [ALERT_DETAILS_URL]: getAlertDetailsUrl(basePath, spaceId, alertUuid) }
: {}),
});
}
};

Some files were not shown because too many files have changed in this diff Show more