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 ErrorBanner from './ErrorBanner.jsx';
|
||||||
import { friendlyTitle } from './util/Utils.js';
|
import { friendlyTitle } from './util/Utils.js';
|
||||||
import MetricsTable from './MetricsTable.jsx';
|
import MetricsTable from './MetricsTable.jsx';
|
||||||
|
import NetworkGraph from './NetworkGraph.jsx';
|
||||||
import PageHeader from './PageHeader.jsx';
|
import PageHeader from './PageHeader.jsx';
|
||||||
import { processMultiResourceRollup } from './util/MetricUtils.js';
|
import { processMultiResourceRollup } from './util/MetricUtils.js';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
@ -121,7 +122,9 @@ class Namespaces extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
const {metrics} = this.state;
|
||||||
let noMetrics = _.isEmpty(this.state.metrics.pods);
|
let noMetrics = _.isEmpty(this.state.metrics.pods);
|
||||||
|
let deploymentsWithMetrics = _.filter(this.state.metrics.deployments, "requestRate");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-content">
|
<div className="page-content">
|
||||||
|
@ -130,10 +133,12 @@ class Namespaces extends React.Component {
|
||||||
<div>
|
<div>
|
||||||
<PageHeader header={`Namespace: ${this.state.ns}`} />
|
<PageHeader header={`Namespace: ${this.state.ns}`} />
|
||||||
{ noMetrics ? <div>No resources detected.</div> : null}
|
{ noMetrics ? <div>No resources detected.</div> : null}
|
||||||
{this.renderResourceSection("Deployment", this.state.metrics.deployments)}
|
{ _.isEmpty(deploymentsWithMetrics) ? null :
|
||||||
{this.renderResourceSection("Replication Controller", this.state.metrics.replicationcontrollers)}
|
<NetworkGraph namespace={this.state.ns} deployments={metrics.deployments} />}
|
||||||
{this.renderResourceSection("Pod", this.state.metrics.pods)}
|
{this.renderResourceSection("Deployment", metrics.deployments)}
|
||||||
{this.renderResourceSection("Authority", this.state.metrics.authorities)}
|
{this.renderResourceSection("Replication Controller", metrics.replicationcontrollers)}
|
||||||
|
{this.renderResourceSection("Pod", metrics.pods)}
|
||||||
|
{this.renderResourceSection("Authority", metrics.authorities)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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(
|
export default withREST(
|
||||||
ResourceListBase,
|
ResourceListBase,
|
||||||
({api, resource}) => [api.fetchMetrics(api.urlsForResource(resource))],
|
({api, resource}) => [api.fetchMetrics(api.urlsForResource(resource))],
|
||||||
['resource'],
|
{
|
||||||
|
resetProps: ['resource'],
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
|
@ -8,9 +8,14 @@ import { withContext } from './AppContext.jsx';
|
||||||
* @constructor
|
* @constructor
|
||||||
* @param {React.Component} WrappedComponent - Component to add functionality to.
|
* @param {React.Component} WrappedComponent - Component to add functionality to.
|
||||||
* @param {List[string]} requestURLs - List of URLs to poll.
|
* @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 {
|
class RESTWrapper extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
api: PropTypes.shape({
|
api: PropTypes.shape({
|
||||||
|
@ -41,7 +46,7 @@ const withREST = (WrappedComponent, componentPromises, resetProps = []) => {
|
||||||
|
|
||||||
componentWillReceiveProps(newProps) {
|
componentWillReceiveProps(newProps) {
|
||||||
const changed = _.filter(
|
const changed = _.filter(
|
||||||
resetProps,
|
localOptions.resetProps,
|
||||||
prop => _.get(newProps, prop) !== _.get(this.props, prop),
|
prop => _.get(newProps, prop) !== _.get(this.props, prop),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -59,13 +64,17 @@ const withREST = (WrappedComponent, componentPromises, resetProps = []) => {
|
||||||
|
|
||||||
startServerPolling = props => {
|
startServerPolling = props => {
|
||||||
this.loadFromServer(props);
|
this.loadFromServer(props);
|
||||||
this.timerId = window.setInterval(
|
if (localOptions.poll) {
|
||||||
this.loadFromServer, this.state.pollingInterval, props);
|
this.timerId = window.setInterval(
|
||||||
|
this.loadFromServer, this.state.pollingInterval, props);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stopServerPolling = () => {
|
stopServerPolling = () => {
|
||||||
window.clearInterval(this.timerId);
|
|
||||||
this.api.cancelCurrentRequests();
|
this.api.cancelCurrentRequests();
|
||||||
|
if (localOptions.poll) {
|
||||||
|
window.clearInterval(this.timerId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadFromServer = props => {
|
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