Add the top routes feature to the dashboard UI (#1868)

Adds a (currently not displayed in sidebar, but available at /routes) page to
mirror the current functionality of `linkerd routes <service>`. So far, this is just a
barebones form and table, but it works.

Adds a /api/routes path and handler to the api to receive TopRoutes requests from the web.
This commit is contained in:
Risha Mars 2018-11-27 16:53:10 -08:00 committed by GitHub
parent f8583df4db
commit d9539bcb37
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 305 additions and 1 deletions

View File

@ -10,7 +10,8 @@ const routeToCrumbTitle = {
"servicemesh": "Service Mesh",
"overview": "Overview",
"tap": "Tap",
"top": "Top"
"top": "Top",
"routes": "Top Routes"
};
class BreadcrumbHeader extends React.Component {

View File

@ -0,0 +1,188 @@
import Button from '@material-ui/core/Button';
import ErrorBanner from './ErrorBanner.jsx';
import Grid from '@material-ui/core/Grid';
import PropTypes from 'prop-types';
import React from 'react';
import TextField from '@material-ui/core/TextField';
import TopRoutesTable from './TopRoutesTable.jsx';
import _ from 'lodash';
import { processTopRoutesResults } from './util/MetricUtils.jsx';
import { withContext } from './util/AppContext.jsx';
class TopRoutes extends React.Component {
static propTypes = {
api: PropTypes.shape({
PrefixedLink: PropTypes.func.isRequired,
}).isRequired
}
constructor(props) {
super(props);
this.api = this.props.api;
this.state = {
query: {
resource_name: '',
namespace: '',
from_name: '',
from_type: '',
from_namespace: ''
},
error: null,
metrics: [],
pollingInterval: 5000,
pendingRequests: false,
pollingInProgress: false
};
}
componentWillUnmount() {
this.stopServerPolling();
}
getQueryParams() {
// TODO: form validation
return _.compact(_.map(this.state.query, (val, name) => {
if (_.isEmpty(val)) {
return null;
} else {
return `${name}=${val}`;
}
})).join("&");
}
loadFromServer = () => {
if (this.state.pendingRequests) {
return; // don't make more requests if the ones we sent haven't completed
}
this.setState({ pendingRequests: true });
let queryParams = this.getQueryParams();
this.api.setCurrentRequests([
this.api.fetchMetrics(`/api/routes?${queryParams}`)
]);
this.serverPromise = Promise.all(this.api.getCurrentPromises())
.then(([routeStats]) => {
let metrics = processTopRoutesResults(_.get(routeStats, 'routes.rows', []));
this.setState({
metrics,
pendingRequests: false,
error: null
});
})
.catch(this.handleApiError);
}
handleApiError = e => {
if (e.isCanceled) {
return;
}
this.setState({
pendingRequests: false,
error: e
});
}
startServerPolling = () => {
this.setState({
pollingInProgress: true
});
this.loadFromServer();
this.timerId = window.setInterval(this.loadFromServer, this.state.pollingInterval);
}
stopServerPolling = () => {
window.clearInterval(this.timerId);
this.api.cancelCurrentRequests();
this.setState({
pollingInProgress: false
});
}
handleFormEvent = key => {
return e => {
let query = this.state.query;
query[key] = _.get(e, 'target.value');
this.setState({ query });
};
}
renderRoutesQueryForm = () => {
return (
<Grid container direction="column">
<Grid item container spacing={8} alignItems="center">
<Grid item xs={6} md={3}>
{ this.renderTextInput("Service", "resource_name", "Name of the configured service") }
</Grid>
<Grid item xs={6} md={3}>
{ this.renderTextInput("Namespace", "namespace", "Namespace of the configured service") }
</Grid>
</Grid>
<Grid item container spacing={8} alignItems="center">
<Grid item xs={6} md={3}>
{ this.renderTextInput("From", "from_name", "Resource name") }
</Grid>
<Grid item xs={6} md={3}>
{ this.renderTextInput("From type", "from_type", "Resource type") }
</Grid>
<Grid item xs={6} md={3}>
{ this.renderTextInput("From namespace", "from_namespace", "Resource namespace") }
</Grid>
</Grid>
<Grid item container spacing={8} alignItems="center">
<Grid item>
<Button
color="primary"
variant="outlined"
disabled={this.state.pollingInProgress}
onClick={this.startServerPolling}>
Start
</Button>
</Grid>
<Grid item>
<Button
color="default"
variant="outlined"
disabled={!this.state.pollingInProgress}
onClick={this.stopServerPolling}>
Stop
</Button>
</Grid>
</Grid>
</Grid>
);
}
renderTextInput = (title, key, helperText) => {
return (
<TextField
id={key}
label={title}
value={this.state.query[key]}
onChange={this.handleFormEvent(key)}
helperText={helperText}
margin="normal" />
);
}
render() {
return (
<div>
{
!this.state.error ? null :
<ErrorBanner message={this.state.error} onHideMessage={() => this.setState({ error: null })} />
}
{this.renderRoutesQueryForm()}
<TopRoutesTable rows={this.state.metrics} />
</div>
);
}
}
export default withContext(TopRoutes);

View File

@ -0,0 +1,71 @@
import { metricToFormatter, numericSort } from './util/Utils.js';
import BaseTable from './BaseTable.jsx';
import PropTypes from 'prop-types';
import React from 'react';
import SuccessRateMiniChart from './util/SuccessRateMiniChart.jsx';
const routesColumns = [
{
title: "Route",
dataIndex: "route",
sorter: (a, b) => (a.route).localeCompare(b.route)
},
{
title: "Success Rate",
dataIndex: "successRate",
isNumeric: true,
render: d => <SuccessRateMiniChart sr={d.successRate} />,
sorter: (a, b) => numericSort(a.successRate, b.successRate)
},
{
title: "Request Rate",
dataIndex: "requestRate",
isNumeric: true,
render: d => metricToFormatter["NO_UNIT"](d.requestRate),
sorter: (a, b) => numericSort(a.requestRate, b.requestRate)
},
{
title: "P50 Latency",
dataIndex: "latency.P50",
isNumeric: true,
render: d => metricToFormatter["LATENCY"](d.latency.P50),
sorter: (a, b) => numericSort(a.P50, b.P50)
},
{
title: "P95 Latency",
dataIndex: "latency.P95",
isNumeric: true,
render: d => metricToFormatter["LATENCY"](d.latency.P95),
sorter: (a, b) => numericSort(a.P95, b.P95)
},
{
title: "P99 Latency",
dataIndex: "latency.P99",
isNumeric: true,
render: d => metricToFormatter["LATENCY"](d.latency.P99),
sorter: (a, b) => numericSort(a.latency.P99, b.latency.P99)
}
];
export default class TopRoutesTable extends React.Component {
static propTypes = {
rows: PropTypes.arrayOf(PropTypes.shape({}))
};
static defaultProps = {
rows: []
};
render() {
const { rows } = this.props;
return (
<BaseTable
tableRows={rows}
tableColumns={routesColumns}
tableClassName="metric-table"
defaultOrderBy="route"
rowKey={r => r.route}
padding="dense" />
);
}
}

View File

@ -156,6 +156,18 @@ const processStatTable = table => {
.value();
};
export const processTopRoutesResults = rows => {
return _.map(rows, row => ({
route: row.route || "UNKNOWN",
totalRequests: getTotalRequests(row),
requestRate: getRequestRate(row),
successRate: getSuccessRate(row),
latency: getLatency(row),
tlsRequestPercent: getTlsRequestPercentage(row),
}
));
};
export const processSingleResourceRollup = rawMetrics => {
let result = processMultiResourceRollup(rawMetrics);
if (_.size(result) > 1) {

View File

@ -19,6 +19,7 @@ import { RouterToUrlQuery } from 'react-url-query';
import ServiceMesh from './components/ServiceMesh.jsx';
import Tap from './components/Tap.jsx';
import Top from './components/Top.jsx';
import TopRoutes from './components/TopRoutes.jsx';
import { dashboardTheme } from './components/util/theme.js';
let appMain = document.getElementById('main');
@ -73,6 +74,9 @@ let applicationHtml = (
<Route
path={`${pathPrefix}/top`}
render={props => <Navigation {...props} ChildComponent={Top} />} />
<Route
path={`${pathPrefix}/routes`}
render={props => <Navigation {...props} ChildComponent={TopRoutes} />} />
<Route
path={`${pathPrefix}/namespaces`}
render={props => <Navigation {...props} ChildComponent={ResourceList} resource="namespace" />} />

View File

@ -133,6 +133,32 @@ func (h *handler) handleApiStat(w http.ResponseWriter, req *http.Request, p http
renderJsonPb(w, result)
}
func (h *handler) handleApiTopRoutes(w http.ResponseWriter, req *http.Request, p httprouter.Params) {
requestParams := util.StatsRequestParams{
TimeWindow: req.FormValue("window"),
ResourceName: req.FormValue("resource_name"),
ResourceType: k8s.Service,
Namespace: req.FormValue("namespace"),
FromName: req.FormValue("from_name"),
FromType: req.FormValue("from_type"),
FromNamespace: req.FormValue("from_namespace"),
}
topReq, err := util.BuildTopRoutesRequest(requestParams)
if err != nil {
renderJsonError(w, err, http.StatusBadRequest)
return
}
result, err := h.apiClient.TopRoutes(req.Context(), topReq)
if err != nil {
renderJsonError(w, err, http.StatusInternalServerError)
return
}
renderJsonPb(w, result)
}
func websocketError(ws *websocket.Conn, wsError int, msg string) {
ws.WriteControl(websocket.CloseMessage,
websocket.FormatCloseMessage(wsError, msg),

View File

@ -96,6 +96,7 @@ func NewServer(addr, templateDir, staticDir, uuid, controllerNamespace, webpackD
server.router.GET("/namespaces/:namespace/replicationcontrollers/:replicationcontroller", handler.handleIndex)
server.router.GET("/tap", handler.handleIndex)
server.router.GET("/top", handler.handleIndex)
server.router.GET("/routes", handler.handleIndex)
server.router.ServeFiles(
"/dist/*filepath", // add catch-all parameter to match all files in dir
filesonly.FileSystem(server.staticDir))
@ -109,6 +110,7 @@ func NewServer(addr, templateDir, staticDir, uuid, controllerNamespace, webpackD
server.router.GET("/api/pods", handler.handleApiPods)
server.router.GET("/api/services", handler.handleApiServices)
server.router.GET("/api/tap", handler.handleApiTap)
server.router.GET("/api/routes", handler.handleApiTopRoutes)
return httpServer
}