mirror of https://github.com/linkerd/linkerd2.git
add application topology graph to namespace tab (#1110)
* This commit adds an application topology graph within the namespace tab. As a developer / operator one would like to see an overview of the services running to identify dependencies. Adding this graph gives Linkerd2 users a good overview of service dependencies. * networkgraphtest added Fixes: #924 Signed-off-by: Franziska von der Goltz <franziska@vdgoltz.eu>
This commit is contained in:
parent
a98bfb1ca7
commit
829371bc06
|
@ -2,6 +2,7 @@ import _ from 'lodash';
|
|||
import ErrorBanner from './ErrorBanner.jsx';
|
||||
import { friendlyTitle } from './util/Utils.js';
|
||||
import MetricsTable from './MetricsTable.jsx';
|
||||
import NetworkGraph from './NetworkGraph.jsx';
|
||||
import PageHeader from './PageHeader.jsx';
|
||||
import { processMultiResourceRollup } from './util/MetricUtils.js';
|
||||
import PropTypes from 'prop-types';
|
||||
|
@ -121,7 +122,9 @@ class Namespaces extends React.Component {
|
|||
}
|
||||
|
||||
render() {
|
||||
const {metrics} = this.state;
|
||||
let noMetrics = _.isEmpty(this.state.metrics.pods);
|
||||
let deploymentsWithMetrics = _.filter(this.state.metrics.deployments, "requestRate");
|
||||
|
||||
return (
|
||||
<div className="page-content">
|
||||
|
@ -130,10 +133,12 @@ class Namespaces extends React.Component {
|
|||
<div>
|
||||
<PageHeader header={`Namespace: ${this.state.ns}`} />
|
||||
{ noMetrics ? <div>No resources detected.</div> : null}
|
||||
{this.renderResourceSection("Deployment", this.state.metrics.deployments)}
|
||||
{this.renderResourceSection("Replication Controller", this.state.metrics.replicationcontrollers)}
|
||||
{this.renderResourceSection("Pod", this.state.metrics.pods)}
|
||||
{this.renderResourceSection("Authority", this.state.metrics.authorities)}
|
||||
{ _.isEmpty(deploymentsWithMetrics) ? null :
|
||||
<NetworkGraph namespace={this.state.ns} deployments={metrics.deployments} />}
|
||||
{this.renderResourceSection("Deployment", metrics.deployments)}
|
||||
{this.renderResourceSection("Replication Controller", metrics.replicationcontrollers)}
|
||||
{this.renderResourceSection("Pod", metrics.pods)}
|
||||
{this.renderResourceSection("Authority", metrics.authorities)}
|
||||
</div>
|
||||
)}
|
||||
</div>);
|
||||
|
|
|
@ -0,0 +1,202 @@
|
|||
import _ from 'lodash';
|
||||
import { metricsPropType } from './util/MetricUtils.js';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { withContext } from './util/AppContext.jsx';
|
||||
import withREST from './util/withREST.jsx';
|
||||
import * as d3 from 'd3';
|
||||
import 'whatwg-fetch';
|
||||
|
||||
|
||||
const defaultSvgWidth = 524;
|
||||
const defaultSvgHeight = 325;
|
||||
const defaultNodeRadius = 15;
|
||||
const margin = { top: 0, right: 0, bottom: 10, left: 0 };
|
||||
|
||||
const simulation = d3.forceSimulation()
|
||||
.force("link",
|
||||
d3.forceLink()
|
||||
.id(d => d.id)
|
||||
.distance(140))
|
||||
.force("charge", d3.forceManyBody().strength(-20))
|
||||
.force("center", d3.forceCenter(defaultSvgWidth / 2, defaultSvgHeight / 2));
|
||||
|
||||
export class NetworkGraphBase extends React.Component {
|
||||
static defaultProps = {
|
||||
deployments: []
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
data: PropTypes.arrayOf(metricsPropType.isRequired).isRequired,
|
||||
deployments: PropTypes.arrayOf(PropTypes.object),
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
let container = document.getElementsByClassName("network-graph-container")[0];
|
||||
let width = !container ? defaultSvgWidth : container.getBoundingClientRect().width;
|
||||
|
||||
this.svg = d3.select(".network-graph-container")
|
||||
.append("svg")
|
||||
.attr("class", "network-graph")
|
||||
.attr("width", width)
|
||||
.attr("height", width)
|
||||
.append("g")
|
||||
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
simulation.alpha(1).restart();
|
||||
this.drawGraph();
|
||||
}
|
||||
|
||||
getGraphData() {
|
||||
const { data } = this.props;
|
||||
let links = [];
|
||||
let nodeList = [];
|
||||
|
||||
_.map(data, (resp, i) => {
|
||||
let rows = _.get(resp, ["ok", "statTables", 0, "podGroup", "rows"]);
|
||||
let dst = this.props.deployments[i].name;
|
||||
_.map(rows, row => {
|
||||
links.push({
|
||||
source: row.resource.name,
|
||||
target: dst,
|
||||
});
|
||||
nodeList.push(row.resource.name);
|
||||
nodeList.push(dst);
|
||||
});
|
||||
});
|
||||
|
||||
let nodes = _.map(_.uniq(nodeList), n => ({ id: n }));
|
||||
return {
|
||||
links,
|
||||
nodes
|
||||
};
|
||||
}
|
||||
|
||||
drawGraph() {
|
||||
let graphData = this.getGraphData();
|
||||
|
||||
// check if graph is present to prevent drawing of multiple graphs
|
||||
if (this.svg.select("circle")._groups[0][0]) {
|
||||
return;
|
||||
}
|
||||
this.drawGraphComponents(graphData.links, graphData.nodes);
|
||||
}
|
||||
|
||||
drawGraphComponents(links, nodes) {
|
||||
if (_.isEmpty(nodes)) {
|
||||
d3.select(".network-graph-container").select("svg").attr("height", 0);
|
||||
return;
|
||||
} else {
|
||||
d3.select(".network-graph-container").select("svg").attr("height", defaultSvgHeight);
|
||||
}
|
||||
|
||||
this.svg.append("svg:defs").selectAll("marker")
|
||||
.data(links) // Different link/path types can be defined here
|
||||
.enter().append("svg:marker") // This section adds in the arrows
|
||||
.attr("id", node => node.source + "/" + node.target)
|
||||
.attr("viewBox", "0 -5 10 10")
|
||||
.attr("refX", 24)
|
||||
.attr("refY", -0.25)
|
||||
.attr("markerWidth", 3)
|
||||
.attr("markerHeight", 3)
|
||||
.attr("fill", "#454242")
|
||||
.attr("orient", "auto")
|
||||
.append("svg:path")
|
||||
.attr("d", "M0,-5L10,0L0,5");
|
||||
|
||||
// add the links and the arrows
|
||||
const path = this.svg.append("svg:g").selectAll("path")
|
||||
.data(links)
|
||||
.enter().append("svg:path")
|
||||
.attr("stroke-width", 3)
|
||||
.attr("stroke", "#454242")
|
||||
.attr("marker-end", node => "url(#"+node.source + "/" + node.target+")");
|
||||
|
||||
const nodeElements = this.svg.append('g')
|
||||
.selectAll('circle')
|
||||
.data(nodes)
|
||||
.enter().append('circle')
|
||||
.attr("r", defaultNodeRadius)
|
||||
.attr('fill', 'steelblue')
|
||||
.call(d3.drag()
|
||||
.on("start", this.dragstarted)
|
||||
.on("drag", this.dragged)
|
||||
.on("end", this.dragended));
|
||||
|
||||
const textElements = this.svg.append('g')
|
||||
.selectAll('text')
|
||||
.data(nodes)
|
||||
.enter().append('text')
|
||||
.text(node => node.id)
|
||||
.attr('font-size', 15)
|
||||
.attr('dx', 20)
|
||||
.attr('dy', 4);
|
||||
|
||||
simulation.nodes(nodes).on("tick", () => {
|
||||
path
|
||||
.attr("d", node => "M" +
|
||||
node.source.x + " " +
|
||||
node.source.y + " L " +
|
||||
node.target.x + " " +
|
||||
node.target.y);
|
||||
|
||||
nodeElements
|
||||
.attr("cx", node => node.x)
|
||||
.attr("cy", node => node.y);
|
||||
|
||||
textElements
|
||||
.attr("x", node => node.x)
|
||||
.attr("y", node => node.y);
|
||||
});
|
||||
|
||||
simulation.force("link")
|
||||
.links(links);
|
||||
}
|
||||
|
||||
dragstarted = d => {
|
||||
if (!d3.event.active) {
|
||||
simulation.alphaTarget(0.3).restart();
|
||||
}
|
||||
d.fx = d.x;
|
||||
d.fy = d.y;
|
||||
}
|
||||
|
||||
dragged = d => {
|
||||
d.fx = d3.event.x;
|
||||
d.fy = d3.event.y;
|
||||
}
|
||||
|
||||
dragended = d => {
|
||||
if (!d3.event.active) {
|
||||
simulation.alphaTarget(0);
|
||||
}
|
||||
d.fx = null;
|
||||
d.fy = null;
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<div className="network-graph-container" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withREST(
|
||||
withContext(NetworkGraphBase),
|
||||
({api, namespace, deployments}) =>
|
||||
_.map(deployments, d => {
|
||||
return api.fetchMetrics(api.urlsForResource("deployment", namespace) + "&to_name=" + d.name);
|
||||
}),
|
||||
{
|
||||
poll: false,
|
||||
resetProps: ["deployment"],
|
||||
},
|
||||
);
|
|
@ -70,5 +70,7 @@ export class ResourceListBase extends React.Component {
|
|||
export default withREST(
|
||||
ResourceListBase,
|
||||
({api, resource}) => [api.fetchMetrics(api.urlsForResource(resource))],
|
||||
['resource'],
|
||||
{
|
||||
resetProps: ['resource'],
|
||||
},
|
||||
);
|
||||
|
|
|
@ -8,9 +8,14 @@ import { withContext } from './AppContext.jsx';
|
|||
* @constructor
|
||||
* @param {React.Component} WrappedComponent - Component to add functionality to.
|
||||
* @param {List[string]} requestURLs - List of URLs to poll.
|
||||
* @param {List[string]} resetProps - Props that on change cause a state reset.
|
||||
* @param {List[string]} options - Options for withREST
|
||||
*/
|
||||
const withREST = (WrappedComponent, componentPromises, resetProps = []) => {
|
||||
const withREST = (WrappedComponent, componentPromises, options={}) => {
|
||||
const localOptions = _.merge({}, {
|
||||
resetProps: [],
|
||||
poll: true,
|
||||
}, options);
|
||||
|
||||
class RESTWrapper extends React.Component {
|
||||
static propTypes = {
|
||||
api: PropTypes.shape({
|
||||
|
@ -41,7 +46,7 @@ const withREST = (WrappedComponent, componentPromises, resetProps = []) => {
|
|||
|
||||
componentWillReceiveProps(newProps) {
|
||||
const changed = _.filter(
|
||||
resetProps,
|
||||
localOptions.resetProps,
|
||||
prop => _.get(newProps, prop) !== _.get(this.props, prop),
|
||||
);
|
||||
|
||||
|
@ -59,13 +64,17 @@ const withREST = (WrappedComponent, componentPromises, resetProps = []) => {
|
|||
|
||||
startServerPolling = props => {
|
||||
this.loadFromServer(props);
|
||||
this.timerId = window.setInterval(
|
||||
this.loadFromServer, this.state.pollingInterval, props);
|
||||
if (localOptions.poll) {
|
||||
this.timerId = window.setInterval(
|
||||
this.loadFromServer, this.state.pollingInterval, props);
|
||||
}
|
||||
}
|
||||
|
||||
stopServerPolling = () => {
|
||||
window.clearInterval(this.timerId);
|
||||
this.api.cancelCurrentRequests();
|
||||
if (localOptions.poll) {
|
||||
window.clearInterval(this.timerId);
|
||||
}
|
||||
}
|
||||
|
||||
loadFromServer = props => {
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
import Adapter from 'enzyme-adapter-react-16';
|
||||
import emojivotoPodFixtures from './fixtures/emojivotoPods.json';
|
||||
import { expect } from 'chai';
|
||||
import { NetworkGraphBase } from '../js/components/NetworkGraph.jsx';
|
||||
import React from 'react';
|
||||
import Enzyme, { shallow } from 'enzyme';
|
||||
|
||||
|
||||
Enzyme.configure({ adapter: new Adapter() });
|
||||
|
||||
const deploys = [
|
||||
{name: "emoji", namespace: "emojivoto", totalRequests: 120, requestRate: 2, successRate: 1},
|
||||
{name: "vote-bot", namespace: "emojivoto", totalRequests: 0, requestRate: null, successRate: null},
|
||||
{name: "voting", namespace: "emojivoto", totalRequests: 59, requestRate: 0.9833333333333333, successRate: 0.7288135593220338},
|
||||
{name: "web", namespace: "emojivoto", totalRequests: 117, requestRate: 1.95, successRate: 0.8803418803418803}
|
||||
];
|
||||
|
||||
describe("NetworkGraph", () => {
|
||||
|
||||
it("checks graph data", () => {
|
||||
const component = shallow(
|
||||
<NetworkGraphBase
|
||||
data={emojivotoPodFixtures}
|
||||
deployments={deploys} />
|
||||
);
|
||||
|
||||
const data = component.instance().getGraphData();
|
||||
expect(data.links).to.have.length(3);
|
||||
expect(data.nodes).to.have.length(4);
|
||||
expect(data.links[0]).to.include({source: "web", target: "emoji"});
|
||||
expect(data.nodes[0]).to.include({ id: "web"});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,109 @@
|
|||
[
|
||||
{
|
||||
"ok": {
|
||||
"statTables": [
|
||||
{
|
||||
"podGroup": {
|
||||
"rows": [
|
||||
{
|
||||
"resource": {
|
||||
"namespace": "emojivoto",
|
||||
"type": "deployments",
|
||||
"name": "web"
|
||||
},
|
||||
"timeWindow": "1m",
|
||||
"meshedPodCount": "1",
|
||||
"runningPodCount": "1",
|
||||
"failedPodCount": "0",
|
||||
"stats": {
|
||||
"successCount": "118",
|
||||
"failureCount": "0",
|
||||
"latencyMsP50": "1",
|
||||
"latencyMsP95": "1",
|
||||
"latencyMsP99": "2",
|
||||
"tlsRequestCount": "0"
|
||||
},
|
||||
"errorsByPod": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"ok": {
|
||||
"statTables": [
|
||||
{
|
||||
"podGroup": {
|
||||
"rows": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"ok": {
|
||||
"statTables": [
|
||||
{
|
||||
"podGroup": {
|
||||
"rows": [
|
||||
{
|
||||
"resource": {
|
||||
"namespace": "emojivoto",
|
||||
"type": "deployments",
|
||||
"name": "web"
|
||||
},
|
||||
"timeWindow": "1m",
|
||||
"meshedPodCount": "1",
|
||||
"runningPodCount": "1",
|
||||
"failedPodCount": "0",
|
||||
"stats": {
|
||||
"successCount": "47",
|
||||
"failureCount": "12",
|
||||
"latencyMsP50": "1",
|
||||
"latencyMsP95": "9",
|
||||
"latencyMsP99": "10",
|
||||
"tlsRequestCount": "0"
|
||||
},
|
||||
"errorsByPod": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"ok": {
|
||||
"statTables": [
|
||||
{
|
||||
"podGroup": {
|
||||
"rows": [
|
||||
{
|
||||
"resource": {
|
||||
"namespace": "emojivoto",
|
||||
"type": "deployments",
|
||||
"name": "vote-bot"
|
||||
},
|
||||
"timeWindow": "1m",
|
||||
"meshedPodCount": "1",
|
||||
"runningPodCount": "1",
|
||||
"failedPodCount": "0",
|
||||
"stats": {
|
||||
"successCount": "108",
|
||||
"failureCount": "12",
|
||||
"latencyMsP50": "2",
|
||||
"latencyMsP95": "3",
|
||||
"latencyMsP99": "4",
|
||||
"tlsRequestCount": "0"
|
||||
},
|
||||
"errorsByPod": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
Loading…
Reference in New Issue