[Uptime] Create new atomic params type for status alerts (#67720)

* Create new atomic params type for status alerts.

* Update executor params typing to support both alert params types.

* Update snapshot for alert factory function.

* Fix broken types and refresh snapshots.

* Clean up naming of action/selector.

* Fix a bug and add tests.

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Justin Kambic 2020-06-03 09:48:54 -04:00 committed by GitHub
parent 884704d847
commit 6f3e1bfbe5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 475 additions and 156 deletions

View file

@ -59,4 +59,32 @@ describe('stringifyKueries', () => {
kueries.set('monitor.id', ['https://elastic.co', 'https://example.com']);
expect(stringifyKueries(kueries)).toMatchSnapshot();
});
it('handles precending empty array', () => {
kueries = new Map<string, string[]>(
Object.entries({
'monitor.type': [],
'observer.geo.name': ['us-east', 'apj', 'sydney', 'us-west'],
tags: [],
'url.port': [],
})
);
expect(stringifyKueries(kueries)).toMatchInlineSnapshot(
`"(observer.geo.name:us-east or observer.geo.name:apj or observer.geo.name:sydney or observer.geo.name:us-west)"`
);
});
it('handles skipped empty arrays', () => {
kueries = new Map<string, string[]>(
Object.entries({
tags: [],
'monitor.type': ['http'],
'url.port': [],
'observer.geo.name': ['us-east', 'apj', 'sydney', 'us-west'],
})
);
expect(stringifyKueries(kueries)).toMatchInlineSnapshot(
`"monitor.type:http and (observer.geo.name:us-east or observer.geo.name:apj or observer.geo.name:sydney or observer.geo.name:us-west)"`
);
});
});

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export * from './combine_filters_and_user_search';
export * from './stringify_kueries';

View file

@ -35,6 +35,10 @@ export const stringifyKueries = (kueries: Map<string, Array<number | string>>):
.reduce((prev, cur, index, array) => {
if (array.length === 1 || index === 0) {
return cur;
} else if (cur === '') {
return prev;
} else if (prev === '' && !!cur) {
return cur;
}
return `${prev} and ${cur}`;
}, '');

View file

@ -6,7 +6,30 @@
import * as t from 'io-ts';
export const StatusCheckExecutorParamsType = t.intersection([
export const StatusCheckFiltersType = t.type({
'monitor.type': t.array(t.string),
'observer.geo.name': t.array(t.string),
tags: t.array(t.string),
'url.port': t.array(t.string),
});
export type StatusCheckFilters = t.TypeOf<typeof StatusCheckFiltersType>;
export const AtomicStatusCheckParamsType = t.intersection([
t.type({
numTimes: t.number,
timerangeCount: t.number,
timerangeUnit: t.string,
}),
t.partial({
search: t.string,
filters: StatusCheckFiltersType,
}),
]);
export type AtomicStatusCheckParams = t.TypeOf<typeof AtomicStatusCheckParamsType>;
export const StatusCheckParamsType = t.intersection([
t.partial({
filters: t.string,
}),
@ -20,4 +43,4 @@ export const StatusCheckExecutorParamsType = t.intersection([
}),
]);
export type StatusCheckExecutorParams = t.TypeOf<typeof StatusCheckExecutorParamsType>;
export type StatusCheckParams = t.TypeOf<typeof StatusCheckParamsType>;

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import { EuiSpacer } from '@elastic/eui';
import { DataPublicPluginSetup } from 'src/plugins/data/public';
import * as labels from './translations';
@ -35,10 +35,6 @@ export const AlertMonitorStatusComponent: React.FC<AlertMonitorStatusProps> = (p
const [newFilters, setNewFilters] = useState<string[]>([]);
useEffect(() => {
setAlertParams('filters', filters);
}, [filters, setAlertParams]);
return (
<>
<EuiSpacer size="m" />

View file

@ -4,10 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import React, { useEffect } from 'react';
import { useSelector } from 'react-redux';
import { DataPublicPluginSetup } from 'src/plugins/data/public';
import { selectMonitorStatusAlert } from '../../../../state/selectors';
import { selectMonitorStatusAlert, searchTextSelector } from '../../../../state/selectors';
import { AlertMonitorStatusComponent } from '../index';
interface Props {
@ -29,6 +29,12 @@ export const AlertMonitorStatus: React.FC<Props> = ({
timerange,
}) => {
const { filters, locations } = useSelector(selectMonitorStatusAlert);
const searchText = useSelector(searchTextSelector);
useEffect(() => {
setAlertParams('search', searchText);
}, [setAlertParams, searchText]);
return (
<AlertMonitorStatusComponent
autocomplete={autocomplete}

View file

@ -12,6 +12,7 @@ import { overviewFiltersSelector } from '../../../../state/selectors';
import { useFilterUpdate } from '../../../../hooks/use_filter_update';
import { filterLabels } from '../../filter_group/translations';
import { alertFilterLabels } from './translations';
import { StatusCheckFilters } from '../../../../../common/runtime_types';
interface Props {
newFilters: string[];
@ -38,19 +39,22 @@ export const FiltersExpressionsSelect: React.FC<Props> = ({
updatedFieldValues.values
);
useEffect(() => {
if (updatedFieldValues.fieldName === 'observer.geo.name') {
setAlertParams('locations', updatedFieldValues.values);
}
}, [setAlertParams, updatedFieldValues]);
const [filters, setFilters] = useState<StatusCheckFilters>({
'observer.geo.name': selectedLocations,
'url.port': selectedPorts,
tags: selectedTags,
'monitor.type': selectedSchemes,
});
useEffect(() => {
setAlertParams('locations', []);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
setAlertParams('filters', filters);
}, [filters, setAlertParams]);
const onFilterFieldChange = (fieldName: string, values: string[]) => {
setFilters({
...filters,
[fieldName]: values,
});
setUpdatedFieldValues({ fieldName, values });
};

View file

@ -51,7 +51,8 @@ export const TimeExpressionSelect: React.FC<Props> = ({ setAlertParams }) => {
useEffect(() => {
const timerangeUnit = timerangeUnitOptions.find(({ checked }) => checked === 'on')?.key ?? 'm';
setAlertParams('timerange', { from: `now-${numUnits}${timerangeUnit}`, to: 'now' });
setAlertParams('timerangeUnit', timerangeUnit);
setAlertParams('timerangeCount', numUnits);
}, [numUnits, timerangeUnitOptions, setAlertParams]);
return (

View file

@ -4,13 +4,13 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { uniqueId, startsWith } from 'lodash';
import { EuiCallOut } from '@elastic/eui';
import styled from 'styled-components';
import { FormattedMessage } from '@kbn/i18n/react';
import { Typeahead } from './typeahead';
import { useUrlParams } from '../../../hooks';
import { useSearchText, useUrlParams } from '../../../hooks';
import {
esKuery,
IIndexPattern,
@ -45,6 +45,7 @@ export function KueryBar({
'data-test-subj': dataTestSubj,
}: Props) {
const { loading, index_pattern: indexPattern } = useIndexPattern();
const { updateSearchText } = useSearchText();
const [state, setState] = useState<State>({
suggestions: [],
@ -56,6 +57,10 @@ export function KueryBar({
const [getUrlParams, updateUrlParams] = useUrlParams();
const { search: kuery } = getUrlParams();
useEffect(() => {
updateSearchText(kuery);
}, [kuery, updateSearchText]);
const indexPatternMissing = loading && !indexPattern;
async function onChange(inputValue: string, selectionStart: number) {
@ -63,6 +68,8 @@ export function KueryBar({
return;
}
updateSearchText(inputValue);
setIsLoadingSuggestions(true);
setState({ ...state, suggestions: [] });

View file

@ -9,3 +9,4 @@ export * from './use_url_params';
export * from './use_telemetry';
export * from './update_kuery_string';
export * from './use_cert_status';
export * from './use_search_text';

View file

@ -4,8 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { combineFiltersAndUserSearch, stringifyKueries } from '../lib/helper';
import { esKuery, IIndexPattern } from '../../../../../src/plugins/data/public';
import { combineFiltersAndUserSearch, stringifyKueries } from '../../common/lib';
const getKueryString = (urlFilters: string): string => {
let kueryString = '';

View file

@ -0,0 +1,22 @@
/*
* 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 { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { setSearchTextAction } from '../state/actions';
import { searchTextSelector } from '../state/selectors';
export const useSearchText = () => {
const dispatch = useDispatch();
const searchText = useSelector(searchTextSelector);
const updateSearchText = useCallback(
(nextSearchText: string) => dispatch(setSearchTextAction(nextSearchText)),
[dispatch]
);
return { searchText, updateSearchText };
};

View file

@ -12,12 +12,9 @@ describe('monitor status alert type', () => {
beforeEach(() => {
params = {
locations: [],
numTimes: 5,
timerange: {
from: 'now-15m',
to: 'now',
},
timerangeCount: 15,
timerangeUnit: 'm',
};
});
@ -27,9 +24,9 @@ describe('monitor status alert type', () => {
"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 }",
"Invalid value undefined supplied to : ({ numTimes: number, timerangeCount: number, timerangeUnit: string } & Partial<{ search: string, filters: { monitor.type: Array<string>, observer.geo.name: Array<string>, tags: Array<string>, url.port: Array<string> } }>)/0: { numTimes: number, timerangeCount: number, timerangeUnit: string }/numTimes: number",
"Invalid value undefined supplied to : ({ numTimes: number, timerangeCount: number, timerangeUnit: string } & Partial<{ search: string, filters: { monitor.type: Array<string>, observer.geo.name: Array<string>, tags: Array<string>, url.port: Array<string> } }>)/0: { numTimes: number, timerangeCount: number, timerangeUnit: string }/timerangeCount: number",
"Invalid value undefined supplied to : ({ numTimes: number, timerangeCount: number, timerangeUnit: string } & Partial<{ search: string, filters: { monitor.type: Array<string>, observer.geo.name: Array<string>, tags: Array<string>, url.port: Array<string> } }>)/0: { numTimes: number, timerangeCount: number, timerangeUnit: string }/timerangeUnit: string",
],
},
}
@ -37,88 +34,21 @@ describe('monitor status alert type', () => {
});
describe('timerange', () => {
it('is undefined', () => {
delete params.timerange;
expect(validate(params)).toMatchInlineSnapshot(`
it('has invalid timerangeCount value', () => {
expect(validate({ ...params, timerangeCount: 0 })).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 }",
],
"invalidTimeRangeValue": "Time range value must be greater than 0",
},
}
`);
});
it('is missing `from` or `to` value', () => {
expect(
validate({
...params,
timerange: {},
})
).toMatchInlineSnapshot(`
it('has NaN timerangeCount value', () => {
expect(validate({ ...params, timerangeCount: NaN })).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 unparseable `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 unparseable `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",
"timeRangeStartValueNaN": "Specified time range value must be a number",
},
}
`);
@ -133,7 +63,7 @@ describe('monitor status alert type', () => {
"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",
"Invalid value undefined supplied to : ({ numTimes: number, timerangeCount: number, timerangeUnit: string } & Partial<{ search: string, filters: { monitor.type: Array<string>, observer.geo.name: Array<string>, tags: Array<string>, url.port: Array<string> } }>)/0: { numTimes: number, timerangeCount: number, timerangeUnit: string }/numTimes: number",
],
},
}
@ -146,7 +76,7 @@ describe('monitor status alert type', () => {
"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",
"Invalid value \\"this isn't a number\\" supplied to : ({ numTimes: number, timerangeCount: number, timerangeUnit: string } & Partial<{ search: string, filters: { monitor.type: Array<string>, observer.geo.name: Array<string>, tags: Array<string>, url.port: Array<string> } }>)/0: { numTimes: number, timerangeCount: number, timerangeUnit: string }/numTimes: number",
],
},
}

View file

@ -4,51 +4,34 @@
* 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 { PathReporter } from 'io-ts/lib/PathReporter';
import { AlertTypeModel } from '../../../../triggers_actions_ui/public';
import { AlertTypeInitializer } from '.';
import { StatusCheckExecutorParamsType } from '../../../common/runtime_types';
import { AtomicStatusCheckParamsType } from '../../../common/runtime_types';
import { MonitorStatusTitle } from './monitor_status_title';
import { CLIENT_ALERT_TYPES } from '../../../common/constants';
import { MonitorStatusTranslations } from './translations';
export const validate = (alertParams: any) => {
export const validate = (alertParams: unknown) => {
const errors: Record<string, any> = {};
const decoded = StatusCheckExecutorParamsType.decode(alertParams);
const decoded = AtomicStatusCheckParamsType.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';
}
} else {
const { numTimes, timerangeCount } = decoded.right;
if (numTimes < 1) {
errors.invalidNumTimes = 'Number of alert check down times must be an integer greater than 0';
}
if (isNaN(timerangeCount)) {
errors.timeRangeStartValueNaN = 'Specified time range value must be a number';
}
if (timerangeCount <= 0) {
errors.invalidTimeRangeValue = 'Time range value must be greater than 0';
}
}
return { errors };

View file

@ -4,10 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { combineFiltersAndUserSearch } from './combine_filters_and_user_search';
export { convertMicrosecondsToMilliseconds } from './convert_measurements';
export * from './observability_integration';
export { getChartDateLabel } from './charts';
export { seriesHasDownValues } from './series_has_down_values';
export { stringifyKueries } from './stringify_kueries';
export { UptimeUrlParams, getSupportedUrlParams } from './url_params';

View file

@ -20,6 +20,8 @@ export const setBasePath = createAction<string>('SET BASE PATH');
export const setEsKueryString = createAction<string>('SET ES KUERY STRING');
export const setSearchTextAction = createAction<string>('SET SEARCH');
export const toggleIntegrationsPopover = createAction<PopoverState>(
'TOGGLE INTEGRATION POPOVER STATE'
);

View file

@ -9,6 +9,7 @@ Object {
"id": "popover-2",
"open": true,
},
"searchText": "",
}
`;
@ -18,5 +19,6 @@ Object {
"basePath": "yyz",
"esKuery": "",
"integrationsPopoverOpen": null,
"searchText": "",
}
`;

View file

@ -18,6 +18,7 @@ describe('ui reducer', () => {
basePath: 'abc',
esKuery: '',
integrationsPopoverOpen: null,
searchText: '',
},
action
)
@ -36,6 +37,7 @@ describe('ui reducer', () => {
basePath: '',
esKuery: '',
integrationsPopoverOpen: null,
searchText: '',
},
action
)
@ -51,6 +53,7 @@ describe('ui reducer', () => {
basePath: '',
esKuery: '',
integrationsPopoverOpen: null,
searchText: '',
},
action
)
@ -60,6 +63,7 @@ describe('ui reducer', () => {
"basePath": "",
"esKuery": "",
"integrationsPopoverOpen": null,
"searchText": "",
}
`);
});

View file

@ -13,6 +13,7 @@ import {
UiPayload,
setAlertFlyoutType,
setAlertFlyoutVisible,
setSearchTextAction,
} from '../actions';
export interface UiState {
@ -20,6 +21,7 @@ export interface UiState {
alertFlyoutType?: string;
basePath: string;
esKuery: string;
searchText: string;
integrationsPopoverOpen: PopoverState | null;
}
@ -27,6 +29,7 @@ const initialState: UiState = {
alertFlyoutVisible: false,
basePath: '',
esKuery: '',
searchText: '',
integrationsPopoverOpen: null,
};
@ -56,6 +59,11 @@ export const uiReducer = handleActions<UiState, UiPayload>(
...state,
alertFlyoutType: action.payload,
}),
[String(setSearchTextAction)]: (state, action: Action<string>) => ({
...state,
searchText: action.payload,
}),
},
initialState
);

View file

@ -44,6 +44,7 @@ describe('state selectors', () => {
basePath: 'yyz',
esKuery: '',
integrationsPopoverOpen: null,
searchText: '',
},
monitorStatus: {
status: null,

View file

@ -84,3 +84,5 @@ export const monitorListSelector = ({ monitorList }: AppState) => monitorList;
export const overviewFiltersSelector = ({ overviewFilters }: AppState) => overviewFilters;
export const esKuerySelector = ({ ui: { esKuery } }: AppState) => esKuery;
export const searchTextSelector = ({ ui: { searchText } }: AppState) => searchText;

View file

@ -23,7 +23,7 @@ export type APICaller = (
export type UMElasticsearchQueryFn<P, R = any> = (
params: { callES: APICaller; dynamicSettings: DynamicSettings } & P
) => Promise<R> | R;
) => Promise<R>;
export type UMSavedObjectsQueryFn<T = any, P = undefined> = (
client: SavedObjectsClientContract | ISavedObjectsRepository,

View file

@ -6,9 +6,11 @@
import {
contextMessage,
uniqueMonitorIds,
statusCheckAlertFactory,
fullListByIdAndLocation,
genFilterString,
hasFilters,
statusCheckAlertFactory,
uniqueMonitorIds,
} from '../status_check';
import { GetMonitorStatusResult } from '../../requests';
import { AlertType } from '../../../../../alerts/server';
@ -310,9 +312,12 @@ describe('status check alert', () => {
expect(Object.keys(alert.validate?.params?.props ?? {})).toMatchInlineSnapshot(`
Array [
"filters",
"numTimes",
"timerange",
"locations",
"numTimes",
"search",
"timerangeCount",
"timerangeUnit",
"timerange",
]
`);
});
@ -332,6 +337,205 @@ describe('status check alert', () => {
});
});
describe('hasFilters', () => {
it('returns false for undefined filters', () => {
expect(hasFilters()).toBe(false);
});
it('returns false for empty filters', () => {
expect(
hasFilters({
'monitor.type': [],
'observer.geo.name': [],
tags: [],
'url.port': [],
})
).toBe(false);
});
it('returns true for an object with a filter', () => {
expect(
hasFilters({
'monitor.type': [],
'observer.geo.name': ['us-east', 'us-west'],
tags: [],
'url.port': [],
})
).toBe(true);
});
});
describe('genFilterString', () => {
const mockGetIndexPattern = jest.fn();
mockGetIndexPattern.mockReturnValue(undefined);
it('returns `undefined` for no filters or search', async () => {
expect(await genFilterString(mockGetIndexPattern)).toBeUndefined();
});
it('creates a filter string for filters only', async () => {
const res = await genFilterString(mockGetIndexPattern, {
'monitor.type': [],
'observer.geo.name': ['us-east', 'us-west'],
tags: [],
'url.port': [],
});
expect(res).toMatchInlineSnapshot(`
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match": Object {
"observer.geo.name": "us-east",
},
},
],
},
},
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match": Object {
"observer.geo.name": "us-west",
},
},
],
},
},
],
},
}
`);
});
it('creates a filter string for search only', async () => {
expect(await genFilterString(mockGetIndexPattern, undefined, 'monitor.id: "kibana-dev"'))
.toMatchInlineSnapshot(`
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match_phrase": Object {
"monitor.id": "kibana-dev",
},
},
],
},
}
`);
});
it('creates a filter string for filters and string', async () => {
const res = await genFilterString(
mockGetIndexPattern,
{
'monitor.type': [],
'observer.geo.name': ['us-east', 'apj', 'sydney', 'us-west'],
tags: [],
'url.port': [],
},
'monitor.id: "kibana-dev"'
);
expect(res).toMatchInlineSnapshot(`
Object {
"bool": Object {
"filter": Array [
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match": Object {
"observer.geo.name": "us-east",
},
},
],
},
},
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match": Object {
"observer.geo.name": "apj",
},
},
],
},
},
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match": Object {
"observer.geo.name": "sydney",
},
},
],
},
},
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match": Object {
"observer.geo.name": "us-west",
},
},
],
},
},
],
},
},
],
},
},
],
},
},
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match_phrase": Object {
"monitor.id": "kibana-dev",
},
},
],
},
},
],
},
}
`);
});
});
describe('uniqueMonitorIds', () => {
let items: GetMonitorStatusResult[];
beforeEach(() => {

View file

@ -11,11 +11,19 @@ import { i18n } from '@kbn/i18n';
import { AlertExecutorOptions } from '../../../../alerts/server';
import { UptimeAlertTypeFactory } from './types';
import { GetMonitorStatusResult } from '../requests';
import { StatusCheckExecutorParamsType } from '../../../common/runtime_types';
import { esKuery, IIndexPattern } from '../../../../../../src/plugins/data/server';
import { JsonObject } from '../../../../../../src/plugins/kibana_utils/common';
import {
StatusCheckParamsType,
StatusCheckParams,
StatusCheckFilters,
AtomicStatusCheckParamsType,
} from '../../../common/runtime_types';
import { ACTION_GROUP_DEFINITIONS } from '../../../common/constants';
import { savedObjectsAdapter } from '../saved_objects';
import { updateState } from './common';
import { commonStateTranslations } from './translations';
import { stringifyKueries, combineFiltersAndUserSearch } from '../../../common/lib';
const { MONITOR_STATUS } = ACTION_GROUP_DEFINITIONS;
@ -124,6 +132,44 @@ export const fullListByIdAndLocation = (
// we might want to make this a parameter in the future
const DEFAULT_MAX_MESSAGE_ROWS = 3;
export const hasFilters = (filters?: StatusCheckFilters) => {
if (!filters) return false;
for (const list of Object.values(filters)) {
if (list.length > 0) {
return true;
}
}
return false;
};
export const genFilterString = async (
getIndexPattern: () => Promise<IIndexPattern | undefined>,
filters?: StatusCheckFilters,
search?: string
): Promise<JsonObject | undefined> => {
const filtersExist = hasFilters(filters);
if (!filtersExist && !search) return undefined;
let filterString: string | undefined;
if (filtersExist) {
filterString = stringifyKueries(new Map(Object.entries(filters ?? {})));
}
let combinedString: string | undefined;
if (filterString && search) {
combinedString = combineFiltersAndUserSearch(filterString, search);
} else if (filterString) {
combinedString = filterString;
} else if (search) {
combinedString = search;
}
return esKuery.toElasticsearchQuery(
esKuery.fromKueryExpression(combinedString ?? ''),
await getIndexPattern()
);
};
export const statusCheckAlertFactory: UptimeAlertTypeFactory = (_server, libs) => ({
id: 'xpack.uptime.alerts.monitorStatus',
name: i18n.translate('xpack.uptime.alerts.monitorStatus', {
@ -131,13 +177,28 @@ export const statusCheckAlertFactory: UptimeAlertTypeFactory = (_server, libs) =
}),
validate: {
params: schema.object({
filters: schema.maybe(schema.string()),
filters: schema.maybe(
schema.oneOf([
schema.object({
'monitor.type': schema.maybe(schema.arrayOf(schema.string())),
'observer.geo.name': schema.maybe(schema.arrayOf(schema.string())),
tags: schema.maybe(schema.arrayOf(schema.string())),
'url.port': schema.maybe(schema.arrayOf(schema.string())),
}),
schema.string(),
])
),
locations: schema.maybe(schema.arrayOf(schema.string())),
numTimes: schema.number(),
timerange: schema.object({
from: schema.string(),
to: schema.string(),
}),
locations: schema.arrayOf(schema.string()),
search: schema.maybe(schema.string()),
timerangeCount: schema.maybe(schema.number()),
timerangeUnit: schema.maybe(schema.string()),
timerange: schema.maybe(
schema.object({
from: schema.string(),
to: schema.string(),
})
),
}),
},
defaultActionGroupId: MONITOR_STATUS.id,
@ -174,18 +235,41 @@ export const statusCheckAlertFactory: UptimeAlertTypeFactory = (_server, libs) =
producer: 'uptime',
async executor(options: AlertExecutorOptions) {
const { params: rawParams } = options;
const decoded = StatusCheckExecutorParamsType.decode(rawParams);
if (!isRight(decoded)) {
const dynamicSettings = await savedObjectsAdapter.getUptimeDynamicSettings(
options.services.savedObjectsClient
);
const atomicDecoded = AtomicStatusCheckParamsType.decode(rawParams);
const decoded = StatusCheckParamsType.decode(rawParams);
let params: StatusCheckParams;
if (isRight(atomicDecoded)) {
const { filters, search, numTimes, timerangeCount, timerangeUnit } = atomicDecoded.right;
const timerange = { from: `now-${String(timerangeCount) + timerangeUnit}`, to: 'now' };
const filterString = JSON.stringify(
await genFilterString(
() =>
libs.requests.getIndexPattern({
callES: options.services.callCluster,
dynamicSettings,
}),
filters,
search
)
);
params = {
timerange,
numTimes,
locations: [],
filters: filterString,
};
} else if (isRight(decoded)) {
params = decoded.right;
} else {
ThrowReporter.report(decoded);
return {
error: 'Alert param types do not conform to required shape.',
};
}
const params = decoded.right;
const dynamicSettings = await savedObjectsAdapter.getUptimeDynamicSettings(
options.services.savedObjectsClient
);
/* 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

View file

@ -8,7 +8,7 @@ import { APICaller, CallAPIOptions } from 'src/core/server';
import { UMElasticsearchQueryFn } from '../adapters';
import { IndexPatternsFetcher, IIndexPattern } from '../../../../../../src/plugins/data/server';
export const getUptimeIndexPattern: UMElasticsearchQueryFn<{}, {}> = async ({
export const getUptimeIndexPattern: UMElasticsearchQueryFn<{}, IIndexPattern | undefined> = async ({
callES,
dynamicSettings,
}) => {

View file

@ -33,13 +33,14 @@ import {
} from '.';
import { GetMonitorStatesResult } from './get_monitor_states';
import { GetSnapshotCountParams } from './get_snapshot_counts';
import { IIndexPattern } from '../../../../../../src/plugins/data/server';
type ESQ<P, R> = UMElasticsearchQueryFn<P, R>;
export interface UptimeRequests {
getCerts: ESQ<GetCertsParams, CertResult>;
getFilterBar: ESQ<GetFilterBarParams, OverviewFilters>;
getIndexPattern: ESQ<{}, {}>;
getIndexPattern: ESQ<{}, IIndexPattern | undefined>;
getLatestMonitor: ESQ<GetLatestMonitorParams, Ping>;
getMonitorDurationChart: ESQ<GetMonitorChartsParams, MonitorDurationResult>;
getMonitorDetails: ESQ<GetMonitorDetailsParams, MonitorDetails>;