diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index e8ccc77f6..436d9efe3 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -26,14 +26,22 @@ export function changeTopologyOption(option, value, topologyId) { AppStore.getActiveTopologyOptions() ); getNodeDetails( - AppStore.getCurrentTopologyUrl(), - AppStore.getSelectedNodeId() + AppStore.getTopologyUrlsById(), + AppStore.getNodeDetails() ); } -export function clickCloseDetails() { +export function clickBackground() { AppDispatcher.dispatch({ - type: ActionTypes.CLICK_CLOSE_DETAILS + type: ActionTypes.CLICK_BACKGROUND + }); + updateRoute(); +} + +export function clickCloseDetails(nodeId) { + AppDispatcher.dispatch({ + type: ActionTypes.CLICK_CLOSE_DETAILS, + nodeId }); updateRoute(); } @@ -49,15 +57,45 @@ export function clickCloseTerminal(pipeId, closePipe) { updateRoute(); } -export function clickNode(nodeId) { +export function clickNode(nodeId, label, origin) { AppDispatcher.dispatch({ type: ActionTypes.CLICK_NODE, - nodeId: nodeId + origin, + label, + nodeId }); updateRoute(); getNodeDetails( + AppStore.getTopologyUrlsById(), + AppStore.getNodeDetails() + ); +} + +export function clickRelative(nodeId, topologyId, label, origin) { + AppDispatcher.dispatch({ + type: ActionTypes.CLICK_RELATIVE, + label, + origin, + nodeId, + topologyId + }); + updateRoute(); + getNodeDetails( + AppStore.getTopologyUrlsById(), + AppStore.getNodeDetails() + ); +} + +export function clickShowTopologyForNode(topologyId, nodeId) { + AppDispatcher.dispatch({ + type: ActionTypes.CLICK_SHOW_TOPOLOGY_FOR_NODE, + topologyId, + nodeId + }); + updateRoute(); + getNodesDelta( AppStore.getCurrentTopologyUrl(), - AppStore.getSelectedNodeId() + AppStore.getActiveTopologyOptions() ); } @@ -121,10 +159,7 @@ export function hitEsc() { type: ActionTypes.CLICK_CLOSE_TERMINAL, pipeId: controlPipe.id }); - // Dont deselect node on ESC if there is a controlPipe (keep terminal open) } else if (AppStore.getSelectedNodeId() && !controlPipe) { - AppDispatcher.dispatch({type: ActionTypes.DESELECT_NODE}); - } updateRoute(); } @@ -181,8 +216,8 @@ export function receiveTopologies(topologies) { AppStore.getActiveTopologyOptions() ); getNodeDetails( - AppStore.getCurrentTopologyUrl(), - AppStore.getSelectedNodeId() + AppStore.getTopologyUrlsById(), + AppStore.getNodeDetails() ); } @@ -195,6 +230,7 @@ export function receiveApiDetails(apiDetails) { } export function receiveControlPipeFromParams(pipeId, rawTty) { + // TODO add nodeId AppDispatcher.dispatch({ type: ActionTypes.RECEIVE_CONTROL_PIPE, pipeId: pipeId, @@ -216,6 +252,7 @@ export function receiveControlPipe(pipeId, nodeId, rawTty) { AppDispatcher.dispatch({ type: ActionTypes.RECEIVE_CONTROL_PIPE, + nodeId: nodeId, pipeId: pipeId, rawTty: rawTty }); @@ -238,6 +275,13 @@ export function receiveError(errorUrl) { }); } +export function receiveNotFound(nodeId) { + AppDispatcher.dispatch({ + nodeId, + type: ActionTypes.RECEIVE_NOT_FOUND + }); +} + export function route(state) { AppDispatcher.dispatch({ state: state, @@ -251,7 +295,7 @@ export function route(state) { AppStore.getActiveTopologyOptions() ); getNodeDetails( - AppStore.getCurrentTopologyUrl(), - AppStore.getSelectedNodeId() + AppStore.getTopologyUrlsById(), + AppStore.getNodeDetails() ); } diff --git a/client/app/scripts/charts/node.js b/client/app/scripts/charts/node.js index faf0cfb8d..39150e6fe 100644 --- a/client/app/scripts/charts/node.js +++ b/client/app/scripts/charts/node.js @@ -1,4 +1,5 @@ import React from 'react'; +import ReactDOM from 'react-dom'; import { Motion, spring } from 'react-motion'; import { clickNode, enterNode, leaveNode } from '../actions/app-actions'; @@ -99,14 +100,14 @@ export default class Node extends React.Component { handleMouseClick(ev) { ev.stopPropagation(); - clickNode(ev.currentTarget.id); + clickNode(this.props.id, this.props.label, ReactDOM.findDOMNode(this).getBoundingClientRect()); } - handleMouseEnter(ev) { - enterNode(ev.currentTarget.id); + handleMouseEnter() { + enterNode(this.props.id); } - handleMouseLeave(ev) { - leaveNode(ev.currentTarget.id); + handleMouseLeave() { + leaveNode(this.props.id); } } diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index 5208c3862..914bffa33 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -5,7 +5,7 @@ import React from 'react'; import { Map as makeMap } from 'immutable'; import timely from 'timely'; -import { clickCloseDetails } from '../actions/app-actions'; +import { clickBackground } from '../actions/app-actions'; import AppStore from '../stores/app-store'; import Edge from './edge'; import { EDGE_ID_SEPARATOR } from '../constants/naming'; @@ -357,7 +357,7 @@ export default class NodesChart extends React.Component { handleMouseClick() { if (!this.isZooming) { - clickCloseDetails(); + clickBackground(); } else { this.isZooming = false; } diff --git a/client/app/scripts/components/__tests__/node-details-test.js b/client/app/scripts/components/__tests__/node-details-test.js index c1b22a3e2..0984aaefd 100644 --- a/client/app/scripts/components/__tests__/node-details-test.js +++ b/client/app/scripts/components/__tests__/node-details-test.js @@ -4,6 +4,9 @@ import TestUtils from 'react/lib/ReactTestUtils'; jest.dontMock('../../dispatcher/app-dispatcher'); jest.dontMock('../node-details.js'); +jest.dontMock('../node-details/node-details-controls.js'); +jest.dontMock('../node-details/node-details-relatives.js'); +jest.dontMock('../node-details/node-details-table.js'); jest.dontMock('../../utils/color-utils'); jest.dontMock('../../utils/title-utils'); @@ -22,14 +25,14 @@ describe('NodeDetails', () => { }); it('shows n/a when node was not found', () => { - const c = TestUtils.renderIntoDocument(); + const c = TestUtils.renderIntoDocument(); const notFound = TestUtils.findRenderedDOMComponentWithClass(c, 'node-details-header-notavailable'); expect(notFound).toBeDefined(); }); it('show label of node with title', () => { nodes = nodes.set(nodeId, Immutable.fromJS({id: nodeId})); - details = {label_major: 'Node 1', tables: []}; + details = {label: 'Node 1'}; const c = TestUtils.renderIntoDocument(); diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index b6f3bfcd4..8259325b1 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -28,9 +28,9 @@ function getStateFromStores() { highlightedEdgeIds: AppStore.getHighlightedEdgeIds(), highlightedNodeIds: AppStore.getHighlightedNodeIds(), hostname: AppStore.getHostname(), - selectedNodeId: AppStore.getSelectedNodeId(), nodeDetails: AppStore.getNodeDetails(), nodes: AppStore.getNodes(), + selectedNodeId: AppStore.getSelectedNodeId(), topologies: AppStore.getTopologies(), topologiesLoaded: AppStore.isTopologiesLoaded(), version: AppStore.getVersion(), @@ -70,7 +70,7 @@ export default class App extends React.Component { } render() { - const showingDetails = this.state.selectedNodeId; + const showingDetails = this.state.nodeDetails.size > 0; const showingTerminal = this.state.controlPipe; const footer = `Version ${this.state.version} on ${this.state.hostname}`; // width of details panel blocking a view @@ -81,13 +81,12 @@ export default class App extends React.Component {
{showingDebugToolbar() && } {showingDetails &&
} {showingTerminal && }
diff --git a/client/app/scripts/components/details-card.js b/client/app/scripts/components/details-card.js new file mode 100644 index 000000000..256311213 --- /dev/null +++ b/client/app/scripts/components/details-card.js @@ -0,0 +1,58 @@ +import React from 'react'; + +import NodeDetails from './node-details'; + +// card dimensions in px +const marginTop = 24; +const marginBottom = 48; +const marginRight = 36; +const panelWidth = 420; +const offset = 8; + +export default class DetailsCard extends React.Component { + + constructor(props, context) { + super(props, context); + this.state = { + mounted: null + }; + } + + componentDidMount() { + setTimeout(() => { + this.setState({mounted: true}); + }); + } + + render() { + let transform; + const origin = this.props.origin; + const panelHeight = window.innerHeight - marginBottom - marginTop; + if (origin && !this.state.mounted) { + // render small panel near origin, will transition into normal panel after being mounted + const scaleY = origin.height / (window.innerHeight - marginBottom - marginTop) / 2; + const scaleX = origin.width / panelWidth / 2; + const centerX = window.innerWidth - marginRight - (panelWidth / 2); + const centerY = (panelHeight) / 2 + marginTop; + const dx = (origin.left + origin.width / 2) - centerX; + const dy = (origin.top + origin.height / 2) - centerY; + transform = `translate(${dx}px, ${dy}px) scale(${scaleX},${scaleY})`; + } else { + // stack effect: shift top cards to the left, shrink lower cards vertically + const shiftX = -1 * this.props.index * offset; + const position = this.props.cardCount - this.props.index - 1; // reverse index + const scaleY = position === 0 ? 1 : (panelHeight - 2 * offset * position) / panelHeight; + if (scaleY !== 1) { + transform = `translateX(${shiftX}px) scaleY(${scaleY})`; + } else { + // scale(1) is sometimes blurry + transform = `translateX(${shiftX}px)`; + } + } + return ( +
+ +
+ ); + } +} diff --git a/client/app/scripts/components/details.js b/client/app/scripts/components/details.js index f9cdaab60..44c6f2bbe 100644 --- a/client/app/scripts/components/details.js +++ b/client/app/scripts/components/details.js @@ -1,30 +1,21 @@ import React from 'react'; -import { clickCloseDetails } from '../actions/app-actions'; -import NodeDetails from './node-details'; +import DetailsCard from './details-card'; export default class Details extends React.Component { - constructor(props, context) { - super(props, context); - this.handleClickClose = this.handleClickClose.bind(this); - } - - handleClickClose(ev) { - ev.preventDefault(); - clickCloseDetails(); - } + // render all details as cards, later cards go on top render() { + const details = this.props.details.toIndexedSeq(); return ( -
-
-
-
- -
-
- -
+
+ {details.map((obj, index) => { + return ( + + ); + })}
); } diff --git a/client/app/scripts/components/node-details.js b/client/app/scripts/components/node-details.js index 7cf31f704..d08fb9576 100644 --- a/client/app/scripts/components/node-details.js +++ b/client/app/scripts/components/node-details.js @@ -2,11 +2,32 @@ import _ from 'lodash'; import React from 'react'; import NodeDetailsControls from './node-details/node-details-controls'; +import NodeDetailsHealth from './node-details/node-details-health'; +import NodeDetailsInfo from './node-details/node-details-info'; +import NodeDetailsRelatives from './node-details/node-details-relatives'; import NodeDetailsTable from './node-details/node-details-table'; -import { brightenColor, getNodeColorDark } from '../utils/color-utils'; +import { clickCloseDetails, clickShowTopologyForNode } from '../actions/app-actions'; +import { brightenColor, getNeutralColor, getNodeColorDark } from '../utils/color-utils'; import { resetDocumentTitle, setDocumentTitle } from '../utils/title-utils'; export default class NodeDetails extends React.Component { + + constructor(props, context) { + super(props, context); + this.handleClickClose = this.handleClickClose.bind(this); + this.handleShowTopologyForNode = this.handleShowTopologyForNode.bind(this); + } + + handleClickClose(ev) { + ev.preventDefault(); + clickCloseDetails(this.props.nodeId); + } + + handleShowTopologyForNode(ev) { + ev.preventDefault(); + clickShowTopologyForNode(this.props.topologyId, this.props.nodeId); + } + componentDidMount() { this.updateTitle(); } @@ -15,9 +36,25 @@ export default class NodeDetails extends React.Component { resetDocumentTitle(); } + renderTools() { + const showSwitchTopology = this.props.index > 0; + const topologyTitle = `View ${this.props.label} in ${this.props.topologyId}`; + + return ( +
+
+ {showSwitchTopology && } + +
+
+ ); + } + renderLoading() { const node = this.props.nodes.get(this.props.nodeId); - const nodeColor = getNodeColorDark(node.get('rank'), node.get('label_major')); + const label = node ? node.get('label_major') : this.props.label; + const nodeColor = node ? getNodeColorDark(node.get('rank'), label) : getNeutralColor(); + const tools = this.renderTools(); const styles = { header: { 'backgroundColor': nodeColor @@ -26,13 +63,14 @@ export default class NodeDetails extends React.Component { return (
+ {tools}

- {node.get('label_major')} + {label}

-
- {node.get('label_minor')} +
+ Loading...
@@ -46,38 +84,42 @@ export default class NodeDetails extends React.Component { } renderNotAvailable() { + const tools = this.renderTools(); return (
+ {tools}

- n/a + {this.props.label}

-
- {this.props.nodeId} +
+ n/a

- This node is not visible to Scope anymore. - The node will re-appear if it communicates again. + {this.props.label} is not visible to Scope when it is not communicating. + Details will become available here when it communicates again.

); } - render() { - const details = this.props.details; - const nodeExists = this.props.nodes && this.props.nodes.has(this.props.nodeId); + renderTable(table) { + const key = _.snakeCase(table.title); + return ; + } - if (details) { - return this.renderDetails(); + render() { + if (this.props.notFound) { + return this.renderNotAvailable(); } - if (!nodeExists) { - return this.renderNotAvailable(); + if (this.props.details) { + return this.renderDetails(); } return this.renderLoading(); @@ -85,8 +127,11 @@ export default class NodeDetails extends React.Component { renderDetails() { const details = this.props.details; + const showSummary = details.metadata !== undefined && details.metrics !== undefined; + const showControls = details.controls && details.controls.length > 0; const nodeColor = getNodeColorDark(details.rank, details.label_major); const {error, pending} = (this.props.controlStatus || {}); + const tools = this.renderTools(); const styles = { controls: { 'backgroundColor': brightenColor(nodeColor) @@ -98,18 +143,19 @@ export default class NodeDetails extends React.Component { return (
+ {tools}
-

- {details.label_major} +

+ {details.label}

-
- {details.label_minor} +
+ {details.parents && }
- {details.controls && details.controls.length > 0 &&
+ {showControls &&
}
- {details.tables.map(function(table) { - const key = _.snakeCase(table.title); - return ; + {showSummary &&
+
Status
+ {details.metrics && } + {details.metadata && } +
} + + {details.children && details.children.map(children => { + return ( +
+ +
+ ); })}
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 ( +
+
{formatMetric(props.item.value, props.item)}
+
+ +
+
{props.item.label}
+
+ ); +}; 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 ( +
+
{formatMetric(this.props.item.value, this.props.item)}
+
{this.props.item.label}
+
+ ); + } +} 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 ( +
+ {items.map(item => )} +
+ Show more +
+
+ ); + } +} 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 ( +
+ {primeMetrics.map(item => { + return ; + })} + {showOverflow && } + {showLess &&
show less
} +
+ ); + } +} 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 ( +
+ {this.props.metadata && this.props.metadata.map(field => { + return ( +
+
+ {field.label} +
+
+
+ {field.value} +
+
+
+ ); + })} +
+ ); + } +} 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 ( + + {this.props.label} + + ); + } +} 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 ( +
+ {relatives.map(relative => { + return ; + })} + {showLimitAction && {limitActionText}} +
+ ); + } +} 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 ( + + {this.props.label} + + ); + } + return ( + + {this.props.label} + + ); + } +} 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 ( -
-
{row.value_major}
-
{row.value_minor}
-
- ); - } -} 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 ( -
-
{row.value_major}
-
{row.value_minor}
-
- ); - } -} 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 ( -
-
- {row.value_major} -
- {row.value_minor &&
- {row.value_minor} -
} -
- ); - } -} 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",