diff --git a/client/app/scripts/components/node-details/node-details-health-item.js b/client/app/scripts/components/node-details/node-details-health-item.js
new file mode 100644
index 000000000..7ddeba23f
--- /dev/null
+++ b/client/app/scripts/components/node-details/node-details-health-item.js
@@ -0,0 +1,17 @@
+import React from 'react';
+
+import Sparkline from '../sparkline';
+import { formatMetric } from '../../utils/string-utils';
+
+export default (props) => {
+ return (
+
+ );
+};
diff --git a/client/app/scripts/components/node-details/node-details-health-overflow-item.js b/client/app/scripts/components/node-details/node-details-health-overflow-item.js
new file mode 100644
index 000000000..1e59d6cfe
--- /dev/null
+++ b/client/app/scripts/components/node-details/node-details-health-overflow-item.js
@@ -0,0 +1,14 @@
+import React from 'react';
+
+import { formatMetric } from '../../utils/string-utils';
+
+export default class NodeDetailsHealthOverflowItem extends React.Component {
+ render() {
+ return (
+
+ );
+ }
+}
diff --git a/client/app/scripts/components/node-details/node-details-health-overflow.js b/client/app/scripts/components/node-details/node-details-health-overflow.js
new file mode 100644
index 000000000..260786fdc
--- /dev/null
+++ b/client/app/scripts/components/node-details/node-details-health-overflow.js
@@ -0,0 +1,18 @@
+import React from 'react';
+
+import NodeDetailsHealthOverflowItem from './node-details-health-overflow-item';
+
+export default class NodeDetailsHealthOverflow extends React.Component {
+ render() {
+ const items = this.props.items.slice(0, 4);
+
+ return (
+
+ );
+ }
+}
diff --git a/client/app/scripts/components/node-details/node-details-health.js b/client/app/scripts/components/node-details/node-details-health.js
new file mode 100644
index 000000000..3f4ecc444
--- /dev/null
+++ b/client/app/scripts/components/node-details/node-details-health.js
@@ -0,0 +1,42 @@
+import React from 'react';
+
+import NodeDetailsHealthOverflow from './node-details-health-overflow';
+import NodeDetailsHealthItem from './node-details-health-item';
+
+export default class NodeDetailsHealth extends React.Component {
+
+ constructor(props, context) {
+ super(props, context);
+ this.state = {
+ expanded: false
+ };
+ this.handleClickMore = this.handleClickMore.bind(this);
+ }
+
+ handleClickMore(ev) {
+ ev.preventDefault();
+ const expanded = !this.state.expanded;
+ this.setState({expanded});
+ }
+
+ render() {
+ const metrics = this.props.metrics || [];
+ const primeCutoff = metrics.length > 3 && !this.state.expanded ? 2 : metrics.length;
+ const primeMetrics = metrics.slice(0, primeCutoff);
+ const overflowMetrics = metrics.slice(primeCutoff);
+ const showOverflow = overflowMetrics.length > 0 && !this.state.expanded;
+ const showLess = this.state.expanded;
+ const flexWrap = showOverflow || !this.state.expanded ? 'nowrap' : 'wrap';
+ const justifyContent = showOverflow || !this.state.expanded ? 'space-around' : 'flex-start';
+
+ return (
+
+ );
+ }
+}
diff --git a/client/app/scripts/components/node-details/node-details-info.js b/client/app/scripts/components/node-details/node-details-info.js
new file mode 100644
index 000000000..1f9b2e27d
--- /dev/null
+++ b/client/app/scripts/components/node-details/node-details-info.js
@@ -0,0 +1,24 @@
+import React from 'react';
+
+export default class NodeDetailsInfo extends React.Component {
+ render() {
+ return (
+
+ );
+ }
+}
diff --git a/client/app/scripts/components/node-details/node-details-relatives-link.js b/client/app/scripts/components/node-details/node-details-relatives-link.js
new file mode 100644
index 000000000..315f7d026
--- /dev/null
+++ b/client/app/scripts/components/node-details/node-details-relatives-link.js
@@ -0,0 +1,27 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+
+import { clickRelative } from '../../actions/app-actions';
+
+export default class NodeDetailsRelativesLink extends React.Component {
+
+ constructor(props, context) {
+ super(props, context);
+ this.handleClick = this.handleClick.bind(this);
+ }
+
+ handleClick(ev) {
+ ev.preventDefault();
+ clickRelative(this.props.id, this.props.topologyId, this.props.label,
+ ReactDOM.findDOMNode(this).getBoundingClientRect());
+ }
+
+ render() {
+ const title = `View in ${this.props.topologyId}: ${this.props.label}`;
+ return (
+
+ );
+ }
+}
diff --git a/client/app/scripts/components/node-details/node-details-relatives.js b/client/app/scripts/components/node-details/node-details-relatives.js
new file mode 100644
index 000000000..b0813bc27
--- /dev/null
+++ b/client/app/scripts/components/node-details/node-details-relatives.js
@@ -0,0 +1,40 @@
+import React from 'react';
+
+import NodeDetailsRelativesLink from './node-details-relatives-link';
+
+export default class NodeDetailsRelatives extends React.Component {
+
+ constructor(props, context) {
+ super(props, context);
+ this.DEFAULT_LIMIT = 5;
+ this.state = {
+ limit: this.DEFAULT_LIMIT
+ };
+ this.handleLimitClick = this.handleLimitClick.bind(this);
+ }
+
+ handleLimitClick(ev) {
+ ev.preventDefault();
+ const limit = this.state.limit ? 0 : this.DEFAULT_LIMIT;
+ this.setState({limit: limit});
+ }
+
+ render() {
+ let relatives = this.props.relatives;
+ const limited = this.state.limit > 0 && relatives.length > this.state.limit;
+ const showLimitAction = limited || (this.state.limit === 0 && relatives.length > this.DEFAULT_LIMIT);
+ const limitActionText = limited ? 'Show more' : 'Show less';
+ if (limited) {
+ relatives = relatives.slice(0, this.state.limit);
+ }
+
+ return (
+
+ );
+ }
+}
diff --git a/client/app/scripts/components/node-details/node-details-table-node-link.js b/client/app/scripts/components/node-details/node-details-table-node-link.js
new file mode 100644
index 000000000..3e2c1f554
--- /dev/null
+++ b/client/app/scripts/components/node-details/node-details-table-node-link.js
@@ -0,0 +1,40 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+
+import { clickRelative } from '../../actions/app-actions';
+
+export default class NodeDetailsTableNodeLink extends React.Component {
+
+ constructor(props, context) {
+ super(props, context);
+ this.handleClick = this.handleClick.bind(this);
+ }
+
+ handleClick(ev) {
+ ev.preventDefault();
+ clickRelative(this.props.id, this.props.topologyId, this.props.label,
+ ReactDOM.findDOMNode(this).getBoundingClientRect());
+ }
+
+ render() {
+ const titleLines = [`${this.props.label} (${this.props.topologyId})`];
+ this.props.metadata.forEach(data => {
+ titleLines.push(`${data.label}: ${data.value}`);
+ });
+ const title = titleLines.join('\n');
+
+ if (this.props.linkable) {
+ return (
+
+ );
+ }
+}
diff --git a/client/app/scripts/components/node-details/node-details-table-row-number.js b/client/app/scripts/components/node-details/node-details-table-row-number.js
deleted file mode 100644
index d41eaa10a..000000000
--- a/client/app/scripts/components/node-details/node-details-table-row-number.js
+++ /dev/null
@@ -1,13 +0,0 @@
-import React from 'react';
-
-export default class NodeDetailsTableRowNumber extends React.Component {
- render() {
- const row = this.props.row;
- return (
-
- );
- }
-}
diff --git a/client/app/scripts/components/node-details/node-details-table-row-sparkline.js b/client/app/scripts/components/node-details/node-details-table-row-sparkline.js
deleted file mode 100644
index f40b77d0f..000000000
--- a/client/app/scripts/components/node-details/node-details-table-row-sparkline.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import React from 'react';
-
-import Sparkline from '../sparkline';
-
-export default class NodeDetailsTableRowSparkline extends React.Component {
- render() {
- const row = this.props.row;
- return (
-
- );
- }
-}
diff --git a/client/app/scripts/components/node-details/node-details-table-row-value.js b/client/app/scripts/components/node-details/node-details-table-row-value.js
deleted file mode 100644
index 1dc765eb5..000000000
--- a/client/app/scripts/components/node-details/node-details-table-row-value.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import React from 'react';
-
-export default class NodeDetailsTableRowValue extends React.Component {
- render() {
- const row = this.props.row;
- return (
-
- );
- }
-}
diff --git a/client/app/scripts/components/node-details/node-details-table.js b/client/app/scripts/components/node-details/node-details-table.js
index b95281b26..26b290c99 100644
--- a/client/app/scripts/components/node-details/node-details-table.js
+++ b/client/app/scripts/components/node-details/node-details-table.js
@@ -1,33 +1,156 @@
+import _ from 'lodash';
import React from 'react';
-import NodeDetailsTableRowValue from './node-details-table-row-value';
-import NodeDetailsTableRowNumber from './node-details-table-row-number';
-import NodeDetailsTableRowSparkline from './node-details-table-row-sparkline';
+import NodeDetailsTableNodeLink from './node-details-table-node-link';
+import { formatMetric } from '../../utils/string-utils';
export default class NodeDetailsTable extends React.Component {
- render() {
- return (
-
-
- {this.props.title}
-
- {this.props.rows.map(function(row) {
- let valueComponent;
- if (row.value_type === 'numeric') {
- valueComponent =
;
- } else if (row.value_type === 'sparkline') {
- valueComponent =
;
- } else {
- valueComponent =
;
- }
- return (
-
-
{row.key}
- {valueComponent}
-
- );
- })}
+ constructor(props, context) {
+ super(props, context);
+ this.DEFAULT_LIMIT = 5;
+ this.state = {
+ limit: this.DEFAULT_LIMIT,
+ sortedDesc: true,
+ sortBy: null
+ };
+ this.handleLimitClick = this.handleLimitClick.bind(this);
+ this.getValueForSortBy = this.getValueForSortBy.bind(this);
+ }
+
+ handleHeaderClick(ev, headerId) {
+ ev.preventDefault();
+ const sortedDesc = headerId === this.state.sortBy ? !this.state.sortedDesc : this.state.sortedDesc;
+ const sortBy = headerId;
+ this.setState({sortBy, sortedDesc});
+ }
+
+ handleLimitClick(ev) {
+ ev.preventDefault();
+ const limit = this.state.limit ? 0 : this.DEFAULT_LIMIT;
+ this.setState({limit});
+ }
+
+ getDefaultSortBy() {
+ // 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) => {
+ return node => node.metadata[index] ? node.metadata[index].value : 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) {
+ return field.value;
+ }
+ }
+ return 0;
+ }
+
+ getValuesForNode(node) {
+ const values = {};
+ ['metrics', 'metadata'].forEach(collection => {
+ if (node[collection]) {
+ node[collection].forEach(field => {
+ values[field.id] = field;
+ });
+ }
+ });
+ return values;
+ }
+
+ renderHeaders() {
+ if (this.props.nodes && this.props.nodes.length > 0) {
+ let headers = [{id: 'label', label: this.props.label}];
+ // gather header labels from metrics and metadata
+ const firstValues = this.getValuesForNode(this.props.nodes[0]);
+ headers = headers.concat(this.props.columns.map(column => ({id: column, label: firstValues[column].label})));
+ const defaultSortBy = this.getDefaultSortBy();
+
+ return (
+
+ {headers.map(header => {
+ 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 isSortedDesc = isSorted && this.state.sortedDesc;
+ const isSortedAsc = isSorted && !isSortedDesc;
+ if (isSorted) {
+ headerClasses.push('node-details-table-header-sorted');
+ }
+ return (
+ |
+ {isSortedAsc && }
+ {isSortedDesc && }
+ {header.label}
+ |
+ );
+ })}
+
+ );
+ }
+ return '';
+ }
+
+ renderValues(node) {
+ const fields = this.getValuesForNode(node);
+ return this.props.columns.map(col => {
+ const field = fields[col];
+ if (field) {
+ return (
+
+ {formatMetric(field.value, field)}
+ |
+ );
+ }
+ });
+ }
+
+ render() {
+ const headers = this.renderHeaders();
+ let nodes = _.sortByAll(this.props.nodes, this.getValueForSortBy, 'label', this.getMetaDataSorters());
+ const limited = nodes && this.state.limit > 0 && nodes.length > this.state.limit;
+ const showLimitAction = nodes && (limited || (this.state.limit === 0 && nodes.length > this.DEFAULT_LIMIT));
+ const limitActionText = limited ? 'Show more' : 'Show less';
+ if (this.state.sortedDesc) {
+ nodes.reverse();
+ }
+ if (nodes && limited) {
+ nodes = nodes.slice(0, this.state.limit);
+ }
+
+ return (
+
+
+
+ {headers}
+
+
+ {nodes && nodes.map(node => {
+ const values = this.renderValues(node);
+ return (
+
+ |
+
+ |
+ {values}
+
+ );
+ })}
+
+
+ {showLimitAction &&
{limitActionText}
}
);
}
diff --git a/client/app/scripts/components/sparkline.js b/client/app/scripts/components/sparkline.js
index f72fbe748..6831d5654 100644
--- a/client/app/scripts/components/sparkline.js
+++ b/client/app/scripts/components/sparkline.js
@@ -116,7 +116,7 @@ export default class Sparkline extends React.Component {
}
Sparkline.defaultProps = {
- width: 100,
+ width: 80,
height: 16,
strokeColor: '#7d7da8',
strokeWidth: '0.5px',
diff --git a/client/app/scripts/constants/action-types.js b/client/app/scripts/constants/action-types.js
index a4019dea4..0cdf0b39a 100644
--- a/client/app/scripts/constants/action-types.js
+++ b/client/app/scripts/constants/action-types.js
@@ -3,9 +3,12 @@ import _ from 'lodash';
const ACTION_TYPES = [
'CHANGE_TOPOLOGY_OPTION',
'CLEAR_CONTROL_ERROR',
+ 'CLICK_BACKGROUND',
'CLICK_CLOSE_DETAILS',
'CLICK_CLOSE_TERMINAL',
'CLICK_NODE',
+ 'CLICK_RELATIVE',
+ 'CLICK_SHOW_TOPOLOGY_FOR_NODE',
'CLICK_TERMINAL',
'CLICK_TOPOLOGY',
'CLOSE_WEBSOCKET',
@@ -23,6 +26,7 @@ const ACTION_TYPES = [
'RECEIVE_NODE_DETAILS',
'RECEIVE_NODES',
'RECEIVE_NODES_DELTA',
+ 'RECEIVE_NOT_FOUND',
'RECEIVE_TOPOLOGIES',
'RECEIVE_API_DETAILS',
'RECEIVE_ERROR',
diff --git a/client/app/scripts/stores/__tests__/app-store-test.js b/client/app/scripts/stores/__tests__/app-store-test.js
index 760426664..ca31a2fdc 100644
--- a/client/app/scripts/stores/__tests__/app-store-test.js
+++ b/client/app/scripts/stores/__tests__/app-store-test.js
@@ -51,6 +51,22 @@ describe('AppStore', function() {
nodeId: 'n1'
};
+ const ClickNode2Action = {
+ type: ActionTypes.CLICK_NODE,
+ nodeId: 'n2'
+ };
+
+ const ClickRelativeAction = {
+ type: ActionTypes.CLICK_RELATIVE,
+ nodeId: 'rel1'
+ };
+
+ const ClickShowTopologyForNodeAction = {
+ type: ActionTypes.CLICK_SHOW_TOPOLOGY_FOR_NODE,
+ topologyId: 'topo2',
+ nodeId: 'rel1'
+ };
+
const ClickSubTopologyAction = {
type: ActionTypes.CLICK_TOPOLOGY,
topologyId: 'topo1-grouped'
@@ -335,4 +351,77 @@ describe('AppStore', function() {
registeredCallback(ClickTopologyAction);
expect(AppStore.isTopologyEmpty()).toBeFalsy();
});
+
+ // selection of relatives
+
+ it('keeps relatives as a stack', function() {
+ registeredCallback(ClickNodeAction);
+ expect(AppStore.getSelectedNodeId()).toBe('n1');
+ expect(AppStore.getNodeDetails().size).toEqual(1);
+ expect(AppStore.getNodeDetails().has('n1')).toBeTruthy();
+ expect(AppStore.getNodeDetails().keySeq().last()).toEqual('n1');
+
+ registeredCallback(ClickRelativeAction);
+ // stack relative, first node stays main node
+ expect(AppStore.getSelectedNodeId()).toBe('n1');
+ expect(AppStore.getNodeDetails().keySeq().last()).toEqual('rel1');
+ expect(AppStore.getNodeDetails().size).toEqual(2);
+ expect(AppStore.getNodeDetails().has('rel1')).toBeTruthy();
+
+ // click on first node should clear the stack
+ registeredCallback(ClickNodeAction);
+ expect(AppStore.getSelectedNodeId()).toBe('n1');
+ expect(AppStore.getNodeDetails().keySeq().last()).toEqual('n1');
+ expect(AppStore.getNodeDetails().size).toEqual(1);
+ expect(AppStore.getNodeDetails().has('rel1')).toBeFalsy();
+ });
+
+ it('keeps clears stack when sibling is clicked', function() {
+ registeredCallback(ClickNodeAction);
+ expect(AppStore.getSelectedNodeId()).toBe('n1');
+ expect(AppStore.getNodeDetails().size).toEqual(1);
+ expect(AppStore.getNodeDetails().has('n1')).toBeTruthy();
+ expect(AppStore.getNodeDetails().keySeq().last()).toEqual('n1');
+
+ registeredCallback(ClickRelativeAction);
+ // stack relative, first node stays main node
+ expect(AppStore.getSelectedNodeId()).toBe('n1');
+ expect(AppStore.getNodeDetails().keySeq().last()).toEqual('rel1');
+ expect(AppStore.getNodeDetails().size).toEqual(2);
+ expect(AppStore.getNodeDetails().has('rel1')).toBeTruthy();
+
+ // click on sibling node should clear the stack
+ registeredCallback(ClickNode2Action);
+ expect(AppStore.getSelectedNodeId()).toBe('n2');
+ expect(AppStore.getNodeDetails().keySeq().last()).toEqual('n2');
+ expect(AppStore.getNodeDetails().size).toEqual(1);
+ expect(AppStore.getNodeDetails().has('n1')).toBeFalsy();
+ expect(AppStore.getNodeDetails().has('rel1')).toBeFalsy();
+ });
+
+ it('selectes relatives topology while keeping node selected', function() {
+ registeredCallback(ClickTopologyAction);
+ registeredCallback(ReceiveTopologiesAction);
+ expect(AppStore.getCurrentTopology().name).toBe('Topo1');
+
+ registeredCallback(ClickNodeAction);
+ expect(AppStore.getSelectedNodeId()).toBe('n1');
+ expect(AppStore.getNodeDetails().size).toEqual(1);
+ expect(AppStore.getNodeDetails().has('n1')).toBeTruthy();
+ expect(AppStore.getNodeDetails().keySeq().last()).toEqual('n1');
+
+ registeredCallback(ClickRelativeAction);
+ // stack relative, first node stays main node
+ expect(AppStore.getSelectedNodeId()).toBe('n1');
+ expect(AppStore.getNodeDetails().keySeq().last()).toEqual('rel1');
+ expect(AppStore.getNodeDetails().size).toEqual(2);
+ expect(AppStore.getNodeDetails().has('rel1')).toBeTruthy();
+
+ // click switches over to relative's topology and selectes relative
+ registeredCallback(ClickShowTopologyForNodeAction);
+ expect(AppStore.getSelectedNodeId()).toBe('rel1');
+ expect(AppStore.getNodeDetails().keySeq().last()).toEqual('rel1');
+ expect(AppStore.getNodeDetails().size).toEqual(1);
+ expect(AppStore.getCurrentTopology().name).toBe('Topo2');
+ });
});
diff --git a/client/app/scripts/stores/app-store.js b/client/app/scripts/stores/app-store.js
index fca513c1e..db93167d4 100644
--- a/client/app/scripts/stores/app-store.js
+++ b/client/app/scripts/stores/app-store.js
@@ -16,7 +16,7 @@ const error = debug('scope:error');
// Helpers
-function findCurrentTopology(subTree, topologyId) {
+function findTopologyById(subTree, topologyId) {
let foundTopology;
_.each(subTree, function(topology) {
@@ -24,7 +24,7 @@ function findCurrentTopology(subTree, topologyId) {
foundTopology = topology;
}
if (!foundTopology) {
- foundTopology = findCurrentTopology(topology.sub_topologies, topologyId);
+ foundTopology = findTopologyById(topology.sub_topologies, topologyId);
}
if (foundTopology) {
return false;
@@ -57,19 +57,22 @@ let hostname = '...';
let version = '...';
let mouseOverEdgeId = null;
let mouseOverNodeId = null;
+let nodeDetails = makeOrderedMap(); // nodeId -> details
let nodes = makeOrderedMap(); // nodeId -> node
-let nodeDetails = null;
let selectedNodeId = null;
let topologies = [];
let topologiesLoaded = false;
+let topologyUrlsById = makeOrderedMap(); // topologyId -> topologyUrl
let routeSet = false;
-let controlPipe = null;
+let controlPipes = makeOrderedMap(); // pipeId -> controlPipe
let websocketClosed = true;
+// adds ID field to topology (based on last part of URL path) and save urls in
+// map for easy lookup
function processTopologies(topologyList) {
- // adds ID field to topology, based on last part of URL path
_.each(topologyList, function(topology) {
topology.id = topology.url.split('/').pop();
+ topologyUrlsById = topologyUrlsById.set(topology.id, topology.url);
processTopologies(topology.sub_topologies);
});
return topologyList;
@@ -77,7 +80,7 @@ function processTopologies(topologyList) {
function setTopology(topologyId) {
currentTopologyId = topologyId;
- currentTopology = findCurrentTopology(topologies, topologyId);
+ currentTopology = findTopologyById(topologies, topologyId);
}
function setDefaultTopologyOptions(topologyList) {
@@ -102,10 +105,24 @@ function setDefaultTopologyOptions(topologyList) {
});
}
-function deSelectNode() {
- selectedNodeId = null;
- nodeDetails = null;
- controlPipe = null;
+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 => {
+ return pipe.nodeId !== popNodeId;
+ });
+ nodeDetails = nodeDetails.delete(popNodeId);
+ }
+ if (nodeDetails.size === 0 || selectedNodeId === nodeId) {
+ selectedNodeId = null;
+ }
+}
+
+function closeAllNodeDetails() {
+ while (nodeDetails.size) {
+ closeNodeDetails();
+ }
}
// Store API
@@ -115,9 +132,10 @@ export class AppStore extends Store {
// keep at the top
getAppState() {
return {
- topologyId: currentTopologyId,
- selectedNodeId: this.getSelectedNodeId(),
controlPipe: this.getControlPipe(),
+ nodeDetails: this.getNodeDetailsState(),
+ selectedNodeId: selectedNodeId,
+ topologyId: currentTopologyId,
topologyOptions: topologyOptions.toJS() // all options
};
}
@@ -148,7 +166,7 @@ export class AppStore extends Store {
}
getControlPipe() {
- return controlPipe;
+ return controlPipes.last();
}
getCurrentTopology() {
@@ -214,6 +232,12 @@ export class AppStore extends Store {
return nodeDetails;
}
+ getNodeDetailsState() {
+ return nodeDetails.toIndexedSeq().map(details => {
+ return {id: details.id, label: details.label, topologyId: details.topologyId};
+ }).toJS();
+ }
+
getNodes() {
return nodes;
}
@@ -226,6 +250,10 @@ export class AppStore extends Store {
return topologies;
}
+ getTopologyUrlsById() {
+ return topologyUrlsById;
+ }
+
getVersion() {
return version;
}
@@ -269,27 +297,76 @@ export class AppStore extends Store {
this.__emitChange();
break;
+ case ActionTypes.CLICK_BACKGROUND:
+ closeAllNodeDetails();
+ this.__emitChange();
+ break;
+
case ActionTypes.CLICK_CLOSE_DETAILS:
- deSelectNode();
+ closeNodeDetails(payload.nodeId);
this.__emitChange();
break;
case ActionTypes.CLICK_CLOSE_TERMINAL:
- controlPipe = null;
+ controlPipes = controlPipes.clear();
this.__emitChange();
break;
case ActionTypes.CLICK_NODE:
- deSelectNode();
- if (payload.nodeId !== selectedNodeId) {
- // select new node if it's not the same (in that case just delesect)
+ 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_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_SHOW_TOPOLOGY_FOR_NODE:
+ nodeDetails = nodeDetails.filter((v, k) => k === payload.nodeId);
+ selectedNodeId = payload.nodeId;
+ if (payload.topologyId !== currentTopologyId) {
+ setTopology(payload.topologyId);
+ nodes = nodes.clear();
+ }
+ this.__emitChange();
+ break;
+
case ActionTypes.CLICK_TOPOLOGY:
- deSelectNode();
+ closeAllNodeDetails();
if (payload.topologyId !== currentTopologyId) {
setTopology(payload.topologyId);
nodes = nodes.clear();
@@ -302,6 +379,11 @@ export class AppStore extends Store {
this.__emitChange();
break;
+ case ActionTypes.DESELECT_NODE:
+ closeNodeDetails();
+ this.__emitChange();
+ break;
+
case ActionTypes.DO_CONTROL:
controlStatus = controlStatus.set(payload.nodeId, makeMap({
pending: true,
@@ -320,11 +402,6 @@ export class AppStore extends Store {
this.__emitChange();
break;
- case ActionTypes.DESELECT_NODE:
- deSelectNode();
- this.__emitChange();
- break;
-
case ActionTypes.LEAVE_EDGE:
mouseOverEdgeId = null;
this.__emitChange();
@@ -360,16 +437,17 @@ export class AppStore extends Store {
break;
case ActionTypes.RECEIVE_CONTROL_PIPE:
- controlPipe = {
+ controlPipes = controlPipes.set(payload.pipeId, {
id: payload.pipeId,
+ nodeId: payload.nodeId,
raw: payload.rawTty
- };
+ });
this.__emitChange();
break;
case ActionTypes.RECEIVE_CONTROL_PIPE_STATUS:
- if (controlPipe) {
- controlPipe.status = payload.status;
+ if (controlPipes.has(payload.pipeId)) {
+ controlPipes = controlPipes.setIn([payload.pipeId, 'status'], payload.status);
this.__emitChange();
}
break;
@@ -382,8 +460,12 @@ export class AppStore extends Store {
case ActionTypes.RECEIVE_NODE_DETAILS:
errorUrl = null;
// disregard if node is not selected anymore
- if (payload.details.id === selectedNodeId) {
- nodeDetails = payload.details;
+ if (nodeDetails.has(payload.details.id)) {
+ nodeDetails = nodeDetails.update(payload.details.id, obj => {
+ obj.notFound = false;
+ obj.details = payload.details;
+ return obj;
+ });
}
this.__emitChange();
break;
@@ -415,7 +497,9 @@ export class AppStore extends Store {
// update existing nodes
_.each(payload.delta.update, function(node) {
- nodes = nodes.set(node.id, nodes.get(node.id).merge(makeNode(node)));
+ if (nodes.has(node.id)) {
+ nodes = nodes.set(node.id, nodes.get(node.id).merge(makeNode(node)));
+ }
});
// add new nodes
@@ -426,8 +510,19 @@ export class AppStore extends Store {
this.__emitChange();
break;
+ case ActionTypes.RECEIVE_NOT_FOUND:
+ if (nodeDetails.has(payload.nodeId)) {
+ nodeDetails = nodeDetails.update(payload.nodeId, obj => {
+ obj.notFound = true;
+ return obj;
+ });
+ this.__emitChange();
+ }
+ break;
+
case ActionTypes.RECEIVE_TOPOLOGIES:
errorUrl = null;
+ topologyUrlsById = topologyUrlsById.clear();
topologies = processTopologies(payload.topologies);
setTopology(currentTopologyId);
// only set on first load, if options are not already set via route
@@ -453,7 +548,18 @@ export class AppStore extends Store {
setTopology(payload.state.topologyId);
setDefaultTopologyOptions(topologies);
selectedNodeId = payload.state.selectedNodeId;
- controlPipe = payload.state.controlPipe;
+ if (payload.state.controlPipe) {
+ controlPipes = makeOrderedMap(
+ [[payload.state.controlPipe.pipeId, payload.state.controlPipe]]
+ );
+ } else {
+ controlPipes = controlPipes.clear();
+ }
+ if (payload.state.nodeDetails) {
+ nodeDetails = makeOrderedMap(payload.state.nodeDetails.map(obj => [obj.id, obj]));
+ } else {
+ nodeDetails = nodeDetails.clear();
+ }
topologyOptions = Immutable.fromJS(payload.state.topologyOptions)
|| topologyOptions;
this.__emitChange();
diff --git a/client/app/scripts/utils/__tests__/string-utils-test.js b/client/app/scripts/utils/__tests__/string-utils-test.js
new file mode 100644
index 000000000..4bf7d418e
--- /dev/null
+++ b/client/app/scripts/utils/__tests__/string-utils-test.js
@@ -0,0 +1,13 @@
+jest.dontMock('../string-utils');
+
+describe('StringUtils', function() {
+ const StringUtils = require('../string-utils');
+
+ describe('formatMetric', function() {
+ const formatMetric = StringUtils.formatMetric;
+
+ it('it should render 0', function() {
+ expect(formatMetric(0)).toBe(0);
+ });
+ });
+});
diff --git a/client/app/scripts/utils/string-utils.js b/client/app/scripts/utils/string-utils.js
new file mode 100644
index 000000000..e317641e9
--- /dev/null
+++ b/client/app/scripts/utils/string-utils.js
@@ -0,0 +1,31 @@
+import React from 'react';
+import filesize from 'filesize';
+
+const formatters = {
+ filesize(value) {
+ const obj = filesize(value, {output: 'object'});
+ return formatters.metric(obj.value, obj.suffix);
+ },
+
+ number(value) {
+ return value;
+ },
+
+ percent(value) {
+ return formatters.metric(value, '%');
+ },
+
+ metric(text, unit) {
+ return (
+
+ {text}
+ {unit}
+
+ );
+ }
+};
+
+export function formatMetric(value, opts) {
+ const formatter = opts && formatters[opts.format] ? opts.format : 'number';
+ return formatters[formatter](value);
+}
diff --git a/client/app/scripts/utils/web-api-utils.js b/client/app/scripts/utils/web-api-utils.js
index 2269aa8c9..b23d2dba1 100644
--- a/client/app/scripts/utils/web-api-utils.js
+++ b/client/app/scripts/utils/web-api-utils.js
@@ -4,7 +4,7 @@ import reqwest from 'reqwest';
import { clearControlError, closeWebsocket, openWebsocket, receiveError,
receiveApiDetails, receiveNodesDelta, receiveNodeDetails, receiveControlError,
receiveControlPipe, receiveControlPipeStatus, receiveControlSuccess,
- receiveTopologies } from '../actions/app-actions';
+ receiveTopologies, receiveNotFound } from '../actions/app-actions';
const wsProto = location.protocol === 'https:' ? 'wss' : 'ws';
const wsUrl = wsProto + '://' + location.host + location.pathname.replace(/\/$/, '');
@@ -118,23 +118,33 @@ export function getNodesDelta(topologyUrl, options) {
}
}
-export function getNodeDetails(topologyUrl, nodeId) {
- if (topologyUrl && nodeId) {
- const url = [topologyUrl, '/', encodeURIComponent(nodeId)]
+export function getNodeDetails(topologyUrlsById, nodeMap) {
+ // get details for all opened nodes
+ const obj = nodeMap.last();
+ if (obj && topologyUrlsById.has(obj.topologyId)) {
+ const topologyUrl = topologyUrlsById.get(obj.topologyId);
+ const url = [topologyUrl, '/', encodeURIComponent(obj.id)]
.join('').substr(1);
reqwest({
url: url,
success: function(res) {
- receiveNodeDetails(res.node);
+ // make sure node is still selected
+ if (nodeMap.has(res.node.id)) {
+ receiveNodeDetails(res.node);
+ }
},
error: function(err) {
log('Error in node details request: ' + err.responseText);
// dont treat missing node as error
- if (err.status !== 404) {
+ if (err.status === 404) {
+ receiveNotFound(obj.id);
+ } else {
receiveError(topologyUrl);
}
}
});
+ } else {
+ log('No details or url found for ', obj);
}
}
diff --git a/client/app/styles/main.less b/client/app/styles/main.less
index ecd0178d2..51082f9c6 100644
--- a/client/app/styles/main.less
+++ b/client/app/styles/main.less
@@ -29,6 +29,7 @@
@text-color: lighten(@primary-color, 10%);
@text-secondary-color: lighten(@primary-color, 33%);
@text-tertiary-color: lighten(@primary-color, 50%);
+@border-light-color: lighten(@primary-color, 66%);
@text-darker-color: @primary-color;
@white: @background-secondary-color;
@@ -338,20 +339,38 @@ h2 {
}
-#details {
- position: fixed;
- z-index: 1024;
- display: block;
- right: @details-window-padding-left;
- top: 24px;
- bottom: 48px;
- width: @details-window-width;
+.details {
+ &-wrapper {
+ position: fixed;
+ z-index: 1024;
+ right: @details-window-padding-left;
+ top: 24px;
+ bottom: 48px;
+ width: @details-window-width;
+ transition: transform 0.33333s cubic-bezier(0,0,0.21,1);
+ }
+}
- .details-tools-wrapper {
+.node-details {
+ height: 100%;
+ background-color: rgba(255, 255, 255, 0.86);
+ display: flex;
+ flex-flow: column;
+ margin-bottom: 12px;
+ padding-bottom: 2px;
+ border-radius: 2px;
+ background-color: #fff;
+ .shadow-2;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+
+ &-tools-wrapper {
position: relative;
}
- .details-tools {
+ &-tools {
position: absolute;
top: 6px;
right: 8px;
@@ -374,31 +393,11 @@ h2 {
}
}
- .details-wrapper {
- height: 100%;
- padding-bottom: 8px;
- border-radius: 2px;
- background-color: #fff;
- .shadow-2;
- }
-}
-
-.node-details {
- height: 100%;
- width: 100%;
- background-color: rgba(255, 255, 255, 0.86);
- display: flex;
- flex-flow: column;
-
&-header {
.colorable;
&-wrapper {
- padding: 36px 36px 16px 36px;
- }
-
- &-row {
- display: flex;
+ padding: 36px 36px 8px 36px;
}
&-label {
@@ -406,12 +405,6 @@ h2 {
margin: 0;
width: 348px;
padding-top: 0;
-
- &-minor {
- flex: 1;
- font-size: 120%;
- color: @white;
- }
}
.details-tools {
@@ -426,11 +419,50 @@ h2 {
}
+ &-relatives {
+ margin-top: 4px;
+ font-size: 120%;
+ color: @white;
+
+ &-link {
+ .truncate;
+ .palable;
+ display: inline-block;
+ margin-right: 0.5em;
+ cursor: pointer;
+ text-decoration: underline;
+ opacity: 0.8;
+ max-width: 12em;
+
+ &:hover {
+ opacity: 1;
+ }
+ }
+
+ &-more {
+ .palable;
+ padding: 0 2px;
+ text-transform: uppercase;
+ cursor: pointer;
+ opacity: 0.7;
+ font-size: 60%;
+ font-weight: bold;
+ display: inline-block;
+ position: relative;
+ top: -5px;
+
+ &:hover {
+ opacity: 1;
+ }
+ }
+ }
+
&-controls {
white-space: nowrap;
+ padding: 8px 0;
&-wrapper {
- padding: 8px 36px 8px 32px;
+ padding: 0 36px 0 32px;
}
.node-control-button {
@@ -478,8 +510,7 @@ h2 {
&-content {
flex: 1;
padding: 0 36px 0 36px;
- overflow-y: scroll;
- width: 100%;
+ overflow-y: auto;
&-info {
margin-top: 16px;
@@ -492,36 +523,206 @@ h2 {
color: @background-medium-color;
opacity: 0.7;
}
+
+ &-section {
+ margin: 16px 0;
+
+ &-header {
+ text-transform: uppercase;
+ font-size: 90%;
+ color: @text-tertiary-color;
+ padding: 4px 0;
+ }
+ }
+ }
+
+ &-health {
+ display: flex;
+ justify-content: space-around;
+ align-content: center;
+ text-align: center;
+
+ &-expand {
+ .palable;
+ margin: 4px 16px 0;
+ border-top: 1px solid @border-light-color;
+ text-transform: uppercase;
+ font-size: 80%;
+ color: @text-secondary-color;
+ width: 100%;
+ cursor: pointer;
+ opacity: 0.8;
+
+ &:hover {
+ opacity: 1.0;
+ }
+ }
+
+ &-overflow {
+ .palable;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ align-items: center;
+ border-left: 1px solid @border-light-color;
+ opacity: 0.85;
+ cursor: pointer;
+ position: relative;
+ padding-bottom: 16px;
+
+ &:hover {
+ opacity: 1;
+ }
+
+ &-expand {
+ text-transform: uppercase;
+ font-size: 70%;
+ color: @text-secondary-color;
+ position: absolute;
+ bottom: -2px;
+ left: 0;
+ right: 0;
+ }
+
+ &-item {
+ padding: 4px 8px;
+ line-height: 1.2;
+ flex-basis: 48%;
+
+ &-value {
+ color: @text-secondary-color;
+ font-size: 100%;
+ }
+
+ &-label {
+ color: @text-secondary-color;
+ text-transform: uppercase;
+ font-size: 60%;
+ }
+ }
+ }
+
+ &-item {
+ padding: 8px 16px;
+ width: 33%;
+
+ &-label {
+ color: @text-secondary-color;
+ text-transform: uppercase;
+ font-size: 80%;
+ }
+
+ &-value {
+ color: @text-secondary-color;
+ font-size: 150%;
+ padding-bottom: 0.5em;
+ }
+ }
+ }
+
+ &-info {
+ margin: 16px 0;
+
+ &-field {
+ display: flex;
+ align-items: baseline;
+
+ &-label {
+ text-align: right;
+ width: 30%;
+ color: @text-secondary-color;
+ padding: 0 0.5em 0 0;
+ white-space: nowrap;
+ text-transform: uppercase;
+ font-size: 80%;
+
+ &::after {
+ content: ':';
+ }
+ }
+
+ &-value {
+ font-size: 105%;
+ flex: 1;
+ color: @text-color;
+ }
+ }
}
&-table {
+ width: 100%;
+ border-spacing: 0;
+ /* need fixed for truncating, but that does not extend wide columns dynamically */
+ table-layout: fixed;
- &:last-child {
- margin-bottom: 1em;
+ &-wrapper {
+ margin: 24px 0;
}
- &-title {
+ &-header {
text-transform: uppercase;
- margin-bottom: 0;
- color: @text-secondary-color;
- font-size: 100%;
- }
+ color: @text-tertiary-color;
+ font-size: 90%;
+ text-align: right;
+ cursor: pointer;
+ padding: 0;
- &-row {
- white-space: nowrap;
- clear: left;
-
- &-key {
- width: 11em;
- float: left;
+ &-sorted {
+ color: @text-secondary-color;
}
- &-value-major {
- margin-right: 0.5em;
+ &-sorter {
+ margin: 0 0.25em;
+ }
+
+ &:first-child {
+ margin-right: 0;
+ text-align: left;
+ }
+ }
+
+ &-more {
+ .palable;
+ padding: 2px 0;
+ text-transform: uppercase;
+ cursor: pointer;
+ color: @text-secondary-color;
+ opacity: 0.7;
+ font-size: 80%;
+ font-weight: bold;
+
+ &:hover {
+ opacity: 1;
+ }
+ }
+
+ &-node {
+ font-size: 105%;
+ line-height: 1.5;
+
+ > * {
+ padding: 0;
+ }
+
+ &-link {
+ .palable;
+ text-decoration: underline;
+ cursor: pointer;
+ opacity: 0.8;
+
+ &:hover {
+ opacity: 1;
+ }
+ }
+
+ &-value {
+ flex: 1;
+ margin-left: 0.5em;
+ text-align: right;
}
&-value-scalar {
- width: 2em;
+ // width: 2em;
text-align: right;
margin-right: 0.5em;
}
@@ -665,6 +866,12 @@ h2 {
visibility: hidden;
}
+.metric {
+ &-unit {
+ padding-left: 0.25em;
+ }
+}
+
.sidebar {
position: fixed;
bottom: 16px;
diff --git a/client/package.json b/client/package.json
index 435b617d4..bdb70ca42 100644
--- a/client/package.json
+++ b/client/package.json
@@ -10,6 +10,7 @@
"d3": "~3.5.5",
"dagre": "0.7.4",
"debug": "~2.2.0",
+ "filesize": "3.1.4",
"flux": "2.1.1",
"font-awesome": "4.4.0",
"font-awesome-webpack": "0.0.4",