diff --git a/web/app/js/components/BreadcrumbHeader.jsx b/web/app/js/components/BreadcrumbHeader.jsx
index c58101f26..e7c41d137 100644
--- a/web/app/js/components/BreadcrumbHeader.jsx
+++ b/web/app/js/components/BreadcrumbHeader.jsx
@@ -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 {
diff --git a/web/app/js/components/TopRoutes.jsx b/web/app/js/components/TopRoutes.jsx
new file mode 100644
index 000000000..cfaec622c
--- /dev/null
+++ b/web/app/js/components/TopRoutes.jsx
@@ -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 (
+
+
+
+ { this.renderTextInput("Service", "resource_name", "Name of the configured service") }
+
+
+ { this.renderTextInput("Namespace", "namespace", "Namespace of the configured service") }
+
+
+
+
+
+ { this.renderTextInput("From", "from_name", "Resource name") }
+
+
+ { this.renderTextInput("From type", "from_type", "Resource type") }
+
+
+ { this.renderTextInput("From namespace", "from_namespace", "Resource namespace") }
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ renderTextInput = (title, key, helperText) => {
+ return (
+
+ );
+ }
+
+ render() {
+ return (
+
+ {
+ !this.state.error ? null :
+ this.setState({ error: null })} />
+ }
+ {this.renderRoutesQueryForm()}
+
+
+ );
+ }
+}
+
+export default withContext(TopRoutes);
diff --git a/web/app/js/components/TopRoutesTable.jsx b/web/app/js/components/TopRoutesTable.jsx
new file mode 100644
index 000000000..6e76d6a26
--- /dev/null
+++ b/web/app/js/components/TopRoutesTable.jsx
@@ -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 => ,
+ 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 (
+ r.route}
+ padding="dense" />
+ );
+ }
+}
diff --git a/web/app/js/components/util/MetricUtils.jsx b/web/app/js/components/util/MetricUtils.jsx
index b59e886bc..b346a9d03 100644
--- a/web/app/js/components/util/MetricUtils.jsx
+++ b/web/app/js/components/util/MetricUtils.jsx
@@ -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) {
diff --git a/web/app/js/index.js b/web/app/js/index.js
index 0fedb955a..4a1ce196c 100644
--- a/web/app/js/index.js
+++ b/web/app/js/index.js
@@ -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 = (
} />
+ } />
} />
diff --git a/web/srv/api_handlers.go b/web/srv/api_handlers.go
index abb210d85..3e2b9bce7 100644
--- a/web/srv/api_handlers.go
+++ b/web/srv/api_handlers.go
@@ -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),
diff --git a/web/srv/server.go b/web/srv/server.go
index 247873b1f..f51edd090 100644
--- a/web/srv/server.go
+++ b/web/srv/server.go
@@ -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
}