mirror of https://github.com/linkerd/linkerd2.git
Add Edges table to resource detail view of dashboard (#2965)
Adds an Edges table to the resource detail view that shows the source, destination name and identity for proxied connections to and from the resource shown.
This commit is contained in:
parent
2252e44305
commit
dce462acd9
|
@ -1,5 +1,6 @@
|
|||
import CloseIcon from '@material-ui/icons/Close';
|
||||
import FilterListIcon from '@material-ui/icons/FilterList';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import Hidden from '@material-ui/core/Hidden';
|
||||
import Paper from '@material-ui/core/Paper';
|
||||
import PropTypes from 'prop-types';
|
||||
|
@ -19,6 +20,7 @@ import _get from 'lodash/get';
|
|||
import _isNil from 'lodash/isNil';
|
||||
import _orderBy from 'lodash/orderBy';
|
||||
import classNames from 'classnames';
|
||||
import { faQuestionCircle } from '@fortawesome/free-solid-svg-icons/faQuestionCircle';
|
||||
import { withStyles } from '@material-ui/core/styles';
|
||||
|
||||
const styles = theme => ({
|
||||
|
@ -149,7 +151,11 @@ class BaseTable extends React.Component {
|
|||
<Typography
|
||||
className={classes.title}
|
||||
variant="h5">
|
||||
{title}
|
||||
{title} {this.props.showTitleTooltip &&
|
||||
<Tooltip title={this.props.titleTooltipText}>
|
||||
<FontAwesomeIcon icon={faQuestionCircle} />
|
||||
</Tooltip>
|
||||
}
|
||||
</Typography>
|
||||
{this.state.showFilter &&
|
||||
<TextField
|
||||
|
@ -223,6 +229,7 @@ BaseTable.propTypes = {
|
|||
enableFilter: PropTypes.bool,
|
||||
padding: PropTypes.string,
|
||||
rowKey: PropTypes.func,
|
||||
showTitleTooltip: PropTypes.bool,
|
||||
tableClassName: PropTypes.string,
|
||||
tableColumns: PropTypes.arrayOf(PropTypes.shape({
|
||||
dataIndex: PropTypes.string,
|
||||
|
@ -233,7 +240,8 @@ BaseTable.propTypes = {
|
|||
title: PropTypes.string
|
||||
})).isRequired,
|
||||
tableRows: PropTypes.arrayOf(PropTypes.shape({})),
|
||||
title: PropTypes.string
|
||||
title: PropTypes.string,
|
||||
titleTooltipText: PropTypes.string
|
||||
};
|
||||
|
||||
BaseTable.defaultProps = {
|
||||
|
@ -244,7 +252,9 @@ BaseTable.defaultProps = {
|
|||
rowKey: null,
|
||||
tableClassName: "",
|
||||
tableRows: [],
|
||||
title: ""
|
||||
title: "",
|
||||
showTitleTooltip: false,
|
||||
titleTooltipText: ""
|
||||
};
|
||||
|
||||
export default withStyles(styles)(BaseTable);
|
||||
|
|
|
@ -0,0 +1,113 @@
|
|||
import BaseTable from './BaseTable.jsx';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { processedEdgesPropType } from './util/EdgesUtils.jsx';
|
||||
import { withContext } from './util/AppContext.jsx';
|
||||
|
||||
const edgesColumnDefinitions = (PrefixedLink, namespace, type) => {
|
||||
return [
|
||||
{
|
||||
title: "Source",
|
||||
dataIndex: "source",
|
||||
isNumeric: false,
|
||||
filter: d => d.src.name,
|
||||
render: d => {
|
||||
// check that the source is a k8s resource with a name we can link to
|
||||
if (namespace && type && d.src && d.src.name) {
|
||||
return (
|
||||
<PrefixedLink to={`/namespaces/${namespace}/${type}s/${d.src.name}`}>
|
||||
{d.src.name}
|
||||
</PrefixedLink>
|
||||
);
|
||||
} else {
|
||||
return d.src.name;
|
||||
}
|
||||
},
|
||||
sorter: d => d.src.name + d.dst.name
|
||||
},
|
||||
{
|
||||
title: "Destination",
|
||||
dataIndex: "destination",
|
||||
isNumeric: false,
|
||||
filter: d => d.dst.name,
|
||||
render: d => {
|
||||
// check that the destination is a k8s resource with a name we can link to
|
||||
if (namespace && type && d.dst && d.dst.name) {
|
||||
return (
|
||||
<PrefixedLink to={`/namespaces/${namespace}/${type}s/${d.dst.name}`}>
|
||||
{d.dst.name}
|
||||
</PrefixedLink>
|
||||
);
|
||||
} else {
|
||||
return d.dst.name;
|
||||
}
|
||||
},
|
||||
sorter: d => d.dst.name + d.src.name
|
||||
},
|
||||
{
|
||||
title: "Client",
|
||||
dataIndex: "client",
|
||||
isNumeric: false,
|
||||
filter: d => d.clientId,
|
||||
render: d => d.clientId.split('.')[0] + '.' + d.clientId.split('.')[1],
|
||||
sorter: d => d.clientId
|
||||
},
|
||||
{
|
||||
title: "Server",
|
||||
dataIndex: "server",
|
||||
isNumeric: false,
|
||||
filter: d => d.serverId,
|
||||
render: d => d.serverId.split('.')[0] + '.' + d.serverId.split('.')[1],
|
||||
sorter: d => d.serverId
|
||||
},
|
||||
{
|
||||
title: "Message",
|
||||
dataIndex: "message",
|
||||
isNumeric: false,
|
||||
filter: d => d.noIdentityMsg,
|
||||
render: d => d.noIdentityMsg,
|
||||
sorter: d => d.noIdentityMsg
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
const tooltipText = `Edges show the source, destination name and identity
|
||||
for proxied connections. If no identity is known, a message is displayed.`;
|
||||
|
||||
class EdgesTable extends React.Component {
|
||||
static propTypes = {
|
||||
api: PropTypes.shape({
|
||||
prefixedUrl: PropTypes.func.isRequired,
|
||||
}).isRequired,
|
||||
edges: PropTypes.arrayOf(processedEdgesPropType),
|
||||
namespace: PropTypes.string.isRequired,
|
||||
title: PropTypes.string,
|
||||
type: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
edges: [],
|
||||
title: ""
|
||||
};
|
||||
|
||||
|
||||
render() {
|
||||
const { edges, title, api, namespace, type } = this.props;
|
||||
let edgesColumns = edgesColumnDefinitions(api.PrefixedLink, namespace, type);
|
||||
|
||||
return (
|
||||
<BaseTable
|
||||
defaultOrderBy="source"
|
||||
enableFilter={true}
|
||||
showTitleTooltip={true}
|
||||
tableRows={edges}
|
||||
tableColumns={edgesColumns}
|
||||
tableClassName="metric-table"
|
||||
title={title}
|
||||
titleTooltipText={tooltipText}
|
||||
padding="dense" />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withContext(EdgesTable);
|
|
@ -2,6 +2,7 @@ import 'whatwg-fetch';
|
|||
import { emptyMetric, processMultiResourceRollup, processSingleResourceRollup } from './util/MetricUtils.jsx';
|
||||
import { resourceTypeToCamelCase, singularResource } from './util/Utils.js';
|
||||
import AddResources from './AddResources.jsx';
|
||||
import EdgesTable from './EdgesTable.jsx';
|
||||
import ErrorBanner from './ErrorBanner.jsx';
|
||||
import Grid from '@material-ui/core/Grid';
|
||||
import MetricsTable from './MetricsTable.jsx';
|
||||
|
@ -14,15 +15,19 @@ import TopRoutesTabs from './TopRoutesTabs.jsx';
|
|||
import Typography from '@material-ui/core/Typography';
|
||||
import _filter from 'lodash/filter';
|
||||
import _get from 'lodash/get';
|
||||
import _indexOf from 'lodash/indexOf';
|
||||
import _isEmpty from 'lodash/isEmpty';
|
||||
import _isEqual from 'lodash/isEqual';
|
||||
import _isNil from 'lodash/isNil';
|
||||
import _merge from 'lodash/merge';
|
||||
import _reduce from 'lodash/reduce';
|
||||
import { processEdges } from './util/EdgesUtils.jsx';
|
||||
import { withContext } from './util/AppContext.jsx';
|
||||
|
||||
// if there has been no traffic for some time, show a warning
|
||||
const showNoTrafficMsgDelayMs = 6000;
|
||||
// resource types supported when querying API for edge data
|
||||
const edgeDataAvailable = ["daemonset", "deployment", "job", "pod", "replicationcontroller", "statefulset"];
|
||||
|
||||
const getResourceFromUrl = (match, pathPrefix) => {
|
||||
let resource = {
|
||||
|
@ -124,7 +129,8 @@ export class ResourceDetailBase extends React.Component {
|
|||
|
||||
let { resource } = this.state;
|
||||
|
||||
this.api.setCurrentRequests([
|
||||
let apiRequests =
|
||||
[
|
||||
// inbound stats for this resource
|
||||
this.api.fetchMetrics(
|
||||
`${this.api.urlsForResource(resource.type, resource.namespace, true)}&resource_name=${resource.name}`
|
||||
|
@ -143,14 +149,23 @@ export class ResourceDetailBase extends React.Component {
|
|||
this.api.fetchMetrics(
|
||||
`${this.api.urlsForResource("all")}&from_name=${resource.name}&from_type=${resource.type}&from_namespace=${resource.namespace}`
|
||||
)
|
||||
];
|
||||
|
||||
if (_indexOf(edgeDataAvailable, resource.type) > 0) {
|
||||
apiRequests = apiRequests.concat([
|
||||
this.api.fetchEdges(resource.namespace, resource.type)
|
||||
]);
|
||||
}
|
||||
|
||||
this.api.setCurrentRequests(apiRequests);
|
||||
|
||||
Promise.all(this.api.getCurrentPromises())
|
||||
.then(([resourceRsp, podListRsp, podMetricsRsp, upstreamRsp, downstreamRsp]) => {
|
||||
.then(([resourceRsp, podListRsp, podMetricsRsp, upstreamRsp, downstreamRsp, edgesRsp]) => {
|
||||
let resourceMetrics = processSingleResourceRollup(resourceRsp);
|
||||
let podMetrics = processSingleResourceRollup(podMetricsRsp);
|
||||
let upstreamMetrics = processMultiResourceRollup(upstreamRsp);
|
||||
let downstreamMetrics = processMultiResourceRollup(downstreamRsp);
|
||||
let edges = processEdges(edgesRsp, this.state.resource.name);
|
||||
|
||||
// INEFFICIENT: get metrics for all the pods belonging to this resource.
|
||||
// Do this by querying for metrics for all pods in this namespace and then filtering
|
||||
|
@ -208,6 +223,7 @@ export class ResourceDetailBase extends React.Component {
|
|||
podMetrics: podMetricsForResource,
|
||||
upstreamMetrics,
|
||||
downstreamMetrics,
|
||||
edges,
|
||||
lastMetricReceivedTime,
|
||||
isTcpOnly,
|
||||
loaded: true,
|
||||
|
@ -253,6 +269,7 @@ export class ResourceDetailBase extends React.Component {
|
|||
resourceType,
|
||||
namespace,
|
||||
resourceMetrics,
|
||||
edges,
|
||||
unmeshedSources,
|
||||
resourceIsMeshed,
|
||||
lastMetricReceivedTime,
|
||||
|
@ -347,6 +364,21 @@ export class ResourceDetailBase extends React.Component {
|
|||
title="TCP"
|
||||
isTcpTable={true}
|
||||
metrics={this.state.podMetrics} />
|
||||
|
||||
{
|
||||
_isEmpty(this.state.edges) ? null :
|
||||
<Grid container direction="column" justify="center">
|
||||
<Grid item>
|
||||
<EdgesTable
|
||||
api={this.api}
|
||||
namespace={this.state.resource.namespace}
|
||||
type={this.state.resource.type}
|
||||
title="Edges"
|
||||
edges={edges} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -59,6 +59,7 @@ const ApiHelpers = (pathPrefix, defaultMetricsWindow = '1m') => {
|
|||
let metricsWindow = defaultMetricsWindow;
|
||||
const podsPath = `/api/pods`;
|
||||
const servicesPath = `/api/services`;
|
||||
const edgesPath = `/api/edges`;
|
||||
|
||||
const validMetricsWindows = {
|
||||
"10s": "10 minutes",
|
||||
|
@ -110,6 +111,10 @@ const ApiHelpers = (pathPrefix, defaultMetricsWindow = '1m') => {
|
|||
return apiFetch(servicesPath);
|
||||
};
|
||||
|
||||
const fetchEdges = (namespace, resourceType) => {
|
||||
return apiFetch(edgesPath + "?namespace=" + namespace + "&resource_type=" + resourceType);
|
||||
};
|
||||
|
||||
const getMetricsWindow = () => metricsWindow;
|
||||
const getMetricsWindowDisplayText = () => validMetricsWindows[metricsWindow];
|
||||
|
||||
|
@ -211,6 +216,7 @@ const ApiHelpers = (pathPrefix, defaultMetricsWindow = '1m') => {
|
|||
fetchMetrics,
|
||||
fetchPods,
|
||||
fetchServices,
|
||||
fetchEdges,
|
||||
getMetricsWindow,
|
||||
setMetricsWindow,
|
||||
getValidMetricsWindows: () => Object.keys(validMetricsWindows),
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import _each from 'lodash/each';
|
||||
import _isEmpty from 'lodash/isEmpty';
|
||||
import _startsWith from 'lodash/startsWith';
|
||||
|
||||
export const processEdges = (rawEdges, resourceName) => {
|
||||
let edges = [];
|
||||
if (_isEmpty(rawEdges) || _isEmpty(rawEdges.ok) || _isEmpty(rawEdges.ok.edges)) {
|
||||
return edges;
|
||||
}
|
||||
_each(rawEdges.ok.edges, edge => {
|
||||
if (_isEmpty(edge)) {
|
||||
return;
|
||||
}
|
||||
// check if any of the returned edges match the current resourceName
|
||||
if (_startsWith(edge.src.name, resourceName) || _startsWith(edge.dst.name, resourceName)) {
|
||||
edge.key = edge.src.name + edge.dst.name;
|
||||
edges.push(edge);
|
||||
}
|
||||
});
|
||||
return edges;
|
||||
};
|
||||
|
||||
export const processedEdgesPropType = PropTypes.shape({
|
||||
clientId: PropTypes.string,
|
||||
dst: PropTypes.shape(edgeResourcePropType).isRequired,
|
||||
key: PropTypes.string.isRequired,
|
||||
noIdentityMsg: PropTypes.string,
|
||||
serverId: PropTypes.string,
|
||||
src: PropTypes.shape(edgeResourcePropType).isRequired,
|
||||
});
|
||||
|
||||
const edgeResourcePropType = PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
type: PropTypes.string
|
||||
});
|
|
@ -251,3 +251,23 @@ func (h *handler) handleAPITap(w http.ResponseWriter, req *http.Request, p httpr
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) handleAPIEdges(w http.ResponseWriter, req *http.Request, p httprouter.Params) {
|
||||
requestParams := util.EdgesRequestParams{
|
||||
Namespace: req.FormValue("namespace"),
|
||||
ResourceType: req.FormValue("resource_type"),
|
||||
}
|
||||
|
||||
edgesRequest, err := util.BuildEdgesRequest(requestParams)
|
||||
if err != nil {
|
||||
renderJSONError(w, err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.apiClient.Edges(req.Context(), edgesRequest)
|
||||
if err != nil {
|
||||
renderJSONError(w, err, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
renderJSONPb(w, result)
|
||||
}
|
||||
|
|
|
@ -124,6 +124,7 @@ func NewServer(
|
|||
server.router.GET("/api/services", handler.handleAPIServices)
|
||||
server.router.GET("/api/tap", handler.handleAPITap)
|
||||
server.router.GET("/api/routes", handler.handleAPITopRoutes)
|
||||
server.router.GET("/api/edges", handler.handleAPIEdges)
|
||||
|
||||
// grafana proxy
|
||||
server.router.DELETE("/grafana/*grafanapath", handler.handleGrafana)
|
||||
|
|
Loading…
Reference in New Issue