Skip to content

Commit

Permalink
Implement first version of new aggregation controls, including suppor…
Browse files Browse the repository at this point in the history
…t for metric configuration. (#10281)

* Creating basic `AggregationWizard` component.

* Creating basic components for aggregation attribute configuration.

* Rename aggregation attribute to aggregation action.

* Improve aggregation actions structure and expandability.

* Fixing typo.

* Fixing headline font size and `AggregationWizard` props.

* Creating test for `AggregationActionSelect`.

* Creating test for `ActionConfigurationContainer`.

* Creating test for `AggregationWizard`.

* Clearing `AggregationActionSelect` value after selection an action.

* Renaming aggregation action properties label to title and value to key.

* Reverting `AggregationWizard` usage in views bindings.

* Fixing linter warnings.

* List aggregation action in `AggregationActionSelect` if it can be configured multiple times.

* Renaming aggregation action to aggregation element.

* Do not add new section for an already configured aggregation action.

* Removing no longer needed `createDefault` series method.

* Changing order of configured aggregation elements.

* Always display configured aggregation elements in correct order.

* Updating `AggregationElementSelect.test`.`

* Creating `WidgetConfigForm`.

* Moving `SearchBarForm` from `EditWidgetFrame` to `WidgetQueryControls`.

* Rename `MetricConfiguration` to `MetricsConfiguration`.

* Creating basic metric form with function and field select.

* Enable widget configuration update for metrics.

* Providing correct initial values for metrics configuration form.

* Removing no longer needed aggregation wizard state by using `WidgetConfigForm` only.

* Updating type definition of `SeriesConfig` value and constructor.

* Splitting up `AggregationWizard` in multiple components.

* Migrating `AggregationFunctionsStore` to ts.

* Importing aggregation functions for metrics configuration directly from `AggregationFunctionsStore`.

* Reverting `Series` and `SeriesConfiguration` constructor type definition changes.

* Fixing type imports

* Only display aggregation element configuraion container if form has related values.

* Validating metric functions, triggering manual validation on initial mount.

* Validate form on change/on mount.

* Switching from field-level to form-level validation.

* Handling percentile parameter for `percentile` function.

* Do not include metrics errors in validation result if empty.

* Include name field for metric.

* Include placeholder for name field.

* Fixing vertical scrolling of aggregation elements column in aggregation wizard.

(cherry picked from commit 0efd3b2)

* Styling fixes

* Removing section headlines to save vertical space.

* Fixing horizontal scrolling of element configuration containers by adjusting bootstrap elements padding.

The negative row / form-group margin caused the horizontal scroll bar.

* Creating one file for each aggregation element.

* Fixing types.

* Fixing types.

* Replacing aggregation element properties `isConfigured` and `multipleUse` with `allowCreate`.

* Defining theme color for element separator.

* Reducing element container padding.

* Fixing `allowCreate` usage.

* Providing form values as a prop for `AggregationElementSelect` to simplify testing.

* Updating `AggregationElementSelect.test`.

* Udpating `MetricsConfiguration.test`.

* Adding missing key in `MetricsConfiguration`.

* Extend PropType definition for common `Select` value with number.

* Extending `AgggregationWizard.test` with tests for metrics element.

* Fixing linter warnings

* Updating `WidgetQueryControls.test` regarding `SearchBarForm` addition.

* Implement `ElementConfigurationSection` for `MetricsConfiguration` which separates each section of the metrics element configuration.

* Test configuration of metric with multiple functions.

* Creating separate file for metric element tests.

* Fixing linter warnings.

* Reimplementing type definition for `Widget.tsx` config prop.

* Fixing import path.

Co-authored-by: Dennis Oelkers <dennis@graylog.com>
  • Loading branch information
linuspahl and dennisoelkers committed Mar 22, 2021
1 parent 39e080a commit 9f7c4e4
Show file tree
Hide file tree
Showing 32 changed files with 1,657 additions and 140 deletions.
2 changes: 2 additions & 0 deletions graylog2-web-interface/src/components/common/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ type Props = {
optionRenderer?: (option: Option) => React.ReactElement,
options: Array<Option>,
placeholder: string,
ref?: React.Ref<React.ComponentType>,
size?: 'normal' | 'small',
theme: DefaultTheme,
value?: Object | Array<Object> | null | undefined,
Expand Down Expand Up @@ -293,6 +294,7 @@ class Select extends React.Component<Props, State> {
*/
value: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
PropTypes.object,
PropTypes.arrayOf(PropTypes.object),
]),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,15 @@
import * as React from 'react';
import { render, fireEvent, waitFor } from 'wrappedTestingLibrary';
import WrappingContainer from 'WrappingContainer';
import MockStore from 'helpers/mocking/StoreMock';

import { GlobalOverrideActions } from 'views/stores/GlobalOverrideStore';
import SearchActions from 'views/actions/SearchActions';
import { DEFAULT_TIMERANGE } from 'views/Constants';
import GlobalOverride from 'views/logic/search/GlobalOverride';
import Widget from 'views/logic/widgets/Widget';

import WidgetQueryControls from './WidgetQueryControls';
import SearchBarForm from './searchbar/SearchBarForm';
import WidgetContext from './contexts/WidgetContext';

jest.mock('views/stores/WidgetStore', () => ({
WidgetActions: {
Expand All @@ -42,7 +43,15 @@ jest.mock('views/actions/SearchActions', () => ({
refresh: jest.fn(() => Promise.resolve()),
}));

jest.mock('stores/connect', () => (x) => x);
jest.mock('stores/connect', () => {
const originalModule = jest.requireActual('stores/connect');

return {
__esModule: true,
...originalModule,
default: (x) => x,
};
});

jest.mock('moment', () => {
const mockMoment = jest.requireActual('moment');
Expand All @@ -52,6 +61,15 @@ jest.mock('moment', () => {

jest.mock('views/components/searchbar/QueryInput', () => () => <span>Query Input</span>);

jest.mock('views/stores/SearchConfigStore', () => ({
SearchConfigStore: MockStore(['getInitialState', () => ({
searchesClusterConfig: {
relative_timerange_options: { P1D: 'Search in last day', PT0S: 'Search in all messages' },
query_time_range_limit: 'PT0S',
},
})]),
}));

describe('WidgetQueryControls', () => {
beforeEach(() => { jest.clearAllMocks(); });

Expand All @@ -67,15 +85,17 @@ describe('WidgetQueryControls', () => {

const emptyGlobalOverride = {};
const globalOverrideWithQuery = { query: { type: 'elasticsearch', query_string: 'source:foo' } };
const widget = Widget.builder()
.id('deadbeef')
.type('dummy')
.config({})
.build();

const Wrapper = ({ children }: { children: React.ReactNode }) => (
<WrappingContainer>
<SearchBarForm initialValues={{ timerange: DEFAULT_TIMERANGE, queryString: '', streams: [] }}
limitDuration={0}
onSubmit={() => {}}
validateOnMount={false}>
<WidgetContext.Provider value={widget}>
{children}
</SearchBarForm>
</WidgetContext.Provider>
</WrappingContainer>
);

Expand Down
136 changes: 84 additions & 52 deletions graylog2-web-interface/src/views/components/WidgetQueryControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,16 @@
*/
import * as React from 'react';
import styled from 'styled-components';
import { Field, useFormikContext } from 'formik';

import connect from 'stores/connect';
import { Field } from 'formik';
import moment from 'moment';
import { useContext } from 'react';

import connect, { useStore } from 'stores/connect';
import { createElasticsearchQueryString } from 'views/logic/queries/Query';
import Widget from 'views/logic/widgets/Widget';
import { WidgetActions } from 'views/stores/WidgetStore';
import { DEFAULT_TIMERANGE } from 'views/Constants';
import { SearchConfigStore } from 'views/stores/SearchConfigStore';
import { Col, Row } from 'components/graylog';
import { Icon } from 'components/common';
import DocumentationLink from 'components/support/DocumentationLink';
Expand All @@ -29,12 +36,13 @@ import { StreamsStore } from 'views/stores/StreamsStore';
import { GlobalOverrideActions, GlobalOverrideStore } from 'views/stores/GlobalOverrideStore';
import GlobalOverride from 'views/logic/search/GlobalOverride';
import SearchActions from 'views/actions/SearchActions';
import type { SearchBarFormValues } from 'views/Constants';
import WidgetContext from 'views/components/contexts/WidgetContext';

import TimeRangeInput from './searchbar/TimeRangeInput';
import StreamsFilter from './searchbar/StreamsFilter';
import SearchButton from './searchbar/SearchButton';
import QueryInput from './searchbar/AsyncQueryInput';
import SearchBarForm from './searchbar/SearchBarForm';

type Props = {
availableStreams: Array<any>,
Expand Down Expand Up @@ -68,6 +76,17 @@ const ResetFilterButton = styled(Button)`

const _resetOverride = () => GlobalOverrideActions.reset().then(SearchActions.refresh);

const _onSubmit = (values, widget: Widget) => {
const { timerange, streams, queryString } = values;
const newWidget = widget.toBuilder()
.timerange(timerange)
.query(createElasticsearchQueryString(queryString))
.streams(streams)
.build();

return WidgetActions.update(widget.id, newWidget);
};

const ResetOverrideHint = () => (
<CenteredBox>
These controls are disabled, because a filter is applied to all widgets.{' '}
Expand All @@ -76,62 +95,75 @@ const ResetOverrideHint = () => (
);

const WidgetQueryControls = ({ availableStreams, globalOverride }: Props) => {
const widget = useContext(WidgetContext);
const config = useStore(SearchConfigStore, ({ searchesClusterConfig }) => searchesClusterConfig);
const limitDuration = moment.duration(config?.query_time_range_limit).asSeconds() ?? 0;
const { streams } = widget;
const timerange = widget.timerange ?? DEFAULT_TIMERANGE;
const { query_string: queryString } = widget.query ?? createElasticsearchQueryString('');

const isGloballyOverridden: boolean = globalOverride !== undefined
&& globalOverride !== null
&& (globalOverride.query !== undefined || globalOverride.timerange !== undefined);
const Wrapper = isGloballyOverridden ? BlurredWrapper : React.Fragment;
const { dirty, isValid, isSubmitting, handleSubmit, values, setFieldValue } = useFormikContext<SearchBarFormValues>();

return (
<>
{isGloballyOverridden && <ResetOverrideHint />}
<Wrapper>
<TopRow>
<Col md={4}>
<TimeRangeInput disabled={isGloballyOverridden}
onChange={(nextTimeRange) => setFieldValue('timerange', nextTimeRange)}
value={values?.timerange}
hasErrorOnMount={!isValid} />
</Col>

<Col md={8}>
<Field name="streams">
{({ field: { name, value, onChange } }) => (
<StreamsFilter value={value}
disabled={isGloballyOverridden}
streams={availableStreams}
onChange={(newStreams) => onChange({ target: { value: newStreams, name } })} />
)}
</Field>
</Col>
</TopRow>

<Row className="no-bm">
<Col md={12}>
<div className="pull-right search-help">
<DocumentationLink page={DocsHelper.PAGES.SEARCH_QUERY_LANGUAGE}
title="Search query syntax documentation"
text={<Icon name="lightbulb" type="regular" />} />
</div>
<SearchButton disabled={isGloballyOverridden || isSubmitting || !isValid}
dirty={dirty} />

<Field name="queryString">
{({ field: { name, value, onChange } }) => (
<QueryInput value={value}
disabled={isGloballyOverridden}
placeholder={'Type your search query here and press enter. E.g.: ("not found" AND http) OR http_response_code:[400 TO 404]'}
onChange={(newQuery) => {
onChange({ target: { value: newQuery, name } });

return Promise.resolve(newQuery);
}}
onExecute={handleSubmit as () => void} />
)}
</Field>
</Col>
</Row>
</Wrapper>
<SearchBarForm initialValues={{ timerange, streams, queryString }}
limitDuration={limitDuration}
onSubmit={(values) => _onSubmit(values, widget)}
validateOnMount={false}>
{({ dirty, isValid, isSubmitting, handleSubmit, values, setFieldValue }) => (
<Wrapper>
<TopRow>
<Col md={4}>
<TimeRangeInput disabled={isGloballyOverridden}
onChange={(nextTimeRange) => setFieldValue('timerange', nextTimeRange)}
value={values?.timerange}
hasErrorOnMount={!isValid} />
</Col>

<Col md={8}>
<Field name="streams">
{({ field: { name, value, onChange } }) => (
<StreamsFilter value={value}
disabled={isGloballyOverridden}
streams={availableStreams}
onChange={(newStreams) => onChange({ target: { value: newStreams, name } })} />
)}
</Field>
</Col>
</TopRow>

<Row className="no-bm">
<Col md={12}>
<div className="pull-right search-help">
<DocumentationLink page={DocsHelper.PAGES.SEARCH_QUERY_LANGUAGE}
title="Search query syntax documentation"
text={<Icon name="lightbulb" type="regular" />} />
</div>
<SearchButton disabled={isGloballyOverridden || isSubmitting || !isValid}
dirty={dirty} />

<Field name="queryString">
{({ field: { name, value, onChange } }) => (
<QueryInput value={value}
disabled={isGloballyOverridden}
placeholder={'Type your search query here and press enter. E.g.: ("not found" AND http) OR http_response_code:[400 TO 404]'}
onChange={(newQuery) => {
onChange({ target: { value: newQuery, name } });

return Promise.resolve(newQuery);
}}
onExecute={handleSubmit as () => void} />
)}
</Field>
</Col>
</Row>
</Wrapper>
)}
</SearchBarForm>
</>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* Copyright (C) 2020 Graylog, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the Server Side Public License, version 1,
* as published by MongoDB, Inc.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* Server Side Public License for more details.
*
* You should have received a copy of the Server Side Public License
* along with this program. If not, see
* <http://www.mongodb.com/licensing/server-side-public-license>.
*/
import React from 'react';
import { render, screen } from 'wrappedTestingLibrary';
import selectEvent from 'react-select-event';

import AggregationElementSelect from './AggregationElementSelect';
import type { AggregationElement } from './aggregationElements/AggregationElementType';

const aggregationElements: Array<AggregationElement> = [
{
title: 'Metric',
key: 'metric',
order: 1,
allowCreate: () => true,
onCreate: () => {},
component: () => <div />,
},
{
title: 'Sort',
key: 'sort',
order: 1,
allowCreate: () => false,
onCreate: () => {},
component: () => <div />,
},
];

describe('AggregationElementSelect', () => {
it('should select an aggregation element', async () => {
const onElementCreateMock = jest.fn();

render(<AggregationElementSelect onElementCreate={onElementCreateMock}
formValues={{ metrics: [] }}
aggregationElements={aggregationElements} />);

const aggregationElementSelect = screen.getByLabelText('Select an element to add ...');

await selectEvent.openMenu(aggregationElementSelect);
await selectEvent.select(aggregationElementSelect, 'Metric');

expect(onElementCreateMock).toHaveBeenCalledTimes(1);
expect(onElementCreateMock).toHaveBeenCalledWith('metric');
});

it('should not list already configured aggregation elements which can not be configured multiple times', async () => {
render(<AggregationElementSelect onElementCreate={() => {}}
formValues={{ metrics: [] }}
aggregationElements={aggregationElements} />);

const aggregationElementSelect = screen.getByLabelText('Select an element to add ...');

await selectEvent.openMenu(aggregationElementSelect);

expect(screen.queryByText('Sort')).not.toBeInTheDocument();
expect(screen.getByText('Metric')).toBeInTheDocument();
});
});

0 comments on commit 9f7c4e4

Please sign in to comment.