[APM] Make sure stacked area charts handle no data points inco… (#40353) (#40731)

* [APM] Make sure stacked area charts handle no data points incorrectly

Closes #40351.

* Refactor CustomPlot/TransactionCharts

- [x] Keep API calls in a separate file from the useTransactionBreakdown hook
- [x] Bail out of render function early when `receivedDataDuringLifetime` is false
- [x] Use `cluster` prop to draw lines over area instead of creating a new plot
- [x] can lineseries be stacked without creating a new plot?
- [x] Move functionality of SyncChartGroup to ChartsTimeContext (renamed to ChartsSyncContext)
- [x] Only determine noHits inside of CustomPlot instead of using hits.total
- [x] Remove use of getEmptySeries except for testing
- [x] Send TimeSeries from the API instead of mapping data inside of the component
- [x] Use default data in useTransactionBreakdown to prevent is-null checks
- [x] Remove usage of start, end in CustomPlot

* Additional cleanup on aisle five

* Don't show GridLines or YAxis component if there is no data

* Make formatters less defensive

* Fix loading state for TransactionCharts

* Update translation files
This commit is contained in:
Dario Gieselaar 2019-07-10 15:06:14 +02:00 committed by GitHub
parent 8767c1c10c
commit 7a5730639a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 490 additions and 793 deletions

View file

@ -8,7 +8,6 @@ import React from 'react';
import { GenericMetricsChart } from '../../../../server/lib/metrics/transform_metrics_chart';
// @ts-ignore
import CustomPlot from '../../shared/charts/CustomPlot';
import { HoverXHandlers } from '../../shared/charts/SyncChartGroup';
import {
asDynamicBytes,
asPercent,
@ -16,16 +15,16 @@ import {
asDecimal
} from '../../../utils/formatters';
import { Coordinate } from '../../../../typings/timeseries';
import { getEmptySeries } from '../../shared/charts/CustomPlot/getEmptySeries';
import { isValidCoordinateValue } from '../../../utils/isValidCoordinateValue';
import { useChartsSync } from '../../../hooks/useChartsSync';
interface Props {
start: number | string | undefined;
end: number | string | undefined;
chart: GenericMetricsChart;
hoverXHandlers: HoverXHandlers;
}
export function MetricsChart({ start, end, chart, hoverXHandlers }: Props) {
export function MetricsChart({ chart }: Props) {
const formatYValue = getYTickFormatter(chart);
const formatTooltip = getTooltipFormatter(chart);
@ -34,7 +33,7 @@ export function MetricsChart({ start, end, chart, hoverXHandlers }: Props) {
legendValue: formatYValue(series.overallValue)
}));
const noHits = chart.totalHits === 0;
const syncedChartProps = useChartsSync();
return (
<React.Fragment>
@ -42,9 +41,8 @@ export function MetricsChart({ start, end, chart, hoverXHandlers }: Props) {
<span>{chart.title}</span>
</EuiTitle>
<CustomPlot
{...hoverXHandlers}
noHits={noHits}
series={noHits ? getEmptySeries(start, end) : transformedSeries}
{...syncedChartProps}
series={transformedSeries}
tickFormatY={formatYValue}
formatTooltipValue={formatTooltip}
yMax={chart.yUnit === 'percent' ? 1 : 'max'}
@ -64,10 +62,11 @@ function getYTickFormatter(chart: GenericMetricsChart) {
return getFixedByteFormatter(max);
}
case 'percent': {
return (y: number | null) => asPercent(y || 0, 1);
return (y: number | null | undefined) => asPercent(y || 0, 1);
}
default: {
return (y: number | null) => (y === null ? y : asDecimal(y));
return (y: number | null | undefined) =>
isValidCoordinateValue(y) ? asDecimal(y) : y;
}
}
}
@ -81,7 +80,8 @@ function getTooltipFormatter({ yUnit }: GenericMetricsChart) {
return (c: Coordinate) => asPercent(c.y || 0, 1);
}
default: {
return (c: Coordinate) => (c.y === null ? c.y : asDecimal(c.y));
return (c: Coordinate) =>
isValidCoordinateValue(c.y) ? asDecimal(c.y) : c.y;
}
}
}

View file

@ -7,9 +7,9 @@
import { EuiFlexGrid, EuiFlexItem, EuiPanel, EuiSpacer } from '@elastic/eui';
import React from 'react';
import { useServiceMetricCharts } from '../../../hooks/useServiceMetricCharts';
import { SyncChartGroup } from '../../shared/charts/SyncChartGroup';
import { MetricsChart } from './MetricsChart';
import { useUrlParams } from '../../../hooks/useUrlParams';
import { ChartsSyncContextProvider } from '../../../context/ChartsSyncContext';
interface ServiceMetricsProps {
agentName?: string;
@ -21,25 +21,17 @@ export function ServiceMetrics({ agentName }: ServiceMetricsProps) {
const { start, end } = urlParams;
return (
<React.Fragment>
<SyncChartGroup
render={hoverXHandlers => (
<EuiFlexGrid columns={2} gutterSize="s">
{data.charts.map(chart => (
<EuiFlexItem key={chart.key}>
<EuiPanel>
<MetricsChart
start={start}
end={end}
chart={chart}
hoverXHandlers={hoverXHandlers}
/>
</EuiPanel>
</EuiFlexItem>
))}
</EuiFlexGrid>
)}
/>
<EuiFlexGrid columns={2} gutterSize="s">
{data.charts.map(chart => (
<EuiFlexItem key={chart.key}>
<EuiPanel>
<ChartsSyncContextProvider>
<MetricsChart start={start} end={end} chart={chart} />
</ChartsSyncContextProvider>
</EuiPanel>
</EuiFlexItem>
))}
</EuiFlexGrid>
<EuiSpacer size="xxl" />
</React.Fragment>
);

View file

@ -7,7 +7,7 @@
import { EuiPanel, EuiSpacer, EuiTitle, EuiHorizontalRule } from '@elastic/eui';
import _ from 'lodash';
import React from 'react';
import { useTransactionDetailsCharts } from '../../../hooks/useTransactionDetailsCharts';
import { useTransactionCharts } from '../../../hooks/useTransactionCharts';
import { useTransactionDistribution } from '../../../hooks/useTransactionDistribution';
import { useWaterfall } from '../../../hooks/useWaterfall';
import { TransactionCharts } from '../../shared/charts/TransactionCharts';
@ -18,7 +18,7 @@ import { useLocation } from '../../../hooks/useLocation';
import { useUrlParams } from '../../../hooks/useUrlParams';
import { FETCH_STATUS } from '../../../hooks/useFetcher';
import { TransactionBreakdown } from '../../shared/TransactionBreakdown';
import { ChartsTimeContextProvider } from '../../../context/ChartsTimeContext';
import { ChartsSyncContextProvider } from '../../../context/ChartsSyncContext';
export function TransactionDetails() {
const location = useLocation();
@ -27,9 +27,9 @@ export function TransactionDetails() {
data: distributionData,
status: distributionStatus
} = useTransactionDistribution(urlParams);
const { data: transactionDetailsChartsData } = useTransactionDetailsCharts(
urlParams
);
const { data: transactionChartsData } = useTransactionCharts();
const { data: waterfall } = useWaterfall(urlParams);
const transaction = waterfall.getTransactionById(urlParams.transactionId);
@ -43,18 +43,18 @@ export function TransactionDetails() {
</EuiTitle>
</ApmHeader>
<ChartsTimeContextProvider>
<ChartsSyncContextProvider>
<TransactionBreakdown />
<EuiSpacer size="s" />
<TransactionCharts
hasMLJob={false}
charts={transactionDetailsChartsData}
charts={transactionChartsData}
urlParams={urlParams}
location={location}
/>
</ChartsTimeContextProvider>
</ChartsSyncContextProvider>
<EuiHorizontalRule size="full" margin="l" />

View file

@ -16,7 +16,7 @@ import { Location } from 'history';
import { first } from 'lodash';
import React from 'react';
import { useTransactionList } from '../../../hooks/useTransactionList';
import { useTransactionOverviewCharts } from '../../../hooks/useTransactionOverviewCharts';
import { useTransactionCharts } from '../../../hooks/useTransactionCharts';
import { IUrlParams } from '../../../context/UrlParamsContext/types';
import { TransactionCharts } from '../../shared/charts/TransactionCharts';
import { TransactionBreakdown } from '../../shared/TransactionBreakdown';
@ -27,7 +27,7 @@ import { useFetcher } from '../../../hooks/useFetcher';
import { getHasMLJob } from '../../../services/rest/ml';
import { history } from '../../../utils/history';
import { useLocation } from '../../../hooks/useLocation';
import { ChartsTimeContextProvider } from '../../../context/ChartsTimeContext';
import { ChartsSyncContextProvider } from '../../../context/ChartsSyncContext';
interface Props {
urlParams: IUrlParams;
@ -70,9 +70,7 @@ export function TransactionOverview({
})
);
const { data: transactionOverviewCharts } = useTransactionOverviewCharts(
urlParams
);
const { data: transactionCharts } = useTransactionCharts();
// TODO: improve urlParams typings.
// `serviceName` or `transactionType` will never be undefined here, and this check should not be needed
@ -115,18 +113,18 @@ export function TransactionOverview({
</EuiFormRow>
) : null}
<ChartsTimeContextProvider>
<ChartsSyncContextProvider>
<TransactionBreakdown initialIsOpen={true} />
<EuiSpacer size="s" />
<TransactionCharts
hasMLJob={hasMLJob}
charts={transactionOverviewCharts}
charts={transactionCharts}
location={location}
urlParams={urlParams}
/>
</ChartsTimeContextProvider>
</ChartsSyncContextProvider>
<EuiSpacer size="s" />

View file

@ -4,57 +4,40 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useMemo, useCallback } from 'react';
import React from 'react';
import numeral from '@elastic/numeral';
import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n';
import { Coordinate } from '../../../../../typings/timeseries';
import { Coordinate, TimeSeries } from '../../../../../typings/timeseries';
import { TransactionLineChart } from '../../charts/TransactionCharts/TransactionLineChart';
import { asPercent } from '../../../../utils/formatters';
import { unit } from '../../../../style/variables';
import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue';
interface Props {
timeseries: Array<{
name: string;
color: string;
values: Array<{ x: number; y: number | null }>;
}>;
timeseries: TimeSeries[];
}
const tickFormatY = (y: number | null | undefined) => {
return numeral(y || 0).format('0 %');
};
const formatTooltipValue = (coordinate: Coordinate) => {
return isValidCoordinateValue(coordinate.y)
? asPercent(coordinate.y, 1)
: NOT_AVAILABLE_LABEL;
};
const TransactionBreakdownGraph: React.FC<Props> = props => {
const { timeseries } = props;
const series: React.ComponentProps<
typeof TransactionLineChart
>['series'] = useMemo(() => {
return timeseries.map(timeseriesConfig => {
return {
title: timeseriesConfig.name,
color: timeseriesConfig.color,
data: timeseriesConfig.values,
type: 'area',
hideLegend: true
};
}, {});
}, [timeseries]);
const tickFormatY = useCallback((y: number | null) => {
return numeral(y || 0).format('0 %');
}, []);
const formatTooltipValue = useCallback((coordinate: Coordinate) => {
return coordinate.y !== null && coordinate.y !== undefined
? asPercent(coordinate.y, 1)
: NOT_AVAILABLE_LABEL;
}, []);
return (
<TransactionLineChart
series={series}
stacked={true}
series={timeseries}
tickFormatY={tickFormatY}
formatTooltipValue={formatTooltipValue}
yMax={1}
height={unit * 12}
stacked={true}
/>
);
};

View file

@ -3,8 +3,7 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import theme from '@elastic/eui/dist/eui_theme_light.json';
import React, { useState, useMemo } from 'react';
import React, { useState } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
@ -20,19 +19,6 @@ import { TransactionBreakdownKpiList } from './TransactionBreakdownKpiList';
import { TransactionBreakdownGraph } from './TransactionBreakdownGraph';
import { FETCH_STATUS } from '../../../hooks/useFetcher';
const COLORS = [
theme.euiColorVis0,
theme.euiColorVis1,
theme.euiColorVis2,
theme.euiColorVis3,
theme.euiColorVis4,
theme.euiColorVis5,
theme.euiColorVis6,
theme.euiColorVis7,
theme.euiColorVis8,
theme.euiColorVis9
];
const NoTransactionsTitle = styled.span`
font-weight: bold;
`;
@ -48,58 +34,17 @@ const TransactionBreakdown: React.FC<{
receivedDataDuringLifetime
} = useTransactionBreakdown();
const kpis = data ? data.kpis : undefined;
const timeseriesPerSubtype = data ? data.timeseries_per_subtype : undefined;
const legends = useMemo(() => {
const names = kpis ? kpis.map(kpi => kpi.name).sort() : [];
return names.map((name, index) => {
return {
name,
color: COLORS[index % COLORS.length]
};
});
}, [kpis]);
const sortedAndColoredKpis = useMemo(() => {
if (!kpis) {
return null;
}
return legends.map(legend => {
const { color } = legend;
const breakdown = kpis.find(
b => b.name === legend.name
) as typeof kpis[0];
return {
...breakdown,
color
};
});
}, [kpis, legends]);
const loading = status === FETCH_STATUS.LOADING || status === undefined;
const hasHits = data && data.kpis.length > 0;
const timeseries = useMemo(() => {
if (!timeseriesPerSubtype) {
return [];
}
return legends.map(legend => {
const series = timeseriesPerSubtype[legend.name];
const { kpis, timeseries } = data;
return {
name: legend.name,
values: series,
color: legend.color
};
});
}, [timeseriesPerSubtype, legends]);
const hasHits = kpis.length > 0;
return receivedDataDuringLifetime ? (
if (!receivedDataDuringLifetime) {
return null;
}
return (
<EuiPanel>
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem grow={false}>
@ -111,11 +56,9 @@ const TransactionBreakdown: React.FC<{
}}
/>
</EuiFlexItem>
{hasHits && sortedAndColoredKpis ? (
{hasHits ? (
<EuiFlexItem grow={false}>
{sortedAndColoredKpis && (
<TransactionBreakdownKpiList kpis={sortedAndColoredKpis} />
)}
<TransactionBreakdownKpiList kpis={kpis} />
</EuiFlexItem>
) : (
!loading && (
@ -155,7 +98,7 @@ const TransactionBreakdown: React.FC<{
) : null}
</EuiFlexGroup>
</EuiPanel>
) : null;
);
};
export { TransactionBreakdown };

View file

@ -21,8 +21,14 @@ import { rgba } from 'polished';
import StatusText from './StatusText';
import { SharedPlot } from './plotUtils';
import { i18n } from '@kbn/i18n';
import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue';
// undefined values are converted by react-vis into NaN when stacking
// see https://github.com/uber/react-vis/issues/1214
const getNull = d => isValidCoordinateValue(d.y) && !isNaN(d.y);
const X_TICK_TOTAL = 7;
class StaticPlot extends PureComponent {
getVisSeries(series, plotValues) {
return series
@ -36,7 +42,7 @@ class StaticPlot extends PureComponent {
case 'line':
return (
<LineSeries
getNull={d => d.y !== null}
getNull={getNull}
key={serie.title}
xType="time"
curve={'curveMonotoneX'}
@ -48,17 +54,54 @@ class StaticPlot extends PureComponent {
case 'area':
return (
<AreaSeries
getNull={d => d.y !== null}
getNull={getNull}
key={serie.title}
xType="time"
curve={'curveMonotoneX'}
data={serie.data}
color={serie.color}
stroke={serie.stack ? 'rgba(0,0,0,0)' : serie.color}
stroke={serie.color}
fill={serie.areaColor || rgba(serie.color, 0.3)}
stack={serie.stack}
/>
);
case 'areaStacked': {
// convert null into undefined because of stack issues,
// see https://github.com/uber/react-vis/issues/1214
const data = serie.data.map(value => {
return 'y' in value && isValidCoordinateValue(value.y)
? value
: {
...value,
y: undefined
};
});
return [
<AreaSeries
getNull={getNull}
key={`${serie.title}-area`}
xType="time"
curve={'curveMonotoneX'}
data={data}
color={serie.color}
stroke={'rgba(0,0,0,0)'}
fill={serie.areaColor || rgba(serie.color, 0.3)}
stack={true}
cluster="area"
/>,
<LineSeries
getNull={getNull}
key={`${serie.title}-line`}
xType="time"
curve={'curveMonotoneX'}
data={data}
color={serie.color}
stack={true}
cluster="line"
/>
];
}
case 'areaMaxHeight':
const yMax = last(plotValues.yTickValues);
const data = serie.data.map(p => ({
@ -70,7 +113,7 @@ class StaticPlot extends PureComponent {
return (
<VerticalRectSeries
getNull={d => d.y !== null}
getNull={getNull}
key={serie.title}
xType="time"
curve={'curveMonotoneX'}
@ -83,7 +126,7 @@ class StaticPlot extends PureComponent {
case 'linemark':
return (
<LineMarkSeries
getNull={d => d.y !== null}
getNull={getNull}
key={serie.title}
xType="time"
curve={'curveMonotoneX'}
@ -103,17 +146,7 @@ class StaticPlot extends PureComponent {
return (
<SharedPlot plotValues={plotValues}>
<HorizontalGridLines tickValues={yTickValues} />
<XAxis tickSize={0} tickTotal={X_TICK_TOTAL} tickFormat={tickFormatX} />
<YAxis
tickSize={0}
tickValues={yTickValues}
tickFormat={tickFormatY}
style={{
line: { stroke: 'none', fill: 'none' }
}}
/>
{noHits ? (
<StatusText
marginLeft={30}
@ -122,7 +155,19 @@ class StaticPlot extends PureComponent {
})}
/>
) : (
this.getVisSeries(series, plotValues)
[
<HorizontalGridLines key="grid-lines" tickValues={yTickValues} />,
<YAxis
key="y-axis"
tickSize={0}
tickValues={yTickValues}
tickFormat={tickFormatY}
style={{
line: { stroke: 'none', fill: 'none' }
}}
/>,
this.getVisSeries(series, plotValues)
]
)}
</SharedPlot>
);

View file

@ -4,27 +4,27 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { memoize } from 'lodash';
import d3 from 'd3';
export const getEmptySeries = memoize(
(
start: number | string = Date.now() - 3600000,
end: number | string = Date.now()
) => {
const dates = d3.time
.scale()
.domain([new Date(start), new Date(end)])
.ticks();
export const getEmptySeries = (
start = Date.now() - 3600000,
end = Date.now()
) => {
const dates = d3.time
.scale()
.domain([new Date(start), new Date(end)])
.ticks();
return [
{
data: dates.map(x => ({
x: x.getTime(),
y: 1
}))
}
];
},
(start: string, end: string) => [start, end].join('_')
);
return [
{
title: '',
type: 'line',
legendValue: '',
color: '',
data: dates.map(x => ({
x: x.getTime(),
y: null
}))
}
];
};

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { isEmpty } from 'lodash';
import { isEmpty, flatten } from 'lodash';
import { makeWidthFlexible } from 'react-vis';
import PropTypes from 'prop-types';
import React, { PureComponent, Fragment } from 'react';
@ -15,6 +15,7 @@ import InteractivePlot from './InteractivePlot';
import VoronoiPlot from './VoronoiPlot';
import { createSelector } from 'reselect';
import { getPlotValues } from './plotUtils';
import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue';
const VISIBLE_LEGEND_COUNT = 4;
@ -33,31 +34,22 @@ export class InnerCustomPlot extends PureComponent {
getEnabledSeries = createSelector(
state => state.visibleSeries,
state => state.seriesEnabledState,
state => state.stacked,
(visibleSeries, seriesEnabledState, stacked) =>
visibleSeries
.filter((serie, i) => !seriesEnabledState[i])
.map(serie => {
return stacked ? { ...serie, stack: true } : serie;
})
(visibleSeries, seriesEnabledState) =>
visibleSeries.filter((serie, i) => !seriesEnabledState[i])
);
getOptions = createSelector(
state => state.width,
state => state.yMin,
state => state.yMax,
state => state.start,
state => state.end,
state => state.height,
state => state.stacked,
(width, yMin, yMax, start, end, height, stacked) => ({
state => state.stackBy,
(width, yMin, yMax, height, stackBy) => ({
width,
yMin,
yMax,
start,
end,
height,
stacked
stackBy
})
);
@ -78,18 +70,6 @@ export class InnerCustomPlot extends PureComponent {
}
);
getStackedPlotSeries = createSelector(
state => state.enabledSeries,
series => {
return series.map(serie => {
return {
...serie,
type: 'line'
};
});
}
);
clickLegend = i => {
this.setState(({ seriesEnabledState }) => {
const nextSeriesEnabledState = this.props.series.map((value, _i) => {
@ -142,9 +122,9 @@ export class InnerCustomPlot extends PureComponent {
}
render() {
const { series, truncateLegends, noHits, width, stacked } = this.props;
const { series, truncateLegends, width } = this.props;
if (isEmpty(series) || !width) {
if (!width) {
return null;
}
@ -155,14 +135,16 @@ export class InnerCustomPlot extends PureComponent {
const visibleSeries = this.getVisibleSeries({ series });
const enabledSeries = this.getEnabledSeries({
visibleSeries,
seriesEnabledState: this.state.seriesEnabledState,
stacked
seriesEnabledState: this.state.seriesEnabledState
});
const options = this.getOptions(this.props);
const coordinates = flatten(enabledSeries.map(s => s.data));
const noHits = coordinates.every(coord => !isValidCoordinateValue(coord.y));
const plotValues = this.getPlotValues({
visibleSeries,
enabledSeries,
enabledSeries: enabledSeries,
options
});
@ -170,16 +152,6 @@ export class InnerCustomPlot extends PureComponent {
return null;
}
const staticPlot = (
<StaticPlot
noHits={noHits}
plotValues={plotValues}
series={enabledSeries}
tickFormatY={this.props.tickFormatY}
tickFormatX={this.props.tickFormatX}
/>
);
return (
<Fragment>
<div style={{ position: 'relative', height: plotValues.XY_HEIGHT }}>
@ -191,12 +163,6 @@ export class InnerCustomPlot extends PureComponent {
tickFormatX={this.props.tickFormatX}
/>
{stacked
? React.cloneElement(staticPlot, {
series: this.getStackedPlotSeries({ enabledSeries })
})
: null}
<InteractivePlot
plotValues={plotValues}
hoverX={this.props.hoverX}
@ -232,7 +198,6 @@ export class InnerCustomPlot extends PureComponent {
InnerCustomPlot.propTypes = {
formatTooltipValue: PropTypes.func,
hoverX: PropTypes.number,
noHits: PropTypes.bool.isRequired,
onHover: PropTypes.func.isRequired,
onMouseLeave: PropTypes.func.isRequired,
onSelectionEnd: PropTypes.func.isRequired,
@ -240,16 +205,15 @@ InnerCustomPlot.propTypes = {
tickFormatY: PropTypes.func,
truncateLegends: PropTypes.bool,
width: PropTypes.number.isRequired,
stacked: PropTypes.bool,
height: PropTypes.number
height: PropTypes.number,
stackBy: PropTypes.string
};
InnerCustomPlot.defaultProps = {
formatTooltipValue: p => p.y,
tickFormatX: undefined,
tickFormatY: y => y,
truncateLegends: false,
stacked: false
truncateLegends: false
};
export default makeWidthFlexible(InnerCustomPlot);

View file

@ -46,19 +46,16 @@ function getFlattenedCoordinates(visibleSeries, enabledSeries) {
export function getPlotValues(
visibleSeries,
enabledSeries,
{ width, yMin = 0, yMax = 'max', start, end, height, stacked }
{ width, yMin = 0, yMax = 'max', height, stackBy }
) {
const flattenedCoordinates = getFlattenedCoordinates(
visibleSeries,
enabledSeries
);
if (isEmpty(flattenedCoordinates)) {
return null;
}
const xMin = start ? start : d3.min(flattenedCoordinates, d => d.x);
const xMin = d3.min(flattenedCoordinates, d => d.x);
const xMax = end ? end : d3.max(flattenedCoordinates, d => d.x);
const xMax = d3.max(flattenedCoordinates, d => d.x);
if (yMax === 'max') {
yMax = d3.max(flattenedCoordinates, d => d.y);
@ -80,7 +77,7 @@ export function getPlotValues(
XY_MARGIN,
XY_HEIGHT: height || XY_HEIGHT,
XY_WIDTH: width,
...(stacked ? { stackBy: 'y' } : {})
stackBy
};
}

View file

@ -7,7 +7,6 @@
import { mount } from 'enzyme';
import moment from 'moment';
import React from 'react';
import { toJson } from '../../../../../utils/testHelpers';
import { InnerCustomPlot } from '../index';
import responseWithData from './responseWithData.json';
@ -33,7 +32,6 @@ describe('when response has data', () => {
onSelectionEnd = jest.fn();
wrapper = mount(
<InnerCustomPlot
noHits={false}
series={series}
onHover={onHover}
onMouseLeave={onMouseLeave}
@ -291,7 +289,6 @@ describe('when response has no data', () => {
wrapper = mount(
<InnerCustomPlot
noHits={true}
series={series}
onHover={onHover}
onMouseLeave={onMouseLeave}
@ -333,8 +330,8 @@ describe('when response has no data', () => {
expect(wrapper.prop('series').length).toBe(1);
});
it('The series is empty and every y-value is 1', () => {
expect(wrapper.prop('series')[0].data.every(d => d.y === 1)).toEqual(
it('The series is empty and every y-value is null', () => {
expect(wrapper.prop('series')[0].data.every(d => d.y === null)).toEqual(
true
);
});

View file

@ -72,32 +72,6 @@ Array [
onWheel={[Function]}
width={800}
>
<g
className="rv-xy-plot__grid-lines"
transform="translate(80,16)"
>
<line
className="rv-xy-plot__grid-lines__line"
x1={0}
x2={720}
y1={208}
y2={208}
/>
<line
className="rv-xy-plot__grid-lines__line"
x1={0}
x2={720}
y1={104}
y2={104}
/>
<line
className="rv-xy-plot__grid-lines__line"
x1={0}
x2={720}
y1={0}
y2={0}
/>
</g>
<g
className="rv-xy-plot__axis rv-xy-plot__axis--horizontal "
style={Object {}}
@ -255,6 +229,32 @@ Array [
</g>
</g>
</g>
<g
className="rv-xy-plot__grid-lines"
transform="translate(80,16)"
>
<line
className="rv-xy-plot__grid-lines__line"
x1={0}
x2={720}
y1={208}
y2={208}
/>
<line
className="rv-xy-plot__grid-lines__line"
x1={0}
x2={720}
y1={104}
y2={104}
/>
<line
className="rv-xy-plot__grid-lines__line"
x1={0}
x2={720}
y1={0}
y2={0}
/>
</g>
<g
className="rv-xy-plot__axis rv-xy-plot__axis--vertical "
style={
@ -2949,32 +2949,6 @@ Array [
onWheel={[Function]}
width={800}
>
<g
className="rv-xy-plot__grid-lines"
transform="translate(80,16)"
>
<line
className="rv-xy-plot__grid-lines__line"
x1={0}
x2={720}
y1={208}
y2={208}
/>
<line
className="rv-xy-plot__grid-lines__line"
x1={0}
x2={720}
y1={104}
y2={104}
/>
<line
className="rv-xy-plot__grid-lines__line"
x1={0}
x2={720}
y1={0}
y2={0}
/>
</g>
<g
className="rv-xy-plot__axis rv-xy-plot__axis--horizontal "
style={Object {}}
@ -3132,6 +3106,32 @@ Array [
</g>
</g>
</g>
<g
className="rv-xy-plot__grid-lines"
transform="translate(80,16)"
>
<line
className="rv-xy-plot__grid-lines__line"
x1={0}
x2={720}
y1={208}
y2={208}
/>
<line
className="rv-xy-plot__grid-lines__line"
x1={0}
x2={720}
y1={104}
y2={104}
/>
<line
className="rv-xy-plot__grid-lines__line"
x1={0}
x2={720}
y1={0}
y2={0}
/>
</g>
<g
className="rv-xy-plot__axis rv-xy-plot__axis--vertical "
style={
@ -5852,32 +5852,6 @@ Array [
onWheel={[Function]}
width={100}
>
<g
className="rv-xy-plot__grid-lines"
transform="translate(80,16)"
>
<line
className="rv-xy-plot__grid-lines__line"
x1={0}
x2={20}
y1={208}
y2={208}
/>
<line
className="rv-xy-plot__grid-lines__line"
x1={0}
x2={20}
y1={104}
y2={104}
/>
<line
className="rv-xy-plot__grid-lines__line"
x1={0}
x2={20}
y1={0}
y2={0}
/>
</g>
<g
className="rv-xy-plot__axis rv-xy-plot__axis--horizontal "
style={Object {}}
@ -6196,179 +6170,6 @@ Array [
</g>
</g>
</g>
<g
className="rv-xy-plot__axis rv-xy-plot__axis--vertical "
style={
Object {
"line": Object {
"fill": "none",
"stroke": "none",
},
}
}
transform="translate(0,16)"
>
<line
className="rv-xy-plot__axis__line"
style={
Object {
"fill": "none",
"line": Object {
"fill": "none",
"stroke": "none",
},
"stroke": "none",
}
}
x1={80}
x2={80}
y1={0}
y2={208}
/>
<g
className="rv-xy-plot__axis__ticks"
transform="translate(80, 0)"
>
<g
className="rv-xy-plot__axis__tick"
style={
Object {
"line": Object {
"fill": "none",
"stroke": "none",
},
}
}
transform="translate(0, 208)"
>
<line
className="rv-xy-plot__axis__tick__line"
style={
Object {
"fill": "none",
"line": Object {
"fill": "none",
"stroke": "none",
},
"stroke": "none",
}
}
x1={0}
x2={-0}
y1={0}
y2={0}
/>
<text
className="rv-xy-plot__axis__tick__text"
dy="0.32em"
style={
Object {
"line": Object {
"fill": "none",
"stroke": "none",
},
}
}
textAnchor="end"
transform="translate(-8, 0)"
>
0
</text>
</g>
<g
className="rv-xy-plot__axis__tick"
style={
Object {
"line": Object {
"fill": "none",
"stroke": "none",
},
}
}
transform="translate(0, 104)"
>
<line
className="rv-xy-plot__axis__tick__line"
style={
Object {
"fill": "none",
"line": Object {
"fill": "none",
"stroke": "none",
},
"stroke": "none",
}
}
x1={0}
x2={-0}
y1={0}
y2={0}
/>
<text
className="rv-xy-plot__axis__tick__text"
dy="0.32em"
style={
Object {
"line": Object {
"fill": "none",
"stroke": "none",
},
}
}
textAnchor="end"
transform="translate(-8, 0)"
>
0.5
</text>
</g>
<g
className="rv-xy-plot__axis__tick"
style={
Object {
"line": Object {
"fill": "none",
"stroke": "none",
},
}
}
transform="translate(0, 0)"
>
<line
className="rv-xy-plot__axis__tick__line"
style={
Object {
"fill": "none",
"line": Object {
"fill": "none",
"stroke": "none",
},
"stroke": "none",
}
}
x1={0}
x2={-0}
y1={0}
y2={0}
/>
<text
className="rv-xy-plot__axis__tick__text"
dy="0.32em"
style={
Object {
"line": Object {
"fill": "none",
"stroke": "none",
},
}
}
textAnchor="end"
transform="translate(-8, 0)"
>
1
</text>
</g>
</g>
</g>
</svg>
<div
style={

View file

@ -1,71 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { fromQuery, toQuery } from '../../Links/url_helpers';
import { history } from '../../../../utils/history';
export interface RangeSelection {
start: number;
end: number;
}
type HoverX = number;
type OnHoverHandler = (hoverX: HoverX) => void;
type OnMouseLeaveHandler = () => void;
type OnSelectionEndHandler = (range: RangeSelection) => void;
export interface HoverXHandlers {
onHover: OnHoverHandler;
onMouseLeave: OnMouseLeaveHandler;
onSelectionEnd: OnSelectionEndHandler;
hoverX: HoverX | null;
}
interface SyncChartGroupProps {
render: (props: HoverXHandlers) => React.ReactNode;
}
interface SyncChartState {
hoverX: HoverX | null;
}
export class SyncChartGroup extends React.Component<
SyncChartGroupProps,
SyncChartState
> {
public state = { hoverX: null };
public onHover: OnHoverHandler = hoverX => this.setState({ hoverX });
public onMouseLeave: OnMouseLeaveHandler = () =>
this.setState({ hoverX: null });
public onSelectionEnd: OnSelectionEndHandler = range => {
this.setState({ hoverX: null });
const currentSearch = toQuery(history.location.search);
const nextSearch = {
rangeFrom: new Date(range.start).toISOString(),
rangeTo: new Date(range.end).toISOString()
};
history.push({
...history.location,
search: fromQuery({
...currentSearch,
...nextSearch
})
});
};
public render() {
return this.props.render({
onHover: this.onHover,
onMouseLeave: this.onMouseLeave,
onSelectionEnd: this.onSelectionEnd,
hoverX: this.state.hoverX
});
}
}

View file

@ -4,19 +4,14 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useMemo } from 'react';
import { flatten } from 'lodash';
import d3 from 'd3';
import React from 'react';
import {
Coordinate,
RectCoordinate
} from '../../../../../../typings/timeseries';
import { useChartsTime } from '../../../../../hooks/useChartsTime';
import { getEmptySeries } from '../../CustomPlot/getEmptySeries';
import { useChartsSync } from '../../../../../hooks/useChartsSync';
// @ts-ignore
import CustomPlot from '../../CustomPlot';
import { toQuery, fromQuery } from '../../../Links/url_helpers';
import { history } from '../../../../../utils/history';
interface Props {
series: Array<{
@ -26,12 +21,12 @@ interface Props {
data: Array<Coordinate | RectCoordinate>;
type: string;
}>;
stacked?: boolean;
truncateLegends?: boolean;
tickFormatY: (y: number | null) => React.ReactNode;
tickFormatY: (y: number) => React.ReactNode;
formatTooltipValue: (c: Coordinate) => React.ReactNode;
yMax?: string | number;
height?: number;
stacked?: boolean;
}
const TransactionLineChart: React.FC<Props> = (props: Props) => {
@ -39,69 +34,24 @@ const TransactionLineChart: React.FC<Props> = (props: Props) => {
series,
tickFormatY,
formatTooltipValue,
stacked = false,
yMax = 'max',
height,
truncateLegends
truncateLegends,
stacked = false
} = props;
const flattenedCoordinates = flatten(series.map(serie => serie.data));
const start = d3.min(flattenedCoordinates, d => d.x);
const end = d3.max(flattenedCoordinates, d => d.x);
const noHits = series.every(
serie =>
serie.data.filter(value => 'y' in value && value.y !== null).length === 0
);
const { time, setTime } = useChartsTime();
const hoverXHandlers = useMemo(() => {
return {
onHover: (hoverX: number) => {
setTime(hoverX);
},
onMouseLeave: () => {
setTime(null);
},
onSelectionEnd: (range: { start: number; end: number }) => {
setTime(null);
const currentSearch = toQuery(history.location.search);
const nextSearch = {
rangeFrom: new Date(range.start).toISOString(),
rangeTo: new Date(range.end).toISOString()
};
history.push({
...history.location,
search: fromQuery({
...currentSearch,
...nextSearch
})
});
},
hoverX: time
};
}, [time, setTime]);
if (!start || !end) {
return null;
}
const syncedChartsProps = useChartsSync();
return (
<CustomPlot
noHits={noHits}
series={noHits ? getEmptySeries(start, end) : series}
start={new Date(start).getTime()}
end={new Date(end).getTime()}
{...hoverXHandlers}
series={series}
{...syncedChartsProps}
tickFormatY={tickFormatY}
formatTooltipValue={formatTooltipValue}
stacked={stacked}
yMax={yMax}
height={height}
truncateLegends={truncateLegends}
{...(stacked ? { stackBy: 'y' } : {})}
/>
);
};

View file

@ -18,6 +18,7 @@ import { Location } from 'history';
import React, { Component } from 'react';
import { isEmpty } from 'lodash';
import styled from 'styled-components';
import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n';
import { Coordinate } from '../../../../../typings/timeseries';
import { ITransactionChartData } from '../../../../selectors/chartSelectors';
import { IUrlParams } from '../../../../context/UrlParamsContext/types';
@ -25,6 +26,7 @@ import { asInteger, asMillis, tpmUnit } from '../../../../utils/formatters';
import { MLJobLink } from '../../Links/MachineLearningLinks/MLJobLink';
import { LicenseContext } from '../../../../context/LicenseContext';
import { TransactionLineChart } from './TransactionLineChart';
import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue';
interface TransactionChartProps {
hasMLJob: boolean;
@ -45,34 +47,25 @@ const ShiftedEuiText = styled(EuiText)`
top: 5px;
`;
const msTimeUnitLabel = i18n.translate(
'xpack.apm.metrics.transactionChart.msTimeUnitLabel',
{
defaultMessage: 'ms'
}
);
export class TransactionCharts extends Component<TransactionChartProps> {
public getResponseTimeTickFormatter = (t: number | null) => {
return this.props.charts.noHits ? `- ${msTimeUnitLabel}` : asMillis(t);
public getResponseTimeTickFormatter = (t: number) => {
return asMillis(t);
};
public getResponseTimeTooltipFormatter = (p: Coordinate) => {
return this.props.charts.noHits || !p
? `- ${msTimeUnitLabel}`
: asMillis(p.y);
return isValidCoordinateValue(p.y) ? asMillis(p.y) : NOT_AVAILABLE_LABEL;
};
public getTPMFormatter = (t: number | null) => {
const { urlParams, charts } = this.props;
public getTPMFormatter = (t: number) => {
const { urlParams } = this.props;
const unit = tpmUnit(urlParams.transactionType);
return charts.noHits || t === null
? `- ${unit}`
: `${asInteger(t)} ${unit}`;
return `${asInteger(t)} ${unit}`;
};
public getTPMTooltipFormatter = (p: Coordinate) => {
return this.getTPMFormatter(p.y);
return isValidCoordinateValue(p.y)
? this.getTPMFormatter(p.y)
: NOT_AVAILABLE_LABEL;
};
public renderMLHeader(hasValidMlLicense: boolean) {

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useMemo, useState } from 'react';
import { toQuery, fromQuery } from '../components/shared/Links/url_helpers';
import { history } from '../utils/history';
const ChartsSyncContext = React.createContext<{
hoverX: number | null;
onHover: (hoverX: number) => void;
onMouseLeave: () => void;
onSelectionEnd: (range: { start: number; end: number }) => void;
} | null>(null);
const ChartsSyncContextProvider: React.FC = ({ children }) => {
const [time, setTime] = useState<number | null>(null);
const value = useMemo(() => {
const hoverXHandlers = {
onHover: (hoverX: number) => {
setTime(hoverX);
},
onMouseLeave: () => {
setTime(null);
},
onSelectionEnd: (range: { start: number; end: number }) => {
setTime(null);
const currentSearch = toQuery(history.location.search);
const nextSearch = {
rangeFrom: new Date(range.start).toISOString(),
rangeTo: new Date(range.end).toISOString()
};
history.push({
...history.location,
search: fromQuery({
...currentSearch,
...nextSearch
})
});
},
hoverX: time
};
return { ...hoverXHandlers };
}, [time, setTime]);
return <ChartsSyncContext.Provider value={value} children={children} />;
};
export { ChartsSyncContext, ChartsSyncContextProvider };

View file

@ -1,27 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useMemo, useState } from 'react';
const ChartsTimeContext = React.createContext<{
time: number | null;
setTime: (time: number | null) => unknown;
} | null>(null);
const ChartsTimeContextProvider: React.FC = ({ children }) => {
const [time, setTime] = useState<number | null>(null);
const value = useMemo(() => {
return {
time,
setTime
};
}, [time, setTime]);
return <ChartsTimeContext.Provider value={value} children={children} />;
};
export { ChartsTimeContext, ChartsTimeContextProvider };

View file

@ -5,13 +5,13 @@
*/
import { useContext } from 'react';
import { ChartsTimeContext } from '../context/ChartsTimeContext';
import { ChartsSyncContext } from '../context/ChartsSyncContext';
export function useChartsTime() {
const context = useContext(ChartsTimeContext);
export function useChartsSync() {
const context = useContext(ChartsSyncContext);
if (!context) {
throw new Error('Missing ChartsTime context provider');
throw new Error('Missing ChartsSync context provider');
}
return context;

View file

@ -6,10 +6,8 @@
import { useRef } from 'react';
import { useFetcher } from './useFetcher';
import { callApi } from '../services/rest/callApi';
import { getUiFiltersES } from '../services/ui_filters/get_ui_filters_es';
import { TransactionBreakdownAPIResponse } from '../../server/lib/transactions/breakdown';
import { useUrlParams } from './useUrlParams';
import { loadTransactionBreakdown } from '../services/rest/apm/transaction_groups';
export function useTransactionBreakdown() {
const {
@ -17,16 +15,18 @@ export function useTransactionBreakdown() {
uiFilters
} = useUrlParams();
const { data, error, status } = useFetcher(async () => {
const {
data = { kpis: [], timeseries: [] },
error,
status
} = useFetcher(() => {
if (serviceName && start && end) {
return callApi<TransactionBreakdownAPIResponse>({
pathname: `/api/apm/services/${serviceName}/transaction_groups/breakdown`,
query: {
start,
end,
transactionName,
uiFiltersES: await getUiFiltersES(uiFilters)
}
return loadTransactionBreakdown({
start,
end,
serviceName,
transactionName,
uiFilters
});
}
}, [serviceName, start, end, uiFilters]);

View file

@ -7,13 +7,14 @@
import { useMemo } from 'react';
import { loadTransactionCharts } from '../services/rest/apm/transaction_groups';
import { getTransactionCharts } from '../selectors/chartSelectors';
import { IUrlParams } from '../context/UrlParamsContext/types';
import { useUiFilters } from '../context/UrlParamsContext';
import { useFetcher } from './useFetcher';
import { useUrlParams } from './useUrlParams';
export function useTransactionOverviewCharts(urlParams: IUrlParams) {
const { serviceName, start, end, transactionType } = urlParams;
const uiFilters = useUiFilters(urlParams);
export function useTransactionCharts() {
const {
urlParams: { serviceName, transactionType, start, end, transactionName },
uiFilters
} = useUrlParams();
const { data, error, status } = useFetcher(() => {
if (serviceName && start && end) {
@ -21,15 +22,17 @@ export function useTransactionOverviewCharts(urlParams: IUrlParams) {
serviceName,
start,
end,
transactionName,
transactionType,
uiFilters
});
}
}, [serviceName, start, end, transactionType, uiFilters]);
}, [serviceName, start, end, transactionName, transactionType, uiFilters]);
const memoizedData = useMemo(() => getTransactionCharts(urlParams, data), [
data
]);
const memoizedData = useMemo(
() => getTransactionCharts({ transactionType }, data),
[data]
);
return {
data: memoizedData,

View file

@ -1,46 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useMemo } from 'react';
import { loadTransactionCharts } from '../services/rest/apm/transaction_groups';
import { getTransactionCharts } from '../selectors/chartSelectors';
import { IUrlParams } from '../context/UrlParamsContext/types';
import { useUiFilters } from '../context/UrlParamsContext';
import { useFetcher } from './useFetcher';
export function useTransactionDetailsCharts(urlParams: IUrlParams) {
const {
serviceName,
transactionType,
start,
end,
transactionName
} = urlParams;
const uiFilters = useUiFilters(urlParams);
const { data, error, status } = useFetcher(() => {
if (serviceName && start && end && transactionName && transactionType) {
return loadTransactionCharts({
serviceName,
start,
end,
transactionName,
transactionType,
uiFilters
});
}
}, [serviceName, start, end, transactionName, transactionType, uiFilters]);
const memoizedData = useMemo(() => getTransactionCharts(urlParams, data), [
data
]);
return {
data: memoizedData,
status,
error
};
}

View file

@ -4,7 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { ApmTimeSeriesResponse } from '../../../server/lib/transactions/charts/get_timeseries_data/transform';
import {
getAnomalyScoreSeries,
getResponseTimeSeries,
@ -34,8 +33,9 @@ describe('chartSelectors', () => {
p95: [{ x: 0, y: 200 }, { x: 1000, y: 300 }],
p99: [{ x: 0, y: 300 }, { x: 1000, y: 400 }]
},
tpmBuckets: [],
overallAvgDuration: 200
} as ApmTimeSeriesResponse;
};
it('should produce correct series', () => {
expect(
@ -74,13 +74,20 @@ describe('chartSelectors', () => {
});
describe('getTpmSeries', () => {
const apmTimeseries = ({
const apmTimeseries = {
responseTimes: {
avg: [],
p95: [],
p99: []
},
tpmBuckets: [
{ key: 'HTTP 2xx', dataPoints: [{ x: 0, y: 5 }, { x: 0, y: 2 }] },
{ key: 'HTTP 4xx', dataPoints: [{ x: 0, y: 1 }] },
{ key: 'HTTP 5xx', dataPoints: [{ x: 0, y: 0 }] }
]
} as any) as ApmTimeSeriesResponse;
],
overallAvgDuration: 200
};
const transactionType = 'MyTransactionType';
it('should produce correct series', () => {
expect(getTpmSeries(apmTimeseries, transactionType)).toEqual([
@ -108,4 +115,25 @@ describe('chartSelectors', () => {
]);
});
});
describe('empty getTpmSeries', () => {
const apmTimeseries = {
responseTimes: {
avg: [{ x: 0, y: 1 }, { x: 100, y: 1 }],
p95: [{ x: 0, y: 1 }, { x: 100, y: 1 }],
p99: [{ x: 0, y: 1 }, { x: 100, y: 1 }]
},
tpmBuckets: [],
overallAvgDuration: 200
};
const transactionType = 'MyTransactionType';
it('should produce an empty series', () => {
const series = getTpmSeries(apmTimeseries, transactionType);
expect(series[0].data.length).toBe(11);
expect(series[0].data[0].x).toBe(0);
expect(series[0].data[10].x).toBe(100);
});
});
});

View file

@ -19,6 +19,7 @@ import {
} from '../../typings/timeseries';
import { asDecimal, asMillis, tpmUnit } from '../utils/formatters';
import { IUrlParams } from '../context/UrlParamsContext/types';
import { getEmptySeries } from '../components/shared/charts/CustomPlot/getEmptySeries';
export interface ITpmBucket {
title: string;
@ -29,14 +30,12 @@ export interface ITpmBucket {
}
export interface ITransactionChartData {
noHits: boolean;
tpmSeries: ITpmBucket[];
responseTimeSeries: TimeSeries[];
}
const INITIAL_DATA = {
apmTimeseries: {
totalHits: 0,
responseTimes: {
avg: [],
p95: [],
@ -52,7 +51,6 @@ export function getTransactionCharts(
{ transactionType }: IUrlParams,
{ apmTimeseries, anomalyTimeseries }: TimeSeriesAPIResponse = INITIAL_DATA
): ITransactionChartData {
const noHits = apmTimeseries.totalHits === 0;
const tpmSeries = getTpmSeries(apmTimeseries, transactionType);
const responseTimeSeries = getResponseTimeSeries({
@ -61,7 +59,6 @@ export function getTransactionCharts(
});
return {
noHits,
tpmSeries,
responseTimeSeries
};
@ -162,12 +159,20 @@ export function getTpmSeries(
const bucketKeys = tpmBuckets.map(({ key }) => key);
const getColor = getColorByKey(bucketKeys);
const { avg } = apmTimeseries.responseTimes;
if (!tpmBuckets.length && avg.length) {
const start = avg[0].x;
const end = avg[avg.length - 1].x;
return getEmptySeries(start, end);
}
return tpmBuckets.map(bucket => {
const avg = mean(bucket.dataPoints.map(p => p.y));
const average = mean(bucket.dataPoints.map(p => p.y));
return {
title: bucket.key,
data: bucket.dataPoints,
legendValue: `${asDecimal(avg)} ${tpmUnit(transactionType || '')}`,
legendValue: `${asDecimal(average)} ${tpmUnit(transactionType || '')}`,
type: 'linemark',
color: getColor(bucket.key)
};

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { TransactionBreakdownAPIResponse } from '../../../../server/lib/transactions/breakdown';
import { TimeSeriesAPIResponse } from '../../../../server/lib/transactions/charts';
import { ITransactionDistributionAPIResponse } from '../../../../server/lib/transactions/distribution';
import { TransactionListAPIResponse } from '../../../../server/lib/transactions/get_top_transactions';
@ -94,3 +95,27 @@ export async function loadTransactionCharts({
}
});
}
export async function loadTransactionBreakdown({
serviceName,
start,
end,
transactionName,
uiFilters
}: {
serviceName: string;
start: string;
end: string;
transactionName?: string;
uiFilters: UIFilters;
}) {
return callApi<TransactionBreakdownAPIResponse>({
pathname: `/api/apm/services/${serviceName}/transaction_groups/breakdown`,
query: {
start,
end,
transactionName,
uiFiltersES: await getUiFiltersES(uiFilters)
}
});
}

View file

@ -172,15 +172,15 @@ function asTerabytes(value: number | null) {
return `${asDecimal(value / 1e12)} TB`;
}
export function asBytes(value: number | null) {
if (value === null || isNaN(value)) {
export function asBytes(value: number | null | undefined) {
if (value === null || value === undefined || isNaN(value)) {
return '';
}
return `${asDecimal(value)} B`;
}
export function asDynamicBytes(value: number | null) {
if (value === null || isNaN(value)) {
export function asDynamicBytes(value: number | null | undefined) {
if (value === null || value === undefined || isNaN(value)) {
return '';
}
return unmemoizedFixedByteFormatter(value)(value);

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { flatten } from 'lodash';
import { TimeSeries } from '../../typings/timeseries';
export const getRangeFromTimeSeries = (timeseries: TimeSeries[]) => {
const dataPoints = flatten(timeseries.map(series => series.data));
if (dataPoints.length) {
return {
start: dataPoints[0].x,
end: dataPoints[dataPoints.length - 1].x
};
}
return null;
};

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export const isValidCoordinateValue = (
value: number | null | undefined
): value is number => value !== null && value !== undefined;

View file

@ -3,5 +3,19 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import theme from '@elastic/eui/dist/eui_theme_light.json';
export const MAX_KPIS = 20;
export const COLORS = [
theme.euiColorVis0,
theme.euiColorVis1,
theme.euiColorVis2,
theme.euiColorVis3,
theme.euiColorVis4,
theme.euiColorVis5,
theme.euiColorVis6,
theme.euiColorVis7,
theme.euiColorVis8,
theme.euiColorVis9
];

View file

@ -29,7 +29,7 @@ describe('getTransactionBreakdown', () => {
expect(response.kpis.length).toBe(0);
expect(Object.keys(response.timeseries_per_subtype).length).toBe(0);
expect(Object.keys(response.timeseries).length).toBe(0);
});
it('returns transaction breakdowns grouped by type and subtype', async () => {
@ -53,18 +53,20 @@ describe('getTransactionBreakdown', () => {
expect(response.kpis.map(kpi => kpi.name)).toEqual([
'app',
'dispatcher-servlet',
'http',
'postgresql',
'dispatcher-servlet'
'postgresql'
]);
expect(response.kpis[0]).toEqual({
name: 'app',
color: '#00b3a4',
percentage: 0.5408550899466306
});
expect(response.kpis[2]).toEqual({
expect(response.kpis[3]).toEqual({
name: 'postgresql',
color: '#490092',
percentage: 0.047366859295002
});
});
@ -86,17 +88,22 @@ describe('getTransactionBreakdown', () => {
}
});
const { timeseries_per_subtype: timeseriesPerSubtype } = response;
const { timeseries } = response;
expect(Object.keys(timeseriesPerSubtype).length).toBe(4);
expect(timeseries.length).toBe(4);
const appTimeseries = timeseries[0];
expect(appTimeseries.title).toBe('app');
expect(appTimeseries.type).toBe('areaStacked');
expect(appTimeseries.hideLegend).toBe(true);
// empty buckets should result in null values for visible types
expect(timeseriesPerSubtype.app.length).toBe(276);
expect(timeseriesPerSubtype.app.length).not.toBe(257);
expect(appTimeseries.data.length).toBe(276);
expect(appTimeseries.data.length).not.toBe(257);
expect(timeseriesPerSubtype.app[0].x).toBe(1561102380000);
expect(appTimeseries.data[0].x).toBe(1561102380000);
expect(timeseriesPerSubtype.app[0].y).toBeCloseTo(0.8689440187037277);
expect(appTimeseries.data[0].y).toBeCloseTo(0.8689440187037277);
});
it('should not include more KPIs than MAX_KPIs', async () => {
@ -119,8 +126,8 @@ describe('getTransactionBreakdown', () => {
}
});
const { timeseries_per_subtype: timeseriesPerSubtype } = response;
const { timeseries } = response;
expect(Object.keys(timeseriesPerSubtype)).toEqual(['app', 'http']);
expect(timeseries.map(serie => serie.title)).toEqual(['app', 'http']);
});
});

View file

@ -18,16 +18,12 @@ import { PromiseReturnType } from '../../../../typings/common';
import { Setup } from '../../helpers/setup_request';
import { rangeFilter } from '../../helpers/range_filter';
import { getMetricsDateHistogramParams } from '../../helpers/metrics';
import { MAX_KPIS } from './constants';
import { MAX_KPIS, COLORS } from './constants';
export type TransactionBreakdownAPIResponse = PromiseReturnType<
typeof getTransactionBreakdown
>;
interface TimeseriesMap {
[key: string]: Array<{ x: number; y: number | null }>;
}
export async function getTransactionBreakdown({
setup,
serviceName,
@ -154,12 +150,19 @@ export async function getTransactionBreakdown({
return breakdowns;
};
const kpis = sortByOrder(
const visibleKpis = sortByOrder(
formatBucket(resp.aggregations),
'percentage',
'desc'
).slice(0, MAX_KPIS);
const kpis = sortByOrder(visibleKpis, 'name').map((kpi, index) => {
return {
...kpi,
color: COLORS[index % COLORS.length]
};
});
const kpiNames = kpis.map(kpi => kpi.name);
const timeseriesPerSubtype = resp.aggregations.by_date.buckets.reduce(
@ -168,26 +171,38 @@ export async function getTransactionBreakdown({
const time = bucket.key;
return kpiNames.reduce((p, kpiName) => {
const value = formattedValues.find(val => val.name === kpiName) || {
const { name, percentage } = formattedValues.find(
val => val.name === kpiName
) || {
name: kpiName,
percentage: null
};
const { name, percentage } = value;
if (!p[name]) {
p[name] = [];
}
return {
...p,
[value.name]: p[name].concat({ x: time, y: percentage })
[name]: p[name].concat({
x: time,
y: percentage
})
};
}, prev);
},
{} as TimeseriesMap
{} as Record<string, Array<{ x: number; y: number | null }>>
);
const timeseries = kpis.map(kpi => ({
title: kpi.name,
color: kpi.color,
type: 'areaStacked',
data: timeseriesPerSubtype[kpi.name],
hideLegend: true
}));
return {
kpis,
timeseries_per_subtype: timeseriesPerSubtype
timeseries
};
}

View file

@ -983,7 +983,6 @@ Object {
},
],
},
"totalHits": 1297673,
"tpmBuckets": Array [
Object {
"dataPoints": Array [

View file

@ -30,7 +30,6 @@ export function timeseriesTransformer({
const tpmBuckets = getTpmBuckets(transactionResultBuckets, bucketSize);
return {
totalHits: timeseriesResponse.hits.total,
responseTimes: {
avg,
p95,

View file

@ -6,7 +6,7 @@
export interface Coordinate {
x: number;
y: number | null;
y: number | null | undefined;
}
export interface RectCoordinate {

View file

@ -3856,7 +3856,6 @@
"xpack.apm.metrics.plot.noDataLabel": "この時間範囲のデータがありません。",
"xpack.apm.metrics.transactionChart.machineLearningLabel": "機械学習:",
"xpack.apm.metrics.transactionChart.machineLearningTooltip": "平均期間の周りのストリームには予測バウンドが表示されます。異常スコアが >= 75 の場合、注釈が表示されます。",
"xpack.apm.metrics.transactionChart.msTimeUnitLabel": "ms",
"xpack.apm.metrics.transactionChart.pageLoadTimesLabel": "ページ読み込み時間",
"xpack.apm.metrics.transactionChart.requestsPerMinuteLabel": "1 分あたりのリクエスト",
"xpack.apm.metrics.transactionChart.routeChangeTimesLabel": "ルート変更時間",
@ -10918,4 +10917,4 @@
"xpack.watcher.watchActions.logging.logTextIsRequiredValidationMessage": "ログテキストが必要です。",
"xpack.watcher.watcherDescription": "アラートの作成、管理、監視によりデータへの変更を検知します。"
}
}
}

View file

@ -3857,7 +3857,6 @@
"xpack.apm.metrics.plot.noDataLabel": "此时间范围内没有数据。",
"xpack.apm.metrics.transactionChart.machineLearningLabel": "Machine Learning",
"xpack.apm.metrics.transactionChart.machineLearningTooltip": "环绕平均持续时间的流显示预期边界。对 >= 75 的异常分数显示标注。",
"xpack.apm.metrics.transactionChart.msTimeUnitLabel": "ms",
"xpack.apm.metrics.transactionChart.pageLoadTimesLabel": "页面加载时间",
"xpack.apm.metrics.transactionChart.requestsPerMinuteLabel": "每分钟请求数",
"xpack.apm.metrics.transactionChart.routeChangeTimesLabel": "路由更改时间",