diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index 534391783..03646ed96 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,18 @@ export function toggleHelp() { }; } + +export function sortOrderChanged(sortBy, sortedDesc) { + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.SORT_ORDER_CHANGED, + sortBy, sortedDesc + }); + updateRoute(getState); + }; +} + + // // Networks // @@ -83,6 +97,7 @@ export function unpinNetwork(networkId) { // Metrics // + export function selectMetric(metricId) { return { type: ActionTypes.SELECT_METRIC, @@ -217,6 +232,22 @@ export function clickForceRelayout() { }; } +export function toggleGridMode(enabledArgument) { + return (dispatch, getState) => { + const enabled = (enabledArgument === undefined) ? + !getState().get('gridMode') : + enabledArgument; + dispatch({ + type: ActionTypes.SET_GRID_MODE, + enabled + }); + updateRoute(getState); + if (!enabled) { + dispatch(clickForceRelayout()); + } + }; +} + export function clickNode(nodeId, label, origin) { return (dispatch, getState) => { dispatch({ diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index 29bb8eb94..19f949190 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -17,18 +17,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 +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: {} }; } @@ -237,9 +231,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 @@ -310,9 +304,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() @@ -328,7 +320,7 @@ class NodesChart extends React.Component { width: state.width, height: state.height, scale: nodeScale, - margins: MARGINS, + margins: props.margins, forceRelayout: props.forceRelayout, topologyId: this.props.topologyId, topologyOptions: this.props.topologyOptions @@ -353,12 +345,12 @@ 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.zoom && !state.hasZoomed && zoomFactor > 0 && zoomFactor < 1) { zoomScale = zoomFactor; // saving in d3's behavior cache this.zoom.scale(zoomFactor); @@ -402,7 +394,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 new file mode 100644 index 000000000..4f2d46369 --- /dev/null +++ b/client/app/scripts/charts/nodes-grid.js @@ -0,0 +1,159 @@ +/* eslint react/jsx-no-bind: "off", no-multi-comp: "off" */ + +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 } from '../actions/app-actions'; + +import { getNodeColor } from '../utils/color-utils'; + + +const IGNORED_COLUMNS = ['docker_container_ports', 'docker_container_id', 'docker_image_id', + 'docker_container_command', 'docker_container_networks']; + + +function getColumns(nodes) { + 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, metricColumns).toJS(); +} + + +function renderIdCell(props) { + const iconStyle = { + width: 16, + flex: 'none', + color: getNodeColor(props.rank, props.label_major) + }; + const showSubLabel = Boolean(props.pseudo); + + return ( +
+
+
+ {props.label} {showSubLabel && + {props.label_minor}} +
+
+ ); +} + + +class NodesGrid extends React.Component { + + constructor(props, context) { + super(props, context); + + this.onClickRow = this.onClickRow.bind(this); + this.onSortChange = this.onSortChange.bind(this); + } + + 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()); + } + + onSortChange(sortBy, sortedDesc) { + this.props.sortOrderChanged(sortBy, sortedDesc); + } + + render() { + const { margins, nodes, height, gridSortBy, gridSortedDesc, + searchNodeMatches = makeMap(), searchQuery } = this.props; + const cmpStyle = { + height, + 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: this.props.currentTopology && this.props.currentTopology.get('fullName'), + id: '', + nodes: nodes + .toList() + .filter(n => !searchQuery || searchNodeMatches.has(n.get('id'))) + .toJS(), + columns: getColumns(nodes) + }; + + return ( +
+ {nodes.size > 0 && } +
+ ); + } +} + + +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( + mapStateToProps, + { clickNode, sortOrderChanged } +)(NodesGrid); diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index 29f77267e..2e24fe8f4 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -12,9 +12,10 @@ 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'; import MetricSelector from './metric-selector'; import NetworkSelector from './networks-selector'; import EmbeddedTerminal from './embedded-terminal'; @@ -86,6 +87,8 @@ class App extends React.Component { dispatch(pinNextMetric(-1)); } else if (char === '>') { dispatch(pinNextMetric(1)); + } else if (char === 't' || char === 'g') { + dispatch(toggleGridMode()); } else if (char === 'q') { dispatch(unpinMetric()); dispatch(selectMetric(null)); @@ -99,8 +102,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 ( @@ -125,10 +128,11 @@ class App extends React.Component { - + + {showingMetricsSelector && !gridMode && } + {showingNetworkSelector && !gridMode && } + - {showingMetricsSelector && } - {showingNetworkSelector && } @@ -141,6 +145,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..e27d849ee --- /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', 'Graph', !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..45f24e9f2 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: 'Toggle Table mode'}, + {key: 'g', label: 'Toggle Graph mode'}, ]; const CANVAS_METRIC_SHORTCUTS = [ diff --git a/client/app/scripts/components/node-details.js b/client/app/scripts/components/node-details.js index 6e4d688d8..7278e3a58 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'; @@ -48,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 ( @@ -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(); @@ -236,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 new file mode 100644 index 000000000..7948450a1 --- /dev/null +++ b/client/app/scripts/components/node-details/node-details-table-row.js @@ -0,0 +1,148 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +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; + }); + } + }); + + (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 = [], columnWidths = []) { + const fields = getValuesForNode(node); + return columns.map(({id}, i) => { + const field = fields[id]; + const style = { width: columnWidths[i] }; + if (field) { + if (field.valueType === 'metadata') { + return ( + + {field.value} + + ); + } + if (field.valueType === 'relatives') { + return ( + + {} + + ); + } + return ; + } + // empty cell to complete the row for proper hover + return ; + }); +} + + +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); + 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); + } + + onMouseLeave() { + const { node, onMouseLeaveRow } = this.props; + 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, onClick, 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 667e61d04..bc403712a 100644 --- a/client/app/scripts/components/node-details/node-details-table.js +++ b/client/app/scripts/components/node-details/node-details-table.js @@ -1,34 +1,142 @@ import _ from 'lodash'; import React from 'react'; +import classNames from 'classnames'; 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'; function isNumberField(field) { return field.dataType && field.dataType === 'number'; } +const CW = { + S: '44px', + M: '80px', + L: '140px', + XL: '170px', +}; const COLUMN_WIDTHS = { + count: '70px', + docker_container_created: CW.L, + docker_container_restart_count: CW.M, + docker_container_state_human: CW.XL, + docker_container_uptime: '85px', + docker_cpu_total_usage: CW.M, + docker_memory_usage: CW.M, + open_files_count: CW.M, + pid: CW.M, port: '44px', - count: '70px' + ppid: CW.M, + process_cpu_usage_percent: CW.M, + process_memory_usage_bytes: CW.M, + threads: CW.M, }; +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']); +} + + +function getValueForSortBy(sortBy) { + // return the node's value based on the sortBy field + return (node) => { + if (sortBy !== null) { + 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); + } + return field.value; + } + } + + return null; + }; +} + + +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 && 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; +} + + +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%'; + } + } + + // + // 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) { super(props, context); this.DEFAULT_LIMIT = 5; this.state = { - limit: this.DEFAULT_LIMIT, - sortedDesc: true, - sortBy: null + limit: props.limit || this.DEFAULT_LIMIT, + sortedDesc: this.props.sortedDesc, + sortBy: this.props.sortBy }; this.handleLimitClick = this.handleLimitClick.bind(this); - this.getValueForSortBy = this.getValueForSortBy.bind(this); } handleHeaderClick(ev, headerId) { @@ -37,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() { @@ -44,109 +153,42 @@ export default class NodeDetailsTable extends React.Component { 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(); - if (sortBy !== null) { - const field = _.union(node.metrics, node.metadata).find(f => f.id === sortBy); - if (field) { - if (isNumberField(field)) { - return parseFloat(field.value); - } - return field.value; - } - } - return -1e-10; // just under 0 to treat missing values differently from 0 - } - - 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; + 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 = this.getDefaultSortBy(); - - // 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[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 @@ -161,66 +203,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, 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; 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 className = classNames('node-details-table-wrapper-wrapper', this.props.className); + return ( -
- - - {headers} - - - {nodes && nodes.map(node => { - const values = this.renderValues(node); - const nodeId = node[nodeIdKey]; - return ( - - - {values} - - ); - })} - -
- -
- +
+
+ + + {headers} + + + {nodes && nodes.map(node => ( + + ))} + +
+ +
); } @@ -228,5 +257,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 38ae981c2..e9632af3c 100644 --- a/client/app/scripts/components/nodes.js +++ b/client/app/scripts/components/nodes.js @@ -2,14 +2,15 @@ import React from 'react'; import { connect } from 'react-redux'; 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 navbarHeight = 194; const marginTop = 0; -const detailsWidth = 450; /** @@ -66,25 +67,31 @@ class Nodes extends React.Component { } render() { - const { nodes, selectedNodeId, topologyEmpty, topologiesLoaded, nodesLoaded, topologies, - topology } = this.props; + const { nodes, topologyEmpty, gridMode, topologiesLoaded, nodesLoaded, topologies, + currentTopology } = this.props; const layoutPrecision = getLayoutPrecision(nodes.size); - const hasSelectedNode = selectedNodeId && nodes.has(selectedNodeId); return (
{this.renderEmptyTopologyError(topologiesLoaded && nodesLoaded && topologyEmpty)} - + + {gridMode ? + : + }
); } @@ -101,18 +108,20 @@ class Nodes extends React.Component { } } + function mapStateToProps(state) { return { - nodes: state.get('nodes'), + currentTopology: state.get('currentTopology'), + gridMode: state.get('gridMode'), + nodes: state.get('nodes').filter(node => !node.get('filtered')), nodesLoaded: state.get('nodesLoaded'), - selectedNodeId: state.get('selectedNodeId'), topologies: state.get('topologies'), topologiesLoaded: state.get('topologiesLoaded'), topologyEmpty: isTopologyEmpty(state), - topology: state.get('currentTopology'), }; } + export default connect( mapStateToProps )(Nodes); 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/action-types.js b/client/app/scripts/constants/action-types.js index 72d42793d..4e9ab8c17 100644 --- a/client/app/scripts/constants/action-types.js +++ b/client/app/scripts/constants/action-types.js @@ -55,6 +55,8 @@ const ACTION_TYPES = [ 'UNPIN_NETWORK', 'SHOW_NETWORKS', 'SET_RECEIVED_NODES_DELTA', + 'SORT_ORDER_CHANGED', + 'SET_GRID_MODE', ]; export default _.zipObject(ACTION_TYPES, ACTION_TYPES); diff --git a/client/app/scripts/constants/styles.js b/client/app/scripts/constants/styles.js index f626c6733..c6f6d1c71 100644 --- a/client/app/scripts/constants/styles.js +++ b/client/app/scripts/constants/styles.js @@ -10,3 +10,10 @@ export const DETAILS_PANEL_MARGINS = { export const DETAILS_PANEL_OFFSET = 8; export const CANVAS_METRIC_FONT_SIZE = 0.19; + +export const CANVAS_MARGINS = { + top: 160, + left: 40, + right: 40, + bottom: 100, +}; diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index 893247774..ba9ef0dce 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'); @@ -28,6 +28,9 @@ export const initialState = makeMap({ currentTopologyId: 'containers', errorUrl: null, forceRelayout: false, + gridMode: false, + gridSortBy: null, + gridSortedDesc: true, highlightedEdgeIds: makeSet(), highlightedNodeIds: makeSet(), hostname: '...', @@ -79,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); } @@ -166,6 +170,17 @@ 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); + } + case ActionTypes.CLEAR_CONTROL_ERROR: { return state.removeIn(['controlStatus', action.nodeId, 'error']); } @@ -625,6 +640,13 @@ export function rootReducer(state = initialState, action) { selectedNodeId: action.state.selectedNodeId, pinnedMetricType: action.state.pinnedMetricType }); + state = state.set('gridMode', action.state.topologyViewMode === '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/utils/router-utils.js b/client/app/scripts/utils/router-utils.js index 0aba5a157..c69a1f627 100644 --- a/client/app/scripts/utils/router-utils.js +++ b/client/app/scripts/utils/router-utils.js @@ -39,11 +39,14 @@ export function getUrlState(state) { const urlState = { controlPipe: cp ? cp.toJS() : null, + topologyViewMode: state.get('gridMode') ? 'grid' : 'topo', nodeDetails: nodeDetails.toJS(), pinnedMetricType: state.get('pinnedMetricType'), 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 f1c19adce..f7d98859f 100644 --- a/client/app/styles/main.less +++ b/client/app/styles/main.less @@ -269,7 +269,7 @@ h2 { cursor: pointer; padding: 4px 8px; border-radius: @border-radius; - opacity: 0.8; + opacity: 0.9; margin-bottom: 3px; border: 1px solid transparent; @@ -298,6 +298,7 @@ h2 { &-error, &-loading { .hideable; + pointer-events: none; position: absolute; left: 50%; top: 50%; @@ -889,7 +890,7 @@ h2 { font-size: 105%; line-height: 1.5; - &:hover { + &:hover, &.selected { background-color: lighten(@background-color, 5%); } @@ -1130,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; @@ -1182,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; @@ -1225,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 { @@ -1401,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); @@ -1490,3 +1506,110 @@ h2 { } } } + +// +// Nodes grid. +// + +.nodes-grid { + + tr { + border-radius: 6px; + } + + &-label-minor { + opacity: 0.7; + } + + &-id-column { + margin: -3px -4px; + padding: 2px 4px; + display: flex; + div { + flex: 1; + } + } + + .node-details-table-wrapper-wrapper { + + flex: 1; + display: flex; + flex-direction: row; + width: 100%; + + .node-details-table-wrapper { + margin: 0; + flex: 1; + } + + .nodes-grid-graph { + position: relative; + margin-top: 24px; + } + + .node-details-table-node > * { + padding: 3px 4px; + } + + .node-details-table-node, thead tr { + height: 24px; + } + + 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); + } + } + + .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(), }