diff --git a/web/app/css/deployments.css b/web/app/css/deployments.css index 03d6e4cab..0664973eb 100644 --- a/web/app/css/deployments.css +++ b/web/app/css/deployments.css @@ -5,22 +5,22 @@ width: 50%; } -.deployments-list, .scatterplot { - padding-top: 30px; -} - .line-graph { margin-top: var(--base-width); } -.scatterplot-tooltip .chart-label { - float: left; -} +.scatterplot-display { + font-size: 12px; -.scatterplot-tooltip .tooltip { - float: right; - color: black; - font-weight: bold; + & .title { + font-weight: var(--font-weight-bold); + margin-top: var(--base-width); + } + + & .extremal-latencies { + padding-bottom: var(--base-width); + border-bottom: 1px solid #BDBDBD; + } } & .border-container { diff --git a/web/app/css/scatterplot.css b/web/app/css/scatterplot.css index f0c5a37d2..0c78051d9 100644 --- a/web/app/css/scatterplot.css +++ b/web/app/css/scatterplot.css @@ -20,9 +20,21 @@ circle.dot { stroke-width: 2px; - opacity: 0.8; + fill-opacity: 0.7; + stroke-opacity: 0.9; } -.dot-label { - font-size: 11px; +.overlay { + fill: none; + pointer-events: all; +} + +.overlay-tooltip { + fill: #777; + font-size: 12px; +} + +.vertical-highlight { + fill: steelblue; + opacity: 0.1; } diff --git a/web/app/js/components/Deployments.jsx b/web/app/js/components/Deployments.jsx index ceee2699a..f4af381dd 100644 --- a/web/app/js/components/Deployments.jsx +++ b/web/app/js/components/Deployments.jsx @@ -4,15 +4,25 @@ import ConduitSpinner from "./ConduitSpinner.jsx"; import DeploymentSummary from './DeploymentSummary.jsx'; import ErrorBanner from './ErrorBanner.jsx'; import React from 'react'; -import { rowGutter } from './util/Utils.js'; +import ScatterPlot from './ScatterPlot.jsx'; import TabbedMetricsTable from './TabbedMetricsTable.jsx'; import { ApiHelpers, urlsForResource } from './util/ApiHelpers.js'; import { Col, Row } from 'antd'; import { emptyMetric, getPodsByDeployment, processRollupMetrics, processTimeseriesMetrics } from './util/MetricUtils.js'; +import { metricToFormatter, rowGutter } from './util/Utils.js'; import './../../css/deployments.css'; import 'whatwg-fetch'; const maxTsToFetch = 15; // Beyond this, stop showing sparklines in table +let nodeStats = (description, node) => ( +
+
{description}:
+
+ {node.name} ({metricToFormatter["LATENCY"](_.get(node, ["latency", "P99"]))}) +
+
+); + export default class Deployments extends React.Component { constructor(props) { super(props); @@ -125,11 +135,19 @@ export default class Deployments extends React.Component { renderPageContents() { let leastHealthyDeployments = this.getLeastHealthyDeployments(this.state.metrics); + let scatterplotData = _.reduce(this.state.metrics, (mem, datum) => { + if (!_.isNil(datum.successRate) && !_.isNil(datum.latency)) { + mem.push(datum); + } + return mem; + }, []); + + let slowestNode = _.maxBy(scatterplotData, 'latency.P99'); + let fastestNode = _.minBy(scatterplotData, 'latency.P99'); return (
-
Least-healthy deployments
- {_.isEmpty(this.state.metrics) ?
No data
: null} + {_.isEmpty(leastHealthyDeployments) ? null :
Least-healthy deployments
} { _.map(leastHealthyDeployments, deployment => { @@ -144,6 +162,31 @@ export default class Deployments extends React.Component { }) } + + { _.isEmpty(scatterplotData) ? null : +
+
+
Success rate vs p99 latency
+
+ + +
+
+ { !fastestNode ? null : nodeStats("Least latency", fastestNode) } + { !slowestNode ? null : nodeStats("Most latency", slowestNode) } +
+
+ +
+ +
+
+
+ } +
_.get(d, ["latency", "P99", 0, "value"]))); + this.xScale.domain(d3.extent(data, d => d.latency.P99)); this.yScale.domain([0, 1]); this.updateAxes(); @@ -85,32 +151,10 @@ export default class ScatterPlot extends React.Component { .attr("x", 4) .attr("dx", -10) .attr("dy", -4); - } + }; this.yAxis.call(customYAxis); } - componentDidMount() { - this.svg = d3.select("." + this.props.containerClassName) - .append("svg") - .attr("width", defaultSvgWidth) - .attr("height", defaultSvgHeight) - .append("g") - .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); - - this.xAxis = this.svg.append("g") - .attr("class", "x-axis") - .attr("transform", "translate(0," + (this.state.height - graphPadding) + ")"); - - this.yAxis = this.svg.append("g") - .attr("class", "y-axis") - .attr("transform", "translate(" + this.state.width + ",0)"); - - this.tooltip = d3.select("." + this.props.containerClassName + " .scatterplot-tooltip") - .append("div").attr("class", "tooltip"); - - this.renderAxisLabels(); - } - renderAxisLabels() { // text label for the x axis this.svg.append("text") @@ -128,49 +172,147 @@ export default class ScatterPlot extends React.Component { .text("Success rate"); } - updateGraph() { - let plotData = _.reduce(this.props.data, (mem, datum) => { - if(!_.isNil(datum.scatterplot.success) && !_.isNil(datum.scatterplot.latency)) { - mem.push(datum.scatterplot); - } - return mem; - }, []); - this.updateScales(plotData); - this.scatterPlot = this.svg.selectAll(".dot") - .data(plotData) + getNearbyDatapoints(x, data) { + // return nodes that have nearby x-coordinates + let x0 = this.xScale.invert(x - highlightBarWidth); + let x1 = this.xScale.invert(x + highlightBarWidth); - this.labels = this.svg.selectAll(".dot-label") - .data(plotData) + if (x0 === x1) { + // handle case where all the x points are in one column + let datapointsX = this.xScale(_.first(data).latency.P99); + if (Math.abs(x - datapointsX < highlightBarWidth)) { + return data; + } else { + return []; + } + } else { + return _(data).filter(d => { + return d.latency.P99 <= x1 && d.latency.P99 >= x0; + }).orderBy('successRate', 'desc').value(); + } + } + + updateGraph() { + this.updateScales(this.props.data); + + this.scatterPlot = this.svg.selectAll(".dot") + .data(this.props.data); + + this.scatterPlot.exit().remove(); + + let spNode = this.scatterPlot.node(); this.scatterPlot .enter() - .append("circle") - .attr("class", "dot") - .attr("r", circleRadius) + .append("circle") + .attr("class", "dot") + .attr("r", circleRadius) .merge(this.scatterPlot) // newfangled d3 'update' selection - .attr("cx", d => this.xScale(_.get(d, ["latency", "P99", 0, "value"]))) - .attr("cy", d => this.yScale(d.successRate)) - .style("fill", d => successRateColorScale(d.successRate)) - .style("stroke", d => successRateStrokeColorScale(d.successRate)) - .on("mousemove", d => { - let sr = metricToFormatter["SUCCESS_RATE"](d.successRate); - let latency = metricToFormatter["LATENCY"](_.get(d, ["latency", "P99", 0, "value"])); - this.tooltip - .style("left", d3.event.pageX - 50 + "px") - .style("top", d3.event.pageY - 70 + "px") - .style("display", "inline-block") // show tooltip - .text(`${d.name}: (${latency}, ${sr})`); - }) - .on("mouseout", () => this.tooltip.style("display", "none")); + .attr("cx", d => this.xScale(d.latency.P99)) + .attr("cy", d => this.yScale(d.successRate)) + .style("fill", d => successRateColorScale(d.successRate)) + .style("stroke", d => successRateColorScale(d.successRate)) + .on("mousemove", () => { + if (spNode) { + let currXPos = d3.mouse(spNode)[0]; + this.positionOverlayHighlightAndTooltip(currXPos); + } + }); - this.labels - .enter() - .append("text") - .attr("class", "dot-label") - .merge(this.labels) - .text(d => d.name) - .attr("x", d => this.xScale(_.get(d, ["latency", "P99", 0, "value"])) - circleRadius) - .attr("y", d => this.yScale(d.successRate) - 2 * circleRadius) + this.highlightFirstDatapoint(); + this.overlay + .on("mousemove", () => { + let currXPos = d3.mouse(this.overlayNode)[0]; + this.positionOverlayHighlightAndTooltip(currXPos); + }); + } + + positionOverlayHighlightAndTooltip(currXPos) { + let nearestDatapoints = this.getNearbyDatapoints(currXPos, this.props.data); + this.renderOverlayTooltip(nearestDatapoints); + this.renderSidebarTooltip(nearestDatapoints); + this.verticalHighlight.attr("transform", "translate(" + (currXPos - highlightBarWidth / 2) + ", 0)"); + + let bbox = this.overlayTooltip.node().getBBox(); + + let overlayTooltipYPos = 0; + let firstLabelPosition = _.isEmpty(nearestDatapoints) ? null : this.getTooltipLabelY(nearestDatapoints[0]); + if (firstLabelPosition + bbox.height > this.state.height - 50) { + // if there are a bunch of nodes at 0, the labels could extend below the chart + // translate upward if this is the case + overlayTooltipYPos -= bbox.height; + + // re-render tooltip labels, squished together + this.renderOverlayTooltip(nearestDatapoints, true); + } + + let overlayTooltipXPos = currXPos + highlightBarWidth / 2 + baseWidth; + if (currXPos > defaultSvgWidth / 2) { + // display tooltip to the left if we're on the RH side of the graph + overlayTooltipXPos = currXPos - bbox.width - baseWidth - highlightBarWidth / 2; + } + + this.overlayTooltip + .attr("transform", "translate(" + overlayTooltipXPos + ", " + overlayTooltipYPos + ")") + .raise(); + } + + renderSidebarTooltip(data) { + let innerHtml = _.map(data, d => this.renderNodeTooltipDatum(d)); + this.sidebar.html(innerHtml.join('')); + } + + renderOverlayTooltip(data, ignoreNodeSpacing = false) { + this.overlayTooltip.text(''); + let labelWithPosition = this.computeTooltipLabelPositions(data, ignoreNodeSpacing); + + _.each(labelWithPosition, d => { + this.overlayTooltip + .append("text").text(d.name) + .attr("x", 0) + .attr("y", d.computedY); + }); + } + + getTooltipLabelY(datum) { + // position the tooltip label roughly aligned with the center of the node + return this.yScale(datum.successRate) + circleRadius / 2 - 5; + } + + computeTooltipLabelPositions(data, ignoreNodeSpacing = false) { + // in the case that there are multiple nodes in the highlighted area, + // try to position each label next to its corresponding node + // if the nodes are too close together, simply list the node labels + let positions = _.map(data, d => { + return { + name: d.name, + computedY: this.getTooltipLabelY(d) + }; + }); + + if (_.size(positions) > 1) { + _.each(positions, (_d, i) => { + if (i > 0) { + if (positions[i].computedY - positions[i - 1].computedY < 10) { + // labels are too close together, don't label at the node Y values + ignoreNodeSpacing = true; + } + } + }); + if (ignoreNodeSpacing) { + let basePos = positions[0].computedY; + _.each(positions, (d, i) => { + d.computedY = basePos + i * 15; + }); + } + } + return positions; + } + + renderNodeTooltipDatum(d) { + let latency = metricToFormatter["LATENCY"](d.latency.P99); + let sr = metricToFormatter["SUCCESS_RATE"](d.successRate); + return `
${d.name}
${latency}, ${sr}
`; } render() { diff --git a/web/app/js/components/TabbedMetricsTable.jsx b/web/app/js/components/TabbedMetricsTable.jsx index 95208b980..144b2d5ed 100644 --- a/web/app/js/components/TabbedMetricsTable.jsx +++ b/web/app/js/components/TabbedMetricsTable.jsx @@ -146,18 +146,15 @@ export default class TabbedMetricsTable extends React.Component { } preprocessMetrics() { - let tableData = this.props.metrics; + let tableData = _.cloneDeep(this.props.metrics); let totalRequestRate = _.sumBy(this.props.metrics, "requestRate") || 0; _.each(tableData, datum => { datum.totalRequests = totalRequestRate; datum.requestDistribution = new Percentage(datum.requestRate, datum.totalRequests); - _.each(datum.latency, (d, quantile) => { - _.each(d, datapoint => { - let latencyValue = _.isNil(datapoint.value) ? null : parseInt(datapoint.value, 10); - datum[quantile] = latencyValue; - }); + _.each(datum.latency, (value, quantile) => { + datum[quantile] = value; }); }); diff --git a/web/app/js/components/util/MetricUtils.js b/web/app/js/components/util/MetricUtils.js index 9be419e07..3e44e617d 100644 --- a/web/app/js/components/util/MetricUtils.js +++ b/web/app/js/components/util/MetricUtils.js @@ -72,7 +72,10 @@ export const processRollupMetrics = (rawMetrics, targetEntity) => { successRate = _.get(datum, gaugeAccessor); } else if (datum.name === "LATENCY") { let latencies = _.get(datum, latencyAccessor); - latency = _.groupBy(latencies, 'label'); + latency = _.reduce(latencies, (mem, ea) => { + mem[ea.label] = _.isNil(ea.value) ? null : parseInt(ea.value, 10); + return mem; + }, {}); } }); diff --git a/web/app/test/MetricUtilsTest.js b/web/app/test/MetricUtilsTest.js index 454d239e1..01657d9c8 100644 --- a/web/app/test/MetricUtilsTest.js +++ b/web/app/test/MetricUtilsTest.js @@ -41,9 +41,9 @@ describe('MetricUtils', () => { requestRate: 6.1, successRate: 0.3770491803278688, latency: { - P95: [ { label: 'P95', value: '953' } ], - P99: [ { label: 'P99', value: '990' } ], - P50: [ { label: 'P50', value: '537' } ], + P95: 953, + P99: 990, + P50: 537 }, added: true }