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
}