mirror of https://github.com/linkerd/linkerd2.git
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:
parent
f8583df4db
commit
d9539bcb37
|
@ -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 {
|
||||
|
|
|
@ -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);
|
|
@ -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" />
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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" />} />
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue