Update Top Routes in dashboard to use new API, add `to` query options (#2112)

The recent routes API changes caused the Top Routes tab to stop working, as it
wasn't looking for the changed structure of the response. This PR updates that
page to accept the new API response.

This PR also adds to fields to the Top Routes query form, so that the equivalent
of linkerd routes deploy --to deploy/authors will work in the dashboard.
This commit is contained in:
Risha Mars 2019-01-23 14:29:27 -08:00 committed by GitHub
parent 28f662c9c6
commit cce6183c4a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 115 additions and 53 deletions

View File

@ -4,7 +4,8 @@ import {
emptyTapQuery,
httpMethods,
tapQueryPropType,
tapQueryProps
tapQueryProps,
tapResourceTypes
} from './util/TapUtils.jsx';
import Button from '@material-ui/core/Button';
import Card from '@material-ui/core/Card';
@ -42,14 +43,6 @@ import _upperFirst from 'lodash/upperFirst';
import _values from 'lodash/values';
import { withStyles } from '@material-ui/core/styles';
// you can also tap resources to tap all pods in the resource
const resourceTypes = [
"deployment",
"daemonset",
"pod",
"replicationcontroller",
"statefulset"
];
const getResourceList = (resourcesByNs, ns) => {
return resourcesByNs[ns] || _uniq(_flatten(_values(resourcesByNs)));
@ -241,7 +234,7 @@ class TapQueryForm extends React.Component {
let nsEmpty = _isNil(selectedNs) || _isEmpty(selectedNs);
let { classes } = this.props;
let resourceOptions = resourceTypes.concat(
let resourceOptions = tapResourceTypes.concat(
this.state.autocomplete[resourceKey] || [],
nsEmpty ? [] : [`namespace/${selectedNs}`]
).sort();

View File

@ -1,4 +1,5 @@
import { UrlQueryParamTypes, addUrlProps } from 'react-url-query';
import Button from '@material-ui/core/Button';
import Card from '@material-ui/core/Card';
import CardContent from '@material-ui/core/CardContent';
@ -24,6 +25,7 @@ import _pick from 'lodash/pick';
import _uniq from 'lodash/uniq';
import _upperFirst from 'lodash/upperFirst';
import { groupResourcesByNs } from './util/MetricUtils.jsx';
import { tapResourceTypes } from './util/TapUtils.jsx';
import { withContext } from './util/AppContext.jsx';
import { withStyles } from '@material-ui/core/styles';
@ -31,6 +33,9 @@ const topRoutesQueryProps = {
resource_name: PropTypes.string,
resource_type: PropTypes.string,
namespace: PropTypes.string,
to_name: PropTypes.string,
to_type: PropTypes.string,
to_namespace: PropTypes.string,
};
const topRoutesQueryPropType = PropTypes.shape(topRoutesQueryProps);
@ -38,6 +43,10 @@ const urlPropsQueryConfig = _mapValues(topRoutesQueryProps, () => {
return { type: UrlQueryParamTypes.string };
});
const toResourceName = (query, typeKey, nameKey) => {
return `${query[typeKey] || ""}${!query[nameKey] ? "" : "/"}${query[nameKey] || ""}`;
};
const styles = theme => ({
root: {
marginTop: 3 * theme.spacing.unit,
@ -61,6 +70,9 @@ class TopRoutes extends React.Component {
resource_name: '',
resource_type: '',
namespace: '',
to_name: '',
to_type: '',
to_namespace: ''
},
}
@ -151,26 +163,30 @@ class TopRoutes extends React.Component {
// Each time state.query is updated, this method calls the equivalent
// onChange method to reflect the update in url query params. These onChange
// methods are automatically added to props by react-url-query.
handleUrlUpdate = (name, formVal) => {
this.props[`onChange${_upperFirst(name)}`](formVal);
handleUrlUpdate = query => {
for (let key in query) {
this.props[`onChange${_upperFirst(key)}`](query[key]);
}
}
handleNamespaceSelect = e => {
handleNamespaceSelect = nsKey => e => {
let query = this.state.query;
let formVal = _get(e, 'target.value');
query.namespace = formVal;
this.handleUrlUpdate("namespace", formVal);
query[nsKey] = formVal;
this.handleUrlUpdate(query);
this.setState({ query });
};
handleResourceSelect = e => {
handleResourceSelect = (nameKey, typeKey) => e => {
let query = this.state.query;
let resource = _get(e, 'target.value');
let [resource_type, resource_name] = resource.split("/");
query.resource_name = resource_name;
query.resource_type = resource_type;
this.handleUrlUpdate("resource_name", resource_name);
this.handleUrlUpdate("resource_type", resource_type);
let [resourceType, resourceName] = resource.split("/");
resourceName = resourceName || "";
query[nameKey] = resourceName;
query[typeKey] = resourceType;
this.handleUrlUpdate(query);
this.setState({ query });
}
@ -186,7 +202,7 @@ class TopRoutes extends React.Component {
</Grid>
<Grid item>
{ this.renderResourceDropdown() }
{ this.renderResourceDropdown("Resource", "resource_name", "resource_type", "Resource to query") }
</Grid>
<Grid item>
@ -209,6 +225,16 @@ class TopRoutes extends React.Component {
</Button>
</Grid>
</Grid>
<Grid item container spacing={32} alignItems="center" justify="flex-start">
<Grid item>
{ this.renderNamespaceDropdown("To Namespace", "to_namespace", "Namespece of target resource") }
</Grid>
<Grid item>
{ this.renderResourceDropdown("To Resource", "to_name", "to_type", "Target resource") }
</Grid>
</Grid>
</Grid>
<Divider light className={classes.root} />
{this.props.singleNamespace === "true" ? null :
@ -225,7 +251,7 @@ class TopRoutes extends React.Component {
<InputLabel htmlFor={`${key}-dropdown`}>{title}</InputLabel>
<Select
value={this.state.query[key]}
onChange={this.handleNamespaceSelect}
onChange={this.handleNamespaceSelect(key)}
inputProps={{
name: key,
id: `${key}-dropdown`,
@ -241,19 +267,26 @@ class TopRoutes extends React.Component {
);
}
renderResourceDropdown = () => {
renderResourceDropdown = (title, nameKey, typeKey, helperText) => {
const { classes } = this.props;
let { query, services, resourcesByNs } = this.state;
let key = "resource_name";
let servicesWithPrefix = services
.filter(s => s.namespace === query.namespace)
.map(svc => `service/${svc.name}`);
let otherResources = resourcesByNs[query.namespace] || [];
let nsFilterKey = "namespace";
if (typeKey === "to_type") {
nsFilterKey = "to_namespace";
}
let dropdownOptions = servicesWithPrefix.concat(otherResources).sort();
let dropdownVal = _isEmpty(query.resource_name) || _isEmpty(query.resource_type) ? "" :
query.resource_type + "/" + query.resource_name;
let servicesWithPrefix = services
.filter(s => s[nsFilterKey] === query[nsFilterKey])
.map(svc => `service/${svc.name}`);
let otherResources = resourcesByNs[query[nsFilterKey]] || [];
let dropdownOptions = servicesWithPrefix
.concat(otherResources)
.concat(tapResourceTypes)
.sort();
let dropdownVal = toResourceName(query, typeKey, nameKey);
if (_isEmpty(dropdownOptions) && !_isEmpty(dropdownVal)) {
dropdownOptions = [dropdownVal]; // populate from url if autocomplete hasn't loaded
@ -261,28 +294,31 @@ class TopRoutes extends React.Component {
return (
<FormControl className={classes.formControl}>
<InputLabel htmlFor={`${key}-dropdown`}>Resource</InputLabel>
<InputLabel htmlFor={`${nameKey}-dropdown`}>{title}</InputLabel>
<Select
value={dropdownVal}
onChange={this.handleResourceSelect}
onChange={this.handleResourceSelect(nameKey, typeKey)}
disabled={_isEmpty(query.namespace)}
inputProps={{
name: key,
id: `${key}-dropdown`,
name: nameKey,
id: `${nameKey}-dropdown`,
}}
name={key}>
name={nameKey}>
{
dropdownOptions.map(resource => <MenuItem key={resource} value={resource}>{resource}</MenuItem>)
}
</Select>
<FormHelperText>Resource to query</FormHelperText>
<FormHelperText>{helperText}</FormHelperText>
</FormControl>
);
}
render() {
let query = this.state.query;
let emptyQuery = _isEmpty(query.resource_name) || _isEmpty(query.resource_type);
let emptyQuery = _isEmpty(query.resource_type);
let cliQueryToDisplay = _merge({}, query, {toResource: toResourceName(query, "to_type", "to_name"), toNamespace: query.to_namespace});
return (
<div>
@ -292,8 +328,13 @@ class TopRoutes extends React.Component {
}
<Card>
{ this.renderRoutesQueryForm() }
{ emptyQuery ? null :
<QueryToCliCmd cmdName="routes" query={query} resource={query.resource_type + "/" + query.resource_name} /> }
{
emptyQuery ? null :
<QueryToCliCmd
cmdName="routes"
query={cliQueryToDisplay}
resource={toResourceName(query, "resource_type", "resource_name")} />
}
{ !this.state.requestInProgress || !this._isMounted ? null : <TopRoutesModule query={this.state.query} /> }
</Card>
</div>

View File

@ -1,15 +1,15 @@
import { DefaultRoute, processTopRoutesResults } from './util/MetricUtils.jsx';
import CardContent from '@material-ui/core/CardContent';
import ConfigureProfilesMsg from './ConfigureProfilesMsg.jsx';
import ErrorBanner from './ErrorBanner.jsx';
import PropTypes from 'prop-types';
import React from 'react';
import Spinner from './util/Spinner.jsx';
import TopRoutesTable from './TopRoutesTable.jsx';
import _every from 'lodash/every';
import Typography from '@material-ui/core/Typography';
import _get from 'lodash/get';
import _isEmpty from 'lodash/isEmpty';
import { apiErrorPropType } from './util/ApiHelpers.jsx';
import { processTopRoutesResults } from './util/MetricUtils.jsx';
import withREST from './util/withREST.jsx';
class TopRoutesBase extends React.Component {
@ -43,16 +43,30 @@ class TopRoutesBase extends React.Component {
render() {
const {data, loading} = this.props;
let metrics = processTopRoutesResults(_get(data, '[0].routes.rows', []));
let allRoutesUnknown = _every(metrics, m => m.route === DefaultRoute);
let showCallToAction = !loading && (_isEmpty(metrics) || allRoutesUnknown);
let results = _get(data, '[0].ok.routes', []);
let metricsByResource = results.map(r => {
return {
resource: r.resource,
rows: processTopRoutesResults(r.rows)
};
});
return (
<React.Fragment>
{this.loading()}
{this.banner()}
<TopRoutesTable rows={metrics} />
{ showCallToAction ? <ConfigureProfilesMsg /> : null}
{
metricsByResource.map(metric => {
return (
<CardContent key={metric.resource}>
<Typography variant="h5">{metric.resource}</Typography>
<TopRoutesTable rows={metric.rows} />
</CardContent>
);
})
}
{ !loading && _isEmpty(metricsByResource) ? <ConfigureProfilesMsg /> : null }
</React.Fragment>
);
}

View File

@ -21,7 +21,7 @@ const routesColumns = [
}
},
{
title: "Authority",
title: "Service",
tooltip: "hostname:port used when communicating with this target",
dataIndex: "authority",
sorter: (a, b) => (a.authority).localeCompare(b.authority)

View File

@ -1,9 +1,11 @@
import _compact from 'lodash/compact';
import _isNil from 'lodash/isNil';
const topRoutesDisplayOrder = [
const topRoutesDisplayOrder = query => _compact([
"namespace",
];
"toResource",
_isNil(query.to_type) ? null : "toNamespace",
]);
const tapDisplayOrder = query => _compact([
@ -22,7 +24,7 @@ export const displayOrder = (cmd, query) => {
return tapDisplayOrder(query);
}
if (cmd === "routes") {
return topRoutesDisplayOrder;
return topRoutesDisplayOrder(query);
}
return [];
};

View File

@ -30,6 +30,15 @@ export const setMaxRps = query => {
}
};
// resources you can tap/top to tap all pods in the resource
export const tapResourceTypes = [
"deployment",
"daemonset",
"pod",
"replicationcontroller",
"statefulset"
];
// use a generator to get this object, to prevent it from being overwritten
export const emptyTapQuery = () => ({
resource: "",

View File

@ -152,6 +152,9 @@ func (h *handler) handleAPITopRoutes(w http.ResponseWriter, req *http.Request, p
ResourceType: req.FormValue("resource_type"),
Namespace: req.FormValue("namespace"),
},
ToName: req.FormValue("to_name"),
ToType: req.FormValue("to_type"),
ToNamespace: req.FormValue("to_namespace"),
}
topReq, err := util.BuildTopRoutesRequest(requestParams)