mirror of https://github.com/linkerd/linkerd2.git
Add /paths page that displays metrics by path, add ability to sort Table (#133)
* Add /paths page that shows rollup metrics by path * Clean up ApiHelpers a bit Adds ability to sort by column in the tabbed metrics table (to make a TabbedMetricsTable sortable, set sortable={true}) Adds a page, accessible via /paths that shows a table of all paths, with their request/success/latency metrics. I haven't exposed it in the sidebar as it doesn't have design treatment.
This commit is contained in:
parent
63d1a5d70d
commit
1cf9da8ee7
|
@ -220,6 +220,10 @@ a.button.primary:active {
|
|||
border-bottom: 1px solid;
|
||||
border-color: var(--status-gray);
|
||||
word-break: keep-all;
|
||||
|
||||
&.ant-table-column-sort {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
& .ant-table table {
|
||||
|
|
|
@ -49,10 +49,11 @@ export default class Deployment extends React.Component {
|
|||
pollingInterval: 10000,
|
||||
metricsWindow: "10m",
|
||||
deploy: deployment,
|
||||
metrics:[],
|
||||
metrics: [],
|
||||
pods: [],
|
||||
upstreamMetrics: [],
|
||||
downstreamMetrics: [],
|
||||
pathMetrics: [],
|
||||
pendingRequests: false,
|
||||
loaded: false,
|
||||
error: ''
|
||||
|
@ -71,20 +72,23 @@ export default class Deployment extends React.Component {
|
|||
let podRollupUrl = urls["pod"].url(this.state.deploy).rollup;
|
||||
let upstreamRollupUrl = urls["upstream_deployment"].url(this.state.deploy).rollup;
|
||||
let downstreamRollupUrl = urls["downstream_deployment"].url(this.state.deploy).rollup;
|
||||
let pathMetricsUrl = urls["path"].url(this.state.deploy).rollup;
|
||||
|
||||
let deployFetch = this.api.fetch(deployMetricsUrl);
|
||||
let podListFetch = this.api.fetchPods();
|
||||
let podRollupFetch = this.api.fetch(podRollupUrl);
|
||||
let upstreamFetch = this.api.fetch(upstreamRollupUrl);
|
||||
let downstreamFetch = this.api.fetch(downstreamRollupUrl);
|
||||
let pathsFetch = this.api.fetch(pathMetricsUrl);
|
||||
|
||||
// expose serverPromise for testing
|
||||
this.serverPromise = Promise.all([deployFetch, podRollupFetch, upstreamFetch, downstreamFetch, podListFetch])
|
||||
.then(([deployMetrics, podRollup, upstreamRollup, downstreamRollup, podList]) => {
|
||||
this.serverPromise = Promise.all([deployFetch, podRollupFetch, upstreamFetch, downstreamFetch, podListFetch, pathsFetch])
|
||||
.then(([deployMetrics, podRollup, upstreamRollup, downstreamRollup, podList, paths]) => {
|
||||
let tsByDeploy = processTimeseriesMetrics(deployMetrics.metrics, "targetDeploy");
|
||||
let podMetrics = processRollupMetrics(podRollup.metrics, "targetPod");
|
||||
let upstreamMetrics = processRollupMetrics(upstreamRollup.metrics, "sourceDeploy");
|
||||
let downstreamMetrics = processRollupMetrics(downstreamRollup.metrics, "targetDeploy");
|
||||
let pathMetrics = processRollupMetrics(paths.metrics, "path");
|
||||
|
||||
let deploy = _.find(getPodsByDeployment(podList.pods), ["name", this.state.deploy]);
|
||||
let totalRequestRate = _.sumBy(podMetrics, "requestRate");
|
||||
|
@ -97,6 +101,7 @@ export default class Deployment extends React.Component {
|
|||
deployTs: _.get(tsByDeploy, this.state.deploy, {}),
|
||||
upstreamMetrics: upstreamMetrics,
|
||||
downstreamMetrics: downstreamMetrics,
|
||||
pathMetrics: pathMetrics,
|
||||
lastUpdated: Date.now(),
|
||||
pendingRequests: false,
|
||||
loaded: true,
|
||||
|
@ -147,7 +152,8 @@ export default class Deployment extends React.Component {
|
|||
upstreamMetrics={this.state.upstreamMetrics}
|
||||
downstreamMetrics={this.state.downstreamMetrics}
|
||||
metricsWindow={this.state.metricsWindow}
|
||||
pathPrefix={this.props.pathPrefix} />
|
||||
pathPrefix={this.props.pathPrefix} />,
|
||||
this.renderPaths()
|
||||
];
|
||||
}
|
||||
|
||||
|
@ -201,6 +207,24 @@ export default class Deployment extends React.Component {
|
|||
);
|
||||
}
|
||||
|
||||
renderPaths() {
|
||||
return _.size(this.state.pathMetrics) === 0 ? null :
|
||||
<div key="deployment-paths">
|
||||
<div className="border-container border-neutral subsection-header">
|
||||
<div className="border-container-content subsection-header">
|
||||
Paths
|
||||
</div>
|
||||
</div>
|
||||
<TabbedMetricsTable
|
||||
resource="path"
|
||||
metrics={this.state.pathMetrics}
|
||||
hideSparklines={true}
|
||||
lastUpdated={this.props.lastUpdated}
|
||||
metricsWindow={this.props.metricsWindow}
|
||||
pathPrefix={this.props.pathPrefix} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
renderDeploymentTitle() {
|
||||
return (
|
||||
<div className="deployment-title">
|
||||
|
|
|
@ -0,0 +1,90 @@
|
|||
import ConduitSpinner from "./ConduitSpinner.jsx";
|
||||
import ErrorBanner from './ErrorBanner.jsx';
|
||||
import { processRollupMetrics } from './util/MetricUtils.js';
|
||||
import React from 'react';
|
||||
import TabbedMetricsTable from './TabbedMetricsTable.jsx';
|
||||
import { ApiHelpers, urlsForResource } from './util/ApiHelpers.js';
|
||||
import 'whatwg-fetch';
|
||||
|
||||
export default class Paths extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.api = ApiHelpers(this.props.pathPrefix);
|
||||
this.handleApiError = this.handleApiError.bind(this);
|
||||
this.loadFromServer = this.loadFromServer.bind(this);
|
||||
this.state = this.initialState();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.loadFromServer();
|
||||
this.timerId = window.setInterval(this.loadFromServer, this.state.pollingInterval);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.clearInterval(this.timerId);
|
||||
}
|
||||
|
||||
initialState() {
|
||||
return {
|
||||
lastUpdated: 0,
|
||||
pollingInterval: 10000,
|
||||
metricsWindow: "1m",
|
||||
metrics: [],
|
||||
pendingRequests: false,
|
||||
loaded: false,
|
||||
error: ''
|
||||
};
|
||||
}
|
||||
|
||||
loadFromServer() {
|
||||
if (this.state.pendingRequests) {
|
||||
return;
|
||||
}
|
||||
this.setState({ pendingRequests: true });
|
||||
|
||||
let urls = urlsForResource(this.props.pathPrefix, this.state.metricsWindow);
|
||||
|
||||
this.api.fetch(urls["path"].url().rollup).then(r => {
|
||||
let metrics = processRollupMetrics(r.metrics, "path");
|
||||
|
||||
this.setState({
|
||||
metrics: metrics,
|
||||
lastUpdated: Date.now(),
|
||||
pendingRequests: false,
|
||||
loaded: true,
|
||||
error: ''
|
||||
});
|
||||
}).catch(this.handleApiError);
|
||||
}
|
||||
|
||||
handleApiError(e) {
|
||||
this.setState({
|
||||
pendingRequests: false,
|
||||
error: `Error getting data from server: ${e.message}`
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="page-content">
|
||||
{ !this.state.error ? null : <ErrorBanner message={this.state.error} /> }
|
||||
{ !this.state.loaded ? <ConduitSpinner /> :
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1>Paths</h1>
|
||||
</div>
|
||||
|
||||
<TabbedMetricsTable
|
||||
resource="path"
|
||||
metrics={this.state.metrics}
|
||||
lastUpdated={this.state.lastUpdated}
|
||||
pathPrefix={this.props.pathPrefix}
|
||||
metricsWindow={this.state.metricsWindow}
|
||||
sortable={true}
|
||||
hideSparklines={true} />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -19,74 +19,91 @@ const resourceInfo = {
|
|||
"upstream_pod": { title: "upstream pod", url: "/pod?pod=" },
|
||||
"downstream_pod": { title: "downstream pod", url: "/pod?pod=" },
|
||||
"deployment": { title: "deployment", url: "/deployment?deploy=" },
|
||||
"pod": { title: "pod", url: "/pod?pod=" }
|
||||
"pod": { title: "pod", url: "/pod?pod=" },
|
||||
"path": { title: "path", url: null }
|
||||
};
|
||||
|
||||
const columns = {
|
||||
resourceName: (resource, pathPrefix) => {
|
||||
return {
|
||||
title: resource.title,
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
render: name => <Link to={`${pathPrefix}${resource.url}${name}`}>{name}</Link>
|
||||
};
|
||||
},
|
||||
successRate: {
|
||||
title: "Success Rate",
|
||||
dataIndex: "successRate",
|
||||
key: "successRateRollup",
|
||||
className: "numeric",
|
||||
render: d => metricToFormatter["SUCCESS_RATE"](d)
|
||||
},
|
||||
requests: {
|
||||
title: "Request Rate",
|
||||
dataIndex: "requestRate",
|
||||
key: "requestRateRollup",
|
||||
className: "numeric",
|
||||
render: d => metricToFormatter["REQUEST_RATE"](d)
|
||||
},
|
||||
requestDistribution: {
|
||||
title: "Request distribution",
|
||||
key: "distribution",
|
||||
className: "numeric",
|
||||
render: d => (new Percentage(d.requestRate, d.totalRequests)).prettyRate()
|
||||
},
|
||||
latencyP99: {
|
||||
title: "P99 Latency",
|
||||
dataIndex: "latency",
|
||||
key: "p99LatencyRollup",
|
||||
className: "numeric",
|
||||
render: d => metricToFormatter["LATENCY"](_.get(d, ["P99", 0, "value"]))
|
||||
},
|
||||
latencyP95: {
|
||||
title: "P95 Latency",
|
||||
dataIndex: "latency",
|
||||
key: "p95LatencyRollup",
|
||||
className: "numeric",
|
||||
render: d => metricToFormatter["LATENCY"](_.get(d, ["P95", 0, "value"]))
|
||||
},
|
||||
latencyP50: {
|
||||
title: "P50 Latency",
|
||||
dataIndex: "latency",
|
||||
key: "p50LatencyRollup",
|
||||
className: "numeric",
|
||||
render: d => metricToFormatter["LATENCY"](_.get(d, ["P50", 0, "value"]))
|
||||
}
|
||||
const generateColumns = sortable => {
|
||||
return {
|
||||
resourceName: (resource, pathPrefix) => {
|
||||
return {
|
||||
title: resource.title,
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
sorter: sortable ? (a, b) => (a.name || "").localeCompare(b.name) : false,
|
||||
render: name => !resource.url ? name :
|
||||
<Link to={`${pathPrefix}${resource.url}${name}`}>{name}</Link>
|
||||
};
|
||||
},
|
||||
successRate: {
|
||||
title: "Success Rate",
|
||||
dataIndex: "successRate",
|
||||
key: "successRateRollup",
|
||||
className: "numeric",
|
||||
sorter: sortable ? (a, b) => numericSort(a.successRate, b.successRate) : false,
|
||||
render: d => metricToFormatter["SUCCESS_RATE"](d)
|
||||
},
|
||||
requests: {
|
||||
title: "Request Rate",
|
||||
dataIndex: "requestRate",
|
||||
key: "requestRateRollup",
|
||||
className: "numeric",
|
||||
sorter: sortable ? (a, b) => numericSort(a.requestRate, b.requestRate) : false,
|
||||
render: d => metricToFormatter["REQUEST_RATE"](d)
|
||||
},
|
||||
requestDistribution: {
|
||||
title: "Request distribution",
|
||||
dataIndex: "requestDistribution",
|
||||
key: "distribution",
|
||||
className: "numeric",
|
||||
sorter: sortable ? (a, b) =>
|
||||
numericSort(a.requestDistribution.get(), b.requestDistribution.get()) : false,
|
||||
render: d => d.prettyRate()
|
||||
},
|
||||
latencyP99: {
|
||||
title: "P99 Latency",
|
||||
dataIndex: "P99",
|
||||
key: "p99LatencyRollup",
|
||||
className: "numeric",
|
||||
sorter: sortable ? (a, b) => numericSort(a.P99, b.P99) : false,
|
||||
render: metricToFormatter["LATENCY"]
|
||||
},
|
||||
latencyP95: {
|
||||
title: "P95 Latency",
|
||||
dataIndex: "P95",
|
||||
key: "p95LatencyRollup",
|
||||
className: "numeric",
|
||||
sorter: sortable ? (a, b) => numericSort(a.P95, b.P95) : false,
|
||||
render: metricToFormatter["LATENCY"]
|
||||
},
|
||||
latencyP50: {
|
||||
title: "P50 Latency",
|
||||
dataIndex: "P50",
|
||||
key: "p50LatencyRollup",
|
||||
className: "numeric",
|
||||
sorter: sortable ? (a, b) => numericSort(a.P50, b.P50) : false,
|
||||
render: metricToFormatter["LATENCY"]
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const metricToColumns = {
|
||||
requestRate: (resource, pathPrefix) => [
|
||||
columns.resourceName(resource, pathPrefix),
|
||||
columns.requests,
|
||||
resource.title === "deployment" ? null : columns.requestDistribution
|
||||
],
|
||||
successRate: (resource, pathPrefix) => [columns.resourceName(resource, pathPrefix), columns.successRate],
|
||||
latency: (resource, pathPrefix) => [
|
||||
columns.resourceName(resource, pathPrefix),
|
||||
columns.latencyP99,
|
||||
columns.latencyP95,
|
||||
columns.latencyP50
|
||||
]
|
||||
const numericSort = (a, b) => (_.isNil(a) ? -1 : a) - (_.isNil(b) ? -1 : b);
|
||||
|
||||
const metricToColumns = baseCols => {
|
||||
return {
|
||||
requestRate: (resource, pathPrefix) => [
|
||||
baseCols.resourceName(resource, pathPrefix),
|
||||
baseCols.requests,
|
||||
resource.title === "deployment" ? null : baseCols.requestDistribution
|
||||
],
|
||||
successRate: (resource, pathPrefix) => [baseCols.resourceName(resource, pathPrefix), baseCols.successRate],
|
||||
latency: (resource, pathPrefix) => [
|
||||
baseCols.resourceName(resource, pathPrefix),
|
||||
baseCols.latencyP99,
|
||||
baseCols.latencyP95,
|
||||
baseCols.latencyP50
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
const nameToDataKey = {
|
||||
|
@ -106,6 +123,7 @@ export default class TabbedMetricsTable extends React.Component {
|
|||
|
||||
this.state = {
|
||||
timeseries: {},
|
||||
rollup: this.preprocessMetrics(),
|
||||
groupBy: tsHelper.groupBy,
|
||||
metricsUrl: tsHelper.url(this.props.entity),
|
||||
error: '',
|
||||
|
@ -127,6 +145,25 @@ export default class TabbedMetricsTable extends React.Component {
|
|||
window.clearInterval(this.timerId);
|
||||
}
|
||||
|
||||
preprocessMetrics() {
|
||||
let tableData = 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;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return tableData;
|
||||
}
|
||||
|
||||
loadFromServer() {
|
||||
if (this.state.pendingRequests) {
|
||||
return; // don't make more requests if the ones we sent haven't completed
|
||||
|
@ -177,18 +214,14 @@ export default class TabbedMetricsTable extends React.Component {
|
|||
|
||||
renderTable(metric) {
|
||||
let resource = resourceInfo[this.props.resource];
|
||||
let columns = _.compact(metricToColumns[metric](resource, this.props.pathPrefix));
|
||||
let columnDefinitions = metricToColumns(generateColumns(this.props.sortable));
|
||||
let columns = _.compact(columnDefinitions[metric](resource, this.props.pathPrefix));
|
||||
if (!this.props.hideSparklines) {
|
||||
columns.push(this.getSparklineColumn(metric));
|
||||
}
|
||||
|
||||
// TODO: move this into rollup aggregation
|
||||
let tableData = this.props.metrics;
|
||||
let totalRequestRate = _.sumBy(this.props.metrics, "requestRate") || 0;
|
||||
_.each(tableData, datum => datum.totalRequests = totalRequestRate);
|
||||
|
||||
return (<Table
|
||||
dataSource={tableData}
|
||||
dataSource={this.state.rollup}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
className="conduit-table"
|
||||
|
|
|
@ -36,11 +36,12 @@ export const urlsForResource = (pathPrefix, metricsWindow) => {
|
|||
"deployment": {
|
||||
groupBy: "targetDeploy",
|
||||
url: (deploy = null) => {
|
||||
let rollupUrl = !deploy ? metricsUrl : `${metricsUrl}&target_deploy=${deploy}`;
|
||||
let timeseriesUrl = !deploy ? `${metricsUrl}×eries=true` :
|
||||
`${metricsUrl}×eries=true&target_deploy=${deploy}`;
|
||||
return {
|
||||
ts: timeseriesUrl,
|
||||
rollup: metricsUrl
|
||||
rollup: rollupUrl
|
||||
};
|
||||
}
|
||||
},
|
||||
|
@ -103,6 +104,19 @@ export const urlsForResource = (pathPrefix, metricsWindow) => {
|
|||
rollup: downstreamRollupUrl
|
||||
};
|
||||
}
|
||||
},
|
||||
"path": {
|
||||
// all paths (default), or all paths of a given deploy if specified
|
||||
groupBy: "path",
|
||||
url: (deploy = null) => {
|
||||
let pathRollupUrl = `${metricsUrl}&aggregation=path${ !deploy ? "" : `&target_deploy=${deploy}`}`;
|
||||
let pathTsUrl = `${pathRollupUrl}×eries=true`;
|
||||
|
||||
return {
|
||||
ts: pathTsUrl,
|
||||
rollup: pathRollupUrl
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import Deployment from './components/Deployment.jsx';
|
||||
import Deployments from './components/Deployments.jsx';
|
||||
import NoMatch from './components/NoMatch.jsx';
|
||||
import Paths from './components/Paths.jsx';
|
||||
import PodDetail from './components/PodDetail.jsx';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
@ -33,6 +34,7 @@ ReactDOM.render((
|
|||
<Route path={`${pathPrefix}/servicemesh`} render={() => <ServiceMesh pathPrefix={pathPrefix} releaseVersion={appData.releaseVersion} />} />
|
||||
<Route path={`${pathPrefix}/deployments`} render={() => <Deployments pathPrefix={pathPrefix} />} />
|
||||
<Route path={`${pathPrefix}/deployment`} render={props => <Deployment pathPrefix={pathPrefix} location={props.location} />} />
|
||||
<Route path={`${pathPrefix}/paths`} render={props => <Paths pathPrefix={pathPrefix} location={props.location} />} />
|
||||
<Route path={`${pathPrefix}/pod`} render={props => <PodDetail pathPrefix={pathPrefix} location={props.location} />} />
|
||||
<Route path={`${pathPrefix}/routes`} render={() => <Routes pathPrefix={pathPrefix} />} />
|
||||
<Route component={NoMatch} />
|
||||
|
|
|
@ -92,6 +92,7 @@ func NewServer(addr, templateDir, staticDir, uuid, webpackDevServer string, relo
|
|||
server.router.GET("/pod", handler.handleIndex)
|
||||
server.router.GET("/deployment", handler.handleIndex)
|
||||
server.router.GET("/deployments", handler.handleIndex)
|
||||
server.router.GET("/paths", handler.handleIndex)
|
||||
server.router.GET("/servicemesh", handler.handleIndex)
|
||||
server.router.GET("/routes", handler.handleIndex)
|
||||
server.router.ServeFiles(
|
||||
|
|
Loading…
Reference in New Issue