[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:
Justin Kambic 2020-03-19 12:50:05 -04:00 committed by GitHub
parent a0730f7951
commit fcf439625b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
59 changed files with 3245 additions and 44 deletions

View file

@ -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": [

View file

@ -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`

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* 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',
},
};

View file

@ -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';

View file

@ -6,5 +6,4 @@
export const INDEX_NAMES = {
HEARTBEAT: 'heartbeat-8*',
HEARTBEAT_STATES: 'heartbeat-states-8*',
};

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export {
StatusCheckAlertStateType,
StatusCheckAlertState,
StatusCheckExecutorParamsType,
StatusCheckExecutorParams,
} from './status_check';

View file

@ -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>;

View file

@ -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';

View file

@ -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', {

View file

@ -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);

View file

@ -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',

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* 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}
/>
);
};

View file

@ -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';

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* 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))}
/>
);
};

View file

@ -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}
/>
);
};

View file

@ -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';

View file

@ -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) });

View file

@ -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('');
});
});
});

View file

@ -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)
}
/>
)}
</>
);
};

View 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.
*/
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';

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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}
/>
);

View file

@ -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';

View file

@ -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}

View file

@ -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',

View file

@ -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"

View file

@ -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,

View file

@ -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],
}
`);
});
});

View 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];

View file

@ -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}}',
});

View file

@ -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"

View file

@ -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 = () => {

View file

@ -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} />

View file

@ -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" />

View file

@ -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');

View file

@ -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,

View file

@ -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,
}
`);
});
});

View file

@ -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,

View file

@ -35,6 +35,7 @@ describe('state selectors', () => {
loading: false,
},
ui: {
alertFlyoutVisible: false,
basePath: 'yyz',
esKuery: '',
integrationsPopoverOpen: null,

View file

@ -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;
};

View file

@ -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>

View file

@ -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"

View file

@ -55,5 +55,5 @@ export const initServerWithKibana = (server: UptimeCoreSetup, plugins: UptimeCor
},
});
initUptimeServer(libs);
initUptimeServer(server, libs, plugins);
};

View file

@ -31,6 +31,8 @@ export interface UptimeCoreSetup {
export interface UptimeCorePlugins {
features: PluginSetupContract;
alerting: any;
elasticsearch: any;
usageCollection: UsageCollectionSetup;
}

View file

@ -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"`);
});
});
});

View 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];

View 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);
},
});

View 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;

View file

@ -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",
},
]
`);
});
});

View 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), []);
};

View file

@ -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';

View file

@ -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>;

View file

@ -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,

View file

@ -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();
}
})();
}

View file

@ -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);
},

View file

@ -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);
});
});
};

View 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'));
});
});
};

View file

@ -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: {