diff --git a/web/app/js/components/Namespace.jsx b/web/app/js/components/Namespace.jsx index 91edd1d20..8f7e653dc 100644 --- a/web/app/js/components/Namespace.jsx +++ b/web/app/js/components/Namespace.jsx @@ -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 (
@@ -130,10 +133,12 @@ class Namespaces extends React.Component {
{ noMetrics ?
No resources detected.
: 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 : + } + {this.renderResourceSection("Deployment", metrics.deployments)} + {this.renderResourceSection("Replication Controller", metrics.replicationcontrollers)} + {this.renderResourceSection("Pod", metrics.pods)} + {this.renderResourceSection("Authority", metrics.authorities)}
)}
); diff --git a/web/app/js/components/NetworkGraph.jsx b/web/app/js/components/NetworkGraph.jsx new file mode 100644 index 000000000..3ae296361 --- /dev/null +++ b/web/app/js/components/NetworkGraph.jsx @@ -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 ( +
+
+
+ ); + } +} + +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"], + }, +); diff --git a/web/app/js/components/ResourceList.jsx b/web/app/js/components/ResourceList.jsx index f2bb35c00..d1bc2e190 100644 --- a/web/app/js/components/ResourceList.jsx +++ b/web/app/js/components/ResourceList.jsx @@ -70,5 +70,7 @@ export class ResourceListBase extends React.Component { export default withREST( ResourceListBase, ({api, resource}) => [api.fetchMetrics(api.urlsForResource(resource))], - ['resource'], + { + resetProps: ['resource'], + }, ); diff --git a/web/app/js/components/util/withREST.jsx b/web/app/js/components/util/withREST.jsx index b0382ae7a..b27c2c175 100644 --- a/web/app/js/components/util/withREST.jsx +++ b/web/app/js/components/util/withREST.jsx @@ -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 => { diff --git a/web/app/test/NetworkGraphTest.jsx b/web/app/test/NetworkGraphTest.jsx new file mode 100644 index 000000000..015399c8a --- /dev/null +++ b/web/app/test/NetworkGraphTest.jsx @@ -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( + + ); + + 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"}); + }); +}); diff --git a/web/app/test/fixtures/emojivotoPods.json b/web/app/test/fixtures/emojivotoPods.json new file mode 100644 index 000000000..61e1aa941 --- /dev/null +++ b/web/app/test/fixtures/emojivotoPods.json @@ -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": {} + } + ] + } + } + ] + } + } +]