[Uptime] Edit uptime alerts (#68005)

* Extract store creation to plugin start, add redux providers to alert registration.

* Update unit test.

* Move alert registration to `setup` function.

* Allow external editing of uptime client alert types.

* Move alert initialization back to `start`.

* Clean up interfaces for alert types.

* Add code that will work for settings link even outside uptime app.

* Create new atomic params type for status alerts.

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

* Update snapshot for alert factory function.

* Fix broken types and refresh snapshots.

* Allow edits of filters for monitor alerts.

* Support default parameter value for numTimes.

* Support default parameter values for timerange.

* Modify kuery bar to work for alert edits, fix some filter issues.

* Clean up tests and fix types.

* Fix types and add a test.

* Add callout and validation handling for old alerts while editing.

* Add a test for updated validation function.

* Define window for overview filters fetch action.

* Revert store initialization.

* Make monitor counter function while editing alerts.

* Refresh snapshot.

* Move snapshot count in monitor status alert to callout.

* Add new state for selected filters.

* Add basic functional tests for uptime alert flyouts.

* Fix broken types.

* Update unit tests with mock provider.

* Remove unneeded params from hook.

* Add more unit tests.

* Reducing functional test flakiness.

* Alert flyout controls update url only within Uptime app.

* Extract context interaction to container component, update snapshots.

* Add missing parameter to test file.

* Remove flaky functional test.

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: Shahzad <shahzad31comp@gmail.com>
This commit is contained in:
Justin Kambic 2020-06-08 15:15:47 -04:00 committed by GitHub
parent 891342a76f
commit 858523eac6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 1524 additions and 389 deletions

View file

@ -3,6 +3,7 @@
* or more contributor license agreements. Licensed under the Elastic License; * or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License. * you may not use this file except in compliance with the Elastic License.
*/ */
import { import {
CoreSetup, CoreSetup,
CoreStart, CoreStart,
@ -16,8 +17,16 @@ import { PLUGIN } from '../../common/constants';
import { FeatureCatalogueCategory } from '../../../../../src/plugins/home/public'; import { FeatureCatalogueCategory } from '../../../../../src/plugins/home/public';
import { HomePublicPluginSetup } from '../../../../../src/plugins/home/public'; import { HomePublicPluginSetup } from '../../../../../src/plugins/home/public';
import { EmbeddableStart } from '../../../../../src/plugins/embeddable/public'; import { EmbeddableStart } from '../../../../../src/plugins/embeddable/public';
import { TriggersAndActionsUIPublicPluginSetup } from '../../../triggers_actions_ui/public'; import {
import { DataPublicPluginSetup } from '../../../../../src/plugins/data/public'; TriggersAndActionsUIPublicPluginSetup,
TriggersAndActionsUIPublicPluginStart,
} from '../../../triggers_actions_ui/public';
import {
DataPublicPluginSetup,
DataPublicPluginStart,
} from '../../../../../src/plugins/data/public';
import { alertTypeInitializers } from '../lib/alert_types';
import { kibanaService } from '../state/kibana_service';
export interface ClientPluginsSetup { export interface ClientPluginsSetup {
data: DataPublicPluginSetup; data: DataPublicPluginSetup;
@ -27,6 +36,8 @@ export interface ClientPluginsSetup {
export interface ClientPluginsStart { export interface ClientPluginsStart {
embeddable: EmbeddableStart; embeddable: EmbeddableStart;
data: DataPublicPluginStart;
triggers_actions_ui: TriggersAndActionsUIPublicPluginStart;
} }
export type ClientSetup = void; export type ClientSetup = void;
@ -66,6 +77,7 @@ export class UptimePlugin
); );
const { element } = params; const { element } = params;
const libs: UMFrontendLibs = { const libs: UMFrontendLibs = {
framework: getKibanaFrameworkAdapter(coreStart, plugins, corePlugins), framework: getKibanaFrameworkAdapter(coreStart, plugins, corePlugins),
}; };
@ -74,7 +86,21 @@ export class UptimePlugin
}); });
} }
public start(_start: CoreStart, _plugins: {}): void {} public start(start: CoreStart, plugins: ClientPluginsStart): void {
kibanaService.core = start;
alertTypeInitializers.forEach((init) => {
const alertInitializer = init({
core: start,
plugins,
});
if (
plugins.triggers_actions_ui &&
!plugins.triggers_actions_ui.alertTypeRegistry.has(alertInitializer.id)
) {
plugins.triggers_actions_ui.alertTypeRegistry.register(alertInitializer);
}
});
}
public stop(): void {} public stop(): void {}
} }

View file

@ -6,7 +6,7 @@
import React from 'react'; import React from 'react';
import { UptimeDatePicker } from '../uptime_date_picker'; import { UptimeDatePicker } from '../uptime_date_picker';
import { renderWithRouter, shallowWithRouter } from '../../../lib'; import { renderWithRouter, shallowWithRouter, MountWithReduxProvider } from '../../../lib';
describe('UptimeDatePicker component', () => { describe('UptimeDatePicker component', () => {
it('validates props with shallow render', () => { it('validates props with shallow render', () => {
@ -15,7 +15,11 @@ describe('UptimeDatePicker component', () => {
}); });
it('renders properly with mock data', () => { it('renders properly with mock data', () => {
const component = renderWithRouter(<UptimeDatePicker />); const component = renderWithRouter(
<MountWithReduxProvider>
<UptimeDatePicker />
</MountWithReduxProvider>
);
expect(component).toMatchSnapshot(); expect(component).toMatchSnapshot();
}); });
}); });

View file

@ -6,7 +6,7 @@
import React from 'react'; import React from 'react';
import { MonitorBarSeries, MonitorBarSeriesProps } from '../monitor_bar_series'; import { MonitorBarSeries, MonitorBarSeriesProps } from '../monitor_bar_series';
import { renderWithRouter, shallowWithRouter } from '../../../../lib'; import { renderWithRouter, shallowWithRouter, MountWithReduxProvider } from '../../../../lib';
import { HistogramPoint } from '../../../../../common/runtime_types'; import { HistogramPoint } from '../../../../../common/runtime_types';
describe('MonitorBarSeries component', () => { describe('MonitorBarSeries component', () => {
@ -197,7 +197,11 @@ describe('MonitorBarSeries component', () => {
}); });
it('renders if the data series is present', () => { it('renders if the data series is present', () => {
const component = renderWithRouter(<MonitorBarSeries histogramSeries={histogramSeries} />); const component = renderWithRouter(
<MountWithReduxProvider>
<MonitorBarSeries histogramSeries={histogramSeries} />
</MountWithReduxProvider>
);
expect(component).toMatchSnapshot(); expect(component).toMatchSnapshot();
}); });
}); });

View file

@ -6,7 +6,7 @@
import React from 'react'; import React from 'react';
import { PingHistogramComponent, PingHistogramComponentProps } from '../ping_histogram'; import { PingHistogramComponent, PingHistogramComponentProps } from '../ping_histogram';
import { renderWithRouter, shallowWithRouter } from '../../../../lib'; import { renderWithRouter, shallowWithRouter, MountWithReduxProvider } from '../../../../lib';
describe('PingHistogram component', () => { describe('PingHistogram component', () => {
const props: PingHistogramComponentProps = { const props: PingHistogramComponentProps = {
@ -49,7 +49,12 @@ describe('PingHistogram component', () => {
}); });
it('renders the component without errors', () => { it('renders the component without errors', () => {
const component = renderWithRouter(<PingHistogramComponent {...props} />); const component = renderWithRouter(
<MountWithReduxProvider>
<PingHistogramComponent {...props} />
</MountWithReduxProvider>
);
expect(component).toMatchSnapshot(); expect(component).toMatchSnapshot();
}); });
}); });

View file

@ -0,0 +1,180 @@
/*
* 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 { shallowWithIntl, mountWithIntl } from 'test_utils/enzyme_helpers';
import { AddFilterButton } from '../add_filter_btn';
import { EuiButtonEmpty, EuiContextMenuItem } from '@elastic/eui';
describe('AddFilterButton component', () => {
it('provides all filter choices', () => {
const component = shallowWithIntl(
<AddFilterButton newFilters={[]} onNewFilter={jest.fn()} alertFilters={{}} />
);
expect(component).toMatchInlineSnapshot(`
<EuiPopover
anchorPosition="downLeft"
button={
<EuiButtonEmpty
data-test-subj="uptimeCreateAlertAddFilter"
disabled={false}
iconType="plusInCircleFilled"
onClick={[Function]}
>
Add filter
</EuiButtonEmpty>
}
closePopover={[Function]}
display="inlineBlock"
hasArrow={true}
id="singlePanel"
isOpen={false}
ownFocus={false}
panelPaddingSize="none"
>
<EuiContextMenuPanel
hasFocus={true}
items={
Array [
<EuiContextMenuItem
data-test-subj="uptimeAlertAddFilter.observer.geo.name"
onClick={[Function]}
>
Location
</EuiContextMenuItem>,
<EuiContextMenuItem
data-test-subj="uptimeAlertAddFilter.tags"
onClick={[Function]}
>
Tag
</EuiContextMenuItem>,
<EuiContextMenuItem
data-test-subj="uptimeAlertAddFilter.url.port"
onClick={[Function]}
>
Port
</EuiContextMenuItem>,
<EuiContextMenuItem
data-test-subj="uptimeAlertAddFilter.monitor.type"
onClick={[Function]}
>
Type
</EuiContextMenuItem>,
]
}
/>
</EuiPopover>
`);
});
it('excludes filters that already have selected values', () => {
const component = shallowWithIntl(
<AddFilterButton
newFilters={['observer.geo.name', 'tags']}
alertFilters={{ 'url.port': ['443', '80'] }}
onNewFilter={jest.fn()}
/>
);
expect(component).toMatchInlineSnapshot(`
<EuiPopover
anchorPosition="downLeft"
button={
<EuiButtonEmpty
data-test-subj="uptimeCreateAlertAddFilter"
disabled={false}
iconType="plusInCircleFilled"
onClick={[Function]}
>
Add filter
</EuiButtonEmpty>
}
closePopover={[Function]}
display="inlineBlock"
hasArrow={true}
id="singlePanel"
isOpen={false}
ownFocus={false}
panelPaddingSize="none"
>
<EuiContextMenuPanel
hasFocus={true}
items={
Array [
<EuiContextMenuItem
data-test-subj="uptimeAlertAddFilter.monitor.type"
onClick={[Function]}
>
Type
</EuiContextMenuItem>,
]
}
/>
</EuiPopover>
`);
});
it('popover is disabled if no values are available', () => {
const component = shallowWithIntl(
<AddFilterButton
newFilters={[]}
alertFilters={{
'observer.geo.name': ['fairbanks'],
tags: ['foo'],
'url.port': ['80'],
'monitor.type': ['http'],
}}
onNewFilter={jest.fn()}
/>
);
expect(component).toMatchInlineSnapshot(`
<EuiPopover
anchorPosition="downLeft"
button={
<EuiButtonEmpty
data-test-subj="uptimeCreateAlertAddFilter"
disabled={true}
iconType="plusInCircleFilled"
onClick={[Function]}
>
Add filter
</EuiButtonEmpty>
}
closePopover={[Function]}
display="inlineBlock"
hasArrow={true}
id="singlePanel"
isOpen={false}
ownFocus={false}
panelPaddingSize="none"
>
<EuiContextMenuPanel
hasFocus={true}
items={Array []}
/>
</EuiPopover>
`);
});
it('filter select', () => {
const mockOnNewFilter = jest.fn();
const component = mountWithIntl(
<AddFilterButton newFilters={[]} alertFilters={{}} onNewFilter={mockOnNewFilter} />
);
component.find(EuiButtonEmpty).simulate('click', { target: { value: '0' } });
component
.find(EuiContextMenuItem)
.first()
.simulate('click', { target: { value: '0' } });
expect(mockOnNewFilter).toHaveBeenCalled();
expect(mockOnNewFilter.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"observer.geo.name",
],
]
`);
});
});

View file

@ -0,0 +1,145 @@
/*
* 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 { mountWithIntl } from 'test_utils/enzyme_helpers';
import { AlertFieldNumber, handleAlertFieldNumberChange } from '../alert_field_number';
describe('AlertFieldNumber', () => {
describe('handleAlertFieldNumberChange', () => {
let mockSetIsInvalid: jest.Mock;
let mockSetFieldValue: jest.Mock;
beforeEach(() => {
mockSetIsInvalid = jest.fn();
mockSetFieldValue = jest.fn();
});
afterEach(() => {
jest.clearAllMocks();
});
it('sets a valid number', () => {
handleAlertFieldNumberChange(
// @ts-ignore no need to implement this entire type here
{ target: { value: '23' } },
false,
mockSetIsInvalid,
mockSetFieldValue
);
expect(mockSetIsInvalid).not.toHaveBeenCalled();
expect(mockSetFieldValue).toHaveBeenCalledTimes(1);
expect(mockSetFieldValue.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
23,
],
]
`);
});
it('sets invalid for NaN value', () => {
handleAlertFieldNumberChange(
// @ts-ignore no need to implement this entire type here
{ target: { value: 'foo' } },
false,
mockSetIsInvalid,
mockSetFieldValue
);
expect(mockSetIsInvalid).toHaveBeenCalledTimes(1);
expect(mockSetIsInvalid.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
true,
],
]
`);
expect(mockSetFieldValue).not.toHaveBeenCalled();
});
it('sets invalid to false when a valid value is received and invalid is true', () => {
handleAlertFieldNumberChange(
// @ts-ignore no need to implement this entire type here
{ target: { value: '23' } },
true,
mockSetIsInvalid,
mockSetFieldValue
);
expect(mockSetIsInvalid).toHaveBeenCalledTimes(1);
expect(mockSetIsInvalid.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
false,
],
]
`);
expect(mockSetFieldValue).toHaveBeenCalledTimes(1);
expect(mockSetFieldValue.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
23,
],
]
`);
});
});
describe('AlertFieldNumber', () => {
it('responds with correct number value when a valid number is specified', () => {
const mockValueHandler = jest.fn();
const component = mountWithIntl(
<AlertFieldNumber
aria-label="test label"
data-test-subj="foo"
disabled={false}
fieldValue={23}
setFieldValue={mockValueHandler}
/>
);
component.find('input').simulate('change', { target: { value: '45' } });
expect(mockValueHandler).toHaveBeenCalled();
expect(mockValueHandler.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
45,
],
]
`);
});
it('does not set an invalid number value', () => {
const mockValueHandler = jest.fn();
const component = mountWithIntl(
<AlertFieldNumber
aria-label="test label"
data-test-subj="foo"
disabled={false}
fieldValue={23}
setFieldValue={mockValueHandler}
/>
);
component.find('input').simulate('change', { target: { value: 'not a number' } });
expect(mockValueHandler).not.toHaveBeenCalled();
expect(mockValueHandler.mock.calls).toEqual([]);
});
it('does not set a number value less than 1', () => {
const mockValueHandler = jest.fn();
const component = mountWithIntl(
<AlertFieldNumber
aria-label="test label"
data-test-subj="foo"
disabled={false}
fieldValue={23}
setFieldValue={mockValueHandler}
/>
);
component.find('input').simulate('change', { target: { value: '0' } });
expect(mockValueHandler).not.toHaveBeenCalled();
expect(mockValueHandler.mock.calls).toEqual([]);
});
});
});

View file

@ -5,141 +5,120 @@
*/ */
import React from 'react'; import React from 'react';
import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { shallowWithIntl } from 'test_utils/enzyme_helpers';
import { AlertFieldNumber, handleAlertFieldNumberChange } from '../alert_field_number'; import { AlertMonitorStatusComponent, AlertMonitorStatusProps } from '../alert_monitor_status';
describe('alert monitor status component', () => { describe('alert monitor status component', () => {
describe('handleAlertFieldNumberChange', () => { describe('AlertMonitorStatus', () => {
let mockSetIsInvalid: jest.Mock; const defaultProps: AlertMonitorStatusProps = {
let mockSetFieldValue: jest.Mock; alertParams: {
numTimes: 3,
search: 'monitor.id: foo',
timerangeUnit: 'h',
timerangeCount: 21,
},
autocomplete: {
addQuerySuggestionProvider: jest.fn(),
getQuerySuggestions: jest.fn(),
},
enabled: true,
hasFilters: false,
isOldAlert: true,
locations: [],
shouldUpdateUrl: false,
snapshotCount: 0,
snapshotLoading: false,
numTimes: 14,
setAlertParams: jest.fn(),
timerange: { from: 'now-12h', to: 'now' },
};
beforeEach(() => { it('passes default props to children', () => {
mockSetIsInvalid = jest.fn(); const component = shallowWithIntl(<AlertMonitorStatusComponent {...defaultProps} />);
mockSetFieldValue = jest.fn(); expect(component).toMatchInlineSnapshot(`
}); <Fragment>
<OldAlertCallOut
afterEach(() => { isOldAlert={true}
jest.clearAllMocks(); />
}); <EuiSpacer
size="m"
it('sets a valid number', () => { />
handleAlertFieldNumberChange( <KueryBar
// @ts-ignore no need to implement this entire type here aria-label="Input that allows filtering criteria for the monitor status alert"
{ target: { value: '23' } }, autocomplete={
false, Object {
mockSetIsInvalid, "addQuerySuggestionProvider": [MockFunction],
mockSetFieldValue "getQuerySuggestions": [MockFunction],
); }
expect(mockSetIsInvalid).not.toHaveBeenCalled(); }
expect(mockSetFieldValue).toHaveBeenCalledTimes(1); data-test-subj="xpack.uptime.alerts.monitorStatus.filterBar"
expect(mockSetFieldValue.mock.calls).toMatchInlineSnapshot(` defaultKuery="monitor.id: foo"
Array [ shouldUpdateUrl={false}
Array [ updateDefaultKuery={[Function]}
23, />
], <EuiSpacer
] size="s"
/>
<DownNoExpressionSelect
defaultNumTimes={3}
hasFilters={false}
setAlertParams={[MockFunction]}
/>
<EuiSpacer
size="xs"
/>
<TimeExpressionSelect
defaultTimerangeCount={21}
defaultTimerangeUnit="h"
setAlertParams={[MockFunction]}
/>
<EuiSpacer
size="xs"
/>
<FiltersExpressionSelectContainer
alertParams={
Object {
"numTimes": 3,
"search": "monitor.id: foo",
"timerangeCount": 21,
"timerangeUnit": "h",
}
}
newFilters={Array []}
onRemoveFilter={[Function]}
setAlertParams={[MockFunction]}
shouldUpdateUrl={false}
/>
<EuiSpacer
size="xs"
/>
<AddFilterButton
newFilters={Array []}
onNewFilter={[Function]}
/>
<EuiSpacer
size="m"
/>
<EuiCallOut
iconType="iInCircle"
size="s"
title={
<FormattedMessage
defaultMessage="This alert will apply to approximately {snapshotCount} monitors."
id="xpack.uptime.alerts.monitorStatus.monitorCallOut.title"
values={
Object {
"snapshotCount": 0,
}
}
/>
}
/>
<EuiSpacer
size="m"
/>
</Fragment>
`); `);
}); });
it('sets invalid for NaN value', () => {
handleAlertFieldNumberChange(
// @ts-ignore no need to implement this entire type here
{ target: { value: 'foo' } },
false,
mockSetIsInvalid,
mockSetFieldValue
);
expect(mockSetIsInvalid).toHaveBeenCalledTimes(1);
expect(mockSetIsInvalid.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
true,
],
]
`);
expect(mockSetFieldValue).not.toHaveBeenCalled();
});
it('sets invalid to false when a valid value is received and invalid is true', () => {
handleAlertFieldNumberChange(
// @ts-ignore no need to implement this entire type here
{ target: { value: '23' } },
true,
mockSetIsInvalid,
mockSetFieldValue
);
expect(mockSetIsInvalid).toHaveBeenCalledTimes(1);
expect(mockSetIsInvalid.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
false,
],
]
`);
expect(mockSetFieldValue).toHaveBeenCalledTimes(1);
expect(mockSetFieldValue.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
23,
],
]
`);
});
});
describe('AlertFieldNumber', () => {
it('responds with correct number value when a valid number is specified', () => {
const mockValueHandler = jest.fn();
const component = mountWithIntl(
<AlertFieldNumber
aria-label="test label"
data-test-subj="foo"
disabled={false}
fieldValue={23}
setFieldValue={mockValueHandler}
/>
);
component.find('input').simulate('change', { target: { value: '45' } });
expect(mockValueHandler).toHaveBeenCalled();
expect(mockValueHandler.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
45,
],
]
`);
});
it('does not set an invalid number value', () => {
const mockValueHandler = jest.fn();
const component = mountWithIntl(
<AlertFieldNumber
aria-label="test label"
data-test-subj="foo"
disabled={false}
fieldValue={23}
setFieldValue={mockValueHandler}
/>
);
component.find('input').simulate('change', { target: { value: 'not a number' } });
expect(mockValueHandler).not.toHaveBeenCalled();
expect(mockValueHandler.mock.calls).toEqual([]);
});
it('does not set a number value less than 1', () => {
const mockValueHandler = jest.fn();
const component = mountWithIntl(
<AlertFieldNumber
aria-label="test label"
data-test-subj="foo"
disabled={false}
fieldValue={23}
setFieldValue={mockValueHandler}
/>
);
component.find('input').simulate('change', { target: { value: '0' } });
expect(mockValueHandler).not.toHaveBeenCalled();
expect(mockValueHandler.mock.calls).toEqual([]);
});
}); });
}); });

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { shallowWithIntl } from 'test_utils/enzyme_helpers';
import { OldAlertCallOut } from '../old_alert_call_out';
describe('OldAlertCallOut', () => {
it('returns null for new alert type', () => {
expect(shallowWithIntl(<OldAlertCallOut isOldAlert={false} />)).toEqual({});
});
it('renders the call out for old alerts', () => {
expect(shallowWithIntl(<OldAlertCallOut isOldAlert={true} />)).toMatchInlineSnapshot(`
<Fragment>
<EuiSpacer
size="m"
/>
<EuiCallOut
iconType="alert"
size="s"
title={
<FormattedMessage
defaultMessage="You are editing an older alert, some fields may not auto-populate."
id="xpack.uptime.alerts.monitorStatus.oldAlertCallout.title"
values={Object {}}
/>
}
/>
</Fragment>
`);
});
});

View file

@ -6,20 +6,18 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { EuiButtonEmpty, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui'; import { EuiButtonEmpty, EuiContextMenuItem, EuiContextMenuPanel, EuiPopover } from '@elastic/eui';
import { useFilterUpdate } from '../../../hooks/use_filter_update';
import * as labels from './translations'; import * as labels from './translations';
interface Props { interface Props {
newFilters: string[]; newFilters: string[];
onNewFilter: (val: string) => void; onNewFilter: (val: string) => void;
alertFilters: { [key: string]: string[] };
} }
export const AddFilterButton: React.FC<Props> = ({ newFilters, onNewFilter }) => { export const AddFilterButton: React.FC<Props> = ({ newFilters, onNewFilter, alertFilters }) => {
const [isPopoverOpen, setPopover] = useState(false); const [isPopoverOpen, setPopover] = useState(false);
const { selectedFilters } = useFilterUpdate(); const getSelectedItems = (fieldName: string) => alertFilters?.[fieldName] ?? [];
const getSelectedItems = (fieldName: string) => selectedFilters.get(fieldName) || [];
const onButtonClick = () => { const onButtonClick = () => {
setPopover(!isPopoverOpen); setPopover(!isPopoverOpen);

View file

@ -5,25 +5,31 @@
*/ */
import React, { useState } from 'react'; import React, { useState } from 'react';
import { EuiSpacer } from '@elastic/eui'; import { EuiCallOut, EuiSpacer } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { DataPublicPluginSetup } from 'src/plugins/data/public'; import { DataPublicPluginSetup } from 'src/plugins/data/public';
import * as labels from './translations'; import * as labels from './translations';
import { import {
DownNoExpressionSelect, DownNoExpressionSelect,
TimeExpressionSelect, TimeExpressionSelect,
FiltersExpressionsSelect, FiltersExpressionSelectContainer,
} from './monitor_expressions'; } from './monitor_expressions';
import { AddFilterButton } from './add_filter_btn'; import { AddFilterButton } from './add_filter_btn';
import { OldAlertCallOut } from './old_alert_call_out';
import { KueryBar } from '..'; import { KueryBar } from '..';
interface AlertMonitorStatusProps { export interface AlertMonitorStatusProps {
alertParams: { [key: string]: any };
autocomplete: DataPublicPluginSetup['autocomplete']; autocomplete: DataPublicPluginSetup['autocomplete'];
enabled: boolean; enabled: boolean;
filters: string; hasFilters: boolean;
isOldAlert: boolean;
locations: string[]; locations: string[];
snapshotCount: number;
snapshotLoading: boolean;
numTimes: number; numTimes: number;
setAlertParams: (key: string, value: any) => void; setAlertParams: (key: string, value: any) => void;
shouldUpdateUrl: boolean;
timerange: { timerange: {
from: string; from: string;
to: string; to: string;
@ -31,42 +37,70 @@ interface AlertMonitorStatusProps {
} }
export const AlertMonitorStatusComponent: React.FC<AlertMonitorStatusProps> = (props) => { export const AlertMonitorStatusComponent: React.FC<AlertMonitorStatusProps> = (props) => {
const { filters, setAlertParams } = props; const {
alertParams,
hasFilters,
isOldAlert,
setAlertParams,
shouldUpdateUrl,
snapshotCount,
snapshotLoading,
} = props;
const [newFilters, setNewFilters] = useState<string[]>([]); const alertFilters = alertParams?.filters ?? {};
const [newFilters, setNewFilters] = useState<string[]>(
Object.keys(alertFilters).filter((f) => alertFilters[f].length)
);
return ( return (
<> <>
<OldAlertCallOut isOldAlert={isOldAlert} />
<EuiSpacer size="m" /> <EuiSpacer size="m" />
<KueryBar <KueryBar
aria-label={labels.ALERT_KUERY_BAR_ARIA} aria-label={labels.ALERT_KUERY_BAR_ARIA}
autocomplete={props.autocomplete} autocomplete={props.autocomplete}
defaultKuery={alertParams.search}
shouldUpdateUrl={shouldUpdateUrl}
updateDefaultKuery={(value: string) => setAlertParams('search', value)}
data-test-subj="xpack.uptime.alerts.monitorStatus.filterBar" data-test-subj="xpack.uptime.alerts.monitorStatus.filterBar"
/> />
<EuiSpacer size="s" /> <EuiSpacer size="s" />
<DownNoExpressionSelect filters={filters} setAlertParams={setAlertParams} /> <DownNoExpressionSelect
defaultNumTimes={alertParams.numTimes}
<EuiSpacer size="xs" /> hasFilters={hasFilters}
<TimeExpressionSelect setAlertParams={setAlertParams} />
<EuiSpacer size="xs" />
<FiltersExpressionsSelect
setAlertParams={setAlertParams} setAlertParams={setAlertParams}
/>
<EuiSpacer size="xs" />
<TimeExpressionSelect
defaultTimerangeUnit={alertParams.timerangeUnit}
defaultTimerangeCount={alertParams.timerangeCount}
setAlertParams={setAlertParams}
/>
<EuiSpacer size="xs" />
<FiltersExpressionSelectContainer
alertParams={alertParams}
newFilters={newFilters} newFilters={newFilters}
onRemoveFilter={(removeFiler) => { onRemoveFilter={(removeFilter: string) => {
if (newFilters.includes(removeFiler)) { if (newFilters.includes(removeFilter)) {
setNewFilters(newFilters.filter((item) => item !== removeFiler)); setNewFilters(newFilters.filter((item) => item !== removeFilter));
} }
}} }}
setAlertParams={setAlertParams}
shouldUpdateUrl={shouldUpdateUrl}
/> />
<EuiSpacer size="xs" /> <EuiSpacer size="xs" />
<AddFilterButton <AddFilterButton
alertFilters={alertParams.filters}
newFilters={newFilters} newFilters={newFilters}
onNewFilter={(newFilter) => { onNewFilter={(newFilter) => {
setNewFilters([...newFilters, newFilter]); setNewFilters([...newFilters, newFilter]);
@ -74,6 +108,20 @@ export const AlertMonitorStatusComponent: React.FC<AlertMonitorStatusProps> = (p
/> />
<EuiSpacer size="m" /> <EuiSpacer size="m" />
<EuiCallOut
size="s"
title={
<FormattedMessage
id="xpack.uptime.alerts.monitorStatus.monitorCallOut.title"
defaultMessage="This alert will apply to approximately {snapshotCount} monitors."
values={{ snapshotCount: snapshotLoading ? '...' : snapshotCount }}
/>
}
iconType="iInCircle"
/>
<EuiSpacer size="m" />
</> </>
); );
}; };

View file

@ -4,13 +4,31 @@
* you may not use this file except in compliance with the Elastic License. * you may not use this file except in compliance with the Elastic License.
*/ */
import React, { useEffect } from 'react'; import React, { useMemo, useEffect } from 'react';
import { useSelector } from 'react-redux'; import { useLocation } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux';
import { DataPublicPluginSetup } from 'src/plugins/data/public'; import { DataPublicPluginSetup } from 'src/plugins/data/public';
import { selectMonitorStatusAlert, searchTextSelector } from '../../../../state/selectors'; import { isRight } from 'fp-ts/lib/Either';
import {
selectMonitorStatusAlert,
overviewFiltersSelector,
snapshotDataSelector,
esKuerySelector,
selectedFiltersSelector,
} from '../../../../state/selectors';
import { AlertMonitorStatusComponent } from '../index'; import { AlertMonitorStatusComponent } from '../index';
import {
fetchOverviewFilters,
setSearchTextAction,
setEsKueryString,
getSnapshotCountAction,
} from '../../../../state/actions';
import { AtomicStatusCheckParamsType } from '../../../../../common/runtime_types';
import { useIndexPattern } from '../../kuery_bar/use_index_pattern';
import { useUpdateKueryString } from '../../../../hooks';
interface Props { interface Props {
alertParams: { [key: string]: any };
autocomplete: DataPublicPluginSetup['autocomplete']; autocomplete: DataPublicPluginSetup['autocomplete'];
enabled: boolean; enabled: boolean;
numTimes: number; numTimes: number;
@ -27,22 +45,87 @@ export const AlertMonitorStatus: React.FC<Props> = ({
numTimes, numTimes,
setAlertParams, setAlertParams,
timerange, timerange,
alertParams,
}) => { }) => {
const { filters, locations } = useSelector(selectMonitorStatusAlert); const dispatch = useDispatch();
const searchText = useSelector(searchTextSelector);
useEffect(() => { useEffect(() => {
setAlertParams('search', searchText); dispatch(
}, [setAlertParams, searchText]); fetchOverviewFilters({
dateRangeStart: 'now-24h',
dateRangeEnd: 'now',
locations: alertParams.filters?.['observer.geo.name'] ?? [],
ports: alertParams.filters?.['url.port'] ?? [],
tags: alertParams.filters?.tags ?? [],
schemes: alertParams.filters?.['monitor.type'] ?? [],
})
);
}, [alertParams, dispatch]);
const overviewFilters = useSelector(overviewFiltersSelector);
const { locations } = useSelector(selectMonitorStatusAlert);
useEffect(() => {
if (alertParams.search) {
dispatch(setSearchTextAction(alertParams.search));
}
}, [alertParams, dispatch]);
const { index_pattern: indexPattern } = useIndexPattern();
const { count, loading } = useSelector(snapshotDataSelector);
const esKuery = useSelector(esKuerySelector);
const [esFilters] = useUpdateKueryString(
indexPattern,
alertParams.search,
alertParams.filters === undefined || typeof alertParams.filters === 'string'
? ''
: JSON.stringify(Array.from(Object.entries(alertParams.filters)))
);
useEffect(() => {
dispatch(setEsKueryString(esFilters ?? ''));
}, [dispatch, esFilters]);
const isOldAlert = React.useMemo(
() => !isRight(AtomicStatusCheckParamsType.decode(alertParams)),
[alertParams]
);
useEffect(() => {
dispatch(
getSnapshotCountAction({ dateRangeStart: 'now-24h', dateRangeEnd: 'now', filters: esKuery })
);
}, [dispatch, esKuery]);
const selectedFilters = useSelector(selectedFiltersSelector);
useEffect(() => {
if (!alertParams.filters && selectedFilters !== null) {
setAlertParams('filters', {
// @ts-ignore
'url.port': selectedFilters?.ports ?? [],
// @ts-ignore
'observer.geo.name': selectedFilters?.locations ?? [],
// @ts-ignore
'monitor.type': selectedFilters?.schemes ?? [],
// @ts-ignore
tags: selectedFilters?.tags ?? [],
});
}
}, [alertParams, setAlertParams, selectedFilters]);
const { pathname } = useLocation();
const shouldUpdateUrl = useMemo(() => pathname.indexOf('app/uptime') !== -1, [pathname]);
return ( return (
<AlertMonitorStatusComponent <AlertMonitorStatusComponent
alertParams={alertParams}
autocomplete={autocomplete} autocomplete={autocomplete}
enabled={enabled} enabled={enabled}
filters={filters} hasFilters={!!overviewFilters?.filters}
isOldAlert={isOldAlert}
locations={locations} locations={locations}
numTimes={numTimes} numTimes={numTimes}
setAlertParams={setAlertParams} setAlertParams={setAlertParams}
shouldUpdateUrl={shouldUpdateUrl}
snapshotCount={count.total}
snapshotLoading={loading}
timerange={timerange} timerange={timerange}
/> />
); );

View file

@ -9,20 +9,16 @@ import { renderWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers';
import { DownNoExpressionSelect } from '../down_number_select'; import { DownNoExpressionSelect } from '../down_number_select';
describe('DownNoExpressionSelect component', () => { describe('DownNoExpressionSelect component', () => {
const filters =
'"{"bool":{"filter":[{"bool":{"should":[{"match":{"observer.geo.name":"US-West"}}],"minimum_should_match":1}},' +
'{"bool":{"should":[{"match":{"url.port":443}}],"minimum_should_match":1}}]}}"';
it('should shallow renders against props', function () { it('should shallow renders against props', function () {
const component = shallowWithIntl( const component = shallowWithIntl(
<DownNoExpressionSelect filters={filters} setAlertParams={jest.fn()} /> <DownNoExpressionSelect hasFilters={true} setAlertParams={jest.fn()} />
); );
expect(component).toMatchSnapshot(); expect(component).toMatchSnapshot();
}); });
it('should renders against props', function () { it('should renders against props', function () {
const component = renderWithIntl( const component = renderWithIntl(
<DownNoExpressionSelect filters={filters} setAlertParams={jest.fn()} /> <DownNoExpressionSelect hasFilters={true} setAlertParams={jest.fn()} />
); );
expect(component).toMatchSnapshot(); expect(component).toMatchSnapshot();
}); });

View file

@ -0,0 +1,176 @@
/*
* 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 { shallowWithIntl } from 'test_utils/enzyme_helpers';
import { FiltersExpressionsSelect } from '../filters_expression_select';
describe('filters expression select component', () => {
it('is empty when no filters available', () => {
const component = shallowWithIntl(
<FiltersExpressionsSelect
alertParams={{}}
newFilters={[]}
onRemoveFilter={jest.fn()}
filters={{
locations: [],
ports: [],
schemes: [],
tags: [],
}}
setAlertParams={jest.fn()}
setUpdatedFieldValues={jest.fn()}
shouldUpdateUrl={false}
/>
);
expect(component).toMatchInlineSnapshot(`
<Fragment>
<EuiSpacer
size="xs"
/>
</Fragment>
`);
});
it('contains provided new filter values', () => {
const component = shallowWithIntl(
<FiltersExpressionsSelect
alertParams={{}}
newFilters={['observer.geo.name']}
onRemoveFilter={jest.fn()}
filters={{
tags: [],
ports: [],
schemes: [],
locations: [],
}}
setAlertParams={jest.fn()}
setUpdatedFieldValues={jest.fn()}
shouldUpdateUrl={false}
/>
);
expect(component).toMatchInlineSnapshot(`
<Fragment>
<EuiFlexGroup
key="filter_location"
>
<EuiFlexItem>
<FilterPopover
btnContent={
<EuiExpression
aria-label="ariaLabel"
color="secondary"
data-test-subj="uptimeCreateStatusAlert.filter_location"
description="From"
onClick={[Function]}
value="any location"
/>
}
disabled={true}
fieldName="observer.geo.name"
forceOpen={false}
id="filter_location"
items={Array []}
loading={false}
onFilterFieldChange={[Function]}
selectedItems={Array []}
setForceOpen={[Function]}
title="Scheme"
/>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiButtonIcon
aria-label="Remove filter"
color="danger"
iconType="trash"
onClick={[Function]}
/>
</EuiFlexItem>
<EuiSpacer
size="xs"
/>
</EuiFlexGroup>
<EuiSpacer
size="xs"
/>
</Fragment>
`);
});
it('contains provided selected filter values', () => {
const component = shallowWithIntl(
<FiltersExpressionsSelect
alertParams={{}}
newFilters={['tags']}
onRemoveFilter={jest.fn()}
filters={{
tags: ['foo', 'bar'],
ports: [],
schemes: [],
locations: [],
}}
setAlertParams={jest.fn()}
setUpdatedFieldValues={jest.fn()}
shouldUpdateUrl={false}
/>
);
expect(component).toMatchInlineSnapshot(`
<Fragment>
<EuiFlexGroup
key="filter_tags"
>
<EuiFlexItem>
<FilterPopover
btnContent={
<EuiExpression
aria-label="ariaLabel"
color="secondary"
data-test-subj="uptimeCreateStatusAlert.filter_tags"
description="Using"
onClick={[Function]}
value="any tag"
/>
}
disabled={false}
fieldName="tags"
forceOpen={false}
id="filter_tags"
items={
Array [
"foo",
"bar",
]
}
loading={false}
onFilterFieldChange={[Function]}
selectedItems={Array []}
setForceOpen={[Function]}
title="Tags"
/>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiButtonIcon
aria-label="Remove filter"
color="danger"
iconType="trash"
onClick={[Function]}
/>
</EuiFlexItem>
<EuiSpacer
size="xs"
/>
</EuiFlexGroup>
<EuiSpacer
size="xs"
/>
</Fragment>
`);
});
});

View file

@ -10,12 +10,17 @@ import * as labels from '../translations';
import { AlertFieldNumber } from '../alert_field_number'; import { AlertFieldNumber } from '../alert_field_number';
interface Props { interface Props {
defaultNumTimes?: number;
hasFilters: boolean;
setAlertParams: (key: string, value: any) => void; setAlertParams: (key: string, value: any) => void;
filters: string;
} }
export const DownNoExpressionSelect: React.FC<Props> = ({ filters, setAlertParams }) => { export const DownNoExpressionSelect: React.FC<Props> = ({
const [numTimes, setNumTimes] = useState<number>(5); defaultNumTimes,
hasFilters,
setAlertParams,
}) => {
const [numTimes, setNumTimes] = useState<number>(defaultNumTimes ?? 5);
useEffect(() => { useEffect(() => {
setAlertParams('numTimes', numTimes); setAlertParams('numTimes', numTimes);
@ -34,7 +39,7 @@ export const DownNoExpressionSelect: React.FC<Props> = ({ filters, setAlertParam
/> />
} }
data-test-subj="xpack.uptime.alerts.monitorStatus.numTimesExpression" data-test-subj="xpack.uptime.alerts.monitorStatus.numTimesExpression"
description={filters ? labels.MATCHING_MONITORS_DOWN : labels.ANY_MONITOR_DOWN} description={hasFilters ? labels.MATCHING_MONITORS_DOWN : labels.ANY_MONITOR_DOWN}
id="ping-count" id="ping-count"
value={`${numTimes} times`} value={`${numTimes} times`}
/> />

View file

@ -4,57 +4,55 @@
* you may not use this file except in compliance with the Elastic License. * you may not use this file except in compliance with the Elastic License.
*/ */
import React, { useEffect, useState } from 'react'; import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import { EuiButtonIcon, EuiExpression, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { EuiButtonIcon, EuiExpression, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { FilterPopover } from '../../filter_group/filter_popover'; import { FilterPopover } from '../../filter_group/filter_popover';
import { overviewFiltersSelector } from '../../../../state/selectors';
import { useFilterUpdate } from '../../../../hooks/use_filter_update';
import { filterLabels } from '../../filter_group/translations'; import { filterLabels } from '../../filter_group/translations';
import { alertFilterLabels } from './translations'; import { alertFilterLabels } from './translations';
import { StatusCheckFilters } from '../../../../../common/runtime_types'; import { FilterExpressionsSelectProps } from './filters_expression_select_container';
import { OverviewFiltersState } from '../../../../state/reducers/overview_filters';
interface Props { type FilterFieldUpdate = (updateTarget: { fieldName: string; values: string[] }) => void;
newFilters: string[];
onRemoveFilter: (val: string) => void; interface OwnProps {
setAlertParams: (key: string, value: any) => void; setUpdatedFieldValues: FilterFieldUpdate;
} }
type Props = FilterExpressionsSelectProps & Pick<OverviewFiltersState, 'filters'> & OwnProps;
export const FiltersExpressionsSelect: React.FC<Props> = ({ export const FiltersExpressionsSelect: React.FC<Props> = ({
setAlertParams, alertParams,
filters: overviewFilters,
newFilters, newFilters,
onRemoveFilter, onRemoveFilter,
setAlertParams,
setUpdatedFieldValues,
}) => { }) => {
const { const { tags, ports, schemes, locations } = overviewFilters;
filters: { tags, ports, schemes, locations }, const selectedPorts = alertParams?.filters?.['url.port'] ?? [];
} = useSelector(overviewFiltersSelector); const selectedLocations = alertParams?.filters?.['observer.geo.name'] ?? [];
const selectedSchemes = alertParams?.filters?.['monitor.type'] ?? [];
const [updatedFieldValues, setUpdatedFieldValues] = useState<{ const selectedTags = alertParams?.filters?.tags ?? [];
fieldName: string;
values: string[];
}>({ fieldName: '', values: [] });
const { selectedLocations, selectedPorts, selectedSchemes, selectedTags } = useFilterUpdate(
updatedFieldValues.fieldName,
updatedFieldValues.values
);
const [filters, setFilters] = useState<StatusCheckFilters>({
'observer.geo.name': selectedLocations,
'url.port': selectedPorts,
tags: selectedTags,
'monitor.type': selectedSchemes,
});
useEffect(() => {
setAlertParams('filters', filters);
}, [filters, setAlertParams]);
const onFilterFieldChange = (fieldName: string, values: string[]) => { const onFilterFieldChange = (fieldName: string, values: string[]) => {
setFilters({ // the `filters` field is no longer a string
...filters, if (alertParams.filters && typeof alertParams.filters !== 'string') {
[fieldName]: values, setAlertParams('filters', { ...alertParams.filters, [fieldName]: values });
}); } else {
setAlertParams(
'filters',
Object.assign(
{},
{
tags: [],
'url.port': [],
'observer.geo.name': [],
'monitor.type': [],
},
{ [fieldName]: values }
)
);
}
setUpdatedFieldValues({ fieldName, values }); setUpdatedFieldValues({ fieldName, values });
}; };

View file

@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import { FiltersExpressionsSelect } from './filters_expression_select';
import { overviewFiltersSelector } from '../../../../state/selectors';
import { useFilterUpdate } from '../../../../hooks/use_filter_update';
export interface FilterExpressionsSelectProps {
alertParams: { [key: string]: any };
newFilters: string[];
onRemoveFilter: (val: string) => void;
setAlertParams: (key: string, value: any) => void;
shouldUpdateUrl: boolean;
}
export const FiltersExpressionSelectContainer: React.FC<FilterExpressionsSelectProps> = (props) => {
const [updatedFieldValues, setUpdatedFieldValues] = useState<{
fieldName: string;
values: string[];
}>({ fieldName: '', values: [] });
useFilterUpdate(updatedFieldValues.fieldName, updatedFieldValues.values, props.shouldUpdateUrl);
const overviewFilters = useSelector(overviewFiltersSelector);
return (
<FiltersExpressionsSelect
{...overviewFilters}
{...props}
setUpdatedFieldValues={setUpdatedFieldValues}
/>
);
};

View file

@ -6,4 +6,5 @@
export { DownNoExpressionSelect } from './down_number_select'; export { DownNoExpressionSelect } from './down_number_select';
export { FiltersExpressionsSelect } from './filters_expression_select'; export { FiltersExpressionsSelect } from './filters_expression_select';
export { FiltersExpressionSelectContainer } from './filters_expression_select_container';
export { TimeExpressionSelect } from './time_expression_select'; export { TimeExpressionSelect } from './time_expression_select';

View file

@ -13,9 +13,13 @@ import { AlertFieldNumber } from '../alert_field_number';
import { timeExpLabels } from './translations'; import { timeExpLabels } from './translations';
interface Props { interface Props {
defaultTimerangeCount?: number;
defaultTimerangeUnit?: string;
setAlertParams: (key: string, value: any) => void; setAlertParams: (key: string, value: any) => void;
} }
const DEFAULT_TIMERANGE_UNIT = 'm';
const TimeRangeOptions = [ const TimeRangeOptions = [
{ {
'aria-label': labels.SECONDS_TIME_RANGE, 'aria-label': labels.SECONDS_TIME_RANGE,
@ -26,7 +30,6 @@ const TimeRangeOptions = [
{ {
'aria-label': labels.MINUTES_TIME_RANGE, 'aria-label': labels.MINUTES_TIME_RANGE,
'data-test-subj': 'xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.minutesOption', 'data-test-subj': 'xpack.uptime.alerts.monitorStatus.timerangeUnitSelectable.minutesOption',
checked: 'on',
key: 'm', key: 'm',
label: labels.MINUTES, label: labels.MINUTES,
}, },
@ -44,10 +47,18 @@ const TimeRangeOptions = [
}, },
]; ];
export const TimeExpressionSelect: React.FC<Props> = ({ setAlertParams }) => { export const TimeExpressionSelect: React.FC<Props> = ({
const [numUnits, setNumUnits] = useState<number>(15); defaultTimerangeCount,
defaultTimerangeUnit,
setAlertParams,
}) => {
const [numUnits, setNumUnits] = useState<number>(defaultTimerangeCount ?? 15);
const [timerangeUnitOptions, setTimerangeUnitOptions] = useState<any[]>(TimeRangeOptions); const [timerangeUnitOptions, setTimerangeUnitOptions] = useState<any[]>(
TimeRangeOptions.map((opt) =>
opt.key === (defaultTimerangeUnit ?? DEFAULT_TIMERANGE_UNIT) ? { ...opt, checked: 'on' } : opt
)
);
useEffect(() => { useEffect(() => {
const timerangeUnit = timerangeUnitOptions.find(({ checked }) => checked === 'on')?.key ?? 'm'; const timerangeUnit = timerangeUnitOptions.find(({ checked }) => checked === 'on')?.key ?? 'm';

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiSpacer, EuiCallOut } from '@elastic/eui';
import React from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
interface Props {
isOldAlert: boolean;
}
export const OldAlertCallOut: React.FC<Props> = ({ isOldAlert }) => {
if (!isOldAlert) return null;
return (
<>
<EuiSpacer size="m" />
<EuiCallOut
size="s"
title={
<FormattedMessage
id="xpack.uptime.alerts.monitorStatus.oldAlertCallout.title"
defaultMessage="You are editing an older alert, some fields may not auto-populate."
/>
}
iconType="alert"
/>
</>
);
};

View file

@ -4,11 +4,11 @@
* you may not use this file except in compliance with the Elastic License. * you may not use this file except in compliance with the Elastic License.
*/ */
import { EuiLink } from '@elastic/eui';
import { EuiExpression, EuiPopover } from '@elastic/eui'; import { EuiExpression, EuiPopover } from '@elastic/eui';
import { Link } from 'react-router-dom';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { FormattedMessage } from '@kbn/i18n/react'; import { FormattedMessage } from '@kbn/i18n/react';
import { SETTINGS_ROUTE } from '../../../../common/constants'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
interface SettingsMessageExpressionPopoverProps { interface SettingsMessageExpressionPopoverProps {
'aria-label': string; 'aria-label': string;
@ -25,9 +25,12 @@ export const SettingsMessageExpressionPopover: React.FC<SettingsMessageExpressio
value, value,
id, id,
}) => { }) => {
const kibana = useKibana();
const path = kibana.services?.application?.getUrlForApp('uptime', { path: 'settings' });
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
return ( return (
<EuiPopover <EuiPopover
data-test-subj={`xpack.uptime.alerts.tls.expressionPopover.${id}`}
id={id} id={id}
anchorPosition="downLeft" anchorPosition="downLeft"
button={ button={
@ -50,7 +53,7 @@ export const SettingsMessageExpressionPopover: React.FC<SettingsMessageExpressio
settingsPageLink: ( settingsPageLink: (
// this link is wrapped around a span so we can also change the UI state // this link is wrapped around a span so we can also change the UI state
// and hide the alert flyout before triggering the navigation to the settings page // and hide the alert flyout before triggering the navigation to the settings page
<Link to={SETTINGS_ROUTE} data-test-subj="xpack.uptime.alerts.tlsFlyout.linkToSettings"> <EuiLink href={path} data-test-subj="xpack.uptime.alerts.tlsFlyout.linkToSettings">
<span <span
onClick={() => { onClick={() => {
setAlertFlyoutVisible(false); setAlertFlyoutVisible(false);
@ -63,7 +66,7 @@ export const SettingsMessageExpressionPopover: React.FC<SettingsMessageExpressio
> >
settings page settings page
</span> </span>
</Link> </EuiLink>
), ),
}} }}
/> />

View file

@ -36,13 +36,19 @@ function convertKueryToEsQuery(kuery: string, indexPattern: IIndexPattern) {
interface Props { interface Props {
'aria-label': string; 'aria-label': string;
autocomplete: DataPublicPluginSetup['autocomplete']; autocomplete: DataPublicPluginSetup['autocomplete'];
defaultKuery?: string;
'data-test-subj': string; 'data-test-subj': string;
shouldUpdateUrl?: boolean;
updateDefaultKuery?: (value: string) => void;
} }
export function KueryBar({ export function KueryBar({
'aria-label': ariaLabel, 'aria-label': ariaLabel,
autocomplete: autocompleteService, autocomplete: autocompleteService,
defaultKuery,
'data-test-subj': dataTestSubj, 'data-test-subj': dataTestSubj,
shouldUpdateUrl,
updateDefaultKuery,
}: Props) { }: Props) {
const { loading, index_pattern: indexPattern } = useIndexPattern(); const { loading, index_pattern: indexPattern } = useIndexPattern();
const { updateSearchText } = useSearchText(); const { updateSearchText } = useSearchText();
@ -68,8 +74,6 @@ export function KueryBar({
return; return;
} }
updateSearchText(inputValue);
setIsLoadingSuggestions(true); setIsLoadingSuggestions(true);
setState({ ...state, suggestions: [] }); setState({ ...state, suggestions: [] });
@ -112,7 +116,13 @@ export function KueryBar({
return; return;
} }
updateUrlParams({ search: inputValue.trim() }); if (shouldUpdateUrl !== false) {
updateUrlParams({ search: inputValue.trim() });
}
updateSearchText(inputValue);
if (updateDefaultKuery) {
updateDefaultKuery(inputValue);
}
} catch (e) { } catch (e) {
console.log('Invalid kuery syntax'); // eslint-disable-line no-console console.log('Invalid kuery syntax'); // eslint-disable-line no-console
} }
@ -125,7 +135,7 @@ export function KueryBar({
data-test-subj={dataTestSubj} data-test-subj={dataTestSubj}
disabled={indexPatternMissing} disabled={indexPatternMissing}
isLoading={isLoadingSuggestions || loading} isLoading={isLoadingSuggestions || loading}
initialValue={kuery} initialValue={defaultKuery || kuery}
onChange={onChange} onChange={onChange}
onSubmit={onSubmit} onSubmit={onSubmit}
suggestions={state.suggestions} suggestions={state.suggestions}

View file

@ -6,7 +6,7 @@
import React from 'react'; import React from 'react';
import { FilterStatusButton, FilterStatusButtonProps } from '../filter_status_button'; import { FilterStatusButton, FilterStatusButtonProps } from '../filter_status_button';
import { renderWithRouter, shallowWithRouter } from '../../../../lib'; import { renderWithRouter, shallowWithRouter, MountWithReduxProvider } from '../../../../lib';
describe('FilterStatusButton', () => { describe('FilterStatusButton', () => {
let props: FilterStatusButtonProps; let props: FilterStatusButtonProps;
@ -26,7 +26,11 @@ describe('FilterStatusButton', () => {
}); });
it('renders without errors for valid props', () => { it('renders without errors for valid props', () => {
const wrapper = renderWithRouter(<FilterStatusButton {...props} />); const wrapper = renderWithRouter(
<MountWithReduxProvider>
<FilterStatusButton {...props} />
</MountWithReduxProvider>
);
expect(wrapper).toMatchSnapshot(); expect(wrapper).toMatchSnapshot();
}); });
}); });

View file

@ -5,7 +5,12 @@
*/ */
import React from 'react'; import React from 'react';
import { mountWithRouter, renderWithRouter, shallowWithRouter } from '../../../../lib'; import {
mountWithRouter,
renderWithRouter,
shallowWithRouter,
MountWithReduxProvider,
} from '../../../../lib';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
import { StatusFilter } from '../status_filter'; import { StatusFilter } from '../status_filter';
import { FilterStatusButton } from '../filter_status_button'; import { FilterStatusButton } from '../filter_status_button';
@ -18,7 +23,12 @@ describe('StatusFilterComponent', () => {
initialEntries: [`/?g=%22%22&statusFilter=${status}`], initialEntries: [`/?g=%22%22&statusFilter=${status}`],
}); });
const wrapper = mountWithRouter(<StatusFilter />, history); const wrapper = mountWithRouter(
<MountWithReduxProvider>
<StatusFilter />
</MountWithReduxProvider>,
history
);
const filterBtns = wrapper.find(FilterStatusButton); const filterBtns = wrapper.find(FilterStatusButton);
const allBtn = filterBtns.at(0); const allBtn = filterBtns.at(0);
@ -34,7 +44,11 @@ describe('StatusFilterComponent', () => {
}); });
it('renders without errors for valid props', () => { it('renders without errors for valid props', () => {
const wrapper = renderWithRouter(<StatusFilter />); const wrapper = renderWithRouter(
<MountWithReduxProvider>
<StatusFilter />
</MountWithReduxProvider>
);
expect(wrapper).toMatchSnapshot(); expect(wrapper).toMatchSnapshot();
}); });

View file

@ -137,30 +137,95 @@ exports[`useUrlParams deletes keys that do not have truthy values 1`] = `
} }
} }
> >
<UseUrlParamsTestComponent <MountWithReduxProvider>
hook={[Function]} <Provider
updateParams={ store={
Object { Object {
"pagination": "", "dispatch": [MockFunction],
"getState": [MockFunction] {
"calls": Array [
Array [],
Array [],
Array [],
Array [],
],
"results": Array [
Object {
"type": "return",
"value": Object {
"selectedFilters": null,
},
},
Object {
"type": "return",
"value": Object {
"selectedFilters": null,
},
},
Object {
"type": "return",
"value": Object {
"selectedFilters": null,
},
},
Object {
"type": "return",
"value": Object {
"selectedFilters": null,
},
},
],
},
"replaceReducer": [MockFunction],
"subscribe": [MockFunction] {
"calls": Array [
Array [
[Function],
],
Array [
[Function],
],
],
"results": Array [
Object {
"type": "return",
"value": undefined,
},
Object {
"type": "return",
"value": undefined,
},
],
},
}
} }
}
>
<div>
{"absoluteDateRangeStart":20,"absoluteDateRangeEnd":20,"autorefreshInterval":60000,"autorefreshIsPaused":false,"dateRangeStart":"now-12","dateRangeEnd":"now","filters":"","search":"","selectedPingStatus":"","statusFilter":"","pagination":"foo"}
</div>
<button
id="setUrlParams"
onClick={[Function]}
> >
Set url params <UseUrlParamsTestComponent
</button> hook={[Function]}
<button updateParams={
id="getUrlParams" Object {
onClick={[Function]} "pagination": "",
> }
Get url params }
</button> >
</UseUrlParamsTestComponent> <div>
{"absoluteDateRangeStart":20,"absoluteDateRangeEnd":20,"autorefreshInterval":60000,"autorefreshIsPaused":false,"dateRangeStart":"now-12","dateRangeEnd":"now","filters":"","search":"","selectedPingStatus":"","statusFilter":"","pagination":"foo"}
</div>
<button
id="setUrlParams"
onClick={[Function]}
>
Set url params
</button>
<button
id="getUrlParams"
onClick={[Function]}
>
Get url params
</button>
</UseUrlParamsTestComponent>
</Provider>
</MountWithReduxProvider>
</Router> </Router>
`; `;
@ -301,24 +366,89 @@ exports[`useUrlParams gets the expected values using the context 1`] = `
} }
} }
> >
<UseUrlParamsTestComponent <MountWithReduxProvider>
hook={[Function]} <Provider
> store={
<div> Object {
{"absoluteDateRangeStart":20,"absoluteDateRangeEnd":20,"autorefreshInterval":60000,"autorefreshIsPaused":false,"dateRangeStart":"now-15m","dateRangeEnd":"now","filters":"","search":"","selectedPingStatus":"","statusFilter":""} "dispatch": [MockFunction],
</div> "getState": [MockFunction] {
<button "calls": Array [
id="setUrlParams" Array [],
onClick={[Function]} Array [],
Array [],
Array [],
],
"results": Array [
Object {
"type": "return",
"value": Object {
"selectedFilters": null,
},
},
Object {
"type": "return",
"value": Object {
"selectedFilters": null,
},
},
Object {
"type": "return",
"value": Object {
"selectedFilters": null,
},
},
Object {
"type": "return",
"value": Object {
"selectedFilters": null,
},
},
],
},
"replaceReducer": [MockFunction],
"subscribe": [MockFunction] {
"calls": Array [
Array [
[Function],
],
Array [
[Function],
],
],
"results": Array [
Object {
"type": "return",
"value": undefined,
},
Object {
"type": "return",
"value": undefined,
},
],
},
}
}
> >
Set url params <UseUrlParamsTestComponent
</button> hook={[Function]}
<button >
id="getUrlParams" <div>
onClick={[Function]} {"absoluteDateRangeStart":20,"absoluteDateRangeEnd":20,"autorefreshInterval":60000,"autorefreshIsPaused":false,"dateRangeStart":"now-15m","dateRangeEnd":"now","filters":"","search":"","selectedPingStatus":"","statusFilter":""}
> </div>
Get url params <button
</button> id="setUrlParams"
</UseUrlParamsTestComponent> onClick={[Function]}
>
Set url params
</button>
<button
id="getUrlParams"
onClick={[Function]}
>
Get url params
</button>
</UseUrlParamsTestComponent>
</Provider>
</MountWithReduxProvider>
</Router> </Router>
`; `;

View file

@ -10,7 +10,7 @@ import { Route } from 'react-router-dom';
import { mountWithRouter } from '../../lib'; import { mountWithRouter } from '../../lib';
import { OVERVIEW_ROUTE } from '../../../common/constants'; import { OVERVIEW_ROUTE } from '../../../common/constants';
import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public';
import { UptimeUrlParams, getSupportedUrlParams } from '../../lib/helper'; import { UptimeUrlParams, getSupportedUrlParams, MountWithReduxProvider } from '../../lib/helper';
import { makeBaseBreadcrumb, useBreadcrumbs } from '../use_breadcrumbs'; import { makeBaseBreadcrumb, useBreadcrumbs } from '../use_breadcrumbs';
describe('useBreadcrumbs', () => { describe('useBreadcrumbs', () => {
@ -34,11 +34,13 @@ describe('useBreadcrumbs', () => {
}; };
mountWithRouter( mountWithRouter(
<KibanaContextProvider services={{ ...core }}> <MountWithReduxProvider>
<Route path={OVERVIEW_ROUTE}> <KibanaContextProvider services={{ ...core }}>
<Component /> <Route path={OVERVIEW_ROUTE}>
</Route> <Component />
</KibanaContextProvider> </Route>
</KibanaContextProvider>
</MountWithReduxProvider>
); );
const urlParams: UptimeUrlParams = getSupportedUrlParams({}); const urlParams: UptimeUrlParams = getSupportedUrlParams({});

View file

@ -8,7 +8,7 @@ import DateMath from '@elastic/datemath';
import React, { useState, Fragment } from 'react'; import React, { useState, Fragment } from 'react';
import { useUrlParams, UptimeUrlParamsHook } from '../use_url_params'; import { useUrlParams, UptimeUrlParamsHook } from '../use_url_params';
import { UptimeRefreshContext } from '../../contexts'; import { UptimeRefreshContext } from '../../contexts';
import { mountWithRouter } from '../../lib'; import { mountWithRouter, MountWithReduxProvider } from '../../lib';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
interface MockUrlParamsComponentProps { interface MockUrlParamsComponentProps {
@ -52,9 +52,11 @@ describe('useUrlParams', () => {
jest.spyOn(history, 'push'); jest.spyOn(history, 'push');
const component = mountWithRouter( const component = mountWithRouter(
<UptimeRefreshContext.Provider value={{ lastRefresh: 123, refreshApp: jest.fn() }}> <MountWithReduxProvider>
<UseUrlParamsTestComponent hook={useUrlParams} /> <UptimeRefreshContext.Provider value={{ lastRefresh: 123, refreshApp: jest.fn() }}>
</UptimeRefreshContext.Provider>, <UseUrlParamsTestComponent hook={useUrlParams} />
</UptimeRefreshContext.Provider>
</MountWithReduxProvider>,
history history
); );
@ -68,14 +70,16 @@ describe('useUrlParams', () => {
it('gets the expected values using the context', () => { it('gets the expected values using the context', () => {
const component = mountWithRouter( const component = mountWithRouter(
<UptimeRefreshContext.Provider <MountWithReduxProvider>
value={{ <UptimeRefreshContext.Provider
lastRefresh: 123, value={{
refreshApp: jest.fn(), lastRefresh: 123,
}} refreshApp: jest.fn(),
> }}
<UseUrlParamsTestComponent hook={useUrlParams} /> >
</UptimeRefreshContext.Provider> <UseUrlParamsTestComponent hook={useUrlParams} />
</UptimeRefreshContext.Provider>
</MountWithReduxProvider>
); );
const getUrlParamsButton = component.find('#getUrlParams'); const getUrlParamsButton = component.find('#getUrlParams');
@ -92,14 +96,16 @@ describe('useUrlParams', () => {
jest.spyOn(history, 'push'); jest.spyOn(history, 'push');
const component = mountWithRouter( const component = mountWithRouter(
<UptimeRefreshContext.Provider <MountWithReduxProvider>
value={{ <UptimeRefreshContext.Provider
lastRefresh: 123, value={{
refreshApp: jest.fn(), lastRefresh: 123,
}} refreshApp: jest.fn(),
> }}
<UseUrlParamsTestComponent hook={useUrlParams} updateParams={{ pagination: '' }} /> >
</UptimeRefreshContext.Provider>, <UseUrlParamsTestComponent hook={useUrlParams} updateParams={{ pagination: '' }} />
</UptimeRefreshContext.Provider>
</MountWithReduxProvider>,
history history
); );

View file

@ -20,14 +20,18 @@ interface SelectedFilters {
selectedFilters: Map<string, string[]>; selectedFilters: Map<string, string[]>;
} }
export const useFilterUpdate = (fieldName?: string, values?: string[]): SelectedFilters => { export const useFilterUpdate = (
fieldName?: string,
values?: string[],
shouldUpdateUrl: boolean = true
): SelectedFilters => {
const [getUrlParams, updateUrl] = useUrlParams(); const [getUrlParams, updateUrl] = useUrlParams();
const { filters: currentFilters } = getUrlParams(); const { filters: currentFilters } = getUrlParams();
// update filters in the URL from filter group // update filters in the URL from filter group
const onFilterUpdate = (filtersKuery: string) => { const onFilterUpdate = (filtersKuery: string) => {
if (currentFilters !== filtersKuery) { if (currentFilters !== filtersKuery && shouldUpdateUrl) {
updateUrl({ filters: filtersKuery, pagination: '' }); updateUrl({ filters: filtersKuery, pagination: '' });
} }
}; };

View file

@ -4,9 +4,13 @@
* you may not use this file except in compliance with the Elastic License. * you may not use this file except in compliance with the Elastic License.
*/ */
import { useEffect } from 'react';
import { parse, stringify } from 'query-string'; import { parse, stringify } from 'query-string';
import { useLocation, useHistory } from 'react-router-dom'; import { useLocation, useHistory } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import { UptimeUrlParams, getSupportedUrlParams } from '../lib/helper'; import { UptimeUrlParams, getSupportedUrlParams } from '../lib/helper';
import { selectedFiltersSelector } from '../state/selectors';
import { setSelectedFilters } from '../state/actions/selected_filters';
export type GetUrlParams = () => UptimeUrlParams; export type GetUrlParams = () => UptimeUrlParams;
export type UpdateUrlParams = (updatedParams: { export type UpdateUrlParams = (updatedParams: {
@ -27,9 +31,35 @@ export const useGetUrlParams: GetUrlParams = () => {
return getSupportedUrlParams(params); return getSupportedUrlParams(params);
}; };
const getMapFromFilters = (value: any): Map<string, any> | undefined => {
try {
return new Map(JSON.parse(value));
} catch {
return undefined;
}
};
const mapMapToObject = (map: Map<string, any>) => ({
locations: map.get('observer.geo.name') ?? [],
ports: map.get('url.port') ?? [],
schemes: map.get('monitor.type') ?? [],
tags: map.get('tags') ?? [],
});
export const useUrlParams: UptimeUrlParamsHook = () => { export const useUrlParams: UptimeUrlParamsHook = () => {
const location = useLocation(); const location = useLocation();
const history = useHistory(); const history = useHistory();
const dispatch = useDispatch();
const selectedFilters = useSelector(selectedFiltersSelector);
const { filters } = useGetUrlParams();
useEffect(() => {
if (selectedFilters === null) {
const filterMap = getMapFromFilters(filters);
if (filterMap) {
dispatch(setSelectedFilters(mapMapToObject(filterMap)));
}
}
}, [dispatch, filters, selectedFilters]);
const updateUrlParams: UpdateUrlParams = (updatedParams) => { const updateUrlParams: UpdateUrlParams = (updatedParams) => {
if (!history || !location) return; if (!history || !location) return;
@ -57,6 +87,12 @@ export const useUrlParams: UptimeUrlParamsHook = () => {
{ sort: false } { sort: false }
), ),
}); });
const filterMap = getMapFromFilters(mergedParams.filters);
if (!filterMap) {
dispatch(setSelectedFilters(null));
} else {
dispatch(setSelectedFilters(mapMapToObject(filterMap)));
}
}; };
return [useGetUrlParams, updateUrlParams]; return [useGetUrlParams, updateUrlParams];

View file

@ -9,7 +9,6 @@ import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { get } from 'lodash'; import { get } from 'lodash';
import { i18n as i18nFormatter } from '@kbn/i18n'; import { i18n as i18nFormatter } from '@kbn/i18n';
import { alertTypeInitializers } from '../../alert_types';
import { UptimeApp, UptimeAppProps } from '../../../uptime_app'; import { UptimeApp, UptimeAppProps } from '../../../uptime_app';
import { getIntegratedAppAvailability } from './capabilities_adapter'; import { getIntegratedAppAvailability } from './capabilities_adapter';
import { import {
@ -34,18 +33,6 @@ export const getKibanaFrameworkAdapter = (
i18n, i18n,
} = core; } = core;
const {
data: { autocomplete },
triggers_actions_ui,
} = plugins;
alertTypeInitializers.forEach((init) => {
const alertInitializer = init({ autocomplete });
if (!triggers_actions_ui.alertTypeRegistry.has(alertInitializer.id)) {
triggers_actions_ui.alertTypeRegistry.register(init({ autocomplete }));
}
});
const { apm, infrastructure, logs } = getIntegratedAppAvailability( const { apm, infrastructure, logs } = getIntegratedAppAvailability(
capabilities, capabilities,
INTEGRATED_SOLUTIONS INTEGRATED_SOLUTIONS

View file

@ -33,6 +33,24 @@ describe('monitor status alert type', () => {
`); `);
}); });
it('accepts original alert params', () => {
expect(
validate({
locations: ['fairbanks'],
numTimes: 3,
timerange: {
from: 'now-15m',
to: 'now',
},
filters: '{foo: "bar"}',
})
).toMatchInlineSnapshot(`
Object {
"errors": Object {},
}
`);
});
describe('timerange', () => { describe('timerange', () => {
it('has invalid timerangeCount value', () => { it('has invalid timerangeCount value', () => {
expect(validate({ ...params, timerangeCount: 0 })).toMatchInlineSnapshot(` expect(validate({ ...params, timerangeCount: 0 })).toMatchInlineSnapshot(`
@ -96,7 +114,22 @@ describe('monitor status alert type', () => {
}); });
describe('initMonitorStatusAlertType', () => { describe('initMonitorStatusAlertType', () => {
expect(initMonitorStatusAlertType({ autocomplete: {} })).toMatchInlineSnapshot(` expect(
initMonitorStatusAlertType({
store: {
dispatch: jest.fn(),
getState: jest.fn(),
replaceReducer: jest.fn(),
subscribe: jest.fn(),
[Symbol.observable]: jest.fn(),
},
// @ts-ignore we don't need to test this functionality here because
// it's not used by the code this file tests
core: {},
// @ts-ignore
plugins: {},
})
).toMatchInlineSnapshot(`
Object { Object {
"alertParamsExpression": [Function], "alertParamsExpression": [Function],
"defaultActionMessage": "{{context.message}} "defaultActionMessage": "{{context.message}}
@ -104,8 +137,20 @@ describe('monitor status alert type', () => {
{{context.downMonitorsWithGeo}}", {{context.downMonitorsWithGeo}}",
"iconClass": "uptimeApp", "iconClass": "uptimeApp",
"id": "xpack.uptime.alerts.monitorStatus", "id": "xpack.uptime.alerts.monitorStatus",
"name": <MonitorStatusTitle />, "name": <Provider
"requiresAppContext": true, store={
Object {
"dispatch": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
Symbol(observable): [Function],
}
}
>
<MonitorStatusTitle />
</Provider>,
"requiresAppContext": false,
"validate": [Function], "validate": [Function],
} }
`); `);

View file

@ -4,11 +4,16 @@
* you may not use this file except in compliance with the Elastic License. * you may not use this file except in compliance with the Elastic License.
*/ */
import { CoreStart } from 'kibana/public';
import { AlertTypeModel } from '../../../../triggers_actions_ui/public'; import { AlertTypeModel } from '../../../../triggers_actions_ui/public';
import { initMonitorStatusAlertType } from './monitor_status'; import { initMonitorStatusAlertType } from './monitor_status';
import { initTlsAlertType } from './tls'; import { initTlsAlertType } from './tls';
import { ClientPluginsStart } from '../../apps/plugin';
export type AlertTypeInitializer = (dependenies: { autocomplete: any }) => AlertTypeModel; export type AlertTypeInitializer = (dependenies: {
core: CoreStart;
plugins: ClientPluginsStart;
}) => AlertTypeModel;
export const alertTypeInitializers: AlertTypeInitializer[] = [ export const alertTypeInitializers: AlertTypeInitializer[] = [
initMonitorStatusAlertType, initMonitorStatusAlertType,

View file

@ -4,24 +4,33 @@
* you may not use this file except in compliance with the Elastic License. * you may not use this file except in compliance with the Elastic License.
*/ */
import { Provider as ReduxProvider } from 'react-redux';
import React from 'react'; import React from 'react';
import { isRight } from 'fp-ts/lib/Either'; import { isRight } from 'fp-ts/lib/Either';
import { PathReporter } from 'io-ts/lib/PathReporter'; import { PathReporter } from 'io-ts/lib/PathReporter';
import { AlertTypeModel } from '../../../../triggers_actions_ui/public'; import { AlertTypeModel } from '../../../../triggers_actions_ui/public';
import { AlertTypeInitializer } from '.'; import { AlertTypeInitializer } from '.';
import { AtomicStatusCheckParamsType } from '../../../common/runtime_types'; import { AtomicStatusCheckParamsType, StatusCheckParamsType } from '../../../common/runtime_types';
import { MonitorStatusTitle } from './monitor_status_title'; import { MonitorStatusTitle } from './monitor_status_title';
import { CLIENT_ALERT_TYPES } from '../../../common/constants'; import { CLIENT_ALERT_TYPES } from '../../../common/constants';
import { MonitorStatusTranslations } from './translations'; import { MonitorStatusTranslations } from './translations';
import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public';
import { store } from '../../state';
export const validate = (alertParams: unknown) => { export const validate = (alertParams: unknown) => {
const errors: Record<string, any> = {}; const errors: Record<string, any> = {};
const decoded = AtomicStatusCheckParamsType.decode(alertParams); const decoded = AtomicStatusCheckParamsType.decode(alertParams);
const oldDecoded = StatusCheckParamsType.decode(alertParams);
if (!isRight(decoded)) { if (!isRight(decoded) && !isRight(oldDecoded)) {
errors.typeCheckFailure = 'Provided parameters do not conform to the expected type.'; return {
errors.typeCheckParsingMessage = PathReporter.report(decoded); errors: {
} else { typeCheckFailure: 'Provided parameters do not conform to the expected type.',
typeCheckParsingMessage: PathReporter.report(decoded),
},
};
}
if (isRight(decoded)) {
const { numTimes, timerangeCount } = decoded.right; const { numTimes, timerangeCount } = decoded.right;
if (numTimes < 1) { if (numTimes < 1) {
errors.invalidNumTimes = 'Number of alert check down times must be an integer greater than 0'; errors.invalidNumTimes = 'Number of alert check down times must be an integer greater than 0';
@ -44,15 +53,26 @@ const AlertMonitorStatus = React.lazy(() =>
); );
export const initMonitorStatusAlertType: AlertTypeInitializer = ({ export const initMonitorStatusAlertType: AlertTypeInitializer = ({
autocomplete, core,
plugins,
}): AlertTypeModel => ({ }): AlertTypeModel => ({
id: CLIENT_ALERT_TYPES.MONITOR_STATUS, id: CLIENT_ALERT_TYPES.MONITOR_STATUS,
name: <MonitorStatusTitle />, name: (
iconClass: 'uptimeApp', <ReduxProvider store={store}>
alertParamsExpression: (params: any) => ( <MonitorStatusTitle />
<AlertMonitorStatus {...params} autocomplete={autocomplete} /> </ReduxProvider>
), ),
iconClass: 'uptimeApp',
alertParamsExpression: (params: any) => {
return (
<ReduxProvider store={store}>
<KibanaContextProvider services={{ ...core, ...plugins }}>
<AlertMonitorStatus {...params} autocomplete={plugins.data.autocomplete} />
</KibanaContextProvider>
</ReduxProvider>
);
},
validate, validate,
defaultActionMessage, defaultActionMessage,
requiresAppContext: true, requiresAppContext: false,
}); });

View file

@ -5,30 +5,13 @@
*/ */
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux';
import { FormattedMessage } from '@kbn/i18n/react'; import { FormattedMessage } from '@kbn/i18n/react';
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiText } from '@elastic/eui';
import { snapshotDataSelector } from '../../state/selectors';
export const MonitorStatusTitle = () => { export const MonitorStatusTitle = () => {
const { count, loading } = useSelector(snapshotDataSelector);
return ( return (
<EuiFlexGroup> <FormattedMessage
<EuiFlexItem> id="xpack.uptime.alerts.monitorStatus.title.label"
<FormattedMessage defaultMessage="Uptime monitor status"
id="xpack.uptime.alerts.monitorStatus.title.label" />
defaultMessage="Uptime monitor status"
/>{' '}
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ alignSelf: 'center' }}>
{!loading ? (
<EuiText size="s" color="subdued">
{count.total} monitors
</EuiText>
) : (
<EuiLoadingSpinner size="m" />
)}
</EuiFlexItem>
</EuiFlexGroup>
); );
}; };

View file

@ -5,21 +5,30 @@
*/ */
import React from 'react'; import React from 'react';
import { Provider as ReduxProvider } from 'react-redux';
import { AlertTypeModel } from '../../../../triggers_actions_ui/public'; import { AlertTypeModel } from '../../../../triggers_actions_ui/public';
import { CLIENT_ALERT_TYPES } from '../../../common/constants'; import { CLIENT_ALERT_TYPES } from '../../../common/constants';
import { TlsTranslations } from './translations'; import { TlsTranslations } from './translations';
import { AlertTypeInitializer } from '.'; import { AlertTypeInitializer } from '.';
import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public';
import { store } from '../../state';
const { name, defaultActionMessage } = TlsTranslations; const { name, defaultActionMessage } = TlsTranslations;
const TlsAlertExpression = React.lazy(() =>
export const initTlsAlertType: AlertTypeInitializer = (): AlertTypeModel => ({ import('../../components/overview/alerts/alerts_containers/alert_tls')
);
export const initTlsAlertType: AlertTypeInitializer = ({ core, plugins }): AlertTypeModel => ({
id: CLIENT_ALERT_TYPES.TLS, id: CLIENT_ALERT_TYPES.TLS,
iconClass: 'uptimeApp', iconClass: 'uptimeApp',
alertParamsExpression: React.lazy(() => alertParamsExpression: (_params: any) => (
import('../../components/overview/alerts/alerts_containers/alert_tls') <ReduxProvider store={store}>
<KibanaContextProvider services={{ ...core, ...plugins }}>
<TlsAlertExpression />
</KibanaContextProvider>
</ReduxProvider>
), ),
name, name,
validate: () => ({ errors: {} }), validate: () => ({ errors: {} }),
defaultActionMessage, defaultActionMessage,
requiresAppContext: true, requiresAppContext: false,
}); });

View file

@ -0,0 +1,21 @@
/*
* 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 { Provider as ReduxProvider } from 'react-redux';
export const MountWithReduxProvider: React.FC = ({ children }) => (
<ReduxProvider
store={{
dispatch: jest.fn(),
getState: jest.fn().mockReturnValue({ selectedFilters: null }),
subscribe: jest.fn(),
replaceReducer: jest.fn(),
}}
>
{children}
</ReduxProvider>
);

View file

@ -9,3 +9,4 @@ export * from './observability_integration';
export { getChartDateLabel } from './charts'; export { getChartDateLabel } from './charts';
export { seriesHasDownValues } from './series_has_down_values'; export { seriesHasDownValues } from './series_has_down_values';
export { UptimeUrlParams, getSupportedUrlParams } from './url_params'; export { UptimeUrlParams, getSupportedUrlParams } from './url_params';
export { MountWithReduxProvider } from './helper_with_redux';

View file

@ -4,4 +4,5 @@
* you may not use this file except in compliance with the Elastic License. * you may not use this file except in compliance with the Elastic License.
*/ */
export { MountWithReduxProvider } from './helper';
export { renderWithRouter, shallowWithRouter, mountWithRouter } from './helper/helper_with_router'; export { renderWithRouter, shallowWithRouter, mountWithRouter } from './helper/helper_with_router';

View file

@ -6,47 +6,33 @@
import React from 'react'; import React from 'react';
import { PageHeader } from '../page_header'; import { PageHeader } from '../page_header';
import { renderWithRouter } from '../../lib'; import { renderWithRouter, MountWithReduxProvider } from '../../lib';
import { Provider } from 'react-redux';
describe('PageHeader', () => { describe('PageHeader', () => {
it('shallow renders with the date picker', () => { it('shallow renders with the date picker', () => {
const component = renderWithRouter( const component = renderWithRouter(
<MockReduxProvider> <MountWithReduxProvider>
<PageHeader headingText={'TestingHeading'} datePicker={true} /> <PageHeader headingText={'TestingHeading'} datePicker={true} />
</MockReduxProvider> </MountWithReduxProvider>
); );
expect(component).toMatchSnapshot('page_header_with_date_picker'); expect(component).toMatchSnapshot('page_header_with_date_picker');
}); });
it('shallow renders without the date picker', () => { it('shallow renders without the date picker', () => {
const component = renderWithRouter( const component = renderWithRouter(
<MockReduxProvider> <MountWithReduxProvider>
<PageHeader headingText={'TestingHeading'} datePicker={false} /> <PageHeader headingText={'TestingHeading'} datePicker={false} />
</MockReduxProvider> </MountWithReduxProvider>
); );
expect(component).toMatchSnapshot('page_header_no_date_picker'); expect(component).toMatchSnapshot('page_header_no_date_picker');
}); });
it('shallow renders extra links', () => { it('shallow renders extra links', () => {
const component = renderWithRouter( const component = renderWithRouter(
<MockReduxProvider> <MountWithReduxProvider>
<PageHeader headingText={'TestingHeading'} extraLinks={true} datePicker={true} /> <PageHeader headingText={'TestingHeading'} extraLinks={true} datePicker={true} />
</MockReduxProvider> </MountWithReduxProvider>
); );
expect(component).toMatchSnapshot('page_header_with_extra_links'); expect(component).toMatchSnapshot('page_header_with_extra_links');
}); });
}); });
const MockReduxProvider = ({ children }: { children: React.ReactElement }) => (
<Provider
store={{
dispatch: jest.fn(),
getState: jest.fn(),
subscribe: jest.fn(),
replaceReducer: jest.fn(),
}}
>
{children}
</Provider>
);

View file

@ -9,6 +9,7 @@ import { OverviewFilters } from '../../../common/runtime_types';
export const FETCH_OVERVIEW_FILTERS = 'FETCH_OVERVIEW_FILTERS'; export const FETCH_OVERVIEW_FILTERS = 'FETCH_OVERVIEW_FILTERS';
export const FETCH_OVERVIEW_FILTERS_FAIL = 'FETCH_OVERVIEW_FILTERS_FAIL'; export const FETCH_OVERVIEW_FILTERS_FAIL = 'FETCH_OVERVIEW_FILTERS_FAIL';
export const FETCH_OVERVIEW_FILTERS_SUCCESS = 'FETCH_OVERVIEW_FILTERS_SUCCESS'; export const FETCH_OVERVIEW_FILTERS_SUCCESS = 'FETCH_OVERVIEW_FILTERS_SUCCESS';
export const SET_OVERVIEW_FILTERS = 'SET_OVERVIEW_FILTERS';
export interface GetOverviewFiltersPayload { export interface GetOverviewFiltersPayload {
dateRangeStart: string; dateRangeStart: string;
@ -36,10 +37,16 @@ interface GetOverviewFiltersFailAction {
payload: Error; payload: Error;
} }
interface SetOverviewFiltersAction {
type: typeof SET_OVERVIEW_FILTERS;
payload: OverviewFilters;
}
export type OverviewFiltersAction = export type OverviewFiltersAction =
| GetOverviewFiltersFetchAction | GetOverviewFiltersFetchAction
| GetOverviewFiltersSuccessAction | GetOverviewFiltersSuccessAction
| GetOverviewFiltersFailAction; | GetOverviewFiltersFailAction
| SetOverviewFiltersAction;
export const fetchOverviewFilters = ( export const fetchOverviewFilters = (
payload: GetOverviewFiltersPayload payload: GetOverviewFiltersPayload
@ -59,3 +66,8 @@ export const fetchOverviewFiltersSuccess = (
type: FETCH_OVERVIEW_FILTERS_SUCCESS, type: FETCH_OVERVIEW_FILTERS_SUCCESS,
payload: filters, payload: filters,
}); });
export const setOverviewFilters = (filters: OverviewFilters): SetOverviewFiltersAction => ({
type: SET_OVERVIEW_FILTERS,
payload: filters,
});

View file

@ -0,0 +1,21 @@
/*
* 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 { createAction } from 'redux-actions';
export interface SelectedFilters {
locations: string[];
ports: number[];
schemes: string[];
tags: string[];
}
export type SelectedFiltersPayload = SelectedFilters;
export const getSelectedFilters = createAction<void>('GET SELECTED FILTERS');
export const setSelectedFilters = createAction<SelectedFiltersPayload | null>(
'SET_SELECTED_FILTERS'
);

View file

@ -9,12 +9,12 @@ import createSagaMiddleware from 'redux-saga';
import { rootEffect } from './effects'; import { rootEffect } from './effects';
import { rootReducer } from './reducers'; import { rootReducer } from './reducers';
export type AppState = ReturnType<typeof rootReducer>;
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const sagaMW = createSagaMiddleware(); const sagaMW = createSagaMiddleware();
export const store = createStore(rootReducer, composeEnhancers(applyMiddleware(sagaMW))); export const store = createStore(rootReducer, composeEnhancers(applyMiddleware(sagaMW)));
export type AppState = ReturnType<typeof rootReducer>;
sagaMW.run(rootEffect); sagaMW.run(rootEffect);

View file

@ -4,7 +4,12 @@
* you may not use this file except in compliance with the Elastic License. * you may not use this file except in compliance with the Elastic License.
*/ */
import { setBasePath, toggleIntegrationsPopover, setAlertFlyoutVisible } from '../../actions'; import {
setBasePath,
toggleIntegrationsPopover,
setAlertFlyoutVisible,
setSearchTextAction,
} from '../../actions';
import { uiReducer } from '../ui'; import { uiReducer } from '../ui';
import { Action } from 'redux-actions'; import { Action } from 'redux-actions';
@ -67,4 +72,28 @@ describe('ui reducer', () => {
} }
`); `);
}); });
it('sets the search text', () => {
const action = setSearchTextAction('lorem ipsum') as Action<never>;
expect(
uiReducer(
{
alertFlyoutVisible: false,
basePath: '',
esKuery: '',
integrationsPopoverOpen: null,
searchText: '',
},
action
)
).toMatchInlineSnapshot(`
Object {
"alertFlyoutVisible": false,
"basePath": "",
"esKuery": "",
"integrationsPopoverOpen": null,
"searchText": "lorem ipsum",
}
`);
});
}); });

View file

@ -19,6 +19,7 @@ import { monitorDurationReducer } from './monitor_duration';
import { indexStatusReducer } from './index_status'; import { indexStatusReducer } from './index_status';
import { mlJobsReducer } from './ml_anomaly'; import { mlJobsReducer } from './ml_anomaly';
import { certificatesReducer } from '../certificates/certificates'; import { certificatesReducer } from '../certificates/certificates';
import { selectedFiltersReducer } from './selected_filters';
export const rootReducer = combineReducers({ export const rootReducer = combineReducers({
monitor: monitorReducer, monitor: monitorReducer,
@ -35,4 +36,5 @@ export const rootReducer = combineReducers({
monitorDuration: monitorDurationReducer, monitorDuration: monitorDurationReducer,
indexStatus: indexStatusReducer, indexStatus: indexStatusReducer,
certificates: certificatesReducer, certificates: certificatesReducer,
selectedFilters: selectedFiltersReducer,
}); });

View file

@ -10,6 +10,7 @@ import {
FETCH_OVERVIEW_FILTERS_FAIL, FETCH_OVERVIEW_FILTERS_FAIL,
FETCH_OVERVIEW_FILTERS_SUCCESS, FETCH_OVERVIEW_FILTERS_SUCCESS,
OverviewFiltersAction, OverviewFiltersAction,
SET_OVERVIEW_FILTERS,
} from '../actions'; } from '../actions';
export interface OverviewFiltersState { export interface OverviewFiltersState {
@ -51,6 +52,11 @@ export function overviewFiltersReducer(
errors: [...state.errors, action.payload], errors: [...state.errors, action.payload],
loading: false, loading: false,
}; };
case SET_OVERVIEW_FILTERS:
return {
...state,
filters: action.payload,
};
default: default:
return state; return state;
} }

View file

@ -0,0 +1,32 @@
/*
* 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 { Action } from 'redux-actions';
import {
getSelectedFilters,
setSelectedFilters,
SelectedFilters,
} from '../actions/selected_filters';
const initialState: SelectedFilters | null = null;
export function selectedFiltersReducer(
state = initialState,
action: Action<any>
): SelectedFilters | null {
switch (action.type) {
case String(getSelectedFilters):
return state;
case String(setSelectedFilters):
if (state === null) return { ...action.payload };
return {
...(state || {}),
...action.payload,
};
default:
return state;
}
}

View file

@ -107,6 +107,7 @@ describe('state selectors', () => {
loading: false, loading: false,
}, },
}, },
selectedFilters: null,
}; };
it('selects base path from state', () => { it('selects base path from state', () => {

View file

@ -86,3 +86,5 @@ export const overviewFiltersSelector = ({ overviewFilters }: AppState) => overvi
export const esKuerySelector = ({ ui: { esKuery } }: AppState) => esKuery; export const esKuerySelector = ({ ui: { esKuery } }: AppState) => esKuery;
export const searchTextSelector = ({ ui: { searchText } }: AppState) => searchText; export const searchTextSelector = ({ ui: { searchText } }: AppState) => searchText;
export const selectedFiltersSelector = ({ selectedFilters }: AppState) => selectedFilters;

View file

@ -20,14 +20,13 @@ import {
UptimeStartupPluginsContextProvider, UptimeStartupPluginsContextProvider,
} from './contexts'; } from './contexts';
import { CommonlyUsedRange } from './components/common/uptime_date_picker'; import { CommonlyUsedRange } from './components/common/uptime_date_picker';
import { store } from './state';
import { setBasePath } from './state/actions'; import { setBasePath } from './state/actions';
import { PageRouter } from './routes'; import { PageRouter } from './routes';
import { import {
UptimeAlertsContextProvider, UptimeAlertsContextProvider,
UptimeAlertsFlyoutWrapper, UptimeAlertsFlyoutWrapper,
} from './components/overview/alerts'; } from './components/overview/alerts';
import { kibanaService } from './state/kibana_service'; import { store } from './state';
export interface UptimeAppColors { export interface UptimeAppColors {
danger: string; danger: string;
@ -87,8 +86,6 @@ const Application = (props: UptimeAppProps) => {
); );
}, [canSave, renderGlobalHelpControls, setBadge]); }, [canSave, renderGlobalHelpControls, setBadge]);
kibanaService.core = core;
store.dispatch(setBasePath(basePath)); store.dispatch(setBasePath(basePath));
return ( return (