mirror of
https://github.com/weaveworks/scope.git
synced 2026-08-18 11:56:39 +00:00
Details Panel UI Redesign
Refactored nodedetails to support multiple data sets, probably broke some tests Allow api requests to out-of-view topologies Fix ESC behavior with details panel Stack details panel like cards Details pain side-by-side Details panel piles Fix node details table header styles Render load and not-found captions like relatives Fix topology click action Make node detail tables sortable Grouped metrics for details health Group metrics in same style Link node details children Fix scroll issues on double-details Fix DESC sort order for node details table Save selected node labels in state - allows rendering of node labels from other topologies before details are loaded Change detail card UX, newest one at top, pile at bottom Details panel one pile w/ animation Sort details table nodes by metadata too Animate sidepanel from children too Fix radial layout Dont set origin if a node was already selected, suppresses animation stack effect: shift top cards to the left, shrink lower cards vertically Clear details card stack if sibling is selected Check if node is still selected on API response Make detail table sorters robust against non-uniform metadata Dont show scrollbar all the time, fix sort icon issue Button to show topology for relative Overflow metrics for details panel health Fix JS error when no metrics are available for container image details Column-based rendering of node details table Fix JS tests Review feedback (UI)
This commit is contained in:
committed by
Simon Howe
parent
56122dd0cc
commit
d39fd847b7
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(<NodeDetails nodes={nodes} nodeId={nodeId} />);
|
||||
const c = TestUtils.renderIntoDocument(<NodeDetails notFound />);
|
||||
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(<NodeDetails nodes={nodes}
|
||||
nodeId={nodeId} details={details} />);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
<div className="app">
|
||||
{showingDebugToolbar() && <DebugToolbar />}
|
||||
{showingDetails && <Details nodes={this.state.nodes}
|
||||
nodeId={this.state.selectedNodeId}
|
||||
controlStatus={this.state.controlStatus[this.state.selectedNodeId]}
|
||||
details={this.state.nodeDetails} />}
|
||||
|
||||
{showingTerminal && <EmbeddedTerminal
|
||||
pipe={this.state.controlPipe}
|
||||
nodeId={this.state.selectedNodeId}
|
||||
nodeId={this.state.controlPipe.nodeId}
|
||||
nodes={this.state.nodes} />}
|
||||
|
||||
<div className="header">
|
||||
|
||||
@@ -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 (
|
||||
<div className="details-wrapper" style={{transform}}>
|
||||
<NodeDetails nodeId={this.props.id} key={this.props.id} {...this.props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<div id="details">
|
||||
<div className="details-wrapper">
|
||||
<div className="details-tools-wrapper">
|
||||
<div className="details-tools">
|
||||
<span className="fa fa-close" onClick={this.handleClickClose} />
|
||||
</div>
|
||||
</div>
|
||||
<NodeDetails {...this.props} />
|
||||
</div>
|
||||
<div className="details">
|
||||
{details.map((obj, index) => {
|
||||
return (
|
||||
<DetailsCard key={obj.id} controlError={this.props.controlError}
|
||||
controlPending={this.props.controlPending} index={index}
|
||||
cardCount={details.size} nodes={this.props.nodes} {...obj} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="node-details-tools-wrapper">
|
||||
<div className="node-details-tools">
|
||||
{showSwitchTopology && <span title={topologyTitle} className="fa fa-exchange" onClick={this.handleShowTopologyForNode} />}
|
||||
<span title="Close details" className="fa fa-close" onClick={this.handleClickClose} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="node-details">
|
||||
{tools}
|
||||
<div className="node-details-header" style={styles.header}>
|
||||
<div className="node-details-header-wrapper">
|
||||
<h2 className="node-details-header-label truncate">
|
||||
{node.get('label_major')}
|
||||
{label}
|
||||
</h2>
|
||||
<div className="node-details-header-label-minor truncate">
|
||||
{node.get('label_minor')}
|
||||
<div className="node-details-relatives truncate">
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -46,38 +84,42 @@ export default class NodeDetails extends React.Component {
|
||||
}
|
||||
|
||||
renderNotAvailable() {
|
||||
const tools = this.renderTools();
|
||||
return (
|
||||
<div className="node-details">
|
||||
{tools}
|
||||
<div className="node-details-header node-details-header-notavailable">
|
||||
<div className="node-details-header-wrapper">
|
||||
<h2 className="node-details-header-label">
|
||||
n/a
|
||||
{this.props.label}
|
||||
</h2>
|
||||
<div className="node-details-header-label-minor truncate">
|
||||
{this.props.nodeId}
|
||||
<div className="node-details-relatives truncate">
|
||||
n/a
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="node-details-content">
|
||||
<p className="node-details-content-info">
|
||||
This node is not visible to Scope anymore.
|
||||
The node will re-appear if it communicates again.
|
||||
<strong>{this.props.label}</strong> is not visible to Scope when it is not communicating.
|
||||
Details will become available here when it communicates again.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 <NodeDetailsTable title={table.title} key={key} rows={table.rows} isNumeric={table.numeric} />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="node-details">
|
||||
{tools}
|
||||
<div className="node-details-header" style={styles.header}>
|
||||
<div className="node-details-header-wrapper">
|
||||
<h2 className="node-details-header-label truncate" title={details.label_major}>
|
||||
{details.label_major}
|
||||
<h2 className="node-details-header-label truncate" title={details.label}>
|
||||
{details.label}
|
||||
</h2>
|
||||
<div className="node-details-header-label-minor truncate" title={details.label_minor}>
|
||||
{details.label_minor}
|
||||
<div className="node-details-header-relatives">
|
||||
{details.parents && <NodeDetailsRelatives relatives={details.parents} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{details.controls && details.controls.length > 0 && <div className="node-details-controls-wrapper" style={styles.controls}>
|
||||
{showControls && <div className="node-details-controls-wrapper" style={styles.controls}>
|
||||
<NodeDetailsControls nodeId={this.props.nodeId}
|
||||
controls={details.controls}
|
||||
pending={pending}
|
||||
@@ -117,9 +163,18 @@ export default class NodeDetails extends React.Component {
|
||||
</div>}
|
||||
|
||||
<div className="node-details-content">
|
||||
{details.tables.map(function(table) {
|
||||
const key = _.snakeCase(table.title);
|
||||
return <NodeDetailsTable title={table.title} key={key} rows={table.rows} isNumeric={table.numeric} />;
|
||||
{showSummary && <div className="node-details-content-section">
|
||||
<div className="node-details-content-section-header">Status</div>
|
||||
{details.metrics && <NodeDetailsHealth metrics={details.metrics} />}
|
||||
{details.metadata && <NodeDetailsInfo metadata={details.metadata} />}
|
||||
</div>}
|
||||
|
||||
{details.children && details.children.map(children => {
|
||||
return (
|
||||
<div className="node-details-content-section" key={children.topologyId}>
|
||||
<NodeDetailsTable {...children} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
|
||||
import Sparkline from '../sparkline';
|
||||
import { formatMetric } from '../../utils/string-utils';
|
||||
|
||||
export default (props) => {
|
||||
return (
|
||||
<div className="node-details-health-item">
|
||||
<div className="node-details-health-item-value">{formatMetric(props.item.value, props.item)}</div>
|
||||
<div className="node-details-health-item-sparkline">
|
||||
<Sparkline data={props.item.samples} min={0} max={props.item.max}
|
||||
first={props.item.first} last={props.item.last} interpolate="none" />
|
||||
</div>
|
||||
<div className="node-details-health-item-label">{props.item.label}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
import { formatMetric } from '../../utils/string-utils';
|
||||
|
||||
export default class NodeDetailsHealthOverflowItem extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<div className="node-details-health-overflow-item">
|
||||
<div className="node-details-health-overflow-item-value">{formatMetric(this.props.item.value, this.props.item)}</div>
|
||||
<div className="node-details-health-overflow-item-label truncate">{this.props.item.label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="node-details-health-overflow" onClick={this.props.handleClickMore}>
|
||||
{items.map(item => <NodeDetailsHealthOverflowItem key={item.id} item={item} />)}
|
||||
<div className="node-details-health-overflow-expand">
|
||||
Show more
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="node-details-health" style={{flexWrap, justifyContent}}>
|
||||
{primeMetrics.map(item => {
|
||||
return <NodeDetailsHealthItem key={item.id} item={item} />;
|
||||
})}
|
||||
{showOverflow && <NodeDetailsHealthOverflow items={overflowMetrics} handleClickMore={this.handleClickMore} />}
|
||||
{showLess && <div className="node-details-health-expand" onClick={this.handleClickMore}>show less</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
|
||||
export default class NodeDetailsInfo extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<div className="node-details-info">
|
||||
{this.props.metadata && this.props.metadata.map(field => {
|
||||
return (
|
||||
<div className="node-details-info-field" key={field.id}>
|
||||
<div className="node-details-info-field-label truncate" title={field.label}>
|
||||
{field.label}
|
||||
</div>
|
||||
<div className="node-details-info-field-value" title={field.value}>
|
||||
<div className="truncate">
|
||||
{field.value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<span className="node-details-relatives-link" title={title} onClick={this.handleClick}>
|
||||
{this.props.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="node-details-relatives">
|
||||
{relatives.map(relative => {
|
||||
return <NodeDetailsRelativesLink {...relative} key={relative.id} />;
|
||||
})}
|
||||
{showLimitAction && <span className="node-details-relatives-more" onClick={this.handleLimitClick}>{limitActionText}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<span className="node-details-table-node-link truncate" title={title}
|
||||
onClick={this.handleClick}>
|
||||
{this.props.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="node-details-table-node truncate" title={title}>
|
||||
{this.props.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export default class NodeDetailsTableRowNumber extends React.Component {
|
||||
render() {
|
||||
const row = this.props.row;
|
||||
return (
|
||||
<div className="node-details-table-row-value">
|
||||
<div className="node-details-table-row-value-scalar">{row.value_major}</div>
|
||||
<div className="node-details-table-row-value-unit">{row.value_minor}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="node-details-table-row-value">
|
||||
<div className="node-details-table-row-value-sparkline"><Sparkline data={row.metric.samples} min={0} max={row.metric.max} first={row.metric.first} last={row.metric.last} interpolate="none" />{row.value_major}</div>
|
||||
<div className="node-details-table-row-value-unit">{row.value_minor}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export default class NodeDetailsTableRowValue extends React.Component {
|
||||
render() {
|
||||
const row = this.props.row;
|
||||
return (
|
||||
<div className="node-details-table-row-value">
|
||||
<div className="node-details-table-row-value-major truncate" title={row.value_major}>
|
||||
{row.value_major}
|
||||
</div>
|
||||
{row.value_minor && <div className="node-details-table-row-value-minor truncate" title={row.value_minor}>
|
||||
{row.value_minor}
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="node-details-table">
|
||||
<h4 className="node-details-table-title truncate" title={this.props.title}>
|
||||
{this.props.title}
|
||||
</h4>
|
||||
|
||||
{this.props.rows.map(function(row) {
|
||||
let valueComponent;
|
||||
if (row.value_type === 'numeric') {
|
||||
valueComponent = <NodeDetailsTableRowNumber row={row} />;
|
||||
} else if (row.value_type === 'sparkline') {
|
||||
valueComponent = <NodeDetailsTableRowSparkline row={row} />;
|
||||
} else {
|
||||
valueComponent = <NodeDetailsTableRowValue row={row} />;
|
||||
}
|
||||
return (
|
||||
<div className="node-details-table-row" key={row.key + row.value_major + row.value_minor}>
|
||||
<div className="node-details-table-row-key truncate" title={row.key}>{row.key}</div>
|
||||
{valueComponent}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
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 (
|
||||
<tr>
|
||||
{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 (
|
||||
<td className={headerClasses.join(' ')} onClick={onHeaderClick} key={header.id}>
|
||||
{isSortedAsc && <span className="node-details-table-header-sorter fa fa-caret-up" />}
|
||||
{isSortedDesc && <span className="node-details-table-header-sorter fa fa-caret-down" />}
|
||||
{header.label}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
renderValues(node) {
|
||||
const fields = this.getValuesForNode(node);
|
||||
return this.props.columns.map(col => {
|
||||
const field = fields[col];
|
||||
if (field) {
|
||||
return (
|
||||
<td className="node-details-table-node-value" key={field.id}>
|
||||
{formatMetric(field.value, field)}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="node-details-table-wrapper">
|
||||
<table className="node-details-table">
|
||||
<thead>
|
||||
{headers}
|
||||
</thead>
|
||||
<tbody>
|
||||
{nodes && nodes.map(node => {
|
||||
const values = this.renderValues(node);
|
||||
return (
|
||||
<tr className="node-details-table-node" key={node.id}>
|
||||
<td className="node-details-table-node-label truncate">
|
||||
<NodeDetailsTableNodeLink topologyId={this.props.topologyId} {...node} />
|
||||
</td>
|
||||
{values}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{showLimitAction && <div className="node-details-table-more" onClick={this.handleLimitClick}>{limitActionText}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ export default class Sparkline extends React.Component {
|
||||
}
|
||||
|
||||
Sparkline.defaultProps = {
|
||||
width: 100,
|
||||
width: 80,
|
||||
height: 16,
|
||||
strokeColor: '#7d7da8',
|
||||
strokeWidth: '0.5px',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<span className="metric-formatted">
|
||||
<span className="metric-value">{text}</span>
|
||||
<span className="metric-unit">{unit}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export function formatMetric(value, opts) {
|
||||
const formatter = opts && formatters[opts.format] ? opts.format : 'number';
|
||||
return formatters[formatter](value);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+264
-57
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user