Node grid view

More graphs!

Rank is not support by dagre any longer..

Quick go at using facebook's fixed-data-table

Kind of working, kind of interesting.

Hack on the details-panel table, supports sorting etc already!

No, this one! Hacks on the details panel's table.

Hovering on the table works! (highlights nodes)

wip get sorting going

Working on sorting, not behaving!

Pulling out methods to fns

Kind of demoable

More hacks to make it demoable
This commit is contained in:
Simon Howe
2016-04-24 12:39:16 +02:00
parent e7a0a96de2
commit c51e290127
15 changed files with 1357 additions and 165 deletions

View File

@@ -78,6 +78,13 @@ export function unpinNetwork(networkId) {
};
}
export function sortOrderChanged(newOrder) {
AppDispatcher.dispatch({
type: ActionTypes.SORT_ORDER_CHANGED,
newOrder
});
}
//
// Metrics

View File

@@ -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() {

View File

@@ -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 (
<div style={{height, width}} className="nodes-grid-graph">
<NodesChart {...props} />
</div>
);
}
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 (
<div className="nodes-grid">
<NodeDetailsTable
style={cmpStyle}
onMouseOut={this.onMouseOut}
onMouseOverRow={this.onMouseOverRow}
{...detailsData}
highlightedNodeIds={this.props.highlightedNodeIds}
limit={1000}>
<MiniChart {...graphProps} />
</NodeDetailsTable>
</div>
);
}
}

View File

@@ -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

View File

@@ -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 (
<div key={n} className="example-chart" style={style}>
<NodesChart
nodes={nodes}
width={style.width}
height={style.height}
margins={margins}
highlightedNodeIds={makeSet()}
highlightedEdgeIds={makeSet()}
layoutPrecision="3"
topologyId={Math.random()}
noZoom="true"
nodeSize={nodeSize}
/>
</div>
);
}
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 (
<div>
{_.reverse(generators).map(({id, fn}) => (
<div key={id} className="nodes-chart-examples" style={style}>
{_.range(1, nCharts + 1).map(i => (
chart(fn(i), i)
))}
</div>
))}
</div>
);
}
function gridView() {
const nodes = randomGraph(50).map(node => node.remove('label').remove('label_minor'));
const nodeSize = 24;
return (
<NodesGrid width="500" height="500" nodeSize={nodeSize} nodes={nodes} />
);
}
export class Examples extends React.Component {
render() {
return (
<div className="examples">
{gridView()}
{false && variants()}
</div>
);
}
}

View File

@@ -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 (<NodeDetailsTable title={table.title} key={key} rows={table.rows}
isNumeric={table.numeric} />);
}
render() {
if (this.props.notFound) {
return this.renderNotAvailable();

View File

@@ -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 (
<td className="node-details-table-node-value truncate" title={field.value}
key={field.id}>
{field.value}
</td>
);
}
return <NodeDetailsTableNodeMetric key={field.id} {...field} />;
}
// empty cell to complete the row for proper hover
return <td className="node-details-table-node-value" key={id} />;
});
}
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 (
<tr onMouseOver={onMouseOverRow && this.onMouseOver} className={className}>
<td className="node-details-table-node-label truncate">
<NodeDetailsTableNodeLink
topologyId={topologyId}
nodeId={nodeId}
{...node} />
</td>
{values}
</tr>
);
}
}

View File

@@ -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 (
<td className="node-details-table-node-value truncate" title={field.value}
key={field.id}>
{field.value}
</td>
);
}
return <NodeDetailsTableNodeMetric key={field.id} {...field} />;
}
// empty cell to complete the row for proper hover
return <td className="node-details-table-node-value" key={id} />;
});
}
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 (
<div className="node-details-table-wrapper">
<table className="node-details-table">
<thead>
{headers}
</thead>
<tbody>
{nodes && nodes.map(node => {
const values = this.renderValues(node);
const nodeId = node[nodeIdKey];
return (
<tr className="node-details-table-node" key={node.id}>
<td className="node-details-table-node-label truncate">
<NodeDetailsTableNodeLink {...node} topologyId={this.props.topologyId}
nodeId={nodeId} />
</td>
{values}
</tr>
);
})}
</tbody>
</table>
<ShowMore handleClick={this.handleLimitClick} collection={this.props.nodes}
expanded={expanded} notShown={notShown} />
<div className="node-details-table-wrapper-wrapper" style={this.props.style}>
<div className="node-details-table-wrapper" onMouseOut={this.props.onMouseOut}>
<table className="node-details-table">
<thead>
{headers}
</thead>
<tbody>
{nodes && nodes.map(node => (
<NodeDetailsTableRow
key={node.id}
selected={this.props.highlightedNodeIds.has(node.id)}
node={node}
nodeIdKey={nodeIdKey}
columns={columns}
onMouseOverRow={onMouseOverRow}
topologyId={topologyId} />
))}
</tbody>
</table>
<ShowMore
handleClick={this.handleLimitClick}
collection={this.props.nodes}
expanded={expanded}
notShown={notShown} />
</div>
{childrenWithProps}
</div>
);
}

View File

@@ -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} />
</DelayedShow>
{this.renderEmptyTopologyError(topologiesLoaded && nodesLoaded && topologyEmpty)}
<NodesChart {...this.state}
<NodesGrid {...this.state}
nodeSize="24"
width={1300}
height={780}
margins={CANVAS_MARGINS}
detailsWidth={detailsWidth}
layoutPrecision={layoutPrecision}
hasSelectedNode={hasSelectedNode}

View File

@@ -55,6 +55,7 @@ const ACTION_TYPES = [
'UNPIN_NETWORK',
'SHOW_NETWORKS',
'SET_RECEIVED_NODES_DELTA',
'SORT_ORDER_CHANGED'
];
export default _.zipObject(ACTION_TYPES, ACTION_TYPES);

View File

@@ -10,3 +10,10 @@ export const DETAILS_PANEL_MARGINS = {
export const DETAILS_PANEL_OFFSET = 8;
export const CANVAS_METRIC_FONT_SIZE = 0.19;
export const CANVAS_MARGINS = {
top: 130,
left: 40,
right: 40,
bottom: 100,
};

View File

@@ -0,0 +1,10 @@
require('../styles/main.less');
require('../../node_modules/fixed-data-table/dist/fixed-data-table.css');
require('../images/favicon.ico');
import React from 'react';
import ReactDOM from 'react-dom';
import { Examples } from './components/examples.js';
ReactDOM.render(<Examples />, document.getElementById('app'));

View File

@@ -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);

View File

@@ -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;
}
}
}
}

View File

@@ -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'
},
{