Grid-mode tuning!

- Change scrolling behaviour to lock headers in place
- Enable filtering (hitting enter in the search bar) in grid-mode
- Little more top-margin for k8s (can have 3 topos) + taller rows.
- Trying out rank-color + node.relatives in the grid-mode
- First pass at selecting rows.
  - Needs a bit more a fiddle, colors + click areas
- Store grid sort direction (asc/desc) in url state
- Simplify node selection to one method. (over-ride existing card)
  - Remove clicking on name directly (links) to overlay new cards for now.
- Playing w/ grid-mode-toggle icons and labels
- Improves rendering in ff, change of shortcut keys for grid-mode-toggle
- Playing w/ clearer selection colors for grid-mode
- Slight change to selection-ui
- Fixes showNodeInTopology button visibility on the details-panel
  - Was using an old heuristic. Table-mode allows you to open child cards
    before the parent.
- Make it clear what the default sort is in tables
  - E.g. always show a sorting caret
- Sort grid-mode columns, first meta then metrics
- dancing-nodes rememdy #1: pause updates onRowHover
- Splits relatives out into their own columns
- Take into account scrollbar width for grid-mode col header position
- Tooltips on table column headers
- grid-mode: fixes first column headers (proc/container/c-by-image)
- Disable pause-on-hover, too aggresive
- reduce label column width a bit (33pc -> 25pc) for big tables
- Filter grid-mode onSearchChange
  - Rather than previous behaviour of waiting for an <enter>
- Show label_minor on pseudo nodes, that might not have much other info
- grid-mode: further reduce width of id column.
- Fixes go tests, properly moves parents into node-summary
- Fixes sorting of string columns w/ missing fields.
  - E.g. uptime. Where -1e-10 > '3days' doesn't work.
This commit is contained in:
Simon Howe
2016-07-14 12:50:47 +02:00
parent fa502ae6ad
commit d0b99969ea
23 changed files with 563 additions and 903 deletions

View File

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

View File

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

View File

@@ -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 (
<div className="nodes-grid-id-column" onClick={onClick}>
<div className="content">
<div style={style}><i className="fa fa-square" /></div>
<div className="truncate">
{props.label} {props.pseudo &&
<span className="nodes-grid-label-minor">{props.label_minor}</span>}
</div>
</div>
</div>
);
}
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 {
<div className="nodes-grid">
<NodeDetailsTable
style={cmpStyle}
onMouseOut={this.onMouseOut}
onMouseOverRow={this.onMouseOverRow}
className={className}
renderIdCell={this.renderIdCell}
tbodyStyle={tbodyStyle}
topologyId={this.props.topologyId}
onSortChange={this.onSortChange}
{...detailsData}
highlightedNodeIds={this.props.highlightedNodeIds}
sortBy={gridSortBy}
sortedDesc={gridSortedDesc}
selectedNodeId={this.props.selectedNodeId}
limit={1000} />
</div>
);
}
}
export default connect(
() => ({}),
{ clickNode, sortOrderChanged, clickPauseUpdate, clickResumeUpdate }
)(NodesGrid);

View File

@@ -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 {
<Nodes />
<Sidebar>
<Sidebar classNames={gridMode ? 'sidebar-gridmode' : ''}>
{showingMetricsSelector && !gridMode && <MetricSelector />}
{showingNetworkSelector && !gridMode && <NetworkSelector />}
<Status />
<GridModeSelector />
{showingMetricsSelector && <MetricSelector />}
{showingNetworkSelector && <NetworkSelector />}
<TopologyOptions />
</Sidebar>
@@ -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'),

View File

@@ -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 (
<div
className={className}
onClick={onClick} >
<span className={icons} style={{fontSize: 12}} />
<span>{label}</span>
</div>
);
}
render() {
const { gridMode } = this.props;
return (
<div className="grid-mode-selector">
<div className="grid-mode-selector-wrapper">
{this.renderItem('fa fa-share-alt', 'Visualization', !gridMode, this.disableGridMode)}
{this.renderItem('fa fa-table', 'Table', gridMode, this.enableGridMode)}
</div>
</div>
);
}
}
function mapStateToProps(state) {
return {
gridMode: state.get('gridMode'),
};
}
export default connect(
mapStateToProps,
{ toggleGridMode }
)(GridModeSelector);

View File

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

View File

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

View File

@@ -20,14 +20,14 @@ class NodeDetailsTableNodeLink extends React.Component {
render() {
if (this.props.linkable) {
return (
<span className="node-details-table-node-link truncate" title={this.props.label}
<span className="node-details-table-node-link" title={this.props.label}
onClick={this.handleClick}>
{this.props.label}
</span>
);
}
return (
<span className="node-details-table-node truncate" title={this.props.label}>
<span className="node-details-table-node" title={this.props.label}>
{this.props.label}
</span>
);

View File

@@ -4,7 +4,7 @@ import { formatMetric } from '../../utils/string-utils';
function NodeDetailsTableNodeMetric(props) {
return (
<td className="node-details-table-node-metric">
<td className="node-details-table-node-metric" style={props.style}>
{formatMetric(props.value, props)}
</td>
);

View File

@@ -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 (
<td className="node-details-table-node-value truncate" title={field.value}
style={style}
key={field.id}>
{field.value}
</td>
);
}
return <NodeDetailsTableNodeMetric key={field.id} {...field} />;
if (field.valueType === 'relatives') {
return (
<td className="node-details-table-node-value truncate" title={field.value}
style={style}
key={field.id}>
{<NodeDetailsTableNodeLink linkable nodeId={field.relative.id} {...field.relative} />}
</td>
);
}
return <NodeDetailsTableNodeMetric style={style} key={field.id} {...field} />;
}
// empty cell to complete the row for proper hover
return <td className="node-details-table-node-value" key={id} />;
return <td className="node-details-table-node-value" style={style} key={id} />;
});
}
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 (
<tr onMouseOver={onMouseOverRow && this.onMouseOver} className={className}>
<td className="node-details-table-node-label truncate">
<NodeDetailsTableNodeLink
topologyId={topologyId}
nodeId={nodeId}
{...node} />
<tr
onMouseEnter={onMouseEnterRow && this.onMouseEnter}
onMouseLeave={onMouseLeaveRow && this.onMouseLeave}
className={className}>
<td className="node-details-table-node-label truncate"
style={{ width: firstColumnWidth }}>
{this.props.renderIdCell(Object.assign(node, {topologyId, nodeId}))}
</td>
{values}
</tr>
);
}
}
NodeDetailsTableRow.defaultProps = {
renderIdCell: (props) => <NodeDetailsTableNodeLink {...props} />
};

View File

@@ -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 (
<tr>
{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 (
<td className={headerClasses.join(' ')} style={style} onClick={onHeaderClick}
key={header.id}>
title={header.label} key={header.id}>
{isSortedAsc
&& <span className="node-details-table-header-sorter fa fa-caret-up" />}
{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 (
<div className="node-details-table-wrapper-wrapper" style={this.props.style}>
<div className="node-details-table-wrapper" onMouseOut={this.props.onMouseOut}>
<div className={className}
style={this.props.style}>
<div className="node-details-table-wrapper">
<table className="node-details-table">
<thead>
{headers}
</thead>
<tbody>
<tbody style={this.props.tbodyStyle} onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}>
{nodes && nodes.map(node => (
<NodeDetailsTableRow
key={node.id}
selected={this.props.highlightedNodeIds &&
this.props.highlightedNodeIds.has(node.id)}
renderIdCell={this.props.renderIdCell}
selected={this.props.selectedNodeId === node.id}
node={node}
nodeIdKey={nodeIdKey}
widths={getColumnsWidths(this.getColumnHeaders())}
columns={columns}
onMouseOverRow={onMouseOverRow}
onMouseLeaveRow={onMouseLeaveRow}
onMouseEnterRow={onMouseEnterRow}
topologyId={topologyId} />
))}
</tbody>
@@ -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,
};

View File

@@ -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} />
</DelayedShow>
{this.renderEmptyTopologyError(topologiesLoaded && nodesLoaded && topologyEmpty)}
{this.props.gridMode ?
{gridMode ?
<NodesGrid {...this.state}
nodeSize="24"
nodes={nodes}
topology={this.props.currentTopology}
topologyId={this.props.currentTopologyId}
margins={CANVAS_MARGINS}
layoutPrecision={layoutPrecision}
highlightedNodeIds={highlightedNodeIds}
selectedNodeId={selectedNodeId}
gridSortBy={gridSortBy}
gridSortedDesc={gridSortedDesc}
searchNodeMatches={searchNodeMatches}
searchQuery={searchQuery}
/> :
<NodesChart {...this.state}
nodes={nodes}
margins={CANVAS_MARGINS}
layoutPrecision={layoutPrecision}
/>}
@@ -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')
};
}

View File

@@ -1,8 +1,9 @@
import React from 'react';
export default function Sidebar({children}) {
export default function Sidebar({children, classNames}) {
const className = `sidebar ${classNames}`;
return (
<div className="sidebar">
<div className={className}>
{children}
</div>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -32,7 +32,7 @@ function maybeUpdate(getState) {
receiveNodesDelta(delta);
}
if (deltaBuffer.size > 0) {
updateTimer = setTimeout(maybeUpdate, feedInterval);
updateTimer = setTimeout(() => maybeUpdate(getState), feedInterval);
}
}
}

View File

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

View File

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

View File

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

View File

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