[Uptime] Add tags dropdown to Overview filters group (#50837)

* Finish implementing snapshot count redux code.

* Replace GQL-powered Snapshot export with Redux/Rest-powered version.

* Add tests for Snapshot API call.

* Rename new test file from tsx to ts, it has no JSX.

* Rename outdated snapshot file.

* Update filter groups to use redux and add tags dropdown.

* Delete obsolete graphql filter bar query.

* Add fetch effect factory.

* Use generic fetch effect factory to avoid code redundancy.

* Infer isDisabled status from data for filter group buttons and disable when there are no items.

* Fix removal of overview filter from previous rebase.

* Rename generator-related functions from *saga to *effect.

* WIP trying to make filters filterable.

* WIP cleaning up.

* Delete obsolete API test.

* Add API test for filters endpoint.

* Remove obsolete fields from overview filters.

* Add functional testing attributes and delete a comment for filter popover.

* Update obsolete unit test snapshots and test props for filter popover.

* Fix broken types and delete obsolete test snapshots for filters api call.

* Modify filters endpoint to adhere to np routing contracts.

* Add functional test and associated helper functions for filters API.

* Remove obsolete resolver function for filter bar.

* Remove obsolete FilterBar type from graphql schema.

* Delete static types generated for obsolete GQL schema types.

* Delete obsolete fields from default filters state.

* Delete obsolete method from graphql schema.

* Add default values to unit test that requires complete app state mock.

* Extract helper logic to dedicated module.

* Finish working on adapter/helper tests.

* Add state field for overview page search query.

* Apply search kuery to filters.

* Simplify creation of overview filter fetch actions and API call.

* Add tests for overview filter action creators.

* Simplify api query parameterizaton.

* Improve a variable name.

* Update formatting of file.

* Improve a variable name.

* Improve a variable name.

* Simplify API endpoint typing.

* Clean up helper code and rename some functions/vars.

* Clean up parameterization of filter values.

* Move function from dedicated file back to calling file.

* Clean up naming in a function.

* Move function from dedicated file to caller's file.

* Modify interface of function return value.

* Have function throw error when it receives invalid input instead of returning empty object.

* Extract constant value to dedicated function value and remove parameter from function.

* Clean up object declarations.

* Rename a property.

* Fix issue where function was not handling empty input.

* Delete unnecessary snapshots.

* Add message to internal server error response.

* Fix broken type.

* Delete type that was added as a result of a merge error.

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Justin Kambic 2020-01-10 11:15:20 -05:00 committed by GitHub
parent 1d4c2f6ca1
commit aa9126ec04
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
61 changed files with 1394 additions and 260 deletions

View file

@ -30,8 +30,6 @@ export interface Query {
/** Fetch the most recent event data for a monitor ID, date range, location. */
getLatestMonitors: Ping[];
getFilterBar?: FilterBar | null;
/** Fetches the current state of Uptime monitors for the given parameters. */
getMonitorStates?: MonitorSummaryResult | null;
/** Fetches details about the uptime index. */
@ -467,21 +465,6 @@ export interface StatusData {
/** The total down counts for this point. */
total?: number | null;
}
/** The data used to enrich the filter bar. */
export interface FilterBar {
/** A series of monitor IDs in the heartbeat indices. */
ids?: string[] | null;
/** The location values users have configured for the agents. */
locations?: string[] | null;
/** The ports of the monitored endpoints. */
ports?: number[] | null;
/** The schemes used by the monitors. */
schemes?: string[] | null;
/** The possible status values contained in the indices. */
statuses?: string[] | null;
/** The list of URLs */
urls?: string[] | null;
}
/** The primary object returned for monitor states. */
export interface MonitorSummaryResult {

View file

@ -5,5 +5,6 @@
*/
export * from './common';
export * from './snapshot';
export * from './monitor';
export * from './overview_filters';
export * from './snapshot';

View file

@ -0,0 +1,7 @@
/*
* 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 { OverviewFiltersType, OverviewFilters } from './overview_filters';

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import * as t from 'io-ts';
export const OverviewFiltersType = t.type({
locations: t.array(t.string),
ports: t.array(t.number),
schemes: t.array(t.string),
tags: t.array(t.string),
});
export type OverviewFilters = t.TypeOf<typeof OverviewFiltersType>;

View file

@ -13,6 +13,7 @@ exports[`FilterPopover component does not show item list when loading 1`] = `
/>
}
closePopover={[Function]}
data-test-subj="filter-popover_test"
display="inlineBlock"
hasArrow={true}
id="test"
@ -49,6 +50,7 @@ exports[`FilterPopover component renders without errors for valid props 1`] = `
/>
}
closePopover={[Function]}
data-test-subj="filter-popover_test"
display="inlineBlock"
hasArrow={true}
id="test"
@ -83,6 +85,7 @@ exports[`FilterPopover component returns selected items on popover close 1`] = `
</div>
<div
class="euiPopover euiPopover--anchorDownCenter euiPopover--withTitle"
data-test-subj="filter-popover_test"
id="test"
>
<div

View file

@ -0,0 +1,20 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`parseFiltersMap provides values from valid filter string 1`] = `
Object {
"locations": Array [
"us-east-2",
],
"ports": Array [
"5601",
"80",
],
"schemes": Array [
"http",
"tcp",
],
"tags": Array [],
}
`;
exports[`parseFiltersMap returns an empty object for invalid filter 1`] = `"Unable to parse invalid filter string"`;

View file

@ -19,7 +19,7 @@ describe('FilterPopover component', () => {
props = {
fieldName: 'foo',
id: 'test',
isLoading: false,
loading: false,
items: ['first', 'second', 'third', 'fourth'],
onFilterFieldChange: jest.fn(),
selectedItems: ['first', 'third'],
@ -47,7 +47,7 @@ describe('FilterPopover component', () => {
});
it('does not show item list when loading', () => {
props.isLoading = true;
props.loading = true;
const wrapper = shallowWithIntl(<FilterPopover {...props} />);
expect(wrapper).toMatchSnapshot();
});

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 { parseFiltersMap } from '../parse_filter_map';
describe('parseFiltersMap', () => {
it('provides values from valid filter string', () => {
expect(
parseFiltersMap(
'[["url.port",["5601","80"]],["observer.geo.name",["us-east-2"]],["monitor.type",["http","tcp"]]]'
)
).toMatchSnapshot();
});
it('returns an empty object for invalid filter', () => {
expect(() => parseFiltersMap('some invalid string')).toThrowErrorMatchingSnapshot();
});
});

View file

@ -5,35 +5,49 @@
*/
import { EuiFilterGroup } from '@elastic/eui';
import React from 'react';
import { get } from 'lodash';
import React, { useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import { FilterBar as FilterBarType } from '../../../../common/graphql/types';
import { UptimeGraphQLQueryProps, withUptimeGraphQL } from '../../higher_order';
import { filterBarQuery } from '../../../queries';
import { connect } from 'react-redux';
import { FilterPopoverProps, FilterPopover } from './filter_popover';
import { FilterStatusButton } from './filter_status_button';
import { OverviewFilters } from '../../../../common/runtime_types';
import { fetchOverviewFilters, GetOverviewFiltersPayload } from '../../../state/actions';
import { AppState } from '../../../state';
import { useUrlParams } from '../../../hooks';
import { parseFiltersMap } from './parse_filter_map';
interface FilterBarQueryResult {
filters?: FilterBarType;
interface OwnProps {
currentFilter: any;
onFilterUpdate: any;
dateRangeStart: string;
dateRangeEnd: string;
filters?: string;
statusFilter?: string;
}
interface FilterBarDropdownsProps {
currentFilter: string;
onFilterUpdate: (kuery: string) => void;
interface StoreProps {
esKuery: string;
lastRefresh: number;
loading: boolean;
overviewFilters: OverviewFilters;
}
type Props = UptimeGraphQLQueryProps<FilterBarQueryResult> & FilterBarDropdownsProps;
interface DispatchProps {
loadFilterGroup: typeof fetchOverviewFilters;
}
export const FilterGroupComponent = ({
loading: isLoading,
type Props = OwnProps & StoreProps & DispatchProps;
type PresentationalComponentProps = Pick<StoreProps, 'overviewFilters' | 'loading'> &
Pick<OwnProps, 'currentFilter' | 'onFilterUpdate'>;
export const PresentationalComponent: React.FC<PresentationalComponentProps> = ({
currentFilter,
data,
overviewFilters,
loading,
onFilterUpdate,
}: Props) => {
const locations = get<string[]>(data, 'filterBar.locations', []);
const ports = get<string[]>(data, 'filterBar.ports', []);
const schemes = get<string[]>(data, 'filterBar.schemes', []);
}) => {
const { locations, ports, schemes, tags } = overviewFilters;
let filterKueries: Map<string, string[]>;
try {
@ -67,36 +81,50 @@ export const FilterGroupComponent = ({
const filterPopoverProps: FilterPopoverProps[] = [
{
loading,
onFilterFieldChange,
fieldName: 'observer.geo.name',
id: 'location',
isLoading,
items: locations,
onFilterFieldChange,
selectedItems: getSelectedItems('observer.geo.name'),
title: i18n.translate('xpack.uptime.filterBar.options.location.name', {
defaultMessage: 'Location',
}),
},
{
loading,
onFilterFieldChange,
fieldName: 'url.port',
id: 'port',
isLoading,
items: ports,
onFilterFieldChange,
disabled: ports.length === 0,
items: ports.map((p: number) => p.toString()),
selectedItems: getSelectedItems('url.port'),
title: i18n.translate('xpack.uptime.filterBar.options.portLabel', { defaultMessage: 'Port' }),
},
{
loading,
onFilterFieldChange,
fieldName: 'monitor.type',
id: 'scheme',
isLoading,
disabled: schemes.length === 0,
items: schemes,
onFilterFieldChange,
selectedItems: getSelectedItems('monitor.type'),
title: i18n.translate('xpack.uptime.filterBar.options.schemeLabel', {
defaultMessage: 'Scheme',
}),
},
{
loading,
onFilterFieldChange,
fieldName: 'tags',
id: 'tags',
disabled: tags.length === 0,
items: tags,
selectedItems: getSelectedItems('tags'),
title: i18n.translate('xpack.uptime.filterBar.options.tagsLabel', {
defaultMessage: 'Tags',
}),
},
];
return (
@ -124,7 +152,59 @@ export const FilterGroupComponent = ({
);
};
export const FilterGroup = withUptimeGraphQL<FilterBarQueryResult, FilterBarDropdownsProps>(
FilterGroupComponent,
filterBarQuery
);
export const Container: React.FC<Props> = ({
currentFilter,
esKuery,
filters,
loading,
loadFilterGroup,
dateRangeStart,
dateRangeEnd,
overviewFilters,
statusFilter,
onFilterUpdate,
}: Props) => {
const [getUrlParams] = useUrlParams();
const { filters: urlFilters } = getUrlParams();
useEffect(() => {
const filterSelections = parseFiltersMap(urlFilters);
loadFilterGroup({
dateRangeStart,
dateRangeEnd,
locations: filterSelections.locations ?? [],
ports: filterSelections.ports ?? [],
schemes: filterSelections.schemes ?? [],
search: esKuery,
statusFilter,
tags: filterSelections.tags ?? [],
});
}, [dateRangeStart, dateRangeEnd, esKuery, filters, statusFilter, urlFilters, loadFilterGroup]);
return (
<PresentationalComponent
currentFilter={currentFilter}
overviewFilters={overviewFilters}
loading={loading}
onFilterUpdate={onFilterUpdate}
/>
);
};
const mapStateToProps = ({
overviewFilters: { loading, filters },
ui: { esKuery, lastRefresh },
}: AppState): StoreProps => ({
esKuery,
overviewFilters: filters,
lastRefresh,
loading,
});
const mapDispatchToProps = (dispatch: any): DispatchProps => ({
loadFilterGroup: (payload: GetOverviewFiltersPayload) => dispatch(fetchOverviewFilters(payload)),
});
export const FilterGroup = connect<StoreProps, DispatchProps, OwnProps>(
// @ts-ignore connect is expecting null | undefined for some reason
mapStateToProps,
mapDispatchToProps
)(Container);

View file

@ -14,7 +14,8 @@ import { LocationLink } from '../monitor_list';
export interface FilterPopoverProps {
fieldName: string;
id: string;
isLoading: boolean;
loading: boolean;
disabled?: boolean;
items: string[];
onFilterFieldChange: (fieldName: string, values: string[]) => void;
selectedItems: string[];
@ -27,7 +28,8 @@ const isItemSelected = (selectedItems: string[], item: string): 'on' | undefined
export const FilterPopover = ({
fieldName,
id,
isLoading,
disabled,
loading,
items,
onFilterFieldChange,
selectedItems,
@ -48,10 +50,10 @@ export const FilterPopover = ({
}, [searchQuery, items]);
return (
// @ts-ignore zIndex prop is not described in the typing yet
<EuiPopover
button={
<UptimeFilterButton
isDisabled={disabled}
isSelected={tempSelectedItems.length > 0}
numFilters={items.length}
numActiveFilters={tempSelectedItems.length}
@ -66,6 +68,7 @@ export const FilterPopover = ({
setIsOpen(false);
onFilterFieldChange(fieldName, tempSelectedItems);
}}
data-test-subj={`filter-popover_${id}`}
id={id}
isOpen={isOpen}
ownFocus={true}
@ -77,7 +80,7 @@ export const FilterPopover = ({
disabled={items.length === 0}
onSearch={query => setSearchQuery(query)}
placeholder={
isLoading
loading
? i18n.translate('xpack.uptime.filterPopout.loadingMessage', {
defaultMessage: 'Loading...',
})
@ -90,10 +93,11 @@ export const FilterPopover = ({
}
/>
</EuiPopoverTitle>
{!isLoading &&
{!loading &&
itemsToDisplay.map(item => (
<EuiFilterSelectItem
checked={isItemSelected(tempSelectedItems, item)}
data-test-subj={`filter-popover-item_${item}`}
key={item}
onClick={() => toggleSelectedItems(item, tempSelectedItems, setTempSelectedItems)}
>

View file

@ -11,6 +11,7 @@ import { useUrlParams } from '../../../hooks';
export interface FilterStatusButtonProps {
content: string;
dataTestSubj: string;
isDisabled?: boolean;
value: string;
withNext: boolean;
}
@ -18,6 +19,7 @@ export interface FilterStatusButtonProps {
export const FilterStatusButton = ({
content,
dataTestSubj,
isDisabled,
value,
withNext,
}: FilterStatusButtonProps) => {
@ -27,6 +29,7 @@ export const FilterStatusButton = ({
<EuiFilterButton
data-test-subj={dataTestSubj}
hasActiveFilters={urlValue === value}
isDisabled={isDisabled}
onClick={() => {
const nextFilter = { statusFilter: urlValue === value ? '' : value, pagination: '' };
setUrlParams(nextFilter);

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.
*/
interface FilterField {
name: string;
fieldName: string;
}
/**
* These are the only filter fields we are looking to catch at the moment.
* If your code needs to support custom fields, introduce a second parameter to
* `parseFiltersMap` to take a list of FilterField objects.
*/
const filterWhitelist: FilterField[] = [
{ name: 'ports', fieldName: 'url.port' },
{ name: 'locations', fieldName: 'observer.geo.name' },
{ name: 'tags', fieldName: 'tags' },
{ name: 'schemes', fieldName: 'monitor.type' },
];
export const parseFiltersMap = (filterMapString: string) => {
if (!filterMapString) {
return {};
}
const filterSlices: { [key: string]: any } = {};
try {
const map = new Map<string, string[]>(JSON.parse(filterMapString));
filterWhitelist.forEach(({ name, fieldName }) => {
filterSlices[name] = map.get(fieldName) ?? [];
});
return filterSlices;
} catch {
throw new Error('Unable to parse invalid filter string');
}
};

View file

@ -8,6 +8,7 @@ import { EuiFilterButton } from '@elastic/eui';
import React from 'react';
interface UptimeFilterButtonProps {
isDisabled?: boolean;
isSelected: boolean;
numFilters: number;
numActiveFilters: number;
@ -16,6 +17,7 @@ interface UptimeFilterButtonProps {
}
export const UptimeFilterButton = ({
isDisabled,
isSelected,
numFilters,
numActiveFilters,
@ -25,6 +27,7 @@ export const UptimeFilterButton = ({
<EuiFilterButton
hasActiveFilters={numActiveFilters !== 0}
iconType="arrowDown"
isDisabled={isDisabled}
isSelected={isSelected}
numActiveFilters={numActiveFilters}
numFilters={numFilters}

View file

@ -0,0 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`parameterizeValues parameterizes provided values for multiple fields 1`] = `"foo=bar&foo=baz&bar=foo&bar=baz"`;
exports[`parameterizeValues parameterizes the provided values for a given field name 1`] = `"foo=bar&foo=baz"`;

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { parameterizeValues } from '../parameterize_values';
describe('parameterizeValues', () => {
let params: URLSearchParams;
beforeEach(() => {
params = new URLSearchParams();
});
it('parameterizes the provided values for a given field name', () => {
parameterizeValues(params, { foo: ['bar', 'baz'] });
expect(params.toString()).toMatchSnapshot();
});
it('parameterizes provided values for multiple fields', () => {
parameterizeValues(params, { foo: ['bar', 'baz'], bar: ['foo', 'baz'] });
expect(params.toString()).toMatchSnapshot();
});
it('returns an empty string when there are no values provided', () => {
parameterizeValues(params, { foo: [] });
expect(params.toString()).toBe('');
});
});

View file

@ -9,6 +9,7 @@ export { convertMicrosecondsToMilliseconds } from './convert_measurements';
export * from './observability_integration';
export { getApiPath } from './get_api_path';
export { getChartDateLabel } from './charts';
export { parameterizeValues } from './parameterize_values';
export { seriesHasDownValues } from './series_has_down_values';
export { stringifyKueries } from './stringify_kueries';
export { toStaticIndexPattern } from './to_static_index_pattern';

View file

@ -0,0 +1,16 @@
/*
* 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 const parameterizeValues = (
params: URLSearchParams,
obj: Record<string, string[]>
): void => {
Object.keys(obj).forEach(key => {
obj[key].forEach(val => {
params.append(key, val);
});
});
};

View file

@ -22,6 +22,8 @@ import { stringifyUrlParams } from '../lib/helper/stringify_url_params';
import { useTrackPageview } from '../../../infra/public';
import { combineFiltersAndUserSearch, stringifyKueries, toStaticIndexPattern } from '../lib/helper';
import { AutocompleteProviderRegister, esKuery } from '../../../../../../src/plugins/data/public';
import { store } from '../state';
import { setEsKueryString } from '../state/actions';
import { PageHeader } from './page_header';
interface OverviewPageProps {
@ -64,7 +66,6 @@ export const OverviewPage = ({ autocomplete, setBreadcrumbs }: Props) => {
useTrackPageview({ app: 'uptime', path: 'overview' });
useTrackPageview({ app: 'uptime', path: 'overview', delay: 15000 });
const filterQueryString = search || '';
let error: any;
let kueryString: string = '';
try {
@ -76,6 +77,7 @@ export const OverviewPage = ({ autocomplete, setBreadcrumbs }: Props) => {
kueryString = '';
}
const filterQueryString = search || '';
let filters: any | undefined;
try {
if (filterQueryString || urlFilters) {
@ -85,6 +87,15 @@ export const OverviewPage = ({ autocomplete, setBreadcrumbs }: Props) => {
const ast = esKuery.fromKueryExpression(combinedFilterString);
const elasticsearchQuery = esKuery.toElasticsearchQuery(ast, staticIndexPattern);
filters = JSON.stringify(elasticsearchQuery);
const searchDSL: string = filterQueryString
? JSON.stringify(
esKuery.toElasticsearchQuery(
esKuery.fromKueryExpression(filterQueryString),
staticIndexPattern
)
)
: '';
store.dispatch(setEsKueryString(searchDSL));
}
}
} catch (e) {
@ -110,13 +121,13 @@ export const OverviewPage = ({ autocomplete, setBreadcrumbs }: Props) => {
</EuiFlexItem>
<EuiFlexItemStyled grow={true}>
<FilterGroup
{...sharedProps}
currentFilter={urlFilters}
onFilterUpdate={(filtersKuery: string) => {
if (urlFilters !== filtersKuery) {
updateUrl({ filters: filtersKuery, pagination: '' });
}
}}
variables={sharedProps}
/>
</EuiFlexItemStyled>
{error && <OverviewPageParsingErrorCallout error={error} />}

View file

@ -1,23 +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 gql from 'graphql-tag';
export const filterBarQueryString = `
query FilterBar($dateRangeStart: String!, $dateRangeEnd: String!) {
filterBar: getFilterBar(dateRangeStart: $dateRangeStart, dateRangeEnd: $dateRangeEnd) {
ids
locations
ports
schemes
urls
}
}
`;
export const filterBarQuery = gql`
${filterBarQueryString}
`;

View file

@ -5,6 +5,5 @@
*/
export { docCountQuery, docCountQueryString } from './doc_count_query';
export { filterBarQuery, filterBarQueryString } from './filter_bar_query';
export { monitorChartsQuery, monitorChartsQueryString } from './monitor_charts_query';
export { pingsQuery, pingsQueryString } from './pings_query';

View file

@ -0,0 +1,61 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`overview filters action creators creates a fail action 1`] = `
Object {
"payload": [Error: There was an error retrieving the overview filters],
"type": "FETCH_OVERVIEW_FILTERS_FAIL",
}
`;
exports[`overview filters action creators creates a get action 1`] = `
Object {
"payload": Object {
"dateRangeEnd": "now",
"dateRangeStart": "now-15m",
"locations": Array [
"fairbanks",
"tokyo",
],
"ports": Array [
"80",
],
"schemes": Array [
"http",
"tcp",
],
"search": "",
"statusFilter": "down",
"tags": Array [
"api",
"dev",
],
},
"type": "FETCH_OVERVIEW_FILTERS",
}
`;
exports[`overview filters action creators creates a success action 1`] = `
Object {
"payload": Object {
"locations": Array [
"fairbanks",
"tokyo",
"london",
],
"ports": Array [
80,
443,
],
"schemes": Array [
"http",
"tcp",
],
"tags": Array [
"api",
"dev",
"prod",
],
},
"type": "FETCH_OVERVIEW_FILTERS_SUCCESS",
}
`;

View file

@ -0,0 +1,45 @@
/*
* 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 {
fetchOverviewFilters,
fetchOverviewFiltersSuccess,
fetchOverviewFiltersFail,
} from '../overview_filters';
describe('overview filters action creators', () => {
it('creates a get action', () => {
expect(
fetchOverviewFilters({
dateRangeStart: 'now-15m',
dateRangeEnd: 'now',
statusFilter: 'down',
search: '',
locations: ['fairbanks', 'tokyo'],
ports: ['80'],
schemes: ['http', 'tcp'],
tags: ['api', 'dev'],
})
).toMatchSnapshot();
});
it('creates a success action', () => {
expect(
fetchOverviewFiltersSuccess({
locations: ['fairbanks', 'tokyo', 'london'],
ports: [80, 443],
schemes: ['http', 'tcp'],
tags: ['api', 'dev', 'prod'],
})
).toMatchSnapshot();
});
it('creates a fail action', () => {
expect(
fetchOverviewFiltersFail(new Error('There was an error retrieving the overview filters'))
).toMatchSnapshot();
});
});

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
export * from './overview_filters';
export * from './snapshot';
export * from './ui';
export * from './monitor_status';

View file

@ -0,0 +1,61 @@
/*
* 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 { OverviewFilters } from '../../../common/runtime_types';
export const FETCH_OVERVIEW_FILTERS = 'FETCH_OVERVIEW_FILTERS';
export const FETCH_OVERVIEW_FILTERS_FAIL = 'FETCH_OVERVIEW_FILTERS_FAIL';
export const FETCH_OVERVIEW_FILTERS_SUCCESS = 'FETCH_OVERVIEW_FILTERS_SUCCESS';
export interface GetOverviewFiltersPayload {
dateRangeStart: string;
dateRangeEnd: string;
locations: string[];
ports: string[];
schemes: string[];
search?: string;
statusFilter?: string;
tags: string[];
}
interface GetOverviewFiltersFetchAction {
type: typeof FETCH_OVERVIEW_FILTERS;
payload: GetOverviewFiltersPayload;
}
interface GetOverviewFiltersSuccessAction {
type: typeof FETCH_OVERVIEW_FILTERS_SUCCESS;
payload: OverviewFilters;
}
interface GetOverviewFiltersFailAction {
type: typeof FETCH_OVERVIEW_FILTERS_FAIL;
payload: Error;
}
export type OverviewFiltersAction =
| GetOverviewFiltersFetchAction
| GetOverviewFiltersSuccessAction
| GetOverviewFiltersFailAction;
export const fetchOverviewFilters = (
payload: GetOverviewFiltersPayload
): GetOverviewFiltersFetchAction => ({
type: FETCH_OVERVIEW_FILTERS,
payload,
});
export const fetchOverviewFiltersFail = (error: Error): GetOverviewFiltersFailAction => ({
type: FETCH_OVERVIEW_FILTERS_FAIL,
payload: error,
});
export const fetchOverviewFiltersSuccess = (
filters: OverviewFilters
): GetOverviewFiltersSuccessAction => ({
type: FETCH_OVERVIEW_FILTERS_SUCCESS,
payload: filters,
});

View file

@ -5,6 +5,7 @@
*/
import { Snapshot } from '../../../common/runtime_types';
export const FETCH_SNAPSHOT_COUNT = 'FETCH_SNAPSHOT_COUNT';
export const FETCH_SNAPSHOT_COUNT_FAIL = 'FETCH_SNAPSHOT_COUNT_FAIL';
export const FETCH_SNAPSHOT_COUNT_SUCCESS = 'FETCH_SNAPSHOT_COUNT_SUCCESS';

View file

@ -16,6 +16,8 @@ export const setBasePath = createAction<string>('SET BASE PATH');
export const triggerAppRefresh = createAction<number>('REFRESH APP');
export const setEsKueryString = createAction<string>('SET ES KUERY STRING');
export const toggleIntegrationsPopover = createAction<PopoverState>(
'TOGGLE INTEGRATION POPOVER STATE'
);

View file

@ -5,5 +5,6 @@
*/
export * from './monitor';
export * from './overview_filters';
export * from './snapshot';
export * from './monitor_status';

View file

@ -0,0 +1,52 @@
/*
* 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 { ThrowReporter } from 'io-ts/lib/ThrowReporter';
import { isRight } from 'fp-ts/lib/Either';
import { GetOverviewFiltersPayload } from '../actions/overview_filters';
import { getApiPath, parameterizeValues } from '../../lib/helper';
import { OverviewFiltersType } from '../../../common/runtime_types';
type ApiRequest = GetOverviewFiltersPayload & {
basePath: string;
};
export const fetchOverviewFilters = async ({
basePath,
dateRangeStart,
dateRangeEnd,
search,
schemes,
locations,
ports,
tags,
}: ApiRequest) => {
const url = getApiPath(`/api/uptime/filters`, basePath);
const params = new URLSearchParams({
dateRangeStart,
dateRangeEnd,
});
if (search) {
params.append('search', search);
}
parameterizeValues(params, { schemes, locations, ports, tags });
const response = await fetch(`${url}?${params.toString()}`);
if (!response.ok) {
throw new Error(response.statusText);
}
const responseData = await response.json();
const decoded = OverviewFiltersType.decode(responseData);
ThrowReporter.report(decoded);
if (isRight(decoded)) {
return decoded.right;
}
throw new Error('`getOverviewFilters` response did not correspond to expected type');
};

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { call, put, select } from 'redux-saga/effects';
import { Action } from 'redux-actions';
import { getBasePath } from '../selectors';
/**
* Factory function for a fetch effect. It expects three action creators,
* one to call for a fetch, one to call for success, and one to handle failures.
* @param fetch creates a fetch action
* @param success creates a success action
* @param fail creates a failure action
* @template T the action type expected by the fetch action
* @template R the type that the API request should return on success
* @template S tye type of the success action
* @template F the type of the failure action
*/
export function fetchEffectFactory<T, R, S, F>(
fetch: (request: T) => Promise<R>,
success: (response: R) => Action<S>,
fail: (error: Error) => Action<F>
) {
return function*(action: Action<T>) {
try {
if (!action.payload) {
yield put(fail(new Error('Cannot fetch snapshot for undefined parameters.')));
return;
}
const {
payload: { ...params },
} = action;
const basePath = yield select(getBasePath);
const response = yield call(fetch, { ...params, basePath });
yield put(success(response));
} catch (error) {
yield put(fail(error));
}
};
}

View file

@ -6,11 +6,13 @@
import { fork } from 'redux-saga/effects';
import { fetchMonitorDetailsEffect } from './monitor';
import { fetchSnapshotCountSaga } from './snapshot';
import { fetchOverviewFiltersEffect } from './overview_filters';
import { fetchSnapshotCountEffect } from './snapshot';
import { fetchMonitorStatusEffect } from './monitor_status';
export function* rootEffect() {
yield fork(fetchMonitorDetailsEffect);
yield fork(fetchSnapshotCountSaga);
yield fork(fetchSnapshotCountEffect);
yield fork(fetchOverviewFiltersEffect);
yield fork(fetchMonitorStatusEffect);
}

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 { takeLatest } from 'redux-saga/effects';
import {
FETCH_OVERVIEW_FILTERS,
fetchOverviewFiltersFail,
fetchOverviewFiltersSuccess,
} from '../actions';
import { fetchOverviewFilters } from '../api';
import { fetchEffectFactory } from './fetch_effect';
export function* fetchOverviewFiltersEffect() {
yield takeLatest(
FETCH_OVERVIEW_FILTERS,
fetchEffectFactory(fetchOverviewFilters, fetchOverviewFiltersSuccess, fetchOverviewFiltersFail)
);
}

View file

@ -4,42 +4,18 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { call, put, takeLatest, select } from 'redux-saga/effects';
import { Action } from 'redux-actions';
import { takeLatest } from 'redux-saga/effects';
import {
FETCH_SNAPSHOT_COUNT,
GetSnapshotPayload,
fetchSnapshotCountFail,
fetchSnapshotCountSuccess,
} from '../actions';
import { fetchSnapshotCount } from '../api';
import { getBasePath } from '../selectors';
import { fetchEffectFactory } from './fetch_effect';
function* snapshotSaga(action: Action<GetSnapshotPayload>) {
try {
if (!action.payload) {
yield put(
fetchSnapshotCountFail(new Error('Cannot fetch snapshot for undefined parameters.'))
);
return;
}
const {
payload: { dateRangeStart, dateRangeEnd, filters, statusFilter },
} = action;
const basePath = yield select(getBasePath);
const response = yield call(fetchSnapshotCount, {
basePath,
dateRangeStart,
dateRangeEnd,
filters,
statusFilter,
});
yield put(fetchSnapshotCountSuccess(response));
} catch (error) {
yield put(fetchSnapshotCountFail(error));
}
}
export function* fetchSnapshotCountSaga() {
yield takeLatest(FETCH_SNAPSHOT_COUNT, snapshotSaga);
export function* fetchSnapshotCountEffect() {
yield takeLatest(
FETCH_SNAPSHOT_COUNT,
fetchEffectFactory(fetchSnapshotCount, fetchSnapshotCountSuccess, fetchSnapshotCountFail)
);
}

View file

@ -3,6 +3,7 @@
exports[`ui reducer adds integration popover status to state 1`] = `
Object {
"basePath": "",
"esKuery": "",
"integrationsPopoverOpen": Object {
"id": "popover-2",
"open": true,
@ -14,6 +15,7 @@ Object {
exports[`ui reducer sets the application's base path 1`] = `
Object {
"basePath": "yyz",
"esKuery": "",
"integrationsPopoverOpen": null,
"lastRefresh": 125,
}
@ -22,6 +24,7 @@ Object {
exports[`ui reducer updates the refresh value 1`] = `
Object {
"basePath": "abc",
"esKuery": "",
"integrationsPopoverOpen": null,
"lastRefresh": 125,
}

View file

@ -15,6 +15,7 @@ describe('ui reducer', () => {
uiReducer(
{
basePath: 'abc',
esKuery: '',
integrationsPopoverOpen: null,
lastRefresh: 125,
},
@ -32,6 +33,7 @@ describe('ui reducer', () => {
uiReducer(
{
basePath: '',
esKuery: '',
integrationsPopoverOpen: null,
lastRefresh: 125,
},
@ -46,6 +48,7 @@ describe('ui reducer', () => {
uiReducer(
{
basePath: 'abc',
esKuery: '',
integrationsPopoverOpen: null,
lastRefresh: 125,
},

View file

@ -6,12 +6,14 @@
import { combineReducers } from 'redux';
import { monitorReducer } from './monitor';
import { overviewFiltersReducer } from './overview_filters';
import { snapshotReducer } from './snapshot';
import { uiReducer } from './ui';
import { monitorStatusReducer } from './monitor_status';
export const rootReducer = combineReducers({
monitor: monitorReducer,
overviewFilters: overviewFiltersReducer,
snapshot: snapshotReducer,
ui: uiReducer,
monitorStatus: monitorStatusReducer,

View file

@ -0,0 +1,56 @@
/*
* 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 { OverviewFilters } from '../../../common/runtime_types';
import {
FETCH_OVERVIEW_FILTERS,
FETCH_OVERVIEW_FILTERS_FAIL,
FETCH_OVERVIEW_FILTERS_SUCCESS,
OverviewFiltersAction,
} from '../actions';
export interface OverviewFiltersState {
filters: OverviewFilters;
errors: Error[];
loading: boolean;
}
const initialState: OverviewFiltersState = {
filters: {
locations: [],
ports: [],
schemes: [],
tags: [],
},
errors: [],
loading: false,
};
export function overviewFiltersReducer(
state = initialState,
action: OverviewFiltersAction
): OverviewFiltersState {
switch (action.type) {
case FETCH_OVERVIEW_FILTERS:
return {
...state,
loading: true,
};
case FETCH_OVERVIEW_FILTERS_SUCCESS:
return {
...state,
filters: action.payload,
loading: false,
};
case FETCH_OVERVIEW_FILTERS_FAIL:
return {
...state,
errors: [...state.errors, action.payload],
};
default:
return state;
}
}

View file

@ -9,6 +9,7 @@ import {
PopoverState,
toggleIntegrationsPopover,
setBasePath,
setEsKueryString,
triggerAppRefresh,
UiPayload,
} from '../actions/ui';
@ -16,12 +17,14 @@ import {
export interface UiState {
integrationsPopoverOpen: PopoverState | null;
basePath: string;
esKuery: string;
lastRefresh: number;
}
const initialState: UiState = {
integrationsPopoverOpen: null,
basePath: '',
esKuery: '',
lastRefresh: Date.now(),
};
@ -41,6 +44,11 @@ export const uiReducer = handleActions<UiState, UiPayload>(
...state,
lastRefresh: action.payload as number,
}),
[String(setEsKueryString)]: (state, action: Action<string>) => ({
...state,
esKuery: action.payload as string,
}),
},
initialState
);

View file

@ -9,6 +9,16 @@ import { AppState } from '../../../state';
describe('state selectors', () => {
const state: AppState = {
overviewFilters: {
filters: {
locations: [],
ports: [],
schemes: [],
tags: [],
},
errors: [],
loading: false,
},
monitor: {
monitorDetailsList: [],
monitorLocationsList: new Map(),
@ -24,7 +34,12 @@ describe('state selectors', () => {
errors: [],
loading: false,
},
ui: { basePath: 'yyz', integrationsPopoverOpen: null, lastRefresh: 125 },
ui: {
basePath: 'yyz',
esKuery: '',
integrationsPopoverOpen: null,
lastRefresh: 125,
},
monitorStatus: {
status: null,
monitor: null,

View file

@ -7,7 +7,6 @@
import { UMGqlRange } from '../../../common/domain_types';
import { UMResolver } from '../../../common/graphql/resolver_types';
import {
FilterBar,
GetFilterBarQueryArgs,
GetMonitorChartsDataQueryArgs,
MonitorChart,
@ -46,7 +45,6 @@ export const createMonitorsResolvers: CreateUMGraphQLResolvers = (
Query: {
getSnapshotHistogram: UMGetSnapshotHistogram;
getMonitorChartsData: UMGetMonitorChartsResolver;
getFilterBar: UMGetFilterBarResolver;
};
} => ({
Query: {
@ -77,16 +75,5 @@ export const createMonitorsResolvers: CreateUMGraphQLResolvers = (
location,
});
},
async getFilterBar(
_resolver,
{ dateRangeStart, dateRangeEnd },
{ APICaller }
): Promise<FilterBar> {
return await libs.monitors.getFilterBar({
callES: APICaller,
dateRangeStart,
dateRangeEnd,
});
},
},
});

View file

@ -7,22 +7,6 @@
import gql from 'graphql-tag';
export const monitorsSchema = gql`
"The data used to enrich the filter bar."
type FilterBar {
"A series of monitor IDs in the heartbeat indices."
ids: [String!]
"The location values users have configured for the agents."
locations: [String!]
"The ports of the monitored endpoints."
ports: [Int!]
"The schemes used by the monitors."
schemes: [String!]
"The possible status values contained in the indices."
statuses: [String!]
"The list of URLs"
urls: [String!]
}
type HistogramDataPoint {
upCount: Int
downCount: Int
@ -136,19 +120,5 @@ export const monitorsSchema = gql`
dateRangeEnd: String!
location: String
): MonitorChart
"Fetch the most recent event data for a monitor ID, date range, location."
getLatestMonitors(
"The lower limit of the date range."
dateRangeStart: String!
"The upper limit of the date range."
dateRangeEnd: String!
"Optional: a specific monitor ID filter."
monitorId: String
"Optional: a specific instance location filter."
location: String
): [Ping!]!
getFilterBar(dateRangeStart: String!, dateRangeEnd: String!): FilterBar
}
`;

View file

@ -0,0 +1,27 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`extractFilterAggsResults extracts the bucket values of the expected filter fields 1`] = `
Object {
"locations": Array [
"us-east-2",
"fairbanks",
],
"ports": Array [
12349,
80,
5601,
8200,
9200,
9292,
],
"schemes": Array [
"http",
"tcp",
"icmp",
],
"tags": Array [
"api",
"dev",
],
}
`;

View file

@ -0,0 +1,171 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`generateFilterAggs generates expected aggregations object 1`] = `
Object {
"locations": Object {
"aggs": Object {
"term": Object {
"terms": Object {
"field": "observer.geo.name",
},
},
},
"filter": Object {
"bool": Object {
"should": Array [
Object {
"term": Object {
"url.port": "80",
},
},
Object {
"term": Object {
"url.port": "5601",
},
},
Object {
"term": Object {
"tags": "api",
},
},
Object {
"term": Object {
"monitor.type": "http",
},
},
Object {
"term": Object {
"monitor.type": "tcp",
},
},
],
},
},
},
"ports": Object {
"aggs": Object {
"term": Object {
"terms": Object {
"field": "url.port",
},
},
},
"filter": Object {
"bool": Object {
"should": Array [
Object {
"term": Object {
"observer.geo.name": "fairbanks",
},
},
Object {
"term": Object {
"observer.geo.name": "us-east-2",
},
},
Object {
"term": Object {
"tags": "api",
},
},
Object {
"term": Object {
"monitor.type": "http",
},
},
Object {
"term": Object {
"monitor.type": "tcp",
},
},
],
},
},
},
"schemes": Object {
"aggs": Object {
"term": Object {
"terms": Object {
"field": "monitor.type",
},
},
},
"filter": Object {
"bool": Object {
"should": Array [
Object {
"term": Object {
"observer.geo.name": "fairbanks",
},
},
Object {
"term": Object {
"observer.geo.name": "us-east-2",
},
},
Object {
"term": Object {
"url.port": "80",
},
},
Object {
"term": Object {
"url.port": "5601",
},
},
Object {
"term": Object {
"tags": "api",
},
},
],
},
},
},
"tags": Object {
"aggs": Object {
"term": Object {
"terms": Object {
"field": "tags",
},
},
},
"filter": Object {
"bool": Object {
"should": Array [
Object {
"term": Object {
"observer.geo.name": "fairbanks",
},
},
Object {
"term": Object {
"observer.geo.name": "us-east-2",
},
},
Object {
"term": Object {
"url.port": "80",
},
},
Object {
"term": Object {
"url.port": "5601",
},
},
Object {
"term": Object {
"monitor.type": "http",
},
},
Object {
"term": Object {
"monitor.type": "tcp",
},
},
],
},
},
},
}
`;

View file

@ -0,0 +1,110 @@
/*
* 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 { combineRangeWithFilters } from '../elasticsearch_monitors_adapter';
describe('combineRangeWithFilters', () => {
it('combines filters that have no filter clause', () => {
expect(
combineRangeWithFilters('now-15m', 'now', {
bool: { should: [{ match: { 'url.port': 80 } }], minimum_should_match: 1 },
})
).toEqual({
bool: {
should: [
{
match: {
'url.port': 80,
},
},
],
minimum_should_match: 1,
filter: [
{
range: {
'@timestamp': {
gte: 'now-15m',
lte: 'now',
},
},
},
],
},
});
});
it('combines query with filter object', () => {
expect(
combineRangeWithFilters('now-15m', 'now', {
bool: {
filter: { term: { field: 'monitor.id' } },
should: [{ match: { 'url.port': 80 } }],
minimum_should_match: 1,
},
})
).toEqual({
bool: {
filter: [
{
field: 'monitor.id',
},
{
range: {
'@timestamp': {
gte: 'now-15m',
lte: 'now',
},
},
},
],
should: [
{
match: {
'url.port': 80,
},
},
],
minimum_should_match: 1,
},
});
});
it('combines query with filter list', () => {
expect(
combineRangeWithFilters('now-15m', 'now', {
bool: {
filter: [{ field: 'monitor.id' }],
should: [{ match: { 'url.port': 80 } }],
minimum_should_match: 1,
},
})
).toEqual({
bool: {
filter: [
{
field: 'monitor.id',
},
{
range: {
'@timestamp': {
gte: 'now-15m',
lte: 'now',
},
},
},
],
should: [
{
match: {
'url.port': 80,
},
},
],
minimum_should_match: 1,
},
});
});
});

View file

@ -0,0 +1,68 @@
/*
* 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 { extractFilterAggsResults } from '../elasticsearch_monitors_adapter';
describe('extractFilterAggsResults', () => {
it('extracts the bucket values of the expected filter fields', () => {
expect(
extractFilterAggsResults(
{
locations: {
doc_count: 8098,
term: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{ key: 'us-east-2', doc_count: 4050 },
{ key: 'fairbanks', doc_count: 4048 },
],
},
},
schemes: {
doc_count: 8098,
term: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{ key: 'http', doc_count: 5055 },
{ key: 'tcp', doc_count: 2685 },
{ key: 'icmp', doc_count: 358 },
],
},
},
ports: {
doc_count: 8098,
term: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{ key: 12349, doc_count: 3571 },
{ key: 80, doc_count: 2985 },
{ key: 5601, doc_count: 358 },
{ key: 8200, doc_count: 358 },
{ key: 9200, doc_count: 358 },
{ key: 9292, doc_count: 110 },
],
},
},
tags: {
doc_count: 8098,
term: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{ key: 'api', doc_count: 8098 },
{ key: 'dev', doc_count: 8098 },
],
},
},
},
['locations', 'ports', 'schemes', 'tags']
)
).toMatchSnapshot();
});
});

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 { generateFilterAggs } from '../generate_filter_aggs';
describe('generateFilterAggs', () => {
it('generates expected aggregations object', () => {
expect(
generateFilterAggs(
[
{ aggName: 'locations', filterName: 'locations', field: 'observer.geo.name' },
{ aggName: 'ports', filterName: 'ports', field: 'url.port' },
{ aggName: 'schemes', filterName: 'schemes', field: 'monitor.type' },
{ aggName: 'tags', filterName: 'tags', field: 'tags' },
],
{
locations: ['fairbanks', 'us-east-2'],
ports: ['80', '5601'],
tags: ['api'],
schemes: ['http', 'tcp'],
}
)
).toMatchSnapshot();
});
});

View file

@ -6,7 +6,11 @@
import { MonitorChart } from '../../../../common/graphql/types';
import { UMElasticsearchQueryFn } from '../framework';
import { MonitorDetails, MonitorLocations } from '../../../../common/runtime_types';
import {
MonitorDetails,
MonitorLocations,
OverviewFilters,
} from '../../../../common/runtime_types';
export interface GetMonitorChartsDataParams {
/** @member monitorId ID value for the selected monitor */
@ -20,9 +24,15 @@ export interface GetMonitorChartsDataParams {
}
export interface GetFilterBarParams {
/** @param dateRangeStart timestamp bounds */
dateRangeStart: string;
/** @member dateRangeEnd timestamp bounds */
dateRangeEnd: string;
/** @member search this value should correspond to Elasticsearch DSL
* generated from KQL text the user provided.
*/
search?: Record<string, any>;
filterOptions: Record<string, string[] | number[]>;
}
export interface GetMonitorDetailsParams {
@ -48,10 +58,13 @@ export interface UMMonitorsAdapter {
* Fetches data used to populate monitor charts
*/
getMonitorChartsData: UMElasticsearchQueryFn<GetMonitorChartsDataParams, MonitorChart>;
getFilterBar: UMElasticsearchQueryFn<GetFilterBarParams, any>;
/**
* Fetch data for the monitor page title.
* Fetch options for the filter bar.
*/
getFilterBar: UMElasticsearchQueryFn<GetFilterBarParams, OverviewFilters>;
getMonitorDetails: UMElasticsearchQueryFn<GetMonitorDetailsParams, MonitorDetails>;
getMonitorLocations: UMElasticsearchQueryFn<GetMonitorLocationsParams, MonitorLocations>;
}

View file

@ -10,6 +10,52 @@ import { MonitorChart, LocationDurationLine } from '../../../../common/graphql/t
import { getHistogramIntervalFormatted } from '../../helper';
import { MonitorError, MonitorLocation } from '../../../../common/runtime_types';
import { UMMonitorsAdapter } from './adapter_types';
import { generateFilterAggs } from './generate_filter_aggs';
import { OverviewFilters } from '../../../../common/runtime_types';
export const combineRangeWithFilters = (
dateRangeStart: string,
dateRangeEnd: string,
filters?: Record<string, any>
) => {
const range = {
range: {
'@timestamp': {
gte: dateRangeStart,
lte: dateRangeEnd,
},
},
};
if (!filters) return range;
const clientFiltersList = Array.isArray(filters?.bool?.filter ?? {})
? // i.e. {"bool":{"filter":{ ...some nested filter objects }}}
filters.bool.filter
: // i.e. {"bool":{"filter":[ ...some listed filter objects ]}}
Object.keys(filters?.bool?.filter ?? {}).map(key => ({
...filters?.bool?.filter?.[key],
}));
filters.bool.filter = [...clientFiltersList, range];
return filters;
};
type SupportedFields = 'locations' | 'ports' | 'schemes' | 'tags';
export const extractFilterAggsResults = (
responseAggregations: Record<string, any>,
keys: SupportedFields[]
): OverviewFilters => {
const values: OverviewFilters = {
locations: [],
ports: [],
schemes: [],
tags: [],
};
keys.forEach(key => {
const buckets = responseAggregations[key]?.term?.buckets ?? [];
values[key] = buckets.map((item: { key: string | number }) => item.key);
});
return values;
};
const formatStatusBuckets = (time: any, buckets: any, docCount: any) => {
let up = null;
@ -160,39 +206,30 @@ export const elasticsearchMonitorsAdapter: UMMonitorsAdapter = {
return monitorChartsData;
},
getFilterBar: async ({ callES, dateRangeStart, dateRangeEnd }) => {
const fields: { [key: string]: string } = {
ids: 'monitor.id',
schemes: 'monitor.type',
urls: 'url.full',
ports: 'url.port',
locations: 'observer.geo.name',
};
getFilterBar: async ({ callES, dateRangeStart, dateRangeEnd, search, filterOptions }) => {
const aggs = generateFilterAggs(
[
{ aggName: 'locations', filterName: 'locations', field: 'observer.geo.name' },
{ aggName: 'ports', filterName: 'ports', field: 'url.port' },
{ aggName: 'schemes', filterName: 'schemes', field: 'monitor.type' },
{ aggName: 'tags', filterName: 'tags', field: 'tags' },
],
filterOptions
);
const filters = combineRangeWithFilters(dateRangeStart, dateRangeEnd, search);
const params = {
index: INDEX_NAMES.HEARTBEAT,
body: {
size: 0,
query: {
range: {
'@timestamp': {
gte: dateRangeStart,
lte: dateRangeEnd,
},
},
...filters,
},
aggs: Object.values(fields).reduce((acc: { [key: string]: any }, field) => {
acc[field] = { terms: { field, size: 20 } };
return acc;
}, {}),
aggs,
},
};
const { aggregations } = await callES('search', params);
return Object.keys(fields).reduce((acc: { [key: string]: any[] }, field) => {
const bucketName = fields[field];
acc[field] = aggregations[bucketName].buckets.map((b: { key: string | number }) => b.key);
return acc;
}, {});
const { aggregations } = await callES('search', params);
return extractFilterAggsResults(aggregations, ['tags', 'locations', 'ports', 'schemes']);
},
getMonitorDetails: async ({ callES, monitorId, dateStart, dateEnd }) => {

View file

@ -0,0 +1,58 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
interface AggDefinition {
aggName: string;
filterName: string;
field: string;
}
export const FIELD_MAPPINGS: Record<string, string> = {
schemes: 'monitor.type',
ports: 'url.port',
locations: 'observer.geo.name',
tags: 'tags',
};
const getFilterAggConditions = (filterTerms: Record<string, any[]>, except: string) => {
const filters: any[] = [];
Object.keys(filterTerms).forEach((key: string) => {
if (key === except && FIELD_MAPPINGS[key]) return;
filters.push(
...filterTerms[key].map(value => ({
term: {
[FIELD_MAPPINGS[key]]: value,
},
}))
);
});
return filters;
};
export const generateFilterAggs = (
aggDefinitions: AggDefinition[],
filterOptions: Record<string, string[] | number[]>
) =>
aggDefinitions
.map(({ aggName, filterName, field }) => ({
[aggName]: {
filter: {
bool: {
should: [...getFilterAggConditions(filterOptions, filterName)],
},
},
aggs: {
term: {
terms: {
field,
},
},
},
},
}))
.reduce((parent: Record<string, any>, agg: any) => ({ ...parent, ...agg }), {});

View file

@ -9,3 +9,4 @@ export { getHistogramInterval } from './get_histogram_interval';
export { getHistogramIntervalFormatted } from './get_histogram_interval_formatted';
export { parseFilterQuery } from './parse_filter_query';
export { assertCloseTo } from './assert_close_to';
export { objectValuesToArrays } from './object_to_array';

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
/**
* Converts the top-level fields of an object from an object to an array.
* @param record the obect to map
* @type T the type of the objects/arrays that will be mapped
*/
export const objectValuesToArrays = <T>(record: Record<string, T | T[]>): Record<string, T[]> => {
const obj: Record<string, T[]> = {};
Object.keys(record).forEach((key: string) => {
const value = record[key];
obj[key] = value ? (Array.isArray(value) ? value : [value]) : [];
});
return obj;
};

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { createGetOverviewFilters } from './overview_filters';
import { createGetAllRoute } from './pings';
import { createGetIndexPatternRoute } from './index_pattern';
import { createLogMonitorPageRoute, createLogOverviewPageRoute } from './telemetry';
@ -20,6 +21,7 @@ export * from './types';
export { createRouteWithAuth } from './create_route_with_auth';
export { uptimeRouteWrapper } from './uptime_route_wrapper';
export const restApiRoutes: UMRestApiRouteFactory[] = [
createGetOverviewFilters,
createGetAllRoute,
createGetIndexPatternRoute,
createGetMonitorRoute,

View file

@ -0,0 +1,61 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { schema } from '@kbn/config-schema';
import { UMServerLibs } from '../../lib/lib';
import { UMRestApiRouteFactory } from '../types';
import { objectValuesToArrays } from '../../lib/helper';
const arrayOrStringType = schema.maybe(
schema.oneOf([schema.string(), schema.arrayOf(schema.string())])
);
export const createGetOverviewFilters: UMRestApiRouteFactory = (libs: UMServerLibs) => ({
method: 'GET',
path: '/api/uptime/filters',
validate: {
query: schema.object({
dateRangeStart: schema.string(),
dateRangeEnd: schema.string(),
search: schema.maybe(schema.string()),
locations: arrayOrStringType,
schemes: arrayOrStringType,
ports: arrayOrStringType,
tags: arrayOrStringType,
}),
},
options: {
tags: ['access:uptime'],
},
handler: async ({ callES }, _context, request, response) => {
const { dateRangeStart, dateRangeEnd, locations, schemes, search, ports, tags } = request.query;
let parsedSearch: Record<string, any> | undefined;
if (search) {
try {
parsedSearch = JSON.parse(search);
} catch (e) {
return response.badRequest({ body: { message: e.message } });
}
}
const filtersResponse = await libs.monitors.getFilterBar({
callES,
dateRangeStart,
dateRangeEnd,
search: parsedSearch,
filterOptions: objectValuesToArrays<string>({
locations,
ports,
schemes,
tags,
}),
});
return response.ok({ body: { ...filtersResponse } });
},
});

View file

@ -0,0 +1,7 @@
/*
* 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 { createGetOverviewFilters } from './get_overview_filters';

View file

@ -1,35 +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 { expectFixtureEql } from './helpers/expect_fixture_eql';
import { filterBarQueryString } from '../../../../../legacy/plugins/uptime/public/queries';
export default function({ getService }) {
describe('filterBar query', () => {
before('load heartbeat data', () => getService('esArchiver').load('uptime/full_heartbeat'));
after('unload heartbeat index', () => getService('esArchiver').unload('uptime/full_heartbeat'));
const supertest = getService('supertest');
it('returns the expected filters', async () => {
const getFilterBarQuery = {
operationName: 'FilterBar',
query: filterBarQueryString,
variables: {
dateRangeStart: '2019-01-28T17:40:08.078Z',
dateRangeEnd: '2025-01-28T19:00:16.078Z',
},
};
const {
body: { data },
} = await supertest
.post('/api/uptime/graphql')
.set('kbn-xsrf', 'foo')
.send({ ...getFilterBarQuery });
expectFixtureEql(data, 'filter_list');
});
});
}

View file

@ -1,40 +0,0 @@
{
"filterBar": {
"ids": [
"0000-intermittent",
"0001-up",
"0002-up",
"0003-up",
"0004-up",
"0005-up",
"0006-up",
"0007-up",
"0008-up",
"0009-up",
"0010-down",
"0011-up",
"0012-up",
"0013-up",
"0014-up",
"0015-intermittent",
"0016-up",
"0017-up",
"0018-up",
"0019-up"
],
"locations": [
"mpls"
],
"ports": [
5678
],
"schemes": [
"http"
],
"urls": [
"http://localhost:5678/pattern?r=200x1",
"http://localhost:5678/pattern?r=200x5,500x1",
"http://localhost:5678/pattern?r=400x1"
]
}
}

View file

@ -0,0 +1,12 @@
{
"schemes": [
"http"
],
"ports": [
5678
],
"locations": [
"mpls"
],
"tags": []
}

View file

@ -11,7 +11,6 @@ export default function({ loadTestFile }) {
// verifying the pre-loaded documents are returned in a way that
// matches the snapshots contained in './fixtures'
loadTestFile(require.resolve('./doc_count'));
loadTestFile(require.resolve('./filter_bar'));
loadTestFile(require.resolve('./monitor_charts'));
loadTestFile(require.resolve('./monitor_states'));
loadTestFile(require.resolve('./ping_list'));

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { expectFixtureEql } from '../graphql/helpers/expect_fixture_eql';
import { FtrProviderContext } from '../../../ftr_provider_context';
const getApiPath = (dateRangeStart: string, dateRangeEnd: string, filters?: string) =>
`/api/uptime/filters?dateRangeStart=${dateRangeStart}&dateRangeEnd=${dateRangeEnd}${
filters ? `&filters=${filters}` : ''
}`;
export default function({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
describe('filter group endpoint', () => {
const dateRangeStart = '2019-01-28T17:40:08.078Z';
const dateRangeEnd = '2025-01-28T19:00:16.078Z';
it('returns expected filters', async () => {
const resp = await supertest.get(getApiPath(dateRangeStart, dateRangeEnd));
expectFixtureEql(resp.body, 'filters');
});
});
}

View file

@ -29,6 +29,27 @@ export default ({ getPageObjects }: FtrProviderContext) => {
await pageObjects.uptime.pageHasExpectedIds(['0000-intermittent']);
});
it('applies filters for multiple fields', async () => {
await pageObjects.uptime.goToUptimePageAndSetDateRange(DEFAULT_DATE_START, DEFAULT_DATE_END);
await pageObjects.uptime.selectFilterItems({
location: ['mpls'],
port: ['5678'],
scheme: ['http'],
});
await pageObjects.uptime.pageHasExpectedIds([
'0000-intermittent',
'0001-up',
'0002-up',
'0003-up',
'0004-up',
'0005-up',
'0006-up',
'0007-up',
'0008-up',
'0009-up',
]);
});
it('pagination is cleared when filter criteria changes', async () => {
await pageObjects.uptime.goToUptimePageAndSetDateRange(DEFAULT_DATE_START, DEFAULT_DATE_END);
await pageObjects.uptime.changePage('next');

View file

@ -71,6 +71,17 @@ export function UptimePageProvider({ getPageObjects, getService }: FtrProviderCo
}
}
public async selectFilterItems(filters: Record<string, string[]>) {
for (const key in filters) {
if (filters.hasOwnProperty(key)) {
const values = filters[key];
for (let i = 0; i < values.length; i++) {
await uptimeService.selectFilterItem(key, values[i]);
}
}
}
}
public async getSnapshotCount() {
return await uptimeService.getSnapshotCount();
}

View file

@ -49,6 +49,15 @@ export function UptimeProvider({ getService }: FtrProviderContext) {
async setStatusFilterDown() {
await testSubjects.click('xpack.uptime.filterBar.filterStatusDown');
},
async selectFilterItem(filterType: string, option: string) {
const popoverId = `filter-popover_${filterType}`;
const optionId = `filter-popover-item_${option}`;
await testSubjects.existOrFail(popoverId);
await testSubjects.click(popoverId);
await testSubjects.existOrFail(optionId);
await testSubjects.click(optionId);
await testSubjects.click(popoverId);
},
async getSnapshotCount() {
return {
up: await testSubjects.getVisibleText('xpack.uptime.snapshot.donutChart.up'),