mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Uptime] Add Alerting UI (#57919)
* WIP trying things. Add new alert type for Uptime. Add defensive checks to alert executor. Move status check code to dedicated adapter function. Clean up code. * Port adapter function to dedicated file. * WIP. * Working on parameter selection. * Selector expressions working. * Working on actions. * Change anchor prop for popovers. * Reference migrated alerting plugin. * Clean up code for draft. * Add button to expose flyout. Clean up some client code. * Add test for requests function, add support for filters. * Reorganize and clean up files. * Add location and filter support to monitor status request function. * Add tests for monitor status request function. * Specify default action group id in alert registration. * Extract repeated string value to a constant. * Move test file to server in NP plugin. * Update imports after NP migration. * Fix UI bug that caused incorrect location selections in alert creation. * Change alert expression language to clarify meaning. * Add ability for user to select timerange units. * Add code that fixes active item highlighting. * Add better default value for active index selection. * Introduce dedicated field number component. * Add message to status check alert. * Add tests for context message. * Formalize alert action group definitions. * Extract monitor id squashing from context message generator. * Write test for monitor ID uniqueness function. * Add alert state creator function and tests. * Update action group id value. * Add tests for alert factory and executor function. * Rename alert context props to be more domain-specific. * Clean up unnecessary type markup. * Clean up alert ui controls file. * Better organize new registration code. * Simplify some logic code. * Clean up bootstrap code. * Add unit tests for alert type. * Delete temporary test code from triggers_actions_ui. * Rename a test file. * Add some comments to annotate a file. * Add io-ts type checking to alert create validation and alert executor. * Add translation of plaintext content string. * Further simplify monitor status alert validation. * Add io-ts type checking to alert params. * Update a comment. * Prefer inline snapshots to more error-prone assertions. * Clean up and comment request function. * Rename a symbol. * Fix broken types in reducer file and add a test. * Fix a validation logic error and add tests. * Delete unused import. * Delete obsolete dependency. * Fix function call to have correct parameters. * Fixing some import weirdness. * Reintroduce accidentally-deleted code. * Delete unneeded require from legacy entry file. * Remove unneeded connected component. * Update flyout controls for new interface and delete connected components. * Remove unneeded require from app index file. * Introduce data-test-subj attributes to various components to assist with functional tests. * Introduce functional test helpers for alert flyout. * Add functional test arch and a test for alerting UI to ES SSL test suite. * Add explicit exports to module index. * Reorganize file to keep interfaces closer to their implementations. * Move create alert button to better position. * Clean up a file. * Update a functional test attribute, clean up a file, rename a selector, add tests. * Add a comment. * Make better default alert message, translate messages, add/update tests. * Fix broken type. * Update obsolete snapshot. * Introduce mock provider to tests and update snapshots. * Reduce a strange type to `any`. * Add alert flyout button connected component. * Add alert flyout wrapper connected component. * Create connected component for alert monitor status alert. * Clean up index files. * Update i18nrc file to cover translation in server plugin code. * Fix broken imports. * Update test snapshots. * Prefer more descriptive type. * Prefer more descriptive type. * Prefer built-in React propType to custom. * Prefer simpler validation. * Add whitespace to clean up file. * Extract function and write tests. * Simplify validation function. * Add navigate to alerting button. * Move context item inside the items list. * Clean up alert creation component. * Update type check parsing and error messaging, and update snapshot/test assertions. * Update broken snapshot. * Update README for running functional tests. * Update functional test service to reflect improved UX. * Fix broken type that resulted from a mistake during a merge resolution. * Add spacer between alert title and kuery bar. * Update the id and name of our alert type because it was never changed from placeholder value. * Rename alert keys. * Fix broken unit tests. * Add aria-labels to alert UI. * Implement design feedback. * Fix broken test snapshots. * Add missing props to unit tests to staisfy updated types. Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
parent
a0730f7951
commit
fcf439625b
59 changed files with 3245 additions and 44 deletions
|
@ -42,7 +42,7 @@
|
|||
"xpack.transform": "plugins/transform",
|
||||
"xpack.triggersActionsUI": "plugins/triggers_actions_ui",
|
||||
"xpack.upgradeAssistant": "plugins/upgrade_assistant",
|
||||
"xpack.uptime": "legacy/plugins/uptime",
|
||||
"xpack.uptime": ["plugins/uptime", "legacy/plugins/uptime"],
|
||||
"xpack.watcher": "plugins/watcher"
|
||||
},
|
||||
"translations": [
|
||||
|
|
|
@ -62,3 +62,13 @@ You can login with username `elastic` and password `changeme` by default.
|
|||
|
||||
If you want to freeze a UI or API test you can include an async call like `await new Promise(r => setTimeout(r, 1000 * 60))`
|
||||
to freeze the execution for 60 seconds if you need to click around or check things in the state that is loaded.
|
||||
|
||||
#### Running --ssl tests
|
||||
|
||||
Some of our tests require there to be an SSL connection between Kibana and Elasticsearch.
|
||||
|
||||
We can run these tests like described above, but with some special config.
|
||||
|
||||
`node scripts/functional_tests_server.js --config=test/functional_with_es_ssl/config.ts`
|
||||
|
||||
`node scripts/functional_test_runner.js --config=test/functional_with_es_ssl/config.ts`
|
||||
|
|
19
x-pack/legacy/plugins/uptime/common/constants/alerts.ts
Normal file
19
x-pack/legacy/plugins/uptime/common/constants/alerts.ts
Normal 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;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
interface ActionGroupDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
type ActionGroupDefinitions = Record<string, ActionGroupDefinition>;
|
||||
|
||||
export const ACTION_GROUP_DEFINITIONS: ActionGroupDefinitions = {
|
||||
MONITOR_STATUS: {
|
||||
id: 'xpack.uptime.alerts.actionGroups.monitorStatus',
|
||||
name: 'Uptime Down Monitor',
|
||||
},
|
||||
};
|
|
@ -4,6 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export { ACTION_GROUP_DEFINITIONS } from './alerts';
|
||||
export { CHART_FORMAT_LIMITS } from './chart_format_limits';
|
||||
export { CLIENT_DEFAULTS } from './client_defaults';
|
||||
export { CONTEXT_DEFAULTS } from './context_defaults';
|
||||
|
|
|
@ -6,5 +6,4 @@
|
|||
|
||||
export const INDEX_NAMES = {
|
||||
HEARTBEAT: 'heartbeat-8*',
|
||||
HEARTBEAT_STATES: 'heartbeat-states-8*',
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export {
|
||||
StatusCheckAlertStateType,
|
||||
StatusCheckAlertState,
|
||||
StatusCheckExecutorParamsType,
|
||||
StatusCheckExecutorParams,
|
||||
} from './status_check';
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import * as t from 'io-ts';
|
||||
|
||||
export const StatusCheckAlertStateType = t.intersection([
|
||||
t.partial({
|
||||
currentTriggerStarted: t.string,
|
||||
firstTriggeredAt: t.string,
|
||||
lastTriggeredAt: t.string,
|
||||
lastResolvedAt: t.string,
|
||||
}),
|
||||
t.type({
|
||||
firstCheckedAt: t.string,
|
||||
lastCheckedAt: t.string,
|
||||
isTriggered: t.boolean,
|
||||
}),
|
||||
]);
|
||||
|
||||
export type StatusCheckAlertState = t.TypeOf<typeof StatusCheckAlertStateType>;
|
||||
|
||||
export const StatusCheckExecutorParamsType = t.intersection([
|
||||
t.partial({
|
||||
filters: t.string,
|
||||
}),
|
||||
t.type({
|
||||
locations: t.array(t.string),
|
||||
numTimes: t.number,
|
||||
timerange: t.type({
|
||||
from: t.string,
|
||||
to: t.string,
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type StatusCheckExecutorParams = t.TypeOf<typeof StatusCheckExecutorParamsType>;
|
|
@ -4,6 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export * from './alerts';
|
||||
export * from './common';
|
||||
export * from './monitor';
|
||||
export * from './overview_filters';
|
||||
|
|
|
@ -14,7 +14,7 @@ export const uptime = (kibana: any) =>
|
|||
configPrefix: 'xpack.uptime',
|
||||
id: PLUGIN.ID,
|
||||
publicDir: resolve(__dirname, 'public'),
|
||||
require: ['kibana', 'elasticsearch', 'xpack_main'],
|
||||
require: ['alerting', 'kibana', 'elasticsearch', 'xpack_main'],
|
||||
uiExports: {
|
||||
app: {
|
||||
description: i18n.translate('xpack.uptime.pluginDescription', {
|
||||
|
|
|
@ -8,8 +8,9 @@ import { npSetup } from 'ui/new_platform';
|
|||
import { Plugin } from './plugin';
|
||||
import 'uiExports/embeddableFactories';
|
||||
|
||||
new Plugin({
|
||||
const plugin = new Plugin({
|
||||
opaqueId: Symbol('uptime'),
|
||||
env: {} as any,
|
||||
config: { get: () => ({} as any) },
|
||||
}).setup(npSetup);
|
||||
});
|
||||
plugin.setup(npSetup);
|
||||
|
|
|
@ -36,6 +36,7 @@ export class Plugin {
|
|||
public setup(setup: SetupObject) {
|
||||
const { core, plugins } = setup;
|
||||
const { home } = plugins;
|
||||
|
||||
home.featureCatalogue.register({
|
||||
category: FeatureCatalogueCategory.DATA,
|
||||
description: PLUGIN.DESCRIPTION,
|
||||
|
@ -45,6 +46,7 @@ export class Plugin {
|
|||
showOnHomePage: true,
|
||||
title: PLUGIN.TITLE,
|
||||
});
|
||||
|
||||
core.application.register({
|
||||
id: PLUGIN.ID,
|
||||
euiIconType: 'uptimeApp',
|
||||
|
|
|
@ -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;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { DataPublicPluginSetup } from 'src/plugins/data/public';
|
||||
import { selectMonitorStatusAlert } from '../../../state/selectors';
|
||||
import { AlertMonitorStatusComponent } from '../../functional/alerts/alert_monitor_status';
|
||||
|
||||
interface Props {
|
||||
autocomplete: DataPublicPluginSetup['autocomplete'];
|
||||
enabled: boolean;
|
||||
numTimes: number;
|
||||
setAlertParams: (key: string, value: any) => void;
|
||||
timerange: {
|
||||
from: string;
|
||||
to: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const AlertMonitorStatus = ({
|
||||
autocomplete,
|
||||
enabled,
|
||||
numTimes,
|
||||
setAlertParams,
|
||||
timerange,
|
||||
}: Props) => {
|
||||
const { filters, locations } = useSelector(selectMonitorStatusAlert);
|
||||
return (
|
||||
<AlertMonitorStatusComponent
|
||||
autocomplete={autocomplete}
|
||||
enabled={enabled}
|
||||
filters={filters}
|
||||
locations={locations}
|
||||
numTimes={numTimes}
|
||||
setAlertParams={setAlertParams}
|
||||
timerange={timerange}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export { AlertMonitorStatus } from './alert_monitor_status';
|
||||
export { ToggleAlertFlyoutButton } from './toggle_alert_flyout_button';
|
||||
export { UptimeAlertsFlyoutWrapper } from './uptime_alerts_flyout_wrapper';
|
|
@ -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;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { ToggleAlertFlyoutButtonComponent } from '../../functional';
|
||||
import { setAlertFlyoutVisible } from '../../../state/actions';
|
||||
|
||||
export const ToggleAlertFlyoutButton = () => {
|
||||
const dispatch = useDispatch();
|
||||
return (
|
||||
<ToggleAlertFlyoutButtonComponent
|
||||
setAlertFlyoutVisible={(value: boolean) => dispatch(setAlertFlyoutVisible(value))}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { UptimeAlertsFlyoutWrapperComponent } from '../../functional';
|
||||
import { setAlertFlyoutVisible } from '../../../state/actions';
|
||||
import { selectAlertFlyoutVisibility } from '../../../state/selectors';
|
||||
|
||||
interface Props {
|
||||
alertTypeId?: string;
|
||||
canChangeTrigger?: boolean;
|
||||
}
|
||||
|
||||
export const UptimeAlertsFlyoutWrapper = ({ alertTypeId, canChangeTrigger }: Props) => {
|
||||
const dispatch = useDispatch();
|
||||
const setAddFlyoutVisiblity = (value: React.SetStateAction<boolean>) =>
|
||||
// @ts-ignore the value here is a boolean, and it works with the action creator function
|
||||
dispatch(setAlertFlyoutVisible(value));
|
||||
|
||||
const alertFlyoutVisible = useSelector(selectAlertFlyoutVisibility);
|
||||
|
||||
return (
|
||||
<UptimeAlertsFlyoutWrapperComponent
|
||||
alertFlyoutVisible={alertFlyoutVisible}
|
||||
alertTypeId={alertTypeId}
|
||||
canChangeTrigger={canChangeTrigger}
|
||||
setAlertFlyoutVisibility={setAddFlyoutVisiblity}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -4,6 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export { AlertMonitorStatus, ToggleAlertFlyoutButton, UptimeAlertsFlyoutWrapper } from './alerts';
|
||||
export { PingHistogram } from './charts/ping_histogram';
|
||||
export { Snapshot } from './charts/snapshot_container';
|
||||
export { KueryBar } from './kuerybar/kuery_bar_container';
|
||||
|
|
|
@ -8,7 +8,7 @@ import { connect } from 'react-redux';
|
|||
import { AppState } from '../../../state';
|
||||
import { selectIndexPattern } from '../../../state/selectors';
|
||||
import { getIndexPattern } from '../../../state/actions';
|
||||
import { KueryBarComponent } from '../../functional';
|
||||
import { KueryBarComponent } from '../../functional/kuery_bar/kuery_bar';
|
||||
|
||||
const mapStateToProps = (state: AppState) => ({ ...selectIndexPattern(state) });
|
||||
|
||||
|
|
|
@ -0,0 +1,179 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
selectedLocationsToString,
|
||||
AlertFieldNumber,
|
||||
handleAlertFieldNumberChange,
|
||||
} from '../alert_monitor_status';
|
||||
import { mountWithIntl } from 'test_utils/enzyme_helpers';
|
||||
|
||||
describe('alert monitor status component', () => {
|
||||
describe('handleAlertFieldNumberChange', () => {
|
||||
let mockSetIsInvalid: jest.Mock<any, any>;
|
||||
let mockSetFieldValue: jest.Mock<any, any>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSetIsInvalid = jest.fn();
|
||||
mockSetFieldValue = jest.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('sets a valid number', () => {
|
||||
handleAlertFieldNumberChange(
|
||||
// @ts-ignore no need to implement this entire type here
|
||||
{ target: { value: '23' } },
|
||||
false,
|
||||
mockSetIsInvalid,
|
||||
mockSetFieldValue
|
||||
);
|
||||
expect(mockSetIsInvalid).not.toHaveBeenCalled();
|
||||
expect(mockSetFieldValue).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetFieldValue.mock.calls).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Array [
|
||||
23,
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('sets invalid for NaN value', () => {
|
||||
handleAlertFieldNumberChange(
|
||||
// @ts-ignore no need to implement this entire type here
|
||||
{ target: { value: 'foo' } },
|
||||
false,
|
||||
mockSetIsInvalid,
|
||||
mockSetFieldValue
|
||||
);
|
||||
expect(mockSetIsInvalid).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetIsInvalid.mock.calls).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Array [
|
||||
true,
|
||||
],
|
||||
]
|
||||
`);
|
||||
expect(mockSetFieldValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets invalid to false when a valid value is received and invalid is true', () => {
|
||||
handleAlertFieldNumberChange(
|
||||
// @ts-ignore no need to implement this entire type here
|
||||
{ target: { value: '23' } },
|
||||
true,
|
||||
mockSetIsInvalid,
|
||||
mockSetFieldValue
|
||||
);
|
||||
expect(mockSetIsInvalid).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetIsInvalid.mock.calls).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Array [
|
||||
false,
|
||||
],
|
||||
]
|
||||
`);
|
||||
expect(mockSetFieldValue).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetFieldValue.mock.calls).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Array [
|
||||
23,
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AlertFieldNumber', () => {
|
||||
it('responds with correct number value when a valid number is specified', () => {
|
||||
const mockValueHandler = jest.fn();
|
||||
const component = mountWithIntl(
|
||||
<AlertFieldNumber
|
||||
aria-label="test label"
|
||||
data-test-subj="foo"
|
||||
disabled={false}
|
||||
fieldValue={23}
|
||||
setFieldValue={mockValueHandler}
|
||||
/>
|
||||
);
|
||||
component.find('input').simulate('change', { target: { value: '45' } });
|
||||
expect(mockValueHandler).toHaveBeenCalled();
|
||||
expect(mockValueHandler.mock.calls).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Array [
|
||||
45,
|
||||
],
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('does not set an invalid number value', () => {
|
||||
const mockValueHandler = jest.fn();
|
||||
const component = mountWithIntl(
|
||||
<AlertFieldNumber
|
||||
aria-label="test label"
|
||||
data-test-subj="foo"
|
||||
disabled={false}
|
||||
fieldValue={23}
|
||||
setFieldValue={mockValueHandler}
|
||||
/>
|
||||
);
|
||||
component.find('input').simulate('change', { target: { value: 'not a number' } });
|
||||
expect(mockValueHandler).not.toHaveBeenCalled();
|
||||
expect(mockValueHandler.mock.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('does not set a number value less than 1', () => {
|
||||
const mockValueHandler = jest.fn();
|
||||
const component = mountWithIntl(
|
||||
<AlertFieldNumber
|
||||
aria-label="test label"
|
||||
data-test-subj="foo"
|
||||
disabled={false}
|
||||
fieldValue={23}
|
||||
setFieldValue={mockValueHandler}
|
||||
/>
|
||||
);
|
||||
component.find('input').simulate('change', { target: { value: '0' } });
|
||||
expect(mockValueHandler).not.toHaveBeenCalled();
|
||||
expect(mockValueHandler.mock.calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectedLocationsToString', () => {
|
||||
it('generates a formatted string for a valid list of options', () => {
|
||||
const locations = [
|
||||
{
|
||||
checked: 'on',
|
||||
label: 'fairbanks',
|
||||
},
|
||||
{
|
||||
checked: 'on',
|
||||
label: 'harrisburg',
|
||||
},
|
||||
{
|
||||
checked: undefined,
|
||||
label: 'orlando',
|
||||
},
|
||||
];
|
||||
expect(selectedLocationsToString(locations)).toEqual('fairbanks, harrisburg');
|
||||
});
|
||||
|
||||
it('generates a formatted string for a single item', () => {
|
||||
expect(selectedLocationsToString([{ checked: 'on', label: 'fairbanks' }])).toEqual(
|
||||
'fairbanks'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an empty string when no valid options are available', () => {
|
||||
expect(selectedLocationsToString([{ checked: 'off', label: 'harrisburg' }])).toEqual('');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,431 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
EuiExpression,
|
||||
EuiFieldNumber,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiPopover,
|
||||
EuiSelectable,
|
||||
EuiSpacer,
|
||||
EuiSwitch,
|
||||
EuiTitle,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { DataPublicPluginSetup } from 'src/plugins/data/public';
|
||||
import { KueryBar } from '../../connected/kuerybar/kuery_bar_container';
|
||||
|
||||
interface AlertFieldNumberProps {
|
||||
'aria-label': string;
|
||||
'data-test-subj': string;
|
||||
disabled: boolean;
|
||||
fieldValue: number;
|
||||
setFieldValue: React.Dispatch<React.SetStateAction<number>>;
|
||||
}
|
||||
|
||||
export const handleAlertFieldNumberChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
isInvalid: boolean,
|
||||
setIsInvalid: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
setFieldValue: React.Dispatch<React.SetStateAction<number>>
|
||||
) => {
|
||||
const num = parseInt(e.target.value, 10);
|
||||
if (isNaN(num) || num < 1) {
|
||||
setIsInvalid(true);
|
||||
} else {
|
||||
if (isInvalid) setIsInvalid(false);
|
||||
setFieldValue(num);
|
||||
}
|
||||
};
|
||||
|
||||
export const AlertFieldNumber = ({
|
||||
'aria-label': ariaLabel,
|
||||
'data-test-subj': dataTestSubj,
|
||||
disabled,
|
||||
fieldValue,
|
||||
setFieldValue,
|
||||
}: AlertFieldNumberProps) => {
|
||||
const [isInvalid, setIsInvalid] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<EuiFieldNumber
|
||||
aria-label={ariaLabel}
|
||||
compressed
|
||||
data-test-subj={dataTestSubj}
|
||||
min={1}
|
||||
onChange={e => handleAlertFieldNumberChange(e, isInvalid, setIsInvalid, setFieldValue)}
|
||||
disabled={disabled}
|
||||
value={fieldValue}
|
||||
isInvalid={isInvalid}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface AlertExpressionPopoverProps {
|
||||
'aria-label': string;
|
||||
content: React.ReactElement;
|
||||
description: string;
|
||||
'data-test-subj': string;
|
||||
id: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const AlertExpressionPopover: React.FC<AlertExpressionPopoverProps> = ({
|
||||
'aria-label': ariaLabel,
|
||||
content,
|
||||
'data-test-subj': dataTestSubj,
|
||||
description,
|
||||
id,
|
||||
value,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
return (
|
||||
<EuiPopover
|
||||
id={id}
|
||||
anchorPosition="downLeft"
|
||||
button={
|
||||
<EuiExpression
|
||||
aria-label={ariaLabel}
|
||||
color={isOpen ? 'primary' : 'secondary'}
|
||||
data-test-subj={dataTestSubj}
|
||||
description={description}
|
||||
isActive={isOpen}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
value={value}
|
||||
/>
|
||||
}
|
||||
isOpen={isOpen}
|
||||
closePopover={() => setIsOpen(false)}
|
||||
>
|
||||
{content}
|
||||
</EuiPopover>
|
||||
);
|
||||
};
|
||||
|
||||
export const selectedLocationsToString = (selectedLocations: any[]) =>
|
||||
// create a nicely-formatted description string for all `on` locations
|
||||
selectedLocations
|
||||
.filter(({ checked }) => checked === 'on')
|
||||
.map(({ label }) => label)
|
||||
.sort()
|
||||
.reduce((acc, cur) => {
|
||||
if (acc === '') {
|
||||
return cur;
|
||||
}
|
||||
return acc + `, ${cur}`;
|
||||
}, '');
|
||||
|
||||
interface AlertMonitorStatusProps {
|
||||
autocomplete: DataPublicPluginSetup['autocomplete'];
|
||||
enabled: boolean;
|
||||
filters: string;
|
||||
locations: string[];
|
||||
numTimes: number;
|
||||
setAlertParams: (key: string, value: any) => void;
|
||||
timerange: {
|
||||
from: string;
|
||||
to: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const AlertMonitorStatusComponent: React.FC<AlertMonitorStatusProps> = props => {
|
||||
const { filters, locations } = props;
|
||||
const [numTimes, setNumTimes] = useState<number>(5);
|
||||
const [numMins, setNumMins] = useState<number>(15);
|
||||
const [allLabels, setAllLabels] = useState<boolean>(true);
|
||||
|
||||
// locations is an array of `Option[]`, but that type doesn't seem to be exported by EUI
|
||||
const [selectedLocations, setSelectedLocations] = useState<any[]>(
|
||||
locations.map(location => ({
|
||||
'aria-label': i18n.translate('xpack.uptime.alerts.locationSelectionItem.ariaLabel', {
|
||||
defaultMessage: 'Location selection item for "{location}"',
|
||||
values: {
|
||||
location,
|
||||
},
|
||||
}),
|
||||
disabled: allLabels,
|
||||
label: location,
|
||||
}))
|
||||
);
|
||||
const [timerangeUnitOptions, setTimerangeUnitOptions] = useState<any[]>([
|
||||
{
|
||||
'aria-label': i18n.translate(
|
||||
'xpack.uptime.alerts.timerangeUnitSelectable.secondsOption.ariaLabel',
|
||||
{
|
||||
defaultMessage: '"Seconds" time range select item',
|
||||
}
|
||||
),
|
||||
'data-test-subj': 'xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.secondsOption',
|
||||
key: 's',
|
||||
label: i18n.translate('xpack.uptime.alerts.monitorStatus.timerangeOption.seconds', {
|
||||
defaultMessage: 'seconds',
|
||||
}),
|
||||
},
|
||||
{
|
||||
'aria-label': i18n.translate(
|
||||
'xpack.uptime.alerts.timerangeUnitSelectable.minutesOption.ariaLabel',
|
||||
{
|
||||
defaultMessage: '"Minutes" time range select item',
|
||||
}
|
||||
),
|
||||
'data-test-subj': 'xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.minutesOption',
|
||||
checked: 'on',
|
||||
key: 'm',
|
||||
label: i18n.translate('xpack.uptime.alerts.monitorStatus.timerangeOption.minutes', {
|
||||
defaultMessage: 'minutes',
|
||||
}),
|
||||
},
|
||||
{
|
||||
'aria-label': i18n.translate(
|
||||
'xpack.uptime.alerts.timerangeUnitSelectable.hoursOption.ariaLabel',
|
||||
{
|
||||
defaultMessage: '"Hours" time range select item',
|
||||
}
|
||||
),
|
||||
'data-test-subj': 'xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.hoursOption',
|
||||
key: 'h',
|
||||
label: i18n.translate('xpack.uptime.alerts.monitorStatus.timerangeOption.hours', {
|
||||
defaultMessage: 'hours',
|
||||
}),
|
||||
},
|
||||
{
|
||||
'aria-label': i18n.translate(
|
||||
'xpack.uptime.alerts.timerangeUnitSelectable.daysOption.ariaLabel',
|
||||
{
|
||||
defaultMessage: '"Days" time range select item',
|
||||
}
|
||||
),
|
||||
'data-test-subj': 'xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.daysOption',
|
||||
key: 'd',
|
||||
label: i18n.translate('xpack.uptime.alerts.monitorStatus.timerangeOption.days', {
|
||||
defaultMessage: 'days',
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
const { setAlertParams } = props;
|
||||
|
||||
useEffect(() => {
|
||||
setAlertParams('numTimes', numTimes);
|
||||
}, [numTimes, setAlertParams]);
|
||||
|
||||
useEffect(() => {
|
||||
const timerangeUnit = timerangeUnitOptions.find(({ checked }) => checked === 'on')?.key ?? 'm';
|
||||
setAlertParams('timerange', { from: `now-${numMins}${timerangeUnit}`, to: 'now' });
|
||||
}, [numMins, timerangeUnitOptions, setAlertParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (allLabels) {
|
||||
setAlertParams('locations', []);
|
||||
} else {
|
||||
setAlertParams(
|
||||
'locations',
|
||||
selectedLocations.filter(l => l.checked === 'on').map(l => l.label)
|
||||
);
|
||||
}
|
||||
}, [selectedLocations, setAlertParams, allLabels]);
|
||||
|
||||
useEffect(() => {
|
||||
setAlertParams('filters', filters);
|
||||
}, [filters, setAlertParams]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiSpacer size="m" />
|
||||
<KueryBar
|
||||
aria-label={i18n.translate('xpack.uptime.alerts.monitorStatus.filterBar.ariaLabel', {
|
||||
defaultMessage: 'Input that allows filtering criteria for the monitor status alert',
|
||||
})}
|
||||
autocomplete={props.autocomplete}
|
||||
data-test-subj="xpack.uptime.alerts.monitorStatus.filterBar"
|
||||
/>
|
||||
<EuiSpacer size="s" />
|
||||
<AlertExpressionPopover
|
||||
aria-label={i18n.translate(
|
||||
'xpack.uptime.alerts.monitorStatus.numTimesExpression.ariaLabel',
|
||||
{
|
||||
defaultMessage: 'Open the popover for down count input',
|
||||
}
|
||||
)}
|
||||
content={
|
||||
<AlertFieldNumber
|
||||
aria-label={i18n.translate(
|
||||
'xpack.uptime.alerts.monitorStatus.numTimesField.ariaLabel',
|
||||
{
|
||||
defaultMessage: 'Enter number of down counts required to trigger the alert',
|
||||
}
|
||||
)}
|
||||
data-test-subj="xpack.uptime.alerts.monitorStatus.numTimesField"
|
||||
disabled={false}
|
||||
fieldValue={numTimes}
|
||||
setFieldValue={setNumTimes}
|
||||
/>
|
||||
}
|
||||
data-test-subj="xpack.uptime.alerts.monitorStatus.numTimesExpression"
|
||||
description="any monitor is down >"
|
||||
id="ping-count"
|
||||
value={`${numTimes} times`}
|
||||
/>
|
||||
<EuiSpacer size="xs" />
|
||||
<EuiFlexGroup gutterSize="s">
|
||||
<EuiFlexItem grow={false}>
|
||||
<AlertExpressionPopover
|
||||
aria-label={i18n.translate(
|
||||
'xpack.uptime.alerts.monitorStatus.timerangeValueExpression.ariaLabel',
|
||||
{
|
||||
defaultMessage: 'Open the popover for time range value field',
|
||||
}
|
||||
)}
|
||||
content={
|
||||
<AlertFieldNumber
|
||||
aria-label={i18n.translate(
|
||||
'xpack.uptime.alerts.monitorStatus.timerangeValueField.ariaLabel',
|
||||
{
|
||||
defaultMessage: `Enter the number of time units for the alert's range`,
|
||||
}
|
||||
)}
|
||||
data-test-subj="xpack.uptime.alerts.monitorStatus.timerangeValueField"
|
||||
disabled={false}
|
||||
fieldValue={numMins}
|
||||
setFieldValue={setNumMins}
|
||||
/>
|
||||
}
|
||||
data-test-subj="xpack.uptime.alerts.monitorStatus.timerangeValueExpression"
|
||||
description="within"
|
||||
id="timerange"
|
||||
value={`last ${numMins}`}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<AlertExpressionPopover
|
||||
aria-label={i18n.translate(
|
||||
'xpack.uptime.alerts.monitorStatus.timerangeUnitExpression.ariaLabel',
|
||||
{
|
||||
defaultMessage: 'Open the popover for time range unit select field',
|
||||
}
|
||||
)}
|
||||
content={
|
||||
<>
|
||||
<EuiTitle size="xxs">
|
||||
<h5>
|
||||
<FormattedMessage
|
||||
id="xpack.uptime.alerts.monitorStatus.timerangeSelectionHeader"
|
||||
defaultMessage="Select time range unit"
|
||||
/>
|
||||
</h5>
|
||||
</EuiTitle>
|
||||
<EuiSelectable
|
||||
aria-label={i18n.translate(
|
||||
'xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable',
|
||||
{
|
||||
defaultMessage: 'Selectable field for the time range units alerts should use',
|
||||
}
|
||||
)}
|
||||
data-test-subj="xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable"
|
||||
options={timerangeUnitOptions}
|
||||
onChange={newOptions => {
|
||||
if (newOptions.reduce((acc, { checked }) => acc || checked === 'on', false)) {
|
||||
setTimerangeUnitOptions(newOptions);
|
||||
}
|
||||
}}
|
||||
singleSelection={true}
|
||||
listProps={{
|
||||
showIcons: true,
|
||||
}}
|
||||
>
|
||||
{list => list}
|
||||
</EuiSelectable>
|
||||
</>
|
||||
}
|
||||
data-test-subj="xpack.uptime.alerts.monitorStatus.timerangeUnitExpression"
|
||||
description=""
|
||||
id="timerange-unit"
|
||||
value={
|
||||
timerangeUnitOptions.find(({ checked }) => checked === 'on')?.label.toLowerCase() ??
|
||||
''
|
||||
}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="xs" />
|
||||
{selectedLocations.length === 0 && (
|
||||
<EuiExpression
|
||||
color="secondary"
|
||||
data-test-subj="xpack.uptime.alerts.monitorStatus.locationsEmpty"
|
||||
description="in"
|
||||
isActive={false}
|
||||
value="all locations"
|
||||
/>
|
||||
)}
|
||||
{selectedLocations.length > 0 && (
|
||||
<AlertExpressionPopover
|
||||
aria-label={i18n.translate(
|
||||
'xpack.uptime.alerts.monitorStatus.locationsSelectionExpression.ariaLabel',
|
||||
{
|
||||
defaultMessage: 'Open the popover to select locations the alert should trigger',
|
||||
}
|
||||
)}
|
||||
content={
|
||||
<EuiFlexGroup direction="column">
|
||||
<EuiFlexItem>
|
||||
<EuiSwitch
|
||||
aria-label={i18n.translate(
|
||||
'xpack.uptime.alerts.monitorStatus.locationSelectionSwitch.ariaLabel',
|
||||
{
|
||||
defaultMessage: 'Select the locations the alert should trigger',
|
||||
}
|
||||
)}
|
||||
data-test-subj="xpack.uptime.alerts.monitorStatus.locationsSelectionSwitch"
|
||||
label="Check all locations"
|
||||
checked={allLabels}
|
||||
onChange={() => {
|
||||
setAllLabels(!allLabels);
|
||||
setSelectedLocations(
|
||||
selectedLocations.map((l: any) => ({
|
||||
'aria-label': i18n.translate(
|
||||
'xpack.uptime.alerts.monitorStatus.locationSelection',
|
||||
{
|
||||
defaultMessage: 'Select the location {location}',
|
||||
values: {
|
||||
location: l,
|
||||
},
|
||||
}
|
||||
),
|
||||
...l,
|
||||
'data-test-subj': `xpack.uptime.alerts.monitorStatus.locationSelection.${l.label}LocationOption`,
|
||||
disabled: !allLabels,
|
||||
}))
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiSelectable
|
||||
data-test-subj="xpack.uptime.alerts.monitorStatus.locationsSelectionSelectable"
|
||||
options={selectedLocations}
|
||||
onChange={e => setSelectedLocations(e)}
|
||||
>
|
||||
{location => location}
|
||||
</EuiSelectable>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
}
|
||||
data-test-subj="xpack.uptime.alerts.monitorStatus.locationsSelectionExpression"
|
||||
description="from"
|
||||
id="locations"
|
||||
value={
|
||||
selectedLocations.length === 0 || allLabels
|
||||
? 'any location'
|
||||
: selectedLocationsToString(selectedLocations)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export { AlertMonitorStatusComponent } from './alert_monitor_status';
|
||||
export { ToggleAlertFlyoutButtonComponent } from './toggle_alert_flyout_button';
|
||||
export { UptimeAlertsContextProvider } from './uptime_alerts_context_provider';
|
||||
export { UptimeAlertsFlyoutWrapperComponent } from './uptime_alerts_flyout_wrapper';
|
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { EuiButtonEmpty, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui';
|
||||
import React, { useState } from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public';
|
||||
|
||||
interface Props {
|
||||
setAlertFlyoutVisible: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export const ToggleAlertFlyoutButtonComponent = ({ setAlertFlyoutVisible }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const kibana = useKibana();
|
||||
|
||||
return (
|
||||
<EuiPopover
|
||||
button={
|
||||
<EuiButtonEmpty
|
||||
aria-label={i18n.translate('xpack.uptime.alertsPopover.toggleButton.ariaLabel', {
|
||||
defaultMessage: 'Open alert context menu',
|
||||
})}
|
||||
data-test-subj="xpack.uptime.alertsPopover.toggleButton"
|
||||
iconType="arrowDown"
|
||||
iconSide="right"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.uptime.alerts.toggleAlertFlyoutButtonText"
|
||||
defaultMessage="Alerts"
|
||||
/>
|
||||
</EuiButtonEmpty>
|
||||
}
|
||||
closePopover={() => setIsOpen(false)}
|
||||
isOpen={isOpen}
|
||||
ownFocus
|
||||
>
|
||||
<EuiContextMenuPanel
|
||||
items={[
|
||||
<EuiContextMenuItem
|
||||
aria-label={i18n.translate('xpack.uptime.toggleAlertFlyout.ariaLabel', {
|
||||
defaultMessage: 'Open add alert flyout',
|
||||
})}
|
||||
data-test-subj="xpack.uptime.toggleAlertFlyout"
|
||||
key="create-alert"
|
||||
icon="alert"
|
||||
onClick={() => setAlertFlyoutVisible(true)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.uptime.toggleAlertButton.content"
|
||||
defaultMessage="Create alert"
|
||||
/>
|
||||
</EuiContextMenuItem>,
|
||||
<EuiContextMenuItem
|
||||
aria-label={i18n.translate('xpack.uptime.navigateToAlertingUi', {
|
||||
defaultMessage: 'Leave Uptime and go to Alerting Management page',
|
||||
})}
|
||||
data-test-subj="xpack.uptime.navigateToAlertingUi"
|
||||
icon="tableOfContents"
|
||||
key="navigate-to-alerting"
|
||||
href={kibana.services?.application?.getUrlForApp(
|
||||
'kibana#/management/kibana/triggersActions/alerts'
|
||||
)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.uptime.navigateToAlertingButton.content"
|
||||
defaultMessage="Manage alerts"
|
||||
/>
|
||||
</EuiContextMenuItem>,
|
||||
]}
|
||||
/>
|
||||
</EuiPopover>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { AlertsContextProvider } from '../../../../../../../plugins/triggers_actions_ui/public';
|
||||
import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public';
|
||||
|
||||
export const UptimeAlertsContextProvider: React.FC = ({ children }) => {
|
||||
const {
|
||||
services: {
|
||||
data: { fieldFormats },
|
||||
http,
|
||||
charts,
|
||||
notifications,
|
||||
triggers_actions_ui: { actionTypeRegistry, alertTypeRegistry },
|
||||
uiSettings,
|
||||
},
|
||||
} = useKibana();
|
||||
|
||||
return (
|
||||
<AlertsContextProvider
|
||||
value={{
|
||||
actionTypeRegistry,
|
||||
alertTypeRegistry,
|
||||
charts,
|
||||
dataFieldsFormats: fieldFormats,
|
||||
http,
|
||||
toastNotifications: notifications?.toasts,
|
||||
uiSettings,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AlertsContextProvider>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { AlertAdd } from '../../../../../../../plugins/triggers_actions_ui/public';
|
||||
|
||||
interface Props {
|
||||
alertFlyoutVisible: boolean;
|
||||
alertTypeId?: string;
|
||||
canChangeTrigger?: boolean;
|
||||
setAlertFlyoutVisibility: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export const UptimeAlertsFlyoutWrapperComponent = ({
|
||||
alertFlyoutVisible,
|
||||
alertTypeId,
|
||||
canChangeTrigger,
|
||||
setAlertFlyoutVisibility,
|
||||
}: Props) => (
|
||||
<AlertAdd
|
||||
addFlyoutVisible={alertFlyoutVisible}
|
||||
consumer="uptime"
|
||||
setAddFlyoutVisibility={setAlertFlyoutVisibility}
|
||||
alertTypeId={alertTypeId}
|
||||
canChangeTrigger={canChangeTrigger}
|
||||
/>
|
||||
);
|
|
@ -4,6 +4,12 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export {
|
||||
ToggleAlertFlyoutButtonComponent,
|
||||
UptimeAlertsContextProvider,
|
||||
UptimeAlertsFlyoutWrapperComponent,
|
||||
} from './alerts';
|
||||
export * from './alerts';
|
||||
export { DonutChart } from './charts/donut_chart';
|
||||
export { KueryBarComponent } from './kuery_bar/kuery_bar';
|
||||
export { MonitorCharts } from './monitor_charts';
|
||||
|
|
|
@ -33,14 +33,18 @@ function convertKueryToEsQuery(kuery: string, indexPattern: IIndexPattern) {
|
|||
}
|
||||
|
||||
interface Props {
|
||||
'aria-label': string;
|
||||
autocomplete: DataPublicPluginSetup['autocomplete'];
|
||||
'data-test-subj': string;
|
||||
loadIndexPattern: () => void;
|
||||
indexPattern: IIndexPattern | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function KueryBarComponent({
|
||||
'aria-label': ariaLabel,
|
||||
autocomplete: autocompleteService,
|
||||
'data-test-subj': dataTestSubj,
|
||||
loadIndexPattern,
|
||||
indexPattern,
|
||||
loading,
|
||||
|
@ -119,6 +123,8 @@ export function KueryBarComponent({
|
|||
return (
|
||||
<Container>
|
||||
<Typeahead
|
||||
aria-label={ariaLabel}
|
||||
data-test-subj={dataTestSubj}
|
||||
disabled={indexPatternMissing}
|
||||
isLoading={isLoadingSuggestions || loading}
|
||||
initialValue={kuery}
|
||||
|
|
|
@ -158,8 +158,9 @@ export class Typeahead extends Component {
|
|||
render() {
|
||||
return (
|
||||
<ClickOutside onClickOutside={this.onClickOutside} style={{ position: 'relative' }}>
|
||||
<div data-test-subj="xpack.uptime.filterBar" style={{ position: 'relative' }}>
|
||||
<div data-test-subj={this.props['data-test-subj']} style={{ position: 'relative' }}>
|
||||
<EuiFieldSearch
|
||||
aria-label={this.props['aria-label']}
|
||||
fullWidth
|
||||
style={{
|
||||
backgroundImage: 'none',
|
||||
|
|
|
@ -8,7 +8,6 @@ import React from 'react';
|
|||
import { shallowWithIntl } from 'test_utils/enzyme_helpers';
|
||||
import { PingResults, Ping } from '../../../../../common/graphql/types';
|
||||
import { PingListComponent, AllLocationOption, toggleDetails } from '../ping_list';
|
||||
import { EuiComboBoxOptionOption } from '@elastic/eui';
|
||||
import { ExpandedRowMap } from '../../monitor_list/types';
|
||||
|
||||
describe('PingList component', () => {
|
||||
|
@ -205,7 +204,7 @@ describe('PingList component', () => {
|
|||
loading={false}
|
||||
data={{ allPings }}
|
||||
onPageCountChange={jest.fn()}
|
||||
onSelectedLocationChange={(loc: EuiComboBoxOptionOption[]) => {}}
|
||||
onSelectedLocationChange={(_loc: any[]) => {}}
|
||||
onSelectedStatusChange={jest.fn()}
|
||||
pageSize={30}
|
||||
selectedOption="down"
|
||||
|
|
|
@ -10,6 +10,7 @@ import ReactDOM from 'react-dom';
|
|||
import { get } from 'lodash';
|
||||
import { i18n as i18nFormatter } from '@kbn/i18n';
|
||||
import { PluginsSetup } from 'ui/new_platform/new_platform';
|
||||
import { alertTypeInitializers } from '../../alert_types';
|
||||
import { UptimeApp, UptimeAppProps } from '../../../uptime_app';
|
||||
import { getIntegratedAppAvailability } from './capabilities_adapter';
|
||||
import {
|
||||
|
@ -32,15 +33,30 @@ export const getKibanaFrameworkAdapter = (
|
|||
http: { basePath },
|
||||
i18n,
|
||||
} = core;
|
||||
|
||||
const {
|
||||
data: { autocomplete },
|
||||
// TODO: after NP migration we can likely fix this typing problem
|
||||
// @ts-ignore we don't control this type
|
||||
triggers_actions_ui,
|
||||
} = plugins;
|
||||
|
||||
alertTypeInitializers.forEach(init =>
|
||||
triggers_actions_ui.alertTypeRegistry.register(init({ autocomplete }))
|
||||
);
|
||||
|
||||
let breadcrumbs: ChromeBreadcrumb[] = [];
|
||||
core.chrome.getBreadcrumbs$().subscribe((nextBreadcrumbs?: ChromeBreadcrumb[]) => {
|
||||
breadcrumbs = nextBreadcrumbs || [];
|
||||
});
|
||||
|
||||
const { apm, infrastructure, logs } = getIntegratedAppAvailability(
|
||||
capabilities,
|
||||
INTEGRATED_SOLUTIONS
|
||||
);
|
||||
|
||||
const canSave = get(capabilities, 'uptime.save', false);
|
||||
|
||||
const props: UptimeAppProps = {
|
||||
basePath: basePath.get(),
|
||||
canSave,
|
||||
|
|
|
@ -0,0 +1,181 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { validate, initMonitorStatusAlertType } from '../monitor_status';
|
||||
|
||||
describe('monitor status alert type', () => {
|
||||
describe('validate', () => {
|
||||
let params: any;
|
||||
|
||||
beforeEach(() => {
|
||||
params = {
|
||||
locations: [],
|
||||
numTimes: 5,
|
||||
timerange: {
|
||||
from: 'now-15m',
|
||||
to: 'now',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it(`doesn't throw on empty set`, () => {
|
||||
expect(validate({})).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"errors": Object {
|
||||
"typeCheckFailure": "Provided parameters do not conform to the expected type.",
|
||||
"typeCheckParsingMessage": Array [
|
||||
"Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array<string>, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array<string>, numTimes: number, timerange: { from: string, to: string } }/locations: Array<string>",
|
||||
"Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array<string>, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array<string>, numTimes: number, timerange: { from: string, to: string } }/numTimes: number",
|
||||
"Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array<string>, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array<string>, numTimes: number, timerange: { from: string, to: string } }/timerange: { from: string, to: string }",
|
||||
],
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
describe('timerange', () => {
|
||||
it('is undefined', () => {
|
||||
delete params.timerange;
|
||||
expect(validate(params)).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"errors": Object {
|
||||
"typeCheckFailure": "Provided parameters do not conform to the expected type.",
|
||||
"typeCheckParsingMessage": Array [
|
||||
"Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array<string>, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array<string>, numTimes: number, timerange: { from: string, to: string } }/timerange: { from: string, to: string }",
|
||||
],
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('is missing `from` or `to` value', () => {
|
||||
expect(
|
||||
validate({
|
||||
...params,
|
||||
timerange: {},
|
||||
})
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"errors": Object {
|
||||
"typeCheckFailure": "Provided parameters do not conform to the expected type.",
|
||||
"typeCheckParsingMessage": Array [
|
||||
"Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array<string>, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array<string>, numTimes: number, timerange: { from: string, to: string } }/timerange: { from: string, to: string }/from: string",
|
||||
"Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array<string>, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array<string>, numTimes: number, timerange: { from: string, to: string } }/timerange: { from: string, to: string }/to: string",
|
||||
],
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('is invalid timespan', () => {
|
||||
expect(
|
||||
validate({
|
||||
...params,
|
||||
timerange: {
|
||||
from: 'now',
|
||||
to: 'now-15m',
|
||||
},
|
||||
})
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"errors": Object {
|
||||
"invalidTimeRange": "Time range start cannot exceed time range end",
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('has unparse-able `from` value', () => {
|
||||
expect(
|
||||
validate({
|
||||
...params,
|
||||
timerange: {
|
||||
from: 'cannot parse this to a date',
|
||||
to: 'now',
|
||||
},
|
||||
})
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"errors": Object {
|
||||
"timeRangeStartValueNaN": "Specified time range \`from\` is an invalid value",
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('has unparse-able `to` value', () => {
|
||||
expect(
|
||||
validate({
|
||||
...params,
|
||||
timerange: {
|
||||
from: 'now-15m',
|
||||
to: 'cannot parse this to a date',
|
||||
},
|
||||
})
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"errors": Object {
|
||||
"timeRangeEndValueNaN": "Specified time range \`to\` is an invalid value",
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('numTimes', () => {
|
||||
it('is missing', () => {
|
||||
delete params.numTimes;
|
||||
expect(validate(params)).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"errors": Object {
|
||||
"typeCheckFailure": "Provided parameters do not conform to the expected type.",
|
||||
"typeCheckParsingMessage": Array [
|
||||
"Invalid value undefined supplied to : (Partial<{ filters: string }> & { locations: Array<string>, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array<string>, numTimes: number, timerange: { from: string, to: string } }/numTimes: number",
|
||||
],
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('is NaN', () => {
|
||||
expect(validate({ ...params, numTimes: `this isn't a number` })).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"errors": Object {
|
||||
"typeCheckFailure": "Provided parameters do not conform to the expected type.",
|
||||
"typeCheckParsingMessage": Array [
|
||||
"Invalid value \\"this isn't a number\\" supplied to : (Partial<{ filters: string }> & { locations: Array<string>, numTimes: number, timerange: { from: string, to: string } })/1: { locations: Array<string>, numTimes: number, timerange: { from: string, to: string } }/numTimes: number",
|
||||
],
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('is < 1', () => {
|
||||
expect(validate({ ...params, numTimes: 0 })).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"errors": Object {
|
||||
"invalidNumTimes": "Number of alert check down times must be an integer greater than 0",
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('initMonitorStatusAlertType', () => {
|
||||
expect(initMonitorStatusAlertType({ autocomplete: {} })).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"alertParamsExpression": [Function],
|
||||
"defaultActionMessage": "{{context.message}}
|
||||
{{context.completeIdList}}",
|
||||
"iconClass": "uptimeApp",
|
||||
"id": "xpack.uptime.alerts.monitorStatus",
|
||||
"name": "Uptime Monitor Status",
|
||||
"validate": [Function],
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
14
x-pack/legacy/plugins/uptime/public/lib/alert_types/index.ts
Normal file
14
x-pack/legacy/plugins/uptime/public/lib/alert_types/index.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
// TODO: after NP migration is complete we should be able to remove this lint ignore comment
|
||||
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
|
||||
import { AlertTypeModel } from '../../../../../../plugins/triggers_actions_ui/public/types';
|
||||
import { initMonitorStatusAlertType } from './monitor_status';
|
||||
|
||||
export type AlertTypeInitializer = (dependenies: { autocomplete: any }) => AlertTypeModel;
|
||||
|
||||
export const alertTypeInitializers: AlertTypeInitializer[] = [initMonitorStatusAlertType];
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { PathReporter } from 'io-ts/lib/PathReporter';
|
||||
import React from 'react';
|
||||
import DateMath from '@elastic/datemath';
|
||||
import { isRight } from 'fp-ts/lib/Either';
|
||||
import {
|
||||
AlertTypeModel,
|
||||
ValidationResult,
|
||||
// TODO: this typing issue should be resolved after NP migration
|
||||
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
|
||||
} from '../../../../../../plugins/triggers_actions_ui/public/types';
|
||||
import { AlertTypeInitializer } from '.';
|
||||
import { StatusCheckExecutorParamsType } from '../../../common/runtime_types';
|
||||
import { AlertMonitorStatus } from '../../components/connected/alerts';
|
||||
|
||||
export const validate = (alertParams: any): ValidationResult => {
|
||||
const errors: Record<string, any> = {};
|
||||
const decoded = StatusCheckExecutorParamsType.decode(alertParams);
|
||||
|
||||
/*
|
||||
* When the UI initially loads, this validate function is called with an
|
||||
* empty set of params, we don't want to type check against that.
|
||||
*/
|
||||
if (!isRight(decoded)) {
|
||||
errors.typeCheckFailure = 'Provided parameters do not conform to the expected type.';
|
||||
errors.typeCheckParsingMessage = PathReporter.report(decoded);
|
||||
}
|
||||
|
||||
if (isRight(decoded)) {
|
||||
const { numTimes, timerange } = decoded.right;
|
||||
const { from, to } = timerange;
|
||||
const fromAbs = DateMath.parse(from)?.valueOf();
|
||||
const toAbs = DateMath.parse(to)?.valueOf();
|
||||
if (!fromAbs || isNaN(fromAbs)) {
|
||||
errors.timeRangeStartValueNaN = 'Specified time range `from` is an invalid value';
|
||||
}
|
||||
if (!toAbs || isNaN(toAbs)) {
|
||||
errors.timeRangeEndValueNaN = 'Specified time range `to` is an invalid value';
|
||||
}
|
||||
|
||||
// the default values for this test will pass, we only want to specify an error
|
||||
// in the case that `from` is more recent than `to`
|
||||
if ((fromAbs ?? 0) > (toAbs ?? 1)) {
|
||||
errors.invalidTimeRange = 'Time range start cannot exceed time range end';
|
||||
}
|
||||
|
||||
if (numTimes < 1) {
|
||||
errors.invalidNumTimes = 'Number of alert check down times must be an integer greater than 0';
|
||||
}
|
||||
}
|
||||
|
||||
return { errors };
|
||||
};
|
||||
|
||||
export const initMonitorStatusAlertType: AlertTypeInitializer = ({
|
||||
autocomplete,
|
||||
}): AlertTypeModel => ({
|
||||
id: 'xpack.uptime.alerts.monitorStatus',
|
||||
name: 'Uptime Monitor Status',
|
||||
iconClass: 'uptimeApp',
|
||||
alertParamsExpression: params => {
|
||||
return <AlertMonitorStatus {...params} autocomplete={autocomplete} />;
|
||||
},
|
||||
validate,
|
||||
defaultActionMessage: '{{context.message}}\n{{context.completeIdList}}',
|
||||
});
|
|
@ -14,6 +14,39 @@ Array [
|
|||
TestingHeading
|
||||
</h1>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
>
|
||||
<div
|
||||
class="euiPopover euiPopover--anchorDownCenter"
|
||||
>
|
||||
<div
|
||||
class="euiPopover__anchor"
|
||||
>
|
||||
<button
|
||||
aria-label="Open alert context menu"
|
||||
class="euiButtonEmpty euiButtonEmpty--primary euiButtonEmpty--iconRight"
|
||||
data-test-subj="xpack.uptime.alertsPopover.toggleButton"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="euiButtonEmpty__content"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="euiButtonEmpty__icon"
|
||||
data-euiicon-type="arrowDown"
|
||||
/>
|
||||
<span
|
||||
class="euiButtonEmpty__text"
|
||||
>
|
||||
Alerts
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
>
|
||||
|
@ -130,6 +163,39 @@ Array [
|
|||
TestingHeading
|
||||
</h1>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem euiFlexItem--flexGrowZero"
|
||||
>
|
||||
<div
|
||||
class="euiPopover euiPopover--anchorDownCenter"
|
||||
>
|
||||
<div
|
||||
class="euiPopover__anchor"
|
||||
>
|
||||
<button
|
||||
aria-label="Open alert context menu"
|
||||
class="euiButtonEmpty euiButtonEmpty--primary euiButtonEmpty--iconRight"
|
||||
data-test-subj="xpack.uptime.alertsPopover.toggleButton"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="euiButtonEmpty__content"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="euiButtonEmpty__icon"
|
||||
data-euiicon-type="arrowDown"
|
||||
/>
|
||||
<span
|
||||
class="euiButtonEmpty__text"
|
||||
>
|
||||
Alerts
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
<div
|
||||
class="euiSpacer euiSpacer--s"
|
||||
|
|
|
@ -12,6 +12,7 @@ import { OVERVIEW_ROUTE } from '../../../common/constants';
|
|||
import { ChromeBreadcrumb } from 'kibana/public';
|
||||
import { KibanaContextProvider } from '../../../../../../../src/plugins/kibana_react/public';
|
||||
import { UptimeUrlParams, getSupportedUrlParams } from '../../lib/helper';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
describe('PageHeader', () => {
|
||||
const simpleBreadcrumbs: ChromeBreadcrumb[] = [
|
||||
|
@ -21,22 +22,26 @@ describe('PageHeader', () => {
|
|||
|
||||
it('shallow renders with breadcrumbs and the date picker', () => {
|
||||
const component = renderWithRouter(
|
||||
<PageHeader
|
||||
headingText={'TestingHeading'}
|
||||
breadcrumbs={simpleBreadcrumbs}
|
||||
datePicker={true}
|
||||
/>
|
||||
<MockReduxProvider>
|
||||
<PageHeader
|
||||
headingText={'TestingHeading'}
|
||||
breadcrumbs={simpleBreadcrumbs}
|
||||
datePicker={true}
|
||||
/>
|
||||
</MockReduxProvider>
|
||||
);
|
||||
expect(component).toMatchSnapshot('page_header_with_date_picker');
|
||||
});
|
||||
|
||||
it('shallow renders with breadcrumbs without the date picker', () => {
|
||||
const component = renderWithRouter(
|
||||
<PageHeader
|
||||
headingText={'TestingHeading'}
|
||||
breadcrumbs={simpleBreadcrumbs}
|
||||
datePicker={false}
|
||||
/>
|
||||
<MockReduxProvider>
|
||||
<PageHeader
|
||||
headingText={'TestingHeading'}
|
||||
breadcrumbs={simpleBreadcrumbs}
|
||||
datePicker={false}
|
||||
/>
|
||||
</MockReduxProvider>
|
||||
);
|
||||
expect(component).toMatchSnapshot('page_header_no_date_picker');
|
||||
});
|
||||
|
@ -45,13 +50,15 @@ describe('PageHeader', () => {
|
|||
const [getBreadcrumbs, core] = mockCore();
|
||||
mountWithRouter(
|
||||
<KibanaContextProvider services={{ ...core }}>
|
||||
<Route path={OVERVIEW_ROUTE}>
|
||||
<PageHeader
|
||||
headingText={'TestingHeading'}
|
||||
breadcrumbs={simpleBreadcrumbs}
|
||||
datePicker={false}
|
||||
/>
|
||||
</Route>
|
||||
<MockReduxProvider>
|
||||
<Route path={OVERVIEW_ROUTE}>
|
||||
<PageHeader
|
||||
headingText={'TestingHeading'}
|
||||
breadcrumbs={simpleBreadcrumbs}
|
||||
datePicker={false}
|
||||
/>
|
||||
</Route>
|
||||
</MockReduxProvider>
|
||||
</KibanaContextProvider>
|
||||
);
|
||||
|
||||
|
@ -62,6 +69,19 @@ describe('PageHeader', () => {
|
|||
});
|
||||
});
|
||||
|
||||
const MockReduxProvider = ({ children }: { children: React.ReactElement }) => (
|
||||
<Provider
|
||||
store={{
|
||||
dispatch: jest.fn(),
|
||||
getState: jest.fn(),
|
||||
subscribe: jest.fn(),
|
||||
replaceReducer: jest.fn(),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Provider>
|
||||
);
|
||||
|
||||
const mockCore: () => [() => ChromeBreadcrumb[], any] = () => {
|
||||
let breadcrumbObj: ChromeBreadcrumb[] = [];
|
||||
const get = () => {
|
||||
|
|
|
@ -83,7 +83,13 @@ export const OverviewPageComponent = ({ autocomplete, indexPattern, setEsKueryFi
|
|||
<EmptyState>
|
||||
<EuiFlexGroup gutterSize="xs" wrap responsive>
|
||||
<EuiFlexItem grow={1} style={{ flexBasis: 500 }}>
|
||||
<KueryBar autocomplete={autocomplete} />
|
||||
<KueryBar
|
||||
aria-label={i18n.translate('xpack.uptime.filterBar.ariaLabel', {
|
||||
defaultMessage: 'Input filter criteria for the overview page',
|
||||
})}
|
||||
autocomplete={autocomplete}
|
||||
data-test-subj="xpack.uptime.filterBar"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItemStyled grow={true}>
|
||||
<FilterGroup esFilters={esFilters} />
|
||||
|
|
|
@ -13,6 +13,7 @@ import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
|
|||
import { stringifyUrlParams } from '../lib/helper/stringify_url_params';
|
||||
import { useUrlParams } from '../hooks';
|
||||
import { UptimeUrlParams } from '../lib/helper';
|
||||
import { ToggleAlertFlyoutButton } from '../components/connected';
|
||||
|
||||
interface PageHeaderProps {
|
||||
headingText: string;
|
||||
|
@ -60,6 +61,9 @@ export const PageHeader = ({ headingText, breadcrumbs, datePicker = true }: Page
|
|||
<h1>{headingText}</h1>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<ToggleAlertFlyoutButton />
|
||||
</EuiFlexItem>
|
||||
{datePickerComponent}
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="s" />
|
||||
|
|
|
@ -12,6 +12,8 @@ export interface PopoverState {
|
|||
|
||||
export type UiPayload = PopoverState & string & number & Map<string, string[]>;
|
||||
|
||||
export const setAlertFlyoutVisible = createAction<boolean>('TOGGLE ALERT FLYOUT');
|
||||
|
||||
export const setBasePath = createAction<string>('SET BASE PATH');
|
||||
|
||||
export const triggerAppRefresh = createAction<number>('REFRESH APP');
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
exports[`ui reducer adds integration popover status to state 1`] = `
|
||||
Object {
|
||||
"alertFlyoutVisible": false,
|
||||
"basePath": "",
|
||||
"esKuery": "",
|
||||
"integrationsPopoverOpen": Object {
|
||||
|
@ -14,6 +15,7 @@ Object {
|
|||
|
||||
exports[`ui reducer sets the application's base path 1`] = `
|
||||
Object {
|
||||
"alertFlyoutVisible": false,
|
||||
"basePath": "yyz",
|
||||
"esKuery": "",
|
||||
"integrationsPopoverOpen": null,
|
||||
|
@ -23,6 +25,7 @@ Object {
|
|||
|
||||
exports[`ui reducer updates the refresh value 1`] = `
|
||||
Object {
|
||||
"alertFlyoutVisible": false,
|
||||
"basePath": "abc",
|
||||
"esKuery": "",
|
||||
"integrationsPopoverOpen": null,
|
||||
|
|
|
@ -4,7 +4,12 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { setBasePath, toggleIntegrationsPopover, triggerAppRefresh } from '../../actions';
|
||||
import {
|
||||
setBasePath,
|
||||
toggleIntegrationsPopover,
|
||||
triggerAppRefresh,
|
||||
setAlertFlyoutVisible,
|
||||
} from '../../actions';
|
||||
import { uiReducer } from '../ui';
|
||||
import { Action } from 'redux-actions';
|
||||
|
||||
|
@ -14,6 +19,7 @@ describe('ui reducer', () => {
|
|||
expect(
|
||||
uiReducer(
|
||||
{
|
||||
alertFlyoutVisible: false,
|
||||
basePath: 'abc',
|
||||
esKuery: '',
|
||||
integrationsPopoverOpen: null,
|
||||
|
@ -32,6 +38,7 @@ describe('ui reducer', () => {
|
|||
expect(
|
||||
uiReducer(
|
||||
{
|
||||
alertFlyoutVisible: false,
|
||||
basePath: '',
|
||||
esKuery: '',
|
||||
integrationsPopoverOpen: null,
|
||||
|
@ -47,6 +54,7 @@ describe('ui reducer', () => {
|
|||
expect(
|
||||
uiReducer(
|
||||
{
|
||||
alertFlyoutVisible: false,
|
||||
basePath: 'abc',
|
||||
esKuery: '',
|
||||
integrationsPopoverOpen: null,
|
||||
|
@ -56,4 +64,28 @@ describe('ui reducer', () => {
|
|||
)
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('updates the alert flyout value', () => {
|
||||
const action = setAlertFlyoutVisible(true) as Action<never>;
|
||||
expect(
|
||||
uiReducer(
|
||||
{
|
||||
alertFlyoutVisible: false,
|
||||
basePath: '',
|
||||
esKuery: '',
|
||||
integrationsPopoverOpen: null,
|
||||
lastRefresh: 125,
|
||||
},
|
||||
action
|
||||
)
|
||||
).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"alertFlyoutVisible": true,
|
||||
"basePath": "",
|
||||
"esKuery": "",
|
||||
"integrationsPopoverOpen": null,
|
||||
"lastRefresh": 125,
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -12,19 +12,22 @@ import {
|
|||
setEsKueryString,
|
||||
triggerAppRefresh,
|
||||
UiPayload,
|
||||
setAlertFlyoutVisible,
|
||||
} from '../actions/ui';
|
||||
|
||||
export interface UiState {
|
||||
integrationsPopoverOpen: PopoverState | null;
|
||||
alertFlyoutVisible: boolean;
|
||||
basePath: string;
|
||||
esKuery: string;
|
||||
integrationsPopoverOpen: PopoverState | null;
|
||||
lastRefresh: number;
|
||||
}
|
||||
|
||||
const initialState: UiState = {
|
||||
integrationsPopoverOpen: null,
|
||||
alertFlyoutVisible: false,
|
||||
basePath: '',
|
||||
esKuery: '',
|
||||
integrationsPopoverOpen: null,
|
||||
lastRefresh: Date.now(),
|
||||
};
|
||||
|
||||
|
@ -35,6 +38,11 @@ export const uiReducer = handleActions<UiState, UiPayload>(
|
|||
integrationsPopoverOpen: action.payload as PopoverState,
|
||||
}),
|
||||
|
||||
[String(setAlertFlyoutVisible)]: (state, action: Action<boolean | undefined>) => ({
|
||||
...state,
|
||||
alertFlyoutVisible: action.payload ?? !state.alertFlyoutVisible,
|
||||
}),
|
||||
|
||||
[String(setBasePath)]: (state, action: Action<string>) => ({
|
||||
...state,
|
||||
basePath: action.payload as string,
|
||||
|
|
|
@ -35,6 +35,7 @@ describe('state selectors', () => {
|
|||
loading: false,
|
||||
},
|
||||
ui: {
|
||||
alertFlyoutVisible: false,
|
||||
basePath: 'yyz',
|
||||
esKuery: '',
|
||||
integrationsPopoverOpen: null,
|
||||
|
|
|
@ -46,6 +46,15 @@ export const selectDurationLines = ({ monitorDuration }: AppState) => {
|
|||
return monitorDuration;
|
||||
};
|
||||
|
||||
export const selectAlertFlyoutVisibility = ({ ui: { alertFlyoutVisible } }: AppState) =>
|
||||
alertFlyoutVisible;
|
||||
|
||||
export const selectMonitorStatusAlert = ({ indexPattern, overviewFilters, ui }: AppState) => ({
|
||||
filters: ui.esKuery,
|
||||
indexPattern: indexPattern.index_pattern,
|
||||
locations: overviewFilters.filters.locations,
|
||||
});
|
||||
|
||||
export const indexStatusSelector = ({ indexStatus }: AppState) => {
|
||||
return indexStatus;
|
||||
};
|
||||
|
|
|
@ -23,6 +23,8 @@ import { CommonlyUsedRange } from './components/functional/uptime_date_picker';
|
|||
import { store } from './state';
|
||||
import { setBasePath } from './state/actions';
|
||||
import { PageRouter } from './routes';
|
||||
import { UptimeAlertsFlyoutWrapper } from './components/connected';
|
||||
import { UptimeAlertsContextProvider } from './components/functional/alerts';
|
||||
import { kibanaService } from './state/kibana_service';
|
||||
|
||||
export interface UptimeAppColors {
|
||||
|
@ -99,11 +101,14 @@ const Application = (props: UptimeAppProps) => {
|
|||
<UptimeRefreshContextProvider>
|
||||
<UptimeSettingsContextProvider {...props}>
|
||||
<UptimeThemeContextProvider darkMode={darkMode}>
|
||||
<EuiPage className="app-wrapper-panel " data-test-subj="uptimeApp">
|
||||
<main>
|
||||
<PageRouter autocomplete={plugins.data.autocomplete} />
|
||||
</main>
|
||||
</EuiPage>
|
||||
<UptimeAlertsContextProvider>
|
||||
<EuiPage className="app-wrapper-panel " data-test-subj="uptimeApp">
|
||||
<main>
|
||||
<UptimeAlertsFlyoutWrapper />
|
||||
<PageRouter autocomplete={plugins.data.autocomplete} />
|
||||
</main>
|
||||
</EuiPage>
|
||||
</UptimeAlertsContextProvider>
|
||||
</UptimeThemeContextProvider>
|
||||
</UptimeSettingsContextProvider>
|
||||
</UptimeRefreshContextProvider>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
"configPath": ["xpack"],
|
||||
"id": "uptime",
|
||||
"kibanaVersion": "kibana",
|
||||
"requiredPlugins": ["features", "licensing", "usageCollection"],
|
||||
"requiredPlugins": ["alerting", "features", "licensing", "usageCollection"],
|
||||
"server": true,
|
||||
"ui": false,
|
||||
"version": "8.0.0"
|
||||
|
|
|
@ -55,5 +55,5 @@ export const initServerWithKibana = (server: UptimeCoreSetup, plugins: UptimeCor
|
|||
},
|
||||
});
|
||||
|
||||
initUptimeServer(libs);
|
||||
initUptimeServer(server, libs, plugins);
|
||||
};
|
||||
|
|
|
@ -31,6 +31,8 @@ export interface UptimeCoreSetup {
|
|||
|
||||
export interface UptimeCorePlugins {
|
||||
features: PluginSetupContract;
|
||||
alerting: any;
|
||||
elasticsearch: any;
|
||||
usageCollection: UsageCollectionSetup;
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,587 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import {
|
||||
contextMessage,
|
||||
uniqueMonitorIds,
|
||||
updateState,
|
||||
statusCheckAlertFactory,
|
||||
fullListByIdAndLocation,
|
||||
} from '../status_check';
|
||||
import { GetMonitorStatusResult } from '../../requests';
|
||||
import { AlertType } from '../../../../../alerting/server';
|
||||
import { IRouter } from 'kibana/server';
|
||||
import { UMServerLibs } from '../../lib';
|
||||
import { UptimeCoreSetup } from '../../adapters';
|
||||
|
||||
/**
|
||||
* The alert takes some dependencies as parameters; these are things like
|
||||
* kibana core services and plugins. This function helps reduce the amount of
|
||||
* boilerplate required.
|
||||
* @param customRequests client tests can use this paramter to provide their own request mocks,
|
||||
* so we don't have to mock them all for each test.
|
||||
*/
|
||||
const bootstrapDependencies = (customRequests?: any) => {
|
||||
const route: IRouter = {} as IRouter;
|
||||
// these server/libs parameters don't have any functionality, which is fine
|
||||
// because we aren't testing them here
|
||||
const server: UptimeCoreSetup = { route };
|
||||
const libs: UMServerLibs = { requests: {} } as UMServerLibs;
|
||||
libs.requests = { ...libs.requests, ...customRequests };
|
||||
return { server, libs };
|
||||
};
|
||||
|
||||
/**
|
||||
* This function aims to provide an easy way to give mock props that will
|
||||
* reduce boilerplate for tests.
|
||||
* @param params the params received at alert creation time
|
||||
* @param services the core services provided by kibana/alerting platforms
|
||||
* @param state the state the alert maintains
|
||||
*/
|
||||
const mockOptions = (
|
||||
params = { numTimes: 5, locations: [], timerange: { from: 'now-15m', to: 'now' } },
|
||||
services = { callCluster: 'mockESFunction' },
|
||||
state = {}
|
||||
): any => ({
|
||||
params,
|
||||
services,
|
||||
state,
|
||||
});
|
||||
|
||||
describe('status check alert', () => {
|
||||
describe('executor', () => {
|
||||
it('does not trigger when there are no monitors down', async () => {
|
||||
expect.assertions(4);
|
||||
const mockGetter = jest.fn();
|
||||
mockGetter.mockReturnValue([]);
|
||||
const { server, libs } = bootstrapDependencies({ getMonitorStatus: mockGetter });
|
||||
const alert = statusCheckAlertFactory(server, libs);
|
||||
// @ts-ignore the executor can return `void`, but ours never does
|
||||
const state: Record<string, any> = await alert.executor(mockOptions());
|
||||
|
||||
expect(state).not.toBeUndefined();
|
||||
expect(state?.isTriggered).toBe(false);
|
||||
expect(mockGetter).toHaveBeenCalledTimes(1);
|
||||
expect(mockGetter.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"callES": "mockESFunction",
|
||||
"locations": Array [],
|
||||
"numTimes": 5,
|
||||
"timerange": Object {
|
||||
"from": "now-15m",
|
||||
"to": "now",
|
||||
},
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('triggers when monitors are down and provides expected state', async () => {
|
||||
const mockGetter = jest.fn();
|
||||
mockGetter.mockReturnValue([
|
||||
{
|
||||
monitor_id: 'first',
|
||||
location: 'harrisburg',
|
||||
count: 234,
|
||||
status: 'down',
|
||||
},
|
||||
{
|
||||
monitor_id: 'first',
|
||||
location: 'fairbanks',
|
||||
count: 234,
|
||||
status: 'down',
|
||||
},
|
||||
]);
|
||||
const { server, libs } = bootstrapDependencies({ getMonitorStatus: mockGetter });
|
||||
const alert = statusCheckAlertFactory(server, libs);
|
||||
const mockInstanceFactory = jest.fn();
|
||||
const mockReplaceState = jest.fn();
|
||||
const mockScheduleActions = jest.fn();
|
||||
mockInstanceFactory.mockReturnValue({
|
||||
replaceState: mockReplaceState,
|
||||
scheduleActions: mockScheduleActions,
|
||||
});
|
||||
const options = mockOptions();
|
||||
options.services = {
|
||||
...options.services,
|
||||
alertInstanceFactory: mockInstanceFactory,
|
||||
};
|
||||
// @ts-ignore the executor can return `void`, but ours never does
|
||||
const state: Record<string, any> = await alert.executor(options);
|
||||
expect(mockGetter).toHaveBeenCalledTimes(1);
|
||||
expect(mockInstanceFactory).toHaveBeenCalledTimes(1);
|
||||
expect(mockGetter.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"callES": "mockESFunction",
|
||||
"locations": Array [],
|
||||
"numTimes": 5,
|
||||
"timerange": Object {
|
||||
"from": "now-15m",
|
||||
"to": "now",
|
||||
},
|
||||
},
|
||||
]
|
||||
`);
|
||||
expect(mockReplaceState).toHaveBeenCalledTimes(1);
|
||||
expect(mockReplaceState.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"monitors": Array [
|
||||
Object {
|
||||
"count": 234,
|
||||
"location": "fairbanks",
|
||||
"monitor_id": "first",
|
||||
"status": "down",
|
||||
},
|
||||
Object {
|
||||
"count": 234,
|
||||
"location": "harrisburg",
|
||||
"monitor_id": "first",
|
||||
"status": "down",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
`);
|
||||
expect(mockScheduleActions).toHaveBeenCalledTimes(1);
|
||||
expect(mockScheduleActions.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
"xpack.uptime.alerts.actionGroups.monitorStatus",
|
||||
Object {
|
||||
"completeIdList": "first from fairbanks; first from harrisburg; ",
|
||||
"message": "Down monitor: first",
|
||||
"server": Object {
|
||||
"route": Object {},
|
||||
},
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fullListByIdAndLocation', () => {
|
||||
it('renders a list of all monitors', () => {
|
||||
const statuses: GetMonitorStatusResult[] = [
|
||||
{
|
||||
location: 'harrisburg',
|
||||
monitor_id: 'first',
|
||||
status: 'down',
|
||||
count: 34,
|
||||
},
|
||||
{
|
||||
location: 'fairbanks',
|
||||
monitor_id: 'second',
|
||||
status: 'down',
|
||||
count: 23,
|
||||
},
|
||||
{
|
||||
location: 'fairbanks',
|
||||
monitor_id: 'first',
|
||||
status: 'down',
|
||||
count: 23,
|
||||
},
|
||||
{
|
||||
location: 'harrisburg',
|
||||
monitor_id: 'second',
|
||||
status: 'down',
|
||||
count: 34,
|
||||
},
|
||||
];
|
||||
expect(fullListByIdAndLocation(statuses)).toMatchInlineSnapshot(
|
||||
`"first from fairbanks; first from harrisburg; second from fairbanks; second from harrisburg; "`
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a list of monitors when greater than limit', () => {
|
||||
const statuses: GetMonitorStatusResult[] = [
|
||||
{
|
||||
location: 'fairbanks',
|
||||
monitor_id: 'second',
|
||||
status: 'down',
|
||||
count: 23,
|
||||
},
|
||||
{
|
||||
location: 'fairbanks',
|
||||
monitor_id: 'first',
|
||||
status: 'down',
|
||||
count: 23,
|
||||
},
|
||||
{
|
||||
location: 'harrisburg',
|
||||
monitor_id: 'first',
|
||||
status: 'down',
|
||||
count: 34,
|
||||
},
|
||||
{
|
||||
location: 'harrisburg',
|
||||
monitor_id: 'second',
|
||||
status: 'down',
|
||||
count: 34,
|
||||
},
|
||||
];
|
||||
expect(fullListByIdAndLocation(statuses.slice(0, 2), 1)).toMatchInlineSnapshot(
|
||||
`"first from fairbanks; ...and 1 other monitor/location"`
|
||||
);
|
||||
});
|
||||
|
||||
it('renders expected list of monitors when limit difference > 1', () => {
|
||||
const statuses: GetMonitorStatusResult[] = [
|
||||
{
|
||||
location: 'fairbanks',
|
||||
monitor_id: 'second',
|
||||
status: 'down',
|
||||
count: 23,
|
||||
},
|
||||
{
|
||||
location: 'harrisburg',
|
||||
monitor_id: 'first',
|
||||
status: 'down',
|
||||
count: 34,
|
||||
},
|
||||
{
|
||||
location: 'harrisburg',
|
||||
monitor_id: 'second',
|
||||
status: 'down',
|
||||
count: 34,
|
||||
},
|
||||
{
|
||||
location: 'harrisburg',
|
||||
monitor_id: 'third',
|
||||
status: 'down',
|
||||
count: 34,
|
||||
},
|
||||
{
|
||||
location: 'fairbanks',
|
||||
monitor_id: 'third',
|
||||
status: 'down',
|
||||
count: 23,
|
||||
},
|
||||
{
|
||||
location: 'fairbanks',
|
||||
monitor_id: 'first',
|
||||
status: 'down',
|
||||
count: 23,
|
||||
},
|
||||
];
|
||||
expect(fullListByIdAndLocation(statuses, 4)).toMatchInlineSnapshot(
|
||||
`"first from fairbanks; first from harrisburg; second from fairbanks; second from harrisburg; ...and 2 other monitors/locations"`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('alert factory', () => {
|
||||
let alert: AlertType;
|
||||
|
||||
beforeEach(() => {
|
||||
const { server, libs } = bootstrapDependencies();
|
||||
alert = statusCheckAlertFactory(server, libs);
|
||||
});
|
||||
|
||||
it('creates an alert with expected params', () => {
|
||||
// @ts-ignore the `props` key here isn't described
|
||||
expect(Object.keys(alert.validate?.params?.props ?? {})).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
"filters",
|
||||
"numTimes",
|
||||
"timerange",
|
||||
"locations",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('contains the expected static fields like id, name, etc.', () => {
|
||||
expect(alert.id).toBe('xpack.uptime.alerts.monitorStatus');
|
||||
expect(alert.name).toBe('Uptime Monitor Status');
|
||||
expect(alert.defaultActionGroupId).toBe('xpack.uptime.alerts.actionGroups.monitorStatus');
|
||||
expect(alert.actionGroups).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"id": "xpack.uptime.alerts.actionGroups.monitorStatus",
|
||||
"name": "Uptime Down Monitor",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
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({}, false);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"currentTriggerStarted": undefined,
|
||||
"firstCheckedAt": "foo date string",
|
||||
"firstTriggeredAt": undefined,
|
||||
"isTriggered": false,
|
||||
"lastCheckedAt": "foo date string",
|
||||
"lastResolvedAt": undefined,
|
||||
"lastTriggeredAt": undefined,
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('updates the correct field in subsequent calls', () => {
|
||||
spy
|
||||
.mockImplementationOnce(() => 'first date string')
|
||||
.mockImplementationOnce(() => 'second date string');
|
||||
const firstState = updateState({}, false);
|
||||
const secondState = updateState(firstState, true);
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
expect(firstState).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"currentTriggerStarted": undefined,
|
||||
"firstCheckedAt": "first date string",
|
||||
"firstTriggeredAt": undefined,
|
||||
"isTriggered": false,
|
||||
"lastCheckedAt": "first date string",
|
||||
"lastResolvedAt": undefined,
|
||||
"lastTriggeredAt": undefined,
|
||||
}
|
||||
`);
|
||||
expect(secondState).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"currentTriggerStarted": "second date string",
|
||||
"firstCheckedAt": "first date string",
|
||||
"firstTriggeredAt": "second date string",
|
||||
"isTriggered": true,
|
||||
"lastCheckedAt": "second date string",
|
||||
"lastResolvedAt": undefined,
|
||||
"lastTriggeredAt": "second date string",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('correctly marks resolution times', () => {
|
||||
spy
|
||||
.mockImplementationOnce(() => 'first date string')
|
||||
.mockImplementationOnce(() => 'second date string')
|
||||
.mockImplementationOnce(() => 'third date string');
|
||||
const firstState = updateState({}, true);
|
||||
const secondState = updateState(firstState, true);
|
||||
const thirdState = updateState(secondState, false);
|
||||
expect(spy).toHaveBeenCalledTimes(3);
|
||||
expect(firstState).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"currentTriggerStarted": "first date string",
|
||||
"firstCheckedAt": "first date string",
|
||||
"firstTriggeredAt": "first date string",
|
||||
"isTriggered": true,
|
||||
"lastCheckedAt": "first date string",
|
||||
"lastResolvedAt": undefined,
|
||||
"lastTriggeredAt": "first date string",
|
||||
}
|
||||
`);
|
||||
expect(secondState).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"currentTriggerStarted": "first date string",
|
||||
"firstCheckedAt": "first date string",
|
||||
"firstTriggeredAt": "first date string",
|
||||
"isTriggered": true,
|
||||
"lastCheckedAt": "second date string",
|
||||
"lastResolvedAt": undefined,
|
||||
"lastTriggeredAt": "second date string",
|
||||
}
|
||||
`);
|
||||
expect(thirdState).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"currentTriggerStarted": undefined,
|
||||
"firstCheckedAt": "first date string",
|
||||
"firstTriggeredAt": "first date string",
|
||||
"isTriggered": false,
|
||||
"lastCheckedAt": "third date string",
|
||||
"lastResolvedAt": "third date string",
|
||||
"lastTriggeredAt": "second date string",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
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({}, 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 {
|
||||
"currentTriggerStarted": undefined,
|
||||
"firstCheckedAt": "first date string",
|
||||
"firstTriggeredAt": undefined,
|
||||
"isTriggered": false,
|
||||
"lastCheckedAt": "first date string",
|
||||
"lastResolvedAt": undefined,
|
||||
"lastTriggeredAt": undefined,
|
||||
}
|
||||
`);
|
||||
expect(secondState).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"currentTriggerStarted": "second date string",
|
||||
"firstCheckedAt": "first date string",
|
||||
"firstTriggeredAt": "second date string",
|
||||
"isTriggered": true,
|
||||
"lastCheckedAt": "second date string",
|
||||
"lastResolvedAt": undefined,
|
||||
"lastTriggeredAt": "second date string",
|
||||
}
|
||||
`);
|
||||
expect(thirdState).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"currentTriggerStarted": undefined,
|
||||
"firstCheckedAt": "first date string",
|
||||
"firstTriggeredAt": "second date string",
|
||||
"isTriggered": false,
|
||||
"lastCheckedAt": "third date string",
|
||||
"lastResolvedAt": "third date string",
|
||||
"lastTriggeredAt": "second date string",
|
||||
}
|
||||
`);
|
||||
expect(fourthState).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"currentTriggerStarted": "fourth date string",
|
||||
"firstCheckedAt": "first date string",
|
||||
"firstTriggeredAt": "second date string",
|
||||
"isTriggered": true,
|
||||
"lastCheckedAt": "fourth date string",
|
||||
"lastResolvedAt": "third date string",
|
||||
"lastTriggeredAt": "fourth date string",
|
||||
}
|
||||
`);
|
||||
expect(fifthState).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"currentTriggerStarted": undefined,
|
||||
"firstCheckedAt": "first date string",
|
||||
"firstTriggeredAt": "second date string",
|
||||
"isTriggered": false,
|
||||
"lastCheckedAt": "fifth date string",
|
||||
"lastResolvedAt": "fifth date string",
|
||||
"lastTriggeredAt": "fourth date string",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uniqueMonitorIds', () => {
|
||||
let items: GetMonitorStatusResult[];
|
||||
beforeEach(() => {
|
||||
items = [
|
||||
{
|
||||
monitor_id: 'first',
|
||||
location: 'harrisburg',
|
||||
count: 234,
|
||||
status: 'down',
|
||||
},
|
||||
{
|
||||
monitor_id: 'first',
|
||||
location: 'fairbanks',
|
||||
count: 312,
|
||||
status: 'down',
|
||||
},
|
||||
{
|
||||
monitor_id: 'second',
|
||||
location: 'harrisburg',
|
||||
count: 325,
|
||||
status: 'down',
|
||||
},
|
||||
{
|
||||
monitor_id: 'second',
|
||||
location: 'fairbanks',
|
||||
count: 331,
|
||||
status: 'down',
|
||||
},
|
||||
{
|
||||
monitor_id: 'third',
|
||||
location: 'harrisburg',
|
||||
count: 331,
|
||||
status: 'down',
|
||||
},
|
||||
{
|
||||
monitor_id: 'third',
|
||||
location: 'fairbanks',
|
||||
count: 342,
|
||||
status: 'down',
|
||||
},
|
||||
{
|
||||
monitor_id: 'fourth',
|
||||
location: 'harrisburg',
|
||||
count: 355,
|
||||
status: 'down',
|
||||
},
|
||||
{
|
||||
monitor_id: 'fourth',
|
||||
location: 'fairbanks',
|
||||
count: 342,
|
||||
status: 'down',
|
||||
},
|
||||
{
|
||||
monitor_id: 'fifth',
|
||||
location: 'harrisburg',
|
||||
count: 342,
|
||||
status: 'down',
|
||||
},
|
||||
{
|
||||
monitor_id: 'fifth',
|
||||
location: 'fairbanks',
|
||||
count: 342,
|
||||
status: 'down',
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
it('creates a set of unique IDs from a list of composite-unique objects', () => {
|
||||
expect(uniqueMonitorIds(items)).toEqual(
|
||||
new Set<string>(['first', 'second', 'third', 'fourth', 'fifth'])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('contextMessage', () => {
|
||||
let ids: string[];
|
||||
beforeEach(() => {
|
||||
ids = ['first', 'second', 'third', 'fourth', 'fifth'];
|
||||
});
|
||||
|
||||
it('creates a message with appropriate number of monitors', () => {
|
||||
expect(contextMessage(ids, 3)).toMatchInlineSnapshot(
|
||||
`"Down monitors: first, second, third... and 2 other monitors"`
|
||||
);
|
||||
});
|
||||
|
||||
it('throws an error if `max` is less than 2', () => {
|
||||
expect(() => contextMessage(ids, 1)).toThrowErrorMatchingInlineSnapshot(
|
||||
'"Maximum value must be greater than 2, received 1."'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns only the ids if length < max', () => {
|
||||
expect(contextMessage(ids.slice(0, 2), 3)).toMatchInlineSnapshot(
|
||||
`"Down monitors: first, second"`
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a default message when no monitors are provided', () => {
|
||||
expect(contextMessage([], 3)).toMatchInlineSnapshot(`"No down monitor IDs received"`);
|
||||
});
|
||||
});
|
||||
});
|
10
x-pack/plugins/uptime/server/lib/alerts/index.ts
Normal file
10
x-pack/plugins/uptime/server/lib/alerts/index.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { UptimeAlertTypeFactory } from './types';
|
||||
import { statusCheckAlertFactory } from './status_check';
|
||||
|
||||
export const uptimeAlertTypeFactories: UptimeAlertTypeFactory[] = [statusCheckAlertFactory];
|
234
x-pack/plugins/uptime/server/lib/alerts/status_check.ts
Normal file
234
x-pack/plugins/uptime/server/lib/alerts/status_check.ts
Normal file
|
@ -0,0 +1,234 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { isRight } from 'fp-ts/lib/Either';
|
||||
import { ThrowReporter } from 'io-ts/lib/ThrowReporter';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { AlertExecutorOptions } from '../../../../alerting/server';
|
||||
import { ACTION_GROUP_DEFINITIONS } from '../../../../../legacy/plugins/uptime/common/constants';
|
||||
import { UptimeAlertTypeFactory } from './types';
|
||||
import { GetMonitorStatusResult } from '../requests';
|
||||
import {
|
||||
StatusCheckExecutorParamsType,
|
||||
StatusCheckAlertStateType,
|
||||
StatusCheckAlertState,
|
||||
} from '../../../../../legacy/plugins/uptime/common/runtime_types';
|
||||
|
||||
const { MONITOR_STATUS } = ACTION_GROUP_DEFINITIONS;
|
||||
|
||||
/**
|
||||
* Reduce a composite-key array of status results to a set of unique IDs.
|
||||
* @param items to reduce
|
||||
*/
|
||||
export const uniqueMonitorIds = (items: GetMonitorStatusResult[]): Set<string> =>
|
||||
items.reduce((acc, { monitor_id }) => {
|
||||
acc.add(monitor_id);
|
||||
return acc;
|
||||
}, new Set<string>());
|
||||
|
||||
/**
|
||||
* Generates a message to include in contexts of alerts.
|
||||
* @param monitors the list of monitors to include in the message
|
||||
* @param max
|
||||
*/
|
||||
export const contextMessage = (monitorIds: string[], max: number): string => {
|
||||
const MIN = 2;
|
||||
if (max < MIN) throw new Error(`Maximum value must be greater than ${MIN}, received ${max}.`);
|
||||
|
||||
// generate the message
|
||||
let message;
|
||||
if (monitorIds.length === 1) {
|
||||
message = i18n.translate('xpack.uptime.alerts.message.singularTitle', {
|
||||
defaultMessage: 'Down monitor: ',
|
||||
});
|
||||
} else if (monitorIds.length) {
|
||||
message = i18n.translate('xpack.uptime.alerts.message.multipleTitle', {
|
||||
defaultMessage: 'Down monitors: ',
|
||||
});
|
||||
}
|
||||
// this shouldn't happen because the function should only be called
|
||||
// when > 0 monitors are down
|
||||
else {
|
||||
message = i18n.translate('xpack.uptime.alerts.message.emptyTitle', {
|
||||
defaultMessage: 'No down monitor IDs received',
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < monitorIds.length; i++) {
|
||||
const id = monitorIds[i];
|
||||
if (i === max) {
|
||||
return (
|
||||
message +
|
||||
i18n.translate('xpack.uptime.alerts.message.overflowBody', {
|
||||
defaultMessage: `... and {overflowCount} other monitors`,
|
||||
values: {
|
||||
overflowCount: monitorIds.length - i,
|
||||
},
|
||||
})
|
||||
);
|
||||
} else if (i === 0) {
|
||||
message = message + id;
|
||||
} else {
|
||||
message = message + `, ${id}`;
|
||||
}
|
||||
}
|
||||
|
||||
return message;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates an exhaustive list of all the down monitors.
|
||||
* @param list all the monitors that are down
|
||||
* @param sizeLimit the max monitors, we shouldn't allow an arbitrarily long string
|
||||
*/
|
||||
export const fullListByIdAndLocation = (
|
||||
list: GetMonitorStatusResult[],
|
||||
sizeLimit: number = 1000
|
||||
) => {
|
||||
return (
|
||||
list
|
||||
// sort by id, then location
|
||||
.sort((a, b) => {
|
||||
if (a.monitor_id > b.monitor_id) {
|
||||
return 1;
|
||||
} else if (a.monitor_id < b.monitor_id) {
|
||||
return -1;
|
||||
} else if (a.location > b.location) {
|
||||
return 1;
|
||||
}
|
||||
return -1;
|
||||
})
|
||||
.slice(0, sizeLimit)
|
||||
.reduce((cur, { monitor_id: id, location }) => cur + `${id} from ${location}; `, '') +
|
||||
(sizeLimit < list.length
|
||||
? i18n.translate('xpack.uptime.alerts.message.fullListOverflow', {
|
||||
defaultMessage: '...and {overflowCount} other {pluralizedMonitor}',
|
||||
values: {
|
||||
pluralizedMonitor:
|
||||
list.length - sizeLimit === 1 ? 'monitor/location' : 'monitors/locations',
|
||||
overflowCount: list.length - sizeLimit,
|
||||
},
|
||||
})
|
||||
: '')
|
||||
);
|
||||
};
|
||||
|
||||
export const updateState = (
|
||||
state: Record<string, any>,
|
||||
isTriggeredNow: boolean
|
||||
): StatusCheckAlertState => {
|
||||
const now = new Date().toISOString();
|
||||
const decoded = StatusCheckAlertStateType.decode(state);
|
||||
if (!isRight(decoded)) {
|
||||
const triggerVal = isTriggeredNow ? now : undefined;
|
||||
return {
|
||||
currentTriggerStarted: triggerVal,
|
||||
firstCheckedAt: now,
|
||||
firstTriggeredAt: triggerVal,
|
||||
isTriggered: isTriggeredNow,
|
||||
lastTriggeredAt: triggerVal,
|
||||
lastCheckedAt: now,
|
||||
lastResolvedAt: undefined,
|
||||
};
|
||||
}
|
||||
const {
|
||||
currentTriggerStarted,
|
||||
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;
|
||||
|
||||
let cts: string | undefined;
|
||||
if (isTriggeredNow && !currentTriggerStarted) {
|
||||
cts = now;
|
||||
} else if (isTriggeredNow) {
|
||||
cts = currentTriggerStarted;
|
||||
}
|
||||
|
||||
return {
|
||||
currentTriggerStarted: cts,
|
||||
firstCheckedAt: firstCheckedAt ?? now,
|
||||
firstTriggeredAt: isTriggeredNow && !firstTriggeredAt ? now : firstTriggeredAt,
|
||||
lastCheckedAt: now,
|
||||
lastTriggeredAt: isTriggeredNow ? now : lastTriggeredAt,
|
||||
lastResolvedAt: !isTriggeredNow && wasTriggered ? now : lastResolvedAt,
|
||||
isTriggered: isTriggeredNow,
|
||||
};
|
||||
};
|
||||
|
||||
// Right now the maximum number of monitors shown in the message is hardcoded here.
|
||||
// we might want to make this a parameter in the future
|
||||
const DEFAULT_MAX_MESSAGE_ROWS = 3;
|
||||
|
||||
export const statusCheckAlertFactory: UptimeAlertTypeFactory = (server, libs) => ({
|
||||
id: 'xpack.uptime.alerts.monitorStatus',
|
||||
name: i18n.translate('xpack.uptime.alerts.monitorStatus', {
|
||||
defaultMessage: 'Uptime Monitor Status',
|
||||
}),
|
||||
validate: {
|
||||
params: schema.object({
|
||||
filters: schema.maybe(schema.string()),
|
||||
numTimes: schema.number(),
|
||||
timerange: schema.object({
|
||||
from: schema.string(),
|
||||
to: schema.string(),
|
||||
}),
|
||||
locations: schema.arrayOf(schema.string()),
|
||||
}),
|
||||
},
|
||||
defaultActionGroupId: MONITOR_STATUS.id,
|
||||
actionGroups: [
|
||||
{
|
||||
id: MONITOR_STATUS.id,
|
||||
name: MONITOR_STATUS.name,
|
||||
},
|
||||
],
|
||||
async executor(options: AlertExecutorOptions) {
|
||||
const { params: rawParams } = options;
|
||||
const decoded = StatusCheckExecutorParamsType.decode(rawParams);
|
||||
if (!isRight(decoded)) {
|
||||
ThrowReporter.report(decoded);
|
||||
return {
|
||||
error: 'Alert param types do not conform to required shape.',
|
||||
};
|
||||
}
|
||||
|
||||
const params = decoded.right;
|
||||
|
||||
/* This is called `monitorsByLocation` but it's really
|
||||
* monitors by location by status. The query we run to generate this
|
||||
* filters on the status field, so effectively there should be one and only one
|
||||
* status represented in the result set. */
|
||||
const monitorsByLocation = await libs.requests.getMonitorStatus({
|
||||
callES: options.services.callCluster,
|
||||
...params,
|
||||
});
|
||||
|
||||
// if no monitors are down for our query, we don't need to trigger an alert
|
||||
if (monitorsByLocation.length) {
|
||||
const uniqueIds = uniqueMonitorIds(monitorsByLocation);
|
||||
const alertInstance = options.services.alertInstanceFactory(MONITOR_STATUS.id);
|
||||
alertInstance.replaceState({
|
||||
...options.state,
|
||||
monitors: monitorsByLocation,
|
||||
});
|
||||
alertInstance.scheduleActions(MONITOR_STATUS.id, {
|
||||
message: contextMessage(Array.from(uniqueIds.keys()), DEFAULT_MAX_MESSAGE_ROWS),
|
||||
server,
|
||||
completeIdList: fullListByIdAndLocation(monitorsByLocation),
|
||||
});
|
||||
}
|
||||
|
||||
// this stateful data is at the cluster level, not an alert instance level,
|
||||
// so any alert of this type will flush/overwrite the state when they return
|
||||
return updateState(options.state, monitorsByLocation.length > 0);
|
||||
},
|
||||
});
|
11
x-pack/plugins/uptime/server/lib/alerts/types.ts
Normal file
11
x-pack/plugins/uptime/server/lib/alerts/types.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { AlertType } from '../../../../alerting/server';
|
||||
import { UptimeCoreSetup } from '../adapters';
|
||||
import { UMServerLibs } from '../lib';
|
||||
|
||||
export type UptimeAlertTypeFactory = (server: UptimeCoreSetup, libs: UMServerLibs) => AlertType;
|
|
@ -0,0 +1,553 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { elasticsearchServiceMock } from '../../../../../../../src/core/server/mocks';
|
||||
import { getMonitorStatus } from '../get_monitor_status';
|
||||
import { ScopedClusterClient } from 'src/core/server/elasticsearch';
|
||||
|
||||
interface BucketItemCriteria {
|
||||
monitor_id: string;
|
||||
status: string;
|
||||
location: string;
|
||||
doc_count: number;
|
||||
}
|
||||
|
||||
interface BucketKey {
|
||||
monitor_id: string;
|
||||
status: string;
|
||||
location: string;
|
||||
}
|
||||
|
||||
interface BucketItem {
|
||||
key: BucketKey;
|
||||
doc_count: number;
|
||||
}
|
||||
|
||||
interface MultiPageCriteria {
|
||||
after_key?: BucketKey;
|
||||
bucketCriteria: BucketItemCriteria[];
|
||||
}
|
||||
|
||||
const genBucketItem = ({
|
||||
monitor_id,
|
||||
status,
|
||||
location,
|
||||
doc_count,
|
||||
}: BucketItemCriteria): BucketItem => ({
|
||||
key: {
|
||||
monitor_id,
|
||||
status,
|
||||
location,
|
||||
},
|
||||
doc_count,
|
||||
});
|
||||
|
||||
type MockCallES = (method: any, params: any) => Promise<any>;
|
||||
|
||||
const setupMock = (
|
||||
criteria: MultiPageCriteria[]
|
||||
): [MockCallES, jest.Mocked<Pick<ScopedClusterClient, 'callAsCurrentUser'>>] => {
|
||||
const esMock = elasticsearchServiceMock.createScopedClusterClient();
|
||||
|
||||
criteria.forEach(({ after_key, bucketCriteria }) => {
|
||||
const mockResponse = {
|
||||
aggregations: {
|
||||
monitors: {
|
||||
after_key,
|
||||
buckets: bucketCriteria.map(item => genBucketItem(item)),
|
||||
},
|
||||
},
|
||||
};
|
||||
esMock.callAsCurrentUser.mockResolvedValueOnce(mockResponse);
|
||||
});
|
||||
return [(method: any, params: any) => esMock.callAsCurrentUser(method, params), esMock];
|
||||
};
|
||||
|
||||
describe('getMonitorStatus', () => {
|
||||
it('applies bool filters to params', async () => {
|
||||
const [callES, esMock] = setupMock([]);
|
||||
const exampleFilter = `{
|
||||
"bool": {
|
||||
"should": [
|
||||
{
|
||||
"bool": {
|
||||
"should": [
|
||||
{
|
||||
"match_phrase": {
|
||||
"monitor.id": "apm-dev"
|
||||
}
|
||||
}
|
||||
],
|
||||
"minimum_should_match": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"bool": {
|
||||
"should": [
|
||||
{
|
||||
"match_phrase": {
|
||||
"monitor.id": "auto-http-0X8D6082B94BBE3B8A"
|
||||
}
|
||||
}
|
||||
],
|
||||
"minimum_should_match": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"minimum_should_match": 1
|
||||
}
|
||||
}`;
|
||||
await getMonitorStatus({
|
||||
callES,
|
||||
filters: exampleFilter,
|
||||
locations: [],
|
||||
numTimes: 5,
|
||||
timerange: {
|
||||
from: 'now-10m',
|
||||
to: 'now-1m',
|
||||
},
|
||||
});
|
||||
expect(esMock.callAsCurrentUser).toHaveBeenCalledTimes(1);
|
||||
const [method, params] = esMock.callAsCurrentUser.mock.calls[0];
|
||||
expect(method).toEqual('search');
|
||||
expect(params).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"body": Object {
|
||||
"aggs": Object {
|
||||
"monitors": Object {
|
||||
"composite": Object {
|
||||
"size": 2000,
|
||||
"sources": Array [
|
||||
Object {
|
||||
"monitor_id": Object {
|
||||
"terms": Object {
|
||||
"field": "monitor.id",
|
||||
},
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"status": Object {
|
||||
"terms": Object {
|
||||
"field": "monitor.status",
|
||||
},
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"location": Object {
|
||||
"terms": Object {
|
||||
"field": "observer.geo.name",
|
||||
"missing_bucket": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
"query": Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"term": Object {
|
||||
"monitor.status": "down",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"range": Object {
|
||||
"@timestamp": Object {
|
||||
"gte": "now-10m",
|
||||
"lte": "now-1m",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"minimum_should_match": 1,
|
||||
"should": Array [
|
||||
Object {
|
||||
"bool": Object {
|
||||
"minimum_should_match": 1,
|
||||
"should": Array [
|
||||
Object {
|
||||
"match_phrase": Object {
|
||||
"monitor.id": "apm-dev",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"bool": Object {
|
||||
"minimum_should_match": 1,
|
||||
"should": Array [
|
||||
Object {
|
||||
"match_phrase": Object {
|
||||
"monitor.id": "auto-http-0X8D6082B94BBE3B8A",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"size": 0,
|
||||
},
|
||||
"index": "heartbeat-8*",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('applies locations to params', async () => {
|
||||
const [callES, esMock] = setupMock([]);
|
||||
await getMonitorStatus({
|
||||
callES,
|
||||
locations: ['fairbanks', 'harrisburg'],
|
||||
numTimes: 1,
|
||||
timerange: {
|
||||
from: 'now-2m',
|
||||
to: 'now',
|
||||
},
|
||||
});
|
||||
expect(esMock.callAsCurrentUser).toHaveBeenCalledTimes(1);
|
||||
const [method, params] = esMock.callAsCurrentUser.mock.calls[0];
|
||||
expect(method).toEqual('search');
|
||||
expect(params).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"body": Object {
|
||||
"aggs": Object {
|
||||
"monitors": Object {
|
||||
"composite": Object {
|
||||
"size": 2000,
|
||||
"sources": Array [
|
||||
Object {
|
||||
"monitor_id": Object {
|
||||
"terms": Object {
|
||||
"field": "monitor.id",
|
||||
},
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"status": Object {
|
||||
"terms": Object {
|
||||
"field": "monitor.status",
|
||||
},
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"location": Object {
|
||||
"terms": Object {
|
||||
"field": "observer.geo.name",
|
||||
"missing_bucket": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
"query": Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"term": Object {
|
||||
"monitor.status": "down",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"range": Object {
|
||||
"@timestamp": Object {
|
||||
"gte": "now-2m",
|
||||
"lte": "now",
|
||||
},
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"bool": Object {
|
||||
"should": Array [
|
||||
Object {
|
||||
"term": Object {
|
||||
"observer.geo.name": "fairbanks",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"term": Object {
|
||||
"observer.geo.name": "harrisburg",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"size": 0,
|
||||
},
|
||||
"index": "heartbeat-8*",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('fetches single page of results', async () => {
|
||||
const [callES, esMock] = setupMock([
|
||||
{
|
||||
bucketCriteria: [
|
||||
{
|
||||
monitor_id: 'foo',
|
||||
status: 'down',
|
||||
location: 'fairbanks',
|
||||
doc_count: 43,
|
||||
},
|
||||
{
|
||||
monitor_id: 'bar',
|
||||
status: 'down',
|
||||
location: 'harrisburg',
|
||||
doc_count: 53,
|
||||
},
|
||||
{
|
||||
monitor_id: 'foo',
|
||||
status: 'down',
|
||||
location: 'harrisburg',
|
||||
doc_count: 44,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const clientParameters = {
|
||||
filters: undefined,
|
||||
locations: [],
|
||||
numTimes: 5,
|
||||
timerange: {
|
||||
from: 'now-12m',
|
||||
to: 'now-2m',
|
||||
},
|
||||
};
|
||||
const result = await getMonitorStatus({
|
||||
callES,
|
||||
...clientParameters,
|
||||
});
|
||||
expect(esMock.callAsCurrentUser).toHaveBeenCalledTimes(1);
|
||||
const [method, params] = esMock.callAsCurrentUser.mock.calls[0];
|
||||
expect(method).toEqual('search');
|
||||
expect(params).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"body": Object {
|
||||
"aggs": Object {
|
||||
"monitors": Object {
|
||||
"composite": Object {
|
||||
"size": 2000,
|
||||
"sources": Array [
|
||||
Object {
|
||||
"monitor_id": Object {
|
||||
"terms": Object {
|
||||
"field": "monitor.id",
|
||||
},
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"status": Object {
|
||||
"terms": Object {
|
||||
"field": "monitor.status",
|
||||
},
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"location": Object {
|
||||
"terms": Object {
|
||||
"field": "observer.geo.name",
|
||||
"missing_bucket": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
"query": Object {
|
||||
"bool": Object {
|
||||
"filter": Array [
|
||||
Object {
|
||||
"term": Object {
|
||||
"monitor.status": "down",
|
||||
},
|
||||
},
|
||||
Object {
|
||||
"range": Object {
|
||||
"@timestamp": Object {
|
||||
"gte": "now-12m",
|
||||
"lte": "now-2m",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"size": 0,
|
||||
},
|
||||
"index": "heartbeat-8*",
|
||||
}
|
||||
`);
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"count": 43,
|
||||
"location": "fairbanks",
|
||||
"monitor_id": "foo",
|
||||
"status": "down",
|
||||
},
|
||||
Object {
|
||||
"count": 53,
|
||||
"location": "harrisburg",
|
||||
"monitor_id": "bar",
|
||||
"status": "down",
|
||||
},
|
||||
Object {
|
||||
"count": 44,
|
||||
"location": "harrisburg",
|
||||
"monitor_id": "foo",
|
||||
"status": "down",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('fetches multiple pages of results in the thing', async () => {
|
||||
const criteria = [
|
||||
{
|
||||
after_key: {
|
||||
monitor_id: 'foo',
|
||||
location: 'harrisburg',
|
||||
status: 'down',
|
||||
},
|
||||
bucketCriteria: [
|
||||
{
|
||||
monitor_id: 'foo',
|
||||
status: 'down',
|
||||
location: 'fairbanks',
|
||||
doc_count: 43,
|
||||
},
|
||||
{
|
||||
monitor_id: 'bar',
|
||||
status: 'down',
|
||||
location: 'harrisburg',
|
||||
doc_count: 53,
|
||||
},
|
||||
{
|
||||
monitor_id: 'foo',
|
||||
status: 'down',
|
||||
location: 'harrisburg',
|
||||
doc_count: 44,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
after_key: {
|
||||
monitor_id: 'bar',
|
||||
status: 'down',
|
||||
location: 'fairbanks',
|
||||
},
|
||||
bucketCriteria: [
|
||||
{
|
||||
monitor_id: 'sna',
|
||||
status: 'down',
|
||||
location: 'fairbanks',
|
||||
doc_count: 21,
|
||||
},
|
||||
{
|
||||
monitor_id: 'fu',
|
||||
status: 'down',
|
||||
location: 'fairbanks',
|
||||
doc_count: 21,
|
||||
},
|
||||
{
|
||||
monitor_id: 'bar',
|
||||
status: 'down',
|
||||
location: 'fairbanks',
|
||||
doc_count: 45,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
bucketCriteria: [
|
||||
{
|
||||
monitor_id: 'sna',
|
||||
status: 'down',
|
||||
location: 'harrisburg',
|
||||
doc_count: 21,
|
||||
},
|
||||
{
|
||||
monitor_id: 'fu',
|
||||
status: 'down',
|
||||
location: 'harrisburg',
|
||||
doc_count: 21,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const [callES] = setupMock(criteria);
|
||||
const result = await getMonitorStatus({
|
||||
callES,
|
||||
locations: [],
|
||||
numTimes: 5,
|
||||
timerange: {
|
||||
from: 'now-10m',
|
||||
to: 'now-1m',
|
||||
},
|
||||
});
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"count": 43,
|
||||
"location": "fairbanks",
|
||||
"monitor_id": "foo",
|
||||
"status": "down",
|
||||
},
|
||||
Object {
|
||||
"count": 53,
|
||||
"location": "harrisburg",
|
||||
"monitor_id": "bar",
|
||||
"status": "down",
|
||||
},
|
||||
Object {
|
||||
"count": 44,
|
||||
"location": "harrisburg",
|
||||
"monitor_id": "foo",
|
||||
"status": "down",
|
||||
},
|
||||
Object {
|
||||
"count": 21,
|
||||
"location": "fairbanks",
|
||||
"monitor_id": "sna",
|
||||
"status": "down",
|
||||
},
|
||||
Object {
|
||||
"count": 21,
|
||||
"location": "fairbanks",
|
||||
"monitor_id": "fu",
|
||||
"status": "down",
|
||||
},
|
||||
Object {
|
||||
"count": 45,
|
||||
"location": "fairbanks",
|
||||
"monitor_id": "bar",
|
||||
"status": "down",
|
||||
},
|
||||
Object {
|
||||
"count": 21,
|
||||
"location": "harrisburg",
|
||||
"monitor_id": "sna",
|
||||
"status": "down",
|
||||
},
|
||||
Object {
|
||||
"count": 21,
|
||||
"location": "harrisburg",
|
||||
"monitor_id": "fu",
|
||||
"status": "down",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
150
x-pack/plugins/uptime/server/lib/requests/get_monitor_status.ts
Normal file
150
x-pack/plugins/uptime/server/lib/requests/get_monitor_status.ts
Normal file
|
@ -0,0 +1,150 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { UMElasticsearchQueryFn } from '../adapters';
|
||||
import { INDEX_NAMES } from '../../../../../legacy/plugins/uptime/common/constants';
|
||||
|
||||
export interface GetMonitorStatusParams {
|
||||
filters?: string;
|
||||
locations: string[];
|
||||
numTimes: number;
|
||||
timerange: { from: string; to: string };
|
||||
}
|
||||
|
||||
export interface GetMonitorStatusResult {
|
||||
monitor_id: string;
|
||||
status: string;
|
||||
location: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface MonitorStatusKey {
|
||||
monitor_id: string;
|
||||
status: string;
|
||||
location: string;
|
||||
}
|
||||
|
||||
const formatBuckets = async (
|
||||
buckets: any[],
|
||||
numTimes: number
|
||||
): Promise<GetMonitorStatusResult[]> => {
|
||||
return buckets
|
||||
.filter((monitor: any) => monitor?.doc_count > numTimes)
|
||||
.map(({ key, doc_count }: any) => ({ ...key, count: doc_count }));
|
||||
};
|
||||
|
||||
const getLocationClause = (locations: string[]) => ({
|
||||
bool: {
|
||||
should: [
|
||||
...locations.map(location => ({
|
||||
term: {
|
||||
'observer.geo.name': location,
|
||||
},
|
||||
})),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
export const getMonitorStatus: UMElasticsearchQueryFn<
|
||||
GetMonitorStatusParams,
|
||||
GetMonitorStatusResult[]
|
||||
> = async ({ callES, filters, locations, numTimes, timerange: { from, to } }) => {
|
||||
const queryResults: Array<Promise<GetMonitorStatusResult[]>> = [];
|
||||
let afterKey: MonitorStatusKey | undefined;
|
||||
|
||||
do {
|
||||
// today this value is hardcoded. In the future we may support
|
||||
// multiple status types for this alert, and this will become a parameter
|
||||
const STATUS = 'down';
|
||||
const esParams: any = {
|
||||
index: INDEX_NAMES.HEARTBEAT,
|
||||
body: {
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
{
|
||||
term: {
|
||||
'monitor.status': STATUS,
|
||||
},
|
||||
},
|
||||
{
|
||||
range: {
|
||||
'@timestamp': {
|
||||
gte: from,
|
||||
lte: to,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
size: 0,
|
||||
aggs: {
|
||||
monitors: {
|
||||
composite: {
|
||||
size: 2000,
|
||||
sources: [
|
||||
{
|
||||
monitor_id: {
|
||||
terms: {
|
||||
field: 'monitor.id',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
status: {
|
||||
terms: {
|
||||
field: 'monitor.status',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
location: {
|
||||
terms: {
|
||||
field: 'observer.geo.name',
|
||||
missing_bucket: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* `filters` are an unparsed JSON string. We parse them and append the bool fields of the query
|
||||
* to the bool of the parsed filters.
|
||||
*/
|
||||
if (filters) {
|
||||
const parsedFilters = JSON.parse(filters);
|
||||
esParams.body.query.bool = Object.assign({}, esParams.body.query.bool, parsedFilters.bool);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a logical `and` against the selected location filters.
|
||||
*/
|
||||
if (locations.length) {
|
||||
esParams.body.query.bool.filter.push(getLocationClause(locations));
|
||||
}
|
||||
|
||||
/**
|
||||
* We "paginate" results by utilizing the `afterKey` field
|
||||
* to tell Elasticsearch where it should start on subsequent queries.
|
||||
*/
|
||||
if (afterKey) {
|
||||
esParams.body.aggs.monitors.composite.after = afterKey;
|
||||
}
|
||||
|
||||
const result = await callES('search', esParams);
|
||||
afterKey = result?.aggregations?.monitors?.after_key;
|
||||
|
||||
queryResults.push(formatBuckets(result?.aggregations?.monitors?.buckets || [], numTimes));
|
||||
} while (afterKey !== undefined);
|
||||
|
||||
return (await Promise.all(queryResults)).reduce((acc, cur) => acc.concat(cur), []);
|
||||
};
|
|
@ -12,6 +12,8 @@ export { getMonitorDurationChart, GetMonitorChartsParams } from './get_monitor_d
|
|||
export { getMonitorDetails, GetMonitorDetailsParams } from './get_monitor_details';
|
||||
export { getMonitorLocations, GetMonitorLocationsParams } from './get_monitor_locations';
|
||||
export { getMonitorStates, GetMonitorStatesParams } from './get_monitor_states';
|
||||
export { getMonitorStatus, GetMonitorStatusParams } from './get_monitor_status';
|
||||
export * from './get_monitor_status';
|
||||
export { getPings, GetPingsParams } from './get_pings';
|
||||
export { getPingHistogram, GetPingHistogramParams } from './get_ping_histogram';
|
||||
export { UptimeRequests } from './uptime_requests';
|
||||
|
|
|
@ -16,6 +16,8 @@ import {
|
|||
GetMonitorStatesParams,
|
||||
GetPingsParams,
|
||||
GetPingHistogramParams,
|
||||
GetMonitorStatusParams,
|
||||
GetMonitorStatusResult,
|
||||
} from '.';
|
||||
import {
|
||||
OverviewFilters,
|
||||
|
@ -42,6 +44,7 @@ export interface UptimeRequests {
|
|||
getMonitorDetails: ESQ<GetMonitorDetailsParams, MonitorDetails>;
|
||||
getMonitorLocations: ESQ<GetMonitorLocationsParams, MonitorLocations>;
|
||||
getMonitorStates: ESQ<GetMonitorStatesParams, GetMonitorStatesResult>;
|
||||
getMonitorStatus: ESQ<GetMonitorStatusParams, GetMonitorStatusResult[]>;
|
||||
getPings: ESQ<GetPingsParams, PingResults>;
|
||||
getPingHistogram: ESQ<GetPingHistogramParams, HistogramResult>;
|
||||
getSnapshotCount: ESQ<GetSnapshotCountParams, Snapshot>;
|
||||
|
|
|
@ -8,12 +8,22 @@ import { makeExecutableSchema } from 'graphql-tools';
|
|||
import { DEFAULT_GRAPHQL_PATH, resolvers, typeDefs } from './graphql';
|
||||
import { UMServerLibs } from './lib/lib';
|
||||
import { createRouteWithAuth, restApiRoutes, uptimeRouteWrapper } from './rest_api';
|
||||
import { UptimeCoreSetup, UptimeCorePlugins } from './lib/adapters';
|
||||
import { uptimeAlertTypeFactories } from './lib/alerts';
|
||||
|
||||
export const initUptimeServer = (libs: UMServerLibs) => {
|
||||
export const initUptimeServer = (
|
||||
server: UptimeCoreSetup,
|
||||
libs: UMServerLibs,
|
||||
plugins: UptimeCorePlugins
|
||||
) => {
|
||||
restApiRoutes.forEach(route =>
|
||||
libs.framework.registerRoute(uptimeRouteWrapper(createRouteWithAuth(libs, route)))
|
||||
);
|
||||
|
||||
uptimeAlertTypeFactories.forEach(alertTypeFactory =>
|
||||
plugins.alerting.registerType(alertTypeFactory(server, libs))
|
||||
);
|
||||
|
||||
const graphQLSchema = makeExecutableSchema({
|
||||
resolvers: resolvers.map(createResolversFn => createResolversFn(libs)),
|
||||
typeDefs,
|
||||
|
|
|
@ -24,11 +24,13 @@ export function UptimePageProvider({ getPageObjects, getService }: FtrProviderCo
|
|||
public async goToUptimeOverviewAndLoadData(
|
||||
datePickerStartValue: string,
|
||||
datePickerEndValue: string,
|
||||
monitorIdToCheck: string
|
||||
monitorIdToCheck?: string
|
||||
) {
|
||||
await pageObjects.common.navigateToApp('uptime');
|
||||
await pageObjects.timePicker.setAbsoluteRange(datePickerStartValue, datePickerEndValue);
|
||||
await uptimeService.monitorIdExists(monitorIdToCheck);
|
||||
if (monitorIdToCheck) {
|
||||
await uptimeService.monitorIdExists(monitorIdToCheck);
|
||||
}
|
||||
}
|
||||
|
||||
public async loadDataAndGoToMonitorPage(
|
||||
|
@ -96,5 +98,39 @@ export function UptimePageProvider({ getPageObjects, getService }: FtrProviderCo
|
|||
public locationMissingIsDisplayed() {
|
||||
return uptimeService.locationMissingExists();
|
||||
}
|
||||
|
||||
public async openAlertFlyoutAndCreateMonitorStatusAlert({
|
||||
alertInterval,
|
||||
alertName,
|
||||
alertNumTimes,
|
||||
alertTags,
|
||||
alertThrottleInterval,
|
||||
alertTimerangeSelection,
|
||||
filters,
|
||||
}: {
|
||||
alertName: string;
|
||||
alertTags: string[];
|
||||
alertInterval: string;
|
||||
alertThrottleInterval: string;
|
||||
alertNumTimes: string;
|
||||
alertTimerangeSelection: string;
|
||||
filters?: string;
|
||||
}) {
|
||||
const { alerts, setKueryBarText } = uptimeService;
|
||||
await alerts.openFlyout();
|
||||
await alerts.openMonitorStatusAlertType();
|
||||
await alerts.setAlertName(alertName);
|
||||
await alerts.setAlertTags(alertTags);
|
||||
await alerts.setAlertInterval(alertInterval);
|
||||
await alerts.setAlertThrottleInterval(alertThrottleInterval);
|
||||
if (filters) {
|
||||
await setKueryBarText('xpack.uptime.alerts.monitorStatus.filterBar', filters);
|
||||
}
|
||||
await alerts.setAlertStatusNumTimes(alertNumTimes);
|
||||
await alerts.setAlertTimerangeSelection(alertTimerangeSelection);
|
||||
await alerts.setMonitorStatusSelectableToHours();
|
||||
await alerts.setLocationsSelectable();
|
||||
await alerts.clickSaveAlertButtion();
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
|
|
@ -12,6 +12,91 @@ export function UptimeProvider({ getService }: FtrProviderContext) {
|
|||
const retry = getService('retry');
|
||||
|
||||
return {
|
||||
alerts: {
|
||||
async openFlyout() {
|
||||
await testSubjects.click('xpack.uptime.alertsPopover.toggleButton', 5000);
|
||||
await testSubjects.click('xpack.uptime.toggleAlertFlyout', 5000);
|
||||
},
|
||||
async openMonitorStatusAlertType() {
|
||||
return testSubjects.click('xpack.uptime.alerts.monitorStatus-SelectOption', 5000);
|
||||
},
|
||||
async setAlertTags(tags: string[]) {
|
||||
for (let i = 0; i < tags.length; i += 1) {
|
||||
await testSubjects.click('comboBoxSearchInput', 5000);
|
||||
await testSubjects.setValue('comboBoxInput', tags[i]);
|
||||
await browser.pressKeys(browser.keys.ENTER);
|
||||
}
|
||||
},
|
||||
async setAlertName(name: string) {
|
||||
return testSubjects.setValue('alertNameInput', name);
|
||||
},
|
||||
async setAlertInterval(value: string) {
|
||||
return testSubjects.setValue('intervalInput', value);
|
||||
},
|
||||
async setAlertThrottleInterval(value: string) {
|
||||
return testSubjects.setValue('throttleInput', value);
|
||||
},
|
||||
async setAlertExpressionValue(
|
||||
expressionAttribute: string,
|
||||
fieldAttribute: string,
|
||||
value: string
|
||||
) {
|
||||
await testSubjects.click(expressionAttribute);
|
||||
await testSubjects.setValue(fieldAttribute, value);
|
||||
return browser.pressKeys(browser.keys.ESCAPE);
|
||||
},
|
||||
async setAlertStatusNumTimes(value: string) {
|
||||
return this.setAlertExpressionValue(
|
||||
'xpack.uptime.alerts.monitorStatus.numTimesExpression',
|
||||
'xpack.uptime.alerts.monitorStatus.numTimesField',
|
||||
value
|
||||
);
|
||||
},
|
||||
async setAlertTimerangeSelection(value: string) {
|
||||
return this.setAlertExpressionValue(
|
||||
'xpack.uptime.alerts.monitorStatus.timerangeValueExpression',
|
||||
'xpack.uptime.alerts.monitorStatus.timerangeValueField',
|
||||
value
|
||||
);
|
||||
},
|
||||
async setAlertExpressionSelectable(
|
||||
expressionAttribute: string,
|
||||
selectableAttribute: string,
|
||||
optionAttributes: string[]
|
||||
) {
|
||||
await testSubjects.click(expressionAttribute, 5000);
|
||||
await testSubjects.click(selectableAttribute, 5000);
|
||||
for (let i = 0; i < optionAttributes.length; i += 1) {
|
||||
await testSubjects.click(optionAttributes[i], 5000);
|
||||
}
|
||||
return browser.pressKeys(browser.keys.ESCAPE);
|
||||
},
|
||||
async setMonitorStatusSelectableToHours() {
|
||||
return this.setAlertExpressionSelectable(
|
||||
'xpack.uptime.alerts.monitorStatus.timerangeUnitExpression',
|
||||
'xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable',
|
||||
['xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.hoursOption']
|
||||
);
|
||||
},
|
||||
async setLocationsSelectable() {
|
||||
await testSubjects.click(
|
||||
'xpack.uptime.alerts.monitorStatus.locationsSelectionExpression',
|
||||
5000
|
||||
);
|
||||
await testSubjects.click(
|
||||
'xpack.uptime.alerts.monitorStatus.locationsSelectionSwitch',
|
||||
5000
|
||||
);
|
||||
await testSubjects.click(
|
||||
'xpack.uptime.alerts.monitorStatus.locationsSelectionSelectable',
|
||||
5000
|
||||
);
|
||||
return browser.pressKeys(browser.keys.ESCAPE);
|
||||
},
|
||||
async clickSaveAlertButtion() {
|
||||
return testSubjects.click('saveAlertButton');
|
||||
},
|
||||
},
|
||||
async assertExists(key: string) {
|
||||
if (!(await testSubjects.exists(key))) {
|
||||
throw new Error(`Couldn't find expected element with key "${key}".`);
|
||||
|
@ -35,11 +120,14 @@ export function UptimeProvider({ getService }: FtrProviderContext) {
|
|||
async getMonitorNameDisplayedOnPageTitle() {
|
||||
return await testSubjects.getVisibleText('monitor-page-title');
|
||||
},
|
||||
async setFilterText(filterQuery: string) {
|
||||
await testSubjects.click('xpack.uptime.filterBar');
|
||||
await testSubjects.setValue('xpack.uptime.filterBar', filterQuery);
|
||||
async setKueryBarText(attribute: string, value: string) {
|
||||
await testSubjects.click(attribute);
|
||||
await testSubjects.setValue(attribute, value);
|
||||
await browser.pressKeys(browser.keys.ENTER);
|
||||
},
|
||||
async setFilterText(filterQuery: string) {
|
||||
await this.setKueryBarText('xpack.uptime.filterBar', filterQuery);
|
||||
},
|
||||
async goToNextPage() {
|
||||
await testSubjects.click('xpack.uptime.monitorList.nextButton', 5000);
|
||||
},
|
||||
|
|
|
@ -0,0 +1,78 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import expect from '@kbn/expect';
|
||||
import { FtrProviderContext } from '../../ftr_provider_context';
|
||||
|
||||
export default ({ getPageObjects, getService }: FtrProviderContext) => {
|
||||
describe('overview page alert flyout controls', function() {
|
||||
const DEFAULT_DATE_START = 'Sep 10, 2019 @ 12:40:08.078';
|
||||
const DEFAULT_DATE_END = 'Sep 11, 2019 @ 19:40:08.078';
|
||||
const pageObjects = getPageObjects(['common', 'uptime']);
|
||||
const supertest = getService('supertest');
|
||||
const retry = getService('retry');
|
||||
|
||||
it('posts an alert, verfies its presence, and deletes the alert', async () => {
|
||||
await pageObjects.uptime.goToUptimeOverviewAndLoadData(DEFAULT_DATE_START, DEFAULT_DATE_END);
|
||||
|
||||
await pageObjects.uptime.openAlertFlyoutAndCreateMonitorStatusAlert({
|
||||
alertInterval: '11',
|
||||
alertName: 'uptime-test',
|
||||
alertNumTimes: '3',
|
||||
alertTags: ['uptime', 'another'],
|
||||
alertThrottleInterval: '30',
|
||||
alertTimerangeSelection: '1',
|
||||
filters: 'monitor.id: "0001-up"',
|
||||
});
|
||||
|
||||
// The creation of the alert could take some time, so the first few times we query after
|
||||
// the previous line resolves, the API may not be done creating the alert yet, so we
|
||||
// put the fetch code in a retry block with a timeout.
|
||||
let alert: any;
|
||||
await retry.tryForTime(15000, async () => {
|
||||
const apiResponse = await supertest.get('/api/alert/_find');
|
||||
const alertsFromThisTest = apiResponse.body.data.filter(
|
||||
({ name }: { name: string }) => name === 'uptime-test'
|
||||
);
|
||||
expect(alertsFromThisTest).to.have.length(1);
|
||||
alert = alertsFromThisTest[0];
|
||||
});
|
||||
|
||||
// Ensure the parameters and other stateful data
|
||||
// on the alert match up with the values we provided
|
||||
// for our test helper to input into the flyout.
|
||||
const {
|
||||
actions,
|
||||
alertTypeId,
|
||||
consumer,
|
||||
id,
|
||||
params: { numTimes, timerange, locations, filters },
|
||||
schedule: { interval },
|
||||
tags,
|
||||
} = alert;
|
||||
|
||||
// we're not testing the flyout's ability to associate alerts with action connectors
|
||||
expect(actions).to.eql([]);
|
||||
|
||||
expect(alertTypeId).to.eql('xpack.uptime.alerts.monitorStatus');
|
||||
expect(consumer).to.eql('uptime');
|
||||
expect(interval).to.eql('11m');
|
||||
expect(tags).to.eql(['uptime', 'another']);
|
||||
expect(numTimes).to.be(3);
|
||||
expect(timerange.from).to.be('now-1h');
|
||||
expect(timerange.to).to.be('now');
|
||||
expect(locations).to.eql(['mpls']);
|
||||
expect(filters).to.eql(
|
||||
'{"bool":{"should":[{"match_phrase":{"monitor.id":"0001-up"}}],"minimum_should_match":1}}'
|
||||
);
|
||||
|
||||
await supertest
|
||||
.delete(`/api/alert/${id}`)
|
||||
.set('kbn-xsrf', 'true')
|
||||
.expect(204);
|
||||
});
|
||||
});
|
||||
};
|
27
x-pack/test/functional_with_es_ssl/apps/uptime/index.ts
Normal file
27
x-pack/test/functional_with_es_ssl/apps/uptime/index.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import { FtrProviderContext } from '../../ftr_provider_context';
|
||||
|
||||
const ARCHIVE = 'uptime/full_heartbeat';
|
||||
|
||||
export default ({ getService, loadTestFile }: FtrProviderContext) => {
|
||||
const esArchiver = getService('esArchiver');
|
||||
const kibanaServer = getService('kibanaServer');
|
||||
|
||||
describe('Uptime app', function() {
|
||||
this.tags('ciGroup6');
|
||||
|
||||
describe('with real-world data', () => {
|
||||
before(async () => {
|
||||
await esArchiver.load(ARCHIVE);
|
||||
await kibanaServer.uiSettings.replace({ 'dateFormat:tz': 'UTC' });
|
||||
});
|
||||
after(async () => await esArchiver.unload(ARCHIVE));
|
||||
|
||||
loadTestFile(require.resolve('./alert_flyout'));
|
||||
});
|
||||
});
|
||||
};
|
|
@ -28,7 +28,10 @@ export default async function({ readConfigFile }: FtrConfigProviderContext) {
|
|||
services,
|
||||
pageObjects,
|
||||
// list paths to the files that contain your plugins tests
|
||||
testFiles: [resolve(__dirname, './apps/triggers_actions_ui')],
|
||||
testFiles: [
|
||||
resolve(__dirname, './apps/triggers_actions_ui'),
|
||||
resolve(__dirname, './apps/uptime'),
|
||||
],
|
||||
apps: {
|
||||
...xpackFunctionalConfig.get('apps'),
|
||||
triggersActions: {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue