Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement first version of new aggregation controls, including support for metric configuration. #10281

Merged
merged 66 commits into from
Mar 22, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
ea3d5b8
Creating basic `AggregationWizard` component.
linuspahl Mar 10, 2021
e316666
Creating basic components for aggregation attribute configuration.
linuspahl Mar 10, 2021
40513a8
Rename aggregation attribute to aggregation action.
linuspahl Mar 11, 2021
a582e48
Improve aggregation actions structure and expandability.
linuspahl Mar 11, 2021
4631576
Fixing typo.
linuspahl Mar 11, 2021
cdcaddd
Fixing headline font size and `AggregationWizard` props.
linuspahl Mar 11, 2021
3737a6a
Creating test for `AggregationActionSelect`.
linuspahl Mar 11, 2021
2bbc462
Creating test for `ActionConfigurationContainer`.
linuspahl Mar 11, 2021
5d55afb
Creating test for `AggregationWizard`.
linuspahl Mar 11, 2021
d4faddb
Clearing `AggregationActionSelect` value after selection an action.
linuspahl Mar 11, 2021
4c45202
Renaming aggregation action properties label to title and value to key.
linuspahl Mar 11, 2021
d9bdc61
Reverting `AggregationWizard` usage in views bindings.
linuspahl Mar 11, 2021
b69e575
Fixing linter warnings.
linuspahl Mar 11, 2021
f6e4b42
List aggregation action in `AggregationActionSelect` if it can be con…
linuspahl Mar 15, 2021
4c2d08e
Renaming aggregation action to aggregation element.
linuspahl Mar 15, 2021
9ab826a
Do not add new section for an already configured aggregation action.
linuspahl Mar 15, 2021
9b7baa5
Removing no longer needed `createDefault` series method.
linuspahl Mar 15, 2021
465e30a
Changing order of configured aggregation elements.
linuspahl Mar 15, 2021
41fa08f
Always display configured aggregation elements in correct order.
linuspahl Mar 15, 2021
4ac3736
Updating `AggregationElementSelect.test`.`
linuspahl Mar 15, 2021
0a1f8bf
Creating `WidgetConfigForm`.
linuspahl Mar 15, 2021
a3eee78
Moving `SearchBarForm` from `EditWidgetFrame` to `WidgetQueryControls`.
linuspahl Mar 15, 2021
602f72e
Rename `MetricConfiguration` to `MetricsConfiguration`.
linuspahl Mar 15, 2021
4b7a698
Creating basic metric form with function and field select.
linuspahl Mar 16, 2021
bc05ecf
Enable widget configuration update for metrics.
linuspahl Mar 16, 2021
125588f
Providing correct initial values for metrics configuration form.
linuspahl Mar 16, 2021
d4feb45
Removing no longer needed aggregation wizard state by using `WidgetCo…
linuspahl Mar 16, 2021
46ad7dc
Updating type definition of `SeriesConfig` value and constructor.
linuspahl Mar 16, 2021
7e90b1a
Splitting up `AggregationWizard` in multiple components.
linuspahl Mar 17, 2021
d2c9735
Migrating `AggregationFunctionsStore` to ts.
linuspahl Mar 17, 2021
400b516
Importing aggregation functions for metrics configuration directly fr…
linuspahl Mar 17, 2021
2ce4f05
Reverting `Series` and `SeriesConfiguration` constructor type definit…
linuspahl Mar 17, 2021
16f4669
Fixing type imports
linuspahl Mar 17, 2021
0af7525
Only display aggregation element configuraion container if form has r…
linuspahl Mar 17, 2021
67f9b45
Validating metric functions, triggering manual validation on initial …
dennisoelkers Mar 17, 2021
dae34fe
Validate form on change/on mount.
dennisoelkers Mar 17, 2021
1e59e28
Switching from field-level to form-level validation.
dennisoelkers Mar 17, 2021
da59c90
Handling percentile parameter for `percentile` function.
dennisoelkers Mar 17, 2021
198b5e6
Do not include metrics errors in validation result if empty.
dennisoelkers Mar 17, 2021
338731b
Include name field for metric.
dennisoelkers Mar 17, 2021
dd51122
Include placeholder for name field.
dennisoelkers Mar 17, 2021
08e315d
Fixing vertical scrolling of aggregation elements column in aggregati…
linuspahl Mar 16, 2021
329f1a6
Styling fixes
dennisoelkers Mar 17, 2021
d1c8603
Removing section headlines to save vertical space.
dennisoelkers Mar 17, 2021
bf32d7f
Fixing horizontal scrolling of element configuration containers by ad…
linuspahl Mar 17, 2021
eb688fc
Creating one file for each aggregation element.
linuspahl Mar 17, 2021
dc11e4e
Fixing types.
dennisoelkers Mar 17, 2021
0620a7a
Fixing types.
dennisoelkers Mar 17, 2021
3494885
Replacing aggregation element properties `isConfigured` and `multiple…
linuspahl Mar 18, 2021
6a75259
Defining theme color for element separator.
linuspahl Mar 18, 2021
acd6127
Reducing element container padding.
linuspahl Mar 18, 2021
cfa8f71
Fixing `allowCreate` usage.
linuspahl Mar 18, 2021
3217cef
Providing form values as a prop for `AggregationElementSelect` to sim…
linuspahl Mar 18, 2021
28ce5d0
Updating `AggregationElementSelect.test`.
linuspahl Mar 18, 2021
1bf0f9c
Udpating `MetricsConfiguration.test`.
linuspahl Mar 18, 2021
797fd72
Adding missing key in `MetricsConfiguration`.
linuspahl Mar 18, 2021
a3cfe9f
Extend PropType definition for common `Select` value with number.
linuspahl Mar 18, 2021
bf3eb35
Extending `AgggregationWizard.test` with tests for metrics element.
linuspahl Mar 18, 2021
2c776ed
Fixing linter warnings
linuspahl Mar 18, 2021
50ac44a
Updating `WidgetQueryControls.test` regarding `SearchBarForm` addition.
linuspahl Mar 18, 2021
f4745cb
Implement `ElementConfigurationSection` for `MetricsConfiguration` wh…
linuspahl Mar 18, 2021
4444aa1
Test configuration of metric with multiple functions.
linuspahl Mar 18, 2021
d044dce
Creating separate file for metric element tests.
linuspahl Mar 18, 2021
e0268a6
Fixing linter warnings.
linuspahl Mar 18, 2021
77aade5
Reimplementing type definition for `Widget.tsx` config prop.
linuspahl Mar 19, 2021
c450d95
Fixing import path.
dennisoelkers Mar 22, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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();
});
});
Loading