Files
weave-scope/client/app/scripts/charts/nodes-chart.js
David Kaltschmidt d39fd847b7 Details Panel UI Redesign
Refactored nodedetails to support multiple data sets, probably broke some tests
  Allow api requests to out-of-view topologies
  Fix ESC behavior with details panel
  Stack details panel like cards
  Details pain side-by-side
  Details panel piles
  Fix node details table header styles
  Render load and not-found captions like relatives
  Fix topology click action
  Make node detail tables sortable
  Grouped metrics for details health
  Group metrics in same style
  Link node details children
  Fix scroll issues on double-details
  Fix DESC sort order for node details table
  Save selected node labels in state - allows rendering of node labels from other topologies before details are loaded
  Change detail card UX, newest one at top, pile at bottom
  Details panel one pile w/ animation
  Sort details table nodes by metadata too
  Animate sidepanel from children too
  Fix radial layout
  Dont set origin if a node was already selected, suppresses animation
  stack effect: shift top cards to the left, shrink lower cards vertically
  Clear details card stack if sibling is selected
  Check if node is still selected on API response
  Make detail table sorters robust against non-uniform metadata
  Dont show scrollbar all the time, fix sort icon issue
  Button to show topology for relative
  Overflow metrics for details panel health
  Fix JS error when no metrics are available for container image details
  Column-based rendering of node details table
  Fix JS tests
  Review feedback (UI)
2016-01-19 16:47:05 +01:00

475 lines
14 KiB
JavaScript

import _ from 'lodash';
import d3 from 'd3';
import debug from 'debug';
import React from 'react';
import { Map as makeMap } from 'immutable';
import timely from 'timely';
import { clickBackground } from '../actions/app-actions';
import AppStore from '../stores/app-store';
import Edge from './edge';
import { EDGE_ID_SEPARATOR } from '../constants/naming';
import { doLayout } from './nodes-layout';
import Node from './node';
import NodesError from './nodes-error';
const log = debug('scope:nodes-chart');
const MARGINS = {
top: 130,
left: 40,
right: 40,
bottom: 0
};
// make sure circular layouts a bit denser with 3-6 nodes
const radiusDensity = d3.scale.threshold()
.domain([3, 6]).range([2.5, 3.5, 3]);
export default class NodesChart extends React.Component {
constructor(props, context) {
super(props, context);
this.handleMouseClick = this.handleMouseClick.bind(this);
this.zoomed = this.zoomed.bind(this);
this.state = {
nodes: makeMap(),
edges: makeMap(),
panTranslate: [0, 0],
scale: 1,
nodeScale: d3.scale.linear(),
selectedNodeScale: d3.scale.linear(),
hasZoomed: false,
maxNodesExceeded: false
};
}
componentWillMount() {
const state = this.updateGraphState(this.props, this.state);
this.setState(state);
}
componentDidMount() {
// distinguish pan/zoom from click
this.isZooming = false;
this.zoom = d3.behavior.zoom()
.scaleExtent([0.1, 2])
.on('zoom', this.zoomed);
d3.select('.nodes-chart svg')
.call(this.zoom);
}
componentWillReceiveProps(nextProps) {
// gather state, setState should be called only once here
const state = _.assign({}, this.state);
// wipe node states when showing different topology
if (nextProps.topologyId !== this.props.topologyId) {
_.assign(state, {
nodes: makeMap(),
edges: makeMap()
});
}
// FIXME add PureRenderMixin, Immutables, and move the following functions to render()
if (nextProps.nodes !== this.props.nodes) {
_.assign(state, this.updateGraphState(nextProps, state));
}
if (this.props.selectedNodeId !== nextProps.selectedNodeId) {
_.assign(state, this.restoreLayout(state));
}
if (nextProps.selectedNodeId) {
_.assign(state, this.centerSelectedNode(nextProps, state));
}
this.setState(state);
}
componentWillUnmount() {
// undoing .call(zoom)
d3.select('.nodes-chart svg')
.on('mousedown.zoom', null)
.on('onwheel', null)
.on('onmousewheel', null)
.on('dblclick.zoom', null)
.on('touchstart.zoom', null);
}
renderGraphNodes(nodes, nodeScale) {
const hasSelectedNode = this.props.selectedNodeId && this.props.nodes.has(this.props.selectedNodeId);
const adjacency = hasSelectedNode ? AppStore.getAdjacentNodes(this.props.selectedNodeId) : null;
const onNodeClick = this.props.onNodeClick;
const zoomScale = this.state.scale;
const selectedNodeScale = this.state.selectedNodeScale;
// highlighter functions
const setHighlighted = node => {
const highlighted = _.includes(this.props.highlightedNodeIds, node.get('id'))
|| this.props.selectedNodeId === node.get('id');
return node.set('highlighted', highlighted);
};
const setFocused = node => {
const focused = hasSelectedNode
&& (this.props.selectedNodeId === node.get('id') || adjacency.includes(node.get('id')));
return node.set('focused', focused);
};
const setBlurred = node => {
return node.set('blurred', hasSelectedNode && !node.get('focused'));
};
// make sure blurred nodes are in the background
const sortNodes = node => {
if (node.get('blurred')) {
return 0;
}
if (node.get('highlighted')) {
return 2;
}
return 1;
};
return nodes
.toIndexedSeq()
.map(setHighlighted)
.map(setFocused)
.map(setBlurred)
.sortBy(sortNodes)
.map(node => {
return (<Node
blurred={node.get('blurred')}
focused={node.get('focused')}
highlighted={node.get('highlighted')}
onClick={onNodeClick}
key={node.get('id')}
id={node.get('id')}
label={node.get('label')}
pseudo={node.get('pseudo')}
subLabel={node.get('subLabel')}
rank={node.get('rank')}
selectedNodeScale={selectedNodeScale}
nodeScale={nodeScale}
zoomScale={zoomScale}
dx={node.get('x')}
dy={node.get('y')}
/>
);
});
}
renderGraphEdges(edges) {
const selectedNodeId = this.props.selectedNodeId;
const hasSelectedNode = selectedNodeId && this.props.nodes.has(selectedNodeId);
const setHighlighted = edge => {
return edge.set('highlighted', _.includes(this.props.highlightedEdgeIds, edge.get('id')));
};
const setBlurred = edge => {
return (edge.set('blurred', hasSelectedNode
&& edge.get('source') !== selectedNodeId
&& edge.get('target') !== selectedNodeId));
};
return edges
.toIndexedSeq()
.map(setHighlighted)
.map(setBlurred)
.map(edge => {
return (
<Edge key={edge.get('id')} id={edge.get('id')} points={edge.get('points')}
blurred={edge.get('blurred')} highlighted={edge.get('highlighted')} />
);
});
}
renderMaxNodesError(show) {
const errorHint = 'We\u0027re working on it, but for now, try a different view?';
return (
<NodesError faIconClass="fa-ban" hidden={!show}>
<div className="centered">Too many nodes to show in the browser.<br />{errorHint}</div>
</NodesError>
);
}
renderEmptyTopologyError(show) {
return (
<NodesError faIconClass="fa-circle-thin" hidden={!show}>
<div className="heading">Nothing to show. This can have any of these reasons:</div>
<ul>
<li>We haven't received any reports from probes recently. Are the probes properly configured?</li>
<li>There are nodes, but they're currently hidden. Check the view options in the bottom-left if they allow for showing hidden nodes.</li>
<li>Containers view only: you're not running Docker, or you don't have any containers.</li>
</ul>
</NodesError>
);
}
render() {
const nodeElements = this.renderGraphNodes(this.state.nodes, this.state.nodeScale);
const edgeElements = this.renderGraphEdges(this.state.edges, this.state.nodeScale);
const scale = this.state.scale;
const translate = this.state.panTranslate;
const transform = 'translate(' + translate + ') scale(' + scale + ')';
const svgClassNames = this.state.maxNodesExceeded || nodeElements.size === 0 ? 'hide' : '';
const errorEmpty = this.renderEmptyTopologyError(AppStore.isTopologyEmpty());
const errorMaxNodesExceeded = this.renderMaxNodesError(this.state.maxNodesExceeded);
return (
<div className="nodes-chart">
{errorEmpty}
{errorMaxNodesExceeded}
<svg width="100%" height="100%" className={svgClassNames} onClick={this.handleMouseClick}>
<g className="canvas" transform={transform}>
<g className="edges">
{edgeElements}
</g>
<g className="nodes">
{nodeElements}
</g>
</g>
</svg>
</div>
);
}
initNodes(topology) {
return topology.map((node, id) => {
// copy relevant fields to state nodes
return makeMap({
id: id,
label: node.get('label_major'),
pseudo: node.get('pseudo'),
subLabel: node.get('label_minor'),
rank: node.get('rank'),
x: 0,
y: 0
});
});
}
initEdges(topology, stateNodes) {
let edges = makeMap();
topology.forEach(function(node, nodeId) {
const adjacency = node.get('adjacency');
if (adjacency) {
adjacency.forEach(function(adjacent) {
const edge = [nodeId, adjacent];
const edgeId = edge.join(EDGE_ID_SEPARATOR);
if (!edges.has(edgeId)) {
const source = edge[0];
const target = edge[1];
if (!stateNodes.has(source) || !stateNodes.has(target)) {
log('Missing edge node', edge[0], edge[1]);
}
edges = edges.set(edgeId, makeMap({
id: edgeId,
value: 1,
source: source,
target: target
}));
}
});
}
});
return edges;
}
centerSelectedNode(props, state) {
let stateNodes = state.nodes;
let stateEdges = state.edges;
const selectedLayoutNode = stateNodes.get(props.selectedNodeId);
if (!selectedLayoutNode) {
return {};
}
const adjacency = AppStore.getAdjacentNodes(props.selectedNodeId);
const adjacentLayoutNodeIds = [];
adjacency.forEach(function(adjacentId) {
// filter loopback
if (adjacentId !== props.selectedNodeId) {
adjacentLayoutNodeIds.push(adjacentId);
}
});
// move origin node to center of viewport
const zoomScale = state.scale;
const detailsWidth = 420;
const translate = state.panTranslate;
const centerX = (-translate[0] + (props.width + MARGINS.left - detailsWidth) / 2) / zoomScale;
const centerY = (-translate[1] + (props.height + MARGINS.top) / 2) / zoomScale;
stateNodes = stateNodes.mergeIn([props.selectedNodeId], {
x: centerX,
y: centerY
});
// circle layout for adjacent nodes
const adjacentCount = adjacentLayoutNodeIds.length;
const density = radiusDensity(adjacentCount);
const radius = Math.min(props.width, props.height) / density / zoomScale;
const offsetAngle = Math.PI / 4;
stateNodes = stateNodes.map((node) => {
const index = adjacentLayoutNodeIds.indexOf(node.get('id'));
if (index > -1) {
const angle = offsetAngle + Math.PI * 2 * index / adjacentCount;
return node.merge({
x: centerX + radius * Math.sin(angle),
y: centerY + radius * Math.cos(angle)
});
}
return node;
});
// fix all edges for circular nodes
stateEdges = stateEdges.map(edge => {
if (edge.get('source') === selectedLayoutNode.get('id')
|| edge.get('target') === selectedLayoutNode.get('id')
|| _.includes(adjacentLayoutNodeIds, edge.get('source'))
|| _.includes(adjacentLayoutNodeIds, edge.get('target'))) {
const source = stateNodes.get(edge.get('source'));
const target = stateNodes.get(edge.get('target'));
return edge.set('points', [
{x: source.get('x'), y: source.get('y')},
{x: target.get('x'), y: target.get('y')}
]);
}
return edge;
});
// auto-scale node size for selected nodes
const selectedNodeScale = this.getNodeScale(props);
return {
selectedNodeScale,
edges: stateEdges,
nodes: stateNodes
};
}
handleMouseClick() {
if (!this.isZooming) {
clickBackground();
} else {
this.isZooming = false;
}
}
restoreLayout(state) {
// undo any pan/zooming that might have happened
this.zoom.scale(state.scale);
this.zoom.translate(state.panTranslate);
const nodes = state.nodes.map(node => {
return node.merge({
x: node.get('px'),
y: node.get('py')
});
});
const edges = state.edges.map(edge => {
if (edge.has('ppoints')) {
return edge.set('points', edge.get('ppoints'));
}
return edge;
});
return { edges, nodes};
}
updateGraphState(props, state) {
const n = props.nodes.size;
if (n === 0) {
return {
nodes: makeMap(),
edges: makeMap()
};
}
let stateNodes = this.initNodes(props.nodes, state.nodes);
let stateEdges = this.initEdges(props.nodes, stateNodes);
const nodeScale = this.getNodeScale(props);
const options = {
width: props.width,
height: props.height,
scale: nodeScale,
margins: MARGINS,
topologyId: this.props.topologyId
};
const timedLayouter = timely(doLayout);
const graph = timedLayouter(stateNodes, stateEdges, options);
log('graph layout took ' + timedLayouter.time + 'ms');
// layout was aborted
if (!graph) {
return {maxNodesExceeded: true};
}
stateNodes = graph.nodes;
stateEdges = graph.edges;
// save coordinates for restore
stateNodes = stateNodes.map(node => {
return node.merge({
px: node.get('x'),
py: node.get('y')
});
});
stateEdges = stateEdges.map(edge => {
return edge.set('ppoints', edge.get('points'));
});
// adjust layout based on viewport
const xFactor = (props.width - MARGINS.left - MARGINS.right) / graph.width;
const yFactor = props.height / graph.height;
const zoomFactor = Math.min(xFactor, yFactor);
let zoomScale = this.state.scale;
if (this.zoom && !this.state.hasZoomed && zoomFactor > 0 && zoomFactor < 1) {
zoomScale = zoomFactor;
// saving in d3's behavior cache
this.zoom.scale(zoomFactor);
}
return {
nodes: stateNodes,
edges: stateEdges,
scale: zoomScale,
nodeScale: nodeScale,
maxNodesExceeded: false
};
}
getNodeScale(props) {
const expanse = Math.min(props.height, props.width);
const nodeSize = expanse / 3; // single node should fill a third of the screen
const maxNodeSize = expanse / 10;
const normalizedNodeSize = Math.min(nodeSize / Math.sqrt(props.nodes.size), maxNodeSize);
return this.state.nodeScale.copy().range([0, normalizedNodeSize]);
}
zoomed() {
// debug('zoomed', d3.event.scale, d3.event.translate);
this.isZooming = true;
// dont pan while node is selected
if (!this.props.selectedNodeId) {
this.setState({
hasZoomed: true,
panTranslate: d3.event.translate.slice(),
scale: d3.event.scale
});
}
}
}