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 }