[Infra UI] Metrics Explorer (#35846) (#36282)

* Returning live data

* Adding TSVB data population

* adding tests

* Adding UI

* Adding rough draft of metrics control

* Breaking out metric component; adding useCallback to callbacks; adding intl strings

* seperating out form

* Break metrics form out; change to custom color picker; create custom color palette;

* fixing bug with color picker

* changes to color palette; fix callback issue

* Fixing count label

* Fix chart label to truncate

* Changing by to graph per

* Making the metric popover wider to ease field name truncation

* critical changes to the import order

* Changing metrics behavior

* Hide metrics when choosing document count

* Updating chart tooltip; fixing types;

* Setting intial state to open metrics; Tweaking toolbar sizes

* fixing linting issues

* Allow users to filter by a grouping by clicking on the title

* Change rate to rateMax; add rateMin and rateAvg; fix title text-align

* Use relative paths to fix base path bug

* fixing typescript errors; removing rateAvg and rateMin; removing extranious files;

* Fixing formatting issues

* Fixing i18n linting errors

* Changing to elastic-charts

* fixing typing errors with charts

* Moving afterKey out of URL to fix bug with pagination

* Adding support for multiple axises

* Adding tests for useMetricsExplorerData hook

* breaking up the charting code; removing multi-axis support; changing color palette to use blue and red for first two color

* Adding drop down menu to charts for filtering and linking to TSVB

* Adding more tests for useMetricsExplorerData hook; adding error message; adding chart options to non-groupby charts

* only display groupings that have the metric fields

* Refactor page level state into custom hook; add test for options handlers;

* Fixing linting

* removing color picker

* removing useInterval

* Changing group by to use the pills; Changing context menu button; adding icons to context menu.

* Adding test for color palette

* Adding test for createFormatterForMetric()

* removing tsx extension; adding tests for createMetricLabel()

* removing tsx extension; adding tests for createMetricLabel()

* re-organizing helpers

* Moving helpers from libs to helpers; adding test for metricToFormat

* Fixing bug in tsvb link fn; adding timeRange props; adding createTSVBLink() test

* fixing timeRange fixture import; fixing aria label for action button

* removing some unecessary useCallbacks

* Adding test for MetricsExplorerChartContextMenu component

* Fixing linting issues

* Optimizing test

* Adding empty prompts for no metrics and no data

* Removing duplicate sereis def

* tcs has lost it's mind so I had to copy enzyme_helpers.tsx into our plugin

* Appeasing prettier

* Update x-pack/plugins/infra/public/components/metrics_exploerer/metrics.tsx

Co-Authored-By: simianhacker <chris@chriscowan.us>

* fixing path typo

* Adding supportFiltering to dependicy; change options to be more specific

* remove typo

* Fixing typo

* Adding logColumns to source fixture; fixing typo

* Fixing path to be more sane
This commit is contained in:
Chris Cowan 2019-05-08 15:00:21 -07:00 committed by GitHub
parent 0fd0245b87
commit 26f4826675
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
55 changed files with 3229 additions and 62 deletions

View file

@ -103,6 +103,7 @@
"@babel/core": "^7.3.4",
"@babel/polyfill": "^7.2.5",
"@babel/register": "^7.0.0",
"@elastic/charts": "^3.11.2",
"@elastic/datemath": "5.0.2",
"@elastic/eui": "10.4.0",
"@elastic/filesaver": "1.1.2",

View file

@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { sampleColor, MetricsExplorerColor, colorTransformer } from './color_palette';
describe('Color Palette', () => {
describe('sampleColor()', () => {
it('should just work', () => {
const usedColors = [MetricsExplorerColor.color0];
const color = sampleColor(usedColors);
expect(color).toBe(MetricsExplorerColor.color1);
});
it('should return color0 when nothing is available', () => {
const usedColors = [
MetricsExplorerColor.color0,
MetricsExplorerColor.color1,
MetricsExplorerColor.color2,
MetricsExplorerColor.color3,
MetricsExplorerColor.color4,
MetricsExplorerColor.color5,
MetricsExplorerColor.color6,
MetricsExplorerColor.color7,
MetricsExplorerColor.color8,
MetricsExplorerColor.color9,
];
const color = sampleColor(usedColors);
expect(color).toBe(MetricsExplorerColor.color0);
});
});
describe('colorTransformer()', () => {
it('should just work', () => {
expect(colorTransformer(MetricsExplorerColor.color0)).toBe('#3185FC');
});
});
});

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 { difference, first, values } from 'lodash';
export enum MetricsExplorerColor {
color0 = 'color0',
color1 = 'color1',
color2 = 'color2',
color3 = 'color3',
color4 = 'color4',
color5 = 'color5',
color6 = 'color6',
color7 = 'color7',
color8 = 'color8',
color9 = 'color9',
}
export interface MetricsExplorerPalette {
[MetricsExplorerColor.color0]: string;
[MetricsExplorerColor.color1]: string;
[MetricsExplorerColor.color2]: string;
[MetricsExplorerColor.color3]: string;
[MetricsExplorerColor.color4]: string;
[MetricsExplorerColor.color5]: string;
[MetricsExplorerColor.color6]: string;
[MetricsExplorerColor.color7]: string;
[MetricsExplorerColor.color8]: string;
[MetricsExplorerColor.color9]: string;
}
export const defaultPalette: MetricsExplorerPalette = {
[MetricsExplorerColor.color0]: '#3185FC', // euiColorVis1 (blue)
[MetricsExplorerColor.color1]: '#DB1374', // euiColorVis2 (red-ish)
[MetricsExplorerColor.color2]: '#00B3A4', // euiColorVis0 (green-ish)
[MetricsExplorerColor.color3]: '#490092', // euiColorVis3 (purple)
[MetricsExplorerColor.color4]: '#FEB6DB', // euiColorVis4 (pink)
[MetricsExplorerColor.color5]: '#E6C220', // euiColorVis5 (yellow)
[MetricsExplorerColor.color6]: '#BFA180', // euiColorVis6 (tan)
[MetricsExplorerColor.color7]: '#F98510', // euiColorVis7 (orange)
[MetricsExplorerColor.color8]: '#461A0A', // euiColorVis8 (brown)
[MetricsExplorerColor.color9]: '#920000', // euiColorVis9 (maroon)
};
export const createPaletteTransformer = (palette: MetricsExplorerPalette) => (
color: MetricsExplorerColor
) => palette[color];
export const colorTransformer = createPaletteTransformer(defaultPalette);
export const sampleColor = (usedColors: MetricsExplorerColor[] = []): MetricsExplorerColor => {
const available = difference(values(MetricsExplorerColor) as MetricsExplorerColor[], usedColors);
return first(available) || MetricsExplorerColor.color0;
};

View file

@ -597,13 +597,7 @@ export namespace FlyoutItemQuery {
fields: Fields[];
};
export type Key = {
__typename?: 'InfraTimeKey';
time: number;
tiebreaker: number;
};
export type Key = InfraTimeKeyFields.Fragment;
export type Fields = {
__typename?: 'InfraLogItemField';

View file

@ -13,7 +13,7 @@
},
"dependencies": {
"@types/color": "^3.0.0",
"boom": "3.1.1",
"boom": "7.2.2",
"lodash": "^4.17.10"
}
}
}

View file

@ -27,6 +27,7 @@ interface AutocompleteFieldProps {
placeholder?: string;
suggestions: AutocompleteSuggestion[];
value: string;
autoFocus?: boolean;
}
interface AutocompleteFieldState {
@ -86,7 +87,7 @@ export class AutocompleteField extends React.Component<
}
public componentDidMount() {
if (this.inputElement) {
if (this.inputElement && this.props.autoFocus) {
this.inputElement.focus();
}
}

View file

@ -30,6 +30,7 @@ import {
import { createFormatter } from '../../../utils/formatters';
const MARGIN_LEFT = 60;
const chartComponentsByType = {
[InfraMetricLayoutVisualizationType.line]: EuiLineSeries,
[InfraMetricLayoutVisualizationType.area]: EuiAreaSeries,
@ -167,7 +168,7 @@ export const ChartSection = injectI18n(
const itemsFormatter = createItemsFormatter(formatterFunction, seriesLabels, seriesColors);
return (
<EuiPageContentBody>
<EuiTitle size="s">
<EuiTitle size="xs">
<h3 id={section.id}>{section.label}</h3>
</EuiTitle>
<div style={{ height: 200 }}>

View file

@ -25,7 +25,6 @@ interface MetricsTimeControlsProps {
export class MetricsTimeControls extends React.Component<MetricsTimeControlsProps> {
public render() {
const { currentTimeRange, isLiveStreaming, refreshInterval } = this.props;
return (
<MetricsTimeControlsContainer>
<EuiSuperDatePicker

View file

@ -0,0 +1,77 @@
/*
* 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 { EuiSelect } from '@elastic/eui';
import { InjectedIntl, injectI18n } from '@kbn/i18n/react';
import React, { useCallback } from 'react';
import { MetricsExplorerAggregation } from '../../../server/routes/metrics_explorer/types';
import { MetricsExplorerOptions } from '../../containers/metrics_explorer/use_metrics_explorer_options';
interface Props {
intl: InjectedIntl;
options: MetricsExplorerOptions;
fullWidth: boolean;
onChange: (aggregation: MetricsExplorerAggregation) => void;
}
const isMetricsExplorerAggregation = (subject: any): subject is MetricsExplorerAggregation => {
return Object.keys(MetricsExplorerAggregation).includes(subject);
};
export const MetricsExplorerAggregationPicker = injectI18n(({ intl, options, onChange }: Props) => {
const AGGREGATION_LABELS = {
[MetricsExplorerAggregation.avg]: intl.formatMessage({
id: 'xpack.infra.metricsExplorer.aggregationLables.avg',
defaultMessage: 'Average',
}),
[MetricsExplorerAggregation.max]: intl.formatMessage({
id: 'xpack.infra.metricsExplorer.aggregationLables.max',
defaultMessage: 'Max',
}),
[MetricsExplorerAggregation.min]: intl.formatMessage({
id: 'xpack.infra.metricsExplorer.aggregationLables.min',
defaultMessage: 'Min',
}),
[MetricsExplorerAggregation.cardinality]: intl.formatMessage({
id: 'xpack.infra.metricsExplorer.aggregationLables.cardinality',
defaultMessage: 'Cardinality',
}),
[MetricsExplorerAggregation.rate]: intl.formatMessage({
id: 'xpack.infra.metricsExplorer.aggregationLables.rate',
defaultMessage: 'Rate',
}),
[MetricsExplorerAggregation.count]: intl.formatMessage({
id: 'xpack.infra.metricsExplorer.aggregationLables.count',
defaultMessage: 'Document Count',
}),
};
const handleChange = useCallback(
e => {
const aggregation =
(isMetricsExplorerAggregation(e.target.value) && e.target.value) ||
MetricsExplorerAggregation.avg;
onChange(aggregation);
},
[onChange]
);
return (
<EuiSelect
placeholder={intl.formatMessage({
id: 'xpack.infra.metricsExplorer.aggregationSelectLabel',
defaultMessage: 'Select an aggregation',
})}
fullWidth
value={options.aggregation}
options={Object.keys(MetricsExplorerAggregation).map(k => ({
text: AGGREGATION_LABELS[k as MetricsExplorerAggregation],
value: k,
}))}
onChange={handleChange}
/>
);
});

View file

@ -0,0 +1,118 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useCallback } from 'react';
import { InjectedIntl, injectI18n } from '@kbn/i18n/react';
import { EuiTitle } from '@elastic/eui';
import { Chart, Axis, Position, timeFormatter, getAxisId } from '@elastic/charts';
import '@elastic/charts/dist/style.css';
import { first } from 'lodash';
import { niceTimeFormatByDay } from '@elastic/charts/dist/utils/data/formatters';
import { EuiFlexGroup } from '@elastic/eui';
import { EuiFlexItem } from '@elastic/eui';
import { MetricsExplorerSeries } from '../../../server/routes/metrics_explorer/types';
import {
MetricsExplorerOptions,
MetricsExplorerTimeOptions,
} from '../../containers/metrics_explorer/use_metrics_explorer_options';
import euiStyled from '../../../../../common/eui_styled_components';
import { createFormatterForMetric } from './helpers/create_formatter_for_metric';
import { MetricLineSeries } from './line_series';
import { MetricsExplorerChartContextMenu } from './chart_context_menu';
import { SourceQuery } from '../../graphql/types';
import { MetricsExplorerEmptyChart } from './empty_chart';
import { MetricsExplorerNoMetrics } from './no_metrics';
interface Props {
intl: InjectedIntl;
title?: string | null;
onFilter: (query: string) => void;
width?: number | string;
height?: number | string;
options: MetricsExplorerOptions;
series: MetricsExplorerSeries;
source: SourceQuery.Query['source']['configuration'] | undefined;
timeRange: MetricsExplorerTimeOptions;
}
const dateFormatter = timeFormatter(niceTimeFormatByDay(1));
export const MetricsExplorerChart = injectI18n(
({
source,
options,
series,
title,
onFilter,
height = 200,
width = '100%',
timeRange,
}: Props) => {
const { metrics } = options;
const yAxisFormater = useCallback(createFormatterForMetric(first(metrics)), [options]);
return (
<React.Fragment>
{options.groupBy ? (
<EuiTitle size="xs">
<EuiFlexGroup>
<EuiFlexItem grow={1}>
<ChartTitle>{title}</ChartTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<MetricsExplorerChartContextMenu
timeRange={timeRange}
options={options}
series={series}
onFilter={onFilter}
source={source}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiTitle>
) : (
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<MetricsExplorerChartContextMenu
options={options}
series={series}
source={source}
timeRange={timeRange}
/>
</EuiFlexItem>
</EuiFlexGroup>
)}
<div style={{ height, width }}>
{series.rows.length > 0 ? (
<Chart>
{metrics.map((metric, id) => (
<MetricLineSeries key={id} metric={metric} id={id} series={series} />
))}
<Axis
id={getAxisId('timestamp')}
position={Position.Bottom}
showOverlappingTicks={true}
tickFormat={dateFormatter}
/>
<Axis id={getAxisId('values')} position={Position.Left} tickFormat={yAxisFormater} />
</Chart>
) : options.metrics.length > 0 ? (
<MetricsExplorerEmptyChart />
) : (
<MetricsExplorerNoMetrics />
)}
</div>
</React.Fragment>
);
}
);
const ChartTitle = euiStyled.div`
width: 100%
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
`;

View file

@ -0,0 +1,85 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { MetricsExplorerChartContextMenu } from './chart_context_menu';
import { mountWithIntl } from '../../utils/enzyme_helpers';
import { options, source, timeRange } from '../../utils/fixtures/metrics_explorer';
const series = { id: 'exmaple-01', rows: [], columns: [] };
describe('MetricsExplorerChartContextMenu', () => {
it('should just work', async () => {
const onFilter = jest.fn().mockImplementation((query: string) => void 0);
const component = mountWithIntl(
<MetricsExplorerChartContextMenu
timeRange={timeRange}
source={source}
series={series}
options={options}
onFilter={onFilter}
/>
);
component.find('button').simulate('click');
const menuItems = component.find('.euiContextMenuItem__text');
expect(menuItems.length).toBe(2);
expect(menuItems.at(0).text()).toBe('Add Filter');
expect(menuItems.at(1).text()).toBe('Open in Visualize');
});
it('should not display "Add Filter" without onFilter', async () => {
const component = mountWithIntl(
<MetricsExplorerChartContextMenu
timeRange={timeRange}
source={source}
series={series}
options={options}
/>
);
component.find('button').simulate('click');
const menuItems = component.find('.euiContextMenuItem__text');
expect(menuItems.length).toBe(1);
expect(menuItems.at(0).text()).toBe('Open in Visualize');
});
it('should not display "Add Filter" without options.groupBy', async () => {
const customOptions = { ...options, groupBy: void 0 };
const onFilter = jest.fn().mockImplementation((query: string) => void 0);
const component = mountWithIntl(
<MetricsExplorerChartContextMenu
timeRange={timeRange}
source={source}
series={series}
options={customOptions}
onFilter={onFilter}
/>
);
component.find('button').simulate('click');
const menuItems = component.find('.euiContextMenuItem__text');
expect(menuItems.length).toBe(1);
expect(menuItems.at(0).text()).toBe('Open in Visualize');
});
it('should disable "Open in Visualize" when options.metrics is empty', async () => {
const customOptions = { ...options, metrics: [] };
const component = mountWithIntl(
<MetricsExplorerChartContextMenu
timeRange={timeRange}
source={source}
series={series}
options={customOptions}
/>
);
component.find('button').simulate('click');
const menuItems = component.find('button.euiContextMenuItem');
expect(menuItems.length).toBe(1);
expect(menuItems.at(0).prop('disabled')).toBeTruthy();
});
});

View file

@ -0,0 +1,117 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useCallback, useState } from 'react';
import { InjectedIntl, injectI18n } from '@kbn/i18n/react';
import {
EuiButtonEmpty,
EuiContextMenu,
EuiContextMenuPanelDescriptor,
EuiPopover,
} from '@elastic/eui';
import { MetricsExplorerSeries } from '../../../server/routes/metrics_explorer/types';
import {
MetricsExplorerOptions,
MetricsExplorerTimeOptions,
} from '../../containers/metrics_explorer/use_metrics_explorer_options';
import { createTSVBLink } from './helpers/create_tsvb_link';
import { SourceQuery } from '../../graphql/types';
interface Props {
intl: InjectedIntl;
options: MetricsExplorerOptions;
onFilter?: (query: string) => void;
series: MetricsExplorerSeries;
source: SourceQuery.Query['source']['configuration'] | undefined;
timeRange: MetricsExplorerTimeOptions;
}
export const MetricsExplorerChartContextMenu = injectI18n(
({ intl, onFilter, options, series, source, timeRange }: Props) => {
const [isPopoverOpen, setPopoverState] = useState(false);
const supportFiltering = options.groupBy != null && onFilter != null;
const handleFilter = useCallback(
() => {
// onFilter needs check for Typescript even though it's
// covered by supportFiltering variable
if (supportFiltering && onFilter) {
onFilter(`${options.groupBy}: "${series.id}"`);
}
setPopoverState(false);
},
[supportFiltering, options.groupBy, series.id, onFilter]
);
const tsvbUrl = createTSVBLink(source, options, series, timeRange);
// Only display the "Add Filter" option if it's supported
const filterByItem = supportFiltering
? [
{
name: intl.formatMessage({
id: 'xpack.infra.metricsExplorer.filterByLabel',
defaultMessage: 'Add Filter',
}),
icon: 'infraApp',
onClick: handleFilter,
},
]
: [];
const panels: EuiContextMenuPanelDescriptor[] = [
{
id: 0,
title: 'Actions',
items: [
...filterByItem,
{
name: intl.formatMessage({
id: 'xpack.infra.metricsExplorer.openInTSVB',
defaultMessage: 'Open in Visualize',
}),
href: tsvbUrl,
icon: 'visualizeApp',
disabled: options.metrics.length === 0,
},
],
},
];
const handleClose = () => setPopoverState(false);
const handleOpen = () => setPopoverState(true);
const actionAriaLabel = intl.formatMessage(
{
id: 'xpack.infra.metricsExplorer.actionsLabel.aria',
defaultMessage: 'Actions for {grouping}',
},
{ grouping: series.id }
);
const actionLabel = intl.formatMessage({
id: 'xpack.infra.metricsExplorer.actionsLabel.button',
defaultMessage: 'Actions',
});
const button = (
<EuiButtonEmpty
contentProps={{ 'aria-label': actionAriaLabel }}
onClick={handleOpen}
size="s"
iconType="arrowDown"
iconSide="right"
>
{actionLabel}
</EuiButtonEmpty>
);
return (
<EuiPopover
closePopover={handleClose}
id={`${series.id}-popover`}
button={button}
isOpen={isPopoverOpen}
panelPaddingSize="none"
>
<EuiContextMenu initialPanelId={0} panels={panels} />
</EuiPopover>
);
}
);

View file

@ -0,0 +1,120 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiButton, EuiFlexGrid, EuiFlexItem, EuiText, EuiHorizontalRule } from '@elastic/eui';
import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react';
import React from 'react';
import { MetricsExplorerResponse } from '../../../server/routes/metrics_explorer/types';
import {
MetricsExplorerOptions,
MetricsExplorerTimeOptions,
} from '../../containers/metrics_explorer/use_metrics_explorer_options';
import { InfraLoadingPanel } from '../loading';
import { NoData } from '../empty_states/no_data';
import { MetricsExplorerChart } from './chart';
import { SourceQuery } from '../../graphql/types';
interface Props {
loading: boolean;
options: MetricsExplorerOptions;
onLoadMore: (afterKey: string | null) => void;
onRefetch: () => void;
onFilter: (filter: string) => void;
data: MetricsExplorerResponse | null;
intl: InjectedIntl;
source: SourceQuery.Query['source']['configuration'] | undefined;
timeRange: MetricsExplorerTimeOptions;
}
export const MetricsExplorerCharts = injectI18n(
({ loading, data, onLoadMore, options, onRefetch, intl, onFilter, source, timeRange }: Props) => {
if (!data && loading) {
return (
<InfraLoadingPanel
height={800}
width="100%"
text={intl.formatMessage({
defaultMessage: 'Loading charts',
id: 'xpack.infra.metricsExplorer.loadingCharts',
})}
/>
);
}
if (!data || data.series.length === 0) {
return (
<NoData
titleText={intl.formatMessage({
id: 'xpack.infra.metricsExplorer.noDataTitle',
defaultMessage: 'There is no data to display.',
})}
bodyText={intl.formatMessage({
id: 'xpack.infra.metricsExplorer.noDataBodyText',
defaultMessage: 'Try adjusting your time, filters or group by settings.',
})}
refetchText={intl.formatMessage({
id: 'xpack.infra.metricsExplorer.noDataRefetchText',
defaultMessage: 'Check for new data',
})}
testString="metrics-explorer-no-data"
onRefetch={onRefetch}
/>
);
}
return (
<div>
<EuiFlexGrid gutterSize="s" columns={data.series.length === 1 ? 1 : 3}>
{data.series.map(series => (
<EuiFlexItem key={series.id} style={{ padding: 16, minWidth: 0 }}>
<MetricsExplorerChart
key={`chart-${series.id}`}
onFilter={onFilter}
options={options}
title={options.groupBy ? series.id : null}
height={data.series.length > 1 ? 200 : 400}
series={series}
source={source}
timeRange={timeRange}
/>
</EuiFlexItem>
))}
</EuiFlexGrid>
{data.series.length > 1 ? (
<div style={{ textAlign: 'center', marginBottom: 16 }}>
<EuiHorizontalRule />
<EuiText color="subdued">
<p>
<FormattedMessage
id="xpack.infra.metricsExplorer.footerPaginationMessage"
defaultMessage='Displaying {length} of {total} charts grouped by "{groupBy}".'
values={{
length: data.series.length,
total: data.pageInfo.total,
groupBy: options.groupBy,
}}
/>
</p>
</EuiText>
{data.pageInfo.afterKey ? (
<div style={{ margin: '16px 0' }}>
<EuiButton
isLoading={loading}
size="s"
onClick={() => onLoadMore(data.pageInfo.afterKey || null)}
>
<FormattedMessage
id="xpack.infra.metricsExplorer.loadMoreChartsButton"
defaultMessage="Load More Charts"
/>
</EuiButton>
</div>
) : null}
</div>
) : null}
</div>
);
}
);

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
import { EuiEmptyPrompt } from '@elastic/eui';
export const MetricsExplorerEmptyChart = injectI18n(() => {
return (
<EuiEmptyPrompt
iconType="stats"
title={
<h3>
<FormattedMessage
id="xpack.infra.metricsExplorer.emptyChart.title"
defaultMessage="Chart Data Missing"
/>
</h3>
}
body={
<p>
<FormattedMessage
id="xpack.infra.metricsExplorer.emptyChart.body"
defaultMessage="Unable to render chart."
/>
</p>
}
/>
);
});

View file

@ -0,0 +1,44 @@
/*
* 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 { EuiComboBox } from '@elastic/eui';
import { InjectedIntl, injectI18n } from '@kbn/i18n/react';
import React, { useCallback } from 'react';
import { StaticIndexPatternField } from 'ui/index_patterns';
import { MetricsExplorerOptions } from '../../containers/metrics_explorer/use_metrics_explorer_options';
interface Props {
intl: InjectedIntl;
options: MetricsExplorerOptions;
onChange: (groupBy: string | null) => void;
fields: StaticIndexPatternField[];
}
export const MetricsExplorerGroupBy = injectI18n(({ intl, options, onChange, fields }: Props) => {
const handleChange = useCallback(
selectedOptions => {
const groupBy = (selectedOptions.length === 1 && selectedOptions[0].label) || null;
onChange(groupBy);
},
[onChange]
);
return (
<EuiComboBox
placeholder={intl.formatMessage({
id: 'xpack.infra.metricsExplorer.groupByLabel',
defaultMessage: 'Everything',
})}
fullWidth
singleSelection={true}
selectedOptions={(options.groupBy && [{ label: options.groupBy }]) || []}
options={fields
.filter(f => f.aggregatable && f.type === 'string')
.map(f => ({ label: f.name }))}
onChange={handleChange}
isClearable={true}
/>
);
});

View file

@ -0,0 +1,26 @@
/*
* 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 {
MetricsExplorerAggregation,
MetricsExplorerMetric,
} from '../../../../server/routes/metrics_explorer/types';
import { createFormatter } from '../../../utils/formatters';
import { InfraFormatterType } from '../../../lib/lib';
import { metricToFormat } from './metric_to_format';
export const createFormatterForMetric = (metric?: MetricsExplorerMetric) => {
if (metric && metric.field) {
const format = metricToFormat(metric);
if (
format === InfraFormatterType.bits &&
metric.aggregation === MetricsExplorerAggregation.rate
) {
return createFormatter(InfraFormatterType.bits, '{{value}}/s');
}
return createFormatter(format);
}
return createFormatter(InfraFormatterType.number);
};

View file

@ -0,0 +1,41 @@
/*
* 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 { createFormatterForMetric } from './create_formatter_for_metric';
import { MetricsExplorerAggregation } from '../../../../server/routes/metrics_explorer/types';
describe('createFormatterForMetric()', () => {
it('should just work for count', () => {
const metric = { aggregation: MetricsExplorerAggregation.count };
const format = createFormatterForMetric(metric);
expect(format(1291929)).toBe('1,291,929');
});
it('should just work for numerics', () => {
const metric = { aggregation: MetricsExplorerAggregation.avg, field: 'system.load.1' };
const format = createFormatterForMetric(metric);
expect(format(1000.2)).toBe('1,000.2');
});
it('should just work for percents', () => {
const metric = { aggregation: MetricsExplorerAggregation.avg, field: 'system.cpu.total.pct' };
const format = createFormatterForMetric(metric);
expect(format(0.349)).toBe('34.9%');
});
it('should just work for rates', () => {
const metric = {
aggregation: MetricsExplorerAggregation.rate,
field: 'system.network.out.bytes',
};
const format = createFormatterForMetric(metric);
expect(format(103929292)).toBe('103.9Mbit/s');
});
it('should just work for bytes', () => {
const metric = {
aggregation: MetricsExplorerAggregation.avg,
field: 'system.network.out.bytes',
};
const format = createFormatterForMetric(metric);
expect(format(103929292)).toBe('103.9MB');
});
});

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.
*/
import { createMetricLabel } from './create_metric_label';
import { MetricsExplorerAggregation } from '../../../../server/routes/metrics_explorer/types';
describe('createMetricLabel()', () => {
it('should work with metrics with fields', () => {
const metric = { aggregation: MetricsExplorerAggregation.avg, field: 'system.load.1' };
expect(createMetricLabel(metric)).toBe('avg(system.load.1)');
});
it('should work with document count', () => {
const metric = { aggregation: MetricsExplorerAggregation.count };
expect(createMetricLabel(metric)).toBe('count()');
});
});

View file

@ -0,0 +1,11 @@
/*
* 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 { MetricsExplorerMetric } from '../../../../server/routes/metrics_explorer/types';
export const createMetricLabel = (metric: MetricsExplorerMetric) => {
return `${metric.aggregation}(${metric.field || ''})`;
};

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { createTSVBLink } from './create_tsvb_link';
import { source, options, timeRange } from '../../../utils/fixtures/metrics_explorer';
import uuid from 'uuid';
import { OutputBuffer } from 'uuid/interfaces';
import { MetricsExplorerAggregation } from '../../../../server/routes/metrics_explorer/types';
jest.mock('uuid');
const mockedUuid = uuid as jest.Mocked<typeof uuid>;
mockedUuid.v1.mockReturnValue(('test-id' as unknown) as OutputBuffer);
const series = { id: 'example-01', rows: [], columns: [] };
describe('createTSVBLink()', () => {
it('should just work', () => {
const link = createTSVBLink(source, options, series, timeRange);
expect(link).toBe(
"../app/kibana#/visualize/create?type=metrics&_g=(refreshInterval:(pause:!t,value:0),time:(from:now-1h,to:now))&_a=(filters:!(),linked:!f,query:(language:kuery,query:''),uiState:(),vis:(aggs:!(),params:(axis_formatter:number,axis_position:left,axis_scale:normal,default_index_pattern:'metricbeat-*',filter:'host.name: example-01',id:test-id,index_pattern:'metricbeat-*',interval:auto,series:!((axis_position:right,chart_type:line,color:%233185FC,fill:0,formatter:percent,id:test-id,label:'avg(system.cpu.user.pct)',line_width:2,metrics:!((field:system.cpu.user.pct,id:test-id,type:avg)),point_size:0,separate_axis:0,split_mode:everything,stacked:none,value_template:{{value}})),show_grid:1,show_legend:1,time_field:'@timestamp',type:timeseries),title:example-01,type:metrics))"
);
});
it('should work with rates', () => {
const customOptions = {
...options,
metrics: [
{ aggregation: MetricsExplorerAggregation.rate, field: 'system.network.out.bytes' },
],
};
const link = createTSVBLink(source, customOptions, series, timeRange);
expect(link).toBe(
"../app/kibana#/visualize/create?type=metrics&_g=(refreshInterval:(pause:!t,value:0),time:(from:now-1h,to:now))&_a=(filters:!(),linked:!f,query:(language:kuery,query:''),uiState:(),vis:(aggs:!(),params:(axis_formatter:number,axis_position:left,axis_scale:normal,default_index_pattern:'metricbeat-*',filter:'host.name: example-01',id:test-id,index_pattern:'metricbeat-*',interval:auto,series:!((axis_position:right,chart_type:line,color:%233185FC,fill:0,formatter:bytes,id:test-id,label:'rate(system.network.out.bytes)',line_width:2,metrics:!((field:system.network.out.bytes,id:test-id,type:max),(field:test-id,id:test-id,type:derivative,unit:'1s'),(field:test-id,id:test-id,type:positive_only)),point_size:0,separate_axis:0,split_mode:everything,stacked:none,value_template:{{value}}/s)),show_grid:1,show_legend:1,time_field:'@timestamp',type:timeseries),title:example-01,type:metrics))"
);
});
it('should work with time range', () => {
const customTimeRange = { ...timeRange, from: 'now-10m', to: 'now' };
const link = createTSVBLink(source, options, series, customTimeRange);
expect(link).toBe(
"../app/kibana#/visualize/create?type=metrics&_g=(refreshInterval:(pause:!t,value:0),time:(from:now-10m,to:now))&_a=(filters:!(),linked:!f,query:(language:kuery,query:''),uiState:(),vis:(aggs:!(),params:(axis_formatter:number,axis_position:left,axis_scale:normal,default_index_pattern:'metricbeat-*',filter:'host.name: example-01',id:test-id,index_pattern:'metricbeat-*',interval:auto,series:!((axis_position:right,chart_type:line,color:%233185FC,fill:0,formatter:percent,id:test-id,label:'avg(system.cpu.user.pct)',line_width:2,metrics:!((field:system.cpu.user.pct,id:test-id,type:avg)),point_size:0,separate_axis:0,split_mode:everything,stacked:none,value_template:{{value}})),show_grid:1,show_legend:1,time_field:'@timestamp',type:timeseries),title:example-01,type:metrics))"
);
});
it('should work with source', () => {
const customSource = {
...source,
metricAlias: 'my-beats-*',
fields: { ...source.fields, timestamp: 'time' },
};
const link = createTSVBLink(customSource, options, series, timeRange);
expect(link).toBe(
"../app/kibana#/visualize/create?type=metrics&_g=(refreshInterval:(pause:!t,value:0),time:(from:now-1h,to:now))&_a=(filters:!(),linked:!f,query:(language:kuery,query:''),uiState:(),vis:(aggs:!(),params:(axis_formatter:number,axis_position:left,axis_scale:normal,default_index_pattern:'my-beats-*',filter:'host.name: example-01',id:test-id,index_pattern:'my-beats-*',interval:auto,series:!((axis_position:right,chart_type:line,color:%233185FC,fill:0,formatter:percent,id:test-id,label:'avg(system.cpu.user.pct)',line_width:2,metrics:!((field:system.cpu.user.pct,id:test-id,type:avg)),point_size:0,separate_axis:0,split_mode:everything,stacked:none,value_template:{{value}})),show_grid:1,show_legend:1,time_field:time,type:timeseries),title:example-01,type:metrics))"
);
});
});

View file

@ -0,0 +1,123 @@
/*
* 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 { encode } from 'rison-node';
import uuid from 'uuid';
import { colorTransformer, MetricsExplorerColor } from '../../../../common/color_palette';
import {
MetricsExplorerSeries,
MetricsExplorerAggregation,
} from '../../../../server/routes/metrics_explorer/types';
import {
MetricsExplorerOptions,
MetricsExplorerOptionsMetric,
MetricsExplorerTimeOptions,
} from '../../../containers/metrics_explorer/use_metrics_explorer_options';
import { metricToFormat } from './metric_to_format';
import { InfraFormatterType } from '../../../lib/lib';
import { SourceQuery } from '../../../graphql/types';
import { createMetricLabel } from './create_metric_label';
export const metricsExplorerMetricToTSVBMetric = (metric: MetricsExplorerOptionsMetric) => {
if (metric.aggregation === MetricsExplorerAggregation.rate) {
const metricId = uuid.v1();
const positiveOnlyId = uuid.v1();
const derivativeId = uuid.v1();
return [
{
id: metricId,
type: 'max',
field: metric.field || void 0,
},
{
id: derivativeId,
type: 'derivative',
field: metricId,
unit: '1s',
},
{
id: positiveOnlyId,
type: 'positive_only',
field: derivativeId,
},
];
} else {
return [
{
id: uuid.v1(),
type: metric.aggregation,
field: metric.field || void 0,
},
];
}
};
const mapMetricToSeries = (metric: MetricsExplorerOptionsMetric) => {
const format = metricToFormat(metric);
return {
label: createMetricLabel(metric),
axis_position: 'right',
chart_type: 'line',
color: encodeURIComponent(
(metric.color && colorTransformer(metric.color)) ||
colorTransformer(MetricsExplorerColor.color0)
),
fill: 0,
formatter: format === InfraFormatterType.bits ? InfraFormatterType.bytes : format,
value_template:
MetricsExplorerAggregation.rate === metric.aggregation ? '{{value}}/s' : '{{value}}',
id: uuid.v1(),
line_width: 2,
metrics: metricsExplorerMetricToTSVBMetric(metric),
point_size: 0,
separate_axis: 0,
split_mode: 'everything',
stacked: 'none',
};
};
export const createTSVBLink = (
source: SourceQuery.Query['source']['configuration'] | undefined,
options: MetricsExplorerOptions,
series: MetricsExplorerSeries,
timeRange: MetricsExplorerTimeOptions
) => {
const appState = {
filters: [],
linked: false,
query: { language: 'kuery', query: '' },
uiState: {},
vis: {
aggs: [],
params: {
axis_formatter: 'number',
axis_position: 'left',
axis_scale: 'normal',
id: uuid.v1(),
default_index_pattern: (source && source.metricAlias) || 'metricbeat-*',
index_pattern: (source && source.metricAlias) || 'metricbeat-*',
interval: 'auto',
series: options.metrics.map(mapMetricToSeries),
show_grid: 1,
show_legend: 1,
time_field: (source && source.fields.timestamp) || '@timestamp',
type: 'timeseries',
filter: options.groupBy ? `${options.groupBy}: ${series.id}` : '',
},
title: series.id,
type: 'metrics',
},
};
const globalState = {
refreshInterval: { pause: true, value: 0 },
time: { from: timeRange.from, to: timeRange.to },
};
return `../app/kibana#/visualize/create?type=metrics&_g=${encode(globalState)}&_a=${encode(
appState as any
)}`;
};

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 { metricToFormat } from './metric_to_format';
import { MetricsExplorerAggregation } from '../../../../server/routes/metrics_explorer/types';
import { InfraFormatterType } from '../../../lib/lib';
describe('metricToFormat()', () => {
it('should just work for numeric metrics', () => {
const metric = { aggregation: MetricsExplorerAggregation.avg, field: 'system.load.1' };
expect(metricToFormat(metric)).toBe(InfraFormatterType.number);
});
it('should just work for byte metrics', () => {
const metric = {
aggregation: MetricsExplorerAggregation.avg,
field: 'system.network.out.bytes',
};
expect(metricToFormat(metric)).toBe(InfraFormatterType.bytes);
});
it('should just work for rate bytes metrics', () => {
const metric = {
aggregation: MetricsExplorerAggregation.rate,
field: 'system.network.out.bytes',
};
expect(metricToFormat(metric)).toBe(InfraFormatterType.bits);
});
it('should just work for rate metrics', () => {
const metric = {
aggregation: MetricsExplorerAggregation.rate,
field: 'system.cpu.user.ticks',
};
expect(metricToFormat(metric)).toBe(InfraFormatterType.number);
});
it('should just work for percent metrics', () => {
const metric = {
aggregation: MetricsExplorerAggregation.avg,
field: 'system.cpu.user.pct',
};
expect(metricToFormat(metric)).toBe(InfraFormatterType.percent);
});
});

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 { last } from 'lodash';
import {
MetricsExplorerAggregation,
MetricsExplorerMetric,
} from '../../../../server/routes/metrics_explorer/types';
import { InfraFormatterType } from '../../../lib/lib';
export const metricToFormat = (metric?: MetricsExplorerMetric) => {
if (metric && metric.field) {
const suffix = last(metric.field.split(/\./));
if (suffix === 'pct') {
return InfraFormatterType.percent;
}
if (suffix === 'bytes' && metric.aggregation === MetricsExplorerAggregation.rate) {
return InfraFormatterType.bits;
}
if (suffix === 'bytes') {
return InfraFormatterType.bytes;
}
}
return InfraFormatterType.number;
};

View file

@ -0,0 +1,70 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { fromKueryExpression } from '@kbn/es-query';
import { InjectedIntl, injectI18n } from '@kbn/i18n/react';
import React, { useEffect, useState } from 'react';
import { StaticIndexPattern } from 'ui/index_patterns';
import { WithKueryAutocompletion } from '../../containers/with_kuery_autocompletion';
import { AutocompleteField } from '../autocomplete_field';
interface Props {
intl: InjectedIntl;
derivedIndexPattern: StaticIndexPattern;
onSubmit: (query: string) => void;
value?: string | null;
}
function validateQuery(query: string) {
try {
fromKueryExpression(query);
} catch (err) {
return false;
}
return true;
}
export const MetricsExplorerKueryBar = injectI18n(
({ intl, derivedIndexPattern, onSubmit, value }: Props) => {
const [draftQuery, setDraftQuery] = useState<string>(value || '');
const [isValid, setValidation] = useState<boolean>(true);
// This ensures that if value changes out side this component it will update.
useEffect(
() => {
if (value) {
setDraftQuery(value);
}
},
[value]
);
const handleChange = (query: string) => {
setValidation(validateQuery(query));
setDraftQuery(query);
};
return (
<WithKueryAutocompletion indexPattern={derivedIndexPattern}>
{({ isLoadingSuggestions, loadSuggestions, suggestions }) => (
<AutocompleteField
isLoadingSuggestions={isLoadingSuggestions}
isValid={isValid}
loadSuggestions={loadSuggestions}
onChange={handleChange}
onSubmit={onSubmit}
placeholder={intl.formatMessage({
id: 'xpack.infra.homePage.toolbar.kqlSearchFieldPlaceholder',
defaultMessage: 'Search for infrastructure data… (e.g. host.name:host-1)',
})}
suggestions={suggestions}
value={draftQuery}
/>
)}
</WithKueryAutocompletion>
);
}
);

View file

@ -0,0 +1,74 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import {
LineSeries,
ScaleType,
getSpecId,
DataSeriesColorsValues,
CustomSeriesColorsMap,
} from '@elastic/charts';
import '@elastic/charts/dist/style.css';
import { MetricsExplorerSeries } from '../../../server/routes/metrics_explorer/types';
import { colorTransformer, MetricsExplorerColor } from '../../../common/color_palette';
import { createMetricLabel } from './helpers/create_metric_label';
import { MetricsExplorerOptionsMetric } from '../../containers/metrics_explorer/use_metrics_explorer_options';
interface Props {
metric: MetricsExplorerOptionsMetric;
id: string | number;
series: MetricsExplorerSeries;
}
export const MetricLineSeries = ({ metric, id, series }: Props) => {
const color =
(metric.color && colorTransformer(metric.color)) ||
colorTransformer(MetricsExplorerColor.color0);
const seriesLineStyle = {
line: {
stroke: color,
strokeWidth: 2,
visible: true,
},
border: {
visible: false,
strokeWidth: 2,
stroke: color,
},
point: {
visible: false,
radius: 0.2,
stroke: color,
strokeWidth: 2,
opacity: 1,
},
};
const yAccessor = `metric_${id}`;
const specId = getSpecId(yAccessor);
const colors: DataSeriesColorsValues = {
colorValues: [],
specId,
};
const customColors: CustomSeriesColorsMap = new Map();
customColors.set(colors, color);
return (
<LineSeries
key={`series-${series.id}-${yAccessor}`}
id={specId}
name={createMetricLabel(metric)}
xScaleType={ScaleType.Time}
yScaleType={ScaleType.Linear}
xAccessor="timestamp"
yAccessors={[yAccessor]}
data={series.rows}
lineSeriesStyle={seriesLineStyle}
customSeriesColors={customColors}
/>
);
};

View file

@ -0,0 +1,95 @@
/*
* 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 { EuiComboBox } from '@elastic/eui';
import { InjectedIntl, injectI18n } from '@kbn/i18n/react';
import React, { useCallback, useState, useEffect } from 'react';
import { StaticIndexPatternField } from 'ui/index_patterns';
import { colorTransformer, MetricsExplorerColor } from '../../../common/color_palette';
import {
MetricsExplorerMetric,
MetricsExplorerAggregation,
} from '../../../server/routes/metrics_explorer/types';
import { MetricsExplorerOptions } from '../../containers/metrics_explorer/use_metrics_explorer_options';
interface Props {
intl: InjectedIntl;
autoFocus?: boolean;
options: MetricsExplorerOptions;
onChange: (metrics: MetricsExplorerMetric[]) => void;
fields: StaticIndexPatternField[];
}
interface SelectedOption {
value: string;
label: string;
}
export const MetricsExplorerMetrics = injectI18n(
({ intl, options, onChange, fields, autoFocus = false }: Props) => {
const colors = Object.keys(MetricsExplorerColor) as MetricsExplorerColor[];
const [inputRef, setInputRef] = useState<HTMLInputElement | null>(null);
const [focusOnce, setFocusState] = useState<boolean>(false);
useEffect(
() => {
if (inputRef && autoFocus && !focusOnce) {
inputRef.focus();
setFocusState(true);
}
},
[inputRef]
);
// I tried to use useRef originally but the EUIComboBox component's type definition
// would only accept an actual input element or a callback function (with the same type).
// This effectivly does the same thing but is compatible with EuiComboBox.
const handleInputRef = (ref: HTMLInputElement) => {
if (ref) {
setInputRef(ref);
}
};
const handleChange = useCallback(
selectedOptions => {
onChange(
selectedOptions.map((opt: SelectedOption, index: number) => ({
aggregation: options.aggregation,
field: opt.value,
color: colors[index],
}))
);
},
[options, onChange]
);
const comboOptions = fields.map(field => ({ label: field.name, value: field.name }));
const selectedOptions = options.metrics
.filter(m => m.aggregation !== MetricsExplorerAggregation.count)
.map(metric => ({
label: metric.field || '',
value: metric.field || '',
color: colorTransformer(metric.color || MetricsExplorerColor.color0),
}));
const placeholderText = intl.formatMessage({
id: 'xpack.infra.metricsExplorer.metricComboBoxPlaceholder',
defaultMessage: 'choose a metric to plot',
});
return (
<EuiComboBox
isDisabled={options.aggregation === MetricsExplorerAggregation.count}
placeholder={placeholderText}
fullWidth
options={comboOptions}
selectedOptions={selectedOptions}
onChange={handleChange}
isClearable={false}
inputRef={handleInputRef}
/>
);
}
);

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { injectI18n, FormattedMessage } from '@kbn/i18n/react';
import { EuiEmptyPrompt } from '@elastic/eui';
export const MetricsExplorerNoMetrics = injectI18n(() => {
return (
<EuiEmptyPrompt
iconType="stats"
title={
<h3>
<FormattedMessage
id="xpack.infra.metricsExplorer.noMetrics.title"
defaultMessage="Missing Metric"
/>
</h3>
}
body={
<p>
<FormattedMessage
id="xpack.infra.metricsExplorer.noMetrics.body"
defaultMessage="Please choose a metric above."
/>
</p>
}
/>
);
});

View file

@ -0,0 +1,114 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiSuperDatePicker, EuiText } from '@elastic/eui';
import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react';
import React from 'react';
import { StaticIndexPattern } from 'ui/index_patterns';
import {
MetricsExplorerMetric,
MetricsExplorerAggregation,
} from '../../../server/routes/metrics_explorer/types';
import {
MetricsExplorerOptions,
MetricsExplorerTimeOptions,
} from '../../containers/metrics_explorer/use_metrics_explorer_options';
import { Toolbar } from '../eui/toolbar';
import { MetricsExplorerKueryBar } from './kuery_bar';
import { MetricsExplorerMetrics } from './metrics';
import { MetricsExplorerGroupBy } from './group_by';
import { MetricsExplorerAggregationPicker } from './aggregation';
interface Props {
intl: InjectedIntl;
derivedIndexPattern: StaticIndexPattern;
timeRange: MetricsExplorerTimeOptions;
options: MetricsExplorerOptions;
onRefresh: () => void;
onTimeChange: (start: string, end: string) => void;
onGroupByChange: (groupBy: string | null) => void;
onFilterQuerySubmit: (query: string) => void;
onMetricsChange: (metrics: MetricsExplorerMetric[]) => void;
onAggregationChange: (aggregation: MetricsExplorerAggregation) => void;
}
export const MetricsExplorerToolbar = injectI18n(
({
timeRange,
derivedIndexPattern,
options,
onTimeChange,
onRefresh,
onGroupByChange,
onFilterQuerySubmit,
onMetricsChange,
onAggregationChange,
}: Props) => {
const isDefaultOptions =
options.aggregation === MetricsExplorerAggregation.avg && options.metrics.length === 0;
return (
<Toolbar>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={options.aggregation === MetricsExplorerAggregation.count ? 2 : false}>
<MetricsExplorerAggregationPicker
fullWidth
options={options}
onChange={onAggregationChange}
/>
</EuiFlexItem>
{options.aggregation !== MetricsExplorerAggregation.count && (
<EuiText size="s" color="subdued">
<FormattedMessage
id="xpack.infra.metricsExplorer.aggregationLabel"
defaultMessage="of"
/>
</EuiText>
)}
{options.aggregation !== MetricsExplorerAggregation.count && (
<EuiFlexItem grow={2}>
<MetricsExplorerMetrics
autoFocus={isDefaultOptions}
fields={derivedIndexPattern.fields}
options={options}
onChange={onMetricsChange}
/>
</EuiFlexItem>
)}
<EuiText size="s" color="subdued">
<FormattedMessage
id="xpack.infra.metricsExplorer.groupByToolbarLabel"
defaultMessage="graph per"
/>
</EuiText>
<EuiFlexItem grow={1}>
<MetricsExplorerGroupBy
onChange={onGroupByChange}
fields={derivedIndexPattern.fields}
options={options}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem>
<MetricsExplorerKueryBar
derivedIndexPattern={derivedIndexPattern}
onSubmit={onFilterQuerySubmit}
value={options.filterQuery}
/>
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ marginRight: 5 }}>
<EuiSuperDatePicker
start={timeRange.from}
end={timeRange.to}
onTimeChange={({ start, end }) => onTimeChange(start, end)}
onRefresh={onRefresh}
/>
</EuiFlexItem>
</EuiFlexGroup>
</Toolbar>
);
}
);

View file

@ -0,0 +1,175 @@
/*
* 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 { fetch } from '../../utils/fetch';
import { useMetricsExplorerData } from './use_metrics_explorer_data';
import { MetricsExplorerAggregation } from '../../../server/routes/metrics_explorer/types';
import { renderHook } from 'react-hooks-testing-library';
import {
options,
source,
derivedIndexPattern,
timeRange,
resp,
createSeries,
} from '../../utils/fixtures/metrics_explorer';
const renderUseMetricsExplorerDataHook = () =>
renderHook(
props =>
useMetricsExplorerData(
props.options,
props.source,
props.derivedIndexPattern,
props.timeRange,
props.afterKey,
props.signal
),
{
initialProps: {
options,
source,
derivedIndexPattern,
timeRange,
afterKey: null as string | null,
signal: 1,
},
}
);
jest.mock('../../utils/fetch');
const mockedFetch = fetch as jest.Mocked<typeof fetch>;
describe('useMetricsExplorerData Hook', () => {
it('should just work', async () => {
mockedFetch.post.mockResolvedValue({ data: resp } as any);
const { result, waitForNextUpdate } = renderUseMetricsExplorerDataHook();
expect(result.current.data).toBe(null);
expect(result.current.loading).toBe(true);
await waitForNextUpdate();
expect(result.current.data).toEqual(resp);
expect(result.current.loading).toBe(false);
const { series } = result.current.data!;
expect(series).toBeDefined();
expect(series.length).toBe(3);
});
it('should paginate', async () => {
mockedFetch.post.mockResolvedValue({ data: resp } as any);
const { result, waitForNextUpdate, rerender } = renderUseMetricsExplorerDataHook();
expect(result.current.data).toBe(null);
expect(result.current.loading).toBe(true);
await waitForNextUpdate();
expect(result.current.data).toEqual(resp);
expect(result.current.loading).toBe(false);
const { series, pageInfo } = result.current.data!;
expect(series).toBeDefined();
expect(series.length).toBe(3);
mockedFetch.post.mockResolvedValue({
data: {
pageInfo: { total: 10, afterKey: 'host-06' },
series: [createSeries('host-04'), createSeries('host-05'), createSeries('host-06')],
},
} as any);
rerender({
options,
source,
derivedIndexPattern,
timeRange,
afterKey: pageInfo.afterKey!,
signal: 1,
});
expect(result.current.loading).toBe(true);
await waitForNextUpdate();
expect(result.current.loading).toBe(false);
const { series: nextSeries } = result.current.data!;
expect(nextSeries).toBeDefined();
expect(nextSeries.length).toBe(6);
});
it('should reset error upon recovery', async () => {
const error = new Error('Network Error');
mockedFetch.post.mockRejectedValue(error);
const { result, waitForNextUpdate, rerender } = renderUseMetricsExplorerDataHook();
expect(result.current.data).toBe(null);
expect(result.current.error).toEqual(null);
expect(result.current.loading).toBe(true);
await waitForNextUpdate();
expect(result.current.data).toEqual(null);
expect(result.current.error).toEqual(error);
expect(result.current.loading).toBe(false);
mockedFetch.post.mockResolvedValue({ data: resp } as any);
rerender({
options,
source,
derivedIndexPattern,
timeRange,
afterKey: null,
signal: 2,
});
await waitForNextUpdate();
expect(result.current.data).toEqual(resp);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(null);
});
it('should not paginate on option change', async () => {
mockedFetch.post.mockResolvedValue({ data: resp } as any);
const { result, waitForNextUpdate, rerender } = renderUseMetricsExplorerDataHook();
expect(result.current.data).toBe(null);
expect(result.current.loading).toBe(true);
await waitForNextUpdate();
expect(result.current.data).toEqual(resp);
expect(result.current.loading).toBe(false);
const { series, pageInfo } = result.current.data!;
expect(series).toBeDefined();
expect(series.length).toBe(3);
mockedFetch.post.mockResolvedValue({ data: resp } as any);
rerender({
options: {
...options,
aggregation: MetricsExplorerAggregation.count,
metrics: [{ aggregation: MetricsExplorerAggregation.count }],
},
source,
derivedIndexPattern,
timeRange,
afterKey: pageInfo.afterKey!,
signal: 1,
});
expect(result.current.loading).toBe(true);
await waitForNextUpdate();
expect(result.current.data).toEqual(resp);
expect(result.current.loading).toBe(false);
});
it('should not paginate on time change', async () => {
mockedFetch.post.mockResolvedValue({ data: resp } as any);
const { result, waitForNextUpdate, rerender } = renderUseMetricsExplorerDataHook();
expect(result.current.data).toBe(null);
expect(result.current.loading).toBe(true);
await waitForNextUpdate();
expect(result.current.data).toEqual(resp);
expect(result.current.loading).toBe(false);
const { series, pageInfo } = result.current.data!;
expect(series).toBeDefined();
expect(series.length).toBe(3);
mockedFetch.post.mockResolvedValue({ data: resp } as any);
rerender({
options,
source,
derivedIndexPattern,
timeRange: { from: 'now-1m', to: 'now', interval: '>=1m' },
afterKey: pageInfo.afterKey!,
signal: 1,
});
expect(result.current.loading).toBe(true);
await waitForNextUpdate();
expect(result.current.data).toEqual(resp);
expect(result.current.loading).toBe(false);
});
});

View file

@ -0,0 +1,104 @@
/*
* 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 DateMath from '@elastic/datemath';
import { isEqual } from 'lodash';
import { useEffect, useState } from 'react';
import { StaticIndexPattern } from 'ui/index_patterns';
import { SourceQuery } from '../../../common/graphql/types';
import {
MetricsExplorerAggregation,
MetricsExplorerResponse,
} from '../../../server/routes/metrics_explorer/types';
import { fetch } from '../../utils/fetch';
import { convertKueryToElasticSearchQuery } from '../../utils/kuery';
import { MetricsExplorerOptions, MetricsExplorerTimeOptions } from './use_metrics_explorer_options';
function isSameOptions(current: MetricsExplorerOptions, next: MetricsExplorerOptions) {
return isEqual(current, next);
}
export function useMetricsExplorerData(
options: MetricsExplorerOptions,
source: SourceQuery.Query['source']['configuration'],
derivedIndexPattern: StaticIndexPattern,
timerange: MetricsExplorerTimeOptions,
afterKey: string | null,
signal: any
) {
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [data, setData] = useState<MetricsExplorerResponse | null>(null);
const [lastOptions, setLastOptions] = useState<MetricsExplorerOptions | null>(null);
const [lastTimerange, setLastTimerange] = useState<MetricsExplorerTimeOptions | null>(null);
useEffect(
() => {
(async () => {
setLoading(true);
try {
const from = DateMath.parse(timerange.from);
const to = DateMath.parse(timerange.to, { roundUp: true });
if (!from || !to) {
throw new Error('Unalble to parse timerange');
}
const response = await fetch.post<MetricsExplorerResponse>(
'../api/infra/metrics_explorer',
{
metrics:
options.aggregation === MetricsExplorerAggregation.count
? [{ aggregation: MetricsExplorerAggregation.count }]
: options.metrics.map(metric => ({
aggregation: metric.aggregation,
field: metric.field,
})),
groupBy: options.groupBy,
afterKey,
limit: options.limit,
indexPattern: source.metricAlias,
filterQuery:
(options.filterQuery &&
convertKueryToElasticSearchQuery(options.filterQuery, derivedIndexPattern)) ||
void 0,
timerange: {
...timerange,
field: source.fields.timestamp,
from: from.valueOf(),
to: to.valueOf(),
},
}
);
if (response.data) {
if (
data &&
lastOptions &&
data.pageInfo.afterKey !== response.data.pageInfo.afterKey &&
isSameOptions(lastOptions, options) &&
isEqual(timerange, lastTimerange) &&
afterKey
) {
const { series } = data;
setData({
...response.data,
series: [...series, ...response.data.series],
});
} else {
setData(response.data);
}
setLastOptions(options);
setLastTimerange(timerange);
setError(null);
}
} catch (e) {
setError(e);
}
setLoading(false);
})();
},
[options, source, timerange, signal, afterKey]
);
return { error, loading, data };
}

View file

@ -0,0 +1,60 @@
/*
* 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 createContainer from 'constate-latest';
import { useState } from 'react';
import { MetricsExplorerColor } from '../../../common/color_palette';
import {
MetricsExplorerAggregation,
MetricsExplorerMetric,
} from '../../../server/routes/metrics_explorer/types';
export type MetricsExplorerOptionsMetric = MetricsExplorerMetric & {
color?: MetricsExplorerColor;
label?: string;
};
export interface MetricsExplorerOptions {
metrics: MetricsExplorerOptionsMetric[];
limit?: number;
groupBy?: string;
filterQuery?: string;
aggregation: MetricsExplorerAggregation;
}
export interface MetricsExplorerTimeOptions {
from: string;
to: string;
interval: string;
}
const DEFAULT_TIMERANGE: MetricsExplorerTimeOptions = {
from: 'now-1h',
to: 'now',
interval: '>=10s',
};
const DEFAULT_OPTIONS: MetricsExplorerOptions = {
aggregation: MetricsExplorerAggregation.avg,
metrics: [],
};
export const useMetricsExplorerOptions = () => {
const [options, setOptions] = useState<MetricsExplorerOptions>(DEFAULT_OPTIONS);
const [currentTimerange, setTimeRange] = useState<MetricsExplorerTimeOptions>(DEFAULT_TIMERANGE);
const [isAutoReloading, setAutoReloading] = useState<boolean>(false);
return {
options,
currentTimerange,
isAutoReloading,
setOptions,
setTimeRange,
startAutoReload: () => setAutoReloading(true),
stopAutoReload: () => setAutoReloading(false),
};
};
export const MetricsExplorerOptionsContainer = createContainer(useMetricsExplorerOptions);

View file

@ -0,0 +1,113 @@
/*
* 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 Joi from 'joi';
import { set, values } from 'lodash';
import React, { useContext, useMemo } from 'react';
import { MetricsExplorerColor } from '../../../common/color_palette';
import { MetricsExplorerAggregation } from '../../../server/routes/metrics_explorer/types';
import { UrlStateContainer } from '../../utils/url_state';
import {
MetricsExplorerOptions,
MetricsExplorerOptionsContainer,
MetricsExplorerTimeOptions,
} from './use_metrics_explorer_options';
interface MetricsExplorerUrlState {
timerange?: MetricsExplorerTimeOptions;
options?: MetricsExplorerOptions;
}
export const WithMetricsExplorerOptionsUrlState = () => {
const { options, currentTimerange, setOptions: setRawOptions, setTimeRange } = useContext(
MetricsExplorerOptionsContainer.Context
);
const setOptions = (value: MetricsExplorerOptions) => {
setRawOptions(value);
};
const urlState = useMemo(
() => ({
options,
timerange: currentTimerange,
}),
[options, currentTimerange]
);
return (
<UrlStateContainer
urlState={urlState}
urlStateKey="metricsExplorer"
mapToUrlState={mapToUrlState}
onChange={newUrlState => {
if (newUrlState && newUrlState.options) {
setOptions(newUrlState.options);
}
if (newUrlState && newUrlState.timerange) {
setTimeRange(newUrlState.timerange);
}
}}
onInitialize={newUrlState => {
if (newUrlState && newUrlState.options) {
setOptions(newUrlState.options);
}
if (newUrlState && newUrlState.timerange) {
setTimeRange(newUrlState.timerange);
}
}}
/>
);
};
function isMetricExplorerOptions(subject: any): subject is MetricsExplorerOptions {
const schema = Joi.object({
limit: Joi.number()
.min(1)
.default(9),
groupBy: Joi.string(),
filterQuery: Joi.string().allow(''),
aggregation: Joi.string().required(),
metrics: Joi.array()
.items(
Joi.object().keys({
aggregation: Joi.string()
.valid(values(MetricsExplorerAggregation))
.required(),
field: Joi.string(),
rate: Joi.bool().default(false),
color: Joi.string().valid(values(MetricsExplorerColor)),
label: Joi.string(),
})
)
.required(),
});
const validation = Joi.validate(subject, schema);
return validation.error == null;
}
function isMetricExplorerTimeOption(subject: any): subject is MetricsExplorerTimeOptions {
const schema = Joi.object({
from: Joi.string().required(),
to: Joi.string().required(),
interval: Joi.string().required(),
});
const validation = Joi.validate(subject, schema);
return validation.error == null;
}
const mapToUrlState = (value: any): MetricsExplorerUrlState | undefined => {
const finalState = {};
if (value) {
if (value.options && isMetricExplorerOptions(value.options)) {
set(finalState, 'options', value.options);
}
if (value.timerange && isMetricExplorerTimeOption(value.timerange)) {
set(finalState, 'timerange', value.timerange);
}
return finalState;
}
};

View file

@ -597,13 +597,7 @@ export namespace FlyoutItemQuery {
fields: Fields[];
};
export type Key = {
__typename?: 'InfraTimeKey';
time: number;
tiebreaker: number;
};
export type Key = InfraTimeKeyFields.Fragment;
export type Fields = {
__typename?: 'InfraLogItemField';

View file

@ -12,6 +12,9 @@ import { DocumentTitle } from '../../components/document_title';
import { HelpCenterContent } from '../../components/help_center_content';
import { RoutedTabs } from '../../components/navigation/routed_tabs';
import { ColumnarPage } from '../../components/page';
import { MetricsExplorerOptionsContainer } from '../../containers/metrics_explorer/use_metrics_explorer_options';
import { WithMetricsExplorerOptionsUrlState } from '../../containers/metrics_explorer/with_metrics_explorer_options_url_state';
import { WithSource } from '../../containers/with_source';
import { SourceConfigurationFlyoutState } from '../../components/source_configuration';
import { Source } from '../../containers/source';
import { MetricsExplorerPage } from './metrics_explorer';
@ -46,16 +49,32 @@ export const InfrastructurePage = injectI18n(({ match, intl }: InfrastructurePag
title: 'Snapshot',
path: `${match.path}/snapshot`,
},
// {
// title: 'Metrics explorer',
// path: `${match.path}/metrics-explorer`,
// },
{
title: 'Metrics explorer',
path: `${match.path}/metrics-explorer`,
},
]}
/>
<Switch>
<Route path={`${match.path}/snapshot`} component={SnapshotPage} />
<Route path={`${match.path}/metrics-explorer`} component={MetricsExplorerPage} />
<Route
path={`${match.path}/metrics-explorer`}
render={props => (
<WithSource>
{({ configuration, derivedIndexPattern }) => (
<MetricsExplorerOptionsContainer.Provider>
<WithMetricsExplorerOptionsUrlState />
<MetricsExplorerPage
derivedIndexPattern={derivedIndexPattern}
source={configuration}
{...props}
/>
</MetricsExplorerOptionsContainer.Provider>
)}
</WithSource>
)}
/>
</Switch>
</ColumnarPage>
</SourceConfigurationFlyoutState.Provider>

View file

@ -6,27 +6,93 @@
import { InjectedIntl, injectI18n } from '@kbn/i18n/react';
import React from 'react';
import { StaticIndexPattern } from 'ui/index_patterns';
import { DocumentTitle } from '../../../components/document_title';
import { MetricsExplorerCharts } from '../../../components/metrics_explorer/charts';
import { MetricsExplorerToolbar } from '../../../components/metrics_explorer/toolbar';
import { SourceQuery } from '../../../../common/graphql/types';
import { NoData } from '../../../components/empty_states';
import { useMetricsExplorerState } from './use_metric_explorer_state';
interface MetricsExplorerPageProps {
intl: InjectedIntl;
source: SourceQuery.Query['source']['configuration'] | undefined;
derivedIndexPattern: StaticIndexPattern;
}
export const MetricsExplorerPage = injectI18n(({ intl }: MetricsExplorerPageProps) => (
<div>
<DocumentTitle
title={(previousTitle: string) =>
intl.formatMessage(
{
id: 'xpack.infra.infrastructureMetricsExplorerPage.documentTitle',
defaultMessage: '{previousTitle} | Metrics explorer',
},
{
previousTitle,
export const MetricsExplorerPage = injectI18n(
({ intl, source, derivedIndexPattern }: MetricsExplorerPageProps) => {
if (!source) {
return null;
}
const {
loading,
error,
data,
currentTimerange,
options,
handleAggregationChange,
handleMetricsChange,
handleFilterQuerySubmit,
handleGroupByChange,
handleTimeChange,
handleRefresh,
handleLoadMore,
} = useMetricsExplorerState(source, derivedIndexPattern);
return (
<div>
<DocumentTitle
title={(previousTitle: string) =>
intl.formatMessage(
{
id: 'xpack.infra.infrastructureMetricsExplorerPage.documentTitle',
defaultMessage: '{previousTitle} | Metrics explorer',
},
{
previousTitle,
}
)
}
)
}
/>
Metrics Explorer
</div>
));
/>
<MetricsExplorerToolbar
derivedIndexPattern={derivedIndexPattern}
timeRange={currentTimerange}
options={options}
onRefresh={handleRefresh}
onTimeChange={handleTimeChange}
onGroupByChange={handleGroupByChange}
onFilterQuerySubmit={handleFilterQuerySubmit}
onMetricsChange={handleMetricsChange}
onAggregationChange={handleAggregationChange}
/>
{error ? (
<NoData
titleText="Whoops!"
bodyText={intl.formatMessage(
{
id: 'xpack.infra.metricsExplorer.errorMessage',
defaultMessage: 'It looks like the request failed with "{message}"',
},
{ message: error.message }
)}
onRefetch={handleRefresh}
refetchText="Try Again"
/>
) : (
<MetricsExplorerCharts
timeRange={currentTimerange}
loading={loading}
data={data}
source={source}
options={options}
onLoadMore={handleLoadMore}
onFilter={handleFilterQuerySubmit}
onRefetch={handleRefresh}
/>
)}
</div>
);
}
);

View file

@ -0,0 +1,174 @@
/*
* 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 { fetch } from '../../../utils/fetch';
import { renderHook } from 'react-hooks-testing-library';
import { useMetricsExplorerState } from './use_metric_explorer_state';
import { MetricsExplorerOptionsContainer } from '../../../containers/metrics_explorer/use_metrics_explorer_options';
import React from 'react';
import {
source,
derivedIndexPattern,
resp,
createSeries,
} from '../../../utils/fixtures/metrics_explorer';
import { MetricsExplorerAggregation } from '../../../../server/routes/metrics_explorer/types';
const renderUseMetricsExplorerStateHook = () =>
renderHook(props => useMetricsExplorerState(props.source, props.derivedIndexPattern), {
initialProps: { source, derivedIndexPattern },
wrapper: ({ children }) => (
<MetricsExplorerOptionsContainer.Provider>
{children}
</MetricsExplorerOptionsContainer.Provider>
),
});
jest.mock('../../../utils/fetch');
const mockedFetch = fetch as jest.Mocked<typeof fetch>;
describe('useMetricsExplorerState', () => {
beforeEach(() => mockedFetch.post.mockResolvedValue({ data: resp } as any));
it('should just work', async () => {
const { result, waitForNextUpdate } = renderUseMetricsExplorerStateHook();
expect(result.current.data).toBe(null);
expect(result.current.error).toBe(null);
expect(result.current.loading).toBe(true);
await waitForNextUpdate();
expect(result.current.data).toEqual(resp);
expect(result.current.loading).toBe(false);
const { series } = result.current.data!;
expect(series).toBeDefined();
expect(series.length).toBe(3);
});
describe('handleRefresh', () => {
it('should trigger an addition request when handleRefresh is called', async () => {
const { result, waitForNextUpdate } = renderUseMetricsExplorerStateHook();
await waitForNextUpdate();
expect(mockedFetch.post.mock.calls.length).toBe(2);
const { handleRefresh } = result.current;
handleRefresh();
await waitForNextUpdate();
expect(mockedFetch.post.mock.calls.length).toBe(3);
});
});
describe('handleMetricsChange', () => {
it('should change the metric', async () => {
const { result } = renderUseMetricsExplorerStateHook();
const { handleMetricsChange } = result.current;
handleMetricsChange([
{ aggregation: MetricsExplorerAggregation.max, field: 'system.load.1' },
]);
expect(result.current.options.metrics).toEqual([
{ aggregation: MetricsExplorerAggregation.max, field: 'system.load.1' },
]);
});
});
describe('handleGroupByChange', () => {
it('should change the metric', async () => {
const { result } = renderUseMetricsExplorerStateHook();
const { handleGroupByChange } = result.current;
handleGroupByChange('host.name');
expect(result.current.options.groupBy).toBeDefined();
expect(result.current.options.groupBy).toBe('host.name');
});
});
describe('handleTimeChange', () => {
it('should change the time range', async () => {
const { result } = renderUseMetricsExplorerStateHook();
const { handleTimeChange } = result.current;
handleTimeChange('now-10m', 'now');
expect(result.current.currentTimerange).toEqual({
from: 'now-10m',
to: 'now',
interval: '>=10s',
});
});
});
describe('handleFilterQuerySubmit', () => {
it('should set the filter query', async () => {
const { result } = renderUseMetricsExplorerStateHook();
const { handleFilterQuerySubmit } = result.current;
handleFilterQuerySubmit('host.name: "example-host-01"');
expect(result.current.options.filterQuery).toBe('host.name: "example-host-01"');
});
});
describe('handleAggregationChange', () => {
it('should set the metrics to only count when selecting count', async () => {
const { result, waitForNextUpdate } = renderUseMetricsExplorerStateHook();
const { handleMetricsChange } = result.current;
handleMetricsChange([
{ aggregation: MetricsExplorerAggregation.avg, field: 'system.load.1' },
]);
expect(result.current.options.metrics).toEqual([
{ aggregation: MetricsExplorerAggregation.avg, field: 'system.load.1' },
]);
await waitForNextUpdate();
const { handleAggregationChange } = result.current;
handleAggregationChange(MetricsExplorerAggregation.count);
await waitForNextUpdate();
expect(result.current.options.aggregation).toBe(MetricsExplorerAggregation.count);
expect(result.current.options.metrics).toEqual([
{ aggregation: MetricsExplorerAggregation.count },
]);
});
it('should change aggregation for metrics', async () => {
const { result, waitForNextUpdate } = renderUseMetricsExplorerStateHook();
const { handleMetricsChange } = result.current;
handleMetricsChange([
{ aggregation: MetricsExplorerAggregation.avg, field: 'system.load.1' },
]);
expect(result.current.options.metrics).toEqual([
{ aggregation: MetricsExplorerAggregation.avg, field: 'system.load.1' },
]);
await waitForNextUpdate();
const { handleAggregationChange } = result.current;
handleAggregationChange(MetricsExplorerAggregation.max);
await waitForNextUpdate();
expect(result.current.options.aggregation).toBe(MetricsExplorerAggregation.max);
expect(result.current.options.metrics).toEqual([
{ aggregation: MetricsExplorerAggregation.max, field: 'system.load.1' },
]);
});
});
describe('handleLoadMore', () => {
it('should load more based on the afterKey', async () => {
const { result, waitForNextUpdate } = renderUseMetricsExplorerStateHook();
expect(result.current.data).toBe(null);
expect(result.current.loading).toBe(true);
await waitForNextUpdate();
expect(result.current.data).toEqual(resp);
expect(result.current.loading).toBe(false);
const { series, pageInfo } = result.current.data!;
expect(series).toBeDefined();
expect(series.length).toBe(3);
mockedFetch.post.mockResolvedValue({
data: {
pageInfo: { total: 10, afterKey: 'host-06' },
series: [createSeries('host-04'), createSeries('host-05'), createSeries('host-06')],
},
} as any);
const { handleLoadMore } = result.current;
handleLoadMore(pageInfo.afterKey!);
await waitForNextUpdate();
expect(result.current.loading).toBe(true);
await waitForNextUpdate();
expect(result.current.loading).toBe(false);
const { series: nextSeries } = result.current.data!;
expect(nextSeries).toBeDefined();
expect(nextSeries.length).toBe(6);
});
});
});

View file

@ -0,0 +1,116 @@
/*
* 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 { useState, useCallback, useContext } from 'react';
import { StaticIndexPattern } from 'ui/index_patterns';
import {
MetricsExplorerMetric,
MetricsExplorerAggregation,
} from '../../../../server/routes/metrics_explorer/types';
import { useMetricsExplorerData } from '../../../containers/metrics_explorer/use_metrics_explorer_data';
import { MetricsExplorerOptionsContainer } from '../../../containers/metrics_explorer/use_metrics_explorer_options';
import { SourceQuery } from '../../../graphql/types';
export const useMetricsExplorerState = (
source: SourceQuery.Query['source']['configuration'],
derivedIndexPattern: StaticIndexPattern
) => {
const [refreshSignal, setRefreshSignal] = useState(0);
const [afterKey, setAfterKey] = useState<string | null>(null);
const { options, currentTimerange, setTimeRange, setOptions } = useContext(
MetricsExplorerOptionsContainer.Context
);
const { loading, error, data } = useMetricsExplorerData(
options,
source,
derivedIndexPattern,
currentTimerange,
afterKey,
refreshSignal
);
const handleRefresh = useCallback(
() => {
setAfterKey(null);
setRefreshSignal(refreshSignal + 1);
},
[refreshSignal]
);
const handleTimeChange = useCallback(
(start: string, end: string) => {
setOptions({ ...options });
setAfterKey(null);
setTimeRange({ ...currentTimerange, from: start, to: end });
},
[options, currentTimerange]
);
const handleGroupByChange = useCallback(
(groupBy: string | null) => {
setAfterKey(null);
setOptions({
...options,
groupBy: groupBy || void 0,
});
},
[options]
);
const handleFilterQuerySubmit = useCallback(
(query: string) => {
setAfterKey(null);
setOptions({
...options,
filterQuery: query,
});
},
[options]
);
const handleMetricsChange = useCallback(
(metrics: MetricsExplorerMetric[]) => {
setAfterKey(null);
setOptions({
...options,
metrics,
});
},
[options]
);
const handleAggregationChange = useCallback(
(aggregation: MetricsExplorerAggregation) => {
setAfterKey(null);
const metrics =
aggregation === MetricsExplorerAggregation.count
? [{ aggregation }]
: options.metrics
.filter(metric => metric.aggregation !== MetricsExplorerAggregation.count)
.map(metric => ({
...metric,
aggregation,
}));
setOptions({ ...options, aggregation, metrics });
},
[options]
);
return {
loading,
error,
data,
currentTimerange,
options,
handleAggregationChange,
handleMetricsChange,
handleFilterQuerySubmit,
handleGroupByChange,
handleTimeChange,
handleRefresh,
handleLoadMore: setAfterKey,
};
};

View file

@ -48,6 +48,7 @@ export const SnapshotToolbar = injectI18n(({ intl }) => (
})}
suggestions={suggestions}
value={filterQueryDraft ? filterQueryDraft.expression : ''}
autoFocus={true}
/>
)}
</WithWaffleFilter>

View file

@ -0,0 +1,193 @@
/*
* 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.
*/
/**
* Components using the react-intl module require access to the intl context.
* This is not available when mounting single components in Enzyme.
* These helper functions aim to address that and wrap a valid,
* intl context around them.
*/
import { I18nProvider, InjectedIntl, intlShape } from '@kbn/i18n/react';
import { mount, ReactWrapper, render, shallow } from 'enzyme';
import React, { ReactElement, ValidationMap } from 'react';
import { act as reactAct } from 'react-dom/test-utils';
// Use fake component to extract `intl` property to use in tests.
const { intl } = (mount(
<I18nProvider>
<br />
</I18nProvider>
).find('IntlProvider') as ReactWrapper<{}, {}, import('react-intl').IntlProvider>)
.instance()
.getChildContext();
function getOptions(context = {}, childContextTypes = {}, props = {}) {
return {
context: {
...context,
intl,
},
childContextTypes: {
...childContextTypes,
intl: intlShape,
},
...props,
};
}
/**
* When using React-Intl `injectIntl` on components, props.intl is required.
*/
function nodeWithIntlProp<T>(node: ReactElement<T>): ReactElement<T & { intl: InjectedIntl }> {
return React.cloneElement<any>(node, { intl });
}
/**
* Creates the wrapper instance using shallow with provided intl object into context
*
* @param node The React element or cheerio wrapper
* @param options properties to pass into shallow wrapper
* @return The wrapper instance around the rendered output with intl object in context
*/
export function shallowWithIntl<T>(
node: ReactElement<T>,
{
context,
childContextTypes,
...props
}: {
context?: any;
childContextTypes?: ValidationMap<any>;
} = {}
) {
const options = getOptions(context, childContextTypes, props);
return shallow(nodeWithIntlProp(node), options);
}
/**
* Creates the wrapper instance using mount with provided intl object into context
*
* @param node The React element or cheerio wrapper
* @param options properties to pass into mount wrapper
* @return The wrapper instance around the rendered output with intl object in context
*/
export function mountWithIntl<T>(
node: ReactElement<T>,
{
context,
childContextTypes,
...props
}: {
context?: any;
childContextTypes?: ValidationMap<any>;
} = {}
) {
const options = getOptions(context, childContextTypes, props);
return mount(nodeWithIntlProp(node), options);
}
/**
* Creates the wrapper instance using render with provided intl object into context
*
* @param node The React element or cheerio wrapper
* @param options properties to pass into render wrapper
* @return The wrapper instance around the rendered output with intl object in context
*/
export function renderWithIntl<T>(
node: ReactElement<T>,
{
context,
childContextTypes,
...props
}: {
context?: any;
childContextTypes?: ValidationMap<any>;
} = {}
) {
const options = getOptions(context, childContextTypes, props);
return render(nodeWithIntlProp(node), options);
}
/**
* A wrapper object to provide access to the state of a hook under test and to
* enable interaction with that hook.
*/
interface ReactHookWrapper<Args, HookValue> {
/* Ensures that async React operations have settled before and after the
* given actor callback is called. The actor callback arguments provide easy
* access to the last hook value and allow for updating the arguments passed
* to the hook body to trigger reevaluation.
*/
act: (actor: (lastHookValue: HookValue, setArgs: (args: Args) => void) => void) => void;
/* The enzyme wrapper around the test component. */
component: ReactWrapper;
/* The most recent value return the by test harness of the hook. */
getLastHookValue: () => HookValue;
/* The jest Mock function that receives the hook values for introspection. */
hookValueCallback: jest.Mock;
}
/**
* Allows for execution of hooks inside of a test component which records the
* returned values.
*
* @param body A function that calls the hook and returns data derived from it
* @param WrapperComponent A component that, if provided, will be wrapped
* around the test component. This can be useful to provide context values.
* @return {ReactHookWrapper} An object providing access to the hook state and
* functions to interact with it.
*/
export const mountHook = <Args extends {}, HookValue extends any>(
body: (args: Args) => HookValue,
WrapperComponent?: React.ComponentType,
initialArgs: Args = {} as Args
): ReactHookWrapper<Args, HookValue> => {
const hookValueCallback = jest.fn();
let component!: ReactWrapper;
const act: ReactHookWrapper<Args, HookValue>['act'] = actor => {
reactAct(() => {
actor(getLastHookValue(), (args: Args) => component.setProps(args));
component.update();
});
};
const getLastHookValue = () => {
const calls = hookValueCallback.mock.calls;
if (calls.length <= 0) {
throw Error('No recent hook value present.');
}
return calls[calls.length - 1][0];
};
const HookComponent = (props: Args) => {
hookValueCallback(body(props));
return null;
};
const TestComponent: React.FunctionComponent<Args> = args =>
WrapperComponent ? (
<WrapperComponent>
<HookComponent {...args} />
</WrapperComponent>
) : (
<HookComponent {...args} />
);
reactAct(() => {
component = mount(<TestComponent {...initialArgs} />);
});
return {
act,
component,
getLastHookValue,
hookValueCallback,
};
};

View file

@ -0,0 +1,17 @@
/*
* 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 axios from 'axios';
const FETCH_TIMEOUT = 30000;
export const fetch = axios.create({
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'kbn-xsrf': 'professionally-crafted-string-of-text',
},
timeout: FETCH_TIMEOUT,
});

View file

@ -0,0 +1,66 @@
/*
* 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 {
MetricsExplorerAggregation,
MetricsExplorerResponse,
MetricsExplorerSeries,
MetricsExplorerColumnType,
} from '../../../server/routes/metrics_explorer/types';
import {
MetricsExplorerOptions,
MetricsExplorerTimeOptions,
} from '../../containers/metrics_explorer/use_metrics_explorer_options';
export const options: MetricsExplorerOptions = {
limit: 3,
groupBy: 'host.name',
aggregation: MetricsExplorerAggregation.avg,
metrics: [{ aggregation: MetricsExplorerAggregation.avg, field: 'system.cpu.user.pct' }],
};
export const source = {
name: 'default',
description: '',
logAlias: 'filebeat-*',
metricAlias: 'metricbeat-*',
logColumns: [],
fields: {
host: 'host.name',
container: 'container.id',
pod: 'kubernetes.pod.uid',
timestamp: '@timestamp',
message: ['message'],
tiebreaker: '@timestamp',
},
};
export const derivedIndexPattern = { title: 'metricbeat-*', fields: [] };
export const timeRange: MetricsExplorerTimeOptions = {
from: 'now-1h',
to: 'now',
interval: '>=10s',
};
export const createSeries = (id: string): MetricsExplorerSeries => ({
id,
columns: [
{ name: 'timestamp', type: MetricsExplorerColumnType.date },
{ name: 'metric_0', type: MetricsExplorerColumnType.number },
{ name: 'groupBy', type: MetricsExplorerColumnType.string },
],
rows: [
{ timestamp: 1, metric_0: 0.5, groupBy: id },
{ timestamp: 2, metric_0: 0.5, groupBy: id },
{ timestamp: 3, metric_0: 0.5, groupBy: id },
],
});
export const resp: MetricsExplorerResponse = {
pageInfo: { total: 10, afterKey: 'host-04' },
series: [createSeries('host-01'), createSeries('host-02'), createSeries('host-03')],
};

View file

@ -14,6 +14,7 @@ import { createSourceStatusResolvers } from './graphql/source_status';
import { createSourcesResolvers } from './graphql/sources';
import { InfraBackendLibs } from './lib/infra_types';
import { initLegacyLoggingRoutes } from './logging_legacy';
import { initMetricExplorerRoute } from './routes/metrics_explorer';
export const initInfraServer = (libs: InfraBackendLibs) => {
const schema = makeExecutableSchema({
@ -31,4 +32,5 @@ export const initInfraServer = (libs: InfraBackendLibs) => {
libs.framework.registerGraphQLEndpoint('/api/infra/graphql', schema);
initLegacyLoggingRoutes(libs.framework);
initMetricExplorerRoute(libs);
};

View file

@ -87,6 +87,7 @@ export class InfraKibanaBackendFrameworkAdapter implements InfraBackendFramework
this.server.route({
handler: wrappedHandler,
options: route.options,
method: route.method,
path: route.path,
});

View file

@ -64,7 +64,7 @@ export interface InfraMetricModelSeries {
export interface InfraMetricModelBasicMetric {
id: string;
field: string;
field?: string | null;
type: InfraMetricModelMetricType;
}

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import * as Boom from 'boom';
import { boomify } from 'boom';
import { SearchParams } from 'elasticsearch';
import * as Joi from 'joi';
@ -100,7 +100,7 @@ export const initAdjacentSearchResultsRoutes = (framework: InfraBackendFramework
timings,
};
} catch (requestError) {
throw Boom.boomify(requestError);
throw boomify(requestError);
}
},
method: 'POST',

View file

@ -0,0 +1,44 @@
/*
* 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 { boomify } from 'boom';
import { InfraBackendLibs } from '../../lib/infra_types';
import { getGroupings } from './lib/get_groupings';
import { populateSeriesWithTSVBData } from './lib/populate_series_with_tsvb_data';
import { metricsExplorerSchema } from './schema';
import { MetricsExplorerResponse, MetricsExplorerWrappedRequest } from './types';
export const initMetricExplorerRoute = (libs: InfraBackendLibs) => {
const { framework } = libs;
const { callWithRequest } = framework;
framework.registerRoute<MetricsExplorerWrappedRequest, Promise<MetricsExplorerResponse>>({
method: 'POST',
path: '/api/infra/metrics_explorer',
options: {
validate: {
payload: metricsExplorerSchema,
},
},
handler: async req => {
try {
const search = <Aggregation>(searchOptions: object) =>
callWithRequest<{}, Aggregation>(req, 'search', searchOptions);
const options = req.payload;
// First we get the groupings from a composite aggregation
const response = await getGroupings(search, options);
// Then we take the results and fill in the data from TSVB with the
// user's custom metrics
const seriesWithMetrics = await Promise.all(
response.series.map(populateSeriesWithTSVBData(req, options, framework))
);
return { ...response, series: seriesWithMetrics };
} catch (error) {
throw boomify(error);
}
},
});
};

View file

@ -0,0 +1,66 @@
/*
* 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 { InfraMetricModel, InfraMetricModelMetricType } from '../../../lib/adapters/metrics';
import { MetricsExplorerAggregation, MetricsExplorerRequest } from '../types';
export const createMetricModel = (options: MetricsExplorerRequest): InfraMetricModel => {
return {
id: 'custom',
requires: [],
index_pattern: options.indexPattern,
interval: options.timerange.interval,
time_field: options.timerange.field,
type: 'timeseries',
// Create one series per metric requested. The series.id will be used to identify the metric
// when the responses are processed and combined with the grouping request.
series: options.metrics.map((metric, index) => {
// If the metric is a rate then we need to add TSVB metrics for calculating the derivative
if (metric.aggregation === MetricsExplorerAggregation.rate) {
const aggType = InfraMetricModelMetricType.max;
return {
id: `metric_${index}`,
split_mode: 'everything',
metrics: [
{
id: `metric_${aggType}_${index}`,
field: metric.field,
type: aggType,
},
{
id: `metric_deriv_${aggType}_${index}`,
field: `metric_${aggType}_${index}`,
type: InfraMetricModelMetricType.derivative,
unit: '1s',
},
{
id: `metric_posonly_deriv_${aggType}_${index}`,
type: InfraMetricModelMetricType.calculation,
variables: [
{ id: 'var-rate', name: 'rate', field: `metric_deriv_${aggType}_${index}` },
],
script: 'params.rate > 0.0 ? params.rate : 0.0',
},
],
};
}
// Create a basic TSVB series with a single metric
const aggregation =
MetricsExplorerAggregation[metric.aggregation] || MetricsExplorerAggregation.avg;
return {
id: `metric_${index}`,
split_mode: 'everything',
metrics: [
{
field: metric.field,
id: `metric_${aggregation}_${index}`,
type: InfraMetricModelMetricType[aggregation],
},
],
};
}),
};
};

View file

@ -0,0 +1,107 @@
/*
* 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 { isObject, set } from 'lodash';
import { InfraDatabaseSearchResponse } from '../../../lib/adapters/framework';
import { MetricsExplorerRequest, MetricsExplorerResponse } from '../types';
interface GroupingAggregation {
groupingsCount: {
value: number;
};
groupings: {
after_key?: {
[name: string]: string;
};
buckets: Array<{ key: { [id: string]: string }; doc_count: number }>;
};
}
export const getGroupings = async (
search: <Aggregation>(options: object) => Promise<InfraDatabaseSearchResponse<{}, Aggregation>>,
options: MetricsExplorerRequest
): Promise<MetricsExplorerResponse> => {
if (!options.groupBy) {
return {
series: [{ id: 'ALL', columns: [], rows: [] }],
pageInfo: { total: 0, afterKey: null },
};
}
const limit = options.limit || 9;
const params = {
index: options.indexPattern,
body: {
size: 0,
query: {
bool: {
filter: [
{
range: {
[options.timerange.field]: {
gte: options.timerange.from,
lte: options.timerange.to,
format: 'epoch_millis',
},
},
},
...options.metrics
.filter(m => m.field)
.map(m => ({
exists: { field: m.field },
})),
] as object[],
},
},
aggs: {
groupingsCount: {
cardinality: { field: options.groupBy },
},
groupings: {
composite: {
size: limit,
sources: [{ groupBy: { terms: { field: options.groupBy, order: 'asc' } } }],
},
},
},
},
};
if (options.afterKey) {
set(params, 'body.aggs.groupings.composite.after', { groupBy: options.afterKey });
}
if (options.filterQuery) {
try {
const filterObject = JSON.parse(options.filterQuery);
if (isObject(filterObject)) {
params.body.query.bool.filter.push(filterObject);
}
} catch (err) {
params.body.query.bool.filter.push({
query_string: {
query: options.filterQuery,
analyze_wildcard: true,
},
});
}
}
const response = await search<GroupingAggregation>(params);
if (!response.aggregations) {
throw new Error('Aggregations should be present.');
}
const { groupings, groupingsCount } = response.aggregations;
const { after_key: afterKey } = groupings;
return {
series: groupings.buckets.map(bucket => {
return { id: bucket.key.groupBy, rows: [], columns: [] };
}),
pageInfo: {
total: groupingsCount.value,
afterKey: afterKey && groupings.buckets.length === limit ? afterKey.groupBy : null,
},
};
};

View file

@ -0,0 +1,77 @@
/*
* 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 { union } from 'lodash';
import {
InfraBackendFrameworkAdapter,
InfraFrameworkRequest,
} from '../../../lib/adapters/framework';
import {
MetricsExplorerColumnType,
MetricsExplorerRequest,
MetricsExplorerRow,
MetricsExplorerSeries,
MetricsExplorerWrappedRequest,
} from '../types';
import { createMetricModel } from './create_metrics_model';
export const populateSeriesWithTSVBData = (
req: InfraFrameworkRequest<MetricsExplorerWrappedRequest>,
options: MetricsExplorerRequest,
framework: InfraBackendFrameworkAdapter
) => async (series: MetricsExplorerSeries) => {
// Set the filter for the group by or match everything
const filters = options.groupBy ? [{ match: { [options.groupBy]: series.id } }] : [];
const timerange = { min: options.timerange.from, max: options.timerange.to };
// Create the TSVB model based on the request options
const model = createMetricModel(options);
// Get TSVB results using the model, timerange and filters
const tsvbResults = await framework.makeTSVBRequest(req, model, timerange, filters);
// Setup the dynamic columns and row attributes depending on if the user is doing a group by
// and multiple metrics
const attributeColumns =
options.groupBy != null ? [{ name: 'groupBy', type: MetricsExplorerColumnType.string }] : [];
const metricColumns = options.metrics.map((m, i) => ({
name: `metric_${i}`,
type: MetricsExplorerColumnType.number,
}));
const rowAttributes = options.groupBy != null ? { groupBy: series.id } : {};
// To support multiple metrics, there are multiple TSVB series which need to be combined
// into one MetricExplorerRow (Canvas row). This is done by collecting all the timestamps
// across each TSVB series. Then for each timestamp we find the values and create a
// MetricsExplorerRow.
const timestamps = tsvbResults.custom.series.reduce(
(currentTimestamps, tsvbSeries) =>
union(currentTimestamps, tsvbSeries.data.map(row => row[0])).sort(),
[] as number[]
);
// Combine the TSVB series for multiple metrics.
const rows = timestamps.map(timestamp => {
return tsvbResults.custom.series.reduce(
(currentRow, tsvbSeries) => {
const matches = tsvbSeries.data.find(d => d[0] === timestamp);
if (matches) {
return { ...currentRow, [tsvbSeries.id]: matches[1] };
}
return currentRow;
},
{ timestamp, ...rowAttributes } as MetricsExplorerRow
);
});
return {
...series,
rows,
columns: [
{ name: 'timestamp', type: MetricsExplorerColumnType.date },
...metricColumns,
...attributeColumns,
],
};
};

View file

@ -0,0 +1,41 @@
/*
* 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 Joi from 'joi';
import { values } from 'lodash';
import { MetricsExplorerColor } from '../../../common/color_palette';
import { MetricsExplorerAggregation } from './types';
export const metricsExplorerSchema = Joi.object({
limit: Joi.number()
.min(1)
.default(9),
afterKey: Joi.string().allow(null),
groupBy: Joi.string().allow(null),
indexPattern: Joi.string().required(),
metrics: Joi.array()
.items(
Joi.object().keys({
aggregation: Joi.string()
.valid(values(MetricsExplorerAggregation))
.required(),
field: Joi.string(),
rate: Joi.bool().default(false),
color: Joi.string().valid(values(MetricsExplorerColor)),
label: Joi.string(),
})
)
.required(),
filterQuery: Joi.string(),
timerange: Joi.object()
.keys({
field: Joi.string().required(),
from: Joi.number().required(),
to: Joi.number().required(),
interval: Joi.string().required(),
})
.required(),
});

View file

@ -0,0 +1,72 @@
/*
* 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 { InfraWrappableRequest } from '../../lib/adapters/framework';
export interface InfraTimerange {
field: string;
from: number;
to: number;
interval: string;
}
export enum MetricsExplorerAggregation {
avg = 'avg',
max = 'max',
min = 'min',
cardinality = 'cardinality',
rate = 'rate',
count = 'count',
}
export interface MetricsExplorerMetric {
aggregation: MetricsExplorerAggregation;
field?: string | null;
}
export interface MetricsExplorerRequest {
timerange: InfraTimerange;
indexPattern: string;
metrics: MetricsExplorerMetric[];
groupBy?: string;
afterKey?: string;
limit?: number;
filterQuery?: string;
}
export type MetricsExplorerWrappedRequest = InfraWrappableRequest<MetricsExplorerRequest>;
export interface MetricsExplorerPageInfo {
total: number;
afterKey?: string | null;
}
export enum MetricsExplorerColumnType {
date = 'date',
number = 'number',
string = 'string',
}
export interface MetricsExplorerColumn {
name: string;
type: MetricsExplorerColumnType;
}
export interface MetricsExplorerRow {
timestamp: number;
[key: string]: string | number | null | undefined;
}
export interface MetricsExplorerSeries {
id: string;
columns: MetricsExplorerColumn[];
rows: MetricsExplorerRow[];
}
export interface MetricsExplorerResponse {
series: MetricsExplorerSeries[];
pageInfo: MetricsExplorerPageInfo;
}

View file

@ -17,6 +17,8 @@ declare module '@elastic/eui/lib/experimental' {
crosshairValue?: number;
onSelectionBrushEnd?: (args: any) => void;
onCrosshairUpdate?: (crosshairValue: number) => void;
animateData?: boolean;
marginLeft?: number;
};
export const EuiSeriesChart: React.SFC<EuiSeriesChartProps>;

View file

@ -14,6 +14,7 @@ export default function ({ loadTestFile }) {
loadTestFile(require.resolve('./sources'));
loadTestFile(require.resolve('./waffle'));
loadTestFile(require.resolve('./log_item'));
loadTestFile(require.resolve('./metrics_explorer'));
loadTestFile(require.resolve('./feature_controls'));
});
}

View file

@ -0,0 +1,121 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import expect from '@kbn/expect';
import { first } from 'lodash';
import { DATES } from './constants';
import { MetricsExplorerResponse } from '../../../../plugins/infra/server/routes/metrics_explorer/types';
import { KbnTestProvider } from './types';
const { min, max } = DATES['7.0.0'].hosts;
const metricsExplorerTest: KbnTestProvider = ({ getService }) => {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
describe('Metrics Explorer API', () => {
before(() => esArchiver.load('infra/7.0.0/hosts'));
after(() => esArchiver.unload('infra/7.0.0/hosts'));
it('should work for multiple metrics', async () => {
const postBody = {
timerange: {
field: '@timestamp',
to: max,
from: min,
interval: '>=1m',
},
indexPattern: 'metricbeat-*',
metrics: [
{
aggregation: 'avg',
field: 'system.cpu.user.pct',
rate: false,
},
{
aggregation: 'count',
rate: false,
},
],
};
const response = await supertest
.post('/api/infra/metrics_explorer')
.set('kbn-xsrf', 'xxx')
.send(postBody)
.expect(200);
const body: MetricsExplorerResponse = response.body;
expect(body).to.have.property('series');
expect(body.series).length(1);
const firstSeries = first(body.series);
expect(firstSeries).to.have.property('id', 'ALL');
expect(firstSeries).to.have.property('columns');
expect(firstSeries).to.have.property('rows');
expect(firstSeries!.columns).to.eql([
{ name: 'timestamp', type: 'date' },
{ name: 'metric_0', type: 'number' },
{ name: 'metric_1', type: 'number' },
]);
expect(firstSeries!.rows).to.have.length(9);
expect(firstSeries!.rows![1]).to.eql({
metric_0: 0.005333333333333333,
metric_1: 131,
timestamp: 1547571300000,
});
});
it('should work with groupBy', async () => {
const postBody = {
timerange: {
field: '@timestamp',
to: max,
from: min,
interval: '>=1m',
},
indexPattern: 'metricbeat-*',
groupBy: 'event.dataset',
limit: 3,
afterKey: 'system.cpu',
metrics: [
{
aggregation: 'count',
rate: false,
},
],
};
const response = await supertest
.post('/api/infra/metrics_explorer')
.set('kbn-xsrf', 'xxx')
.send(postBody)
.expect(200);
const body: MetricsExplorerResponse = response.body;
expect(body).to.have.property('series');
expect(body.series).length(3);
const firstSeries = first(body.series);
expect(firstSeries).to.have.property('id', 'system.diskio');
expect(firstSeries).to.have.property('columns');
expect(firstSeries).to.have.property('rows');
expect(firstSeries!.columns).to.eql([
{ name: 'timestamp', type: 'date' },
{ name: 'metric_0', type: 'number' },
{ name: 'groupBy', type: 'string' },
]);
expect(firstSeries!.rows).to.have.length(9);
expect(firstSeries!.rows![1]).to.eql({
groupBy: 'system.diskio',
metric_0: 24,
timestamp: 1547571300000,
});
expect(body).to.have.property('pageInfo');
expect(body.pageInfo).to.eql({
afterKey: 'system.fsstat',
total: 12,
});
});
});
};
// eslint-disable-next-line import/no-default-export
export default metricsExplorerTest;

156
yarn.lock
View file

@ -1325,6 +1325,32 @@
lodash "^4.17.11"
to-fast-properties "^2.0.0"
"@elastic/charts@^3.11.2":
version "3.11.2"
resolved "https://registry.yarnpkg.com/@elastic/charts/-/charts-3.11.2.tgz#3644eeb7c0d17d6c368cfb380c2d2ef268655d72"
integrity sha512-ILU4ijT5GZC4usnbab/IVq1brQ6tFgkfBPwa4ixRtN5TlS+BWjPPVPFfIO3j4K9PijXE6XISfs8L2HkH1IDdtA==
dependencies:
"@types/d3-shape" "^1.3.1"
"@types/luxon" "^1.11.1"
classnames "^2.2.6"
d3-array "^2.0.3"
d3-collection "^1.0.7"
d3-scale "^2.2.2"
d3-shape "^1.3.4"
fp-ts "^1.14.2"
konva "^2.6.0"
lodash "^4.17.11"
luxon "^1.11.3"
mobx "^4.9.2"
mobx-react "^5.4.3"
newtype-ts "^0.2.4"
prop-types "^15.7.2"
react "^16.8.3"
react-dom "^16.8.3"
react-konva "16.8.3"
react-spring "^8.0.8"
resize-observer-polyfill "^1.5.1"
"@elastic/elasticsearch@^7.0.0-rc.2":
version "7.0.0-rc.2"
resolved "https://registry.yarnpkg.com/@elastic/elasticsearch/-/elasticsearch-7.0.0-rc.2.tgz#2fb07978d647a257af3976b170e3f61704ba0a18"
@ -3097,6 +3123,11 @@
resolved "https://registry.yarnpkg.com/@types/lru-cache/-/lru-cache-5.1.0.tgz#57f228f2b80c046b4a1bd5cac031f81f207f4f03"
integrity sha512-RaE0B+14ToE4l6UqdarKPnXwVDuigfFv+5j9Dze/Nqr23yyuqdNvzcZi3xB+3Agvi5R4EOgAksfv3lXX4vBt9w==
"@types/luxon@^1.11.1":
version "1.12.0"
resolved "https://registry.yarnpkg.com/@types/luxon/-/luxon-1.12.0.tgz#acf14294d18e6eba427a5e5d7dfce0f5cd2a9400"
integrity sha512-+UzPmwHSEEyv7aGlNkVpuFxp/BirXgl8NnPGCtmyx2KXIzAapoW3IqSVk87/Z3PUk8vEL8Pe1HXEMJbNBOQgtg==
"@types/memoize-one@^4.1.0":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@types/memoize-one/-/memoize-one-4.1.0.tgz#62119f26055b3193ae43ca1882c5b29b88b71ece"
@ -6180,13 +6211,6 @@ boom@2.x.x:
dependencies:
hoek "2.x.x"
boom@3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/boom/-/boom-3.1.1.tgz#b6424f01ed8d492b2b12ae86047c24e8b6a7c937"
integrity sha1-tkJPAe2NSSsrEq6GBHwk6LanyTc=
dependencies:
hoek "3.x.x"
boom@4.x.x:
version "4.3.1"
resolved "https://registry.yarnpkg.com/boom/-/boom-4.3.1.tgz#4f8a3005cb4a7e3889f749030fd25b96e01d2e31"
@ -6201,7 +6225,7 @@ boom@5.x.x:
dependencies:
hoek "4.x.x"
boom@7.x.x, boom@^7.1.0, boom@^7.2.0:
boom@7.2.2, boom@7.x.x, boom@^7.1.0, boom@^7.2.0:
version "7.2.2"
resolved "https://registry.yarnpkg.com/boom/-/boom-7.2.2.tgz#ac92101451aa5cea901aed07d881dd32b4f08345"
integrity sha512-IFUbOa8PS7xqmhIjpeStwT3d09hGkNYQ6aj2iELSTxcVs2u0aKn1NzhkdUQSzsRg1FVkj3uit3I6mXQCBixw+A==
@ -7268,7 +7292,7 @@ classnames@2.2.5, classnames@2.x, classnames@^2.2.4:
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.5.tgz#fb3801d453467649ef3603c7d61a02bd129bde6d"
integrity sha1-+zgB1FNGdknvNgPH1hoCvRKb3m0=
classnames@^2.2.3, classnames@^2.2.5:
classnames@^2.2.3, classnames@^2.2.5, classnames@^2.2.6:
version "2.2.6"
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce"
integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==
@ -8671,7 +8695,7 @@ d3-array@1, d3-array@^1.1.1, d3-array@^1.2.0:
resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.4.tgz#635ce4d5eea759f6f605863dbcfc30edc737f71f"
integrity sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==
d3-array@^2.0.2:
d3-array@^2.0.2, d3-array@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-2.0.3.tgz#9c0531eda701e416f28a030e3d4e6179ba74f19f"
integrity sha512-C7g4aCOoJa+/K5hPVqZLG8wjYHsTUROTk7Z1Ep9F4P5l+WVrvV0+6nAZ1wKTRLMhFWpGbozxUpyjIPZYAaLi+g==
@ -8847,7 +8871,7 @@ d3-scale@^1.0.5:
d3-time "1"
d3-time-format "2"
d3-scale@^2.1.2:
d3-scale@^2.1.2, d3-scale@^2.2.2:
version "2.2.2"
resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-2.2.2.tgz#4e880e0b2745acaaddd3ede26a9e908a9e17b81f"
integrity sha512-LbeEvGgIb8UMcAa0EATLNX0lelKWGYDQiPdHj+gLblGVhGLyNbaCn3EvrJf0A3Y/uOOU5aD6MTh5ZFCdEwGiCw==
@ -8873,6 +8897,13 @@ d3-shape@^1.2.2:
dependencies:
d3-path "1"
d3-shape@^1.3.4:
version "1.3.5"
resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-1.3.5.tgz#e81aea5940f59f0a79cfccac012232a8987c6033"
integrity sha512-VKazVR3phgD+MUCldapHD7P9kcrvPcexeX/PkMJmkUov4JM8IxsSg1DvbYoYich9AtdTsa5nNk2++ImPiDiSxg==
dependencies:
d3-path "1"
d3-time-format@2, d3-time-format@^2.1.3:
version "2.1.3"
resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-2.1.3.tgz#ae06f8e0126a9d60d6364eac5b1533ae1bac826b"
@ -11774,6 +11805,11 @@ fp-ts@^1.0.0:
resolved "https://registry.yarnpkg.com/fp-ts/-/fp-ts-1.12.0.tgz#d333310e4ac104cdcb6bea47908e381bb09978e7"
integrity sha512-fWwnAgVlTsV26Ruo9nx+fxNHIm6l1puE1VJ/C0XJ3nRQJJJIgRHYw6sigB3MuNFZL1o4fpGlhwFhcbxHK0RsOA==
fp-ts@^1.14.2:
version "1.17.0"
resolved "https://registry.yarnpkg.com/fp-ts/-/fp-ts-1.17.0.tgz#289127353ddbb4622ada1920d4ad6643182c1f1f"
integrity sha512-nBq25aCAMbCwVLobUUuM/MZihPKyjn0bCVBf6xMAGriHlf8W8Ze9UhyfLnbmfp0ekFTxMuTfLXrCzpJ34px7PQ==
fragment-cache@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19"
@ -13555,11 +13591,6 @@ hoek@2.x.x:
resolved "https://registry.yarnpkg.com/hoek/-/hoek-2.16.3.tgz#20bb7403d3cea398e91dc4710a8ff1b8274a25ed"
integrity sha1-ILt0A9POo5jpHcRxCo/xuCdKJe0=
hoek@3.x.x:
version "3.0.4"
resolved "https://registry.yarnpkg.com/hoek/-/hoek-3.0.4.tgz#268adff66bb6695c69b4789a88b1e0847c3f3123"
integrity sha1-Jorf9mu2aVxptHiaiLHghHw/MSM=
hoek@4.2.1, hoek@4.x.x:
version "4.2.1"
resolved "https://registry.yarnpkg.com/hoek/-/hoek-4.2.1.tgz#9634502aa12c445dd5a7c5734b572bb8738aacbb"
@ -13585,7 +13616,7 @@ hoist-non-react-statics@^2.3.1, hoist-non-react-statics@^2.5.0, hoist-non-react-
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz#c5903cf409c0dfd908f388e619d86b9c1174cb47"
integrity sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw==
hoist-non-react-statics@^3.3.0:
hoist-non-react-statics@^3.0.0, hoist-non-react-statics@^3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.0.tgz#b09178f0122184fb95acf525daaecb4d8f45958b"
integrity sha512-0XsbTXxgiaCDYDIWFcwkmerZPSwywfUqYmwT4jzewKTQSWoE6FCMoUVOeBJWK3E/CrWbxRG3m5GzY4lnIwGRBA==
@ -16309,6 +16340,11 @@ known-css-properties@^0.3.0:
resolved "https://registry.yarnpkg.com/known-css-properties/-/known-css-properties-0.3.0.tgz#a3d135bbfc60ee8c6eacf2f7e7e6f2d4755e49a4"
integrity sha512-QMQcnKAiQccfQTqtBh/qwquGZ2XK/DXND1jrcN9M8gMMy99Gwla7GQjndVUsEqIaRyP6bsFRuhwRj5poafBGJQ==
konva@^2.6.0:
version "2.6.0"
resolved "https://registry.yarnpkg.com/konva/-/konva-2.6.0.tgz#43165b95e32a4378ce532d9113c914f4998409c3"
integrity sha512-LCOoavICTD9PYoAqtWo8sbxYtCiXdgEeY7vj/Sq8b2bwFmrQr9Ak0RkD4/jxAf5fcUQRL5e1zPLyfRpVndp20A==
kopy@^8.2.0:
version "8.2.5"
resolved "https://registry.yarnpkg.com/kopy/-/kopy-8.2.5.tgz#6c95f312e981ab917680d7e5de3cdf29a1bf221f"
@ -17314,6 +17350,11 @@ lru-queue@0.1:
dependencies:
es5-ext "~0.10.2"
luxon@^1.11.3:
version "1.12.1"
resolved "https://registry.yarnpkg.com/luxon/-/luxon-1.12.1.tgz#924bd61404f70b0cc5168918cb0ac108e52aacc4"
integrity sha512-Zv/qJb2X1ESTrlniAViWx2aqGwi2cVpeoZFTbPdPiCu4EsadKsmb/QCH8HQjMUpDZKKJIHKHsJxV5Rwpq47HKQ==
lz-string@^1.4.4:
version "1.4.4"
resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26"
@ -18014,6 +18055,19 @@ mkdirp@^0.3.5, mkdirp@~0.3.5:
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.3.5.tgz#de3e5f8961c88c787ee1368df849ac4413eca8d7"
integrity sha1-3j5fiWHIjHh+4TaN+EmsRBPsqNc=
mobx-react@^5.4.3:
version "5.4.3"
resolved "https://registry.yarnpkg.com/mobx-react/-/mobx-react-5.4.3.tgz#6709b7dd89670c40e9815914ac2ca49cc02bfb47"
integrity sha512-WC8yFlwvJ91hy8j6CrydAuFteUafcuvdITFQeHl3LRIf5ayfT/4W3M/byhEYD2BcJWejeXr8y4Rh2H26RunCRQ==
dependencies:
hoist-non-react-statics "^3.0.0"
react-lifecycles-compat "^3.0.2"
mobx@^4.9.2:
version "4.9.4"
resolved "https://registry.yarnpkg.com/mobx/-/mobx-4.9.4.tgz#bb37a0e4e05f0b02be89ced9d23445cad73377ad"
integrity sha512-RaEpydw7D1ebp1pdFHrEMZcLk4nALAZyHAroCPQpqLzuIXIxJpLmMIe5PUZwYHqvlcWL6DVqDYCANZpPOi9iXA==
mocha@3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/mocha/-/mocha-3.3.0.tgz#d29b7428d3f52c82e2e65df1ecb7064e1aabbfb5"
@ -18084,6 +18138,11 @@ monaco-editor@^0.14.3:
resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.14.3.tgz#7cc4a4096a3821f52fea9b10489b527ef3034e22"
integrity sha512-RhaO4xXmWn/p0WrkEOXe4PoZj6xOcvDYjoAh0e1kGUrQnP1IOpc0m86Ceuaa2CLEMDINqKijBSmqhvBQnsPLHQ==
monocle-ts@^1.0.0:
version "1.7.1"
resolved "https://registry.yarnpkg.com/monocle-ts/-/monocle-ts-1.7.1.tgz#03a615938aa90983a4fa29749969d30f72d80ba1"
integrity sha512-X9OzpOyd/R83sYex8NYpJjUzi/MLQMvGNVfxDYiIvs+QMXMEUDwR61MQoARFN10Cqz5h/mbFSPnIQNUIGhYd2Q==
monotone-convex-hull-2d@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/monotone-convex-hull-2d/-/monotone-convex-hull-2d-1.0.1.tgz#47f5daeadf3c4afd37764baa1aa8787a40eee08c"
@ -18329,6 +18388,14 @@ nested-object-assign@^1.0.1:
resolved "https://registry.yarnpkg.com/nested-object-assign/-/nested-object-assign-1.0.3.tgz#5aca69390d9affe5a612152b5f0843ae399ac597"
integrity sha512-kgq1CuvLyUcbcIuTiCA93cQ2IJFSlRwXcN+hLcb2qLJwC2qrePHGZZa7IipyWqaWF6tQjdax2pQnVxdq19Zzwg==
newtype-ts@^0.2.4:
version "0.2.4"
resolved "https://registry.yarnpkg.com/newtype-ts/-/newtype-ts-0.2.4.tgz#a02a8f160a3d179f871848d687a93de73a964a41"
integrity sha512-HrzPdG0+0FK1qHbc3ld/HXu252OYgmN993bFxUtZ6NFCLUk1eq+yKwdvP07BblXQibGqMWNXBUrNoLUq23Ma2Q==
dependencies:
fp-ts "^1.0.0"
monocle-ts "^1.0.0"
next-tick@1:
version "1.0.0"
resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c"
@ -20393,7 +20460,7 @@ prop-types@^15.5.7:
loose-envify "^1.3.1"
object-assign "^4.1.1"
prop-types@^15.6.0, prop-types@^15.6.2:
prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2:
version "15.7.2"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==
@ -21108,6 +21175,16 @@ react-dom@^16.8.1:
prop-types "^15.6.2"
scheduler "^0.13.5"
react-dom@^16.8.3:
version "16.8.6"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.8.6.tgz#71d6303f631e8b0097f56165ef608f051ff6e10f"
integrity sha512-1nL7PIq9LTL3fthPqwkvr2zY7phIPjYrT0jp4HjyEQrEROnw4dG41VVwi/wfoCneoleqrNX7iAD+pXebJZwrwA==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
prop-types "^15.6.2"
scheduler "^0.13.6"
react-draggable@3.x, "react-draggable@^2.2.6 || ^3.0.3":
version "3.0.5"
resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-3.0.5.tgz#c031e0ed4313531f9409d6cd84c8ebcec0ddfe2d"
@ -21264,6 +21341,14 @@ react-is@~16.3.0:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.3.2.tgz#f4d3d0e2f5fbb6ac46450641eb2e25bf05d36b22"
integrity sha512-ybEM7YOr4yBgFd6w8dJqwxegqZGJNBZl6U27HnGKuTZmDvVrD5quWOK/wAnMywiZzW+Qsk+l4X2c70+thp/A8Q==
react-konva@16.8.3:
version "16.8.3"
resolved "https://registry.yarnpkg.com/react-konva/-/react-konva-16.8.3.tgz#e55390040ea54675a0ef0d40b4fa93731e6d7b03"
integrity sha512-gU36TBxcPZANQOV5prAFnpRSNp2ikAT7zCICHTBJvOzAfa8Yhcyaey6EIrD+NTT/4y0PyGFBIkmWq6zdrlNrQg==
dependencies:
react-reconciler "^0.20.1"
scheduler "^0.13.3"
react-lib-adler32@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/react-lib-adler32/-/react-lib-adler32-1.0.1.tgz#01f7a0e24fe715580aadb8a827c39a850e1ccc4e"
@ -21368,6 +21453,16 @@ react-portal@^3.2.0:
dependencies:
prop-types "^15.5.8"
react-reconciler@^0.20.1:
version "0.20.4"
resolved "https://registry.yarnpkg.com/react-reconciler/-/react-reconciler-0.20.4.tgz#3da6a95841592f849cb4edd3d38676c86fd920b2"
integrity sha512-kxERc4H32zV2lXMg/iMiwQHOtyqf15qojvkcZ5Ja2CPkjVohHw9k70pdDBwrnQhLVetUJBSYyqU3yqrlVTOajA==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
prop-types "^15.6.2"
scheduler "^0.13.6"
react-redux-request@^1.5.6:
version "1.5.6"
resolved "https://registry.yarnpkg.com/react-redux-request/-/react-redux-request-1.5.6.tgz#8c514dc88264d225e113b4b54a265064e8020651"
@ -21518,6 +21613,13 @@ react-sizeme@^2.3.6:
invariant "^2.2.2"
lodash "^4.17.4"
react-spring@^8.0.8:
version "8.0.19"
resolved "https://registry.yarnpkg.com/react-spring/-/react-spring-8.0.19.tgz#62f4f396b4b73fa402838200a1c80374338cb12e"
integrity sha512-DjrwjXqqVEitj6e6GqdW5dUp1BoVyeFQhEcXvPfoQxwyIVSJ9smNt8CNjSvoQqRujVllE7XKaJRWSZO/ewd1/A==
dependencies:
"@babel/runtime" "^7.3.1"
react-sticky@^6.0.3:
version "6.0.3"
resolved "https://registry.yarnpkg.com/react-sticky/-/react-sticky-6.0.3.tgz#7a18b643e1863da113d7f7036118d2a75d9ecde4"
@ -21666,6 +21768,16 @@ react@^16.8.1:
prop-types "^15.6.2"
scheduler "^0.13.5"
react@^16.8.3:
version "16.8.6"
resolved "https://registry.yarnpkg.com/react/-/react-16.8.6.tgz#ad6c3a9614fd3a4e9ef51117f54d888da01f2bbe"
integrity sha512-pC0uMkhLaHm11ZSJULfOBqV4tIZkx87ZLvbbQYunNixAAvjnC+snJCg0XQXn9VIsttVsbZP/H/ewzgsd5fxKXw==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
prop-types "^15.6.2"
scheduler "^0.13.6"
reactcss@1.2.3, reactcss@^1.2.0:
version "1.2.3"
resolved "https://registry.yarnpkg.com/reactcss/-/reactcss-1.2.3.tgz#c00013875e557b1cf0dfd9a368a1c3dab3b548dd"
@ -23034,6 +23146,14 @@ scheduler@^0.13.2:
loose-envify "^1.1.0"
object-assign "^4.1.1"
scheduler@^0.13.3, scheduler@^0.13.6:
version "0.13.6"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.13.6.tgz#466a4ec332467b31a91b9bf74e5347072e4cd889"
integrity sha512-IWnObHt413ucAYKsD9J1QShUKkbKLQQHdxRyw73sw4FN26iWr3DY/H34xGPe4nmL1DwXyWmSWmMrA9TfQbE/XQ==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
scheduler@^0.13.5:
version "0.13.5"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.13.5.tgz#b7226625167041298af3b98088a9dbbf6d7733a8"