[Uptime] One click simple monitor down alert (#73835)

* WIP

* added anomaly alert

* update types

* update types

* update

* types

* types

* update ML part

* update ML part

* update ML part

* unnecessary change

* icon for disable

* update test

* update api

* update labels

* resolve conflicts

* fix types

* fix editing alert

* fix types

* added actions column

* added code to add alert

* update anomaly message

* added anomaly alert test

* update

* update type

* fix ml legacy scoped client

* update

* WIP

* fix conflict

* added aria label

* Added deleteion loading

* fix type

* update

* update tests

* update

* update type

* fix types

* WIP

* added enabled alerts section

* add data

* update

* update tests

* fix test

* update i18n

* update i18n

* update i18n

* fix

* update message

* update

* update

* update

* revert

* update types

* added component

* update test

* incorporate PR feedback

* fix focus

* update drawer

* handle edge case

* improve btn text

* improve btn text

* use switch instead of icons

* update snapshot

* use compressed form

* fix type

* update snapshot

* update snapshot

* update test

* update test

* PR feedback

* fix test and type

* remove delete action

* remove unnecessary function

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Shahzad 2020-08-27 07:02:28 +02:00 committed by GitHub
parent 42942327e5
commit a358c5768e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
93 changed files with 1829 additions and 266 deletions

View file

@ -21,6 +21,7 @@ export {
AlertTypeParamsExpressionProps,
ValidationResult,
ActionVariable,
ActionConnector,
} from './types';
export {
ConnectorAddFlyout,

View file

@ -24,6 +24,9 @@ export enum API_URLS {
ML_DELETE_JOB = `/api/ml/jobs/delete_jobs`,
ML_CAPABILITIES = '/api/ml/ml_capabilities',
ML_ANOMALIES_RESULT = `/api/ml/results/anomalies_table_data`,
ALERT_ACTIONS = '/api/actions',
CREATE_ALERT = '/api/alerts/alert',
ALERT = '/api/alerts/alert/',
ALERTS_FIND = '/api/alerts/_find',
}

View file

@ -10,4 +10,5 @@ export const DYNAMIC_SETTINGS_DEFAULTS: DynamicSettings = {
heartbeatIndices: 'heartbeat-8*',
certAgeThreshold: 730,
certExpirationThreshold: 30,
defaultConnectors: [],
};

View file

@ -25,6 +25,7 @@ export const AtomicStatusCheckParamsType = t.intersection([
search: t.string,
filters: StatusCheckFiltersType,
shouldCheckStatus: t.boolean,
isAutoGenerated: t.boolean,
}),
]);
@ -34,6 +35,7 @@ export const StatusCheckParamsType = t.intersection([
t.partial({
filters: t.string,
shouldCheckStatus: t.boolean,
isAutoGenerated: t.boolean,
}),
t.type({
locations: t.array(t.string),

View file

@ -10,6 +10,7 @@ export const DynamicSettingsType = t.type({
heartbeatIndices: t.string,
certAgeThreshold: t.number,
certExpirationThreshold: t.number,
defaultConnectors: t.array(t.string),
});
export const DynamicSettingsSaveType = t.intersection([

View file

@ -18,7 +18,6 @@ export type MonitorError = t.TypeOf<typeof MonitorErrorType>;
export const MonitorDetailsType = t.intersection([
t.type({ monitorId: t.string }),
t.partial({ error: MonitorErrorType }),
t.partial({ timestamp: t.string }),
t.partial({ error: MonitorErrorType, timestamp: t.string, alerts: t.unknown }),
]);
export type MonitorDetails = t.TypeOf<typeof MonitorDetailsType>;

View file

@ -8,10 +8,10 @@ import React from 'react';
import { shallow, mount } from 'enzyme';
import { EuiLink, EuiButton } from '@elastic/eui';
import '../../../../lib/__mocks__/react_router_history.mock';
import '../../../../lib/__mocks__/ut_router_history.mock';
import { ReactRouterEuiLink, ReactRouterEuiButton } from '../link_for_eui';
import { mockHistory } from '../../../../lib/__mocks__';
import { mockHistory } from '../../../../lib/__mocks__/ut_router_history.mock';
describe('EUI & React Router Component Helpers', () => {
beforeEach(() => {

View file

@ -22,7 +22,7 @@ import { useMonitorId } from '../../../hooks';
import { setAlertFlyoutType, setAlertFlyoutVisible } from '../../../state/actions';
import { useAnomalyAlert } from './use_anomaly_alert';
import { ConfirmAlertDeletion } from './confirm_alert_delete';
import { deleteAlertAction } from '../../../state/actions/alerts';
import { deleteAnomalyAlertAction } from '../../../state/alerts/alerts';
interface Props {
hasMLJob: boolean;
@ -52,7 +52,7 @@ export const ManageMLJobComponent = ({ hasMLJob, onEnableJob, onJobDelete }: Pro
const [isConfirmAlertDeleteOpen, setIsConfirmAlertDeleteOpen] = useState(false);
const deleteAnomalyAlert = () =>
dispatch(deleteAlertAction.get({ alertId: anomalyAlert?.id as string }));
dispatch(deleteAnomalyAlertAction.get({ alertId: anomalyAlert?.id as string }));
const showLoading = isMLJobCreating || isMLJobLoading;

View file

@ -6,10 +6,10 @@
import { useContext, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { getExistingAlertAction } from '../../../state/actions/alerts';
import { alertSelector, selectAlertFlyoutVisibility } from '../../../state/selectors';
import { selectAlertFlyoutVisibility } from '../../../state/selectors';
import { UptimeRefreshContext } from '../../../contexts';
import { useMonitorId } from '../../../hooks';
import { anomalyAlertSelector, getAnomalyAlertAction } from '../../../state/alerts/alerts';
export const useAnomalyAlert = () => {
const { lastRefresh } = useContext(UptimeRefreshContext);
@ -18,12 +18,12 @@ export const useAnomalyAlert = () => {
const monitorId = useMonitorId();
const { data: anomalyAlert } = useSelector(alertSelector);
const { data: anomalyAlert } = useSelector(anomalyAlertSelector);
const alertFlyoutVisible = useSelector(selectAlertFlyoutVisibility);
useEffect(() => {
dispatch(getExistingAlertAction.get({ monitorId }));
dispatch(getAnomalyAlertAction.get({ monitorId }));
}, [monitorId, lastRefresh, dispatch, alertFlyoutVisible]);
return anomalyAlert;

View file

@ -5,7 +5,7 @@
*/
import { getLayerList } from '../map_config';
import { mockLayerList } from './__mocks__/mock';
import { mockLayerList } from './__mocks__/poly_layer_mock';
import { LocationPoint } from '../embedded_map';
import { UptimeAppColors } from '../../../../../../apps/uptime_app';

View file

@ -1055,9 +1055,26 @@ exports[`MonitorList component renders the monitor list 1`] = `
</span>
</div>
</th>
<th
class="euiTableHeaderCell"
data-test-subj="tableHeaderCell_Status alert_5"
role="columnheader"
scope="col"
style="width:150px"
>
<div
class="euiTableCellContent euiTableCellContent--alignCenter"
>
<span
class="euiTableCellContent__text"
>
Status alert
</span>
</div>
</th>
<td
class="euiTableHeaderCell"
data-test-subj="tableHeaderCell_monitor_id_5"
data-test-subj="tableHeaderCell_monitor_id_6"
role="columnheader"
scope="col"
style="width:24px"
@ -1228,6 +1245,52 @@ exports[`MonitorList component renders the monitor list 1`] = `
</span>
</div>
</td>
<td
class="euiTableRowCell"
style="width:150px"
>
<div
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
>
Status alert
</div>
<div
class="euiTableCellContent euiTableCellContent--alignCenter euiTableCellContent--overflowingContent"
>
<div
class="euiPopover euiPopover--anchorDownCenter"
>
<div
class="euiPopover__anchor"
>
<div
class="euiSwitch euiSwitch--compressed"
>
<button
aria-checked="false"
aria-label="Enable status alert"
class="euiSwitch__button"
data-test-subj="uptimeDisplayDefineConnector"
id="defineAlertSettingsSwitch"
role="switch"
type="button"
>
<span
class="euiSwitch__body"
>
<span
class="euiSwitch__thumb"
/>
<span
class="euiSwitch__track"
/>
</span>
</button>
</div>
</div>
</div>
</div>
</td>
<td
class="euiTableRowCell euiTableRowCell--isExpander"
style="width:24px"
@ -1405,6 +1468,52 @@ exports[`MonitorList component renders the monitor list 1`] = `
</span>
</div>
</td>
<td
class="euiTableRowCell"
style="width:150px"
>
<div
class="euiTableRowCell__mobileHeader euiTableRowCell--hideForDesktop"
>
Status alert
</div>
<div
class="euiTableCellContent euiTableCellContent--alignCenter euiTableCellContent--overflowingContent"
>
<div
class="euiPopover euiPopover--anchorDownCenter"
>
<div
class="euiPopover__anchor"
>
<div
class="euiSwitch euiSwitch--compressed"
>
<button
aria-checked="false"
aria-label="Enable status alert"
class="euiSwitch__button"
data-test-subj="uptimeDisplayDefineConnector"
id="defineAlertSettingsSwitch"
role="switch"
type="button"
>
<span
class="euiSwitch__body"
>
<span
class="euiSwitch__thumb"
/>
<span
class="euiSwitch__track"
/>
</span>
</button>
</div>
</div>
</div>
</div>
</td>
<td
class="euiTableRowCell euiTableRowCell--isExpander"
style="width:24px"

View file

@ -0,0 +1,89 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`EnableAlertComponent renders without errors for valid props 1`] = `
<div
class="euiPopover euiPopover--anchorDownCenter"
>
<div
class="euiPopover__anchor"
>
<div
class="euiSwitch euiSwitch--compressed"
>
<button
aria-checked="false"
aria-label="Enable status alert"
class="euiSwitch__button"
data-test-subj="uptimeDisplayDefineConnector"
id="defineAlertSettingsSwitch"
role="switch"
type="button"
>
<span
class="euiSwitch__body"
>
<span
class="euiSwitch__thumb"
/>
<span
class="euiSwitch__track"
/>
</span>
</button>
</div>
</div>
</div>
`;
exports[`EnableAlertComponent shallow renders without errors for valid props 1`] = `
<Provider
store={
Object {
"dispatch": [MockFunction],
"getState": [MockFunction],
"replaceReducer": [MockFunction],
"subscribe": [MockFunction],
}
}
>
<Router
history={
Object {
"action": "POP",
"block": [Function],
"canGo": [Function],
"createHref": [Function],
"entries": Array [
Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
],
"go": [Function],
"goBack": [Function],
"goForward": [Function],
"index": 0,
"length": 1,
"listen": [Function],
"location": Object {
"hash": "",
"key": "TestKeyForTesting",
"pathname": "/",
"search": "",
"state": undefined,
},
"push": [Function],
"replace": [Function],
}
}
>
<EnableMonitorAlert
monitorId="testMonitor"
monitorName="My website"
/>
</Router>
</Provider>
`;

View file

@ -0,0 +1,90 @@
/*
* 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 { EnableMonitorAlert } from '../enable_alert';
import * as redux from 'react-redux';
import {
mountWithRouterRedux,
renderWithRouterRedux,
shallowWithRouterRedux,
} from '../../../../../lib';
import { EuiPopover, EuiText } from '@elastic/eui';
import { DYNAMIC_SETTINGS_DEFAULTS } from '../../../../../../common/constants';
describe('EnableAlertComponent', () => {
let defaultConnectors: string[] = [];
let alerts: any = [];
beforeEach(() => {
jest.spyOn(redux, 'useDispatch').mockReturnValue(jest.fn());
jest.spyOn(redux, 'useSelector').mockImplementation((fn, d) => {
if (fn.name === 'selectDynamicSettings') {
return {
settings: Object.assign(DYNAMIC_SETTINGS_DEFAULTS, {
defaultConnectors,
}),
};
}
if (fn.name === 'alertsSelector') {
return {
data: {
data: alerts,
},
loading: false,
};
}
return {};
});
});
it('shallow renders without errors for valid props', () => {
const wrapper = shallowWithRouterRedux(
<EnableMonitorAlert monitorId={'testMonitor'} monitorName={'My website'} />
);
expect(wrapper).toMatchSnapshot();
});
it('renders without errors for valid props', () => {
const wrapper = renderWithRouterRedux(
<EnableMonitorAlert monitorId={'testMonitor'} monitorName={'My website'} />
);
expect(wrapper).toMatchSnapshot();
});
it('displays define connectors when there is none', () => {
defaultConnectors = [];
const wrapper = mountWithRouterRedux(
<EnableMonitorAlert monitorId={'testMonitor'} monitorName={'My website'} />
);
expect(wrapper.find(EuiPopover)).toHaveLength(1);
wrapper.find('button').simulate('click');
expect(wrapper.find(EuiText).text()).toBe(
'To start enabling alerts, please define a default alert action connector in Settings'
);
});
it('does not displays define connectors when there is connector', () => {
defaultConnectors = ['infra-slack-connector-id'];
const wrapper = mountWithRouterRedux(
<EnableMonitorAlert monitorId={'testMonitor'} monitorName={'My website'} />
);
expect(wrapper.find(EuiPopover)).toHaveLength(0);
});
it('displays disable when alert is there', () => {
alerts = [{ id: 'test-alert', params: { search: 'testMonitor' } }];
defaultConnectors = ['infra-slack-connector-id'];
const wrapper = mountWithRouterRedux(
<EnableMonitorAlert monitorId={'testMonitor'} monitorName={'My website'} />
);
expect(wrapper.find('button').prop('aria-label')).toBe('Disable status alert');
});
});

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState } from 'react';
import { EuiPopover, EuiSwitch, EuiText } from '@elastic/eui';
import { useRouteMatch } from 'react-router-dom';
import { FormattedMessage } from '@kbn/i18n/react';
import { ReactRouterEuiLink } from '../../../common/react_router_helpers';
import { MONITOR_ROUTE, SETTINGS_ROUTE } from '../../../../../common/constants';
import { ENABLE_STATUS_ALERT } from './translations';
import { SETTINGS_LINK_TEXT } from '../../../../pages/page_header';
export const DefineAlertConnectors = () => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const onButtonClick = () => setIsPopoverOpen((val) => !val);
const closePopover = () => setIsPopoverOpen(false);
const isMonitorPage = useRouteMatch(MONITOR_ROUTE);
return (
<EuiPopover
button={
<EuiSwitch
id={'defineAlertSettingsSwitch'}
label={ENABLE_STATUS_ALERT}
showLabel={!!isMonitorPage}
aria-label={ENABLE_STATUS_ALERT}
onChange={onButtonClick}
checked={false}
compressed={!isMonitorPage}
data-test-subj={'uptimeDisplayDefineConnector'}
/>
}
isOpen={isPopoverOpen}
closePopover={closePopover}
>
<EuiText style={{ width: '350px' }} data-test-subj={'uptimeSettingsDefineConnector'}>
<FormattedMessage
id="xpack.uptime.monitorList.defineConnector.description"
defaultMessage="To start enabling alerts, please define a default alert action connector in"
/>{' '}
<ReactRouterEuiLink
to={SETTINGS_ROUTE + '?focusConnectorField=true'}
data-test-subj={'uptimeSettingsLink'}
>
{SETTINGS_LINK_TEXT}
</ReactRouterEuiLink>
</EuiText>
</EuiPopover>
);
};

View file

@ -0,0 +1,130 @@
/*
* 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, { useEffect, useState } from 'react';
import { EuiLoadingSpinner, EuiToolTip, EuiSwitch } from '@elastic/eui';
import { useRouteMatch } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import { selectDynamicSettings } from '../../../../state/selectors';
import {
alertsSelector,
connectorsSelector,
createAlertAction,
deleteAlertAction,
isAlertDeletedSelector,
newAlertSelector,
} from '../../../../state/alerts/alerts';
import { MONITOR_ROUTE } from '../../../../../common/constants';
import { DefineAlertConnectors } from './define_connectors';
import { DISABLE_STATUS_ALERT, ENABLE_STATUS_ALERT } from './translations';
interface Props {
monitorId: string;
monitorName?: string;
}
export const EnableMonitorAlert = ({ monitorId, monitorName }: Props) => {
const [isLoading, setIsLoading] = useState(false);
const { settings } = useSelector(selectDynamicSettings);
const isMonitorPage = useRouteMatch(MONITOR_ROUTE);
const dispatch = useDispatch();
const { data: actionConnectors } = useSelector(connectorsSelector);
const { data: alerts, loading: alertsLoading } = useSelector(alertsSelector);
const { data: deletedAlertId } = useSelector(isAlertDeletedSelector);
const { data: newAlert } = useSelector(newAlertSelector);
const isNewAlert = newAlert?.params.search.includes(monitorId);
let hasAlert = (alerts?.data ?? []).find((alert) => alert.params.search.includes(monitorId));
if (isNewAlert) {
// if it's newly created alert, we assign that quickly without waiting for find alert result
hasAlert = newAlert!;
}
if (deletedAlertId === hasAlert?.id) {
// if it just got deleted, we assign that quickly without waiting for find alert result
hasAlert = undefined;
}
const defaultActions = (actionConnectors ?? []).filter((act) =>
settings?.defaultConnectors?.includes(act.id)
);
const enableAlert = () => {
dispatch(
createAlertAction.get({
defaultActions,
monitorId,
monitorName,
})
);
setIsLoading(true);
};
const disableAlert = () => {
if (hasAlert) {
dispatch(
deleteAlertAction.get({
alertId: hasAlert.id,
})
);
setIsLoading(true);
}
};
useEffect(() => {
setIsLoading(false);
}, [hasAlert, deletedAlertId]);
const hasDefaultConnectors = (settings?.defaultConnectors ?? []).length > 0;
const showSpinner = isLoading || (alertsLoading && !alerts);
const onAlertClick = () => {
if (hasAlert) {
disableAlert();
} else {
enableAlert();
}
};
const btnLabel = hasAlert ? DISABLE_STATUS_ALERT : ENABLE_STATUS_ALERT;
return hasDefaultConnectors || hasAlert ? (
<div className="eui-displayInlineBlock" style={{ marginRight: 10 }}>
{
<EuiToolTip content={btnLabel}>
<>
<EuiSwitch
id={'enableDisableAlertSwitch'}
compressed={!isMonitorPage}
disabled={showSpinner}
label={btnLabel}
showLabel={!!isMonitorPage}
aria-label={btnLabel}
onChange={onAlertClick}
checked={!!hasAlert}
data-test-subj={
hasAlert
? 'uptimeDisableSimpleDownAlert' + monitorId
: 'uptimeEnableSimpleDownAlert' + monitorId
}
/>{' '}
{showSpinner && <EuiLoadingSpinner className="eui-alignMiddle" />}
</>
</EuiToolTip>
}
</div>
) : (
<DefineAlertConnectors />
);
};

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
export const ENABLE_STATUS_ALERT = i18n.translate('xpack.uptime.monitorList.enableDownAlert', {
defaultMessage: 'Enable status alert',
});
export const DISABLE_STATUS_ALERT = i18n.translate('xpack.uptime.monitorList.disableDownAlert', {
defaultMessage: 'Disable status alert',
});

View file

@ -31,6 +31,8 @@ import { MonitorList } from '../../../state/reducers/monitor_list';
import { CertStatusColumn } from './cert_status_column';
import { MonitorListHeader } from './monitor_list_header';
import { URL_LABEL } from '../../common/translations';
import { EnableMonitorAlert } from './columns/enable_alert';
import { STATUS_ALERT_COLUMN } from './translations';
interface Props extends MonitorListProps {
pageSize: number;
@ -49,7 +51,13 @@ export const noItemsMessage = (loading: boolean, filters?: string) => {
return !!filters ? labels.NO_MONITOR_ITEM_SELECTED : labels.NO_DATA_MESSAGE;
};
export const MonitorListComponent: React.FC<Props> = ({
export const MonitorListComponent: ({
filters,
monitorList: { list, error, loading },
linkParameters,
pageSize,
setPageSize,
}: Props) => any = ({
filters,
monitorList: { list, error, loading },
linkParameters,
@ -69,7 +77,7 @@ export const MonitorListComponent: React.FC<Props> = ({
...map,
[id]: (
<MonitorListDrawer
summary={items.find(({ monitor_id: monitorId }) => monitorId === id)}
summary={items.find(({ monitor_id: monitorId }) => monitorId === id)!}
/>
),
};
@ -135,6 +143,18 @@ export const MonitorListComponent: React.FC<Props> = ({
<MonitorBarSeries histogramSeries={histogramSeries} />
),
},
{
align: 'center' as const,
field: '',
name: STATUS_ALERT_COLUMN,
width: '150px',
render: (item: MonitorSummary) => (
<EnableMonitorAlert
monitorId={item.monitor_id}
monitorName={item.state.monitor.name || item.monitor_id}
/>
),
},
{
align: 'right' as const,
field: 'monitor_id',

View file

@ -86,6 +86,7 @@ exports[`MonitorListDrawer component renders a MonitorListDrawer when there are
}
>
<MonitorListDrawerComponent
loading={false}
monitorDetails={
Object {
"error": Object {
@ -261,6 +262,7 @@ exports[`MonitorListDrawer component renders a MonitorListDrawer when there is o
}
>
<MonitorListDrawerComponent
loading={false}
monitorDetails={
Object {
"error": Object {

View file

@ -21,7 +21,7 @@ Array [
>
<a
data-test-subj="monitor-page-link-bad-ssl"
href="/monitor/YmFkLXNzbA==/?selectedPingStatus=down"
href="/monitor/YmFkLXNzbA==/?selectedPingStatus=down&focusConnectorField=false"
>
Get https://expired.badssl.com: x509: certificate has expired or is not yet valid
</a>

View file

@ -52,7 +52,11 @@ describe('MonitorListDrawer component', () => {
it('renders nothing when no summary data is present', () => {
const component = shallowWithRouter(
<MonitorListDrawerComponent summary={summary} monitorDetails={monitorDetails} />
<MonitorListDrawerComponent
summary={summary}
monitorDetails={monitorDetails}
loading={false}
/>
);
expect(component).toEqual({});
});
@ -60,14 +64,22 @@ describe('MonitorListDrawer component', () => {
it('renders nothing when no check data is present', () => {
delete summary.state.summaryPings;
const component = shallowWithRouter(
<MonitorListDrawerComponent summary={summary} monitorDetails={monitorDetails} />
<MonitorListDrawerComponent
summary={summary}
monitorDetails={monitorDetails}
loading={false}
/>
);
expect(component).toEqual({});
});
it('renders a MonitorListDrawer when there is only one check', () => {
const component = shallowWithRouter(
<MonitorListDrawerComponent summary={summary} monitorDetails={monitorDetails} />
<MonitorListDrawerComponent
summary={summary}
monitorDetails={monitorDetails}
loading={false}
/>
);
expect(component).toMatchSnapshot();
});
@ -88,7 +100,11 @@ describe('MonitorListDrawer component', () => {
}
const component = shallowWithRouter(
<MonitorListDrawerComponent summary={summary} monitorDetails={monitorDetails} />
<MonitorListDrawerComponent
summary={summary}
monitorDetails={monitorDetails}
loading={false}
/>
);
expect(component).toMatchSnapshot();
});

View file

@ -0,0 +1,57 @@
/*
* 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, { useContext } from 'react';
import { EuiCallOut, EuiListGroup, EuiLoadingSpinner, EuiSpacer, EuiText } from '@elastic/eui';
import { EuiListGroupItemProps } from '@elastic/eui/src/components/list_group/list_group_item';
import { i18n } from '@kbn/i18n';
import { UptimeSettingsContext } from '../../../../contexts';
import { Alert } from '../../../../../../triggers_actions_ui/public';
interface Props {
monitorAlerts: Alert[];
loading: boolean;
}
export const EnabledAlerts = ({ monitorAlerts, loading }: Props) => {
const { basePath } = useContext(UptimeSettingsContext);
const listItems: EuiListGroupItemProps[] = [];
(monitorAlerts ?? []).forEach((alert, ind) => {
listItems.push({
label: alert.name,
href: basePath + '/app/management/insightsAndAlerting/triggersActions/alert/' + alert.id,
size: 's',
'data-test-subj': 'uptimeMonitorListDrawerAlert' + ind,
});
});
return (
<>
<EuiSpacer />
<span>
<EuiText size="xs">
<h3>
{i18n.translate('xpack.uptime.monitorList.enabledAlerts.title', {
defaultMessage: 'Enabled alerts:',
description: 'Alerts enabled for this monitor',
})}
</h3>
</EuiText>
</span>
{listItems.length === 0 && !loading && (
<EuiCallOut
size="s"
title={i18n.translate('xpack.uptime.monitorList.enabledAlerts.noAlert', {
defaultMessage: 'No alert is enabled for this monitor.',
})}
/>
)}
{loading ? <EuiLoadingSpinner /> : <EuiListGroup listItems={listItems} />}
</>
);
};

View file

@ -4,44 +4,52 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useEffect } from 'react';
import { connect } from 'react-redux';
import React, { useContext, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { AppState } from '../../../../state';
import { monitorDetailsSelector } from '../../../../state/selectors';
import { MonitorDetailsActionPayload } from '../../../../state/actions/types';
import { monitorDetailsLoadingSelector, monitorDetailsSelector } from '../../../../state/selectors';
import { getMonitorDetailsAction } from '../../../../state/actions/monitor';
import { MonitorListDrawerComponent } from './monitor_list_drawer';
import { useGetUrlParams } from '../../../../hooks';
import { MonitorDetails, MonitorSummary } from '../../../../../common/runtime_types';
import { MonitorSummary } from '../../../../../common/runtime_types';
import { alertsSelector } from '../../../../state/alerts/alerts';
import { UptimeRefreshContext } from '../../../../contexts';
interface ContainerProps {
summary: MonitorSummary;
monitorDetails: MonitorDetails;
loadMonitorDetails: typeof getMonitorDetailsAction;
}
const Container: React.FC<ContainerProps> = ({ summary, loadMonitorDetails, monitorDetails }) => {
export const MonitorListDrawer: React.FC<ContainerProps> = ({ summary }) => {
const { lastRefresh } = useContext(UptimeRefreshContext);
const monitorId = summary?.monitor_id;
const { dateRangeStart: dateStart, dateRangeEnd: dateEnd } = useGetUrlParams();
const monitorDetails = useSelector((state: AppState) => monitorDetailsSelector(state, summary));
const isLoading = useSelector(monitorDetailsLoadingSelector);
const dispatch = useDispatch();
const { data: alerts, loading: alertsLoading } = useSelector(alertsSelector);
const hasAlert = (alerts?.data ?? []).find((alert) => alert.params.search.includes(monitorId));
useEffect(() => {
loadMonitorDetails({
dateStart,
dateEnd,
monitorId,
});
}, [dateStart, dateEnd, monitorId, loadMonitorDetails]);
return <MonitorListDrawerComponent monitorDetails={monitorDetails} summary={summary} />;
dispatch(
getMonitorDetailsAction.get({
dateStart,
dateEnd,
monitorId,
})
);
}, [dateStart, dateEnd, monitorId, dispatch, hasAlert, lastRefresh]);
return (
<MonitorListDrawerComponent
monitorDetails={monitorDetails}
summary={summary}
loading={isLoading || alertsLoading}
/>
);
};
const mapStateToProps = (state: AppState, { summary }: any) => ({
monitorDetails: monitorDetailsSelector(state, summary),
});
const mapDispatchToProps = (dispatch: any) => ({
loadMonitorDetails: (actionPayload: MonitorDetailsActionPayload) =>
dispatch(getMonitorDetailsAction(actionPayload)),
});
export const MonitorListDrawer = connect(mapStateToProps, mapDispatchToProps)(Container);

View file

@ -6,11 +6,13 @@
import React from 'react';
import styled from 'styled-components';
import { EuiLink, EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText } from '@elastic/eui';
import { EuiLink, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiText } from '@elastic/eui';
import { MostRecentError } from './most_recent_error';
import { MonitorStatusList } from './monitor_status_list';
import { MonitorDetails, MonitorSummary } from '../../../../../common/runtime_types';
import { ActionsPopover } from './actions_popover/actions_popover_container';
import { EnabledAlerts } from './enabled_alerts';
import { Alert } from '../../../../../../triggers_actions_ui/public';
const ContainerDiv = styled.div`
padding: 10px;
@ -27,13 +29,18 @@ interface MonitorListDrawerProps {
* Monitor details to be fetched from rest api using monitorId
*/
monitorDetails: MonitorDetails;
loading: boolean;
}
/**
* The elements shown when the user expands the monitor list rows.
*/
export function MonitorListDrawerComponent({ summary, monitorDetails }: MonitorListDrawerProps) {
export function MonitorListDrawerComponent({
summary,
monitorDetails,
loading,
}: MonitorListDrawerProps) {
const monitorUrl = summary?.state?.url?.full || '';
return summary && summary.state.summaryPings ? (
@ -51,8 +58,8 @@ export function MonitorListDrawerComponent({ summary, monitorDetails }: MonitorL
<ActionsPopover summary={summary} />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<MonitorStatusList summaryPings={summary.state.summaryPings} />
<EnabledAlerts loading={loading} monitorAlerts={monitorDetails?.alerts as Alert[]} />
{monitorDetails && monitorDetails.error && (
<MostRecentError
error={monitorDetails.error}

View file

@ -76,3 +76,7 @@ export const RESPONSE_ANOMALY_SCORE = i18n.translate(
defaultMessage: 'Response Anomaly Score',
}
);
export const STATUS_ALERT_COLUMN = i18n.translate('xpack.uptime.monitorList.statusAlert.label', {
defaultMessage: 'Status alert',
});

View file

@ -91,6 +91,7 @@ exports[`CertificateForm shallow renders expected elements for valid props 1`] =
Object {
"certAgeThreshold": 36,
"certExpirationThreshold": 7,
"defaultConnectors": Array [],
"heartbeatIndices": "heartbeat-8*",
}
}

View file

@ -91,6 +91,7 @@ exports[`CertificateForm shallow renders expected elements for valid props 1`] =
Object {
"certAgeThreshold": 36,
"certExpirationThreshold": 7,
"defaultConnectors": Array [],
"heartbeatIndices": "heartbeat-8*",
}
}

View file

@ -19,6 +19,7 @@ describe('CertificateForm', () => {
heartbeatIndices: 'heartbeat-8*',
certExpirationThreshold: 7,
certAgeThreshold: 36,
defaultConnectors: [],
}}
fieldErrors={null}
isDisabled={false}
@ -37,6 +38,7 @@ describe('CertificateForm', () => {
heartbeatIndices: 'heartbeat-8*',
certExpirationThreshold: 7,
certAgeThreshold: 36,
defaultConnectors: [],
}}
fieldErrors={null}
isDisabled={false}
@ -90,6 +92,7 @@ describe('CertificateForm', () => {
heartbeatIndices: 'heartbeat-8*',
certExpirationThreshold: 7,
certAgeThreshold: 36,
defaultConnectors: [],
}}
fieldErrors={null}
isDisabled={false}

View file

@ -19,6 +19,7 @@ describe('CertificateForm', () => {
heartbeatIndices: 'heartbeat-8*',
certAgeThreshold: 36,
certExpirationThreshold: 7,
defaultConnectors: [],
}}
fieldErrors={null}
isDisabled={false}

View file

@ -0,0 +1,70 @@
/*
* 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, { useEffect, useState } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { useDispatch } from 'react-redux';
import { EuiButtonEmpty } from '@elastic/eui';
import {
ActionsConnectorsContextProvider,
ConnectorAddFlyout,
} from '../../../../triggers_actions_ui/public';
import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
import { getConnectorsAction } from '../../state/alerts/alerts';
interface Props {
focusInput: () => void;
}
export const AddConnectorFlyout = ({ focusInput }: Props) => {
const [addFlyoutVisible, setAddFlyoutVisibility] = useState<boolean>(false);
const {
services: {
triggers_actions_ui: { actionTypeRegistry },
application,
docLinks,
http,
notifications,
},
} = useKibana();
const dispatch = useDispatch();
useEffect(() => {
dispatch(getConnectorsAction.get());
focusInput();
}, [addFlyoutVisible, dispatch, focusInput]);
return (
<>
<EuiButtonEmpty
iconType="plusInCircleFilled"
iconSide="left"
onClick={() => setAddFlyoutVisibility(true)}
>
<FormattedMessage
id="xpack.uptime.alerts.settings.createConnector"
defaultMessage="Create connector"
/>
</EuiButtonEmpty>
<ActionsConnectorsContextProvider
value={{
http,
docLinks,
actionTypeRegistry,
toastNotifications: notifications?.toasts,
capabilities: application?.capabilities,
}}
>
<ConnectorAddFlyout
addFlyoutVisible={addFlyoutVisible}
setAddFlyoutVisibility={setAddFlyoutVisibility}
/>
</ActionsConnectorsContextProvider>
</>
);
};

View file

@ -0,0 +1,182 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiDescribedFormGroup,
EuiFormRow,
EuiTitle,
EuiSpacer,
EuiComboBox,
EuiIcon,
EuiComboBoxOptionOption,
} from '@elastic/eui';
import { useSelector } from 'react-redux';
import styled from 'styled-components';
import { SettingsFormProps } from '../../pages/settings';
import { connectorsSelector } from '../../state/alerts/alerts';
import { AddConnectorFlyout } from './add_connector_flyout';
import { useGetUrlParams, useUrlParams } from '../../hooks';
import { alertFormI18n } from './translations';
import { useInitApp } from '../../hooks/use_init_app';
import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
type ConnectorOption = EuiComboBoxOptionOption<string>;
const ConnectorSpan = styled.span`
.euiIcon {
margin-right: 5px;
}
> img {
width: 16px;
height: 20px;
}
`;
export const AlertDefaultsForm: React.FC<SettingsFormProps> = ({
onChange,
loading,
formFields,
fieldErrors,
isDisabled,
}) => {
const {
services: {
triggers_actions_ui: { actionTypeRegistry },
},
} = useKibana();
const { focusConnectorField } = useGetUrlParams();
const updateUrlParams = useUrlParams()[1];
const inputRef = useRef<HTMLInputElement | null>(null);
useInitApp();
useEffect(() => {
if (focusConnectorField && inputRef.current && !loading) {
inputRef.current.focus();
}
}, [focusConnectorField, inputRef, loading]);
const { data = [] } = useSelector(connectorsSelector);
const [error, setError] = useState<string | undefined>(undefined);
const onBlur = () => {
if (inputRef.current) {
const { value } = inputRef.current;
setError(value.length === 0 ? undefined : `"${value}" is not a valid option`);
}
if (inputRef.current && !loading && focusConnectorField) {
updateUrlParams({ focusConnectorField: undefined });
}
};
const onSearchChange = (value: string, hasMatchingOptions?: boolean) => {
setError(
value.length === 0 || hasMatchingOptions ? undefined : `"${value}" is not a valid option`
);
};
const options = (data ?? []).map((connectorAction) => ({
value: connectorAction.id,
label: connectorAction.name,
'data-test-subj': connectorAction.name,
}));
const renderOption = (option: ConnectorOption) => {
const { label, value } = option;
const { actionTypeId: type } = data?.find((dt) => dt.id === value) ?? {};
return (
<ConnectorSpan>
<EuiIcon type={actionTypeRegistry.get(type as string).iconClass} />
<span>{label}</span>
</ConnectorSpan>
);
};
const onOptionChange = (selectedOptions: ConnectorOption[]) => {
onChange({
defaultConnectors: selectedOptions.map((item) => {
const conOpt = data?.find((dt) => dt.id === item.value)!;
return conOpt.id;
}),
});
};
return (
<>
<EuiTitle size="s">
<h3>
<FormattedMessage
id="xpack.uptime.sourceConfiguration.alertDefaults"
defaultMessage="Alert defaults"
/>
</h3>
</EuiTitle>
<EuiSpacer size="m" />
<EuiDescribedFormGroup
title={
<h4>
<FormattedMessage
id="xpack.uptime.sourceConfiguration.alertConnectors"
defaultMessage="Alert Connectors"
/>
</h4>
}
description={
<FormattedMessage
id="xpack.uptime.sourceConfiguration.defaultConnectors.description"
defaultMessage="Default connectors to be used to send an alert."
/>
}
>
<EuiFormRow
describedByIds={['defaultConnectors']}
error={error}
fullWidth
isInvalid={!!error}
label={
<FormattedMessage
id="xpack.uptime.sourceConfiguration.defaultConnectors"
defaultMessage="Default connectors"
/>
}
>
<EuiComboBox
placeholder={alertFormI18n.inputPlaceHolder}
options={options}
selectedOptions={options.filter((opt) =>
formFields?.defaultConnectors?.includes(opt.value)
)}
inputRef={(input) => {
inputRef.current = input;
}}
onSearchChange={onSearchChange}
onBlur={onBlur}
isLoading={loading}
isDisabled={isDisabled}
onChange={onOptionChange}
data-test-subj={`default-connectors-input-${loading ? 'loading' : 'loaded'}`}
renderOption={renderOption}
/>
</EuiFormRow>
<span>
<AddConnectorFlyout
focusInput={useCallback(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, [])}
/>
</span>
</EuiDescribedFormGroup>
</>
);
};

View file

@ -22,3 +22,12 @@ export const certificateFormTranslations = {
}
),
};
export const alertFormI18n = {
inputPlaceHolder: i18n.translate(
'xpack.uptime.sourceConfiguration.alertDefaultForm.selectConnector',
{
defaultMessage: 'Please select one or more connectors',
}
),
};

View file

@ -209,7 +209,7 @@ exports[`useUrlParams deletes keys that do not have truthy values 1`] = `
}
>
<div>
{"absoluteDateRangeStart":20,"absoluteDateRangeEnd":20,"autorefreshInterval":60000,"autorefreshIsPaused":false,"dateRangeStart":"now-12","dateRangeEnd":"now","filters":"","search":"","selectedPingStatus":"","statusFilter":"","pagination":"foo"}
{"absoluteDateRangeStart":20,"absoluteDateRangeEnd":20,"autorefreshInterval":60000,"autorefreshIsPaused":false,"dateRangeStart":"now-12","dateRangeEnd":"now","filters":"","search":"","selectedPingStatus":"","statusFilter":"","pagination":"foo","focusConnectorField":false}
</div>
<button
id="setUrlParams"
@ -433,7 +433,7 @@ exports[`useUrlParams gets the expected values using the context 1`] = `
hook={[Function]}
>
<div>
{"absoluteDateRangeStart":20,"absoluteDateRangeEnd":20,"autorefreshInterval":60000,"autorefreshIsPaused":false,"dateRangeStart":"now-15m","dateRangeEnd":"now","filters":"","search":"","selectedPingStatus":"","statusFilter":""}
{"absoluteDateRangeStart":20,"absoluteDateRangeEnd":20,"autorefreshInterval":60000,"autorefreshIsPaused":false,"dateRangeStart":"now-15m","dateRangeEnd":"now","filters":"","search":"","selectedPingStatus":"","statusFilter":"","focusConnectorField":false}
</div>
<button
id="setUrlParams"

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { getConnectorsAction } from '../state/alerts/alerts';
// this hook will be use to reload all the data required in common view in app
export const useInitApp = () => {
const dispatch = useDispatch();
useEffect(() => {
dispatch(getConnectorsAction.get());
}, [dispatch]);
};

View file

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

View file

@ -0,0 +1,120 @@
/*
* 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 { DYNAMIC_SETTINGS_DEFAULTS } from '../../../common/constants';
/**
* NOTE: This variable name MUST start with 'mock*' in order for
* Jest to accept its use within a jest.mock()
*/
export const mockStore = {
overviewFilters: {
filters: {
locations: [],
ports: [],
schemes: [],
tags: [],
},
errors: [],
loading: false,
},
dynamicSettings: {
settings: DYNAMIC_SETTINGS_DEFAULTS,
loading: false,
},
monitor: {
monitorDetailsList: [],
monitorLocationsList: new Map(),
loading: false,
errors: [],
},
snapshot: {
count: {
up: 2,
down: 0,
total: 2,
},
errors: [],
loading: false,
},
ui: {
alertFlyoutVisible: false,
basePath: 'yyz',
esKuery: '',
integrationsPopoverOpen: null,
searchText: '',
monitorId: '',
},
monitorStatus: {
status: null,
loading: false,
},
indexPattern: {
index_pattern: null,
loading: false,
errors: [],
},
ping: {
pingHistogram: null,
loading: false,
errors: [],
},
pingList: {
loading: false,
pingList: {
total: 0,
locations: [],
pings: [],
},
},
monitorDuration: {
durationLines: null,
loading: false,
errors: [],
},
monitorList: {
list: {
prevPagePagination: null,
nextPagePagination: null,
summaries: [],
totalSummaryCount: 0,
},
loading: false,
},
ml: {
mlJob: {
data: null,
loading: false,
},
createJob: { data: null, loading: false },
deleteJob: { data: null, loading: false },
mlCapabilities: { data: null, loading: false },
anomalies: {
data: null,
loading: false,
},
},
indexStatus: {
indexStatus: {
data: null,
loading: false,
},
},
certificates: {
certs: {
data: null,
loading: false,
},
},
selectedFilters: null,
alerts: {
alertDeletion: { data: null, loading: false },
anomalyAlert: { data: null, loading: false },
alerts: { data: null, loading: false },
connectors: { data: null, loading: false },
newAlert: { data: null, loading: false },
},
};

View file

@ -26,9 +26,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 : ({ 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> }, shouldCheckStatus: boolean }>)/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> }, shouldCheckStatus: boolean }>)/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> }, shouldCheckStatus: boolean }>)/0: { numTimes: number, timerangeCount: number, timerangeUnit: string }/timerangeUnit: 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> }, shouldCheckStatus: boolean, isAutoGenerated: boolean }>)/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> }, shouldCheckStatus: boolean, isAutoGenerated: boolean }>)/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> }, shouldCheckStatus: boolean, isAutoGenerated: boolean }>)/0: { numTimes: number, timerangeCount: number, timerangeUnit: string }/timerangeUnit: string",
],
},
}
@ -151,7 +151,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 : ({ 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> }, shouldCheckStatus: boolean }>)/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> }, shouldCheckStatus: boolean, isAutoGenerated: boolean }>)/0: { numTimes: number, timerangeCount: number, timerangeUnit: string }/numTimes: number",
],
},
}
@ -164,7 +164,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 : ({ 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> }, shouldCheckStatus: boolean }>)/0: { numTimes: number, timerangeCount: number, timerangeUnit: 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> }, shouldCheckStatus: boolean, isAutoGenerated: boolean }>)/0: { numTimes: number, timerangeCount: number, timerangeUnit: string }/numTimes: number",
],
},
}

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import React from 'react';
import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public';
import { ActionConnector } from '../../state/alerts/alerts';
export const simpleAlertEnabled = (defaultActions: ActionConnector[]) => {
return {
title: i18n.translate('xpack.uptime.overview.alerts.enabled.success', {
defaultMessage: 'Alert successfully enabled ',
}),
text: toMountPoint(
<FormattedMessage
id="xpack.uptime.overview.alerts.enabled.success.description"
defaultMessage="Message will be send to {actionConnectors} when monitor is down."
values={{
actionConnectors: <strong>{defaultActions.map(({ name }) => name).join(', ')}</strong>,
}}
/>
),
};
};

View file

@ -6,12 +6,13 @@
import React from 'react';
import { Provider as ReduxProvider } from 'react-redux';
import { AppState } from '../../state';
export const MountWithReduxProvider: React.FC = ({ children }) => (
export const MountWithReduxProvider: React.FC<{ store?: AppState }> = ({ children, store }) => (
<ReduxProvider
store={{
dispatch: jest.fn(),
getState: jest.fn().mockReturnValue({ selectedFilters: null }),
getState: jest.fn().mockReturnValue(store || { selectedFilters: null }),
subscribe: jest.fn(),
replaceReducer: jest.fn(),
}}

View file

@ -10,12 +10,16 @@ import { Router } from 'react-router-dom';
import { MemoryHistory } from 'history/createMemoryHistory';
import { createMemoryHistory } from 'history';
import { mountWithIntl, renderWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers';
import { AppState } from '../../state';
import { MountWithReduxProvider } from './helper_with_redux';
const helperWithRouter: <R>(
helper: (node: ReactElement) => R,
component: ReactElement,
customHistory?: MemoryHistory
) => R = (helper, component, customHistory) => {
customHistory?: MemoryHistory,
wrapReduxStore?: boolean,
storeState?: AppState
) => R = (helper, component, customHistory, wrapReduxStore, storeState) => {
if (customHistory) {
customHistory.location.key = 'TestKeyForTesting';
return helper(<Router history={customHistory}>{component}</Router>);
@ -23,6 +27,14 @@ const helperWithRouter: <R>(
const history = createMemoryHistory();
history.location.key = 'TestKeyForTesting';
if (wrapReduxStore) {
return helper(
<MountWithReduxProvider store={storeState}>
<Router history={history}>{component}</Router>
</MountWithReduxProvider>
);
}
return helper(<Router history={history}>{component}</Router>);
};
@ -37,3 +49,24 @@ export const shallowWithRouter = (component: ReactElement, customHistory?: Memor
export const mountWithRouter = (component: ReactElement, customHistory?: MemoryHistory) => {
return helperWithRouter(mountWithIntl, component, customHistory);
};
export const renderWithRouterRedux = (component: ReactElement, customHistory?: MemoryHistory) => {
return helperWithRouter(renderWithIntl, component, customHistory, true);
};
export const shallowWithRouterRedux = (component: ReactElement, customHistory?: MemoryHistory) => {
return helperWithRouter(shallowWithIntl, component, customHistory, true);
};
export const mountWithRouterRedux = (
component: ReactElement,
options?: { customHistory?: MemoryHistory; storeState?: AppState }
) => {
return helperWithRouter(
mountWithIntl,
component,
options?.customHistory,
true,
options?.storeState
);
};

View file

@ -9,6 +9,7 @@ Object {
"dateRangeEnd": "now",
"dateRangeStart": "now-15m",
"filters": "",
"focusConnectorField": false,
"pagination": undefined,
"search": "",
"selectedPingStatus": "",
@ -25,6 +26,7 @@ Object {
"dateRangeEnd": "now",
"dateRangeStart": "now-15m",
"filters": "",
"focusConnectorField": false,
"pagination": undefined,
"search": "",
"selectedPingStatus": "",
@ -41,6 +43,7 @@ Object {
"dateRangeEnd": "now",
"dateRangeStart": "now-15m",
"filters": "",
"focusConnectorField": false,
"pagination": undefined,
"search": "monitor.status: down",
"selectedPingStatus": "up",
@ -57,6 +60,7 @@ Object {
"dateRangeEnd": "now",
"dateRangeStart": "now-15m",
"filters": "",
"focusConnectorField": false,
"pagination": undefined,
"search": "",
"selectedPingStatus": "",
@ -73,6 +77,7 @@ Object {
"dateRangeEnd": "now",
"dateRangeStart": "now-18d",
"filters": "",
"focusConnectorField": false,
"pagination": undefined,
"search": "",
"selectedPingStatus": "",

View file

@ -59,6 +59,8 @@ describe('getSupportedUrlParams', () => {
dateRangeStart: DATE_RANGE_START,
dateRangeEnd: DATE_RANGE_END,
filters: FILTERS,
focusConnectorField: false,
pagination: undefined,
search: SEARCH,
selectedPingStatus: SELECTED_PING_LIST_STATUS,
statusFilter: STATUS_FILTER,

View file

@ -21,6 +21,7 @@ export interface UptimeUrlParams {
search: string;
selectedPingStatus: string;
statusFilter: string;
focusConnectorField?: boolean;
}
const {
@ -75,6 +76,7 @@ export const getSupportedUrlParams = (params: {
selectedPingStatus,
statusFilter,
pagination,
focusConnectorField,
} = filteredParams;
return {
@ -97,5 +99,6 @@ export const getSupportedUrlParams = (params: {
selectedPingStatus === undefined ? SELECTED_PING_LIST_STATUS : selectedPingStatus,
statusFilter: statusFilter || STATUS_FILTER,
pagination,
focusConnectorField: !!focusConnectorField,
};
};

View file

@ -5,4 +5,4 @@
*/
export { MountWithReduxProvider } from './helper';
export { renderWithRouter, shallowWithRouter, mountWithRouter } from './helper/helper_with_router';
export * from './helper/helper_with_router';

View file

@ -2,25 +2,21 @@
exports[`PageHeader shallow renders extra links: page_header_with_extra_links 1`] = `
Array [
.c0 {
white-space: nowrap;
}
@media only screen and (max-width:1024px) and (min-width:868px) {
.c1.c1.c1 .euiSuperDatePicker__flexWrapper {
@media only screen and (max-width:1024px) and (min-width:868px) {
.c0.c0.c0 .euiSuperDatePicker__flexWrapper {
width: 500px;
}
}
@media only screen and (max-width:880px) {
.c1.c1.c1 {
.c0.c0.c0 {
-webkit-box-flex: 1;
-webkit-flex-grow: 1;
-ms-flex-positive: 1;
flex-grow: 1;
}
.c1.c1.c1 .euiSuperDatePicker__flexWrapper {
.c0.c0.c0 .euiSuperDatePicker__flexWrapper {
width: calc(100% + 8px);
}
}
@ -32,7 +28,7 @@ Array [
class="euiFlexItem"
>
<h1
class="c0 euiTitle euiTitle--medium"
class="euiTitle euiTitle--medium eui-textNoWrap"
>
TestingHeading
</h1>
@ -124,7 +120,7 @@ Array [
</div>
</div>
<div
class="euiFlexItem euiFlexItem--flexGrowZero c1"
class="euiFlexItem euiFlexItem--flexGrowZero c0"
style="flex-basis:485px"
>
<div
@ -234,25 +230,21 @@ Array [
exports[`PageHeader shallow renders with the date picker: page_header_with_date_picker 1`] = `
Array [
.c0 {
white-space: nowrap;
}
@media only screen and (max-width:1024px) and (min-width:868px) {
.c1.c1.c1 .euiSuperDatePicker__flexWrapper {
@media only screen and (max-width:1024px) and (min-width:868px) {
.c0.c0.c0 .euiSuperDatePicker__flexWrapper {
width: 500px;
}
}
@media only screen and (max-width:880px) {
.c1.c1.c1 {
.c0.c0.c0 {
-webkit-box-flex: 1;
-webkit-flex-grow: 1;
-ms-flex-positive: 1;
flex-grow: 1;
}
.c1.c1.c1 .euiSuperDatePicker__flexWrapper {
.c0.c0.c0 .euiSuperDatePicker__flexWrapper {
width: calc(100% + 8px);
}
}
@ -264,16 +256,13 @@ Array [
class="euiFlexItem"
>
<h1
class="c0 euiTitle euiTitle--medium"
class="euiTitle euiTitle--medium eui-textNoWrap"
>
TestingHeading
</h1>
</div>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
/>
<div
class="euiFlexItem euiFlexItem--flexGrowZero c1"
class="euiFlexItem euiFlexItem--flexGrowZero c0"
style="flex-basis:485px"
>
<div
@ -383,25 +372,18 @@ Array [
exports[`PageHeader shallow renders without the date picker: page_header_no_date_picker 1`] = `
Array [
.c0 {
white-space: nowrap;
}
<div
<div
class="euiFlexGroup euiFlexGroup--gutterSmall euiFlexGroup--alignItemsCenter euiFlexGroup--justifyContentSpaceBetween euiFlexGroup--directionRow euiFlexGroup--wrap"
>
<div
class="euiFlexItem"
>
<h1
class="c0 euiTitle euiTitle--medium"
class="euiTitle euiTitle--medium eui-textNoWrap"
>
TestingHeading
</h1>
</div>
<div
class="euiFlexItem euiFlexItem--flexGrowZero"
/>
</div>,
<div
class="euiSpacer euiSpacer--s"

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiSpacer } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { monitorStatusSelector } from '../state/selectors';
@ -17,6 +17,9 @@ import { MonitorStatusDetails, PingList } from '../components/monitor';
import { getDynamicSettings } from '../state/actions/dynamic_settings';
import { Ping } from '../../common/runtime_types/ping';
import { setSelectedMonitorId } from '../state/actions';
import { EnableMonitorAlert } from '../components/overview/monitor_list/columns/enable_alert';
import { getMonitorAlertsAction } from '../state/alerts/alerts';
import { useInitApp } from '../hooks/use_init_app';
const isAutogeneratedId = (id: string) => {
const autoGeneratedId = /^auto-(icmp|http|tcp)-OX[A-F0-9]{16}-[a-f0-9]{16}/;
@ -38,6 +41,8 @@ const getPageTitle = (monId: string, selectedMonitor: Ping | null) => {
export const MonitorPage: React.FC = () => {
const dispatch = useDispatch();
useInitApp();
useEffect(() => {
dispatch(getDynamicSettings());
}, [dispatch]);
@ -46,6 +51,7 @@ export const MonitorPage: React.FC = () => {
useEffect(() => {
dispatch(setSelectedMonitorId(monitorId));
dispatch(getMonitorAlertsAction.get());
}, [monitorId, dispatch]);
const selectedMonitor = useSelector(monitorStatusSelector);
@ -57,7 +63,20 @@ export const MonitorPage: React.FC = () => {
useBreadcrumbs([{ text: nameOrId }]);
return (
<>
<PageHeader headingText={nameOrId} datePicker={true} />
<PageHeader
headingText={
<EuiFlexGroup wrap={false}>
<EuiFlexItem grow={false}>{nameOrId}</EuiFlexItem>
<EuiFlexItem grow={false}>
<EnableMonitorAlert
monitorId={monitorId}
monitorName={selectedMonitor?.monitor?.name || selectedMonitor?.url?.full}
/>
</EuiFlexItem>
</EuiFlexGroup>
}
datePicker={true}
/>
<EuiSpacer size="s" />
<MonitorStatusDetails monitorId={monitorId} />
<EuiSpacer size="s" />

View file

@ -8,6 +8,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import React, { useEffect } from 'react';
import styled from 'styled-components';
import { i18n } from '@kbn/i18n';
import { useDispatch } from 'react-redux';
import { useGetUrlParams } from '../hooks';
import { stringifyUrlParams } from '../lib/helper/stringify_url_params';
import { PageHeader } from './page_header';
@ -18,6 +19,8 @@ import { useTrackPageview } from '../../../observability/public';
import { MonitorList } from '../components/overview/monitor_list/monitor_list_container';
import { EmptyState, FilterGroup, KueryBar, ParsingErrorCallout } from '../components/overview';
import { StatusPanel } from '../components/overview/status_panel';
import { getConnectorsAction, getMonitorAlertsAction } from '../state/alerts/alerts';
import { useInitApp } from '../hooks/use_init_app';
interface Props {
loading: boolean;
@ -45,12 +48,21 @@ export const OverviewPageComponent = React.memo(
useTrackPageview({ app: 'uptime', path: 'overview' });
useTrackPageview({ app: 'uptime', path: 'overview', delay: 15000 });
useInitApp();
const [esFilters, error] = useUpdateKueryString(indexPattern, search, urlFilters);
useEffect(() => {
setEsKueryFilters(esFilters ?? '');
}, [esFilters, setEsKueryFilters]);
const dispatch = useDispatch();
useEffect(() => {
dispatch(getConnectorsAction.get());
dispatch(getMonitorAlertsAction.get());
}, [dispatch]);
const linkParameters = stringifyUrlParams(params, true);
const heading = i18n.translate('xpack.uptime.overviewPage.headerText', {

View file

@ -20,7 +20,7 @@ interface PageHeaderProps {
datePicker?: boolean;
}
const SETTINGS_LINK_TEXT = i18n.translate('xpack.uptime.page_header.settingsLink', {
export const SETTINGS_LINK_TEXT = i18n.translate('xpack.uptime.page_header.settingsLink', {
defaultMessage: 'Settings',
});
@ -44,10 +44,6 @@ const StyledPicker = styled(EuiFlexItem)`
}
`;
const H1Text = styled.h1`
white-space: nowrap;
`;
export const PageHeader = React.memo(
({ headingText, extraLinks = false, datePicker = true }: PageHeaderProps) => {
const DatePickerComponent = () =>
@ -96,10 +92,10 @@ export const PageHeader = React.memo(
>
<EuiFlexItem grow={true}>
<EuiTitle>
<H1Text>{headingText}</H1Text>
<h1 className="eui-textNoWrap">{headingText}</h1>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>{extraLinkComponents}</EuiFlexItem>
{extraLinks && <EuiFlexItem grow={false}>{extraLinkComponents}</EuiFlexItem>}
<DatePickerComponent />
</EuiFlexGroup>
<EuiSpacer size="s" />

View file

@ -34,6 +34,7 @@ import {
VALUE_MUST_BE_AN_INTEGER,
} from '../../common/translations';
import { ReactRouterEuiButtonEmpty } from '../components/common/react_router_helpers';
import { AlertDefaultsForm } from '../components/settings/alert_defaults_form';
interface SettingsPageFieldErrors {
heartbeatIndices: string | '';
@ -83,7 +84,8 @@ const isDirtyForm = (formFields: DynamicSettings | null, settings?: DynamicSetti
return (
settings?.certAgeThreshold !== formFields?.certAgeThreshold ||
settings?.certExpirationThreshold !== formFields?.certExpirationThreshold ||
settings?.heartbeatIndices !== formFields?.heartbeatIndices
settings?.heartbeatIndices !== formFields?.heartbeatIndices ||
JSON.stringify(settings?.defaultConnectors) !== JSON.stringify(formFields?.defaultConnectors)
);
};
@ -161,7 +163,7 @@ export const SettingsPage: React.FC = () => {
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<form onSubmit={onApply}>
<div id="settings-form">
<EuiForm>
<IndicesForm
loading={dss.loading}
@ -170,6 +172,13 @@ export const SettingsPage: React.FC = () => {
fieldErrors={fieldErrors}
isDisabled={isFormDisabled}
/>
<AlertDefaultsForm
loading={dss.loading}
formFields={formFields}
onChange={onChangeFormField}
fieldErrors={fieldErrors}
isDisabled={isFormDisabled}
/>
<CertificateExpirationForm
loading={dss.loading}
onChange={onChangeFormField}
@ -197,7 +206,7 @@ export const SettingsPage: React.FC = () => {
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="apply-settings-button"
type="submit"
onClick={onApply}
color="primary"
isDisabled={!isFormDirty || !isFormValid || isFormDisabled}
fill
@ -210,7 +219,7 @@ export const SettingsPage: React.FC = () => {
</EuiFlexItem>
</EuiFlexGroup>
</EuiForm>
</form>
</div>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>

View file

@ -9,6 +9,7 @@ import { MonitorDetailsActionPayload } from './types';
import { MonitorError } from '../../../common/runtime_types';
import { MonitorLocations } from '../../../common/runtime_types';
import { QueryParams } from './types';
import { createAsyncAction } from './utils';
export interface MonitorLocationsPayload extends QueryParams {
monitorId: string;
@ -19,13 +20,10 @@ export interface MonitorDetailsState {
error: MonitorError;
}
export const getMonitorDetailsAction = createAction<MonitorDetailsActionPayload>(
'GET_MONITOR_DETAILS'
);
export const getMonitorDetailsActionSuccess = createAction<MonitorDetailsState>(
'GET_MONITOR_DETAILS_SUCCESS'
);
export const getMonitorDetailsActionFail = createAction<Error>('GET_MONITOR_DETAILS_FAIL');
export const getMonitorDetailsAction = createAsyncAction<
MonitorDetailsActionPayload,
MonitorDetailsState
>('GET_MONITOR_DETAILS');
export const getMonitorLocationsAction = createAction<MonitorLocationsPayload>(
'GET_MONITOR_LOCATIONS'

View file

@ -6,6 +6,7 @@
import { Action } from 'redux-actions';
import { IHttpFetchError } from 'src/core/public';
import { Alert } from '../../../../triggers_actions_ui/public';
export interface AsyncAction<Payload, SuccessPayload> {
get: (payload: Payload) => Action<Payload>;
@ -53,3 +54,10 @@ export interface DeleteJobResults {
error?: any;
};
}
export interface AlertsResult {
page: number;
perPage: number;
total: number;
data: Alert[];
}

View file

@ -0,0 +1,165 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { handleActions, Action } from 'redux-actions';
import { call, put, select, takeLatest } from 'redux-saga/effects';
import { createAsyncAction } from '../actions/utils';
import { asyncInitState, handleAsyncAction } from '../reducers/utils';
import { AppState } from '../index';
import { AsyncInitState } from '../reducers/types';
import { fetchEffectFactory } from '../effects/fetch_effect';
import {
createAlert,
disableAlertById,
fetchAlertRecords,
fetchConnectors,
fetchMonitorAlertRecords,
NewAlertParams,
} from '../api/alerts';
import {
ActionConnector as RawActionConnector,
Alert,
} from '../../../../triggers_actions_ui/public';
import { kibanaService } from '../kibana_service';
import { monitorIdSelector } from '../selectors';
import { AlertsResult, MonitorIdParam } from '../actions/types';
import { simpleAlertEnabled } from '../../lib/alert_types/alert_messages';
export type ActionConnector = Omit<RawActionConnector, 'secrets'>;
export const createAlertAction = createAsyncAction<NewAlertParams, Alert | null>('CREATE ALERT');
export const getConnectorsAction = createAsyncAction<{}, ActionConnector[]>('GET CONNECTORS');
export const getMonitorAlertsAction = createAsyncAction<{}, AlertsResult | null>('GET ALERTS');
export const getAnomalyAlertAction = createAsyncAction<MonitorIdParam, Alert>(
'GET EXISTING ALERTS'
);
export const deleteAlertAction = createAsyncAction<{ alertId: string }, string | null>(
'DELETE ALERTS'
);
export const deleteAnomalyAlertAction = createAsyncAction<{ alertId: string }, any>(
'DELETE ANOMALY ALERT'
);
interface AlertState {
connectors: AsyncInitState<ActionConnector[]>;
newAlert: AsyncInitState<Alert>;
alerts: AsyncInitState<AlertsResult>;
anomalyAlert: AsyncInitState<Alert>;
alertDeletion: AsyncInitState<string>;
anomalyAlertDeletion: AsyncInitState<boolean>;
}
const initialState = {
connectors: asyncInitState(),
newAlert: asyncInitState(),
alerts: asyncInitState(),
anomalyAlert: asyncInitState(),
alertDeletion: asyncInitState(),
anomalyAlertDeletion: asyncInitState(),
};
export const alertsReducer = handleActions<AlertState>(
{
...handleAsyncAction<AlertState>('connectors', getConnectorsAction),
...handleAsyncAction<AlertState>('newAlert', createAlertAction),
...handleAsyncAction<AlertState>('alerts', getMonitorAlertsAction),
...handleAsyncAction<AlertState>('anomalyAlert', getAnomalyAlertAction),
...handleAsyncAction<AlertState>('alertDeletion', deleteAlertAction),
...handleAsyncAction<AlertState>('anomalyAlertDeletion', deleteAnomalyAlertAction),
},
initialState
);
const showAlertDisabledSuccess = () => {
kibanaService.core.notifications.toasts.addSuccess(
i18n.translate('xpack.uptime.overview.alerts.disabled.success', {
defaultMessage: 'Alert successfully disabled!',
})
);
};
const showAlertDisabledFailed = (err: Error) => {
kibanaService.core.notifications.toasts.addError(err, {
title: i18n.translate('xpack.uptime.overview.alerts.disabled.failed', {
defaultMessage: 'Alert cannot be disabled!',
}),
});
};
export function* fetchAlertsEffect() {
yield takeLatest(
getAnomalyAlertAction.get,
fetchEffectFactory(fetchAlertRecords, getAnomalyAlertAction.success, getAnomalyAlertAction.fail)
);
yield takeLatest(deleteAnomalyAlertAction.get, function* (action: Action<{ alertId: string }>) {
try {
yield call(disableAlertById, action.payload);
yield put(deleteAnomalyAlertAction.success(action.payload.alertId));
showAlertDisabledSuccess();
const monitorId = yield select(monitorIdSelector);
yield put(getAnomalyAlertAction.get({ monitorId }));
} catch (err) {
showAlertDisabledFailed(err);
yield put(deleteAnomalyAlertAction.fail(err));
}
});
yield takeLatest(deleteAlertAction.get, function* (action: Action<{ alertId: string }>) {
try {
yield call(disableAlertById, action.payload);
// clear previous state
yield put(createAlertAction.success(null));
yield put(deleteAlertAction.success(action.payload.alertId));
showAlertDisabledSuccess();
yield put(getMonitorAlertsAction.get());
} catch (err) {
showAlertDisabledFailed(err);
yield put(deleteAlertAction.fail(err));
}
});
yield takeLatest(
getConnectorsAction.get,
fetchEffectFactory(fetchConnectors, getConnectorsAction.success, getConnectorsAction.fail)
);
yield takeLatest(
getMonitorAlertsAction.get,
fetchEffectFactory(
fetchMonitorAlertRecords,
getMonitorAlertsAction.success,
getMonitorAlertsAction.fail
)
);
yield takeLatest(createAlertAction.get, function* (action: Action<NewAlertParams>) {
try {
const response = yield call(createAlert, action.payload);
yield put(createAlertAction.success(response));
kibanaService.core.notifications.toasts.addSuccess(
simpleAlertEnabled(action.payload.defaultActions)
);
yield put(getMonitorAlertsAction.get());
} catch (err) {
kibanaService.core.notifications.toasts.addError(err, {
title: i18n.translate('xpack.uptime.overview.alerts.enabled.failed', {
defaultMessage: 'Alert cannot be enabled!',
}),
});
yield put(createAlertAction.fail(err));
}
});
}
export const connectorsSelector = ({ alerts }: AppState) => alerts.connectors;
export const newAlertSelector = ({ alerts }: AppState) => alerts.newAlert;
export const alertsSelector = ({ alerts }: AppState) => alerts.alerts;
export const isAlertDeletedSelector = ({ alerts }: AppState) => alerts.alertDeletion;
export const anomalyAlertSelector = ({ alerts }: AppState) => alerts.anomalyAlert;

View file

@ -4,10 +4,81 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { ACTION_GROUP_DEFINITIONS, CLIENT_ALERT_TYPES } from '../../../common/constants/alerts';
import { apiService } from './utils';
import { ActionConnector } from '../alerts/alerts';
import { AlertsResult, MonitorIdParam } from '../actions/types';
import { Alert, AlertAction } from '../../../../triggers_actions_ui/public';
import { API_URLS } from '../../../common/constants';
import { MonitorIdParam } from '../actions/types';
import { Alert } from '../../../../triggers_actions_ui/public';
import { MonitorStatusTranslations } from '../../../common/translations';
const { MONITOR_STATUS } = ACTION_GROUP_DEFINITIONS;
const UPTIME_AUTO_ALERT = 'UPTIME_AUTO';
export const fetchConnectors = async () => {
return await apiService.get(API_URLS.ALERT_ACTIONS);
};
export interface NewAlertParams {
monitorId: string;
monitorName?: string;
defaultActions: ActionConnector[];
}
export const createAlert = async ({
defaultActions,
monitorId,
monitorName,
}: NewAlertParams): Promise<Alert> => {
const actions: AlertAction[] = [];
defaultActions.forEach((aId) => {
actions.push({
id: aId.id,
actionTypeId: aId.actionTypeId,
group: MONITOR_STATUS.id,
params: {
message: MonitorStatusTranslations.defaultActionMessage,
},
});
});
const data = {
actions,
params: {
numTimes: 1,
timerangeUnit: 'm',
timerangeCount: 1,
shouldCheckStatus: true,
shouldCheckAvailability: false,
isAutoGenerated: true,
search: `monitor.id : ${monitorId} `,
filters: { 'url.port': [], 'observer.geo.name': [], 'monitor.type': [], tags: [] },
},
consumer: 'uptime',
alertTypeId: CLIENT_ALERT_TYPES.MONITOR_STATUS,
schedule: { interval: '1m' },
tags: [UPTIME_AUTO_ALERT],
name: `${monitorName} (Simple status alert)`,
};
return await apiService.post(API_URLS.CREATE_ALERT, data);
};
export const fetchMonitorAlertRecords = async (): Promise<AlertsResult> => {
const data = {
page: 1,
per_page: 500,
filter: 'alert.attributes.alertTypeId:(xpack.uptime.alerts.monitorStatus)',
default_search_operator: 'AND',
sort_field: 'name.keyword',
sort_order: 'asc',
search_fields: ['name', 'tags'],
search: 'UPTIME_AUTO',
};
return await apiService.get(API_URLS.ALERTS_FIND, data);
};
export const fetchAlertRecords = async ({ monitorId }: MonitorIdParam): Promise<Alert> => {
const data = {
@ -22,6 +93,6 @@ export const fetchAlertRecords = async ({ monitorId }: MonitorIdParam): Promise<
return alerts.data.find((alert: Alert) => alert.params.monitorId === monitorId);
};
export const disableAnomalyAlert = async ({ alertId }: { alertId: string }) => {
export const disableAlertById = async ({ alertId }: { alertId: string }) => {
return await apiService.delete(API_URLS.ALERT + alertId);
};

View file

@ -7,10 +7,10 @@
import { handleActions } from 'redux-actions';
import { takeLatest } from 'redux-saga/effects';
import { createAsyncAction } from '../actions/utils';
import { getAsyncInitialState, handleAsyncAction } from '../reducers/utils';
import { asyncInitState, handleAsyncAction } from '../reducers/utils';
import { CertResult, GetCertsParams } from '../../../common/runtime_types';
import { AppState } from '../index';
import { AsyncInitialState } from '../reducers/types';
import { AsyncInitState } from '../reducers/types';
import { fetchEffectFactory } from '../effects/fetch_effect';
import { fetchCertificates } from '../api/certificates';
@ -19,11 +19,11 @@ export const getCertificatesAction = createAsyncAction<GetCertsParams, CertResul
);
interface CertificatesState {
certs: AsyncInitialState<CertResult>;
certs: AsyncInitState<CertResult>;
}
const initialState = {
certs: getAsyncInitialState(),
certs: asyncInitState(),
};
export const certificatesReducer = handleActions<CertificatesState>(

View file

@ -8,7 +8,7 @@ import { Action } from 'redux-actions';
import { call, put, takeLatest, select } from 'redux-saga/effects';
import { fetchEffectFactory } from './fetch_effect';
import { deleteAlertAction, getExistingAlertAction } from '../actions/alerts';
import { disableAnomalyAlert, fetchAlertRecords } from '../api/alerts';
import { disableAlertById, fetchAlertRecords } from '../api/alerts';
import { kibanaService } from '../kibana_service';
import { monitorIdSelector } from '../selectors';
@ -24,7 +24,7 @@ export function* fetchAlertsEffect() {
yield takeLatest(String(deleteAlertAction.get), function* (action: Action<{ alertId: string }>) {
try {
const response = yield call(disableAnomalyAlert, action.payload);
const response = yield call(disableAlertById, action.payload);
yield put(deleteAlertAction.success(response));
kibanaService.core.notifications.toasts.addSuccess('Alert successfully deleted!');
const monitorId = yield select(monitorIdSelector);

View file

@ -17,7 +17,7 @@ import { fetchMonitorDurationEffect } from './monitor_duration';
import { fetchMLJobEffect } from './ml_anomaly';
import { fetchIndexStatusEffect } from './index_status';
import { fetchCertificatesEffect } from '../certificates/certificates';
import { fetchAlertsEffect } from './alerts';
import { fetchAlertsEffect } from '../alerts/alerts';
export function* rootEffect() {
yield fork(fetchMonitorDetailsEffect);

View file

@ -21,9 +21,8 @@ import {
deleteMLJob,
getMLCapabilities,
} from '../api/ml_anomaly';
import { deleteAlertAction } from '../actions/alerts';
import { alertSelector } from '../selectors';
import { MonitorIdParam } from '../actions/types';
import { anomalyAlertSelector, deleteAlertAction } from '../alerts/alerts';
export function* fetchMLJobEffect() {
yield takeLatest(
@ -49,7 +48,7 @@ export function* fetchMLJobEffect() {
yield put(deleteMLJobAction.success(response));
// let's delete alert as well if it's there
const { data: anomalyAlert } = yield select(alertSelector);
const { data: anomalyAlert } = yield select(anomalyAlertSelector);
if (anomalyAlert) {
yield put(deleteAlertAction.get({ alertId: anomalyAlert.id as string }));
}

View file

@ -7,8 +7,6 @@
import { takeLatest } from 'redux-saga/effects';
import {
getMonitorDetailsAction,
getMonitorDetailsActionSuccess,
getMonitorDetailsActionFail,
getMonitorLocationsAction,
getMonitorLocationsActionSuccess,
getMonitorLocationsActionFail,
@ -18,11 +16,11 @@ import { fetchEffectFactory } from './fetch_effect';
export function* fetchMonitorDetailsEffect() {
yield takeLatest(
getMonitorDetailsAction,
getMonitorDetailsAction.get,
fetchEffectFactory(
fetchMonitorDetails,
getMonitorDetailsActionSuccess,
getMonitorDetailsActionFail
getMonitorDetailsAction.success,
getMonitorDetailsAction.fail
)
);

View file

@ -1,29 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { handleActions } from 'redux-actions';
import { getAsyncInitialState, handleAsyncAction } from './utils';
import { AsyncInitialState } from './types';
import { deleteAlertAction, getExistingAlertAction } from '../actions/alerts';
import { Alert } from '../../../../triggers_actions_ui/public';
export interface AlertsState {
alert: AsyncInitialState<Alert>;
alertDeletion: AsyncInitialState<boolean>;
}
const initialState: AlertsState = {
alert: getAsyncInitialState(),
alertDeletion: getAsyncInitialState(),
};
export const alertsReducer = handleActions<AlertsState>(
{
...handleAsyncAction<AlertsState>('alert', getExistingAlertAction),
...handleAsyncAction<AlertsState>('alertDeletion', deleteAlertAction),
},
initialState
);

View file

@ -20,7 +20,7 @@ import { indexStatusReducer } from './index_status';
import { mlJobsReducer } from './ml_anomaly';
import { certificatesReducer } from '../certificates/certificates';
import { selectedFiltersReducer } from './selected_filters';
import { alertsReducer } from './alerts';
import { alertsReducer } from '../alerts/alerts';
export const rootReducer = combineReducers({
monitor: monitorReducer,

View file

@ -6,16 +6,16 @@
import { handleActions } from 'redux-actions';
import { indexStatusAction } from '../actions';
import { getAsyncInitialState, handleAsyncAction } from './utils';
import { AsyncInitialState } from './types';
import { asyncInitState, handleAsyncAction } from './utils';
import { AsyncInitState } from './types';
import { StatesIndexStatus } from '../../../common/runtime_types';
export interface IndexStatusState {
indexStatus: AsyncInitialState<StatesIndexStatus | null>;
indexStatus: AsyncInitState<StatesIndexStatus | null>;
}
const initialState: IndexStatusState = {
indexStatus: getAsyncInitialState(),
indexStatus: asyncInitState(),
};
type PayLoad = StatesIndexStatus & Error;

View file

@ -14,25 +14,25 @@ import {
AnomalyRecords,
getMLCapabilitiesAction,
} from '../actions';
import { getAsyncInitialState, handleAsyncAction } from './utils';
import { AsyncInitialState } from './types';
import { asyncInitState, handleAsyncAction } from './utils';
import { AsyncInitState } from './types';
import { MlCapabilitiesResponse, JobExistResult } from '../../../../../plugins/ml/public';
import { CreateMLJobSuccess, DeleteJobResults } from '../actions/types';
export interface MLJobState {
mlJob: AsyncInitialState<JobExistResult>;
createJob: AsyncInitialState<CreateMLJobSuccess>;
deleteJob: AsyncInitialState<DeleteJobResults>;
anomalies: AsyncInitialState<AnomalyRecords>;
mlCapabilities: AsyncInitialState<MlCapabilitiesResponse>;
mlJob: AsyncInitState<JobExistResult>;
createJob: AsyncInitState<CreateMLJobSuccess>;
deleteJob: AsyncInitState<DeleteJobResults>;
anomalies: AsyncInitState<AnomalyRecords>;
mlCapabilities: AsyncInitState<MlCapabilitiesResponse>;
}
const initialState: MLJobState = {
mlJob: getAsyncInitialState(),
createJob: getAsyncInitialState(),
deleteJob: getAsyncInitialState(),
anomalies: getAsyncInitialState(),
mlCapabilities: getAsyncInitialState(),
mlJob: asyncInitState(),
createJob: asyncInitState(),
deleteJob: asyncInitState(),
anomalies: asyncInitState(),
mlCapabilities: asyncInitState(),
};
export const mlJobsReducer = handleActions<MLJobState>(

View file

@ -9,8 +9,6 @@ import {
MonitorDetailsState,
getMonitorDetailsAction,
getMonitorLocationsAction,
getMonitorDetailsActionSuccess,
getMonitorDetailsActionFail,
getMonitorLocationsActionSuccess,
getMonitorLocationsActionFail,
} from '../actions/monitor';
@ -34,12 +32,12 @@ const initialState: MonitorState = {
export function monitorReducer(state = initialState, action: Action<any>): MonitorState {
switch (action.type) {
case String(getMonitorDetailsAction):
case String(getMonitorDetailsAction.get):
return {
...state,
loading: true,
};
case String(getMonitorDetailsActionSuccess):
case String(getMonitorDetailsAction.success):
const { monitorId } = action.payload;
return {
...state,
@ -49,10 +47,11 @@ export function monitorReducer(state = initialState, action: Action<any>): Monit
},
loading: false,
};
case String(getMonitorDetailsActionFail):
case String(getMonitorDetailsAction.fail):
return {
...state,
errors: [...state.errors, action.payload],
loading: false,
};
case String(getMonitorLocationsAction):
return {

View file

@ -6,7 +6,7 @@
import { IHttpFetchError } from 'src/core/public';
export interface AsyncInitialState<ReduceStateType> {
export interface AsyncInitState<ReduceStateType> {
data: ReduceStateType | null;
loading: boolean;
error?: IHttpFetchError | null;

View file

@ -8,7 +8,7 @@ import { Action } from 'redux-actions';
import { AsyncAction } from '../actions/types';
export function handleAsyncAction<ReducerState>(
storeKey: string,
storeKey: keyof ReducerState,
asyncAction: AsyncAction<any, any>
) {
return {
@ -20,14 +20,16 @@ export function handleAsyncAction<ReducerState>(
},
}),
[String(asyncAction.success)]: (state: ReducerState, action: Action<any>) => ({
...state,
[storeKey]: {
...(state as any)[storeKey],
data: action.payload,
loading: false,
},
}),
[String(asyncAction.success)]: (state: ReducerState, action: Action<any>) => {
return {
...state,
[storeKey]: {
...(state as any)[storeKey],
data: action.payload,
loading: false,
},
};
},
[String(asyncAction.fail)]: (state: ReducerState, action: Action<any>) => ({
...state,
@ -41,7 +43,7 @@ export function handleAsyncAction<ReducerState>(
};
}
export function getAsyncInitialState(initialData = null) {
export function asyncInitState(initialData = null) {
return {
data: initialData,
loading: false,

View file

@ -111,7 +111,11 @@ describe('state selectors', () => {
selectedFilters: null,
alerts: {
alertDeletion: { data: null, loading: false },
alert: { data: null, loading: false },
anomalyAlert: { data: null, loading: false },
alerts: { data: null, loading: false },
connectors: { data: null, loading: false },
newAlert: { data: null, loading: false },
anomalyAlertDeletion: { data: null, loading: false },
},
};

View file

@ -18,6 +18,8 @@ export const monitorDetailsSelector = (state: AppState, summary: any) => {
return state.monitor.monitorDetailsList[summary.monitor_id];
};
export const monitorDetailsLoadingSelector = (state: AppState) => state.monitor.loading;
export const monitorLocationsSelector = (state: AppState, monitorId: string) => {
return state.monitor.monitorLocationsList?.get(monitorId);
};
@ -92,5 +94,3 @@ export const searchTextSelector = ({ ui: { searchText } }: AppState) => searchTe
export const selectedFiltersSelector = ({ selectedFilters }: AppState) => selectedFilters;
export const monitorIdSelector = ({ ui: { monitorId } }: AppState) => monitorId;
export const alertSelector = ({ alerts }: AppState) => alerts.alert;

View file

@ -43,7 +43,7 @@ export const initServerWithKibana = (server: UptimeCoreSetup, plugins: UptimeCor
all: {
app: ['uptime', 'kibana'],
catalogue: ['uptime'],
api: ['uptime-read', 'uptime-write'],
api: ['uptime-read', 'uptime-write', 'lists-all'],
savedObject: {
all: [umDynamicSettings.name, 'alert'],
read: [],
@ -54,12 +54,12 @@ export const initServerWithKibana = (server: UptimeCoreSetup, plugins: UptimeCor
management: {
insightsAndAlerting: ['triggersActions'],
},
ui: ['save', 'configureSettings', 'show'],
ui: ['save', 'configureSettings', 'show', 'alerting:save'],
},
read: {
app: ['uptime', 'kibana'],
catalogue: ['uptime'],
api: ['uptime-read'],
api: ['uptime-read', 'lists-read'],
savedObject: {
all: ['alert'],
read: [umDynamicSettings.name],
@ -70,7 +70,7 @@ export const initServerWithKibana = (server: UptimeCoreSetup, plugins: UptimeCor
management: {
insightsAndAlerting: ['triggersActions'],
},
ui: ['show'],
ui: ['show', 'alerting:save'],
},
},
});

View file

@ -7,23 +7,19 @@
import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
import {
IRouter,
LegacyCallAPIOptions,
SavedObjectsClientContract,
ISavedObjectsRepository,
ILegacyScopedClusterClient,
} from 'src/core/server';
import { UMKibanaRoute } from '../../../rest_api';
import { PluginSetupContract } from '../../../../../features/server';
import { DynamicSettings } from '../../../../common/runtime_types';
import { MlPluginSetup as MlSetup } from '../../../../../ml/server';
export type APICaller = (
endpoint: string,
clientParams: Record<string, any>,
options?: LegacyCallAPIOptions
) => Promise<any>;
export type ESAPICaller = ILegacyScopedClusterClient['callAsCurrentUser'];
export type UMElasticsearchQueryFn<P, R = any> = (
params: { callES: APICaller; dynamicSettings: DynamicSettings } & P
params: { callES: ESAPICaller; dynamicSettings: DynamicSettings } & P
) => Promise<R>;
export type UMSavedObjectsQueryFn<T = any, P = undefined> = (

View file

@ -8,7 +8,7 @@ import moment from 'moment';
import { ISavedObjectsRepository, SavedObjectsClientContract } from 'kibana/server';
import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
import { PageViewParams, UptimeTelemetry, Usage } from './types';
import { APICaller } from '../framework';
import { ESAPICaller } from '../framework';
import { savedObjectsAdapter } from '../../saved_objects';
interface UptimeTelemetryCollector {
@ -69,7 +69,7 @@ export class KibanaTelemetryAdapter {
},
},
},
fetch: async (callCluster: APICaller) => {
fetch: async (callCluster: ESAPICaller) => {
const savedObjectsClient = getSavedObjectsClient()!;
if (savedObjectsClient) {
await this.countNoOfUniqueMonitorAndLocations(callCluster, savedObjectsClient);
@ -125,7 +125,7 @@ export class KibanaTelemetryAdapter {
}
public static async countNoOfUniqueMonitorAndLocations(
callCluster: APICaller,
callCluster: ESAPICaller,
savedObjectsClient: ISavedObjectsRepository | SavedObjectsClientContract
) {
const dynamicSettings = await savedObjectsAdapter.getUptimeDynamicSettings(savedObjectsClient);

View file

@ -98,6 +98,7 @@ describe('status check alert', () => {
"dynamicSettings": Object {
"certAgeThreshold": 730,
"certExpirationThreshold": 30,
"defaultConnectors": Array [],
"heartbeatIndices": "heartbeat-8*",
},
"filters": undefined,
@ -153,6 +154,7 @@ describe('status check alert', () => {
"dynamicSettings": Object {
"certAgeThreshold": 730,
"certExpirationThreshold": 30,
"defaultConnectors": Array [],
"heartbeatIndices": "heartbeat-8*",
},
"filters": undefined,
@ -331,6 +333,7 @@ describe('status check alert', () => {
"dynamicSettings": Object {
"certAgeThreshold": 730,
"certExpirationThreshold": 30,
"defaultConnectors": Array [],
"heartbeatIndices": "heartbeat-8*",
},
"filters": Object {
@ -568,6 +571,7 @@ describe('status check alert', () => {
"dynamicSettings": Object {
"certAgeThreshold": 730,
"certExpirationThreshold": 30,
"defaultConnectors": Array [],
"heartbeatIndices": "heartbeat-8*",
},
"filters": Object {
@ -754,6 +758,7 @@ describe('status check alert', () => {
"dynamicSettings": Object {
"certAgeThreshold": 730,
"certExpirationThreshold": 30,
"defaultConnectors": Array [],
"heartbeatIndices": "heartbeat-8*",
},
"filters": "{\\"bool\\":{\\"filter\\":[{\\"bool\\":{\\"should\\":[{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"url.port\\":12349}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"url.port\\":5601}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"url.port\\":443}}],\\"minimum_should_match\\":1}}],\\"minimum_should_match\\":1}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"filter\\":[{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"observer.geo.name\\":\\"harrisburg\\"}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"filter\\":[{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"monitor.type\\":\\"http\\"}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"tags\\":\\"unsecured\\"}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"tags\\":\\"containers\\"}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"match_phrase\\":{\\"tags\\":\\"org:google\\"}}],\\"minimum_should_match\\":1}}],\\"minimum_should_match\\":1}}],\\"minimum_should_match\\":1}}]}}]}}]}}",
@ -808,6 +813,7 @@ describe('status check alert', () => {
"dynamicSettings": Object {
"certAgeThreshold": 730,
"certExpirationThreshold": 30,
"defaultConnectors": Array [],
"heartbeatIndices": "heartbeat-8*",
},
"filters": "{\\"bool\\":{\\"should\\":[{\\"exists\\":{\\"field\\":\\"ur.port\\"}}],\\"minimum_should_match\\":1}}",
@ -851,6 +857,7 @@ describe('status check alert', () => {
"dynamicSettings": Object {
"certAgeThreshold": 730,
"certExpirationThreshold": 30,
"defaultConnectors": Array [],
"heartbeatIndices": "heartbeat-8*",
},
"filters": undefined,
@ -886,6 +893,7 @@ describe('status check alert', () => {
"timerangeUnit",
"timerange",
"version",
"isAutoGenerated",
]
`);
});

View file

@ -6,7 +6,6 @@
import { schema } from '@kbn/config-schema';
import { i18n } from '@kbn/i18n';
import { ILegacyScopedClusterClient } from 'kibana/server';
import Mustache from 'mustache';
import { UptimeAlertTypeFactory } from './types';
import { esKuery, IIndexPattern } from '../../../../../../src/plugins/data/server';
@ -22,11 +21,13 @@ import { updateState } from './common';
import { commonMonitorStateI18, commonStateTranslations, DOWN_LABEL } from './translations';
import { stringifyKueries, combineFiltersAndUserSearch } from '../../../common/lib';
import { GetMonitorAvailabilityResult } from '../requests/get_monitor_availability';
import { UMServerLibs } from '../lib';
import { GetMonitorStatusResult } from '../requests/get_monitor_status';
import { UNNAMED_LOCATION } from '../../../common/constants';
import { uptimeAlertWrapper } from './uptime_alert_wrapper';
import { MonitorStatusTranslations } from '../../../common/translations';
import { ESAPICaller } from '../adapters/framework';
import { getUptimeIndexPattern } from '../requests/get_index_pattern';
import { UMServerLibs } from '../lib';
const { MONITOR_STATUS } = ACTION_GROUP_DEFINITIONS;
@ -77,19 +78,21 @@ export const generateFilterDSL = async (
);
};
const formatFilterString = async (
libs: UMServerLibs,
export const formatFilterString = async (
dynamicSettings: DynamicSettings,
callES: ILegacyScopedClusterClient['callAsCurrentUser'],
callES: ESAPICaller,
filters: StatusCheckFilters,
search: string
search: string,
libs?: UMServerLibs
) =>
await generateFilterDSL(
() =>
libs.requests.getIndexPattern({
callES,
dynamicSettings,
}),
libs?.requests?.getIndexPattern
? libs?.requests?.getIndexPattern({ callES, dynamicSettings })
: getUptimeIndexPattern({
callES,
dynamicSettings,
}),
filters,
search
);
@ -197,6 +200,7 @@ export const statusCheckAlertFactory: UptimeAlertTypeFactory = (_server, libs) =
})
),
version: schema.maybe(schema.number()),
isAutoGenerated: schema.maybe(schema.boolean()),
}),
},
defaultActionGroupId: MONITOR_STATUS.id,
@ -244,26 +248,19 @@ export const statusCheckAlertFactory: UptimeAlertTypeFactory = (_server, libs) =
availability,
shouldCheckAvailability,
shouldCheckStatus,
isAutoGenerated,
timerange: oldVersionTimeRange,
} = rawParams;
const filterString = await formatFilterString(dynamicSettings, callES, filters, search, libs);
const timerange = oldVersionTimeRange || {
from: `now-${String(timerangeCount) + timerangeUnit}`,
from: isAutoGenerated
? state.lastCheckedAt
: `now-${String(timerangeCount) + timerangeUnit}`,
to: 'now',
};
const filterString = await formatFilterString(libs, dynamicSettings, callES, filters, search);
let availabilityResults: GetMonitorAvailabilityResult[] = [];
if (shouldCheckAvailability) {
availabilityResults = await libs.requests.getMonitorAvailability({
callES,
dynamicSettings,
...availability,
filters: JSON.stringify(filterString) || undefined,
});
}
let downMonitorsByLocation: GetMonitorStatusResult[] = [];
// if oldVersionTimeRange present means it's 7.7 format and
@ -279,6 +276,37 @@ export const statusCheckAlertFactory: UptimeAlertTypeFactory = (_server, libs) =
});
}
if (isAutoGenerated) {
for (const monitorLoc of downMonitorsByLocation) {
const monitorInfo = monitorLoc.monitorInfo;
const alertInstance = alertInstanceFactory(MONITOR_STATUS.id + monitorLoc.location);
const monitorSummary = getMonitorSummary(monitorInfo);
const statusMessage = getStatusMessage(monitorInfo);
alertInstance.replaceState({
...state,
...monitorSummary,
statusMessage,
...updateState(state, true),
});
alertInstance.scheduleActions(MONITOR_STATUS.id);
}
return updateState(state, downMonitorsByLocation.length > 0);
}
let availabilityResults: GetMonitorAvailabilityResult[] = [];
if (shouldCheckAvailability) {
availabilityResults = await libs.requests.getMonitorAvailability({
callES,
dynamicSettings,
...availability,
filters: JSON.stringify(filterString) || undefined,
});
}
const mergedIdsByLoc = getUniqueIdsByLoc(downMonitorsByLocation, availabilityResults);
mergedIdsByLoc.forEach((monIdByLoc) => {

View file

@ -94,6 +94,7 @@ describe('getCerts', () => {
heartbeatIndices: 'heartbeat*',
certAgeThreshold: DYNAMIC_SETTINGS_DEFAULTS.certAgeThreshold,
certExpirationThreshold: DYNAMIC_SETTINGS_DEFAULTS.certExpirationThreshold,
defaultConnectors: [],
},
index: 1,
from: 'now-2d',

View file

@ -32,7 +32,7 @@ describe('getLatestMonitor', () => {
},
},
size: 1,
_source: ['url', 'monitor', 'observer', '@timestamp', 'tls.*', 'http'],
_source: ['url', 'monitor', 'observer', '@timestamp', 'tls.*', 'http', 'error'],
sort: {
'@timestamp': { order: 'desc' },
},

View file

@ -16,6 +16,10 @@ export interface GetLatestMonitorParams {
/** @member monitorId optional limit to monitorId */
monitorId?: string | null;
observerLocation?: string;
status?: string;
}
// Get The monitor latest state sorted by timestamp with date range
@ -25,6 +29,8 @@ export const getLatestMonitor: UMElasticsearchQueryFn<GetLatestMonitorParams, Pi
dateStart,
dateEnd,
monitorId,
observerLocation,
status,
}) => {
const params = {
index: dynamicSettings.heartbeatIndices,
@ -40,12 +46,14 @@ export const getLatestMonitor: UMElasticsearchQueryFn<GetLatestMonitorParams, Pi
},
},
},
...(status ? [{ term: { 'monitor.status': status } }] : []),
...(monitorId ? [{ term: { 'monitor.id': monitorId } }] : []),
...(observerLocation ? [{ term: { 'observer.geo.name': observerLocation } }] : []),
],
},
},
size: 1,
_source: ['url', 'monitor', 'observer', '@timestamp', 'tls.*', 'http'],
_source: ['url', 'monitor', 'observer', '@timestamp', 'tls.*', 'http', 'error'],
sort: {
'@timestamp': { order: 'desc' },
},

View file

@ -4,19 +4,87 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { UMElasticsearchQueryFn } from '../adapters';
import { ESAPICaller, UMElasticsearchQueryFn } from '../adapters';
import { MonitorDetails, MonitorError } from '../../../common/runtime_types';
import { formatFilterString } from '../alerts/status_check';
export interface GetMonitorDetailsParams {
monitorId: string;
dateStart: string;
dateEnd: string;
alertsClient: any;
}
const getMonitorAlerts = async (
callES: ESAPICaller,
dynamicSettings: any,
alertsClient: any,
monitorId: string
) => {
const options: any = {
page: 1,
perPage: 500,
filter: 'alert.attributes.alertTypeId:(xpack.uptime.alerts.monitorStatus)',
defaultSearchOperator: 'AND',
sortField: 'name.keyword',
};
const { data } = await alertsClient.find({ options });
const monitorAlerts = [];
for (let i = 0; i < data.length; i++) {
const currAlert = data[i];
if (currAlert.params.search?.includes(monitorId)) {
monitorAlerts.push(currAlert);
continue;
}
const esParams: any = {
index: dynamicSettings.heartbeatIndices,
body: {
query: {
bool: {
filter: [
{
term: {
'monitor.id': monitorId,
},
},
],
},
},
size: 0,
aggs: {
monitors: {
terms: {
field: 'monitor.id',
size: 1000,
},
},
},
},
};
const parsedFilters = await formatFilterString(
dynamicSettings,
callES,
currAlert.params.filters,
currAlert.params.search
);
esParams.body.query.bool = Object.assign({}, esParams.body.query.bool, parsedFilters?.bool);
const result = await callES('search', esParams);
if (result.hits.total.value > 0) {
monitorAlerts.push(currAlert);
}
}
return monitorAlerts;
};
export const getMonitorDetails: UMElasticsearchQueryFn<
GetMonitorDetailsParams,
MonitorDetails
> = async ({ callES, dynamicSettings, monitorId, dateStart, dateEnd }) => {
> = async ({ callES, dynamicSettings, monitorId, dateStart, dateEnd, alertsClient }) => {
const queryFilters: any = [
{
range: {
@ -66,10 +134,11 @@ export const getMonitorDetails: UMElasticsearchQueryFn<
const monitorError: MonitorError | undefined = data?.error;
const errorTimestamp: string | undefined = data?.['@timestamp'];
const monAlerts = await getMonitorAlerts(callES, dynamicSettings, alertsClient, monitorId);
return {
monitorId,
error: monitorError,
timestamp: errorTimestamp,
alerts: monAlerts,
};
};

View file

@ -142,7 +142,7 @@ export const getMonitorStatus: UMElasticsearchQueryFn<
} while (afterKey !== undefined);
return monitors
.filter((monitor: any) => monitor?.doc_count > numTimes)
.filter((monitor: any) => monitor?.doc_count >= numTimes)
.map(({ key, doc_count: count, fields }: any) => ({
...key,
count,

View file

@ -36,6 +36,9 @@ export const umDynamicSettings: SavedObjectsType = {
certExpirationThreshold: {
type: 'long',
},
defaultConnectors: {
type: 'keyword',
},
*/
},
},

View file

@ -14,6 +14,7 @@ describe('dynamic settings', () => {
certAgeThreshold: -1,
certExpirationThreshold: 2,
heartbeatIndices: 'foo',
defaultConnectors: [],
})
).toMatchInlineSnapshot(`
Object {
@ -28,6 +29,7 @@ describe('dynamic settings', () => {
certAgeThreshold: 10.2,
certExpirationThreshold: 2,
heartbeatIndices: 'foo',
defaultConnectors: [],
})
).toMatchInlineSnapshot(`
Object {
@ -42,6 +44,7 @@ describe('dynamic settings', () => {
certAgeThreshold: 2,
certExpirationThreshold: -1,
heartbeatIndices: 'foo',
defaultConnectors: [],
})
).toMatchInlineSnapshot(`
Object {
@ -56,6 +59,7 @@ describe('dynamic settings', () => {
certAgeThreshold: 2,
certExpirationThreshold: 1.23,
heartbeatIndices: 'foo',
defaultConnectors: [],
})
).toMatchInlineSnapshot(`
Object {
@ -70,6 +74,7 @@ describe('dynamic settings', () => {
certAgeThreshold: 2,
certExpirationThreshold: 13,
heartbeatIndices: 'foo',
defaultConnectors: [],
})
).toBeUndefined();
});

View file

@ -54,6 +54,7 @@ export const createPostDynamicSettingsRoute: UMRestApiRouteFactory = (libs: UMSe
heartbeatIndices: schema.string(),
certAgeThreshold: schema.number(),
certExpirationThreshold: schema.number(),
defaultConnectors: schema.arrayOf(schema.string()),
}),
},
writeAccess: true,

View file

@ -19,8 +19,11 @@ export const createGetMonitorDetailsRoute: UMRestApiRouteFactory = (libs: UMServ
dateEnd: schema.maybe(schema.string()),
}),
},
handler: async ({ callES, dynamicSettings }, _context, request, response): Promise<any> => {
handler: async ({ callES, dynamicSettings }, context, request, response): Promise<any> => {
const { monitorId, dateStart, dateEnd } = request.query;
const alertsClient = context.alerting?.getAlertsClient();
return response.ok({
body: {
...(await libs.requests.getMonitorDetails({
@ -29,6 +32,7 @@ export const createGetMonitorDetailsRoute: UMRestApiRouteFactory = (libs: UMServ
monitorId,
dateStart,
dateEnd,
alertsClient,
})),
},
});

View file

@ -22,6 +22,7 @@ export const createGetMonitorDurationRoute: UMRestApiRouteFactory = (libs: UMSer
},
handler: async ({ callES, dynamicSettings }, _context, request, response): Promise<any> => {
const { monitorId, dateStart, dateEnd } = request.query;
return response.ok({
body: {
...(await libs.requests.getMonitorDurationChart({

View file

@ -27,6 +27,7 @@ export default function ({ getService }: FtrProviderContext) {
heartbeatIndices: 'myIndex1*',
certAgeThreshold: 15,
certExpirationThreshold: 5,
defaultConnectors: [],
};
const postResponse = await supertest
.post(`/api/uptime/dynamic_settings`)

View file

@ -12,6 +12,28 @@ import {
const ARCHIVE = 'uptime/full_heartbeat';
export const deleteUptimeSettingsObject = async (server: any) => {
// delete the saved object
try {
await server.savedObjects.delete({
type: settingsObjectType,
id: settingsObjectId,
});
} catch (e) {
// a 404 just means the doc is already missing
if (e.response.status !== 404) {
const { status, statusText, data, headers, config } = e.response;
throw new Error(
`error attempting to delete settings:\n${JSON.stringify(
{ status, statusText, data, headers, config },
null,
2
)}`
);
}
}
};
export default ({ loadTestFile, getService }: FtrProviderContext) => {
const esArchiver = getService('esArchiver');
const kibanaServer = getService('kibanaServer');
@ -22,25 +44,7 @@ export default ({ loadTestFile, getService }: FtrProviderContext) => {
this.tags('ciGroup6');
beforeEach('delete settings', async () => {
// delete the saved object
try {
await server.savedObjects.delete({
type: settingsObjectType,
id: settingsObjectId,
});
} catch (e) {
// a 404 just means the doc is already missing
if (e.response.status !== 404) {
const { status, statusText, data, headers, config } = e.response;
throw new Error(
`error attempting to delete settings:\n${JSON.stringify(
{ status, statusText, data, headers, config },
null,
2
)}`
);
}
}
await deleteUptimeSettingsObject(server);
});
describe('with generated data', () => {

View file

@ -63,6 +63,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
heartbeatIndices: 'new*',
certAgeThreshold: 365,
certExpirationThreshold: 30,
defaultConnectors: [],
};
await settings.changeHeartbeatIndicesInput(newFieldValues.heartbeatIndices);
await settings.apply();

View file

@ -39,6 +39,7 @@ export function UptimePageProvider({ getPageObjects, getService }: FtrProviderCo
if (monitorIdToCheck) {
await commonService.monitorIdExists(monitorIdToCheck);
}
await pageObjects.header.waitUntilLoadingHasFinished();
}
public async loadDataAndGoToMonitorPage(dateStart: string, dateEnd: string, monitorId: string) {

View file

@ -6,12 +6,14 @@
import { FtrProviderContext } from '../../ftr_provider_context';
export function UptimeCommonProvider({ getService }: FtrProviderContext) {
export function UptimeCommonProvider({ getService, getPageObjects }: FtrProviderContext) {
const testSubjects = getService('testSubjects');
const browser = getService('browser');
const retry = getService('retry');
const find = getService('find');
const { header } = getPageObjects(['header']);
return {
async assertExists(key: string) {
if (!(await testSubjects.exists(key))) {
@ -92,9 +94,11 @@ export function UptimeCommonProvider({ getService }: FtrProviderContext) {
);
},
async waitUntilDataIsLoaded() {
await header.waitUntilLoadingHasFinished();
return retry.tryForTime(60 * 1000, async () => {
if (await testSubjects.exists('data-missing')) {
await testSubjects.click('superDatePickerApplyTimeButton');
await header.waitUntilLoadingHasFinished();
}
await testSubjects.missingOrFail('data-missing');
});

View file

@ -16,6 +16,7 @@ export function UptimeNavigationProvider({ getService, getPageObjects }: FtrProv
await retry.tryForTime(60 * 1000, async () => {
if (await testSubjects.exists('uptimeSettingsToOverviewLink', { timeout: 0 })) {
await testSubjects.click('uptimeSettingsToOverviewLink');
await PageObjects.header.waitUntilLoadingHasFinished();
await testSubjects.existOrFail('uptimeOverviewPage', { timeout: 2000 });
} else {
await PageObjects.common.navigateToApp('uptime');

View file

@ -30,5 +30,9 @@ export function UptimeOverviewProvider({ getService }: FtrProviderContext) {
await testSubjects.click('xpack.uptime.alertsPopover.toggleButton');
return testSubjects.click('xpack.uptime.openAlertContextPanel');
},
async displaysDefineConnector() {
return await testSubjects.existOrFail('uptimeSettingsDefineConnector');
},
};
}

View file

@ -43,6 +43,7 @@ export function UptimeSettingsProvider({ getService }: FtrProviderContext) {
heartbeatIndices,
certAgeThreshold: parseInt(age, 10),
certExpirationThreshold: parseInt(expiration, 10),
defaultConnectors: [],
};
},
applyButtonIsDisabled: async () => {

View file

@ -23,6 +23,7 @@ export default ({ getService, loadTestFile }: FtrProviderContext) => {
loadTestFile(require.resolve('./alert_flyout'));
loadTestFile(require.resolve('./anomaly_alert'));
loadTestFile(require.resolve('./simple_down_alert'));
});
});
};

View file

@ -0,0 +1,109 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
import { deleteUptimeSettingsObject } from '../../../functional/apps/uptime';
export default ({ getPageObjects, getService }: FtrProviderContext) => {
describe('uptime simple status alert', () => {
const pageObjects = getPageObjects(['common', 'header', 'uptime']);
const server = getService('kibanaServer');
const uptimeService = getService('uptime');
const retry = getService('retry');
const supertest = getService('supertest');
const testSubjects = getService('testSubjects');
const monitorId = '0000-intermittent';
const uptime = getService('uptime');
const DEFAULT_DATE_START = 'Sep 10, 2019 @ 12:40:08.078';
const DEFAULT_DATE_END = 'Sep 11, 2019 @ 19:40:08.078';
before(async () => {
// delete the saved object
await deleteUptimeSettingsObject(server);
await uptime.navigation.goToUptime();
await pageObjects.uptime.goToUptimeOverviewAndLoadData(DEFAULT_DATE_START, DEFAULT_DATE_END);
});
beforeEach(async () => {
await pageObjects.header.waitUntilLoadingHasFinished();
});
it('displays to define default connector', async () => {
await testSubjects.click('uptimeDisplayDefineConnector');
await testSubjects.existOrFail('uptimeSettingsDefineConnector');
});
it('go to settings to define connector', async () => {
await testSubjects.click('uptimeSettingsLink');
await uptimeService.common.waitUntilDataIsLoaded();
await testSubjects.existOrFail('comboBoxInput');
});
it('define default connector', async () => {
await testSubjects.click('comboBoxInput');
await testSubjects.click('Slack#xyztest');
await testSubjects.click('apply-settings-button');
await uptimeService.navigation.goToUptime();
});
it('enable simple status alert', async () => {
await pageObjects.uptime.goToUptimeOverviewAndLoadData(DEFAULT_DATE_START, DEFAULT_DATE_END);
await testSubjects.click('uptimeEnableSimpleDownAlert' + monitorId);
await pageObjects.header.waitUntilLoadingHasFinished();
});
it('displays relevant alert in list drawer', async () => {
await testSubjects.click(`xpack.uptime.monitorList.${monitorId}.expandMonitorDetail`);
await pageObjects.header.waitUntilLoadingHasFinished();
await testSubjects.existOrFail('uptimeMonitorListDrawerAlert0');
});
it('has created a valid simple alert with expected parameters', async () => {
let alert: any;
await retry.tryForTime(15000, async () => {
const apiResponse = await supertest.get(`/api/alerts/_find?search=Simple status alert`);
const alertsFromThisTest = apiResponse.body.data.filter(({ params }: { params: any }) =>
params.search.includes(monitorId)
);
expect(alertsFromThisTest).to.have.length(1);
alert = alertsFromThisTest[0];
});
const { actions, alertTypeId, consumer, id, tags } = alert ?? {};
try {
expect(actions).to.eql([
{
actionTypeId: '.slack',
group: 'xpack.uptime.alerts.actionGroups.monitorStatus',
id: 'my-slack1',
params: {
message:
'Monitor {{state.monitorName}} with url {{{state.monitorUrl}}} is {{state.statusMessage}} from {{state.observerLocation}}. The latest error message is {{{state.latestErrorMessage}}}',
},
},
]);
expect(alertTypeId).to.eql('xpack.uptime.alerts.monitorStatus');
expect(consumer).to.eql('uptime');
expect(tags).to.eql(['UPTIME_AUTO']);
} catch (e) {
await supertest.delete(`/api/alerts/alert/${id}`).set('kbn-xsrf', 'true').expect(204);
}
});
it('disable simple status alert', async () => {
await testSubjects.click('uptimeDisableSimpleDownAlert' + monitorId);
await pageObjects.header.waitUntilLoadingHasFinished();
await testSubjects.existOrFail('uptimeEnableSimpleDownAlert' + monitorId);
});
});
};