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