mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
* 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:
parent
0fd0245b87
commit
26f4826675
55 changed files with 3229 additions and 62 deletions
|
@ -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",
|
||||
|
|
38
x-pack/plugins/infra/common/color_palette.test.ts
Normal file
38
x-pack/plugins/infra/common/color_palette.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
56
x-pack/plugins/infra/common/color_palette.ts
Normal file
56
x-pack/plugins/infra/common/color_palette.ts
Normal 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;
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@types/color": "^3.0.0",
|
||||
"boom": "3.1.1",
|
||||
"boom": "7.2.2",
|
||||
"lodash": "^4.17.10"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 }}>
|
||||
|
|
|
@ -25,7 +25,6 @@ interface MetricsTimeControlsProps {
|
|||
export class MetricsTimeControls extends React.Component<MetricsTimeControlsProps> {
|
||||
public render() {
|
||||
const { currentTimeRange, isLiveStreaming, refreshInterval } = this.props;
|
||||
|
||||
return (
|
||||
<MetricsTimeControlsContainer>
|
||||
<EuiSuperDatePicker
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
|
@ -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;
|
||||
`;
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
|
@ -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);
|
||||
};
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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()');
|
||||
});
|
||||
});
|
|
@ -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 || ''})`;
|
||||
};
|
|
@ -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))"
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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
|
||||
)}`;
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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 };
|
||||
}
|
|
@ -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);
|
|
@ -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;
|
||||
}
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -48,6 +48,7 @@ export const SnapshotToolbar = injectI18n(({ intl }) => (
|
|||
})}
|
||||
suggestions={suggestions}
|
||||
value={filterQueryDraft ? filterQueryDraft.expression : ''}
|
||||
autoFocus={true}
|
||||
/>
|
||||
)}
|
||||
</WithWaffleFilter>
|
||||
|
|
193
x-pack/plugins/infra/public/utils/enzyme_helpers.tsx
Normal file
193
x-pack/plugins/infra/public/utils/enzyme_helpers.tsx
Normal 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,
|
||||
};
|
||||
};
|
17
x-pack/plugins/infra/public/utils/fetch.ts
Normal file
17
x-pack/plugins/infra/public/utils/fetch.ts
Normal 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,
|
||||
});
|
|
@ -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')],
|
||||
};
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -87,6 +87,7 @@ export class InfraKibanaBackendFrameworkAdapter implements InfraBackendFramework
|
|||
|
||||
this.server.route({
|
||||
handler: wrappedHandler,
|
||||
options: route.options,
|
||||
method: route.method,
|
||||
path: route.path,
|
||||
});
|
||||
|
|
|
@ -64,7 +64,7 @@ export interface InfraMetricModelSeries {
|
|||
|
||||
export interface InfraMetricModelBasicMetric {
|
||||
id: string;
|
||||
field: string;
|
||||
field?: string | null;
|
||||
type: InfraMetricModelMetricType;
|
||||
}
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
44
x-pack/plugins/infra/server/routes/metrics_explorer/index.ts
Normal file
44
x-pack/plugins/infra/server/routes/metrics_explorer/index.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
|
@ -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],
|
||||
},
|
||||
],
|
||||
};
|
||||
}),
|
||||
};
|
||||
};
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
};
|
|
@ -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(),
|
||||
});
|
72
x-pack/plugins/infra/server/routes/metrics_explorer/types.ts
Normal file
72
x-pack/plugins/infra/server/routes/metrics_explorer/types.ts
Normal 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;
|
||||
}
|
|
@ -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>;
|
||||
|
||||
|
|
|
@ -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'));
|
||||
});
|
||||
}
|
||||
|
|
121
x-pack/test/api_integration/apis/infra/metrics_explorer.ts
Normal file
121
x-pack/test/api_integration/apis/infra/metrics_explorer.ts
Normal 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
156
yarn.lock
|
@ -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"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue