Initial version of the resource view (#2296)

* Added resource view selector button

* Showing resource boxes in the resource view

* Crude CPU resource view prototype

* Improved the viewMode state logic

* Extracted zooming into a separate wrapper component

* Split the layout selectors between graph-view and resource-view

* Proper zooming logic for the resource view

* Moved all node networks utils to selectors

* Improved the zoom caching logic

* Further refactoring of selectors

* Added sticky labels to the resource boxes

* Added panning translation limits in the resource view

* Renamed GridModeSelector -> ViewModeSelector

* Polished the topology resource view selection logic

* Search bar hidden in the resource view

* Added per-layer topology names to the resource view

* Made metric selectors work for the resource view

* Adjusted the viewport selectors

* Renamed viewport selector to canvas (+ maximal zoom fix)

* Showing more useful metric info in the resource box labels

* Fetching only necessary nodes for the resource view

* Refactored the resource view layer component

* Addressed first batch UI comments (from the Scope meeting)

* Switch to deep zooming transform in the resource view to avoid SVG precision errors

* Renamed and moved resource view components

* Polished all the resource view components

* Changing the available metrics selection

* Improved and polished the state transition logic for the resource view

* Separated zoom limits from the zoom active state

* Renaming and bunch of comments

* Addressed all the UI comments (@davkal + @fons)

* Made graph view selectors independent from resource view selectors
This commit is contained in:
Filip Barl
2017-03-24 14:51:53 +01:00
committed by GitHub
parent 8814e856e0
commit 69fd397217
50 changed files with 1592 additions and 568 deletions
+17 -12
View File
@@ -16,13 +16,18 @@ import { focusSearch, pinNextMetric, hitBackspace, hitEnter, hitEsc, unpinMetric
selectMetric, toggleHelp, toggleGridMode, shutdown } from '../actions/app-actions';
import Details from './details';
import Nodes from './nodes';
import GridModeSelector from './grid-mode-selector';
import MetricSelector from './metric-selector';
import ViewModeSelector from './view-mode-selector';
import NetworkSelector from './networks-selector';
import DebugToolbar, { showingDebugToolbar, toggleDebugToolbar } from './debug-toolbar';
import { getRouter, getUrlState } from '../utils/router-utils';
import { activeTopologyOptionsSelector } from '../selectors/topology';
import { availableNetworksSelector } from '../selectors/node-networks';
import {
activeTopologyOptionsSelector,
isResourceViewModeSelector,
isTableViewModeSelector,
isGraphViewModeSelector,
} from '../selectors/topology';
const BACKSPACE_KEY_CODE = 8;
const ENTER_KEY_CODE = 13;
@@ -102,7 +107,7 @@ class App extends React.Component {
}
render() {
const { gridMode, showingDetails, showingHelp, showingMetricsSelector,
const { isTableViewMode, isGraphViewMode, isResourceViewMode, showingDetails, showingHelp,
showingNetworkSelector, showingTroubleshootingMenu } = this.props;
const isIframe = window !== window.top;
@@ -124,16 +129,15 @@ class App extends React.Component {
</div>
<Search />
<Topologies />
<GridModeSelector />
<ViewModeSelector />
</div>
<Nodes />
<Sidebar classNames={gridMode ? 'sidebar-gridmode' : ''}>
{showingMetricsSelector && !gridMode && <MetricSelector />}
{showingNetworkSelector && !gridMode && <NetworkSelector />}
<Status />
<TopologyOptions />
<Sidebar classNames={isTableViewMode ? 'sidebar-gridmode' : ''}>
{showingNetworkSelector && isGraphViewMode && <NetworkSelector />}
{!isResourceViewMode && <Status />}
{!isResourceViewMode && <TopologyOptions />}
</Sidebar>
<Footer />
@@ -146,14 +150,15 @@ class App extends React.Component {
function mapStateToProps(state) {
return {
activeTopologyOptions: activeTopologyOptionsSelector(state),
gridMode: state.get('gridMode'),
isResourceViewMode: isResourceViewModeSelector(state),
isTableViewMode: isTableViewModeSelector(state),
isGraphViewMode: isGraphViewModeSelector(state),
routeSet: state.get('routeSet'),
searchFocused: state.get('searchFocused'),
searchQuery: state.get('searchQuery'),
showingDetails: state.get('nodeDetails').size > 0,
showingHelp: state.get('showingHelp'),
showingTroubleshootingMenu: state.get('showingTroubleshootingMenu'),
showingMetricsSelector: state.get('availableCanvasMetrics').count() > 0,
showingNetworkSelector: availableNetworksSelector(state).count() > 0,
showingTerminal: state.get('controlPipes').size > 0,
urlState: getUrlState(state)
@@ -10,6 +10,7 @@ import debug from 'debug';
import ActionTypes from '../constants/action-types';
import { receiveNodesDelta } from '../actions/app-actions';
import { getNodeColor, getNodeColorDark, text2degree } from '../utils/color-utils';
import { availableMetricsSelector } from '../selectors/node-metric';
const SHAPES = ['square', 'hexagon', 'heptagon', 'circle'];
@@ -291,7 +292,7 @@ class DebugToolbar extends React.Component {
}
render() {
const { availableCanvasMetrics } = this.props;
const { availableMetrics } = this.props;
return (
<div className="debug-panel">
@@ -302,7 +303,7 @@ class DebugToolbar extends React.Component {
<input type="number" onChange={this.onChange} value={this.state.nodesToAdd} />
<button onClick={() => this.addNodes(this.state.nodesToAdd)}>+</button>
<button onClick={() => this.asyncDispatch(addAllVariants)}>Variants</button>
<button onClick={() => this.asyncDispatch(addAllMetricVariants(availableCanvasMetrics))}>
<button onClick={() => this.asyncDispatch(addAllMetricVariants(availableMetrics))}>
Metric Variants
</button>
<button onClick={() => this.addNodes(1, LOREM)}>Long name</button>
@@ -379,7 +380,7 @@ class DebugToolbar extends React.Component {
function mapStateToProps(state) {
return {
nodes: state.get('nodes'),
availableCanvasMetrics: state.get('availableCanvasMetrics')
availableMetrics: availableMetricsSelector(state),
};
}
@@ -1,62 +0,0 @@
import React from 'react';
import { connect } from 'react-redux';
import classNames from 'classnames';
import { toggleGridMode } from '../actions/app-actions';
const Item = (icons, label, isSelected, onClick) => {
const className = classNames('grid-mode-selector-action', {
'grid-mode-selector-action-selected': isSelected
});
return (
<div
className={className}
onClick={onClick} >
<span className={icons} style={{fontSize: 12}} />
<span>{label}</span>
</div>
);
};
class GridModeSelector extends React.Component {
constructor(props, context) {
super(props, context);
this.enableGridMode = this.enableGridMode.bind(this);
this.disableGridMode = this.disableGridMode.bind(this);
}
enableGridMode() {
return this.props.toggleGridMode(true);
}
disableGridMode() {
return this.props.toggleGridMode(false);
}
render() {
const { gridMode } = this.props;
return (
<div className="grid-mode-selector">
<div className="grid-mode-selector-wrapper">
{Item('fa fa-share-alt', 'Graph', !gridMode, this.disableGridMode)}
{Item('fa fa-table', 'Table', gridMode, this.enableGridMode)}
</div>
</div>
);
}
}
function mapStateToProps(state) {
return {
gridMode: state.get('gridMode'),
};
}
export default connect(
mapStateToProps,
{ toggleGridMode }
)(GridModeSelector);
+4 -3
View File
@@ -2,7 +2,7 @@ import React from 'react';
import { connect } from 'react-redux';
import { searchableFieldsSelector } from '../selectors/search';
import { CANVAS_MARGINS } from '../constants/styles';
import { canvasMarginsSelector } from '../selectors/canvas';
import { hideHelp } from '../actions/app-actions';
@@ -149,10 +149,10 @@ function renderFieldsPanel(currentTopologyName, searchableFields) {
}
function HelpPanel({currentTopologyName, searchableFields, onClickClose}) {
function HelpPanel({ currentTopologyName, searchableFields, onClickClose, canvasMargins }) {
return (
<div className="help-panel-wrapper">
<div className="help-panel" style={{marginTop: CANVAS_MARGINS.top}}>
<div className="help-panel" style={{marginTop: canvasMargins.top}}>
<div className="help-panel-header">
<h2>Help</h2>
</div>
@@ -176,6 +176,7 @@ function HelpPanel({currentTopologyName, searchableFields, onClickClose}) {
function mapStateToProps(state) {
return {
canvasMargins: canvasMarginsSelector(state),
searchableFields: searchableFieldsSelector(state),
currentTopologyName: state.getIn(['currentTopology', 'fullName'])
};
+2 -2
View File
@@ -2,9 +2,9 @@
/* eslint max-len: "off" */
import React from 'react';
export default function Logo() {
export default function Logo({ transform = '' }) {
return (
<g className="logo">
<g className="logo" transform={transform}>
<path fill="#32324B" d="M114.937,118.165l75.419-67.366c-5.989-4.707-12.71-8.52-19.981-11.211l-55.438,49.52V118.165z" />
<path fill="#32324B" d="M93.265,108.465l-20.431,18.25c1.86,7.57,4.88,14.683,8.87,21.135l11.561-10.326V108.465z" />
<path fill="#00D2FF" d="M155.276,53.074V35.768C151.815,35.27,148.282,35,144.685,35c-3.766,0-7.465,0.286-11.079,0.828v36.604
@@ -3,6 +3,7 @@ import classNames from 'classnames';
import { connect } from 'react-redux';
import { selectMetric, pinMetric, unpinMetric } from '../actions/app-actions';
import { pinnedMetricSelector } from '../selectors/node-metric';
class MetricSelectorItem extends React.Component {
@@ -22,15 +23,15 @@ class MetricSelectorItem extends React.Component {
const k = this.props.metric.get('id');
const pinnedMetric = this.props.pinnedMetric;
if (k === pinnedMetric) {
this.props.unpinMetric(k);
} else {
if (k !== pinnedMetric) {
this.props.pinMetric(k);
} else if (!this.props.alwaysPinned) {
this.props.unpinMetric(k);
}
}
render() {
const {metric, selectedMetric, pinnedMetric} = this.props;
const { metric, selectedMetric, pinnedMetric } = this.props;
const id = metric.get('id');
const isPinned = (id === pinnedMetric);
const isSelected = (id === selectedMetric);
@@ -54,7 +55,7 @@ class MetricSelectorItem extends React.Component {
function mapStateToProps(state) {
return {
selectedMetric: state.get('selectedMetric'),
pinnedMetric: state.get('pinnedMetric')
pinnedMetric: pinnedMetricSelector(state),
};
}
@@ -2,12 +2,13 @@ import React from 'react';
import { connect } from 'react-redux';
import { selectMetric } from '../actions/app-actions';
import { availableMetricsSelector } from '../selectors/node-metric';
import MetricSelectorItem from './metric-selector-item';
class MetricSelector extends React.Component {
constructor(props, context) {
super(props, context);
this.onMouseOut = this.onMouseOut.bind(this);
}
@@ -16,16 +17,18 @@ class MetricSelector extends React.Component {
}
render() {
const {availableCanvasMetrics} = this.props;
const items = availableCanvasMetrics.map(metric => (
<MetricSelectorItem key={metric.get('id')} metric={metric} />
));
const { alwaysPinned, availableMetrics } = this.props;
return (
<div className="metric-selector">
<div className="metric-selector-wrapper" onMouseLeave={this.onMouseOut}>
{items}
{availableMetrics.map(metric => (
<MetricSelectorItem
key={metric.get('id')}
alwaysPinned={alwaysPinned}
metric={metric}
/>
))}
</div>
</div>
);
@@ -34,8 +37,7 @@ class MetricSelector extends React.Component {
function mapStateToProps(state) {
return {
availableCanvasMetrics: state.get('availableCanvasMetrics'),
pinnedMetric: state.get('pinnedMetric')
availableMetrics: availableMetricsSelector(state),
};
}
@@ -0,0 +1,51 @@
import React from 'react';
import { connect } from 'react-redux';
import Logo from './logo';
import ZoomWrapper from './zoom-wrapper';
import NodesResourcesLayer from './nodes-resources/node-resources-layer';
import { layersTopologyIdsSelector } from '../selectors/resource-view/layout';
import {
resourcesZoomLimitsSelector,
resourcesZoomStateSelector,
} from '../selectors/resource-view/zoom';
class NodesResources extends React.Component {
renderLayers(transform) {
return this.props.layersTopologyIds.map((topologyId, index) => (
<NodesResourcesLayer
key={topologyId}
topologyId={topologyId}
transform={transform}
slot={index}
/>
));
}
render() {
return (
<div className="nodes-resources">
<svg id="canvas" width="100%" height="100%">
<Logo transform="translate(24,24) scale(0.25)" />
<ZoomWrapper
svg="canvas" bounded forwardTransform fixVertical
zoomLimitsSelector={resourcesZoomLimitsSelector}
zoomStateSelector={resourcesZoomStateSelector}>
{transform => this.renderLayers(transform)}
</ZoomWrapper>
</svg>
</div>
);
}
}
function mapStateToProps(state) {
return {
layersTopologyIds: layersTopologyIdsSelector(state),
};
}
export default connect(
mapStateToProps
)(NodesResources);
@@ -0,0 +1,31 @@
import React from 'react';
import pick from 'lodash/pick';
import { applyTransform } from '../../utils/transform-utils';
import {
RESOURCES_LAYER_TITLE_WIDTH,
RESOURCES_LAYER_HEIGHT,
} from '../../constants/styles';
export default class NodeResourcesLayerTopology extends React.Component {
render() {
// This component always has a fixed horizontal position and width,
// so we only apply the vertical zooming transformation to match the
// vertical position and height of the resource boxes.
const verticalTransform = pick(this.props.transform, ['translateY', 'scaleY']);
const { width, height, y } = applyTransform(verticalTransform, {
width: RESOURCES_LAYER_TITLE_WIDTH,
height: RESOURCES_LAYER_HEIGHT,
y: this.props.verticalPosition,
});
return (
<foreignObject width={width} height={height} y={y}>
<div className="node-resources-layer-topology" style={{ lineHeight: `${height}px` }}>
{this.props.topologyId}
</div>
</foreignObject>
);
}
}
@@ -0,0 +1,53 @@
import React from 'react';
import { connect } from 'react-redux';
import { Map as makeMap } from 'immutable';
import NodeResourcesMetricBox from './node-resources-metric-box';
import NodeResourcesLayerTopology from './node-resources-layer-topology';
import {
layerVerticalPositionByTopologyIdSelector,
layoutNodesByTopologyIdSelector,
} from '../../selectors/resource-view/layout';
class NodesResourcesLayer extends React.Component {
render() {
const { layerVerticalPosition, topologyId, transform, layoutNodes } = this.props;
return (
<g className="node-resources-layer">
<g className="node-resources-metric-boxes">
{layoutNodes.toIndexedSeq().map(node => (
<NodeResourcesMetricBox
key={node.get('id')}
color={node.get('color')}
label={node.get('label')}
metricSummary={node.get('metricSummary')}
width={node.get('width')}
height={node.get('height')}
x={node.get('offset')}
y={layerVerticalPosition}
transform={transform}
/>
))}
</g>
{!layoutNodes.isEmpty() && <NodeResourcesLayerTopology
verticalPosition={layerVerticalPosition}
transform={transform}
topologyId={topologyId}
/>}
</g>
);
}
}
function mapStateToProps(state, props) {
return {
layerVerticalPosition: layerVerticalPositionByTopologyIdSelector(state).get(props.topologyId),
layoutNodes: layoutNodesByTopologyIdSelector(state).get(props.topologyId, makeMap()),
};
}
export default connect(
mapStateToProps
)(NodesResourcesLayer);
@@ -0,0 +1,33 @@
import React from 'react';
export default class NodeResourcesMetricBoxInfo extends React.Component {
humanizedMetricInfo() {
const { humanizedTotalCapacity, humanizedAbsoluteConsumption,
humanizedRelativeConsumption, showCapacity, format } = this.props.metricSummary.toJS();
const showExtendedInfo = showCapacity && format !== 'percent';
return (
<span>
<strong>
{showExtendedInfo ? humanizedRelativeConsumption : humanizedAbsoluteConsumption}
</strong> used
{showExtendedInfo && <i>{' - '}
({humanizedAbsoluteConsumption} / <strong>{humanizedTotalCapacity}</strong>)
</i>}
</span>
);
}
render() {
const { width, x, y } = this.props;
return (
<foreignObject x={x} y={y} width={width} height="45px">
<div className="node-resources-metric-box-info">
<span className="wrapper label truncate">{this.props.label}</span>
<span className="wrapper consumption truncate">{this.humanizedMetricInfo()}</span>
</div>
</foreignObject>
);
}
}
@@ -0,0 +1,111 @@
import React from 'react';
import { connect } from 'react-redux';
import NodeResourcesMetricBoxInfo from './node-resources-metric-box-info';
import { applyTransform } from '../../utils/transform-utils';
import {
RESOURCES_LAYER_TITLE_WIDTH,
RESOURCES_LABEL_MIN_SIZE,
RESOURCES_LABEL_PADDING,
} from '../../constants/styles';
// Transforms the rectangle box according to the zoom state forwarded by
// the zooming wrapper. Two main reasons why we're doing it per component
// instead of on the parent group are:
// 1. Due to single-precision SVG coordinate system implemented by most browsers,
// the resource boxes would be incorrectly rendered on extreme zoom levels (it's
// not just about a few pixels here and there, the whole layout gets screwed). So
// we don't actually use the native SVG transform but transform the coordinates
// ourselves (with `applyTransform` helper).
// 2. That also enables us to do the resources info label clipping, which would otherwise
// not be possible with pure zooming.
//
// The downside is that the rendering becomes slower as the transform prop needs to be forwarded
// down to this component, so a lot of stuff gets rerendered/recalculated on every zoom action.
// On the other hand, this enables us to easily leave out the nodes that are not in the viewport.
const transformedDimensions = (props) => {
const { width, height, x, y } = applyTransform(props.transform, props);
// Trim the beginning of the resource box just after the layer topology
// name to the left and the viewport width to the right. That enables us
// to make info tags 'sticky', but also not to render the nodes with no
// visible part in the viewport.
const xStart = Math.max(RESOURCES_LAYER_TITLE_WIDTH, x);
const xEnd = Math.min(x + width, props.viewportWidth);
// Update the horizontal transform with trimmed values.
return {
width: xEnd - xStart,
height,
x: xStart,
y,
};
};
class NodeResourcesMetricBox extends React.Component {
constructor(props, context) {
super(props, context);
this.state = transformedDimensions(props);
}
componentWillReceiveProps(nextProps) {
this.setState(transformedDimensions(nextProps));
}
defaultRectProps(relativeHeight = 1) {
const { x, y, width, height } = this.state;
const translateY = height * (1 - relativeHeight);
return {
transform: `translate(0, ${translateY})`,
opacity: this.props.contrastMode ? 1 : 0.85,
stroke: this.props.contrastMode ? 'black' : 'white',
height: height * relativeHeight,
width,
x,
y,
};
}
render() {
const { x, y, width } = this.state;
const { label, color, metricSummary } = this.props;
const { showCapacity, relativeConsumption, type } = metricSummary.toJS();
const showInfo = width >= RESOURCES_LABEL_MIN_SIZE;
const showNode = width >= 1; // hide the thin nodes
// Don't display the nodes which are less than 1px wide.
// TODO: Show `+ 31 nodes` kind of tag in their stead.
if (!showNode) return null;
const resourceUsageTooltipInfo = showCapacity ?
metricSummary.get('humanizedRelativeConsumption') :
metricSummary.get('humanizedAbsoluteConsumption');
return (
<g className="node-resources-metric-box">
<title>{label} - {type} usage at {resourceUsageTooltipInfo}</title>
{showCapacity && <rect className="frame" {...this.defaultRectProps()} />}
<rect className="bar" fill={color} {...this.defaultRectProps(relativeConsumption)} />
{showInfo && <NodeResourcesMetricBoxInfo
label={label}
metricSummary={metricSummary}
width={width - (2 * RESOURCES_LABEL_PADDING)}
x={x + RESOURCES_LABEL_PADDING}
y={y + RESOURCES_LABEL_PADDING}
/>}
</g>
);
}
}
function mapStateToProps(state) {
return {
contrastMode: state.get('contrastMode'),
viewportWidth: state.getIn(['viewport', 'width']),
};
}
export default connect(mapStateToProps)(NodeResourcesMetricBox);
+17 -10
View File
@@ -4,17 +4,21 @@ import { debounce } from 'lodash';
import NodesChart from '../charts/nodes-chart';
import NodesGrid from '../charts/nodes-grid';
import NodesResources from '../components/nodes-resources';
import NodesError from '../charts/nodes-error';
import DelayedShow from '../utils/delayed-show';
import { Loading, getNodeType } from './loading';
import { isTopologyEmpty } from '../utils/topology-utils';
import { setViewportDimensions } from '../actions/app-actions';
import {
isGraphViewModeSelector,
isTableViewModeSelector,
isResourceViewModeSelector,
} from '../selectors/topology';
import { VIEWPORT_RESIZE_DEBOUNCE_INTERVAL } from '../constants/timer';
const navbarHeight = 194;
const marginTop = 0;
const EmptyTopologyError = show => (
<NodesError faIconClass="fa-circle-thin" hidden={!show}>
<div className="heading">Nothing to show. This can have any of these reasons:</div>
@@ -47,9 +51,10 @@ class Nodes extends React.Component {
}
render() {
const { topologyEmpty, gridMode, topologiesLoaded, nodesLoaded, topologies,
currentTopology } = this.props;
const { topologyEmpty, topologiesLoaded, nodesLoaded, topologies, currentTopology,
isGraphViewMode, isTableViewMode, isResourceViewMode } = this.props;
// TODO: Rename view mode components.
return (
<div className="nodes-wrapper">
<DelayedShow delay={1000} show={!topologiesLoaded || (topologiesLoaded && !nodesLoaded)}>
@@ -60,23 +65,25 @@ class Nodes extends React.Component {
</DelayedShow>
{EmptyTopologyError(topologiesLoaded && nodesLoaded && topologyEmpty)}
{gridMode ? <NodesGrid /> : <NodesChart />}
{isGraphViewMode && <NodesChart />}
{isTableViewMode && <NodesGrid />}
{isResourceViewMode && <NodesResources />}
</div>
);
}
setDimensions() {
const width = window.innerWidth;
const height = window.innerHeight - navbarHeight - marginTop;
this.props.setViewportDimensions(width, height);
this.props.setViewportDimensions(window.innerWidth, window.innerHeight);
}
}
function mapStateToProps(state) {
return {
isGraphViewMode: isGraphViewModeSelector(state),
isTableViewMode: isTableViewModeSelector(state),
isResourceViewMode: isResourceViewModeSelector(state),
currentTopology: state.get('currentTopology'),
gridMode: state.get('gridMode'),
nodesLoaded: state.get('nodesLoaded'),
topologies: state.get('topologies'),
topologiesLoaded: state.get('topologiesLoaded'),
+7 -4
View File
@@ -5,6 +5,7 @@ import { debounce } from 'lodash';
import { blurSearch, doSearch, focusSearch, showHelp } from '../actions/app-actions';
import { searchMatchCountByTopologySelector } from '../selectors/search';
import { isResourceViewModeSelector } from '../selectors/topology';
import { slugify } from '../utils/string-utils';
import { isTopologyEmpty } from '../utils/topology-utils';
import SearchItem from './search-item';
@@ -89,7 +90,7 @@ class Search extends React.Component {
componentWillReceiveProps(nextProps) {
// when cleared from the outside, reset internal state
if (this.props.searchQuery !== nextProps.searchQuery && nextProps.searchQuery === '') {
this.setState({value: ''});
this.setState({ value: '' });
}
}
@@ -102,16 +103,17 @@ class Search extends React.Component {
}
render() {
const { nodes, pinnedSearches, searchFocused, searchMatchCountByTopology,
const { nodes, pinnedSearches, searchFocused, searchMatchCountByTopology, isResourceViewMode,
searchQuery, topologiesLoaded, onClickHelp, inputId = 'search' } = this.props;
const disabled = this.props.isTopologyEmpty;
const hidden = !topologiesLoaded || isResourceViewMode;
const disabled = this.props.isTopologyEmpty && !hidden;
const matchCount = searchMatchCountByTopology
.reduce((count, topologyMatchCount) => count + topologyMatchCount, 0);
const showPinnedSearches = pinnedSearches.size > 0;
// manual clear (null) has priority, then props, then state
const value = this.state.value === null ? '' : this.state.value || searchQuery || '';
const classNames = classnames('search', 'hideable', {
hide: !topologiesLoaded,
hide: hidden,
'search-pinned': showPinnedSearches,
'search-matched': matchCount,
'search-filled': value,
@@ -153,6 +155,7 @@ class Search extends React.Component {
export default connect(
state => ({
nodes: state.get('nodes'),
isResourceViewMode: isResourceViewModeSelector(state),
isTopologyEmpty: isTopologyEmpty(state),
topologiesLoaded: state.get('topologiesLoaded'),
pinnedSearches: state.get('pinnedSearches'),
+6 -2
View File
@@ -3,6 +3,7 @@ import { connect } from 'react-redux';
import classnames from 'classnames';
import { searchMatchCountByTopologySelector } from '../selectors/search';
import { isResourceViewModeSelector } from '../selectors/topology';
import { clickTopology } from '../actions/app-actions';
@@ -35,8 +36,9 @@ class Topologies extends React.Component {
const searchMatchCount = this.props.searchMatchCountByTopology.get(topologyId) || 0;
const title = basicTopologyInfo(subTopology, searchMatchCount);
const className = classnames('topologies-sub-item', {
// Don't show matches in the resource view as searching is not supported there yet.
'topologies-sub-item-matched': !this.props.isResourceViewMode && searchMatchCount,
'topologies-sub-item-active': isActive,
'topologies-sub-item-matched': searchMatchCount
});
return (
@@ -54,8 +56,9 @@ class Topologies extends React.Component {
const isActive = topology === this.props.currentTopology;
const searchMatchCount = this.props.searchMatchCountByTopology.get(topology.get('id')) || 0;
const className = classnames('topologies-item-main', {
// Don't show matches in the resource view as searching is not supported there yet.
'topologies-item-main-matched': !this.props.isResourceViewMode && searchMatchCount,
'topologies-item-main-active': isActive,
'topologies-item-main-matched': searchMatchCount
});
const topologyId = topology.get('id');
const title = basicTopologyInfo(topology, searchMatchCount);
@@ -91,6 +94,7 @@ function mapStateToProps(state) {
topologies: state.get('topologies'),
currentTopology: state.get('currentTopology'),
searchMatchCountByTopology: searchMatchCountByTopologySelector(state),
isResourceViewMode: isResourceViewModeSelector(state),
};
}
@@ -38,7 +38,7 @@ class DebugMenu extends React.Component {
</span>
</a>
</div>
{!this.props.gridMode && <div className="troubleshooting-menu-item">
<div className="troubleshooting-menu-item">
<a
href=""
className="footer-icon"
@@ -50,7 +50,7 @@ class DebugMenu extends React.Component {
Save canvas as SVG (does not include search highlighting)
</span>
</a>
</div>}
</div>
<div className="troubleshooting-menu-item">
<a
href=""
@@ -86,13 +86,7 @@ class DebugMenu extends React.Component {
}
}
function mapStateToProps(state) {
return {
gridMode: state.get('gridMode'),
};
}
export default connect(mapStateToProps, {
export default connect(null, {
toggleTroubleshootingMenu,
resetLocalViewState,
clickDownloadGraph
@@ -0,0 +1,68 @@
import React from 'react';
import { connect } from 'react-redux';
import classNames from 'classnames';
import MetricSelector from './metric-selector';
import { setGraphView, setTableView, setResourceView } from '../actions/app-actions';
import { layersTopologyIdsSelector } from '../selectors/resource-view/layout';
import { availableMetricsSelector } from '../selectors/node-metric';
import {
isGraphViewModeSelector,
isTableViewModeSelector,
isResourceViewModeSelector,
} from '../selectors/topology';
const Item = (icons, label, isSelected, onClick, isEnabled = true) => {
const className = classNames('view-mode-selector-action', {
'view-mode-selector-action-selected': isSelected,
});
return (
<div
className={className}
disabled={!isEnabled}
onClick={isEnabled && onClick}>
<span className={icons} style={{fontSize: 12}} />
<span>{label}</span>
</div>
);
};
class ViewModeSelector extends React.Component {
componentWillReceiveProps(nextProps) {
if (nextProps.isResourceViewMode && !nextProps.hasResourceView) {
nextProps.setGraphView();
}
}
render() {
const { isGraphViewMode, isTableViewMode, isResourceViewMode, hasResourceView } = this.props;
return (
<div className="view-mode-selector">
<div className="view-mode-selector-wrapper">
{Item('fa fa-share-alt', 'Graph', isGraphViewMode, this.props.setGraphView)}
{Item('fa fa-table', 'Table', isTableViewMode, this.props.setTableView)}
{Item('fa fa-bar-chart', 'Resources', isResourceViewMode, this.props.setResourceView,
hasResourceView)}
</div>
<MetricSelector alwaysPinned={isResourceViewMode} />
</div>
);
}
}
function mapStateToProps(state) {
return {
isGraphViewMode: isGraphViewModeSelector(state),
isTableViewMode: isTableViewModeSelector(state),
isResourceViewMode: isResourceViewModeSelector(state),
hasResourceView: !layersTopologyIdsSelector(state).isEmpty(),
showingMetricsSelector: availableMetricsSelector(state).count() > 0,
};
}
export default connect(
mapStateToProps,
{ setGraphView, setTableView, setResourceView }
)(ViewModeSelector);
@@ -0,0 +1,190 @@
import React from 'react';
import { connect } from 'react-redux';
import { debounce, pick } from 'lodash';
import { fromJS } from 'immutable';
import { event as d3Event, select } from 'd3-selection';
import { zoom, zoomIdentity } from 'd3-zoom';
import { cacheZoomState } from '../actions/app-actions';
import { transformToString } from '../utils/transform-utils';
import { activeTopologyZoomCacheKeyPathSelector } from '../selectors/zooming';
import {
canvasMarginsSelector,
canvasWidthSelector,
canvasHeightSelector,
} from '../selectors/canvas';
import { ZOOM_CACHE_DEBOUNCE_INTERVAL } from '../constants/timer';
class ZoomWrapper extends React.Component {
constructor(props, context) {
super(props, context);
this.state = {
minTranslateX: 0,
maxTranslateX: 0,
minTranslateY: 0,
maxTranslateY: 0,
translateX: 0,
translateY: 0,
minScale: 1,
maxScale: 1,
scaleX: 1,
scaleY: 1,
};
this.debouncedCacheZoom = debounce(this.cacheZoom.bind(this), ZOOM_CACHE_DEBOUNCE_INTERVAL);
this.zoomed = this.zoomed.bind(this);
}
componentDidMount() {
this.zoomRestored = false;
this.zoom = zoom().on('zoom', this.zoomed);
this.svg = select(`svg#${this.props.svg}`);
this.setZoomTriggers(!this.props.disabled);
this.updateZoomLimits(this.props);
this.restoreZoomState(this.props);
}
componentWillUnmount() {
this.setZoomTriggers(false);
this.debouncedCacheZoom.cancel();
}
componentWillReceiveProps(nextProps) {
const layoutChanged = nextProps.layoutId !== this.props.layoutId;
const disabledChanged = nextProps.disabled !== this.props.disabled;
// If the layout has changed (either active topology or its options) or
// relayouting has been requested, stop pending zoom caching event and
// ask for the new zoom settings to be restored again from the cache.
if (layoutChanged || nextProps.forceRelayout) {
this.debouncedCacheZoom.cancel();
this.zoomRestored = false;
}
// If the zooming has been enabled/disabled, update its triggers.
if (disabledChanged) {
this.setZoomTriggers(!nextProps.disabled);
}
this.updateZoomLimits(nextProps);
if (!this.zoomRestored) {
this.restoreZoomState(nextProps);
}
}
render() {
// `forwardTransform` says whether the zoom transform is forwarded to the child
// component. The advantage of that is more control rendering control in the
// children, while the disadvantage is that it's slower, as all the children
// get updated on every zoom/pan action.
const { children, forwardTransform } = this.props;
const transform = forwardTransform ? '' : transformToString(this.state);
return (
<g className="cachable-zoom-wrapper" transform={transform}>
{forwardTransform ? children(this.state) : children}
</g>
);
}
setZoomTriggers(zoomingEnabled) {
if (zoomingEnabled) {
this.svg.call(this.zoom);
} else {
this.svg.on('.zoom', null);
}
}
// Decides which part of the zoom state is cachable depending
// on the horizontal/vertical degrees of freedom.
cachableState(state = this.state) {
const cachableFields = []
.concat(this.props.fixHorizontal ? [] : ['scaleX', 'translateX'])
.concat(this.props.fixVertical ? [] : ['scaleY', 'translateY']);
return pick(state, cachableFields);
}
cacheZoom() {
this.props.cacheZoomState(fromJS(this.cachableState()));
}
updateZoomLimits(props) {
const zoomLimits = props.layoutZoomLimits.toJS();
this.zoom = this.zoom.scaleExtent([zoomLimits.minScale, zoomLimits.maxScale]);
if (props.bounded) {
this.zoom = this.zoom
// Translation limits are only set if explicitly demanded (currently we are using them
// in the resource view, but not in the graph view, although I think the idea would be
// to use them everywhere).
.translateExtent([
[zoomLimits.minTranslateX, zoomLimits.minTranslateY],
[zoomLimits.maxTranslateX, zoomLimits.maxTranslateY],
])
// This is to ensure that the translation limits are properly
// centered, so that the canvas margins are respected.
.extent([
[props.canvasMargins.left, props.canvasMargins.top],
[props.canvasMargins.left + props.width, props.canvasMargins.top + props.height]
]);
}
this.setState(zoomLimits);
}
// Restore the zooming settings
restoreZoomState(props) {
if (!props.layoutZoomState.isEmpty()) {
const zoomState = props.layoutZoomState.toJS();
// After the limits have been set, update the zoom.
this.svg.call(this.zoom.transform, zoomIdentity
.translate(zoomState.translateX, zoomState.translateY)
.scale(zoomState.scaleX, zoomState.scaleY));
// Update the state variables.
this.setState(zoomState);
this.zoomRestored = true;
}
}
zoomed() {
if (!this.props.disabled) {
const updatedState = this.cachableState({
scaleX: d3Event.transform.k,
scaleY: d3Event.transform.k,
translateX: d3Event.transform.x,
translateY: d3Event.transform.y,
});
this.setState(updatedState);
this.debouncedCacheZoom();
}
}
}
function mapStateToProps(state, props) {
return {
width: canvasWidthSelector(state),
height: canvasHeightSelector(state),
canvasMargins: canvasMarginsSelector(state),
layoutZoomState: props.zoomStateSelector(state),
layoutZoomLimits: props.zoomLimitsSelector(state),
layoutId: JSON.stringify(activeTopologyZoomCacheKeyPathSelector(state)),
forceRelayout: state.get('forceRelayout'),
};
}
export default connect(
mapStateToProps,
{ cacheZoomState }
)(ZoomWrapper);