Improve the top routes request form and code structure. (#1886)

Separates out the querying and table display of route data, so that this module
can be easily placed in other places in the UI. 

Adds usability improvements to the routes query form at /routes:
- displays CLI equivalent 
- adds dropdown with populated options for service / namespace 

As part of this work, made TapQueryCliCmd more generic, so it can work
for other CLI commands besides tap/top.
This commit is contained in:
Risha Mars 2018-11-29 10:20:53 -08:00 committed by GitHub
parent e9aa9114e1
commit 7a2689ce4a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 386 additions and 145 deletions

View File

@ -0,0 +1,58 @@
import {
CardContent,
Typography
} from '@material-ui/core';
import PropTypes from 'prop-types';
import React from 'react';
import _ from 'lodash';
const toCliParam = {
"namespace": "--namespace",
"toResource": "--to",
"toNamespace": "--to-namespace",
"method": "--method",
"path": "--path",
"scheme": "--scheme",
"authority": "--authority",
"maxRps": "--max-rps",
"from": "--from",
"from_namespace": "--from-namespace"
};
/*
prints a given linkerd api query in an equivalent CLI format, such that it
could be pasted into a terminal
*/
export default class QueryToCliCmd extends React.Component {
static propTypes = {
cmdName: PropTypes.string.isRequired,
displayOrder: PropTypes.arrayOf(PropTypes.string).isRequired,
query: PropTypes.shape({}).isRequired,
resource: PropTypes.string.isRequired
}
renderCliItem = (queryLabel, queryVal) => {
return _.isEmpty(queryVal) ? null : ` ${queryLabel} ${queryVal}`;
}
render = () => {
let { cmdName, query, resource, displayOrder } = this.props;
return (
_.isEmpty(resource) ? null :
<CardContent>
<Typography variant="caption" gutterBottom>
Current {_.startCase(cmdName)} query
</Typography>
<code>
linkerd {this.props.cmdName} {resource}
{ _.map(displayOrder, item => {
return !toCliParam[item] ? null : this.renderCliItem(toCliParam[item], query[item]);
})}
</code>
</CardContent>
);
}
}

View File

@ -0,0 +1,108 @@
import QueryToCliCmd from './QueryToCliCmd.jsx';
import React from 'react';
import { mount } from 'enzyme';
describe('QueryToCliCmd', () => {
it('renders a query as a linkerd CLI command', () => {
let query = {
"resource": "deploy/controller",
"namespace": "linkerd",
"scheme": ""
}
let cliQueryDisplayOrder = [
"namespace",
"scheme"
]
let component = mount(
<QueryToCliCmd
cmdName="routes"
query={query}
resource={query.resource}
displayOrder={cliQueryDisplayOrder} />
);
expect(component).toIncludeText("Current Routes query");
expect(component).toIncludeText("linkerd routes deploy/controller --namespace linkerd");
});
it('does not render flags for items that are not populated in the query', () => {
let query = {
"resource": "deploy/controller",
"namespace": "linkerd",
"scheme": "",
"maxRps": "",
"authority": "foo.bar:8080"
}
let cliQueryDisplayOrder = [
"namespace",
"scheme",
"maxRps",
"authority"
]
let component = mount(
<QueryToCliCmd
cmdName="tap"
query={query}
resource={query.resource}
displayOrder={cliQueryDisplayOrder} />
);
expect(component).toIncludeText("Current Tap query");
expect(component).toIncludeText("linkerd tap deploy/controller --namespace linkerd --authority foo.bar:8080");
});
it('displays the flags in the specified displayOrder', () => {
let query = {
"resource_name": "deploy/controller",
"namespace": "linkerd",
"scheme": "HTTPS",
"maxRps": "",
"toResource": "deploy/prometheus",
"authority": "foo.bar:8080"
}
let cliQueryDisplayOrder = [
"namespace",
"toResource",
"scheme",
"maxRps",
"authority"
]
let component = mount(
<QueryToCliCmd
cmdName="tap"
query={query}
resource={query.resource_name}
displayOrder={cliQueryDisplayOrder} />
);
expect(component).toIncludeText("Current Tap query");
expect(component).toIncludeText("linkerd tap deploy/controller --namespace linkerd --to deploy/prometheus --scheme HTTPS --authority foo.bar:8080");
});
it("doesn't render commands for which a flag is not specified", () => {
let query = {
"resource": "deploy/controller",
"namespace": "linkerd",
"scheme": "HTTPS"
}
let cliQueryDisplayOrder = [
"namespace",
"theLimitDoesNotExist",
"scheme"
]
let component = mount(
<QueryToCliCmd
cmdName="routes"
query={query}
resource={query.resource}
displayOrder={cliQueryDisplayOrder} />
);
expect(component).toIncludeText("Current Routes query");
expect(component).toIncludeText("linkerd routes deploy/controller --namespace linkerd --scheme HTTPS");
});
});

View File

@ -1,76 +0,0 @@
import {
CardContent,
Typography
} from '@material-ui/core';
import PropTypes from 'prop-types';
import React from 'react';
import _ from 'lodash';
import { tapQueryPropType } from './util/TapUtils.jsx';
/*
prints a given tap query in an equivalent CLI format, such that it
could be pasted into a terminal
*/
export default class TapQueryCliCmd extends React.Component {
static propTypes = {
cmdName: PropTypes.string.isRequired,
query: tapQueryPropType
}
static defaultProps = {
query: {
resource: "",
namespace: "",
toResource: "",
toNamespace: "",
method: "",
path: "",
scheme: "",
authority: "",
maxRps: ""
}
}
renderCliItem = (queryLabel, queryVal) => {
return _.isEmpty(queryVal) ? null : ` ${queryLabel} ${queryVal}`;
}
render = () => {
let {
resource,
namespace,
toResource,
toNamespace,
method,
path,
scheme,
authority,
maxRps
} = this.props.query;
return (
_.isEmpty(resource) ? null :
<CardContent className="tap-query">
<Typography variant="caption" gutterBottom>
Current {_.startCase(this.props.cmdName)} query
</Typography>
<code>
linkerd {this.props.cmdName} {resource}
{ resource.indexOf("namespace") === 0 ? null : this.renderCliItem("--namespace", namespace) }
{ this.renderCliItem("--to", toResource) }
{
_.isEmpty(toResource) || toResource.indexOf("namespace") === 0 ? null :
this.renderCliItem("--to-namespace", toNamespace)
}
{ this.renderCliItem("--method", method) }
{ this.renderCliItem("--scheme", scheme) }
{ this.renderCliItem("--authority", authority) }
{ this.renderCliItem("--path", path) }
{ this.renderCliItem("--max-rps", maxRps) }
</code>
</CardContent>
);
}
}

View File

@ -25,8 +25,8 @@ import {
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import PropTypes from 'prop-types';
import QueryToCliCmd from './QueryToCliCmd.jsx';
import React from 'react';
import TapQueryCliCmd from './TapQueryCliCmd.jsx';
import _ from 'lodash';
import { withStyles } from '@material-ui/core/styles';
@ -396,7 +396,16 @@ class TapQueryForm extends React.Component {
render() {
const { classes } = this.props;
let cliQueryDisplayOrder = _.compact([
this.state.query.resource.indexOf("namespace") === 0 ? null : "namespace",
"toResource",
this.state.query.toResource.indexOf("namespace") === 0 ? null : "toNamespace",
"method",
"path",
"scheme",
"authority",
"maxRps"
]);
return (
<Card className={classes.card}>
<CardContent>
@ -423,9 +432,9 @@ class TapQueryForm extends React.Component {
</Grid>
</CardContent>
<TapQueryCliCmd cmdName="tap" query={this.state.query} />
<QueryToCliCmd cmdName="tap" query={this.state.query} resource={this.state.query.resource} displayOrder={cliQueryDisplayOrder} />
{ !this.props.enableAdvancedForm ? null : this.renderAdvancedTapForm() }
{ !this.props.enableAdvancedForm ? null : this.renderAdvancedTapForm() }
</Card>
);

View File

@ -1,12 +1,19 @@
import Button from '@material-ui/core/Button';
import Card from '@material-ui/core/Card';
import CardContent from '@material-ui/core/CardContent';
import ErrorBanner from './ErrorBanner.jsx';
import FormControl from '@material-ui/core/FormControl';
import FormHelperText from '@material-ui/core/FormHelperText';
import Grid from '@material-ui/core/Grid';
import InputLabel from '@material-ui/core/InputLabel';
import MenuItem from '@material-ui/core/MenuItem';
import PropTypes from 'prop-types';
import QueryToCliCmd from './QueryToCliCmd.jsx';
import React from 'react';
import Select from '@material-ui/core/Select';
import TextField from '@material-ui/core/TextField';
import TopRoutesTable from './TopRoutesTable.jsx';
import TopRoutesModule from './TopRoutesModule.jsx';
import _ from 'lodash';
import { processTopRoutesResults } from './util/MetricUtils.jsx';
import { withContext } from './util/AppContext.jsx';
class TopRoutes extends React.Component {
@ -29,46 +36,38 @@ class TopRoutes extends React.Component {
from_namespace: ''
},
error: null,
metrics: [],
services: [],
namespaces: [],
pollingInterval: 5000,
pendingRequests: false,
pollingInProgress: false
requestInProgress: false
};
}
componentDidMount() {
this.startServerPolling();
}
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.api.setCurrentRequests([this.api.fetchServices()]);
this.serverPromise = Promise.all(this.api.getCurrentPromises())
.then(([routeStats]) => {
let metrics = processTopRoutesResults(_.get(routeStats, 'routes.rows', []));
.then(([svcList]) => {
let services = _.get(svcList, 'services', []);
let namespaces = _.uniq(_.map(services, 'namespace'));
this.setState({
metrics,
services,
namespaces,
pendingRequests: false,
error: null
});
@ -88,9 +87,6 @@ class TopRoutes extends React.Component {
}
startServerPolling = () => {
this.setState({
pollingInProgress: true
});
this.loadFromServer();
this.timerId = window.setInterval(this.loadFromServer, this.state.pollingInterval);
}
@ -98,8 +94,11 @@ class TopRoutes extends React.Component {
stopServerPolling = () => {
window.clearInterval(this.timerId);
this.api.cancelCurrentRequests();
}
handleBtnClick = inProgress => () => {
this.setState({
pollingInProgress: false
requestInProgress: inProgress
});
}
@ -113,49 +112,102 @@ class TopRoutes extends React.Component {
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>
<CardContent>
<Grid container direction="column">
<Grid item container spacing={8} alignItems="center">
<Grid item xs={6} md={3}>
{ this.renderNamespaceDropdown("Namespace", "namespace", "Namespace of the configured service") }
</Grid>
<Grid item container spacing={8} alignItems="center">
<Grid item xs={6} md={3}>
{ this.renderTextInput("From", "from_name", "Resource name") }
<Grid item xs={6} md={3}>
{ this.renderServiceDropdown() }
</Grid>
</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}>
<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.requestInProgress}
onClick={this.handleBtnClick(true)}>
Start
</Button>
</Grid>
<Grid item>
<Button
color="default"
variant="outlined"
disabled={!this.state.pollingInProgress}
onClick={this.stopServerPolling}>
</Button>
</Grid>
<Grid item>
<Button
color="default"
variant="outlined"
disabled={!this.state.requestInProgress}
onClick={this.handleBtnClick(false)}>
Stop
</Button>
</Button>
</Grid>
</Grid>
</Grid>
</Grid>
</CardContent>
);
}
renderNamespaceDropdown = (title, key, helperText) => {
return (
<FormControl>
<InputLabel htmlFor={`${key}-dropdown`}>{title}</InputLabel>
<Select
value={this.state.query[key]}
onChange={this.handleFormEvent(key)}
inputProps={{
name: key,
id: `${key}-dropdown`,
}}
name={key}>
{
_.map(_.sortBy(this.state.namespaces), ns =>
<MenuItem key={`namespace-${ns}`} value={ns}>{ns}</MenuItem>)
}
</Select>
<FormHelperText>{helperText}</FormHelperText>
</FormControl>
);
}
renderServiceDropdown = () => {
let key = "resource_name";
let services = _.chain(this.state.services)
.filter(['namespace', this.state.query.namespace])
.map('name').sortBy().value();
return (
<FormControl>
<InputLabel htmlFor={`${key}-dropdown`}>Service</InputLabel>
<Select
value={this.state.query[key]}
onChange={this.handleFormEvent(key)}
disabled={_.isEmpty(this.state.query.namespace)}
autoWidth
inputProps={{
name: key,
id: `${key}-dropdown`,
}}
name={key}>
{
_.map(services, svc => <MenuItem key={`service-${svc}`} value={svc}>{svc}</MenuItem>)
}
</Select>
<FormHelperText>The configured service</FormHelperText>
</FormControl>
);
}
@ -172,14 +224,31 @@ class TopRoutes extends React.Component {
}
render() {
let cliQueryDisplayOrder = [
"namespace",
"from",
"from_namespace"
];
let query = this.state.query;
let from = '';
if (_.isEmpty(query.from_type)) {
from = query.from_name;
} else {
from = `${query.from_type}${_.isEmpty(query.from_name) ? "" : "/"}${query.from_name}`;
}
query.from = from;
return (
<div>
{
!this.state.error ? null :
<ErrorBanner message={this.state.error} onHideMessage={() => this.setState({ error: null })} />
}
{this.renderRoutesQueryForm()}
<TopRoutesTable rows={this.state.metrics} />
<Card>
{ this.renderRoutesQueryForm() }
<QueryToCliCmd cmdName="routes" query={query} resource={this.state.query.resource_name} displayOrder={cliQueryDisplayOrder} />
</Card>
{ !this.state.requestInProgress ? null : <TopRoutesModule query={this.state.query} /> }
</div>
);
}

View File

@ -0,0 +1,64 @@
import Card from '@material-ui/core/Card';
import CardContent from '@material-ui/core/CardContent';
import ErrorBanner from './ErrorBanner.jsx';
import PropTypes from 'prop-types';
import React from 'react';
import TopRoutesTable from './TopRoutesTable.jsx';
import Typography from '@material-ui/core/Typography';
import _ from 'lodash';
import { apiErrorPropType } from './util/ApiHelpers.jsx';
import { processTopRoutesResults } from './util/MetricUtils.jsx';
import withREST from './util/withREST.jsx';
class TopRoutesBase extends React.Component {
static defaultProps = {
error: null
}
static propTypes = {
data: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
error: apiErrorPropType,
query: PropTypes.shape({}).isRequired,
}
banner = () => {
const {error} = this.props;
if (!error) {
return;
}
return <ErrorBanner message={error} />;
}
configureProfileMsg() {
return (
<Card>
<CardContent>
<Typography>
No traffic found. Does the service have a service profile? You can create one with the `linkerd profile` command.
</Typography>
</CardContent>
</Card>
);
}
render() {
const {data} = this.props;
let metrics = processTopRoutesResults(_.get(data, '[0].routes.rows', []));
return (
<React.Fragment>
{this.banner()}
{_.isEmpty(metrics) ? this.configureProfileMsg() : null}
<TopRoutesTable rows={metrics} />
</React.Fragment>
);
}
}
export default withREST(
TopRoutesBase,
({api, query}) => {
let queryParams = new URLSearchParams(query).toString();
return [api.fetchMetrics(`/api/routes?${queryParams}`)];
}
);

View File

@ -55,6 +55,7 @@ export const apiErrorPropType = PropTypes.shape({
const ApiHelpers = (pathPrefix, defaultMetricsWindow = '1m') => {
let metricsWindow = defaultMetricsWindow;
const podsPath = `/api/pods`;
const servicesPath = `/api/services`;
const validMetricsWindows = {
"10s": "10 minutes",
@ -89,6 +90,13 @@ const ApiHelpers = (pathPrefix, defaultMetricsWindow = '1m') => {
return apiFetch(podsPath);
};
const fetchServices = namespace => {
if (!_.isNil(namespace)) {
return apiFetch(servicesPath + "?namespace=" + namespace);
}
return apiFetch(servicesPath);
};
const getMetricsWindow = () => metricsWindow;
const getMetricsWindowDisplayText = () => validMetricsWindows[metricsWindow];
@ -187,6 +195,7 @@ const ApiHelpers = (pathPrefix, defaultMetricsWindow = '1m') => {
fetch: apiFetch,
fetchMetrics,
fetchPods,
fetchServices,
getMetricsWindow,
setMetricsWindow,
getValidMetricsWindows: () => _.keys(validMetricsWindows),