Merge pull request #1673 from weaveworks/grid-view

Grid Mode
This commit is contained in:
Simon
2016-08-03 09:48:23 +02:00
committed by GitHub
23 changed files with 834 additions and 231 deletions
+31
View File
@@ -18,10 +18,12 @@ export function showHelp() {
return {type: ActionTypes.SHOW_HELP};
}
export function hideHelp() {
return {type: ActionTypes.HIDE_HELP};
}
export function toggleHelp() {
return (dispatch, getState) => {
if (getState().get('showingHelp')) {
@@ -32,6 +34,18 @@ export function toggleHelp() {
};
}
export function sortOrderChanged(sortBy, sortedDesc) {
return (dispatch, getState) => {
dispatch({
type: ActionTypes.SORT_ORDER_CHANGED,
sortBy, sortedDesc
});
updateRoute(getState);
};
}
//
// Networks
//
@@ -83,6 +97,7 @@ export function unpinNetwork(networkId) {
// Metrics
//
export function selectMetric(metricId) {
return {
type: ActionTypes.SELECT_METRIC,
@@ -217,6 +232,22 @@ export function clickForceRelayout() {
};
}
export function toggleGridMode(enabledArgument) {
return (dispatch, getState) => {
const enabled = (enabledArgument === undefined) ?
!getState().get('gridMode') :
enabledArgument;
dispatch({
type: ActionTypes.SET_GRID_MODE,
enabled
});
updateRoute(getState);
if (!enabled) {
dispatch(clickForceRelayout());
}
};
}
export function clickNode(nodeId, label, origin) {
return (dispatch, getState) => {
dispatch({
+10 -19
View File
@@ -17,18 +17,12 @@ import { getActiveTopologyOptions, getAdjacentNodes,
const log = debug('scope:nodes-chart');
const MARGINS = {
top: 130,
left: 40,
right: 40,
bottom: 0
};
const ZOOM_CACHE_FIELDS = ['scale', 'panTranslateX', 'panTranslateY'];
// make sure circular layouts a bit denser with 3-6 nodes
const radiusDensity = d3.scale.threshold()
.domain([3, 6]).range([2.5, 3.5, 3]);
.domain([3, 6])
.range([2.5, 3.5, 3]);
class NodesChart extends React.Component {
@@ -47,8 +41,8 @@ class NodesChart extends React.Component {
scale: 1,
selectedNodeScale: d3.scale.linear(),
hasZoomed: false,
height: 0,
width: 0,
height: props.height || 0,
width: props.width || 0,
zoomCache: {}
};
}
@@ -237,9 +231,9 @@ class NodesChart extends React.Component {
// move origin node to center of viewport
const zoomScale = state.scale;
const translate = [state.panTranslateX, state.panTranslateY];
const centerX = (-translate[0] + (state.width + MARGINS.left
const centerX = (-translate[0] + (state.width + props.margins.left
- DETAILS_PANEL_WIDTH) / 2) / zoomScale;
const centerY = (-translate[1] + (state.height + MARGINS.top) / 2) / zoomScale;
const centerY = (-translate[1] + (state.height + props.margins.top) / 2) / zoomScale;
stateNodes = stateNodes.mergeIn([props.selectedNodeId], {
x: centerX,
y: centerY
@@ -310,9 +304,7 @@ class NodesChart extends React.Component {
}
updateGraphState(props, state) {
const n = props.nodes.size;
if (n === 0) {
if (props.nodes.size === 0) {
return {
nodes: makeMap(),
edges: makeMap()
@@ -328,7 +320,7 @@ class NodesChart extends React.Component {
width: state.width,
height: state.height,
scale: nodeScale,
margins: MARGINS,
margins: props.margins,
forceRelayout: props.forceRelayout,
topologyId: this.props.topologyId,
topologyOptions: this.props.topologyOptions
@@ -353,12 +345,12 @@ class NodesChart extends React.Component {
.map(edge => edge.set('ppoints', edge.get('points')));
// adjust layout based on viewport
const xFactor = (state.width - MARGINS.left - MARGINS.right) / graph.width;
const xFactor = (state.width - props.margins.left - props.margins.right) / graph.width;
const yFactor = state.height / graph.height;
const zoomFactor = Math.min(xFactor, yFactor);
let zoomScale = this.state.scale;
if (!state.hasZoomed && zoomFactor > 0 && zoomFactor < 1) {
if (this.zoom && !state.hasZoomed && zoomFactor > 0 && zoomFactor < 1) {
zoomScale = zoomFactor;
// saving in d3's behavior cache
this.zoom.scale(zoomFactor);
@@ -402,7 +394,6 @@ function mapStateToProps(state) {
return {
adjacentNodes: getAdjacentNodes(state),
forceRelayout: state.get('forceRelayout'),
nodes: state.get('nodes').filter(node => !node.get('filtered')),
selectedNodeId: state.get('selectedNodeId'),
topologyId: state.get('currentTopologyId'),
topologyOptions: getActiveTopologyOptions(state)
+159
View File
@@ -0,0 +1,159 @@
/* eslint react/jsx-no-bind: "off", no-multi-comp: "off" */
import React from 'react';
import { connect } from 'react-redux';
import { List as makeList, Map as makeMap } from 'immutable';
import NodeDetailsTable from '../components/node-details/node-details-table';
import { clickNode, sortOrderChanged } from '../actions/app-actions';
import { getNodeColor } from '../utils/color-utils';
const IGNORED_COLUMNS = ['docker_container_ports', 'docker_container_id', 'docker_image_id',
'docker_container_command', 'docker_container_networks'];
function getColumns(nodes) {
const metricColumns = nodes
.toList()
.flatMap(n => {
const metrics = (n.get('metrics') || makeList())
.map(m => makeMap({ id: m.get('id'), label: m.get('label') }));
return metrics;
})
.toSet()
.toList()
.sortBy(m => m.get('label'));
const metadataColumns = nodes
.toList()
.flatMap(n => {
const metadata = (n.get('metadata') || makeList())
.map(m => makeMap({ id: m.get('id'), label: m.get('label') }));
return metadata;
})
.toSet()
.filter(n => !IGNORED_COLUMNS.includes(n.get('id')))
.toList()
.sortBy(m => m.get('label'));
const relativesColumns = nodes
.toList()
.flatMap(n => {
const metadata = (n.get('parents') || makeList())
.map(m => makeMap({ id: m.get('topologyId'), label: m.get('topologyId') }));
return metadata;
})
.toSet()
.toList()
.sortBy(m => m.get('label'));
return relativesColumns.concat(metadataColumns, metricColumns).toJS();
}
function renderIdCell(props) {
const iconStyle = {
width: 16,
flex: 'none',
color: getNodeColor(props.rank, props.label_major)
};
const showSubLabel = Boolean(props.pseudo);
return (
<div title={props.label} className="nodes-grid-id-column">
<div style={iconStyle}><i className="fa fa-square" /></div>
<div className="truncate">
{props.label} {showSubLabel &&
<span className="nodes-grid-label-minor">{props.label_minor}</span>}
</div>
</div>
);
}
class NodesGrid extends React.Component {
constructor(props, context) {
super(props, context);
this.onClickRow = this.onClickRow.bind(this);
this.onSortChange = this.onSortChange.bind(this);
}
onClickRow(ev, node, el) {
// TODO: do this better
if (ev.target.className === 'node-details-table-node-link') {
return;
}
this.props.clickNode(node.id, node.label, el.getBoundingClientRect());
}
onSortChange(sortBy, sortedDesc) {
this.props.sortOrderChanged(sortBy, sortedDesc);
}
render() {
const { margins, nodes, height, gridSortBy, gridSortedDesc,
searchNodeMatches = makeMap(), searchQuery } = this.props;
const cmpStyle = {
height,
marginTop: margins.top,
paddingLeft: margins.left,
paddingRight: margins.right,
};
const tbodyHeight = height - 24 - 18;
const className = 'scroll-body';
const tbodyStyle = {
height: `${tbodyHeight}px`,
};
const detailsData = {
label: this.props.currentTopology && this.props.currentTopology.get('fullName'),
id: '',
nodes: nodes
.toList()
.filter(n => !searchQuery || searchNodeMatches.has(n.get('id')))
.toJS(),
columns: getColumns(nodes)
};
return (
<div className="nodes-grid">
{nodes.size > 0 && <NodeDetailsTable
style={cmpStyle}
className={className}
renderIdCell={renderIdCell}
tbodyStyle={tbodyStyle}
topologyId={this.props.currentTopologyId}
onSortChange={this.onSortChange}
onClickRow={this.onClickRow}
sortBy={gridSortBy}
sortedDesc={gridSortedDesc}
selectedNodeId={this.props.selectedNodeId}
limit={1000}
{...detailsData}
/>}
</div>
);
}
}
function mapStateToProps(state) {
return {
gridSortBy: state.get('gridSortBy'),
gridSortedDesc: state.get('gridSortedDesc'),
currentTopology: state.get('currentTopology'),
currentTopologyId: state.get('currentTopologyId'),
searchNodeMatches: state.getIn(['searchNodeMatches', state.get('currentTopologyId')]),
searchQuery: state.get('searchQuery'),
selectedNodeId: state.get('selectedNodeId')
};
}
export default connect(
mapStateToProps,
{ clickNode, sortOrderChanged }
)(NodesGrid);
+11 -6
View File
@@ -12,9 +12,10 @@ import Topologies from './topologies.js';
import TopologyOptions from './topology-options.js';
import { getApiDetails, getTopologies } from '../utils/web-api-utils';
import { focusSearch, pinNextMetric, hitBackspace, hitEnter, hitEsc, unpinMetric,
selectMetric, toggleHelp } from '../actions/app-actions';
selectMetric, toggleHelp, toggleGridMode } from '../actions/app-actions';
import Details from './details';
import Nodes from './nodes';
import GridModeSelector from './grid-mode-selector';
import MetricSelector from './metric-selector';
import NetworkSelector from './networks-selector';
import EmbeddedTerminal from './embedded-terminal';
@@ -86,6 +87,8 @@ class App extends React.Component {
dispatch(pinNextMetric(-1));
} else if (char === '>') {
dispatch(pinNextMetric(1));
} else if (char === 't' || char === 'g') {
dispatch(toggleGridMode());
} else if (char === 'q') {
dispatch(unpinMetric());
dispatch(selectMetric(null));
@@ -99,8 +102,8 @@ class App extends React.Component {
}
render() {
const { showingDetails, showingHelp, showingMetricsSelector, showingNetworkSelector,
showingTerminal } = this.props;
const { gridMode, showingDetails, showingHelp, showingMetricsSelector,
showingNetworkSelector, showingTerminal } = this.props;
const isIframe = window !== window.top;
return (
@@ -125,10 +128,11 @@ class App extends React.Component {
<Nodes />
<Sidebar>
<Sidebar classNames={gridMode ? 'sidebar-gridmode' : ''}>
{showingMetricsSelector && !gridMode && <MetricSelector />}
{showingNetworkSelector && !gridMode && <NetworkSelector />}
<GridModeSelector />
<Status />
{showingMetricsSelector && <MetricSelector />}
{showingNetworkSelector && <NetworkSelector />}
<TopologyOptions />
</Sidebar>
@@ -141,6 +145,7 @@ class App extends React.Component {
function mapStateToProps(state) {
return {
activeTopologyOptions: getActiveTopologyOptions(state),
gridMode: state.get('gridMode'),
routeSet: state.get('routeSet'),
searchFocused: state.get('searchFocused'),
searchQuery: state.get('searchQuery'),
@@ -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', 'Graph', !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);
@@ -4,6 +4,8 @@ const GENERAL_SHORTCUTS = [
{key: 'esc', label: 'Close active panel'},
{key: '/', label: 'Activate search field'},
{key: '?', label: 'Toggle shortcut menu'},
{key: 't', label: 'Toggle Table mode'},
{key: 'g', label: 'Toggle Graph mode'},
];
const CANVAS_METRIC_SHORTCUTS = [
@@ -1,4 +1,3 @@
import _ from 'lodash';
import React from 'react';
import { connect } from 'react-redux';
import { Map as makeMap } from 'immutable';
@@ -48,7 +47,7 @@ export class NodeDetails extends React.Component {
}
renderTools() {
const showSwitchTopology = this.props.index > 0;
const showSwitchTopology = this.props.nodeId !== this.props.selectedNodeId;
const topologyTitle = `View ${this.props.label} in ${this.props.topologyId}`;
return (
@@ -123,12 +122,6 @@ export class NodeDetails extends React.Component {
);
}
renderTable(table) {
const key = _.snakeCase(table.title);
return (<NodeDetailsTable title={table.title} key={key} rows={table.rows}
isNumeric={table.numeric} />);
}
render() {
if (this.props.notFound) {
return this.renderNotAvailable();
@@ -236,7 +229,8 @@ function mapStateToProps(state, ownProps) {
const currentTopologyId = state.get('currentTopologyId');
return {
nodeMatches: state.getIn(['searchNodeMatches', currentTopologyId, ownProps.id]),
nodes: state.get('nodes')
nodes: state.get('nodes'),
selectedNodeId: state.get('selectedNodeId'),
};
}
@@ -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>
);
@@ -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>
);
@@ -0,0 +1,148 @@
import React from 'react';
import ReactDOM from 'react-dom';
import classNames from 'classnames';
import NodeDetailsTableNodeLink from './node-details-table-node-link';
import NodeDetailsTableNodeMetric from './node-details-table-node-metric';
function getValuesForNode(node) {
const values = {};
['metrics', 'metadata'].forEach(collection => {
if (node[collection]) {
node[collection].forEach(field => {
const result = Object.assign({}, field);
result.valueType = collection;
values[field.id] = result;
});
}
});
(node.parents || []).forEach(p => {
values[p.topologyId] = {
id: p.topologyId,
label: p.topologyId,
value: p.label,
relative: p,
valueType: 'relatives',
};
});
return values;
}
function renderValues(node, columns = [], columnWidths = []) {
const fields = getValuesForNode(node);
return columns.map(({id}, i) => {
const field = fields[id];
const style = { width: columnWidths[i] };
if (field) {
if (field.valueType === 'metadata') {
return (
<td className="node-details-table-node-value truncate" title={field.value}
style={style}
key={field.id}>
{field.value}
</td>
);
}
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" style={style} key={id} />;
});
}
export default class NodeDetailsTableRow extends React.Component {
constructor(props, context) {
super(props, context);
//
// We watch how far the mouse moves when click on a row, move to much and we assume that the
// user is selecting some data in the row. In this case don't trigger the onClick event which
// is most likely a details panel popping open.
//
this.mouseDragOrigin = [0, 0];
this.storeLabelRef = this.storeLabelRef.bind(this);
this.onMouseDown = this.onMouseDown.bind(this);
this.onMouseUp = this.onMouseUp.bind(this);
this.onMouseEnter = this.onMouseEnter.bind(this);
this.onMouseLeave = this.onMouseLeave.bind(this);
}
storeLabelRef(ref) {
this.labelEl = ref;
}
onMouseEnter() {
const { node, onMouseEnterRow } = this.props;
onMouseEnterRow(node);
}
onMouseLeave() {
const { node, onMouseLeaveRow } = this.props;
onMouseLeaveRow(node);
}
onMouseDown(ev) {
const { pageX, pageY } = ev;
this.mouseDragOrigin = [pageX, pageY];
}
onMouseUp(ev) {
const [originX, originY] = this.mouseDragOrigin;
const { pageX, pageY } = ev;
const thresholdPx = 2;
const movedTheMouseTooMuch = (
Math.abs(originX - pageX) > thresholdPx ||
Math.abs(originY - pageY) > thresholdPx
);
if (movedTheMouseTooMuch) {
return;
}
const { node, onClick } = this.props;
onClick(ev, node, ReactDOM.findDOMNode(this.labelEl));
}
render() {
const { node, nodeIdKey, topologyId, columns, onClick, onMouseEnterRow, onMouseLeaveRow,
selected, widths } = this.props;
const [firstColumnWidth, ...columnWidths] = widths;
const values = renderValues(node, columns, columnWidths);
const nodeId = node[nodeIdKey];
const className = classNames('node-details-table-node', { selected });
return (
<tr
onMouseDown={onClick && this.onMouseDown}
onMouseUp={onClick && this.onMouseUp}
onMouseEnter={onMouseEnterRow && this.onMouseEnter}
onMouseLeave={onMouseLeaveRow && this.onMouseLeave}
className={className}>
<td ref={this.storeLabelRef} 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} />
};
@@ -1,34 +1,142 @@
import _ from 'lodash';
import React from 'react';
import classNames from 'classnames';
import ShowMore from '../show-more';
import NodeDetailsTableNodeLink from './node-details-table-node-link';
import NodeDetailsTableNodeMetric from './node-details-table-node-metric';
import NodeDetailsTableRow from './node-details-table-row';
function isNumberField(field) {
return field.dataType && field.dataType === 'number';
}
const CW = {
S: '44px',
M: '80px',
L: '140px',
XL: '170px',
};
const COLUMN_WIDTHS = {
count: '70px',
docker_container_created: CW.L,
docker_container_restart_count: CW.M,
docker_container_state_human: CW.XL,
docker_container_uptime: '85px',
docker_cpu_total_usage: CW.M,
docker_memory_usage: CW.M,
open_files_count: CW.M,
pid: CW.M,
port: '44px',
count: '70px'
ppid: CW.M,
process_cpu_usage_percent: CW.M,
process_memory_usage_bytes: CW.M,
threads: CW.M,
};
function getDefaultSortBy(columns, nodes) {
// default sorter specified by columns
const defaultSortColumn = _.find(columns, {defaultSort: true});
if (defaultSortColumn) {
return defaultSortColumn.id;
}
// otherwise choose first metric
return _.get(nodes, [0, 'metrics', 0, 'id']);
}
function getValueForSortBy(sortBy) {
// return the node's value based on the sortBy field
return (node) => {
if (sortBy !== null) {
let field = _.union(node.metrics, node.metadata).find(f => f.id === sortBy);
if (!field && node.parents) {
field = node.parents.find(f => f.topologyId === sortBy);
if (field) {
return field.label;
}
}
if (field) {
if (isNumberField(field)) {
return parseFloat(field.value);
}
return field.value;
}
}
return null;
};
}
function getMetaDataSorters(nodes) {
// returns an array of sorters that will take a node
return _.get(nodes, [0, 'metadata'], []).map((field, index) => node => {
const nodeMetadataField = node.metadata && node.metadata[index];
if (nodeMetadataField) {
if (isNumberField(nodeMetadataField)) {
return parseFloat(nodeMetadataField.value);
}
return nodeMetadataField.value;
}
return null;
});
}
function getSortedNodes(nodes, columns, sortBy, sortedDesc) {
const sortedNodes = _.sortBy(
nodes,
getValueForSortBy(sortBy || getDefaultSortBy(columns, nodes)),
'label',
getMetaDataSorters(nodes)
);
if (sortedDesc) {
sortedNodes.reverse();
}
return sortedNodes;
}
function getColumnsWidths(headers) {
return headers.map((h, i) => {
//
// Beauty hack: adjust first column width if there are only few columns;
// this assumes the other columns are narrow metric columns of 20% table width
//
if (i === 0) {
if (headers.length === 2) {
return '66%';
} else if (headers.length === 3) {
return '50%';
} else if (headers.length > 3 && headers.length <= 5) {
return '33%';
}
}
//
// More beauty hacking, ports and counts can only get so big, free up WS for other longer
// fields like IPs!
//
return COLUMN_WIDTHS[h.id];
});
}
export default class NodeDetailsTable extends React.Component {
constructor(props, context) {
super(props, context);
this.DEFAULT_LIMIT = 5;
this.state = {
limit: this.DEFAULT_LIMIT,
sortedDesc: true,
sortBy: null
limit: props.limit || this.DEFAULT_LIMIT,
sortedDesc: this.props.sortedDesc,
sortBy: this.props.sortBy
};
this.handleLimitClick = this.handleLimitClick.bind(this);
this.getValueForSortBy = this.getValueForSortBy.bind(this);
}
handleHeaderClick(ev, headerId) {
@@ -37,6 +145,7 @@ export default class NodeDetailsTable extends React.Component {
? !this.state.sortedDesc : this.state.sortedDesc;
const sortBy = headerId;
this.setState({sortBy, sortedDesc});
this.props.onSortChange(sortBy, sortedDesc);
}
handleLimitClick() {
@@ -44,109 +153,42 @@ export default class NodeDetailsTable extends React.Component {
this.setState({limit});
}
getDefaultSortBy() {
// default sorter specified by columns
const defaultSortColumn = _.find(this.props.columns, {defaultSort: true});
if (defaultSortColumn) {
return defaultSortColumn.id;
}
// otherwise choose first metric
return _.get(this.props.nodes, [0, 'metrics', 0, 'id']);
}
getMetaDataSorters() {
// returns an array of sorters that will take a node
return _.get(this.props.nodes, [0, 'metadata'], []).map((field, index) => node => {
const nodeMetadataField = node.metadata[index];
if (nodeMetadataField) {
if (isNumberField(nodeMetadataField)) {
return parseFloat(nodeMetadataField.value);
}
return nodeMetadataField.value;
}
return null;
});
}
getValueForSortBy(node) {
// return the node's value based on the sortBy field
const sortBy = this.state.sortBy || this.getDefaultSortBy();
if (sortBy !== null) {
const field = _.union(node.metrics, node.metadata).find(f => f.id === sortBy);
if (field) {
if (isNumberField(field)) {
return parseFloat(field.value);
}
return field.value;
}
}
return -1e-10; // just under 0 to treat missing values differently from 0
}
getValuesForNode(node) {
const values = {};
['metrics', 'metadata'].forEach(collection => {
if (node[collection]) {
node[collection].forEach(field => {
const result = Object.assign({}, field);
result.valueType = collection;
values[field.id] = result;
});
}
});
return values;
getColumnHeaders() {
const columns = this.props.columns || [];
return [{id: 'label', label: this.props.label}].concat(columns);
}
renderHeaders() {
if (this.props.nodes && this.props.nodes.length > 0) {
const columns = this.props.columns || [];
const headers = [{id: 'label', label: this.props.label}].concat(columns);
const defaultSortBy = this.getDefaultSortBy();
// Beauty hack: adjust first column width if there are only few columns;
// this assumes the other columns are narrow metric columns of 20% table width
if (headers.length === 2) {
headers[0].width = '66%';
} else if (headers.length === 3) {
headers[0].width = '50%';
} else if (headers.length >= 3) {
headers[0].width = '33%';
}
//
// More beauty hacking, ports and counts can only get so big, free up WS for other longer
// fields like IPs!
//
headers.forEach(h => {
h.width = COLUMN_WIDTHS[h.id];
});
const headers = this.getColumnHeaders();
const widths = getColumnsWidths(headers);
const defaultSortBy = getDefaultSortBy(this.props.columns, this.props.nodes);
return (
<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
@@ -161,66 +203,53 @@ export default class NodeDetailsTable extends React.Component {
return '';
}
renderValues(node) {
const fields = this.getValuesForNode(node);
const columns = this.props.columns || [];
return columns.map(({id}) => {
const field = fields[id];
if (field) {
if (field.valueType === 'metadata') {
return (
<td className="node-details-table-node-value truncate" title={field.value}
key={field.id}>
{field.value}
</td>
);
}
return <NodeDetailsTableNodeMetric key={field.id} {...field} />;
}
// empty cell to complete the row for proper hover
return <td className="node-details-table-node-value" key={id} />;
});
}
render() {
const headers = this.renderHeaders();
const { nodeIdKey } = this.props;
let nodes = _.sortBy(this.props.nodes, this.getValueForSortBy, 'label',
this.getMetaDataSorters());
const { nodeIdKey, columns, topologyId, onClickRow, onMouseEnter, onMouseLeave,
onMouseEnterRow, onMouseLeaveRow } = this.props;
let nodes = getSortedNodes(this.props.nodes, this.props.columns, this.state.sortBy,
this.state.sortedDesc);
const limited = nodes && this.state.limit > 0 && nodes.length > this.state.limit;
const expanded = this.state.limit === 0;
const notShown = nodes.length - this.DEFAULT_LIMIT;
if (this.state.sortedDesc) {
nodes.reverse();
}
const notShown = nodes.length - this.state.limit;
if (nodes && limited) {
nodes = nodes.slice(0, this.state.limit);
}
const className = classNames('node-details-table-wrapper-wrapper', this.props.className);
return (
<div className="node-details-table-wrapper">
<table className="node-details-table">
<thead>
{headers}
</thead>
<tbody>
{nodes && nodes.map(node => {
const values = this.renderValues(node);
const nodeId = node[nodeIdKey];
return (
<tr className="node-details-table-node" key={node.id}>
<td className="node-details-table-node-label truncate">
<NodeDetailsTableNodeLink {...node} topologyId={this.props.topologyId}
nodeId={nodeId} />
</td>
{values}
</tr>
);
})}
</tbody>
</table>
<ShowMore handleClick={this.handleLimitClick} collection={this.props.nodes}
expanded={expanded} notShown={notShown} />
<div className={className}
style={this.props.style}>
<div className="node-details-table-wrapper">
<table className="node-details-table">
<thead>
{headers}
</thead>
<tbody style={this.props.tbodyStyle} onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}>
{nodes && nodes.map(node => (
<NodeDetailsTableRow
key={node.id}
renderIdCell={this.props.renderIdCell}
selected={this.props.selectedNodeId === node.id}
node={node}
nodeIdKey={nodeIdKey}
widths={getColumnsWidths(this.getColumnHeaders())}
columns={columns}
onClick={onClickRow}
onMouseLeaveRow={onMouseLeaveRow}
onMouseEnterRow={onMouseEnterRow}
topologyId={topologyId} />
))}
</tbody>
</table>
<ShowMore
handleClick={this.handleLimitClick}
collection={this.props.nodes}
expanded={expanded}
notShown={notShown} />
</div>
</div>
);
}
@@ -228,5 +257,7 @@ export default class NodeDetailsTable extends React.Component {
NodeDetailsTable.defaultProps = {
nodeIdKey: 'id' // key to identify a node in a row (used for topology links)
nodeIdKey: 'id', // key to identify a node in a row (used for topology links)
onSortChange: () => {},
sortedDesc: true,
};
+23 -14
View File
@@ -2,14 +2,15 @@ import React from 'react';
import { connect } from 'react-redux';
import NodesChart from '../charts/nodes-chart';
import NodesGrid from '../charts/nodes-grid';
import NodesError from '../charts/nodes-error';
import { DelayedShow } from '../utils/delayed-show';
import { Loading, getNodeType } from './loading';
import { isTopologyEmpty } from '../utils/topology-utils';
import { CANVAS_MARGINS } from '../constants/styles';
const navbarHeight = 160;
const navbarHeight = 194;
const marginTop = 0;
const detailsWidth = 450;
/**
@@ -66,25 +67,31 @@ class Nodes extends React.Component {
}
render() {
const { nodes, selectedNodeId, topologyEmpty, topologiesLoaded, nodesLoaded, topologies,
topology } = this.props;
const { nodes, topologyEmpty, gridMode, topologiesLoaded, nodesLoaded, topologies,
currentTopology } = this.props;
const layoutPrecision = getLayoutPrecision(nodes.size);
const hasSelectedNode = selectedNodeId && nodes.has(selectedNodeId);
return (
<div className="nodes-wrapper">
<DelayedShow delay={1000} show={!topologiesLoaded || (topologiesLoaded && !nodesLoaded)}>
<Loading itemType="topologies" show={!topologiesLoaded} />
<Loading
itemType={getNodeType(topology, topologies)}
itemType={getNodeType(currentTopology, topologies)}
show={topologiesLoaded && !nodesLoaded} />
</DelayedShow>
{this.renderEmptyTopologyError(topologiesLoaded && nodesLoaded && topologyEmpty)}
<NodesChart {...this.state}
detailsWidth={detailsWidth}
layoutPrecision={layoutPrecision}
hasSelectedNode={hasSelectedNode}
/>
{gridMode ?
<NodesGrid {...this.state}
nodeSize="24"
nodes={nodes}
margins={CANVAS_MARGINS}
/> :
<NodesChart {...this.state}
nodes={nodes}
margins={CANVAS_MARGINS}
layoutPrecision={layoutPrecision}
/>}
</div>
);
}
@@ -101,18 +108,20 @@ class Nodes extends React.Component {
}
}
function mapStateToProps(state) {
return {
nodes: state.get('nodes'),
currentTopology: state.get('currentTopology'),
gridMode: state.get('gridMode'),
nodes: state.get('nodes').filter(node => !node.get('filtered')),
nodesLoaded: state.get('nodesLoaded'),
selectedNodeId: state.get('selectedNodeId'),
topologies: state.get('topologies'),
topologiesLoaded: state.get('topologiesLoaded'),
topologyEmpty: isTopologyEmpty(state),
topology: state.get('currentTopology'),
};
}
export default connect(
mapStateToProps
)(Nodes);
+3 -2
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>
);
@@ -55,6 +55,8 @@ const ACTION_TYPES = [
'UNPIN_NETWORK',
'SHOW_NETWORKS',
'SET_RECEIVED_NODES_DELTA',
'SORT_ORDER_CHANGED',
'SET_GRID_MODE',
];
export default _.zipObject(ACTION_TYPES, ACTION_TYPES);
+7
View File
@@ -10,3 +10,10 @@ export const DETAILS_PANEL_MARGINS = {
export const DETAILS_PANEL_OFFSET = 8;
export const CANVAS_METRIC_FONT_SIZE = 0.19;
export const CANVAS_MARGINS = {
top: 160,
left: 40,
right: 40,
bottom: 100,
};
+24 -2
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');
@@ -28,6 +28,9 @@ export const initialState = makeMap({
currentTopologyId: 'containers',
errorUrl: null,
forceRelayout: false,
gridMode: false,
gridSortBy: null,
gridSortedDesc: true,
highlightedEdgeIds: makeSet(),
highlightedNodeIds: makeSet(),
hostname: '...',
@@ -79,7 +82,8 @@ function processTopologies(state, nextTopologies) {
state = state.set('topologyUrlsById',
setTopologyUrlsById(state.get('topologyUrlsById'), topologiesWithId));
const immNextTopologies = fromJS(topologiesWithId).sortBy(topologySorter);
const topologiesWithFullnames = addTopologyFullname(topologiesWithId);
const immNextTopologies = fromJS(topologiesWithFullnames).sortBy(topologySorter);
return state.mergeDeepIn(['topologies'], immNextTopologies);
}
@@ -166,6 +170,17 @@ export function rootReducer(state = initialState, action) {
return state.set('exportingGraph', action.exporting);
}
case ActionTypes.SORT_ORDER_CHANGED: {
return state.merge({
gridSortBy: action.sortBy,
gridSortedDesc: action.sortedDesc,
});
}
case ActionTypes.SET_GRID_MODE: {
return state.setIn(['gridMode'], action.enabled);
}
case ActionTypes.CLEAR_CONTROL_ERROR: {
return state.removeIn(['controlStatus', action.nodeId, 'error']);
}
@@ -625,6 +640,13 @@ export function rootReducer(state = initialState, action) {
selectedNodeId: action.state.selectedNodeId,
pinnedMetricType: action.state.pinnedMetricType
});
state = state.set('gridMode', action.state.topologyViewMode === 'grid');
if (action.state.gridSortBy) {
state = state.set('gridSortBy', action.state.gridSortBy);
}
if (action.state.gridSortedDesc !== undefined) {
state = state.set('gridSortedDesc', action.state.gridSortedDesc);
}
if (action.state.showingNetworks) {
state = state.set('showingNetworks', action.state.showingNetworks);
}
+3
View File
@@ -39,11 +39,14 @@ export function getUrlState(state) {
const urlState = {
controlPipe: cp ? cp.toJS() : null,
topologyViewMode: state.get('gridMode') ? 'grid' : 'topo',
nodeDetails: nodeDetails.toJS(),
pinnedMetricType: state.get('pinnedMetricType'),
pinnedSearches: state.get('pinnedSearches').toJS(),
searchQuery: state.get('searchQuery'),
selectedNodeId: state.get('selectedNodeId'),
gridSortBy: state.get('gridSortBy'),
gridSortedDesc: state.get('gridSortedDesc'),
topologyId: state.get('currentTopologyId'),
topologyOptions: state.get('topologyOptions').toJS() // all options
};
@@ -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) {
@@ -32,7 +32,7 @@ function maybeUpdate(getState) {
receiveNodesDelta(delta);
}
if (deltaBuffer.size > 0) {
updateTimer = setTimeout(maybeUpdate, feedInterval);
updateTimer = setTimeout(() => maybeUpdate(getState), feedInterval);
}
}
}
+129 -6
View File
@@ -269,7 +269,7 @@ h2 {
cursor: pointer;
padding: 4px 8px;
border-radius: @border-radius;
opacity: 0.8;
opacity: 0.9;
margin-bottom: 3px;
border: 1px solid transparent;
@@ -298,6 +298,7 @@ h2 {
&-error, &-loading {
.hideable;
pointer-events: none;
position: absolute;
left: 50%;
top: 50%;
@@ -889,7 +890,7 @@ h2 {
font-size: 105%;
line-height: 1.5;
&:hover {
&:hover, &.selected {
background-color: lighten(@background-color, 5%);
}
@@ -1130,7 +1131,7 @@ h2 {
}
}
.topology-option, .metric-selector, .network-selector {
.topology-option, .metric-selector, .network-selector, .grid-mode-selector {
color: @text-secondary-color;
margin: 6px 0;
@@ -1182,6 +1183,12 @@ h2 {
}
}
.grid-mode-selector .fa {
margin-right: 4px;
margin-left: 0;
color: @text-secondary-color;
}
.network-selector-action {
border-top: 3px solid transparent;
border-bottom: 3px solid @background-dark-color;
@@ -1225,9 +1232,18 @@ h2 {
.sidebar {
position: fixed;
bottom: 16px;
left: 16px;
bottom: 12px;
left: 12px;
padding: 4px;
font-size: .7rem;
border-radius: 8px;
border: 1px solid transparent;
}
.sidebar-gridmode {
background-color: #e9e9f1;
border-color: @background-darker-color;
opacity: 0.9;
}
.search {
@@ -1401,7 +1417,7 @@ h2 {
//
@help-panel-width: 400px;
@help-panel-height: 380px;
@help-panel-height: 420px;
.help-panel {
position: absolute;
-webkit-transform: translate3d(0, 0, 0);
@@ -1490,3 +1506,110 @@ h2 {
}
}
}
//
// Nodes grid.
//
.nodes-grid {
tr {
border-radius: 6px;
}
&-label-minor {
opacity: 0.7;
}
&-id-column {
margin: -3px -4px;
padding: 2px 4px;
display: flex;
div {
flex: 1;
}
}
.node-details-table-wrapper-wrapper {
flex: 1;
display: flex;
flex-direction: row;
width: 100%;
.node-details-table-wrapper {
margin: 0;
flex: 1;
}
.nodes-grid-graph {
position: relative;
margin-top: 24px;
}
.node-details-table-node > * {
padding: 3px 4px;
}
.node-details-table-node, thead tr {
height: 24px;
}
tr:nth-child(even) {
background: @background-color;
}
tbody tr {
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
}
tbody tr.selected, tbody tr:hover {
background-color: #d7ecf5;
border: 1px solid @weave-blue;
}
tbody tr.selected {
// box-shadow: 0 4px 2px -2px rgba(0, 0, 0, 0.16);
}
}
.scroll-body {
table {
border-bottom: 1px solid #ccc;
}
thead {
// osx scrollbar width: 0
// linux scrollbar width: 16
// avg scrollbar width: 8
padding-right: 8px;
}
thead, tbody tr {
display: table;
width: 100%;
table-layout: fixed;
}
tbody:after {
content: '';
display: block;
// height of the controls so you can scroll the last row up above them
// and have a good look.
height: 140px;
}
thead {
box-shadow: 0 4px 2px -2px rgba(0, 0, 0, 0.16);
border-bottom: 1px solid #aaa;
}
tbody {
display: block;
overflow-y: scroll;
}
}
}
-2
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),
+29 -29
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",
+2
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(),
}