mirror of
https://github.com/weaveworks/scope.git
synced 2026-07-25 16:26:48 +00:00
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)
475 lines
14 KiB
JavaScript
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
|
|
});
|
|
}
|
|
}
|
|
}
|