mirror of
https://github.com/weaveworks/scope.git
synced 2026-08-19 04:16:21 +00:00
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:
@@ -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);
|
||||
@@ -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,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);
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user