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:
Franziska von der Goltz 2018-07-18 17:10:15 -07:00 committed by GitHub
parent a98bfb1ca7
commit 829371bc06
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 371 additions and 11 deletions

View File

@ -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>);

View File

@ -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"],
},
);

View File

@ -70,5 +70,7 @@ export class ResourceListBase extends React.Component {
export default withREST(
ResourceListBase,
({api, resource}) => [api.fetchMetrics(api.urlsForResource(resource))],
['resource'],
{
resetProps: ['resource'],
},
);

View File

@ -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 => {

View File

@ -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"});
});
});

109
web/app/test/fixtures/emojivotoPods.json vendored Normal file
View File

@ -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": {}
}
]
}
}
]
}
}
]