diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js
index 534391783..1b799642e 100644
--- a/client/app/scripts/actions/app-actions.js
+++ b/client/app/scripts/actions/app-actions.js
@@ -78,6 +78,13 @@ export function unpinNetwork(networkId) {
};
}
+export function sortOrderChanged(newOrder) {
+ AppDispatcher.dispatch({
+ type: ActionTypes.SORT_ORDER_CHANGED,
+ newOrder
+ });
+}
+
//
// Metrics
diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js
index 29bb8eb94..41a70a047 100644
--- a/client/app/scripts/charts/nodes-chart.js
+++ b/client/app/scripts/charts/nodes-chart.js
@@ -5,6 +5,7 @@ import React from 'react';
import { connect } from 'react-redux';
import { Map as makeMap, fromJS, is as isDeepEqual } from 'immutable';
import timely from 'timely';
+import { Set as makeSet } from 'immutable';
import { clickBackground } from '../actions/app-actions';
import { EDGE_ID_SEPARATOR } from '../constants/naming';
@@ -17,18 +18,12 @@ import { getActiveTopologyOptions, getAdjacentNodes,
const log = debug('scope:nodes-chart');
-const MARGINS = {
- top: 130,
- left: 40,
- right: 40,
- bottom: 0
-};
-
const ZOOM_CACHE_FIELDS = ['scale', 'panTranslateX', 'panTranslateY'];
// 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]);
+ .domain([3, 6])
+ .range([2.5, 3.5, 3]);
class NodesChart extends React.Component {
@@ -47,8 +42,8 @@ class NodesChart extends React.Component {
scale: 1,
selectedNodeScale: d3.scale.linear(),
hasZoomed: false,
- height: 0,
- width: 0,
+ height: props.height || 0,
+ width: props.width || 0,
zoomCache: {}
};
}
@@ -67,7 +62,7 @@ class NodesChart extends React.Component {
// re-apply cached canvas zoom/pan to d3 behavior (or set defaul values)
const defaultZoom = { scale: 1, panTranslateX: 0, panTranslateY: 0, hasZoomed: false };
const nextZoom = this.state.zoomCache[nextProps.topologyId] || defaultZoom;
- if (nextZoom) {
+ if (nextZoom && this.zoom) {
this.zoom.scale(nextZoom.scale);
this.zoom.translate([nextZoom.panTranslateX, nextZoom.panTranslateY]);
}
@@ -85,13 +80,13 @@ class NodesChart extends React.Component {
}
// reset layout dimensions only when forced
- state.height = nextProps.forceRelayout ? nextProps.height : (state.height || nextProps.height);
- state.width = nextProps.forceRelayout ? nextProps.width : (state.width || nextProps.width);
+ state.height = nextProps.height;
+ state.width = nextProps.width;
- // _.assign(state, this.updateGraphState(nextProps, state));
- if (nextProps.forceRelayout || !isSameTopology(nextProps.nodes, this.props.nodes)) {
- _.assign(state, this.updateGraphState(nextProps, state));
- }
+ _.assign(state, this.updateGraphState(nextProps, state));
+ // if (nextProps.forceRelayout || nextProps.nodes !== this.props.nodes) {
+ // _.assign(state, this.updateGraphState(nextProps, state));
+ // }
if (this.props.selectedNodeId !== nextProps.selectedNodeId) {
_.assign(state, this.restoreLayout(state));
@@ -105,6 +100,10 @@ class NodesChart extends React.Component {
componentDidMount() {
// distinguish pan/zoom from click
+ if (this.props.noZoom) {
+ return;
+ }
+
this.isZooming = false;
this.zoom = d3.behavior.zoom()
@@ -116,6 +115,10 @@ class NodesChart extends React.Component {
}
componentWillUnmount() {
+ if (this.props.noZoom) {
+ return;
+ }
+
// undoing .call(zoom)
d3.select('.nodes-chart svg')
.on('mousedown.zoom', null)
@@ -237,9 +240,9 @@ class NodesChart extends React.Component {
// move origin node to center of viewport
const zoomScale = state.scale;
const translate = [state.panTranslateX, state.panTranslateY];
- const centerX = (-translate[0] + (state.width + MARGINS.left
+ const centerX = (-translate[0] + (state.width + props.margins.left
- DETAILS_PANEL_WIDTH) / 2) / zoomScale;
- const centerY = (-translate[1] + (state.height + MARGINS.top) / 2) / zoomScale;
+ const centerY = (-translate[1] + (state.height + props.margins.top) / 2) / zoomScale;
stateNodes = stateNodes.mergeIn([props.selectedNodeId], {
x: centerX,
y: centerY
@@ -291,8 +294,10 @@ class NodesChart extends React.Component {
restoreLayout(state) {
// undo any pan/zooming that might have happened
- this.zoom.scale(state.scale);
- this.zoom.translate([state.panTranslateX, state.panTranslateY]);
+ if (this.zoom) {
+ this.zoom.scale(state.scale);
+ this.zoom.translate([state.panTranslateX, state.panTranslateY]);
+ }
const nodes = state.nodes.map(node => node.merge({
x: node.get('px'),
@@ -310,9 +315,7 @@ class NodesChart extends React.Component {
}
updateGraphState(props, state) {
- const n = props.nodes.size;
-
- if (n === 0) {
+ if (props.nodes.size === 0) {
return {
nodes: makeMap(),
edges: makeMap()
@@ -323,17 +326,24 @@ class NodesChart extends React.Component {
const stateEdges = this.initEdges(props.nodes, stateNodes);
const nodeScale = this.getNodeScale(props.nodes, state.width, state.height);
const nextState = { nodeScale };
+ console.log(props.nodeOrder);
+ const nodeOrder = props.nodeOrder || makeMap(stateNodes
+ .toList()
+ .sortBy(n => n.get('label'))
+ .map((n, i) => [n.get('id'), i]));
const options = {
width: state.width,
height: state.height,
scale: nodeScale,
- margins: MARGINS,
+ margins: props.margins,
forceRelayout: props.forceRelayout,
topologyId: this.props.topologyId,
- topologyOptions: this.props.topologyOptions
+ topologyOptions: this.props.topologyOptions,
+ nodeOrder
};
+ console.log('nodes-chart', state.height);
const timedLayouter = timely(doLayout);
const graph = timedLayouter(stateNodes, stateEdges, options);
@@ -353,15 +363,17 @@ class NodesChart extends React.Component {
.map(edge => edge.set('ppoints', edge.get('points')));
// adjust layout based on viewport
- const xFactor = (state.width - MARGINS.left - MARGINS.right) / graph.width;
+ const xFactor = (state.width - props.margins.left - props.margins.right) / graph.width;
const yFactor = state.height / graph.height;
const zoomFactor = Math.min(xFactor, yFactor);
let zoomScale = this.state.scale;
- if (!state.hasZoomed && zoomFactor > 0 && zoomFactor < 1) {
+ if (!this.props.noZoom && !state.hasZoomed && zoomFactor > 0 && zoomFactor < 1) {
zoomScale = zoomFactor;
// saving in d3's behavior cache
- this.zoom.scale(zoomFactor);
+ if (this.zoom) {
+ this.zoom.scale(zoomFactor);
+ }
}
nextState.scale = zoomScale;
@@ -380,7 +392,7 @@ class NodesChart extends React.Component {
const nodeSize = expanse / 3; // single node should fill a third of the screen
const maxNodeSize = expanse / 10;
const normalizedNodeSize = Math.min(nodeSize / Math.sqrt(nodes.size), maxNodeSize);
- return this.state.nodeScale.copy().range([0, normalizedNodeSize]);
+ return this.state.nodeScale.copy().range([0, this.props.nodeSize || normalizedNodeSize]);
}
zoomed() {
diff --git a/client/app/scripts/charts/nodes-grid.js b/client/app/scripts/charts/nodes-grid.js
new file mode 100644
index 000000000..771f74407
--- /dev/null
+++ b/client/app/scripts/charts/nodes-grid.js
@@ -0,0 +1,86 @@
+/* eslint react/jsx-no-bind: "off", no-multi-comp: "off" */
+
+import React from 'react';
+import { Set as makeSet, List as makeList, Map as makeMap } from 'immutable';
+import NodesChart from './nodes-chart';
+import NodeDetailsTable from '../components/node-details/node-details-table';
+import { enterNode, leaveNode } from '../actions/app-actions';
+
+
+function MiniChart(props) {
+ const {width, height} = props;
+ return (
+
+
+
+ );
+}
+
+
+const IGNORED_COLUMNS = ['docker_container_ports'];
+
+
+function getColumns(nodes) {
+ const allColumns = nodes.toList().flatMap(n => {
+ const metrics = (n.get('metrics') || makeList())
+ .map(m => makeMap({ id: m.get('id'), label: m.get('label') }));
+ const metadata = (n.get('metadata') || makeList())
+ .map(m => makeMap({ id: m.get('id'), label: m.get('label') }));
+ return metadata.concat(metrics);
+ });
+ return makeSet(allColumns).filter(n => !IGNORED_COLUMNS.includes(n.get('id'))).toJS();
+}
+
+
+export default class NodesGrid extends React.Component {
+
+ onMouseOverRow(node) {
+ enterNode(node.id);
+ }
+
+ onMouseOut() {
+ leaveNode();
+ }
+
+ render() {
+ const {margins, nodes, height, nodeSize} = this.props;
+ const rowStyle = { height: nodeSize };
+ const tableHeight = nodes.size * rowStyle.height;
+ const graphProps = Object.assign({}, this.props, {
+ height: tableHeight,
+ width: 400,
+ noZoom: true,
+ nodeSize: nodeSize - 4,
+ margins: {top: 0, left: 0, right: 0, bottom: 0},
+ nodes: nodes.map(node => node.remove('label').remove('label_minor'))
+ });
+ const cmpStyle = {
+ height,
+ paddingTop: margins.top,
+ paddingBottom: margins.bottom,
+ paddingLeft: margins.left,
+ paddingRight: margins.right,
+ };
+
+ const detailsData = {
+ label: 'procs',
+ id: '',
+ nodes: nodes.toList().toJS(),
+ columns: getColumns(nodes)
+ };
+
+ return (
+
+
+
+
+
+ );
+ }
+}
diff --git a/client/app/scripts/charts/nodes-layout.js b/client/app/scripts/charts/nodes-layout.js
index 116e35f4d..2a9aaff72 100644
--- a/client/app/scripts/charts/nodes-layout.js
+++ b/client/app/scripts/charts/nodes-layout.js
@@ -1,5 +1,6 @@
import dagre from 'dagre';
import debug from 'debug';
+import d3 from 'd3';
import { fromJS, Map as makeMap, Set as ImmSet } from 'immutable';
import { EDGE_ID_SEPARATOR } from '../constants/naming';
@@ -49,7 +50,9 @@ function runLayoutEngine(graph, imNodes, imEdges, opts) {
// configure node margins
graph.setGraph({
nodesep,
- ranksep
+ ranksep,
+ rankdir: 'LR',
+ align: 'UL'
});
// add nodes to the graph if not already there
@@ -176,6 +179,7 @@ function layoutSingleNodes(layout, opts) {
offsetY = offsetY || margins.top + nodeHeight / 2;
const columns = Math.ceil(Math.sqrt(singleNodes.size));
+ const rows = Math.ceil(singleNodes.size / columns);
let row = 0;
let col = 0;
let singleX;
@@ -197,9 +201,11 @@ function layoutSingleNodes(layout, opts) {
return node;
});
+ console.log(singleX, singleY);
+
// adjust layout dimensions if graph is now bigger
- result.width = Math.max(layout.width, singleX + nodeWidth / 2 + nodesep);
- result.height = Math.max(layout.height, singleY + nodeHeight / 2 + ranksep);
+ result.width = Math.max(layout.width, columns * nodeWidth + (columns - 1) * nodesep);
+ result.height = Math.max(layout.height, rows * nodeHeight + (rows - 1) * ranksep);
result.nodes = nodes;
}
@@ -259,6 +265,40 @@ function setSimpleEdgePoints(edge, nodeCache) {
]));
}
+
+function uniqueRowConstraint(layout, options) {
+ const result = Object.assign({}, layout);
+ const scale = options.scale || DEFAULT_SCALE;
+ const nodeHeight = scale(NODE_SIZE_FACTOR);
+ const nodeWidth = scale(NODE_SIZE_FACTOR);
+ const margins = options.margins || DEFAULT_MARGINS;
+
+ const rowHeight = options.height / layout.nodes.size;
+ const nodeOrder = options.nodeOrder || makeMap(layout.nodes
+ .toList()
+ .sortBy(n => n.get('y'))
+ .map((n, i) => [n.get('id'), i]));
+
+ const nodeXs = layout.nodes.map(n => n.get('x')).toList().toJS();
+ const xScale = d3.scale.linear()
+ .domain(d3.extent(nodeXs))
+ .range([nodeWidth, options.width - nodeWidth])
+ .clamp(false);
+
+ console.log('uniqueRowConstraint', options.height);
+ result.nodes = layout.nodes.map(node => node.merge({
+ x: xScale(node.get('x')),
+ y: nodeOrder.get(node.get('id')) * rowHeight + nodeHeight * 0.5 + margins.top + 2
+ }));
+
+ result.edges = layout.edges.map(edge => (
+ setSimpleEdgePoints(edge, result.nodes)
+ ));
+
+ return result;
+}
+
+
/**
* Determine if nodes were added between node sets
* @param {Map} nodes new Map of nodes
@@ -355,7 +395,7 @@ export function doLayout(immNodes, immEdges, opts) {
let layout;
++layoutRuns;
- if (!options.forceRelayout && cachedLayout && nodeCache && edgeCache
+ if (false && !options.forceRelayout && cachedLayout && nodeCache && edgeCache
&& !hasUnseenNodes(immNodes, nodeCache)) {
log('skip layout, trivial adjustment', ++layoutRunsTrivial, layoutRuns);
layout = cloneLayout(cachedLayout, immNodes, immEdges);
@@ -370,6 +410,7 @@ export function doLayout(immNodes, immEdges, opts) {
}
layout = layoutSingleNodes(layout, opts);
layout = shiftLayoutToCenter(layout, opts);
+ layout = uniqueRowConstraint(layout, opts);
}
// cache results
diff --git a/client/app/scripts/components/examples.js b/client/app/scripts/components/examples.js
new file mode 100644
index 000000000..43a82b439
--- /dev/null
+++ b/client/app/scripts/components/examples.js
@@ -0,0 +1,150 @@
+/* eslint no-unused-vars: "off" */
+import React from 'react';
+import _ from 'lodash';
+import NodesChart from '../charts/nodes-chart';
+import NodesGrid from '../charts/nodes-grid';
+import { deltaAdd, makeNodes } from './debug-toolbar';
+import { fromJS, Map as makeMap, Set as makeSet } from 'immutable';
+
+
+function clog(v) {
+ console.log(v);
+ return v;
+}
+
+function randomGraph(n) {
+ return makeMap(makeNodes(n, 'ewq', 4, 'hexagon').map(d => [d.id, fromJS(d)]));
+}
+
+function deltaAddSimple(name, adjacency = []) {
+ return deltaAdd(name, adjacency, 'circle', false, 1, '');
+}
+
+
+function makeIds(n) {
+ return _.range(n).map(i => `n${i}`);
+}
+
+
+function disconnectedGraph(n) {
+ return makeMap(makeIds(n)
+ .map((id) => deltaAddSimple(id))
+ .map(d => [d.id, fromJS(d)]));
+}
+
+
+function completeGraph(n) {
+ const ids = makeIds(n);
+ const allEdges = _.flatMap(ids, i => ids.filter(ii => i !== ii).map(ii => [i, ii]));
+ const oneWayEdges = allEdges.filter(edge => _.isEqual(edge, _.sortBy(edge)));
+ const adjacencyMap = _(oneWayEdges)
+ .groupBy(e => e[0])
+ .mapValues(edges => edges.map(e => e[1]))
+ .value();
+ return makeMap(ids
+ .map((id) => deltaAddSimple(id, adjacencyMap[id] || []))
+ .map(d => [d.id, fromJS(d)]));
+}
+
+
+function completeGraphBi(n) {
+ const ids = makeIds(n);
+ const adjacency = (id) => ids.filter(_id => _id !== id);
+ return makeMap(ids
+ .map((id) => deltaAddSimple(id, adjacency(id)))
+ .map(d => [d.id, fromJS(d)]));
+}
+
+
+function flatTree(n) {
+ const ids = makeIds(n + 1);
+ const p = ids.pop();
+ const adjacency = id => id === p ? ids : [];
+ return makeMap(ids.concat([p])
+ .map((id) => deltaAddSimple(id, adjacency(id)))
+ .map(d => [d.id, fromJS(d)]));
+}
+
+function proxyGraph(n) {
+ const ids = makeIds(n * 2 + 1);
+ const p = ids.pop();
+ const topIds = _.take(ids, n);
+ const bottomIds = _.drop(ids, n);
+ const adjacencyMap = Object.assign({
+ [p]: bottomIds
+ }, _.fromPairs(topIds.map(id => [id, [p]])));
+
+ return makeMap(ids.concat([p])
+ .map((id) => deltaAddSimple(id, adjacencyMap[id] || []))
+ .map(d => [d.id, fromJS(d)]));
+}
+
+
+function chart(nodes,
+ n,
+ style = { width: 250, height: 250 },
+ margins = { top: 0, left: 0, right: 0, bottom: 0 },
+ nodeSize = null) {
+ return (
+
+
+
+ );
+}
+
+
+function variants() {
+ const nCharts = 5;
+ const width = 250;
+ const style = {width: nCharts * (width + 16)};
+ const generators = [
+ { id: 'disconnectedGraph', fn: disconnectedGraph },
+ { id: 'completeGraphBi', fn: completeGraphBi },
+ { id: 'completeGraph', fn: completeGraph },
+ { id: 'flatTree', fn: flatTree },
+ { id: 'proxyGraph', fn: proxyGraph }
+ ];
+ return (
+
+ {_.reverse(generators).map(({id, fn}) => (
+
+ {_.range(1, nCharts + 1).map(i => (
+ chart(fn(i), i)
+ ))}
+
+ ))}
+
+ );
+}
+
+
+function gridView() {
+ const nodes = randomGraph(50).map(node => node.remove('label').remove('label_minor'));
+ const nodeSize = 24;
+ return (
+
+ );
+}
+
+
+export class Examples extends React.Component {
+ render() {
+ return (
+
+ {gridView()}
+ {false && variants()}
+
+ );
+ }
+}
diff --git a/client/app/scripts/components/node-details.js b/client/app/scripts/components/node-details.js
index 6e4d688d8..da0a58a70 100644
--- a/client/app/scripts/components/node-details.js
+++ b/client/app/scripts/components/node-details.js
@@ -1,4 +1,3 @@
-import _ from 'lodash';
import React from 'react';
import { connect } from 'react-redux';
import { Map as makeMap } from 'immutable';
@@ -123,12 +122,6 @@ export class NodeDetails extends React.Component {
);
}
- renderTable(table) {
- const key = _.snakeCase(table.title);
- return ();
- }
-
render() {
if (this.props.notFound) {
return this.renderNotAvailable();
diff --git a/client/app/scripts/components/node-details/node-details-table-row.js b/client/app/scripts/components/node-details/node-details-table-row.js
new file mode 100644
index 000000000..c7a26c138
--- /dev/null
+++ b/client/app/scripts/components/node-details/node-details-table-row.js
@@ -0,0 +1,72 @@
+import React from 'react';
+import classNames from 'classnames';
+
+import NodeDetailsTableNodeLink from './node-details-table-node-link';
+import NodeDetailsTableNodeMetric from './node-details-table-node-metric';
+
+
+function getValuesForNode(node) {
+ const values = {};
+ ['metrics', 'metadata'].forEach(collection => {
+ if (node[collection]) {
+ node[collection].forEach(field => {
+ const result = Object.assign({}, field);
+ result.valueType = collection;
+ values[field.id] = result;
+ });
+ }
+ });
+ return values;
+}
+
+
+function renderValues(node, columns = []) {
+ const fields = getValuesForNode(node);
+ return columns.map(({id}) => {
+ const field = fields[id];
+ if (field) {
+ if (field.valueType === 'metadata') {
+ return (
+
+ {field.value}
+ |
+ );
+ }
+ return ;
+ }
+ // empty cell to complete the row for proper hover
+ return | ;
+ });
+}
+
+export default class NodeDetailsTableRow extends React.Component {
+
+ constructor(props, context) {
+ super(props, context);
+ this.onMouseOver = this.onMouseOver.bind(this);
+ }
+
+ onMouseOver() {
+ const { node, onMouseOverRow } = this.props;
+ onMouseOverRow(node);
+ }
+
+ render() {
+ const { node, nodeIdKey, topologyId, columns, onMouseOverRow, selected } = this.props;
+ const values = renderValues(node, columns);
+ const nodeId = node[nodeIdKey];
+ const className = classNames('node-details-table-node', { selected });
+ return (
+
+ |
+
+ |
+ {values}
+
+ );
+ }
+}
diff --git a/client/app/scripts/components/node-details/node-details-table.js b/client/app/scripts/components/node-details/node-details-table.js
index 667e61d04..61c4a923f 100644
--- a/client/app/scripts/components/node-details/node-details-table.js
+++ b/client/app/scripts/components/node-details/node-details-table.js
@@ -1,9 +1,10 @@
import _ from 'lodash';
import React from 'react';
+import { Map as makeMap } from 'immutable';
import ShowMore from '../show-more';
-import NodeDetailsTableNodeLink from './node-details-table-node-link';
-import NodeDetailsTableNodeMetric from './node-details-table-node-metric';
+import NodeDetailsTableRow from './node-details-table-row';
+import { sortOrderChanged } from '../../actions/app-actions';
function isNumberField(field) {
@@ -16,61 +17,20 @@ const COLUMN_WIDTHS = {
count: '70px'
};
-
-export default class NodeDetailsTable extends React.Component {
-
- constructor(props, context) {
- super(props, context);
- this.DEFAULT_LIMIT = 5;
- this.state = {
- limit: this.DEFAULT_LIMIT,
- sortedDesc: true,
- sortBy: null
- };
- this.handleLimitClick = this.handleLimitClick.bind(this);
- this.getValueForSortBy = this.getValueForSortBy.bind(this);
+function getDefaultSortBy(columns, nodes) {
+ // default sorter specified by columns
+ const defaultSortColumn = _.find(columns, {defaultSort: true});
+ if (defaultSortColumn) {
+ return defaultSortColumn.id;
}
+ // otherwise choose first metric
+ return _.get(nodes, [0, 'metrics', 0, 'id']);
+}
- handleHeaderClick(ev, headerId) {
- ev.preventDefault();
- const sortedDesc = headerId === this.state.sortBy
- ? !this.state.sortedDesc : this.state.sortedDesc;
- const sortBy = headerId;
- this.setState({sortBy, sortedDesc});
- }
- handleLimitClick() {
- const limit = this.state.limit ? 0 : this.DEFAULT_LIMIT;
- this.setState({limit});
- }
-
- getDefaultSortBy() {
- // default sorter specified by columns
- const defaultSortColumn = _.find(this.props.columns, {defaultSort: true});
- if (defaultSortColumn) {
- return defaultSortColumn.id;
- }
- // otherwise choose first metric
- return _.get(this.props.nodes, [0, 'metrics', 0, 'id']);
- }
-
- getMetaDataSorters() {
- // returns an array of sorters that will take a node
- return _.get(this.props.nodes, [0, 'metadata'], []).map((field, index) => node => {
- const nodeMetadataField = node.metadata[index];
- if (nodeMetadataField) {
- if (isNumberField(nodeMetadataField)) {
- return parseFloat(nodeMetadataField.value);
- }
- return nodeMetadataField.value;
- }
- return null;
- });
- }
-
- getValueForSortBy(node) {
- // return the node's value based on the sortBy field
- const sortBy = this.state.sortBy || this.getDefaultSortBy();
+function getValueForSortBy(sortBy) {
+ // return the node's value based on the sortBy field
+ return (node) => {
if (sortBy !== null) {
const field = _.union(node.metrics, node.metadata).find(f => f.id === sortBy);
if (field) {
@@ -81,27 +41,73 @@ export default class NodeDetailsTable extends React.Component {
}
}
return -1e-10; // just under 0 to treat missing values differently from 0
+ };
+}
+
+
+/*
+function getMetaDataSorters(nodes) {
+ // returns an array of sorters that will take a node
+ return _.get(nodes, [0, 'metadata'], []).map((field, index) => node => {
+ const nodeMetadataField = node.metadata[index];
+ if (nodeMetadataField) {
+ if (isNumberField(nodeMetadataField)) {
+ return parseFloat(nodeMetadataField.value);
+ }
+ return nodeMetadataField.value;
+ }
+ return null;
+ });
+}
+*/
+
+
+function getSortedNodes(nodes, columns, sortBy, sortedDesc) {
+ const sortedNodes = _.sortBy(
+ nodes,
+ getValueForSortBy(sortBy || getDefaultSortBy(columns, nodes)),
+ 'label'
+ // getMetaDataSorters(nodes)
+ );
+ if (sortedDesc) {
+ sortedNodes.reverse();
+ }
+ return sortedNodes;
+}
+
+
+export default class NodeDetailsTable extends React.Component {
+
+ constructor(props, context) {
+ super(props, context);
+ this.DEFAULT_LIMIT = 5;
+ this.state = {
+ limit: props.limit || this.DEFAULT_LIMIT,
+ sortedDesc: true,
+ sortBy: null
+ };
+ this.handleLimitClick = this.handleLimitClick.bind(this);
}
- getValuesForNode(node) {
- const values = {};
- ['metrics', 'metadata'].forEach(collection => {
- if (node[collection]) {
- node[collection].forEach(field => {
- const result = Object.assign({}, field);
- result.valueType = collection;
- values[field.id] = result;
- });
- }
- });
- return values;
+ handleHeaderClick(ev, headerId) {
+ ev.preventDefault();
+ const sortedDesc = headerId === this.state.sortBy
+ ? !this.state.sortedDesc : this.state.sortedDesc;
+ const sortBy = headerId;
+ this.setState({sortBy, sortedDesc});
+ sortOrderChanged({sortBy, sortedDesc});
+ }
+
+ handleLimitClick() {
+ const limit = this.state.limit ? 0 : this.DEFAULT_LIMIT;
+ this.setState({limit});
}
renderHeaders() {
if (this.props.nodes && this.props.nodes.length > 0) {
const columns = this.props.columns || [];
const headers = [{id: 'label', label: this.props.label}].concat(columns);
- const defaultSortBy = this.getDefaultSortBy();
+ const defaultSortBy = getDefaultSortBy(this.props);
// Beauty hack: adjust first column width if there are only few columns;
// this assumes the other columns are narrow metric columns of 20% table width
@@ -109,7 +115,7 @@ export default class NodeDetailsTable extends React.Component {
headers[0].width = '66%';
} else if (headers.length === 3) {
headers[0].width = '50%';
- } else if (headers.length >= 3) {
+ } else if (headers.length >= 3 && headers.length < 5) {
headers[0].width = '33%';
}
@@ -161,66 +167,53 @@ export default class NodeDetailsTable extends React.Component {
return '';
}
- renderValues(node) {
- const fields = this.getValuesForNode(node);
- const columns = this.props.columns || [];
- return columns.map(({id}) => {
- const field = fields[id];
- if (field) {
- if (field.valueType === 'metadata') {
- return (
-
- {field.value}
- |
- );
- }
- return ;
- }
- // empty cell to complete the row for proper hover
- return | ;
- });
- }
-
render() {
const headers = this.renderHeaders();
- const { nodeIdKey } = this.props;
- let nodes = _.sortBy(this.props.nodes, this.getValueForSortBy, 'label',
- this.getMetaDataSorters());
+ const { nodeIdKey, columns, topologyId, onMouseOverRow } = this.props;
+ let nodes = getSortedNodes(this.props.nodes, this.props.columns, this.state.sortBy,
+ this.state.sortedDesc);
const limited = nodes && this.state.limit > 0 && nodes.length > this.state.limit;
const expanded = this.state.limit === 0;
- const notShown = nodes.length - this.DEFAULT_LIMIT;
- if (this.state.sortedDesc) {
- nodes.reverse();
- }
+ const notShown = nodes.length - this.state.limit;
if (nodes && limited) {
nodes = nodes.slice(0, this.state.limit);
}
+ const nodeOrderJS = (nodes || []).map((n, i) => [n.id, i]);
+ const nodeOrder = makeMap(nodeOrderJS);
+ const childrenWithProps = React.Children.map(this.props.children, (child) => (
+ React.cloneElement(child, { nodeOrder })
+ ));
+
+ console.log(this.props.selectedRowId);
+
return (
-
-
-
- {headers}
-
-
- {nodes && nodes.map(node => {
- const values = this.renderValues(node);
- const nodeId = node[nodeIdKey];
- return (
-
- |
-
- |
- {values}
-
- );
- })}
-
-
-
+
+
+
+
+ {headers}
+
+
+ {nodes && nodes.map(node => (
+
+ ))}
+
+
+
+
+ {childrenWithProps}
);
}
diff --git a/client/app/scripts/components/nodes.js b/client/app/scripts/components/nodes.js
index 38ae981c2..262d5a78f 100644
--- a/client/app/scripts/components/nodes.js
+++ b/client/app/scripts/components/nodes.js
@@ -1,11 +1,13 @@
import React from 'react';
import { connect } from 'react-redux';
-import NodesChart from '../charts/nodes-chart';
+// import NodesChart from '../charts/nodes-chart';
+import NodesGrid from '../charts/nodes-grid';
import NodesError from '../charts/nodes-error';
import { DelayedShow } from '../utils/delayed-show';
import { Loading, getNodeType } from './loading';
import { isTopologyEmpty } from '../utils/topology-utils';
+import { CANVAS_MARGINS } from '../constants/styles';
const navbarHeight = 160;
const marginTop = 0;
@@ -80,7 +82,11 @@ class Nodes extends React.Component {
show={topologiesLoaded && !nodesLoaded} />
{this.renderEmptyTopologyError(topologiesLoaded && nodesLoaded && topologyEmpty)}
-
, document.getElementById('app'));
diff --git a/client/app/scripts/stores/app-store.js b/client/app/scripts/stores/app-store.js
new file mode 100644
index 000000000..eea6372e7
--- /dev/null
+++ b/client/app/scripts/stores/app-store.js
@@ -0,0 +1,749 @@
+import _ from 'lodash';
+import debug from 'debug';
+import { fromJS, is as isDeepEqual, List, Map, OrderedMap, Set } from 'immutable';
+import { Store } from 'flux/utils';
+
+import AppDispatcher from '../dispatcher/app-dispatcher';
+import ActionTypes from '../constants/action-types';
+import { EDGE_ID_SEPARATOR } from '../constants/naming';
+import { findTopologyById, setTopologyUrlsById, updateTopologyIds,
+ filterHiddenTopologies } from '../utils/topology-utils';
+
+const makeList = List;
+const makeMap = Map;
+const makeOrderedMap = OrderedMap;
+const makeSet = Set;
+const log = debug('scope:app-store');
+
+const error = debug('scope:error');
+
+// Helpers
+
+function makeNode(node) {
+ return {
+ id: node.id,
+ label: node.label,
+ label_minor: node.label_minor,
+ node_count: node.node_count,
+ rank: node.rank,
+ pseudo: node.pseudo,
+ stack: node.stack,
+ shape: node.shape,
+ adjacency: node.adjacency,
+ metrics: node.metrics
+ };
+}
+
+// Initial values
+
+let topologyOptions = makeOrderedMap(); // topologyId -> options
+let controlStatus = makeMap();
+let currentTopology = null;
+let currentTopologyId = 'containers';
+let errorUrl = null;
+let forceRelayout = false;
+let highlightedEdgeIds = makeSet();
+let highlightedNodeIds = makeSet();
+let hostname = '...';
+let version = '...';
+let versionUpdate = null;
+let plugins = [];
+let mouseOverEdgeId = null;
+let mouseOverNodeId = null;
+let nodeDetails = makeOrderedMap(); // nodeId -> details
+let nodes = makeOrderedMap(); // nodeId -> node
+let selectedNodeId = null;
+let topologies = makeList();
+let topologiesLoaded = false;
+let topologyUrlsById = makeOrderedMap(); // topologyId -> topologyUrl
+let routeSet = false;
+let controlPipes = makeOrderedMap(); // pipeId -> controlPipe
+let updatePausedAt = null; // Date
+let websocketClosed = true;
+let showingHelp = false;
+let tableSortOrder = null;
+
+let selectedMetric = null;
+let pinnedMetric = selectedMetric;
+// class of metric, e.g. 'cpu', rather than 'host_cpu' or 'process_cpu'.
+// allows us to keep the same metric "type" selected when the topology changes.
+let pinnedMetricType = null;
+let availableCanvasMetrics = makeList();
+
+
+const topologySorter = topology => topology.get('rank');
+
+// adds ID field to topology (based on last part of URL path) and save urls in
+// map for easy lookup
+function processTopologies(nextTopologies) {
+ // filter out hidden topos
+ const visibleTopologies = filterHiddenTopologies(nextTopologies);
+
+ // add IDs to topology objects in-place
+ const topologiesWithId = updateTopologyIds(visibleTopologies);
+
+ // cache URLs by ID
+ topologyUrlsById = setTopologyUrlsById(topologyUrlsById, topologiesWithId);
+
+ const immNextTopologies = fromJS(topologiesWithId).sortBy(topologySorter);
+ topologies = topologies.mergeDeep(immNextTopologies);
+}
+
+function setTopology(topologyId) {
+ currentTopology = findTopologyById(topologies, topologyId);
+ currentTopologyId = topologyId;
+}
+
+function setDefaultTopologyOptions(topologyList) {
+ topologyList.forEach(topology => {
+ let defaultOptions = makeOrderedMap();
+ if (topology.has('options') && topology.get('options')) {
+ topology.get('options').forEach((option) => {
+ const optionId = option.get('id');
+ const defaultValue = option.get('defaultValue');
+ defaultOptions = defaultOptions.set(optionId, defaultValue);
+ });
+ }
+
+ if (defaultOptions.size) {
+ topologyOptions = topologyOptions.set(
+ topology.get('id'),
+ defaultOptions
+ );
+ }
+ });
+}
+
+function closeNodeDetails(nodeId) {
+ if (nodeDetails.size > 0) {
+ const popNodeId = nodeId || nodeDetails.keySeq().last();
+ // remove pipe if it belongs to the node being closed
+ controlPipes = controlPipes.filter(pipe => pipe.get('nodeId') !== popNodeId);
+ nodeDetails = nodeDetails.delete(popNodeId);
+ }
+ if (nodeDetails.size === 0 || selectedNodeId === nodeId) {
+ selectedNodeId = null;
+ }
+}
+
+function closeAllNodeDetails() {
+ while (nodeDetails.size) {
+ closeNodeDetails();
+ }
+}
+
+function resumeUpdate() {
+ updatePausedAt = null;
+}
+
+// Store API
+
+export class AppStore extends Store {
+
+ // keep at the top
+ getAppState() {
+ const cp = this.getControlPipe();
+ return {
+ controlPipe: cp ? cp.toJS() : null,
+ nodeDetails: this.getNodeDetailsState().toJS(),
+ selectedNodeId,
+ pinnedMetricType,
+ topologyId: currentTopologyId,
+ topologyOptions: topologyOptions.toJS() // all options
+ };
+ }
+
+ getTableSortOrder() {
+ return tableSortOrder;
+ }
+
+ getShowingHelp() {
+ return showingHelp;
+ }
+
+ getActiveTopologyOptions() {
+ // options for current topology, sub-topologies share options with parent
+ if (currentTopology && currentTopology.get('parentId')) {
+ return topologyOptions.get(currentTopology.get('parentId'));
+ }
+ return topologyOptions.get(currentTopologyId);
+ }
+
+ getAdjacentNodes(nodeId) {
+ let adjacentNodes = makeSet();
+
+ if (nodes.has(nodeId)) {
+ adjacentNodes = makeSet(nodes.getIn([nodeId, 'adjacency']));
+ // fill up set with reverse edges
+ nodes.forEach((node, id) => {
+ if (node.get('adjacency') && node.get('adjacency').includes(nodeId)) {
+ adjacentNodes = adjacentNodes.add(id);
+ }
+ });
+ }
+
+ return adjacentNodes;
+ }
+
+ getPinnedMetric() {
+ return pinnedMetric;
+ }
+
+ getSelectedMetric() {
+ return selectedMetric;
+ }
+
+ getAvailableCanvasMetrics() {
+ return availableCanvasMetrics;
+ }
+
+ getAvailableCanvasMetricsTypes() {
+ return makeMap(this.getAvailableCanvasMetrics().map(m => [m.get('id'), m.get('label')]));
+ }
+
+ getControlStatus() {
+ return controlStatus;
+ }
+
+ getControlPipe() {
+ return controlPipes.last();
+ }
+
+ getCurrentTopology() {
+ if (!currentTopology) {
+ currentTopology = setTopology(currentTopologyId);
+ }
+ return currentTopology;
+ }
+
+ getCurrentTopologyId() {
+ return currentTopologyId;
+ }
+
+ getCurrentTopologyOptions() {
+ return currentTopology && currentTopology.get('options') || makeOrderedMap();
+ }
+
+ getCurrentTopologyUrl() {
+ return currentTopology && currentTopology.get('url');
+ }
+
+ getErrorUrl() {
+ return errorUrl;
+ }
+
+ getHighlightedEdgeIds() {
+ return highlightedEdgeIds;
+ }
+
+ getHighlightedNodeIds() {
+ return highlightedNodeIds;
+ }
+
+ getHostname() {
+ return hostname;
+ }
+
+ getNodeDetails() {
+ return nodeDetails;
+ }
+
+ getNodeDetailsState() {
+ return nodeDetails.toIndexedSeq().map(details => ({
+ id: details.id, label: details.label, topologyId: details.topologyId
+ }));
+ }
+
+ getTopCardNodeId() {
+ return nodeDetails.last() && nodeDetails.last().id;
+ }
+
+ getNodes() {
+ return nodes;
+ }
+
+ getSelectedNodeId() {
+ return selectedNodeId;
+ }
+
+ getTopologies() {
+ return topologies;
+ }
+
+ getTopologyUrlsById() {
+ return topologyUrlsById;
+ }
+
+ getUpdatePausedAt() {
+ return updatePausedAt;
+ }
+
+ getVersion() {
+ return version;
+ }
+
+ getVersionUpdate() {
+ return versionUpdate;
+ }
+
+ getPlugins() {
+ return plugins;
+ }
+
+ isForceRelayout() {
+ return forceRelayout;
+ }
+
+ isRouteSet() {
+ return routeSet;
+ }
+
+ isTopologiesLoaded() {
+ return topologiesLoaded;
+ }
+
+ isTopologyEmpty() {
+ return currentTopology && currentTopology.get('stats')
+ && currentTopology.get('stats').get('node_count') === 0 && nodes.size === 0;
+ }
+
+ isUpdatePaused() {
+ return updatePausedAt !== null;
+ }
+
+ isWebsocketClosed() {
+ return websocketClosed;
+ }
+
+ __onDispatch(payload) {
+ if (!payload.type) {
+ error('Payload missing a type!', payload);
+ }
+
+ switch (payload.type) {
+ case ActionTypes.CHANGE_TOPOLOGY_OPTION: {
+ resumeUpdate();
+ // set option on parent topology
+ const topology = findTopologyById(topologies, payload.topologyId);
+ if (topology) {
+ const topologyId = topology.get('parentId') || topology.get('id');
+ if (topologyOptions.getIn([topologyId, payload.option]) !== payload.value) {
+ nodes = nodes.clear();
+ }
+ topologyOptions = topologyOptions.setIn(
+ [topologyId, payload.option],
+ payload.value
+ );
+ this.__emitChange();
+ }
+ break;
+ }
+ case ActionTypes.CLEAR_CONTROL_ERROR: {
+ controlStatus = controlStatus.removeIn([payload.nodeId, 'error']);
+ this.__emitChange();
+ break;
+ }
+ case ActionTypes.CLICK_BACKGROUND: {
+ closeAllNodeDetails();
+ this.__emitChange();
+ break;
+ }
+ case ActionTypes.CLICK_CLOSE_DETAILS: {
+ closeNodeDetails(payload.nodeId);
+ this.__emitChange();
+ break;
+ }
+ case ActionTypes.SORT_ORDER_CHANGED: {
+ tableSortOrder = makeMap((payload.newOrder || []).map((n, i) => [n.id, i]));
+ this.__emitChange();
+ break;
+ }
+ case ActionTypes.CLICK_CLOSE_TERMINAL: {
+ controlPipes = controlPipes.clear();
+ this.__emitChange();
+ break;
+ }
+ case ActionTypes.CLICK_FORCE_RELAYOUT: {
+ forceRelayout = true;
+ // fire only once, reset after emitChange
+ setTimeout(() => {
+ forceRelayout = false;
+ }, 0);
+ this.__emitChange();
+ break;
+ }
+ case ActionTypes.CLICK_NODE: {
+ const prevSelectedNodeId = selectedNodeId;
+ const prevDetailsStackSize = nodeDetails.size;
+ // click on sibling closes all
+ closeAllNodeDetails();
+ // select new node if it's not the same (in that case just delesect)
+ if (prevDetailsStackSize > 1 || prevSelectedNodeId !== payload.nodeId) {
+ // dont set origin if a node was already selected, suppresses animation
+ const origin = prevSelectedNodeId === null ? payload.origin : null;
+ nodeDetails = nodeDetails.set(
+ payload.nodeId,
+ {
+ id: payload.nodeId,
+ label: payload.label,
+ origin,
+ topologyId: currentTopologyId
+ }
+ );
+ selectedNodeId = payload.nodeId;
+ }
+ this.__emitChange();
+ break;
+ }
+ case ActionTypes.CLICK_PAUSE_UPDATE: {
+ updatePausedAt = new Date;
+ this.__emitChange();
+ break;
+ }
+ case ActionTypes.CLICK_RELATIVE: {
+ if (nodeDetails.has(payload.nodeId)) {
+ // bring to front
+ const details = nodeDetails.get(payload.nodeId);
+ nodeDetails = nodeDetails.delete(payload.nodeId);
+ nodeDetails = nodeDetails.set(payload.nodeId, details);
+ } else {
+ nodeDetails = nodeDetails.set(
+ payload.nodeId,
+ {
+ id: payload.nodeId,
+ label: payload.label,
+ origin: payload.origin,
+ topologyId: payload.topologyId
+ }
+ );
+ }
+ this.__emitChange();
+ break;
+ }
+ case ActionTypes.CLICK_RESUME_UPDATE: {
+ resumeUpdate();
+ this.__emitChange();
+ break;
+ }
+ case ActionTypes.CLICK_SHOW_TOPOLOGY_FOR_NODE: {
+ resumeUpdate();
+ nodeDetails = nodeDetails.filter((v, k) => k === payload.nodeId);
+ controlPipes = controlPipes.clear();
+ selectedNodeId = payload.nodeId;
+ if (payload.topologyId !== currentTopologyId) {
+ setTopology(payload.topologyId);
+ nodes = nodes.clear();
+ }
+ availableCanvasMetrics = makeList();
+ tableSortOrder = null;
+ this.__emitChange();
+ break;
+ }
+ case ActionTypes.CLICK_TOPOLOGY: {
+ resumeUpdate();
+ closeAllNodeDetails();
+ if (payload.topologyId !== currentTopologyId) {
+ setTopology(payload.topologyId);
+ nodes = nodes.clear();
+ }
+ availableCanvasMetrics = makeList();
+ tableSortOrder = null;
+
+ this.__emitChange();
+ break;
+ }
+ case ActionTypes.CLOSE_WEBSOCKET: {
+ if (!websocketClosed) {
+ websocketClosed = true;
+ this.__emitChange();
+ }
+ break;
+ }
+ case ActionTypes.SELECT_METRIC: {
+ selectedMetric = payload.metricId;
+ this.__emitChange();
+ break;
+ }
+ case ActionTypes.PIN_METRIC: {
+ pinnedMetric = payload.metricId;
+ pinnedMetricType = this.getAvailableCanvasMetricsTypes().get(payload.metricId);
+ selectedMetric = payload.metricId;
+ this.__emitChange();
+ break;
+ }
+ case ActionTypes.UNPIN_METRIC: {
+ pinnedMetric = null;
+ pinnedMetricType = null;
+ this.__emitChange();
+ break;
+ }
+ case ActionTypes.SHOW_HELP: {
+ showingHelp = true;
+ this.__emitChange();
+ break;
+ }
+ case ActionTypes.HIDE_HELP: {
+ showingHelp = false;
+ this.__emitChange();
+ break;
+ }
+ case ActionTypes.DESELECT_NODE: {
+ closeNodeDetails();
+ this.__emitChange();
+ break;
+ }
+ case ActionTypes.DO_CONTROL: {
+ controlStatus = controlStatus.set(payload.nodeId, makeMap({
+ pending: true,
+ error: null
+ }));
+ this.__emitChange();
+ break;
+ }
+ case ActionTypes.ENTER_EDGE: {
+ // clear old highlights
+ highlightedNodeIds = highlightedNodeIds.clear();
+ highlightedEdgeIds = highlightedEdgeIds.clear();
+
+ // highlight edge
+ highlightedEdgeIds = highlightedEdgeIds.add(payload.edgeId);
+
+ // highlight adjacent nodes
+ highlightedNodeIds = highlightedNodeIds.union(payload.edgeId.split(EDGE_ID_SEPARATOR));
+
+ this.__emitChange();
+ break;
+ }
+ case ActionTypes.ENTER_NODE: {
+ const nodeId = payload.nodeId;
+ const adjacentNodes = this.getAdjacentNodes(nodeId);
+
+ // clear old highlights
+ highlightedNodeIds = highlightedNodeIds.clear();
+ highlightedEdgeIds = highlightedEdgeIds.clear();
+
+ // highlight nodes
+ highlightedNodeIds = highlightedNodeIds.add(nodeId);
+ highlightedNodeIds = highlightedNodeIds.union(adjacentNodes);
+
+ // highlight edges
+ if (adjacentNodes.size > 0) {
+ // all neighbour combinations because we dont know which direction exists
+ highlightedEdgeIds = highlightedEdgeIds.union(adjacentNodes.flatMap((adjacentId) => [
+ [adjacentId, nodeId].join(EDGE_ID_SEPARATOR),
+ [nodeId, adjacentId].join(EDGE_ID_SEPARATOR)
+ ]));
+ }
+
+ this.__emitChange();
+ break;
+ }
+ case ActionTypes.LEAVE_EDGE: {
+ highlightedEdgeIds = highlightedEdgeIds.clear();
+ highlightedNodeIds = highlightedNodeIds.clear();
+ this.__emitChange();
+ break;
+ }
+ case ActionTypes.LEAVE_NODE: {
+ highlightedEdgeIds = highlightedEdgeIds.clear();
+ highlightedNodeIds = highlightedNodeIds.clear();
+ this.__emitChange();
+ break;
+ }
+ case ActionTypes.OPEN_WEBSOCKET: {
+ // flush nodes cache after re-connect
+ nodes = nodes.clear();
+ websocketClosed = false;
+
+ this.__emitChange();
+ break;
+ }
+ case ActionTypes.DO_CONTROL_ERROR: {
+ controlStatus = controlStatus.set(payload.nodeId, makeMap({
+ pending: false,
+ error: payload.error
+ }));
+ this.__emitChange();
+ break;
+ }
+ case ActionTypes.DO_CONTROL_SUCCESS: {
+ controlStatus = controlStatus.set(payload.nodeId, makeMap({
+ pending: false,
+ error: null
+ }));
+ this.__emitChange();
+ break;
+ }
+ case ActionTypes.RECEIVE_CONTROL_PIPE: {
+ controlPipes = controlPipes.set(payload.pipeId, makeOrderedMap({
+ id: payload.pipeId,
+ nodeId: payload.nodeId,
+ raw: payload.rawTty
+ }));
+ this.__emitChange();
+ break;
+ }
+ case ActionTypes.RECEIVE_CONTROL_PIPE_STATUS: {
+ if (controlPipes.has(payload.pipeId)) {
+ controlPipes = controlPipes.setIn([payload.pipeId, 'status'], payload.status);
+ this.__emitChange();
+ }
+ break;
+ }
+ case ActionTypes.RECEIVE_ERROR: {
+ if (errorUrl !== null) {
+ errorUrl = payload.errorUrl;
+ this.__emitChange();
+ }
+ break;
+ }
+ case ActionTypes.RECEIVE_NODE_DETAILS: {
+ errorUrl = null;
+
+ // disregard if node is not selected anymore
+ if (nodeDetails.has(payload.details.id)) {
+ nodeDetails = nodeDetails.update(payload.details.id, obj => {
+ const result = Object.assign({}, obj);
+ result.notFound = false;
+ result.details = payload.details;
+ return result;
+ });
+ }
+ this.__emitChange();
+ break;
+ }
+ case ActionTypes.RECEIVE_NODES_DELTA: {
+ const emptyMessage = !payload.delta.add && !payload.delta.remove
+ && !payload.delta.update;
+ // this action is called frequently, good to check if something changed
+ const emitChange = !emptyMessage || errorUrl !== null;
+
+ if (!emptyMessage) {
+ log('RECEIVE_NODES_DELTA',
+ 'remove', _.size(payload.delta.remove),
+ 'update', _.size(payload.delta.update),
+ 'add', _.size(payload.delta.add));
+ }
+
+ errorUrl = null;
+
+ // nodes that no longer exist
+ _.each(payload.delta.remove, (nodeId) => {
+ // in case node disappears before mouseleave event
+ if (mouseOverNodeId === nodeId) {
+ mouseOverNodeId = null;
+ }
+ if (nodes.has(nodeId) && _.includes(mouseOverEdgeId, nodeId)) {
+ mouseOverEdgeId = null;
+ }
+ nodes = nodes.delete(nodeId);
+ });
+
+ // update existing nodes
+ _.each(payload.delta.update, (node) => {
+ if (nodes.has(node.id)) {
+ nodes = nodes.set(node.id, nodes.get(node.id).merge(fromJS(node)));
+ }
+ });
+
+ // add new nodes
+ _.each(payload.delta.add, (node) => {
+ nodes = nodes.set(node.id, fromJS(makeNode(node)));
+ });
+
+ availableCanvasMetrics = nodes
+ .valueSeq()
+ .flatMap(n => (n.get('metrics') || makeList()).map(m => (
+ makeMap({id: m.get('id'), label: m.get('label')})
+ )))
+ .toSet()
+ .toList()
+ .sortBy(m => m.get('label'));
+
+ const similarTypeMetric = availableCanvasMetrics
+ .find(m => m.get('label') === pinnedMetricType);
+ pinnedMetric = similarTypeMetric && similarTypeMetric.get('id');
+ // if something in the current topo is not already selected, select it.
+ if (!availableCanvasMetrics.map(m => m.get('id')).toSet().has(selectedMetric)) {
+ selectedMetric = pinnedMetric;
+ }
+
+ if (emitChange) {
+ this.__emitChange();
+ }
+ break;
+ }
+ case ActionTypes.RECEIVE_NOT_FOUND: {
+ if (nodeDetails.has(payload.nodeId)) {
+ nodeDetails = nodeDetails.update(payload.nodeId, obj => {
+ const result = Object.assign({}, obj);
+ result.notFound = true;
+ return result;
+ });
+ this.__emitChange();
+ }
+ break;
+ }
+ case ActionTypes.RECEIVE_TOPOLOGIES: {
+ errorUrl = null;
+ topologyUrlsById = topologyUrlsById.clear();
+ processTopologies(payload.topologies);
+ setTopology(currentTopologyId);
+ // only set on first load, if options are not already set via route
+ if (!topologiesLoaded && topologyOptions.size === 0) {
+ setDefaultTopologyOptions(topologies);
+ }
+ topologiesLoaded = true;
+
+ this.__emitChange();
+ break;
+ }
+ case ActionTypes.RECEIVE_API_DETAILS: {
+ errorUrl = null;
+ hostname = payload.hostname;
+ version = payload.version;
+ plugins = payload.plugins;
+ versionUpdate = payload.newVersion;
+ this.__emitChange();
+ break;
+ }
+ case ActionTypes.ROUTE_TOPOLOGY: {
+ routeSet = true;
+ if (currentTopologyId !== payload.state.topologyId) {
+ nodes = nodes.clear();
+ }
+ setTopology(payload.state.topologyId);
+ setDefaultTopologyOptions(topologies);
+ selectedNodeId = payload.state.selectedNodeId;
+ pinnedMetricType = payload.state.pinnedMetricType;
+ if (payload.state.controlPipe) {
+ controlPipes = makeOrderedMap({
+ [payload.state.controlPipe.id]:
+ makeOrderedMap(payload.state.controlPipe)
+ });
+ } else {
+ controlPipes = controlPipes.clear();
+ }
+ if (payload.state.nodeDetails) {
+ const payloadNodeDetails = makeOrderedMap(
+ payload.state.nodeDetails.map(obj => [obj.id, obj]));
+ // check if detail IDs have changed
+ if (!isDeepEqual(nodeDetails.keySeq(), payloadNodeDetails.keySeq())) {
+ nodeDetails = payloadNodeDetails;
+ }
+ } else {
+ nodeDetails = nodeDetails.clear();
+ }
+ topologyOptions = fromJS(payload.state.topologyOptions)
+ || topologyOptions;
+ this.__emitChange();
+ break;
+ }
+ default: {
+ break;
+ }
+ }
+ }
+}
+
+export default new AppStore(AppDispatcher);
diff --git a/client/app/styles/main.less b/client/app/styles/main.less
index f1c19adce..8c8393a0a 100644
--- a/client/app/styles/main.less
+++ b/client/app/styles/main.less
@@ -889,7 +889,7 @@ h2 {
font-size: 105%;
line-height: 1.5;
- &:hover {
+ &:hover, &.selected {
background-color: lighten(@background-color, 5%);
}
@@ -1490,3 +1490,57 @@ h2 {
}
}
}
+
+//
+// Examples
+//
+
+.examples {
+ background-color: @background-average-color;
+ .example-chart {
+ position: relative;
+ }
+}
+
+.nodes-chart-examples {
+ .example-chart {
+ margin: 8px;
+ display: inline-block;
+ border: 1px solid steelBlue;
+ }
+}
+
+.nodes-grid {
+ .node-details-table-wrapper-wrapper {
+ flex: 1;
+
+ overflow: scroll;
+ display: flex;
+ flex-direction: row;
+ margin: 8px 16px;
+ width: 100%;
+ // border: 1px solid @background-darker-color;
+ padding-bottom: 36px;
+
+ .node-details-table-wrapper {
+ margin: 0;
+ flex: 1;
+ }
+
+ .nodes-grid-graph {
+ position: relative;
+ margin-top: 24px;
+ }
+
+ .node-details-table-node, thead tr {
+ height: 24px;
+ }
+
+ .node-details-table-node {
+ &:hover, &.selected {
+ background-color: @background-darker-color;
+ }
+ }
+
+ }
+}
diff --git a/client/webpack.local.config.js b/client/webpack.local.config.js
index 4698cb5cb..40d1a96c9 100644
--- a/client/webpack.local.config.js
+++ b/client/webpack.local.config.js
@@ -15,7 +15,13 @@ var HtmlWebpackPlugin = require('html-webpack-plugin');
*/
// Inject websocket url to dev backend
- var WEBPACK_SERVER_HOST = process.env.WEBPACK_SERVER_HOST || 'localhost';
+
+var WEBPACK_SERVER_HOST = process.env.WEBPACK_SERVER_HOST || 'localhost';
+var COMMON_DEPS = [
+ 'webpack-dev-server/client?http://' + WEBPACK_SERVER_HOST + ':4041',
+ 'webpack/hot/only-dev-server',
+ './app/scripts/debug'
+];
module.exports = {
@@ -63,6 +69,11 @@ module.exports = {
new webpack.HotModuleReplacementPlugin(),
new webpack.NoErrorsPlugin(),
new webpack.IgnorePlugin(/^\.\/locale$/, [/moment$/]),
+ new HtmlWebpackPlugin({
+ chunks: ['vendors', 'examples-app'],
+ template: 'app/html/index.html',
+ filename: 'examples.html'
+ }),
new HtmlWebpackPlugin({
chunks: ['vendors', 'contrast-app'],
template: 'app/html/index.html',
@@ -104,7 +115,7 @@ module.exports = {
loader: 'json-loader'
},
{
- test: /\.less$/,
+ test: /(\.css|\.less)$/,
loader: 'style-loader!css-loader!postcss-loader!less-loader'
},
{