From c51e29012740b9842c07887d236d4e53539d9b87 Mon Sep 17 00:00:00 2001 From: Simon Howe Date: Sun, 24 Apr 2016 12:39:16 +0200 Subject: [PATCH 01/10] 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 --- client/app/scripts/actions/app-actions.js | 7 + client/app/scripts/charts/nodes-chart.js | 72 +- client/app/scripts/charts/nodes-grid.js | 86 ++ client/app/scripts/charts/nodes-layout.js | 49 +- client/app/scripts/components/examples.js | 150 ++++ client/app/scripts/components/node-details.js | 7 - .../node-details/node-details-table-row.js | 72 ++ .../node-details/node-details-table.js | 231 +++--- client/app/scripts/components/nodes.js | 10 +- client/app/scripts/constants/action-types.js | 1 + client/app/scripts/constants/styles.js | 7 + client/app/scripts/examples-main.js | 10 + client/app/scripts/stores/app-store.js | 749 ++++++++++++++++++ client/app/styles/main.less | 56 +- client/webpack.local.config.js | 15 +- 15 files changed, 1357 insertions(+), 165 deletions(-) create mode 100644 client/app/scripts/charts/nodes-grid.js create mode 100644 client/app/scripts/components/examples.js create mode 100644 client/app/scripts/components/node-details/node-details-table-row.js create mode 100644 client/app/scripts/examples-main.js create mode 100644 client/app/scripts/stores/app-store.js 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' }, { From fa502ae6adbb3a656754cbca8f34c881dcc64f6a Mon Sep 17 00:00:00 2001 From: Simon Howe Date: Wed, 4 May 2016 14:58:36 +0200 Subject: [PATCH 02/10] Removes the combined table-topo-view --- client/app/scripts/actions/app-actions.js | 24 ++++++++++---- client/app/scripts/charts/nodes-chart.js | 23 +++++++------ client/app/scripts/charts/nodes-grid.js | 27 ++-------------- client/app/scripts/charts/nodes-layout.js | 21 ++++++------ client/app/scripts/components/app.js | 2 ++ .../app/scripts/components/debug-toolbar.js | 2 +- .../node-details/node-details-table.js | 7 ++-- client/app/scripts/components/nodes.js | 32 ++++++++++--------- client/app/scripts/constants/action-types.js | 3 +- client/app/scripts/reducers/root.js | 6 ++++ client/app/scripts/utils/router-utils.js | 1 + client/app/styles/main.less | 3 +- 12 files changed, 72 insertions(+), 79 deletions(-) diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index 1b799642e..e1ec2eca5 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -18,10 +18,12 @@ export function showHelp() { return {type: ActionTypes.SHOW_HELP}; } + export function hideHelp() { return {type: ActionTypes.HIDE_HELP}; } + export function toggleHelp() { return (dispatch, getState) => { if (getState().get('showingHelp')) { @@ -32,6 +34,20 @@ export function toggleHelp() { }; } + +export function toggleGridMode(enabled) { + return {type: ActionTypes.SET_GRID_MODE, enabled}; +} + + +export function sortOrderChanged(newOrder) { + return { + type: ActionTypes.SORT_ORDER_CHANGED, + newOrder + }; +} + + // // Networks // @@ -78,18 +94,12 @@ export function unpinNetwork(networkId) { }; } -export function sortOrderChanged(newOrder) { - AppDispatcher.dispatch({ - type: ActionTypes.SORT_ORDER_CHANGED, - newOrder - }); -} - // // Metrics // + export function selectMetric(metricId) { return { type: ActionTypes.SELECT_METRIC, diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index 41a70a047..c248cf972 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -5,7 +5,6 @@ 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'; @@ -13,8 +12,7 @@ import { DETAILS_PANEL_WIDTH } from '../constants/styles'; import Logo from '../components/logo'; import { doLayout } from './nodes-layout'; import NodesChartElements from './nodes-chart-elements'; -import { getActiveTopologyOptions, getAdjacentNodes, - isSameTopology } from '../utils/topology-utils'; +import { getActiveTopologyOptions, getAdjacentNodes } from '../utils/topology-utils'; const log = debug('scope:nodes-chart'); @@ -62,7 +60,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 && this.zoom) { + if (this.zoom && nextZoom) { this.zoom.scale(nextZoom.scale); this.zoom.translate([nextZoom.panTranslateX, nextZoom.panTranslateY]); } @@ -80,13 +78,15 @@ class NodesChart extends React.Component { } // reset layout dimensions only when forced - state.height = nextProps.height; - state.width = nextProps.width; + // state.height = nextProps.height; + // state.width = nextProps.width; + state.height = nextProps.forceRelayout ? nextProps.height : (state.height || nextProps.height); + state.width = nextProps.forceRelayout ? nextProps.width : (state.width || nextProps.width); - _.assign(state, this.updateGraphState(nextProps, state)); - // if (nextProps.forceRelayout || 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)); @@ -326,7 +326,6 @@ 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')) @@ -343,7 +342,6 @@ class NodesChart extends React.Component { nodeOrder }; - console.log('nodes-chart', state.height); const timedLayouter = timely(doLayout); const graph = timedLayouter(stateNodes, stateEdges, options); @@ -370,6 +368,7 @@ class NodesChart extends React.Component { if (!this.props.noZoom && !state.hasZoomed && zoomFactor > 0 && zoomFactor < 1) { zoomScale = zoomFactor; + console.log(zoomScale); // saving in d3's behavior cache if (this.zoom) { this.zoom.scale(zoomFactor); diff --git a/client/app/scripts/charts/nodes-grid.js b/client/app/scripts/charts/nodes-grid.js index 771f74407..91b20458f 100644 --- a/client/app/scripts/charts/nodes-grid.js +++ b/client/app/scripts/charts/nodes-grid.js @@ -2,21 +2,10 @@ 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']; @@ -43,17 +32,7 @@ export default class NodesGrid extends React.Component { } 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 { margins, nodes, height } = this.props; const cmpStyle = { height, paddingTop: margins.top, @@ -77,9 +56,7 @@ export default class NodesGrid extends React.Component { onMouseOverRow={this.onMouseOverRow} {...detailsData} highlightedNodeIds={this.props.highlightedNodeIds} - limit={1000}> - - + limit={1000} />
); } diff --git a/client/app/scripts/charts/nodes-layout.js b/client/app/scripts/charts/nodes-layout.js index 2a9aaff72..0c0392466 100644 --- a/client/app/scripts/charts/nodes-layout.js +++ b/client/app/scripts/charts/nodes-layout.js @@ -51,8 +51,8 @@ function runLayoutEngine(graph, imNodes, imEdges, opts) { graph.setGraph({ nodesep, ranksep, - rankdir: 'LR', - align: 'UL' + // rankdir: 'LR', + // align: 'UL' }); // add nodes to the graph if not already there @@ -182,16 +182,14 @@ function layoutSingleNodes(layout, opts) { const rows = Math.ceil(singleNodes.size / columns); let row = 0; let col = 0; - let singleX; - let singleY; nodes = nodes.sortBy(node => node.get('rank')).map(node => { if (singleNodes.has(node.get('id'))) { if (col === columns) { col = 0; row++; } - singleX = col * (nodesep + nodeWidth) + offsetX; - singleY = row * (ranksep + nodeHeight) + offsetY; + const singleX = col * (nodesep + nodeWidth) + offsetX; + const singleY = row * (ranksep + nodeHeight) + offsetY; col++; return node.merge({ x: singleX, @@ -201,8 +199,6 @@ function layoutSingleNodes(layout, opts) { return node; }); - console.log(singleX, singleY); - // adjust layout dimensions if graph is now bigger result.width = Math.max(layout.width, columns * nodeWidth + (columns - 1) * nodesep); result.height = Math.max(layout.height, rows * nodeHeight + (rows - 1) * ranksep); @@ -250,6 +246,7 @@ function shiftLayoutToCenter(layout, opts) { return result; } + /** * Adds `points` array to edge based on location of source and target * @param {Map} edge new edge @@ -266,7 +263,7 @@ function setSimpleEdgePoints(edge, nodeCache) { } -function uniqueRowConstraint(layout, options) { +export function uniqueRowConstraint(layout, options) { const result = Object.assign({}, layout); const scale = options.scale || DEFAULT_SCALE; const nodeHeight = scale(NODE_SIZE_FACTOR); @@ -285,7 +282,6 @@ function uniqueRowConstraint(layout, options) { .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 @@ -395,7 +391,8 @@ export function doLayout(immNodes, immEdges, opts) { let layout; ++layoutRuns; - if (false && !options.forceRelayout && cachedLayout && nodeCache && edgeCache + // if (false && !options.forceRelayout && cachedLayout && nodeCache && edgeCache + if (!options.forceRelayout && cachedLayout && nodeCache && edgeCache && !hasUnseenNodes(immNodes, nodeCache)) { log('skip layout, trivial adjustment', ++layoutRunsTrivial, layoutRuns); layout = cloneLayout(cachedLayout, immNodes, immEdges); @@ -410,7 +407,7 @@ export function doLayout(immNodes, immEdges, opts) { } layout = layoutSingleNodes(layout, opts); layout = shiftLayoutToCenter(layout, opts); - layout = uniqueRowConstraint(layout, opts); + // layout = uniqueRowConstraint(layout, opts); } // cache results diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index 29f77267e..0d3d01306 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -15,6 +15,7 @@ import { focusSearch, pinNextMetric, hitBackspace, hitEnter, hitEsc, unpinMetric selectMetric, toggleHelp } from '../actions/app-actions'; import Details from './details'; import Nodes from './nodes'; +import GridModeSelector from './grid-mode-selector'; import MetricSelector from './metric-selector'; import NetworkSelector from './networks-selector'; import EmbeddedTerminal from './embedded-terminal'; @@ -127,6 +128,7 @@ class App extends React.Component { + {showingMetricsSelector && } {showingNetworkSelector && } diff --git a/client/app/scripts/components/debug-toolbar.js b/client/app/scripts/components/debug-toolbar.js index ae61fff59..8d2a4e703 100644 --- a/client/app/scripts/components/debug-toolbar.js +++ b/client/app/scripts/components/debug-toolbar.js @@ -184,7 +184,7 @@ class DebugToolbar extends React.Component { addNodes(n, prefix = 'zing') { const ns = this.props.nodes; const nodeNames = ns.keySeq().toJS(); - const newNodeNames = _.range(ns.size, ns.size + n).map(i => ( + const newNodeNames = _.range(nodeNames.length, nodeNames.length + n).map(i => ( // `${randomLetter()}${randomLetter()}-zing` `${prefix}${i}` )); 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 61c4a923f..74170199f 100644 --- a/client/app/scripts/components/node-details/node-details-table.js +++ b/client/app/scripts/components/node-details/node-details-table.js @@ -4,7 +4,6 @@ import { Map as makeMap } from 'immutable'; import ShowMore from '../show-more'; import NodeDetailsTableRow from './node-details-table-row'; -import { sortOrderChanged } from '../../actions/app-actions'; function isNumberField(field) { @@ -95,7 +94,6 @@ export default class NodeDetailsTable extends React.Component { ? !this.state.sortedDesc : this.state.sortedDesc; const sortBy = headerId; this.setState({sortBy, sortedDesc}); - sortOrderChanged({sortBy, sortedDesc}); } handleLimitClick() { @@ -185,8 +183,6 @@ export default class NodeDetailsTable extends React.Component { React.cloneElement(child, { nodeOrder }) )); - console.log(this.props.selectedRowId); - return (
@@ -198,7 +194,8 @@ export default class NodeDetailsTable extends React.Component { {nodes && nodes.map(node => ( @@ -82,15 +80,18 @@ class Nodes extends React.Component { show={topologiesLoaded && !nodesLoaded} /> {this.renderEmptyTopologyError(topologiesLoaded && nodesLoaded && topologyEmpty)} - + {this.props.gridMode ? + : + }
); } @@ -109,13 +110,14 @@ class Nodes extends React.Component { function mapStateToProps(state) { return { + gridMode: state.get('gridMode'), nodes: state.get('nodes'), nodesLoaded: state.get('nodesLoaded'), - selectedNodeId: state.get('selectedNodeId'), topologies: state.get('topologies'), topologiesLoaded: state.get('topologiesLoaded'), topologyEmpty: isTopologyEmpty(state), topology: state.get('currentTopology'), + highlightedNodeIds: state.get('highlightedNodeIds') }; } diff --git a/client/app/scripts/constants/action-types.js b/client/app/scripts/constants/action-types.js index 9245d42eb..4e9ab8c17 100644 --- a/client/app/scripts/constants/action-types.js +++ b/client/app/scripts/constants/action-types.js @@ -55,7 +55,8 @@ const ACTION_TYPES = [ 'UNPIN_NETWORK', 'SHOW_NETWORKS', 'SET_RECEIVED_NODES_DELTA', - 'SORT_ORDER_CHANGED' + 'SORT_ORDER_CHANGED', + 'SET_GRID_MODE', ]; export default _.zipObject(ACTION_TYPES, ACTION_TYPES); diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index 893247774..e2ad25ea0 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -28,6 +28,7 @@ export const initialState = makeMap({ currentTopologyId: 'containers', errorUrl: null, forceRelayout: false, + gridMode: false, highlightedEdgeIds: makeSet(), highlightedNodeIds: makeSet(), hostname: '...', @@ -166,6 +167,10 @@ export function rootReducer(state = initialState, action) { return state.set('exportingGraph', action.exporting); } + case ActionTypes.SET_GRID_MODE: { + return state.setIn(['gridMode'], action.enabled); + } + case ActionTypes.CLEAR_CONTROL_ERROR: { return state.removeIn(['controlStatus', action.nodeId, 'error']); } @@ -625,6 +630,7 @@ export function rootReducer(state = initialState, action) { selectedNodeId: action.state.selectedNodeId, pinnedMetricType: action.state.pinnedMetricType }); + state = state.set('gridMode', action.state.mode === 'grid'); if (action.state.showingNetworks) { state = state.set('showingNetworks', action.state.showingNetworks); } diff --git a/client/app/scripts/utils/router-utils.js b/client/app/scripts/utils/router-utils.js index 0aba5a157..7ed77ee20 100644 --- a/client/app/scripts/utils/router-utils.js +++ b/client/app/scripts/utils/router-utils.js @@ -39,6 +39,7 @@ export function getUrlState(state) { const urlState = { controlPipe: cp ? cp.toJS() : null, + mode: state.get('gridMode') ? 'grid' : 'topo', nodeDetails: nodeDetails.toJS(), pinnedMetricType: state.get('pinnedMetricType'), pinnedSearches: state.get('pinnedSearches').toJS(), diff --git a/client/app/styles/main.less b/client/app/styles/main.less index 8c8393a0a..f2a963f1d 100644 --- a/client/app/styles/main.less +++ b/client/app/styles/main.less @@ -269,10 +269,11 @@ h2 { cursor: pointer; padding: 4px 8px; border-radius: @border-radius; - opacity: 0.8; + opacity: 0.9; margin-bottom: 3px; border: 1px solid transparent; + background-color: #f7f7fa; &-active, &:hover { color: @text-color; background-color: @background-darker-secondary-color; From d0b99969ea8ddb59005e4575f3262500740214e4 Mon Sep 17 00:00:00 2001 From: Simon Howe Date: Thu, 14 Jul 2016 12:50:47 +0200 Subject: [PATCH 03/10] Grid-mode tuning! - Change scrolling behaviour to lock headers in place - Enable filtering (hitting enter in the search bar) in grid-mode - Little more top-margin for k8s (can have 3 topos) + taller rows. - Trying out rank-color + node.relatives in the grid-mode - First pass at selecting rows. - Needs a bit more a fiddle, colors + click areas - Store grid sort direction (asc/desc) in url state - Simplify node selection to one method. (over-ride existing card) - Remove clicking on name directly (links) to overlay new cards for now. - Playing w/ grid-mode-toggle icons and labels - Improves rendering in ff, change of shortcut keys for grid-mode-toggle - Playing w/ clearer selection colors for grid-mode - Slight change to selection-ui - Fixes showNodeInTopology button visibility on the details-panel - Was using an old heuristic. Table-mode allows you to open child cards before the parent. - Make it clear what the default sort is in tables - E.g. always show a sorting caret - Sort grid-mode columns, first meta then metrics - dancing-nodes rememdy #1: pause updates onRowHover - Splits relatives out into their own columns - Take into account scrollbar width for grid-mode col header position - Tooltips on table column headers - grid-mode: fixes first column headers (proc/container/c-by-image) - Disable pause-on-hover, too aggresive - reduce label column width a bit (33pc -> 25pc) for big tables - Filter grid-mode onSearchChange - Rather than previous behaviour of waiting for an - Show label_minor on pseudo nodes, that might not have much other info - grid-mode: further reduce width of id column. - Fixes go tests, properly moves parents into node-summary - Fixes sorting of string columns w/ missing fields. - E.g. uptime. Where -1e-10 > '3days' doesn't work. --- client/app/scripts/actions/app-actions.js | 19 +- client/app/scripts/charts/nodes-chart.js | 2 - client/app/scripts/charts/nodes-grid.js | 146 +++- client/app/scripts/components/app.js | 17 +- .../scripts/components/grid-mode-selector.js | 61 ++ client/app/scripts/components/help-panel.js | 2 + client/app/scripts/components/node-details.js | 5 +- .../node-details-table-node-link.js | 4 +- .../node-details-table-node-metric.js | 2 +- .../node-details/node-details-table-row.js | 71 +- .../node-details/node-details-table.js | 129 ++- client/app/scripts/components/nodes.js | 27 +- client/app/scripts/components/sidebar.js | 5 +- client/app/scripts/constants/styles.js | 2 +- client/app/scripts/reducers/root.js | 20 +- client/app/scripts/stores/app-store.js | 749 ------------------ client/app/scripts/utils/router-utils.js | 2 + client/app/scripts/utils/topology-utils.js | 14 + .../app/scripts/utils/update-buffer-utils.js | 2 +- client/app/styles/main.less | 125 ++- render/detailed/node.go | 2 - render/detailed/node_test.go | 58 +- render/detailed/summary.go | 2 + 23 files changed, 563 insertions(+), 903 deletions(-) create mode 100644 client/app/scripts/components/grid-mode-selector.js delete mode 100644 client/app/scripts/stores/app-store.js diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index e1ec2eca5..88b3b7b8d 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -36,14 +36,23 @@ export function toggleHelp() { export function toggleGridMode(enabled) { - return {type: ActionTypes.SET_GRID_MODE, enabled}; + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.SET_GRID_MODE, + enabled + }); + updateRoute(getState); + }; } -export function sortOrderChanged(newOrder) { - return { - type: ActionTypes.SORT_ORDER_CHANGED, - newOrder +export function sortOrderChanged(sortBy, sortedDesc) { + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.SORT_ORDER_CHANGED, + sortBy, sortedDesc + }); + updateRoute(getState); }; } diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index c248cf972..3ce0c85f3 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -368,7 +368,6 @@ class NodesChart extends React.Component { if (!this.props.noZoom && !state.hasZoomed && zoomFactor > 0 && zoomFactor < 1) { zoomScale = zoomFactor; - console.log(zoomScale); // saving in d3's behavior cache if (this.zoom) { this.zoom.scale(zoomFactor); @@ -413,7 +412,6 @@ function mapStateToProps(state) { return { adjacentNodes: getAdjacentNodes(state), forceRelayout: state.get('forceRelayout'), - nodes: state.get('nodes').filter(node => !node.get('filtered')), selectedNodeId: state.get('selectedNodeId'), topologyId: state.get('currentTopologyId'), topologyOptions: getActiveTopologyOptions(state) diff --git a/client/app/scripts/charts/nodes-grid.js b/client/app/scripts/charts/nodes-grid.js index 91b20458f..1bfcf72e4 100644 --- a/client/app/scripts/charts/nodes-grid.js +++ b/client/app/scripts/charts/nodes-grid.js @@ -1,50 +1,137 @@ /* 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 { connect } from 'react-redux'; +import { List as makeList, Map as makeMap } from 'immutable'; import NodeDetailsTable from '../components/node-details/node-details-table'; -import { enterNode, leaveNode } from '../actions/app-actions'; +import { clickNode, sortOrderChanged, clickPauseUpdate, + clickResumeUpdate } from '../actions/app-actions'; + +import { getNodeColor } from '../utils/color-utils'; -const IGNORED_COLUMNS = ['docker_container_ports']; +const IGNORED_COLUMNS = ['docker_container_ports', 'docker_container_id', 'docker_image_id', + 'docker_container_command', 'docker_container_networks']; 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(); + const metricColumns = nodes + .toList() + .flatMap(n => { + const metrics = (n.get('metrics') || makeList()) + .map(m => makeMap({ id: m.get('id'), label: m.get('label') })); + return metrics; + }) + .toSet() + .toList() + .sortBy(m => m.get('label')); + + const metadataColumns = nodes + .toList() + .flatMap(n => { + const metadata = (n.get('metadata') || makeList()) + .map(m => makeMap({ id: m.get('id'), label: m.get('label') })); + return metadata; + }) + .toSet() + .filter(n => !IGNORED_COLUMNS.includes(n.get('id'))) + .toList() + .sortBy(m => m.get('label')); + + const relativesColumns = nodes + .toList() + .flatMap(n => { + const metadata = (n.get('parents') || makeList()) + .map(m => makeMap({ id: m.get('topologyId'), label: m.get('topologyId') })); + return metadata; + }) + .toSet() + .toList() + .sortBy(m => m.get('label')); + + return relativesColumns.concat(metadataColumns.concat(metricColumns)).toJS(); } -export default class NodesGrid extends React.Component { +function renderIdCell(props, onClick) { + const style = { + width: 16, + flex: 'none', + color: getNodeColor(props.rank, props.label_major) + }; - onMouseOverRow(node) { - enterNode(node.id); + return ( +
+
+
+
+ {props.label} {props.pseudo && + {props.label_minor}} +
+
+
+ ); +} + + +class NodesGrid extends React.Component { + + constructor(props, context) { + super(props, context); + + this.renderIdCell = this.renderIdCell.bind(this); + this.clickRow = this.clickRow.bind(this); + + this.onSortChange = this.onSortChange.bind(this); + this.onMouseEnterRow = this.onMouseEnterRow.bind(this); + this.onMouseLeaveRow = this.onMouseLeaveRow.bind(this); } - onMouseOut() { - leaveNode(); + clickRow(ev, nodeId, nodeLabel) { + if (ev.target.className === 'node-details-relatives-link') { + return; + } + this.props.clickNode(nodeId, nodeLabel); + } + + renderIdCell(props) { + return renderIdCell(props, (ev) => this.clickRow(ev, props.id, props.label)); + } + + onMouseEnterRow() { + this.props.clickPauseUpdate(); + } + + onMouseLeaveRow() { + this.props.clickResumeUpdate(); + } + + onSortChange(sortBy, sortedDesc) { + this.props.sortOrderChanged(sortBy, sortedDesc); } render() { - const { margins, nodes, height } = this.props; + const { margins, nodes, height, gridSortBy, gridSortedDesc, + searchNodeMatches = makeMap(), searchQuery } = this.props; const cmpStyle = { height, - paddingTop: margins.top, - paddingBottom: margins.bottom, + marginTop: margins.top, paddingLeft: margins.left, paddingRight: margins.right, }; + const tbodyHeight = height - 24 - 18; + const className = 'scroll-body'; + const tbodyStyle = { + height: `${tbodyHeight}px`, + }; const detailsData = { - label: 'procs', + label: this.props.topology && this.props.topology.get('fullName'), id: '', - nodes: nodes.toList().toJS(), + nodes: nodes + .toList() + .filter(n => !searchQuery || searchNodeMatches.has(n.get('id'))) + .toJS(), columns: getColumns(nodes) }; @@ -52,12 +139,23 @@ export default class NodesGrid extends React.Component {
); } } + + +export default connect( + () => ({}), + { clickNode, sortOrderChanged, clickPauseUpdate, clickResumeUpdate } +)(NodesGrid); diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index 0d3d01306..0c22392e3 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -12,7 +12,7 @@ import Topologies from './topologies.js'; import TopologyOptions from './topology-options.js'; import { getApiDetails, getTopologies } from '../utils/web-api-utils'; import { focusSearch, pinNextMetric, hitBackspace, hitEnter, hitEsc, unpinMetric, - selectMetric, toggleHelp } from '../actions/app-actions'; + selectMetric, toggleHelp, toggleGridMode } from '../actions/app-actions'; import Details from './details'; import Nodes from './nodes'; import GridModeSelector from './grid-mode-selector'; @@ -87,6 +87,10 @@ class App extends React.Component { dispatch(pinNextMetric(-1)); } else if (char === '>') { dispatch(pinNextMetric(1)); + } else if (char === 'v') { + dispatch(toggleGridMode(false)); + } else if (char === 't') { + dispatch(toggleGridMode(true)); } else if (char === 'q') { dispatch(unpinMetric()); dispatch(selectMetric(null)); @@ -100,8 +104,8 @@ class App extends React.Component { } render() { - const { showingDetails, showingHelp, showingMetricsSelector, showingNetworkSelector, - showingTerminal } = this.props; + const { gridMode, showingDetails, showingHelp, showingMetricsSelector, + showingNetworkSelector, showingTerminal } = this.props; const isIframe = window !== window.top; return ( @@ -126,11 +130,11 @@ class App extends React.Component { - + + {showingMetricsSelector && !gridMode && } + {showingNetworkSelector && !gridMode && } - {showingMetricsSelector && } - {showingNetworkSelector && } @@ -143,6 +147,7 @@ class App extends React.Component { function mapStateToProps(state) { return { activeTopologyOptions: getActiveTopologyOptions(state), + gridMode: state.get('gridMode'), routeSet: state.get('routeSet'), searchFocused: state.get('searchFocused'), searchQuery: state.get('searchQuery'), diff --git a/client/app/scripts/components/grid-mode-selector.js b/client/app/scripts/components/grid-mode-selector.js new file mode 100644 index 000000000..4bdc1723d --- /dev/null +++ b/client/app/scripts/components/grid-mode-selector.js @@ -0,0 +1,61 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import classNames from 'classnames'; + +import { toggleGridMode } from '../actions/app-actions'; + +class GridModeSelector extends React.Component { + + constructor(props, context) { + super(props, context); + + this.enableGridMode = this.enableGridMode.bind(this); + this.disableGridMode = this.disableGridMode.bind(this); + } + + enableGridMode() { + return this.props.toggleGridMode(true); + } + + disableGridMode() { + return this.props.toggleGridMode(false); + } + + renderItem(icons, label, isSelected, onClick) { + const className = classNames('grid-mode-selector-action', { + 'grid-mode-selector-action-selected': isSelected + }); + return ( +
+ + {label} +
+ ); + } + + render() { + const { gridMode } = this.props; + + return ( +
+
+ {this.renderItem('fa fa-share-alt', 'Visualization', !gridMode, this.disableGridMode)} + {this.renderItem('fa fa-table', 'Table', gridMode, this.enableGridMode)} +
+
+ ); + } +} + +function mapStateToProps(state) { + return { + gridMode: state.get('gridMode'), + }; +} + +export default connect( + mapStateToProps, + { toggleGridMode } +)(GridModeSelector); diff --git a/client/app/scripts/components/help-panel.js b/client/app/scripts/components/help-panel.js index 9df27eb92..417127064 100644 --- a/client/app/scripts/components/help-panel.js +++ b/client/app/scripts/components/help-panel.js @@ -4,6 +4,8 @@ const GENERAL_SHORTCUTS = [ {key: 'esc', label: 'Close active panel'}, {key: '/', label: 'Activate search field'}, {key: '?', label: 'Toggle shortcut menu'}, + {key: 't', label: 'Activate Table mode'}, + {key: 'v', label: 'Activate Visualization mode'}, ]; const CANVAS_METRIC_SHORTCUTS = [ diff --git a/client/app/scripts/components/node-details.js b/client/app/scripts/components/node-details.js index da0a58a70..7278e3a58 100644 --- a/client/app/scripts/components/node-details.js +++ b/client/app/scripts/components/node-details.js @@ -47,7 +47,7 @@ export class NodeDetails extends React.Component { } renderTools() { - const showSwitchTopology = this.props.index > 0; + const showSwitchTopology = this.props.nodeId !== this.props.selectedNodeId; const topologyTitle = `View ${this.props.label} in ${this.props.topologyId}`; return ( @@ -229,7 +229,8 @@ function mapStateToProps(state, ownProps) { const currentTopologyId = state.get('currentTopologyId'); return { nodeMatches: state.getIn(['searchNodeMatches', currentTopologyId, ownProps.id]), - nodes: state.get('nodes') + nodes: state.get('nodes'), + selectedNodeId: state.get('selectedNodeId'), }; } diff --git a/client/app/scripts/components/node-details/node-details-table-node-link.js b/client/app/scripts/components/node-details/node-details-table-node-link.js index 3c54af6ab..f821f0f11 100644 --- a/client/app/scripts/components/node-details/node-details-table-node-link.js +++ b/client/app/scripts/components/node-details/node-details-table-node-link.js @@ -20,14 +20,14 @@ class NodeDetailsTableNodeLink extends React.Component { render() { if (this.props.linkable) { return ( - {this.props.label} ); } return ( - + {this.props.label} ); diff --git a/client/app/scripts/components/node-details/node-details-table-node-metric.js b/client/app/scripts/components/node-details/node-details-table-node-metric.js index 4b1ec5261..b0dc2b79d 100644 --- a/client/app/scripts/components/node-details/node-details-table-node-metric.js +++ b/client/app/scripts/components/node-details/node-details-table-node-metric.js @@ -4,7 +4,7 @@ import { formatMetric } from '../../utils/string-utils'; function NodeDetailsTableNodeMetric(props) { return ( - + {formatMetric(props.value, props)} ); 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 index c7a26c138..3e1eb47c2 100644 --- a/client/app/scripts/components/node-details/node-details-table-row.js +++ b/client/app/scripts/components/node-details/node-details-table-row.js @@ -16,57 +16,94 @@ function getValuesForNode(node) { }); } }); + + (node.parents || []).forEach(p => { + values[p.topologyId] = { + id: p.topologyId, + label: p.topologyId, + value: p.label, + relative: p, + valueType: 'relatives', + }; + }); + return values; } -function renderValues(node, columns = []) { +function renderValues(node, columns = [], columnWidths = []) { const fields = getValuesForNode(node); - return columns.map(({id}) => { + return columns.map(({id}, i) => { const field = fields[id]; + const style = { width: columnWidths[i] }; if (field) { if (field.valueType === 'metadata') { return ( {field.value} ); } - return ; + if (field.valueType === 'relatives') { + return ( + + {} + + ); + } + return ; } // empty cell to complete the row for proper hover - return ; + return ; }); } -export default class NodeDetailsTableRow extends React.Component { +export default class NodeDetailsTableRow extends React.Component { constructor(props, context) { super(props, context); - this.onMouseOver = this.onMouseOver.bind(this); + this.onMouseEnter = this.onMouseEnter.bind(this); + this.onMouseLeave = this.onMouseLeave.bind(this); } - onMouseOver() { - const { node, onMouseOverRow } = this.props; - onMouseOverRow(node); + onMouseEnter() { + const { node, onMouseEnterRow } = this.props; + onMouseEnterRow(node); + } + + onMouseLeave() { + const { node, onMouseLeaveRow } = this.props; + onMouseLeaveRow(node); } render() { - const { node, nodeIdKey, topologyId, columns, onMouseOverRow, selected } = this.props; - const values = renderValues(node, columns); + const { node, nodeIdKey, topologyId, columns, onMouseEnterRow, onMouseLeaveRow, selected, + widths } = this.props; + const [firstColumnWidth, ...columnWidths] = widths; + const values = renderValues(node, columns, columnWidths); const nodeId = node[nodeIdKey]; const className = classNames('node-details-table-node', { selected }); + return ( - - - + + + {this.props.renderIdCell(Object.assign(node, {topologyId, nodeId}))} {values} ); } } + + +NodeDetailsTableRow.defaultProps = { + renderIdCell: (props) => +}; 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 74170199f..75ec09ff7 100644 --- a/client/app/scripts/components/node-details/node-details-table.js +++ b/client/app/scripts/components/node-details/node-details-table.js @@ -1,6 +1,7 @@ import _ from 'lodash'; import React from 'react'; import { Map as makeMap } from 'immutable'; +import classNames from 'classnames'; import ShowMore from '../show-more'; import NodeDetailsTableRow from './node-details-table-row'; @@ -13,9 +14,23 @@ function isNumberField(field) { const COLUMN_WIDTHS = { port: '44px', - count: '70px' + count: '70px', + process_cpu_usage_percent: '80px', + threads: '80px', + process_memory_usage_bytes: '80px', + docker_cpu_total_usage: '80px', + docker_memory_usage: '80px', + docker_container_uptime: '85px', + docker_container_restart_count: '80px', + docker_container_ips: '80px', + docker_container_created: '110px', + docker_container_state_human: '120px', + open_files_count: '80px', + ppid: '80px', + pid: '80px', }; + function getDefaultSortBy(columns, nodes) { // default sorter specified by columns const defaultSortColumn = _.find(columns, {defaultSort: true}); @@ -31,7 +46,15 @@ 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); + let field = _.union(node.metrics, node.metadata).find(f => f.id === sortBy); + + if (!field && node.parents) { + field = node.parents.find(f => f.topologyId === sortBy); + if (field) { + return field.label; + } + } + if (field) { if (isNumberField(field)) { return parseFloat(field.value); @@ -39,7 +62,8 @@ function getValueForSortBy(sortBy) { return field.value; } } - return -1e-10; // just under 0 to treat missing values differently from 0 + + return ''; }; } @@ -75,6 +99,33 @@ function getSortedNodes(nodes, columns, sortBy, sortedDesc) { } +function getColumnsWidths(headers) { + return headers.map((h, i) => { + // + // 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 + // + if (i === 0) { + if (headers.length === 2) { + return '66%'; + } else if (headers.length === 3) { + return '50%'; + } else if (headers.length > 3 && headers.length <= 5) { + return '33%'; + } else if (headers.length > 5) { + return '20%'; + } + } + + // + // More beauty hacking, ports and counts can only get so big, free up WS for other longer + // fields like IPs! + // + return COLUMN_WIDTHS[h.id]; + }); +} + + export default class NodeDetailsTable extends React.Component { constructor(props, context) { @@ -82,8 +133,8 @@ export default class NodeDetailsTable extends React.Component { this.DEFAULT_LIMIT = 5; this.state = { limit: props.limit || this.DEFAULT_LIMIT, - sortedDesc: true, - sortBy: null + sortedDesc: this.props.sortedDesc, + sortBy: this.props.sortBy }; this.handleLimitClick = this.handleLimitClick.bind(this); } @@ -94,6 +145,7 @@ export default class NodeDetailsTable extends React.Component { ? !this.state.sortedDesc : this.state.sortedDesc; const sortBy = headerId; this.setState({sortBy, sortedDesc}); + this.props.onSortChange(sortBy, sortedDesc); } handleLimitClick() { @@ -101,56 +153,42 @@ export default class NodeDetailsTable extends React.Component { this.setState({limit}); } + getColumnHeaders() { + const columns = this.props.columns || []; + return [{id: 'label', label: this.props.label}].concat(columns); + } + 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 = 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 - if (headers.length === 2) { - headers[0].width = '66%'; - } else if (headers.length === 3) { - headers[0].width = '50%'; - } else if (headers.length >= 3 && headers.length < 5) { - headers[0].width = '33%'; - } - - // - // More beauty hacking, ports and counts can only get so big, free up WS for other longer - // fields like IPs! - // - headers.forEach(h => { - h.width = COLUMN_WIDTHS[h.id]; - }); + const headers = this.getColumnHeaders(); + const widths = getColumnsWidths(headers); + const defaultSortBy = getDefaultSortBy(this.props.columns, this.props.nodes); return ( - {headers.map(header => { + {headers.map((header, i) => { const headerClasses = ['node-details-table-header', 'truncate']; const onHeaderClick = ev => { this.handleHeaderClick(ev, header.id); }; - // sort by first metric by default - const isSorted = this.state.sortBy !== null - ? header.id === this.state.sortBy : header.id === defaultSortBy; + const isSorted = header.id === (this.state.sortBy || defaultSortBy); const isSortedDesc = isSorted && this.state.sortedDesc; const isSortedAsc = isSorted && !isSortedDesc; + if (isSorted) { headerClasses.push('node-details-table-header-sorted'); } // set header width in percent const style = {}; - if (header.width) { - style.width = header.width; + if (widths[i]) { + style.width = widths[i]; } return ( + title={header.label} key={header.id}> {isSortedAsc && } {isSortedDesc @@ -167,7 +205,8 @@ export default class NodeDetailsTable extends React.Component { render() { const headers = this.renderHeaders(); - const { nodeIdKey, columns, topologyId, onMouseOverRow } = this.props; + const { nodeIdKey, columns, topologyId, onMouseEnter, onMouseLeave, onMouseEnterRow, + onMouseLeaveRow } = 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; @@ -183,23 +222,29 @@ export default class NodeDetailsTable extends React.Component { React.cloneElement(child, { nodeOrder }) )); + const className = classNames('node-details-table-wrapper-wrapper', this.props.className); + return ( -
-
+
+
{headers} - + {nodes && nodes.map(node => ( ))} @@ -218,5 +263,7 @@ export default class NodeDetailsTable extends React.Component { NodeDetailsTable.defaultProps = { - nodeIdKey: 'id' // key to identify a node in a row (used for topology links) + nodeIdKey: 'id', // key to identify a node in a row (used for topology links) + onSortChange: () => {}, + sortedDesc: true, }; diff --git a/client/app/scripts/components/nodes.js b/client/app/scripts/components/nodes.js index 6b8fef680..cac1b184b 100644 --- a/client/app/scripts/components/nodes.js +++ b/client/app/scripts/components/nodes.js @@ -9,7 +9,7 @@ import { Loading, getNodeType } from './loading'; import { isTopologyEmpty } from '../utils/topology-utils'; import { CANVAS_MARGINS } from '../constants/styles'; -const navbarHeight = 160; +const navbarHeight = 194; const marginTop = 0; @@ -67,8 +67,9 @@ class Nodes extends React.Component { } render() { - const { nodes, topologyEmpty, topologiesLoaded, nodesLoaded, topologies, - topology, highlightedNodeIds } = this.props; + const { nodes, topologyEmpty, selectedNodeId, gridMode, gridSortBy, + topologiesLoaded, nodesLoaded, topologies, topology, + gridSortedDesc, searchNodeMatches, searchQuery } = this.props; const layoutPrecision = getLayoutPrecision(nodes.size); return ( @@ -80,15 +81,22 @@ class Nodes extends React.Component { show={topologiesLoaded && !nodesLoaded} /> {this.renderEmptyTopologyError(topologiesLoaded && nodesLoaded && topologyEmpty)} - {this.props.gridMode ? + {gridMode ? : } @@ -111,13 +119,20 @@ class Nodes extends React.Component { function mapStateToProps(state) { return { gridMode: state.get('gridMode'), - nodes: state.get('nodes'), nodesLoaded: state.get('nodesLoaded'), topologies: state.get('topologies'), topologiesLoaded: state.get('topologiesLoaded'), + gridSortBy: state.get('gridSortBy'), + gridSortedDesc: state.get('gridSortedDesc'), + nodes: state.get('nodes').filter(node => !node.get('filtered')), + currentTopology: state.get('currentTopology'), + currentTopologyId: state.get('currentTopologyId'), topologyEmpty: isTopologyEmpty(state), topology: state.get('currentTopology'), highlightedNodeIds: state.get('highlightedNodeIds') + searchNodeMatches: state.getIn(['searchNodeMatches', state.get('currentTopologyId')]), + searchQuery: state.get('searchQuery'), + selectedNodeId: state.get('selectedNodeId') }; } diff --git a/client/app/scripts/components/sidebar.js b/client/app/scripts/components/sidebar.js index 4a6973c00..4e3f0c641 100644 --- a/client/app/scripts/components/sidebar.js +++ b/client/app/scripts/components/sidebar.js @@ -1,8 +1,9 @@ import React from 'react'; -export default function Sidebar({children}) { +export default function Sidebar({children, classNames}) { + const className = `sidebar ${classNames}`; return ( -
+
{children}
); diff --git a/client/app/scripts/constants/styles.js b/client/app/scripts/constants/styles.js index 0a232277f..c6f6d1c71 100644 --- a/client/app/scripts/constants/styles.js +++ b/client/app/scripts/constants/styles.js @@ -12,7 +12,7 @@ export const DETAILS_PANEL_OFFSET = 8; export const CANVAS_METRIC_FONT_SIZE = 0.19; export const CANVAS_MARGINS = { - top: 130, + top: 160, left: 40, right: 40, bottom: 100, diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index e2ad25ea0..e69a3b511 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -8,7 +8,7 @@ import { EDGE_ID_SEPARATOR } from '../constants/naming'; import { applyPinnedSearches, updateNodeMatches } from '../utils/search-utils'; import { getNetworkNodes, getAvailableNetworks } from '../utils/network-view-utils'; import { findTopologyById, getAdjacentNodes, setTopologyUrlsById, - updateTopologyIds, filterHiddenTopologies } from '../utils/topology-utils'; + updateTopologyIds, filterHiddenTopologies, addTopologyFullname } from '../utils/topology-utils'; const log = debug('scope:app-store'); const error = debug('scope:error'); @@ -29,6 +29,8 @@ export const initialState = makeMap({ errorUrl: null, forceRelayout: false, gridMode: false, + gridSortBy: null, + gridSortedDesc: true, highlightedEdgeIds: makeSet(), highlightedNodeIds: makeSet(), hostname: '...', @@ -80,7 +82,8 @@ function processTopologies(state, nextTopologies) { state = state.set('topologyUrlsById', setTopologyUrlsById(state.get('topologyUrlsById'), topologiesWithId)); - const immNextTopologies = fromJS(topologiesWithId).sortBy(topologySorter); + const topologiesWithFullnames = addTopologyFullname(topologiesWithId); + const immNextTopologies = fromJS(topologiesWithFullnames).sortBy(topologySorter); return state.mergeDeepIn(['topologies'], immNextTopologies); } @@ -167,6 +170,13 @@ export function rootReducer(state = initialState, action) { return state.set('exportingGraph', action.exporting); } + case ActionTypes.SORT_ORDER_CHANGED: { + return state.merge({ + gridSortBy: action.sortBy, + gridSortedDesc: action.sortedDesc, + }); + } + case ActionTypes.SET_GRID_MODE: { return state.setIn(['gridMode'], action.enabled); } @@ -631,6 +641,12 @@ export function rootReducer(state = initialState, action) { pinnedMetricType: action.state.pinnedMetricType }); state = state.set('gridMode', action.state.mode === 'grid'); + if (action.state.gridSortBy) { + state = state.set('gridSortBy', action.state.gridSortBy); + } + if (action.state.gridSortedDesc !== undefined) { + state = state.set('gridSortedDesc', action.state.gridSortedDesc); + } if (action.state.showingNetworks) { state = state.set('showingNetworks', action.state.showingNetworks); } diff --git a/client/app/scripts/stores/app-store.js b/client/app/scripts/stores/app-store.js deleted file mode 100644 index eea6372e7..000000000 --- a/client/app/scripts/stores/app-store.js +++ /dev/null @@ -1,749 +0,0 @@ -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/scripts/utils/router-utils.js b/client/app/scripts/utils/router-utils.js index 7ed77ee20..86bef1cb1 100644 --- a/client/app/scripts/utils/router-utils.js +++ b/client/app/scripts/utils/router-utils.js @@ -45,6 +45,8 @@ export function getUrlState(state) { pinnedSearches: state.get('pinnedSearches').toJS(), searchQuery: state.get('searchQuery'), selectedNodeId: state.get('selectedNodeId'), + gridSortBy: state.get('gridSortBy'), + gridSortedDesc: state.get('gridSortedDesc'), topologyId: state.get('currentTopologyId'), topologyOptions: state.get('topologyOptions').toJS() // all options }; diff --git a/client/app/scripts/utils/topology-utils.js b/client/app/scripts/utils/topology-utils.js index 4da44970a..14c365558 100644 --- a/client/app/scripts/utils/topology-utils.js +++ b/client/app/scripts/utils/topology-utils.js @@ -63,6 +63,20 @@ export function updateTopologyIds(topologies, parentId) { }); } +export function addTopologyFullname(topologies) { + return topologies.map(t => { + if (!t.sub_topologies) { + return Object.assign({}, t, {fullName: t.name}); + } + return Object.assign({}, t, { + fullName: t.name, + sub_topologies: t.sub_topologies.map(st => ( + Object.assign({}, st, {fullName: `${t.name} ${st.name}`}) + )) + }); + }); +} + // adds ID field to topology (based on last part of URL path) and save urls in // map for easy lookup export function setTopologyUrlsById(topologyUrlsById, topologies) { diff --git a/client/app/scripts/utils/update-buffer-utils.js b/client/app/scripts/utils/update-buffer-utils.js index 8f628aaea..d77706a70 100644 --- a/client/app/scripts/utils/update-buffer-utils.js +++ b/client/app/scripts/utils/update-buffer-utils.js @@ -32,7 +32,7 @@ function maybeUpdate(getState) { receiveNodesDelta(delta); } if (deltaBuffer.size > 0) { - updateTimer = setTimeout(maybeUpdate, feedInterval); + updateTimer = setTimeout(() => maybeUpdate(getState), feedInterval); } } } diff --git a/client/app/styles/main.less b/client/app/styles/main.less index f2a963f1d..7e0fdc920 100644 --- a/client/app/styles/main.less +++ b/client/app/styles/main.less @@ -15,6 +15,7 @@ /* weave company colours */ @weave-gray-blue: rgb(85,105,145); @weave-blue: rgb(0,210,255); +@weave-blue-transparent: rgb(0,210,255, 0.1); @weave-orange: rgb(255,75,25); @weave-charcoal-blue: rgb(50,50,75); // #32324B @@ -273,7 +274,6 @@ h2 { margin-bottom: 3px; border: 1px solid transparent; - background-color: #f7f7fa; &-active, &:hover { color: @text-color; background-color: @background-darker-secondary-color; @@ -1131,7 +1131,7 @@ h2 { } } -.topology-option, .metric-selector, .network-selector { +.topology-option, .metric-selector, .network-selector, .grid-mode-selector { color: @text-secondary-color; margin: 6px 0; @@ -1183,6 +1183,12 @@ h2 { } } +.grid-mode-selector .fa { + margin-right: 4px; + margin-left: 0; + color: @text-secondary-color; +} + .network-selector-action { border-top: 3px solid transparent; border-bottom: 3px solid @background-dark-color; @@ -1226,9 +1232,18 @@ h2 { .sidebar { position: fixed; - bottom: 16px; - left: 16px; + bottom: 12px; + left: 12px; + padding: 4px; font-size: .7rem; + border-radius: 8px; + border: 1px solid transparent; +} + +.sidebar-gridmode { + background-color: #e9e9f1; + border-color: @background-darker-color; + opacity: 0.9; } .search { @@ -1402,7 +1417,7 @@ h2 { // @help-panel-width: 400px; -@help-panel-height: 380px; +@help-panel-height: 420px; .help-panel { position: absolute; -webkit-transform: translate3d(0, 0, 0); @@ -1512,16 +1527,63 @@ h2 { } .nodes-grid { + + tr { + border-radius: 6px; + } + + &-label-minor { + opacity: 0.7; + } + + &-id-column { + margin: -3px -4px; + padding: 2px 2px; + + .content { + padding: 1px 4px; + cursor: pointer; + border: 1px solid transparent; + border-radius: 4px; + display: flex; + + div { + flex: 1; + } + } + + .selected &, &:hover { + .content { + background-color: #d7ecf5; + } + } + .selected & .content { + border: 1px solid @weave-blue; + } + } + + /* + .node-details-relatives { + color: inherit; + font-size: 90%; + white-space: normal; + opacity: 0.8; + line-height: 110%; + text-align: right; + + margin-left: 0; + // margin-top: 0; + // display: inline-block; + float: right; + } + */ + .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; @@ -1533,15 +1595,56 @@ h2 { margin-top: 24px; } + .node-details-table-node > * { + padding: 3px 4px; + } + .node-details-table-node, thead tr { height: 24px; } .node-details-table-node { - &:hover, &.selected { - background-color: @background-darker-color; + &.selected, &:hover { + background-color: @background-lighter-color; } } + } + .scroll-body { + + table { + border-bottom: 1px solid #ccc; + } + + thead { + // osx scrollbar width: 0 + // linux scrollbar width: 16 + // avg scrollbar width: 8 + padding-right: 8px; + } + + thead, tbody tr { + display: table; + width: 100%; + table-layout: fixed; + } + + tbody:after { + content: ''; + display: block; + // height of the controls so you can scroll the last row up above them + // and have a good look. + height: 140px; + } + + thead { + box-shadow: 0 4px 2px -2px rgba(0, 0, 0, 0.16); + border-bottom: 1px solid #aaa; + } + + tbody { + display: block; + overflow-y: scroll; + } } } diff --git a/render/detailed/node.go b/render/detailed/node.go index c01a5e73a..fa04b3a2c 100644 --- a/render/detailed/node.go +++ b/render/detailed/node.go @@ -18,7 +18,6 @@ type Node struct { NodeSummary Controls []ControlInstance `json:"controls"` Children []NodeSummaryGroup `json:"children,omitempty"` - Parents []Parent `json:"parents,omitempty"` Connections []ConnectionsSummary `json:"connections,omitempty"` } @@ -86,7 +85,6 @@ func MakeNode(topologyID string, r report.Report, ns report.Nodes, n report.Node NodeSummary: summary, Controls: controls(r, n), Children: children(r, n), - Parents: Parents(r, n), Connections: []ConnectionsSummary{ incomingConnectionsSummary(topologyID, r, n, ns), outgoingConnectionsSummary(topologyID, r, n, ns), diff --git a/render/detailed/node_test.go b/render/detailed/node_test.go index f314a83ac..9d6659438 100644 --- a/render/detailed/node_test.go +++ b/render/detailed/node_test.go @@ -218,6 +218,23 @@ func TestMakeDetailedContainerNode(t *testing.T) { Metric: &fixture.ServerContainerMemoryMetric, }, }, + Parents: []detailed.Parent{ + { + ID: expected.ServerContainerImageNodeID, + Label: fixture.ServerContainerImageName, + TopologyID: "containers-by-image", + }, + { + ID: fixture.ServerHostNodeID, + Label: fixture.ServerHostName, + TopologyID: "hosts", + }, + { + ID: fixture.ServerPodNodeID, + Label: "pong-b", + TopologyID: "pods", + }, + }, }, Controls: []detailed.ControlInstance{}, Children: []detailed.NodeSummaryGroup{ @@ -232,23 +249,6 @@ func TestMakeDetailedContainerNode(t *testing.T) { Nodes: []detailed.NodeSummary{serverProcessNodeSummary}, }, }, - Parents: []detailed.Parent{ - { - ID: expected.ServerContainerImageNodeID, - Label: fixture.ServerContainerImageName, - TopologyID: "containers-by-image", - }, - { - ID: fixture.ServerHostNodeID, - Label: fixture.ServerHostName, - TopologyID: "hosts", - }, - { - ID: fixture.ServerPodNodeID, - Label: "pong-b", - TopologyID: "pods", - }, - }, Connections: []detailed.ConnectionsSummary{ { ID: "incoming-connections", @@ -335,6 +335,18 @@ func TestMakeDetailedPodNode(t *testing.T) { {ID: "container", Label: "# Containers", Value: "1", Priority: 4, Datatype: "number"}, {ID: "kubernetes_namespace", Label: "Namespace", Value: "ping", Priority: 5}, }, + Parents: []detailed.Parent{ + { + ID: fixture.ServerHostNodeID, + Label: fixture.ServerHostName, + TopologyID: "hosts", + }, + { + ID: fixture.ServiceNodeID, + Label: fixture.ServiceName, + TopologyID: "services", + }, + }, }, Controls: []detailed.ControlInstance{}, Children: []detailed.NodeSummaryGroup{ @@ -358,18 +370,6 @@ func TestMakeDetailedPodNode(t *testing.T) { Nodes: []detailed.NodeSummary{serverProcessNodeSummary}, }, }, - Parents: []detailed.Parent{ - { - ID: fixture.ServerHostNodeID, - Label: fixture.ServerHostName, - TopologyID: "hosts", - }, - { - ID: fixture.ServiceNodeID, - Label: fixture.ServiceName, - TopologyID: "services", - }, - }, Connections: []detailed.ConnectionsSummary{ { ID: "incoming-connections", diff --git a/render/detailed/summary.go b/render/detailed/summary.go index a78f52436..4d176aa7f 100644 --- a/render/detailed/summary.go +++ b/render/detailed/summary.go @@ -64,6 +64,7 @@ type NodeSummary struct { Linkable bool `json:"linkable,omitempty"` // Whether this node can be linked-to Pseudo bool `json:"pseudo,omitempty"` Metadata []report.MetadataRow `json:"metadata,omitempty"` + Parents []Parent `json:"parents,omitempty"` Metrics []report.MetricRow `json:"metrics,omitempty"` Tables []report.Table `json:"tables,omitempty"` Adjacency report.IDList `json:"adjacency,omitempty"` @@ -133,6 +134,7 @@ func baseNodeSummary(r report.Report, n report.Node) NodeSummary { Linkable: true, Metadata: NodeMetadata(r, n), Metrics: NodeMetrics(r, n), + Parents: Parents(r, n), Tables: NodeTables(r, n), Adjacency: n.Adjacency.Copy(), } From 29e67683b3d54921e9ab3c1aceedcc9aba7eddb4 Mon Sep 17 00:00:00 2001 From: Simon Howe Date: Wed, 27 Jul 2016 20:37:35 +0200 Subject: [PATCH 04/10] New selection model. - highlight whole row - don't do anything when selecting text --- client/app/scripts/charts/nodes-grid.js | 13 ++--- .../node-details/node-details-table-row.js | 40 +++++++++++++-- .../node-details/node-details-table.js | 12 +++-- client/app/styles/main.less | 51 +++++++------------ 4 files changed, 68 insertions(+), 48 deletions(-) diff --git a/client/app/scripts/charts/nodes-grid.js b/client/app/scripts/charts/nodes-grid.js index 1bfcf72e4..e78a2a9bd 100644 --- a/client/app/scripts/charts/nodes-grid.js +++ b/client/app/scripts/charts/nodes-grid.js @@ -53,7 +53,7 @@ function getColumns(nodes) { } -function renderIdCell(props, onClick) { +function renderIdCell(props) { const style = { width: 16, flex: 'none', @@ -61,7 +61,7 @@ function renderIdCell(props, onClick) { }; return ( -
+
@@ -87,15 +87,15 @@ class NodesGrid extends React.Component { this.onMouseLeaveRow = this.onMouseLeaveRow.bind(this); } - clickRow(ev, nodeId, nodeLabel) { - if (ev.target.className === 'node-details-relatives-link') { + clickRow(ev, node, el) { + if (ev.target.className === 'node-details-table-node-link') { return; } - this.props.clickNode(nodeId, nodeLabel); + this.props.clickNode(node.id, node.label, el.getBoundingClientRect()); } renderIdCell(props) { - return renderIdCell(props, (ev) => this.clickRow(ev, props.id, props.label)); + return renderIdCell(props); } onMouseEnterRow() { @@ -144,6 +144,7 @@ class NodesGrid extends React.Component { tbodyStyle={tbodyStyle} topologyId={this.props.topologyId} onSortChange={this.onSortChange} + onClickRow={this.clickRow} {...detailsData} sortBy={gridSortBy} sortedDesc={gridSortedDesc} 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 index 3e1eb47c2..25f40d6b0 100644 --- a/client/app/scripts/components/node-details/node-details-table-row.js +++ b/client/app/scripts/components/node-details/node-details-table-row.js @@ -1,4 +1,5 @@ import React from 'react'; +import ReactDOM from 'react-dom'; import classNames from 'classnames'; import NodeDetailsTableNodeLink from './node-details-table-node-link'; @@ -66,10 +67,20 @@ function renderValues(node, columns = [], columnWidths = []) { export default class NodeDetailsTableRow extends React.Component { constructor(props, context) { super(props, context); + + this.mouseDragOrigin = [0, 0]; + + this.storeLabelRef = this.storeLabelRef.bind(this); + this.onMouseDown = this.onMouseDown.bind(this); + this.onMouseUp = this.onMouseUp.bind(this); this.onMouseEnter = this.onMouseEnter.bind(this); this.onMouseLeave = this.onMouseLeave.bind(this); } + storeLabelRef(ref) { + this.labelEl = ref; + } + onMouseEnter() { const { node, onMouseEnterRow } = this.props; onMouseEnterRow(node); @@ -80,9 +91,30 @@ export default class NodeDetailsTableRow extends React.Component { onMouseLeaveRow(node); } + onMouseDown(ev) { + const { pageX, pageY } = ev; + this.mouseDragOrigin = [pageX, pageY]; + } + + onMouseUp(ev) { + const [originX, originY] = this.mouseDragOrigin; + const { pageX, pageY } = ev; + const thresholdPx = 2; + const movedTheMouseTooMuch = ( + Math.abs(originX - pageX) > thresholdPx || + Math.abs(originY - pageY) > thresholdPx + ); + if (movedTheMouseTooMuch) { + return; + } + + const { node, onClick } = this.props; + onClick(ev, node, ReactDOM.findDOMNode(this.labelEl)); + } + render() { - const { node, nodeIdKey, topologyId, columns, onMouseEnterRow, onMouseLeaveRow, selected, - widths } = this.props; + const { node, nodeIdKey, topologyId, columns, onClick, onMouseEnterRow, onMouseLeaveRow, + selected, widths } = this.props; const [firstColumnWidth, ...columnWidths] = widths; const values = renderValues(node, columns, columnWidths); const nodeId = node[nodeIdKey]; @@ -90,10 +122,12 @@ export default class NodeDetailsTableRow extends React.Component { return (
- 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 75ec09ff7..cd9f17fbf 100644 --- a/client/app/scripts/components/node-details/node-details-table.js +++ b/client/app/scripts/components/node-details/node-details-table.js @@ -23,8 +23,9 @@ const COLUMN_WIDTHS = { docker_container_uptime: '85px', docker_container_restart_count: '80px', docker_container_ips: '80px', - docker_container_created: '110px', - docker_container_state_human: '120px', + // 27 Jul 16 11:33 UTC + docker_container_created: '140px', + docker_container_state_human: '170px', open_files_count: '80px', ppid: '80px', pid: '80px', @@ -63,7 +64,7 @@ function getValueForSortBy(sortBy) { } } - return ''; + return null; }; } @@ -205,8 +206,8 @@ export default class NodeDetailsTable extends React.Component { render() { const headers = this.renderHeaders(); - const { nodeIdKey, columns, topologyId, onMouseEnter, onMouseLeave, onMouseEnterRow, - onMouseLeaveRow } = this.props; + const { nodeIdKey, columns, topologyId, onClickRow, onMouseEnter, onMouseLeave, + onMouseEnterRow, onMouseLeaveRow } = 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; @@ -243,6 +244,7 @@ export default class NodeDetailsTable extends React.Component { nodeIdKey={nodeIdKey} widths={getColumnsWidths(this.getColumnHeaders())} columns={columns} + onClick={onClickRow} onMouseLeaveRow={onMouseLeaveRow} onMouseEnterRow={onMouseEnterRow} topologyId={topologyId} /> diff --git a/client/app/styles/main.less b/client/app/styles/main.less index 7e0fdc920..ee97b40fb 100644 --- a/client/app/styles/main.less +++ b/client/app/styles/main.less @@ -1541,43 +1541,13 @@ h2 { padding: 2px 2px; .content { - padding: 1px 4px; - cursor: pointer; - border: 1px solid transparent; - border-radius: 4px; display: flex; - div { flex: 1; } } - - .selected &, &:hover { - .content { - background-color: #d7ecf5; - } - } - .selected & .content { - border: 1px solid @weave-blue; - } } - /* - .node-details-relatives { - color: inherit; - font-size: 90%; - white-space: normal; - opacity: 0.8; - line-height: 110%; - text-align: right; - - margin-left: 0; - // margin-top: 0; - // display: inline-block; - float: right; - } - */ - .node-details-table-wrapper-wrapper { flex: 1; @@ -1603,10 +1573,23 @@ h2 { height: 24px; } - .node-details-table-node { - &.selected, &:hover { - background-color: @background-lighter-color; - } + tr:nth-child(even) { + background: @background-color; + } + + tbody tr { + border: 1px solid transparent; + border-radius: 4px; + cursor: pointer; + } + + tbody tr.selected, tbody tr:hover { + background-color: #d7ecf5; + border: 1px solid @weave-blue; + } + + tbody tr.selected { + // box-shadow: 0 4px 2px -2px rgba(0, 0, 0, 0.16); } } From f83e0f1a48b4e697ba6c4d66ccc813682c2c4ee2 Mon Sep 17 00:00:00 2001 From: Simon Howe Date: Thu, 28 Jul 2016 09:20:12 +0200 Subject: [PATCH 05/10] Remove a lot of un-used code that I still want to keep --- client/app/scripts/charts/nodes-chart.js | 42 ++--- client/app/scripts/charts/nodes-grid.js | 43 ++--- client/app/scripts/charts/nodes-layout.js | 52 +----- .../app/scripts/components/debug-toolbar.js | 2 +- client/app/scripts/components/examples.js | 150 ------------------ .../node-details/node-details-table.js | 14 +- client/app/scripts/examples-main.js | 10 -- client/app/styles/main.less | 30 +--- client/webpack.local.config.js | 15 +- 9 files changed, 44 insertions(+), 314 deletions(-) delete mode 100644 client/app/scripts/components/examples.js delete mode 100644 client/app/scripts/examples-main.js diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index 3ce0c85f3..301b36576 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -12,7 +12,8 @@ import { DETAILS_PANEL_WIDTH } from '../constants/styles'; import Logo from '../components/logo'; import { doLayout } from './nodes-layout'; import NodesChartElements from './nodes-chart-elements'; -import { getActiveTopologyOptions, getAdjacentNodes } from '../utils/topology-utils'; +import { getActiveTopologyOptions, getAdjacentNodes, + isSameTopology } from '../utils/topology-utils'; const log = debug('scope:nodes-chart'); @@ -40,8 +41,8 @@ class NodesChart extends React.Component { scale: 1, selectedNodeScale: d3.scale.linear(), hasZoomed: false, - height: props.height || 0, - width: props.width || 0, + height: 0, + width: 0, zoomCache: {} }; } @@ -60,7 +61,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 (this.zoom && nextZoom) { + if (nextZoom) { this.zoom.scale(nextZoom.scale); this.zoom.translate([nextZoom.panTranslateX, nextZoom.panTranslateY]); } @@ -78,13 +79,11 @@ class NodesChart extends React.Component { } // reset layout dimensions only when forced - // state.height = nextProps.height; - // state.width = nextProps.width; state.height = nextProps.forceRelayout ? nextProps.height : (state.height || nextProps.height); state.width = nextProps.forceRelayout ? nextProps.width : (state.width || nextProps.width); // _.assign(state, this.updateGraphState(nextProps, state)); - if (nextProps.forceRelayout || nextProps.nodes !== this.props.nodes) { + if (nextProps.forceRelayout || !isSameTopology(nextProps.nodes, this.props.nodes)) { _.assign(state, this.updateGraphState(nextProps, state)); } @@ -100,10 +99,6 @@ class NodesChart extends React.Component { componentDidMount() { // distinguish pan/zoom from click - if (this.props.noZoom) { - return; - } - this.isZooming = false; this.zoom = d3.behavior.zoom() @@ -115,10 +110,6 @@ class NodesChart extends React.Component { } componentWillUnmount() { - if (this.props.noZoom) { - return; - } - // undoing .call(zoom) d3.select('.nodes-chart svg') .on('mousedown.zoom', null) @@ -294,10 +285,8 @@ class NodesChart extends React.Component { restoreLayout(state) { // undo any pan/zooming that might have happened - if (this.zoom) { - this.zoom.scale(state.scale); - this.zoom.translate([state.panTranslateX, state.panTranslateY]); - } + this.zoom.scale(state.scale); + this.zoom.translate([state.panTranslateX, state.panTranslateY]); const nodes = state.nodes.map(node => node.merge({ x: node.get('px'), @@ -326,10 +315,6 @@ 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 }; - const nodeOrder = props.nodeOrder || makeMap(stateNodes - .toList() - .sortBy(n => n.get('label')) - .map((n, i) => [n.get('id'), i])); const options = { width: state.width, @@ -338,8 +323,7 @@ class NodesChart extends React.Component { margins: props.margins, forceRelayout: props.forceRelayout, topologyId: this.props.topologyId, - topologyOptions: this.props.topologyOptions, - nodeOrder + topologyOptions: this.props.topologyOptions }; const timedLayouter = timely(doLayout); @@ -366,12 +350,10 @@ class NodesChart extends React.Component { const zoomFactor = Math.min(xFactor, yFactor); let zoomScale = this.state.scale; - if (!this.props.noZoom && !state.hasZoomed && zoomFactor > 0 && zoomFactor < 1) { + if (!state.hasZoomed && zoomFactor > 0 && zoomFactor < 1) { zoomScale = zoomFactor; // saving in d3's behavior cache - if (this.zoom) { - this.zoom.scale(zoomFactor); - } + this.zoom.scale(zoomFactor); } nextState.scale = zoomScale; @@ -390,7 +372,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, this.props.nodeSize || normalizedNodeSize]); + return this.state.nodeScale.copy().range([0, normalizedNodeSize]); } zoomed() { diff --git a/client/app/scripts/charts/nodes-grid.js b/client/app/scripts/charts/nodes-grid.js index e78a2a9bd..8e791a3c6 100644 --- a/client/app/scripts/charts/nodes-grid.js +++ b/client/app/scripts/charts/nodes-grid.js @@ -54,20 +54,19 @@ function getColumns(nodes) { function renderIdCell(props) { - const style = { + const iconStyle = { width: 16, flex: 'none', color: getNodeColor(props.rank, props.label_major) }; + const showSubLabel = Boolean(props.pseudo); return (
-
-
-
- {props.label} {props.pseudo && - {props.label_minor}} -
+
+
+ {props.label} {showSubLabel && + {props.label_minor}}
); @@ -79,33 +78,18 @@ class NodesGrid extends React.Component { constructor(props, context) { super(props, context); - this.renderIdCell = this.renderIdCell.bind(this); - this.clickRow = this.clickRow.bind(this); - + this.onClickRow = this.onClickRow.bind(this); this.onSortChange = this.onSortChange.bind(this); - this.onMouseEnterRow = this.onMouseEnterRow.bind(this); - this.onMouseLeaveRow = this.onMouseLeaveRow.bind(this); } - clickRow(ev, node, el) { + onClickRow(ev, node, el) { + // TODO: do this better if (ev.target.className === 'node-details-table-node-link') { return; } this.props.clickNode(node.id, node.label, el.getBoundingClientRect()); } - renderIdCell(props) { - return renderIdCell(props); - } - - onMouseEnterRow() { - this.props.clickPauseUpdate(); - } - - onMouseLeaveRow() { - this.props.clickResumeUpdate(); - } - onSortChange(sortBy, sortedDesc) { this.props.sortOrderChanged(sortBy, sortedDesc); } @@ -140,16 +124,17 @@ class NodesGrid extends React.Component { + limit={1000} + {...detailsData} + />
); } diff --git a/client/app/scripts/charts/nodes-layout.js b/client/app/scripts/charts/nodes-layout.js index 0c0392466..116e35f4d 100644 --- a/client/app/scripts/charts/nodes-layout.js +++ b/client/app/scripts/charts/nodes-layout.js @@ -1,6 +1,5 @@ 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'; @@ -50,9 +49,7 @@ function runLayoutEngine(graph, imNodes, imEdges, opts) { // configure node margins graph.setGraph({ nodesep, - ranksep, - // rankdir: 'LR', - // align: 'UL' + ranksep }); // add nodes to the graph if not already there @@ -179,17 +176,18 @@ 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; + let singleY; nodes = nodes.sortBy(node => node.get('rank')).map(node => { if (singleNodes.has(node.get('id'))) { if (col === columns) { col = 0; row++; } - const singleX = col * (nodesep + nodeWidth) + offsetX; - const singleY = row * (ranksep + nodeHeight) + offsetY; + singleX = col * (nodesep + nodeWidth) + offsetX; + singleY = row * (ranksep + nodeHeight) + offsetY; col++; return node.merge({ x: singleX, @@ -200,8 +198,8 @@ function layoutSingleNodes(layout, opts) { }); // adjust layout dimensions if graph is now bigger - result.width = Math.max(layout.width, columns * nodeWidth + (columns - 1) * nodesep); - result.height = Math.max(layout.height, rows * nodeHeight + (rows - 1) * ranksep); + result.width = Math.max(layout.width, singleX + nodeWidth / 2 + nodesep); + result.height = Math.max(layout.height, singleY + nodeHeight / 2 + ranksep); result.nodes = nodes; } @@ -246,7 +244,6 @@ function shiftLayoutToCenter(layout, opts) { return result; } - /** * Adds `points` array to edge based on location of source and target * @param {Map} edge new edge @@ -262,39 +259,6 @@ function setSimpleEdgePoints(edge, nodeCache) { ])); } - -export 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); - - 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 @@ -391,7 +355,6 @@ export function doLayout(immNodes, immEdges, opts) { let layout; ++layoutRuns; - // if (false && !options.forceRelayout && cachedLayout && nodeCache && edgeCache if (!options.forceRelayout && cachedLayout && nodeCache && edgeCache && !hasUnseenNodes(immNodes, nodeCache)) { log('skip layout, trivial adjustment', ++layoutRunsTrivial, layoutRuns); @@ -407,7 +370,6 @@ 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/debug-toolbar.js b/client/app/scripts/components/debug-toolbar.js index 8d2a4e703..ae61fff59 100644 --- a/client/app/scripts/components/debug-toolbar.js +++ b/client/app/scripts/components/debug-toolbar.js @@ -184,7 +184,7 @@ class DebugToolbar extends React.Component { addNodes(n, prefix = 'zing') { const ns = this.props.nodes; const nodeNames = ns.keySeq().toJS(); - const newNodeNames = _.range(nodeNames.length, nodeNames.length + n).map(i => ( + const newNodeNames = _.range(ns.size, ns.size + n).map(i => ( // `${randomLetter()}${randomLetter()}-zing` `${prefix}${i}` )); diff --git a/client/app/scripts/components/examples.js b/client/app/scripts/components/examples.js deleted file mode 100644 index 43a82b439..000000000 --- a/client/app/scripts/components/examples.js +++ /dev/null @@ -1,150 +0,0 @@ -/* 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/node-details-table.js b/client/app/scripts/components/node-details/node-details-table.js index cd9f17fbf..d8bfc32ff 100644 --- a/client/app/scripts/components/node-details/node-details-table.js +++ b/client/app/scripts/components/node-details/node-details-table.js @@ -1,6 +1,5 @@ import _ from 'lodash'; import React from 'react'; -import { Map as makeMap } from 'immutable'; import classNames from 'classnames'; import ShowMore from '../show-more'; @@ -69,7 +68,6 @@ function getValueForSortBy(sortBy) { } -/* function getMetaDataSorters(nodes) { // returns an array of sorters that will take a node return _.get(nodes, [0, 'metadata'], []).map((field, index) => node => { @@ -83,15 +81,14 @@ function getMetaDataSorters(nodes) { return null; }); } -*/ function getSortedNodes(nodes, columns, sortBy, sortedDesc) { const sortedNodes = _.sortBy( nodes, getValueForSortBy(sortBy || getDefaultSortBy(columns, nodes)), - 'label' - // getMetaDataSorters(nodes) + 'label', + getMetaDataSorters(nodes) ); if (sortedDesc) { sortedNodes.reverse(); @@ -217,12 +214,6 @@ export default class NodeDetailsTable extends React.Component { 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 }) - )); - const className = classNames('node-details-table-wrapper-wrapper', this.props.className); return ( @@ -257,7 +248,6 @@ export default class NodeDetailsTable extends React.Component { expanded={expanded} notShown={notShown} /> - {childrenWithProps} ); } diff --git a/client/app/scripts/examples-main.js b/client/app/scripts/examples-main.js deleted file mode 100644 index 562e2f508..000000000 --- a/client/app/scripts/examples-main.js +++ /dev/null @@ -1,10 +0,0 @@ -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(, document.getElementById('app')); diff --git a/client/app/styles/main.less b/client/app/styles/main.less index ee97b40fb..e58463a9e 100644 --- a/client/app/styles/main.less +++ b/client/app/styles/main.less @@ -1508,24 +1508,9 @@ h2 { } // -// Examples +// Nodes grid. // -.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 { tr { @@ -1538,19 +1523,16 @@ h2 { &-id-column { margin: -3px -4px; - padding: 2px 2px; - - .content { - display: flex; - div { - flex: 1; - } + padding: 2px 4px; + display: flex; + div { + flex: 1; } } .node-details-table-wrapper-wrapper { - flex: 1; + flex: 1; display: flex; flex-direction: row; width: 100%; diff --git a/client/webpack.local.config.js b/client/webpack.local.config.js index 40d1a96c9..4698cb5cb 100644 --- a/client/webpack.local.config.js +++ b/client/webpack.local.config.js @@ -15,13 +15,7 @@ var HtmlWebpackPlugin = require('html-webpack-plugin'); */ // Inject websocket url to dev backend - -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' -]; + var WEBPACK_SERVER_HOST = process.env.WEBPACK_SERVER_HOST || 'localhost'; module.exports = { @@ -69,11 +63,6 @@ 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', @@ -115,7 +104,7 @@ module.exports = { loader: 'json-loader' }, { - test: /(\.css|\.less)$/, + test: /\.less$/, loader: 'style-loader!css-loader!postcss-loader!less-loader' }, { From df1efff19ba840e7a40ce01a511b85173aef7d5c Mon Sep 17 00:00:00 2001 From: Simon Howe Date: Thu, 28 Jul 2016 10:55:04 +0200 Subject: [PATCH 06/10] Force a layout after a toggle to viz-mode --- client/app/scripts/actions/app-actions.js | 24 ++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index 88b3b7b8d..c83a8bf48 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -35,17 +35,6 @@ export function toggleHelp() { } -export function toggleGridMode(enabled) { - return (dispatch, getState) => { - dispatch({ - type: ActionTypes.SET_GRID_MODE, - enabled - }); - updateRoute(getState); - }; -} - - export function sortOrderChanged(sortBy, sortedDesc) { return (dispatch, getState) => { dispatch({ @@ -243,6 +232,19 @@ export function clickForceRelayout() { }; } +export function toggleGridMode(enabled) { + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.SET_GRID_MODE, + enabled + }); + updateRoute(getState); + if (!enabled) { + dispatch(clickForceRelayout()); + } + }; +} + export function clickNode(nodeId, label, origin) { return (dispatch, getState) => { dispatch({ From a2810afaf1ca8ec324b13745e6db0cea2fbdac4f Mon Sep 17 00:00:00 2001 From: Simon Howe Date: Thu, 28 Jul 2016 15:32:35 +0200 Subject: [PATCH 07/10] Fixes grid -> topo transition. - Also don't show table borders when no nodes. - 't' toggles table mode on/off, rather than 't'/v' --- client/app/scripts/actions/app-actions.js | 5 ++++- client/app/scripts/charts/nodes-chart.js | 4 ++-- client/app/scripts/charts/nodes-grid.js | 4 ++-- client/app/scripts/components/app.js | 4 +--- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index c83a8bf48..03646ed96 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -232,8 +232,11 @@ export function clickForceRelayout() { }; } -export function toggleGridMode(enabled) { +export function toggleGridMode(enabledArgument) { return (dispatch, getState) => { + const enabled = (enabledArgument === undefined) ? + !getState().get('gridMode') : + enabledArgument; dispatch({ type: ActionTypes.SET_GRID_MODE, enabled diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index 301b36576..943395b83 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -41,8 +41,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: {} }; } diff --git a/client/app/scripts/charts/nodes-grid.js b/client/app/scripts/charts/nodes-grid.js index 8e791a3c6..a97e7a98d 100644 --- a/client/app/scripts/charts/nodes-grid.js +++ b/client/app/scripts/charts/nodes-grid.js @@ -121,7 +121,7 @@ class NodesGrid extends React.Component { return (
- 0 && + />}
); } diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index 0c22392e3..518026649 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -87,10 +87,8 @@ class App extends React.Component { dispatch(pinNextMetric(-1)); } else if (char === '>') { dispatch(pinNextMetric(1)); - } else if (char === 'v') { - dispatch(toggleGridMode(false)); } else if (char === 't') { - dispatch(toggleGridMode(true)); + dispatch(toggleGridMode()); } else if (char === 'q') { dispatch(unpinMetric()); dispatch(selectMetric(null)); From 3e4ec1dc61c2b9840a2c753979608a68332a6dea Mon Sep 17 00:00:00 2001 From: Simon Howe Date: Mon, 1 Aug 2016 16:48:09 +0200 Subject: [PATCH 08/10] Review feedback + fiddling w/ the columns widths a little more. --- client/app/scripts/charts/nodes-chart.js | 2 +- client/app/scripts/charts/nodes-grid.js | 26 ++++++++++---- client/app/scripts/components/app.js | 4 +-- .../scripts/components/grid-mode-selector.js | 2 +- client/app/scripts/components/help-panel.js | 4 +-- .../node-details/node-details-table-row.js | 5 +++ .../node-details/node-details-table.js | 34 ++++++++++--------- client/app/scripts/components/nodes.js | 20 ++--------- client/app/scripts/reducers/root.js | 2 +- client/app/scripts/utils/router-utils.js | 2 +- client/app/styles/main.less | 1 - 11 files changed, 52 insertions(+), 50 deletions(-) diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index 943395b83..19f949190 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -350,7 +350,7 @@ class NodesChart extends React.Component { const zoomFactor = Math.min(xFactor, yFactor); let zoomScale = this.state.scale; - if (!state.hasZoomed && zoomFactor > 0 && zoomFactor < 1) { + if (this.zoom && !state.hasZoomed && zoomFactor > 0 && zoomFactor < 1) { zoomScale = zoomFactor; // saving in d3's behavior cache this.zoom.scale(zoomFactor); diff --git a/client/app/scripts/charts/nodes-grid.js b/client/app/scripts/charts/nodes-grid.js index a97e7a98d..94f652033 100644 --- a/client/app/scripts/charts/nodes-grid.js +++ b/client/app/scripts/charts/nodes-grid.js @@ -4,8 +4,7 @@ import React from 'react'; import { connect } from 'react-redux'; import { List as makeList, Map as makeMap } from 'immutable'; import NodeDetailsTable from '../components/node-details/node-details-table'; -import { clickNode, sortOrderChanged, clickPauseUpdate, - clickResumeUpdate } from '../actions/app-actions'; +import { clickNode, sortOrderChanged } from '../actions/app-actions'; import { getNodeColor } from '../utils/color-utils'; @@ -49,7 +48,7 @@ function getColumns(nodes) { .toList() .sortBy(m => m.get('label')); - return relativesColumns.concat(metadataColumns.concat(metricColumns)).toJS(); + return relativesColumns.concat(metadataColumns, metricColumns).toJS(); } @@ -110,7 +109,7 @@ class NodesGrid extends React.Component { }; const detailsData = { - label: this.props.topology && this.props.topology.get('fullName'), + label: this.props.currentTopology && this.props.currentTopology.get('fullName'), id: '', nodes: nodes .toList() @@ -126,7 +125,7 @@ class NodesGrid extends React.Component { className={className} renderIdCell={renderIdCell} tbodyStyle={tbodyStyle} - topologyId={this.props.topologyId} + topologyId={this.props.currentTopologyId} onSortChange={this.onSortChange} onClickRow={this.onClickRow} sortBy={gridSortBy} @@ -141,7 +140,20 @@ class NodesGrid extends React.Component { } +function mapStateToProps(state) { + return { + gridSortBy: state.get('gridSortBy'), + gridSortedDesc: state.get('gridSortedDesc'), + currentTopology: state.get('currentTopology'), + currentTopologyId: state.get('currentTopologyId'), + searchNodeMatches: state.getIn(['searchNodeMatches', state.get('currentTopologyId')]), + searchQuery: state.get('searchQuery'), + selectedNodeId: state.get('selectedNodeId') + }; +} + + export default connect( - () => ({}), - { clickNode, sortOrderChanged, clickPauseUpdate, clickResumeUpdate } + mapStateToProps, + { clickNode, sortOrderChanged } )(NodesGrid); diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index 518026649..2e24fe8f4 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -87,7 +87,7 @@ class App extends React.Component { dispatch(pinNextMetric(-1)); } else if (char === '>') { dispatch(pinNextMetric(1)); - } else if (char === 't') { + } else if (char === 't' || char === 'g') { dispatch(toggleGridMode()); } else if (char === 'q') { dispatch(unpinMetric()); @@ -131,8 +131,8 @@ class App extends React.Component { {showingMetricsSelector && !gridMode && } {showingNetworkSelector && !gridMode && } - + diff --git a/client/app/scripts/components/grid-mode-selector.js b/client/app/scripts/components/grid-mode-selector.js index 4bdc1723d..e27d849ee 100644 --- a/client/app/scripts/components/grid-mode-selector.js +++ b/client/app/scripts/components/grid-mode-selector.js @@ -41,7 +41,7 @@ class GridModeSelector extends React.Component { return (
- {this.renderItem('fa fa-share-alt', 'Visualization', !gridMode, this.disableGridMode)} + {this.renderItem('fa fa-share-alt', 'Graph', !gridMode, this.disableGridMode)} {this.renderItem('fa fa-table', 'Table', gridMode, this.enableGridMode)}
diff --git a/client/app/scripts/components/help-panel.js b/client/app/scripts/components/help-panel.js index 417127064..45f24e9f2 100644 --- a/client/app/scripts/components/help-panel.js +++ b/client/app/scripts/components/help-panel.js @@ -4,8 +4,8 @@ const GENERAL_SHORTCUTS = [ {key: 'esc', label: 'Close active panel'}, {key: '/', label: 'Activate search field'}, {key: '?', label: 'Toggle shortcut menu'}, - {key: 't', label: 'Activate Table mode'}, - {key: 'v', label: 'Activate Visualization mode'}, + {key: 't', label: 'Toggle Table mode'}, + {key: 'g', label: 'Toggle Graph mode'}, ]; const CANVAS_METRIC_SHORTCUTS = [ 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 index 25f40d6b0..7948450a1 100644 --- a/client/app/scripts/components/node-details/node-details-table-row.js +++ b/client/app/scripts/components/node-details/node-details-table-row.js @@ -68,6 +68,11 @@ export default class NodeDetailsTableRow extends React.Component { constructor(props, context) { super(props, context); + // + // We watch how far the mouse moves when click on a row, move to much and we assume that the + // user is selecting some data in the row. In this case don't trigger the onClick event which + // is most likely a details panel popping open. + // this.mouseDragOrigin = [0, 0]; this.storeLabelRef = this.storeLabelRef.bind(this); 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 d8bfc32ff..b8b450399 100644 --- a/client/app/scripts/components/node-details/node-details-table.js +++ b/client/app/scripts/components/node-details/node-details-table.js @@ -10,24 +10,28 @@ function isNumberField(field) { return field.dataType && field.dataType === 'number'; } +const CW = { + S: '44px', + M: '80px', + L: '140px', + XL: '170px', +}; const COLUMN_WIDTHS = { - port: '44px', count: '70px', - process_cpu_usage_percent: '80px', - threads: '80px', - process_memory_usage_bytes: '80px', - docker_cpu_total_usage: '80px', - docker_memory_usage: '80px', + docker_container_created: CW.L, + docker_container_restart_count: CW.M, + docker_container_state_human: CW.XL, docker_container_uptime: '85px', - docker_container_restart_count: '80px', - docker_container_ips: '80px', - // 27 Jul 16 11:33 UTC - docker_container_created: '140px', - docker_container_state_human: '170px', - open_files_count: '80px', - ppid: '80px', - pid: '80px', + docker_cpu_total_usage: CW.M, + docker_memory_usage: CW.M, + open_files_count: CW.M, + pid: CW.M, + port: '44px', + ppid: CW.M, + process_cpu_usage_percent: CW.M, + process_memory_usage_bytes: CW.M, + threads: CW.M, }; @@ -110,8 +114,6 @@ function getColumnsWidths(headers) { return '50%'; } else if (headers.length > 3 && headers.length <= 5) { return '33%'; - } else if (headers.length > 5) { - return '20%'; } } diff --git a/client/app/scripts/components/nodes.js b/client/app/scripts/components/nodes.js index cac1b184b..826cfeaf8 100644 --- a/client/app/scripts/components/nodes.js +++ b/client/app/scripts/components/nodes.js @@ -85,15 +85,7 @@ class Nodes extends React.Component { : !node.get('filtered')), - currentTopology: state.get('currentTopology'), - currentTopologyId: state.get('currentTopologyId'), - topologyEmpty: isTopologyEmpty(state), topology: state.get('currentTopology'), - highlightedNodeIds: state.get('highlightedNodeIds') - searchNodeMatches: state.getIn(['searchNodeMatches', state.get('currentTopologyId')]), - searchQuery: state.get('searchQuery'), - selectedNodeId: state.get('selectedNodeId') + nodes: state.get('nodes').filter(node => !node.get('filtered')), + topologyEmpty: isTopologyEmpty(state), }; } diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index e69a3b511..ba9ef0dce 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -640,7 +640,7 @@ export function rootReducer(state = initialState, action) { selectedNodeId: action.state.selectedNodeId, pinnedMetricType: action.state.pinnedMetricType }); - state = state.set('gridMode', action.state.mode === 'grid'); + state = state.set('gridMode', action.state.topologyViewMode === 'grid'); if (action.state.gridSortBy) { state = state.set('gridSortBy', action.state.gridSortBy); } diff --git a/client/app/scripts/utils/router-utils.js b/client/app/scripts/utils/router-utils.js index 86bef1cb1..c69a1f627 100644 --- a/client/app/scripts/utils/router-utils.js +++ b/client/app/scripts/utils/router-utils.js @@ -39,7 +39,7 @@ export function getUrlState(state) { const urlState = { controlPipe: cp ? cp.toJS() : null, - mode: state.get('gridMode') ? 'grid' : 'topo', + topologyViewMode: state.get('gridMode') ? 'grid' : 'topo', nodeDetails: nodeDetails.toJS(), pinnedMetricType: state.get('pinnedMetricType'), pinnedSearches: state.get('pinnedSearches').toJS(), diff --git a/client/app/styles/main.less b/client/app/styles/main.less index e58463a9e..92ed77cb9 100644 --- a/client/app/styles/main.less +++ b/client/app/styles/main.less @@ -15,7 +15,6 @@ /* weave company colours */ @weave-gray-blue: rgb(85,105,145); @weave-blue: rgb(0,210,255); -@weave-blue-transparent: rgb(0,210,255, 0.1); @weave-orange: rgb(255,75,25); @weave-charcoal-blue: rgb(50,50,75); // #32324B From 925905bc43985810726f5118d88b6099fd8c46ee Mon Sep 17 00:00:00 2001 From: Simon Howe Date: Tue, 2 Aug 2016 11:17:40 +0200 Subject: [PATCH 09/10] Table-mode: fixes processes view if internet is present. - Also adds tooltip for id label column (container/process name) --- client/app/scripts/charts/nodes-grid.js | 2 +- .../app/scripts/components/node-details/node-details-table.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/app/scripts/charts/nodes-grid.js b/client/app/scripts/charts/nodes-grid.js index 94f652033..4f2d46369 100644 --- a/client/app/scripts/charts/nodes-grid.js +++ b/client/app/scripts/charts/nodes-grid.js @@ -61,7 +61,7 @@ function renderIdCell(props) { const showSubLabel = Boolean(props.pseudo); return ( -
+
{props.label} {showSubLabel && 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 b8b450399..bc403712a 100644 --- a/client/app/scripts/components/node-details/node-details-table.js +++ b/client/app/scripts/components/node-details/node-details-table.js @@ -75,7 +75,7 @@ function getValueForSortBy(sortBy) { 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]; + const nodeMetadataField = node.metadata && node.metadata[index]; if (nodeMetadataField) { if (isNumberField(nodeMetadataField)) { return parseFloat(nodeMetadataField.value); From 504eb7f6e3055cc1cc3063fac23fdce023ace3f5 Mon Sep 17 00:00:00 2001 From: Simon Howe Date: Wed, 3 Aug 2016 09:11:17 +0200 Subject: [PATCH 10/10] Fixes after rebasing onto master (loading-indicator) --- client/app/scripts/components/nodes.js | 14 ++++++++------ client/app/styles/main.less | 1 + 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/client/app/scripts/components/nodes.js b/client/app/scripts/components/nodes.js index 826cfeaf8..e9632af3c 100644 --- a/client/app/scripts/components/nodes.js +++ b/client/app/scripts/components/nodes.js @@ -67,9 +67,8 @@ class Nodes extends React.Component { } render() { - const { nodes, topologyEmpty, selectedNodeId, gridMode, gridSortBy, - topologiesLoaded, nodesLoaded, topologies, topology, - gridSortedDesc, searchNodeMatches, searchQuery } = this.props; + const { nodes, topologyEmpty, gridMode, topologiesLoaded, nodesLoaded, topologies, + currentTopology } = this.props; const layoutPrecision = getLayoutPrecision(nodes.size); return ( @@ -77,10 +76,11 @@ class Nodes extends React.Component { {this.renderEmptyTopologyError(topologiesLoaded && nodesLoaded && topologyEmpty)} + {gridMode ? !node.get('filtered')), nodesLoaded: state.get('nodesLoaded'), topologies: state.get('topologies'), topologiesLoaded: state.get('topologiesLoaded'), - topology: state.get('currentTopology'), - nodes: state.get('nodes').filter(node => !node.get('filtered')), topologyEmpty: isTopologyEmpty(state), }; } + export default connect( mapStateToProps )(Nodes); diff --git a/client/app/styles/main.less b/client/app/styles/main.less index 92ed77cb9..f7d98859f 100644 --- a/client/app/styles/main.less +++ b/client/app/styles/main.less @@ -298,6 +298,7 @@ h2 { &-error, &-loading { .hideable; + pointer-events: none; position: absolute; left: 50%; top: 50%;
{this.props.renderIdCell(Object.assign(node, {topologyId, nodeId}))}