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(), }