mirror of
https://github.com/weaveworks/scope.git
synced 2026-03-03 02:00:43 +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:
@@ -13,6 +13,7 @@ import {
|
||||
import {
|
||||
doControlRequest,
|
||||
getAllNodes,
|
||||
getResourceViewNodesSnapshot,
|
||||
getNodesDelta,
|
||||
getNodeDetails,
|
||||
getTopologies,
|
||||
@@ -23,7 +24,16 @@ import {
|
||||
import { getCurrentTopologyUrl } from '../utils/topology-utils';
|
||||
import { storageSet } from '../utils/storage-utils';
|
||||
import { loadTheme } from '../utils/contrast-utils';
|
||||
import { activeTopologyOptionsSelector } from '../selectors/topology';
|
||||
import { availableMetricsSelector, pinnedMetricSelector } from '../selectors/node-metric';
|
||||
import {
|
||||
activeTopologyOptionsSelector,
|
||||
isResourceViewModeSelector,
|
||||
} from '../selectors/topology';
|
||||
import {
|
||||
GRAPH_VIEW_MODE,
|
||||
TABLE_VIEW_MODE,
|
||||
RESOURCE_VIEW_MODE,
|
||||
} from '../constants/naming';
|
||||
|
||||
const log = debug('scope:app-actions');
|
||||
|
||||
@@ -141,7 +151,7 @@ export function unpinMetric() {
|
||||
export function pinNextMetric(delta) {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const metrics = state.get('availableCanvasMetrics').map(m => m.get('id'));
|
||||
const metrics = availableMetricsSelector(state).map(m => m.get('id'));
|
||||
const currentIndex = metrics.indexOf(state.get('selectedMetric'));
|
||||
const nextIndex = modulo(currentIndex + delta, metrics.count());
|
||||
const nextMetric = metrics.get(nextIndex);
|
||||
@@ -248,25 +258,57 @@ export function clickForceRelayout() {
|
||||
};
|
||||
}
|
||||
|
||||
export function doSearch(searchQuery) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({
|
||||
type: ActionTypes.DO_SEARCH,
|
||||
searchQuery
|
||||
});
|
||||
updateRoute(getState);
|
||||
};
|
||||
}
|
||||
|
||||
export function setViewportDimensions(width, height) {
|
||||
return (dispatch) => {
|
||||
dispatch({ type: ActionTypes.SET_VIEWPORT_DIMENSIONS, width, height });
|
||||
};
|
||||
}
|
||||
|
||||
export function toggleGridMode(enabledArgument) {
|
||||
export function setGraphView() {
|
||||
return (dispatch, getState) => {
|
||||
const enabled = (enabledArgument === undefined) ?
|
||||
!getState().get('gridMode') :
|
||||
enabledArgument;
|
||||
dispatch({
|
||||
type: ActionTypes.SET_GRID_MODE,
|
||||
enabled
|
||||
type: ActionTypes.SET_VIEW_MODE,
|
||||
viewMode: GRAPH_VIEW_MODE,
|
||||
});
|
||||
updateRoute(getState);
|
||||
};
|
||||
}
|
||||
|
||||
export function setTableView() {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({
|
||||
type: ActionTypes.SET_VIEW_MODE,
|
||||
viewMode: TABLE_VIEW_MODE,
|
||||
});
|
||||
updateRoute(getState);
|
||||
};
|
||||
}
|
||||
|
||||
export function setResourceView() {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({
|
||||
type: ActionTypes.SET_VIEW_MODE,
|
||||
viewMode: RESOURCE_VIEW_MODE,
|
||||
});
|
||||
// Pin the first metric if none of the visible ones is pinned.
|
||||
if (!pinnedMetricSelector(getState())) {
|
||||
dispatch({ type: ActionTypes.PIN_METRIC });
|
||||
}
|
||||
getResourceViewNodesSnapshot(getState, dispatch);
|
||||
updateRoute(getState);
|
||||
};
|
||||
}
|
||||
|
||||
export function clickNode(nodeId, label, origin) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({
|
||||
@@ -323,6 +365,25 @@ export function clickResumeUpdate() {
|
||||
};
|
||||
}
|
||||
|
||||
function updateTopology(dispatch, getState) {
|
||||
const state = getState();
|
||||
// If we're in the resource view, get the snapshot of all the relevant node topologies.
|
||||
if (isResourceViewModeSelector(state)) {
|
||||
getResourceViewNodesSnapshot(getState, dispatch);
|
||||
}
|
||||
updateRoute(getState);
|
||||
// update all request workers with new options
|
||||
resetUpdateBuffer();
|
||||
// NOTE: This is currently not needed for our static resource
|
||||
// view, but we'll need it here later and it's simpler to just
|
||||
// keep it than to redo the nodes delta updating logic.
|
||||
getNodesDelta(
|
||||
getCurrentTopologyUrl(state),
|
||||
activeTopologyOptionsSelector(state),
|
||||
dispatch
|
||||
);
|
||||
}
|
||||
|
||||
export function clickShowTopologyForNode(topologyId, nodeId) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({
|
||||
@@ -330,15 +391,7 @@ export function clickShowTopologyForNode(topologyId, nodeId) {
|
||||
topologyId,
|
||||
nodeId
|
||||
});
|
||||
updateRoute(getState);
|
||||
// update all request workers with new options
|
||||
resetUpdateBuffer();
|
||||
const state = getState();
|
||||
getNodesDelta(
|
||||
getCurrentTopologyUrl(state),
|
||||
activeTopologyOptionsSelector(state),
|
||||
dispatch
|
||||
);
|
||||
updateTopology(dispatch, getState);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -348,15 +401,7 @@ export function clickTopology(topologyId) {
|
||||
type: ActionTypes.CLICK_TOPOLOGY,
|
||||
topologyId
|
||||
});
|
||||
updateRoute(getState);
|
||||
// update all request workers with new options
|
||||
resetUpdateBuffer();
|
||||
const state = getState();
|
||||
getNodesDelta(
|
||||
getCurrentTopologyUrl(state),
|
||||
activeTopologyOptionsSelector(state),
|
||||
dispatch
|
||||
);
|
||||
updateTopology(dispatch, getState);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -397,16 +442,6 @@ export function doControl(nodeId, control) {
|
||||
};
|
||||
}
|
||||
|
||||
export function doSearch(searchQuery) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({
|
||||
type: ActionTypes.DO_SEARCH,
|
||||
searchQuery
|
||||
});
|
||||
updateRoute(getState);
|
||||
};
|
||||
}
|
||||
|
||||
export function enterEdge(edgeId) {
|
||||
return {
|
||||
type: ActionTypes.ENTER_EDGE,
|
||||
@@ -700,6 +735,12 @@ export function route(urlState) {
|
||||
state.get('nodeDetails'),
|
||||
dispatch
|
||||
);
|
||||
// If we are landing on the resource view page, we need to fetch not only all the
|
||||
// nodes for the current topology, but also the nodes of all the topologies that make
|
||||
// the layers in the resource view.
|
||||
if (isResourceViewModeSelector(state)) {
|
||||
getResourceViewNodesSnapshot(getState, dispatch);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,15 +8,15 @@ import {
|
||||
selectedScaleSelector,
|
||||
layoutNodesSelector,
|
||||
layoutEdgesSelector
|
||||
} from '../selectors/nodes-chart-layout';
|
||||
} from '../selectors/graph-view/layout';
|
||||
|
||||
|
||||
class NodesChartElements extends React.Component {
|
||||
render() {
|
||||
const { layoutNodes, layoutEdges, selectedScale, transform, isAnimated } = this.props;
|
||||
const { layoutNodes, layoutEdges, selectedScale, isAnimated } = this.props;
|
||||
|
||||
return (
|
||||
<g className="nodes-chart-elements" transform={transform}>
|
||||
<g className="nodes-chart-elements">
|
||||
<NodesChartEdges
|
||||
layoutEdges={layoutEdges}
|
||||
selectedScale={selectedScale}
|
||||
|
||||
@@ -1,176 +1,76 @@
|
||||
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 Logo from '../components/logo';
|
||||
import NodesChartElements from './nodes-chart-elements';
|
||||
import { clickBackground, cacheZoomState } from '../actions/app-actions';
|
||||
import { activeLayoutZoomSelector } from '../selectors/nodes-chart-zoom';
|
||||
import { activeTopologyZoomCacheKeyPathSelector } from '../selectors/topology';
|
||||
import { ZOOM_CACHE_DEBOUNCE_INTERVAL } from '../constants/timer';
|
||||
import ZoomWrapper from '../components/zoom-wrapper';
|
||||
import { clickBackground } from '../actions/app-actions';
|
||||
import {
|
||||
graphZoomLimitsSelector,
|
||||
graphZoomStateSelector,
|
||||
} from '../selectors/graph-view/zoom';
|
||||
|
||||
|
||||
const ZOOM_CACHE_FIELDS = [
|
||||
'zoomScale',
|
||||
'minZoomScale',
|
||||
'maxZoomScale',
|
||||
'panTranslateX',
|
||||
'panTranslateY',
|
||||
];
|
||||
const EdgeMarkerDefinition = ({ selectedNodeId }) => {
|
||||
const markerOffset = selectedNodeId ? '35' : '40';
|
||||
const markerSize = selectedNodeId ? '10' : '30';
|
||||
return (
|
||||
<defs>
|
||||
<marker
|
||||
className="edge-marker"
|
||||
id="end-arrow"
|
||||
viewBox="1 0 10 10"
|
||||
refX={markerOffset}
|
||||
refY="3.5"
|
||||
markerWidth={markerSize}
|
||||
markerHeight={markerSize}
|
||||
orient="auto">
|
||||
<polygon className="link" points="0 0, 10 3.5, 0 7" />
|
||||
</marker>
|
||||
</defs>
|
||||
);
|
||||
};
|
||||
|
||||
class NodesChart extends React.Component {
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
zoomScale: 0,
|
||||
minZoomScale: 0,
|
||||
maxZoomScale: 0,
|
||||
panTranslateX: 0,
|
||||
panTranslateY: 0,
|
||||
};
|
||||
|
||||
this.debouncedCacheZoom = debounce(this.cacheZoom.bind(this), ZOOM_CACHE_DEBOUNCE_INTERVAL);
|
||||
this.handleMouseClick = this.handleMouseClick.bind(this);
|
||||
this.zoomed = this.zoomed.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
// distinguish pan/zoom from click
|
||||
this.isZooming = false;
|
||||
this.zoomRestored = false;
|
||||
this.zoom = zoom().on('zoom', this.zoomed);
|
||||
|
||||
this.svg = select('.nodes-chart svg');
|
||||
this.svg.call(this.zoom);
|
||||
|
||||
this.restoreCachedZoom(this.props);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// undoing .call(zoom)
|
||||
this.svg
|
||||
.on('mousedown.zoom', null)
|
||||
.on('onwheel', null)
|
||||
.on('onmousewheel', null)
|
||||
.on('dblclick.zoom', null)
|
||||
.on('touchstart.zoom', null);
|
||||
|
||||
this.debouncedCacheZoom.cancel();
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
const layoutChanged = nextProps.layoutId !== this.props.layoutId;
|
||||
|
||||
// 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 (!this.zoomRestored) {
|
||||
this.restoreCachedZoom(nextProps);
|
||||
handleMouseClick() {
|
||||
if (this.props.selectedNodeId) {
|
||||
this.props.clickBackground();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
// Not passing transform into child components for perf reasons.
|
||||
const { panTranslateX, panTranslateY, zoomScale } = this.state;
|
||||
const transform = `translate(${panTranslateX}, ${panTranslateY}) scale(${zoomScale})`;
|
||||
const svgClassNames = this.props.isEmpty ? 'hide' : '';
|
||||
const markerOffset = this.props.selectedNodeId ? '35' : '40';
|
||||
const markerSize = this.props.selectedNodeId ? '10' : '30';
|
||||
|
||||
const { selectedNodeId } = this.props;
|
||||
return (
|
||||
<div className="nodes-chart">
|
||||
<svg
|
||||
width="100%" height="100%" id="nodes-chart-canvas"
|
||||
className={svgClassNames} onClick={this.handleMouseClick}
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
className="edge-marker"
|
||||
id="end-arrow"
|
||||
viewBox="1 0 10 10"
|
||||
refX={markerOffset}
|
||||
refY="3.5"
|
||||
markerWidth={markerSize}
|
||||
markerHeight={markerSize}
|
||||
orient="auto"
|
||||
>
|
||||
<polygon className="link" points="0 0, 10 3.5, 0 7" />
|
||||
</marker>
|
||||
</defs>
|
||||
<g transform="translate(24,24) scale(0.25)">
|
||||
<Logo />
|
||||
</g>
|
||||
<NodesChartElements transform={transform} />
|
||||
<svg id="canvas" width="100%" height="100%" onClick={this.handleMouseClick}>
|
||||
<Logo transform="translate(24,24) scale(0.25)" />
|
||||
<EdgeMarkerDefinition selectedNodeId={selectedNodeId} />
|
||||
<ZoomWrapper
|
||||
svg="canvas" disabled={selectedNodeId}
|
||||
zoomLimitsSelector={graphZoomLimitsSelector}
|
||||
zoomStateSelector={graphZoomStateSelector}>
|
||||
<NodesChartElements />
|
||||
</ZoomWrapper>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
cacheZoom() {
|
||||
const zoomState = pick(this.state, ZOOM_CACHE_FIELDS);
|
||||
this.props.cacheZoomState(fromJS(zoomState));
|
||||
}
|
||||
|
||||
restoreCachedZoom(props) {
|
||||
if (!props.layoutZoom.isEmpty()) {
|
||||
const zoomState = props.layoutZoom.toJS();
|
||||
|
||||
// Restore the zooming settings
|
||||
this.zoom = this.zoom.scaleExtent([zoomState.minZoomScale, zoomState.maxZoomScale]);
|
||||
this.svg.call(this.zoom.transform, zoomIdentity
|
||||
.translate(zoomState.panTranslateX, zoomState.panTranslateY)
|
||||
.scale(zoomState.zoomScale));
|
||||
|
||||
// Update the state variables
|
||||
this.setState(zoomState);
|
||||
this.zoomRestored = true;
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseClick() {
|
||||
if (!this.isZooming || this.props.selectedNodeId) {
|
||||
this.props.clickBackground();
|
||||
} else {
|
||||
this.isZooming = false;
|
||||
}
|
||||
}
|
||||
|
||||
zoomed() {
|
||||
this.isZooming = true;
|
||||
// don't pan while node is selected
|
||||
if (!this.props.selectedNodeId) {
|
||||
this.setState({
|
||||
panTranslateX: d3Event.transform.x,
|
||||
panTranslateY: d3Event.transform.y,
|
||||
zoomScale: d3Event.transform.k
|
||||
});
|
||||
this.debouncedCacheZoom();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
layoutZoom: activeLayoutZoomSelector(state),
|
||||
layoutId: JSON.stringify(activeTopologyZoomCacheKeyPathSelector(state)),
|
||||
selectedNodeId: state.get('selectedNodeId'),
|
||||
forceRelayout: state.get('forceRelayout'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
{ clickBackground, cacheZoomState }
|
||||
{ clickBackground }
|
||||
)(NodesChart);
|
||||
|
||||
@@ -7,7 +7,7 @@ import NodeDetailsTable from '../components/node-details/node-details-table';
|
||||
import { clickNode, sortOrderChanged } from '../actions/app-actions';
|
||||
import { shownNodesSelector } from '../selectors/node-filters';
|
||||
|
||||
import { CANVAS_MARGINS } from '../constants/styles';
|
||||
import { canvasMarginsSelector, canvasHeightSelector } from '../selectors/canvas';
|
||||
import { searchNodeMatchesSelector } from '../selectors/search';
|
||||
import { getNodeColor } from '../utils/color-utils';
|
||||
|
||||
@@ -97,14 +97,15 @@ class NodesGrid extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { nodes, height, gridSortedBy, gridSortedDesc,
|
||||
const { nodes, height, gridSortedBy, gridSortedDesc, canvasMargins,
|
||||
searchNodeMatches, searchQuery } = this.props;
|
||||
const cmpStyle = {
|
||||
height,
|
||||
marginTop: CANVAS_MARGINS.top,
|
||||
paddingLeft: CANVAS_MARGINS.left,
|
||||
paddingRight: CANVAS_MARGINS.right,
|
||||
marginTop: canvasMargins.top,
|
||||
paddingLeft: canvasMargins.left,
|
||||
paddingRight: canvasMargins.right,
|
||||
};
|
||||
// TODO: What are 24 and 18? Use a comment or extract into constants.
|
||||
const tbodyHeight = height - 24 - 18;
|
||||
const className = 'scroll-body';
|
||||
const tbodyStyle = {
|
||||
@@ -146,6 +147,8 @@ class NodesGrid extends React.Component {
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
nodes: shownNodesSelector(state),
|
||||
canvasMargins: canvasMarginsSelector(state),
|
||||
height: canvasHeightSelector(state),
|
||||
gridSortedBy: state.get('gridSortedBy'),
|
||||
gridSortedDesc: state.get('gridSortedDesc'),
|
||||
currentTopology: state.get('currentTopology'),
|
||||
@@ -153,7 +156,6 @@ function mapStateToProps(state) {
|
||||
searchNodeMatches: searchNodeMatchesSelector(state),
|
||||
searchQuery: state.get('searchQuery'),
|
||||
selectedNodeId: state.get('selectedNodeId'),
|
||||
height: state.getIn(['viewport', 'height']),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
51
client/app/scripts/components/nodes-resources.js
Normal file
51
client/app/scripts/components/nodes-resources.js
Normal file
@@ -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
|
||||
|
||||
68
client/app/scripts/components/view-mode-selector.js
Normal file
68
client/app/scripts/components/view-mode-selector.js
Normal file
@@ -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);
|
||||
190
client/app/scripts/components/zoom-wrapper.js
Normal file
190
client/app/scripts/components/zoom-wrapper.js
Normal file
@@ -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);
|
||||
@@ -59,7 +59,7 @@ const ACTION_TYPES = [
|
||||
'SHOW_NETWORKS',
|
||||
'SET_RECEIVED_NODES_DELTA',
|
||||
'SORT_ORDER_CHANGED',
|
||||
'SET_GRID_MODE',
|
||||
'SET_VIEW_MODE',
|
||||
'CHANGE_INSTANCE',
|
||||
'TOGGLE_CONTRAST_MODE',
|
||||
'SHUTDOWN'
|
||||
|
||||
@@ -1,2 +1,8 @@
|
||||
|
||||
export const EDGE_ID_SEPARATOR = '---';
|
||||
|
||||
// NOTE: Inconsistent naming is a consequence of
|
||||
// keeping it backwards-compatible with the old URLs.
|
||||
export const GRAPH_VIEW_MODE = 'topo';
|
||||
export const TABLE_VIEW_MODE = 'grid';
|
||||
export const RESOURCE_VIEW_MODE = 'resource';
|
||||
|
||||
27
client/app/scripts/constants/resources.js
Normal file
27
client/app/scripts/constants/resources.js
Normal file
@@ -0,0 +1,27 @@
|
||||
|
||||
// Cap the number of layers in the resource view to this constant. The reason why we have
|
||||
// this constant is not just about the style, but also helps us build the selectors.
|
||||
export const RESOURCE_VIEW_MAX_LAYERS = 3;
|
||||
|
||||
// TODO: Consider fetching these from the backend.
|
||||
export const TOPOLOGIES_WITH_CAPACITY = ['hosts'];
|
||||
|
||||
// TODO: These too should ideally be provided by the backend. Currently, we are showing
|
||||
// the same layers for all the topologies, because their number is small, but later on
|
||||
// we might be interested in fully customizing the layers' hierarchy per topology.
|
||||
export const RESOURCE_VIEW_LAYERS = {
|
||||
hosts: ['hosts', 'containers', 'processes'],
|
||||
containers: ['hosts', 'containers', 'processes'],
|
||||
processes: ['hosts', 'containers', 'processes'],
|
||||
};
|
||||
|
||||
// TODO: These are all the common metrics that appear across all the current resource view
|
||||
// topologies. The reason for taking them only is that we want to get meaningful data for all
|
||||
// the layers. These should be taken directly from the backend report, but as their info is
|
||||
// currently only contained in the nodes data, it would be hard to determine them before all
|
||||
// the nodes for all the layers have been loaded, so we'd need to change the routing logic
|
||||
// since the requirement is that one these is always pinned in the resource view.
|
||||
export const RESOURCE_VIEW_METRICS = [
|
||||
{ label: 'CPU', id: 'host_cpu_usage_percent' },
|
||||
{ label: 'Memory', id: 'host_mem_usage_bytes' },
|
||||
];
|
||||
@@ -1,20 +1,20 @@
|
||||
import { GRAPH_VIEW_MODE, TABLE_VIEW_MODE, RESOURCE_VIEW_MODE } from './naming';
|
||||
|
||||
|
||||
export const DETAILS_PANEL_WIDTH = 420;
|
||||
|
||||
export const DETAILS_PANEL_OFFSET = 8;
|
||||
export const DETAILS_PANEL_MARGINS = {
|
||||
top: 24,
|
||||
bottom: 48,
|
||||
right: 36
|
||||
};
|
||||
|
||||
export const DETAILS_PANEL_OFFSET = 8;
|
||||
|
||||
export const CANVAS_MARGINS = {
|
||||
top: 160,
|
||||
left: 40,
|
||||
right: 40,
|
||||
bottom: 0,
|
||||
};
|
||||
// Resource view
|
||||
export const RESOURCES_LAYER_TITLE_WIDTH = 200;
|
||||
export const RESOURCES_LAYER_HEIGHT = 150;
|
||||
export const RESOURCES_LAYER_PADDING = 10;
|
||||
export const RESOURCES_LABEL_MIN_SIZE = 50;
|
||||
export const RESOURCES_LABEL_PADDING = 10;
|
||||
|
||||
// Node shapes
|
||||
export const NODE_SHAPE_HIGHLIGHT_RADIUS = 70;
|
||||
@@ -30,6 +30,13 @@ export const NODE_SHAPE_DOT_RADIUS = 10;
|
||||
// are given on a small unit scale as foreign objects in SVG.
|
||||
export const NODE_BASE_SIZE = 100;
|
||||
|
||||
|
||||
export const CANVAS_MARGINS = {
|
||||
[GRAPH_VIEW_MODE]: { top: 160, left: 40, right: 40, bottom: 150 },
|
||||
[TABLE_VIEW_MODE]: { top: 160, left: 40, right: 40, bottom: 30 },
|
||||
[RESOURCE_VIEW_MODE]: { top: 160, left: 210, right: 40, bottom: 50 },
|
||||
};
|
||||
|
||||
// Node details table constants
|
||||
export const NODE_DETAILS_TABLE_CW = {
|
||||
XS: '32px',
|
||||
|
||||
65
client/app/scripts/decorators/node.js
Normal file
65
client/app/scripts/decorators/node.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Map as makeMap } from 'immutable';
|
||||
|
||||
import { getNodeColor } from '../utils/color-utils';
|
||||
import { formatMetricSvg } from '../utils/string-utils';
|
||||
import { RESOURCES_LAYER_HEIGHT } from '../constants/styles';
|
||||
|
||||
|
||||
export function nodeResourceViewColorDecorator(node) {
|
||||
// Color lightness is normally determined from the node label. However, in the resource view
|
||||
// mode, we don't want to vary the lightness so we just always forward the empty string instead.
|
||||
return node.set('color', getNodeColor(node.get('rank'), '', node.get('pseudo')));
|
||||
}
|
||||
|
||||
// Decorates the resource node with dimensions taken from its metric summary.
|
||||
export function nodeResourceBoxDecorator(node) {
|
||||
const metricSummary = node.get('metricSummary', makeMap());
|
||||
const width = metricSummary.get('showCapacity') ?
|
||||
metricSummary.get('totalCapacity') :
|
||||
metricSummary.get('absoluteConsumption');
|
||||
const height = RESOURCES_LAYER_HEIGHT;
|
||||
|
||||
return node.merge(makeMap({ width, height }));
|
||||
}
|
||||
|
||||
// Decorates the node with the summary info of its metric of a fixed type.
|
||||
export function nodeMetricSummaryDecoratorByType(metricType, showCapacity) {
|
||||
return (node) => {
|
||||
const metric = node
|
||||
.get('metrics', makeMap())
|
||||
.find(m => m.get('label') === metricType);
|
||||
|
||||
// Do nothing if there is no metric info.
|
||||
if (!metric) return node;
|
||||
|
||||
const absoluteConsumption = metric.get('value');
|
||||
const totalCapacity = showCapacity ? metric.get('max') : absoluteConsumption;
|
||||
const relativeConsumption = absoluteConsumption / totalCapacity;
|
||||
const defaultMetric = { format: metric.get('format') };
|
||||
const percentMetric = { format: 'percent' };
|
||||
const format = metric.get('format');
|
||||
|
||||
return node.set('metricSummary', makeMap({
|
||||
showCapacity,
|
||||
type: metricType,
|
||||
humanizedTotalCapacity: formatMetricSvg(totalCapacity, defaultMetric),
|
||||
humanizedAbsoluteConsumption: formatMetricSvg(absoluteConsumption, defaultMetric),
|
||||
humanizedRelativeConsumption: formatMetricSvg(100 * relativeConsumption, percentMetric),
|
||||
totalCapacity,
|
||||
absoluteConsumption,
|
||||
relativeConsumption,
|
||||
format,
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
// Decorates the node with the ID of the parent node belonging to a fixed topology.
|
||||
export function nodeParentDecoratorByTopologyId(topologyId) {
|
||||
return (node) => {
|
||||
const parent = node
|
||||
.get('parents', makeMap())
|
||||
.find(p => p.get('topologyId') === topologyId);
|
||||
|
||||
return parent ? node.set('parentNodeId', parent.get('id')) : node;
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {is, fromJS} from 'immutable';
|
||||
import { is, fromJS } from 'immutable';
|
||||
import { TABLE_VIEW_MODE } from '../../constants/naming';
|
||||
// Root reducer test suite using Jasmine matchers
|
||||
import { constructEdgeId } from '../../utils/layouter-utils';
|
||||
|
||||
@@ -501,10 +502,10 @@ describe('RootReducer', () => {
|
||||
nextState = reducer(nextState, { type: ActionTypes.CLICK_BACKGROUND });
|
||||
expect(nextState.get('showingHelp')).toBe(false);
|
||||
});
|
||||
it('switches to grid mode when complexity is high', () => {
|
||||
it('switches to table view when complexity is high', () => {
|
||||
let nextState = initialState.set('currentTopology', fromJS(topologies[0]));
|
||||
nextState = reducer(nextState, {type: ActionTypes.SET_RECEIVED_NODES_DELTA});
|
||||
expect(nextState.get('gridMode')).toBe(true);
|
||||
expect(nextState.get('topologyViewMode')).toEqual(TABLE_VIEW_MODE);
|
||||
expect(nextState.get('initialNodesLoaded')).toBe(true);
|
||||
});
|
||||
it('cleans up old adjacencies', () => {
|
||||
|
||||
@@ -5,13 +5,18 @@ import { fromJS, is as isDeepEqual, List as makeList, Map as makeMap,
|
||||
OrderedMap as makeOrderedMap, Set as makeSet } from 'immutable';
|
||||
|
||||
import ActionTypes from '../constants/action-types';
|
||||
import { EDGE_ID_SEPARATOR } from '../constants/naming';
|
||||
import {
|
||||
EDGE_ID_SEPARATOR,
|
||||
GRAPH_VIEW_MODE,
|
||||
TABLE_VIEW_MODE,
|
||||
} from '../constants/naming';
|
||||
import {
|
||||
graphExceedsComplexityThreshSelector,
|
||||
activeTopologyZoomCacheKeyPathSelector,
|
||||
isResourceViewModeSelector,
|
||||
} from '../selectors/topology';
|
||||
import { activeTopologyZoomCacheKeyPathSelector } from '../selectors/zooming';
|
||||
import { availableMetricsSelector, pinnedMetricSelector } from '../selectors/node-metric';
|
||||
import { applyPinnedSearches } from '../utils/search-utils';
|
||||
import { getNetworkNodes } from '../utils/network-view-utils';
|
||||
import {
|
||||
findTopologyById,
|
||||
getAdjacentNodes,
|
||||
@@ -32,8 +37,6 @@ const topologySorter = topology => topology.get('rank');
|
||||
// Initial values
|
||||
|
||||
export const initialState = makeMap({
|
||||
availableCanvasMetrics: makeList(),
|
||||
availableNetworks: makeList(),
|
||||
contrastMode: false,
|
||||
controlPipes: makeOrderedMap(), // pipeId -> controlPipe
|
||||
controlStatus: makeMap(),
|
||||
@@ -42,7 +45,6 @@ export const initialState = makeMap({
|
||||
errorUrl: null,
|
||||
exportingGraph: false,
|
||||
forceRelayout: false,
|
||||
gridMode: false,
|
||||
gridSortedBy: null,
|
||||
gridSortedDesc: null,
|
||||
// TODO: Calculate these sets from selectors instead.
|
||||
@@ -52,13 +54,11 @@ export const initialState = makeMap({
|
||||
initialNodesLoaded: false,
|
||||
mouseOverEdgeId: null,
|
||||
mouseOverNodeId: null,
|
||||
networkNodes: makeMap(),
|
||||
nodeDetails: makeOrderedMap(), // nodeId -> details
|
||||
nodes: makeOrderedMap(), // nodeId -> node
|
||||
nodesLoaded: false,
|
||||
// nodes cache, infrequently updated, used for search
|
||||
// nodes cache, infrequently updated, used for search & resource view
|
||||
nodesByTopology: makeMap(), // topologyId -> nodes
|
||||
pinnedMetric: null,
|
||||
// class of metric, e.g. 'cpu', rather than 'host_cpu' or 'process_cpu'.
|
||||
// allows us to keep the same metric "type" selected when the topology changes.
|
||||
pinnedMetricType: null,
|
||||
@@ -78,6 +78,7 @@ export const initialState = makeMap({
|
||||
topologiesLoaded: false,
|
||||
topologyOptions: makeOrderedMap(), // topologyId -> options
|
||||
topologyUrlsById: makeOrderedMap(), // topologyId -> topologyUrl
|
||||
topologyViewMode: GRAPH_VIEW_MODE,
|
||||
updatePausedAt: null, // Date
|
||||
version: '...',
|
||||
versionUpdate: null,
|
||||
@@ -207,8 +208,8 @@ export function rootReducer(state = initialState, action) {
|
||||
});
|
||||
}
|
||||
|
||||
case ActionTypes.SET_GRID_MODE: {
|
||||
return state.setIn(['gridMode'], action.enabled);
|
||||
case ActionTypes.SET_VIEW_MODE: {
|
||||
return state.set('topologyViewMode', action.viewMode);
|
||||
}
|
||||
|
||||
case ActionTypes.CACHE_ZOOM_STATE: {
|
||||
@@ -309,7 +310,6 @@ export function rootReducer(state = initialState, action) {
|
||||
state = setTopology(state, action.topologyId);
|
||||
state = clearNodes(state);
|
||||
}
|
||||
state = state.set('availableCanvasMetrics', makeList());
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -323,7 +323,6 @@ export function rootReducer(state = initialState, action) {
|
||||
state = clearNodes(state);
|
||||
}
|
||||
|
||||
state = state.set('availableCanvasMetrics', makeList());
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -372,20 +371,18 @@ export function rootReducer(state = initialState, action) {
|
||||
}
|
||||
|
||||
case ActionTypes.PIN_METRIC: {
|
||||
const metricTypes = makeMap(
|
||||
state.get('availableCanvasMetrics').map(m => [m.get('id'), m.get('label')]));
|
||||
const canvasMetrics = availableMetricsSelector(state);
|
||||
const metricTypes = makeMap(canvasMetrics.map(m => [m.get('id'), m.get('label')]));
|
||||
// Pin the first metric if no metric ID was explicitly given.
|
||||
const metricId = action.metricId || (canvasMetrics.first() || makeMap()).get('id');
|
||||
return state.merge({
|
||||
pinnedMetric: action.metricId,
|
||||
pinnedMetricType: metricTypes.get(action.metricId),
|
||||
selectedMetric: action.metricId
|
||||
pinnedMetricType: metricTypes.get(metricId),
|
||||
selectedMetric: metricId,
|
||||
});
|
||||
}
|
||||
|
||||
case ActionTypes.UNPIN_METRIC: {
|
||||
return state.merge({
|
||||
pinnedMetric: null,
|
||||
pinnedMetricType: null
|
||||
});
|
||||
return state.set('pinnedMetricType', null);
|
||||
}
|
||||
|
||||
case ActionTypes.SHOW_HELP: {
|
||||
@@ -550,9 +547,10 @@ export function rootReducer(state = initialState, action) {
|
||||
// Turn on the table view if the graph is too complex, but skip
|
||||
// this block if the user has already loaded topologies once.
|
||||
if (!state.get('initialNodesLoaded') && !state.get('nodesLoaded')) {
|
||||
state = graphExceedsComplexityThreshSelector(state)
|
||||
? state.set('gridMode', true)
|
||||
: state;
|
||||
if (state.get('topologyViewMode') === GRAPH_VIEW_MODE) {
|
||||
state = graphExceedsComplexityThreshSelector(state)
|
||||
? state.set('topologyViewMode', TABLE_VIEW_MODE) : state;
|
||||
}
|
||||
state = state.set('initialNodesLoaded', true);
|
||||
}
|
||||
return state.set('nodesLoaded', true);
|
||||
@@ -600,30 +598,23 @@ export function rootReducer(state = initialState, action) {
|
||||
|
||||
// apply pinned searches, filters nodes that dont match
|
||||
state = applyPinnedSearches(state);
|
||||
state = state.set('networkNodes', getNetworkNodes(state));
|
||||
|
||||
state = state.set('availableCanvasMetrics', state.get('nodes')
|
||||
.valueSeq()
|
||||
.flatMap(n => (n.get('metrics') || makeList()).map(m => (
|
||||
makeMap({id: m.get('id'), label: m.get('label')})
|
||||
)))
|
||||
.toSet()
|
||||
.toList()
|
||||
.sortBy(m => m.get('label')));
|
||||
|
||||
const similarTypeMetric = state.get('availableCanvasMetrics')
|
||||
.find(m => m.get('label') === state.get('pinnedMetricType'));
|
||||
state = state.set('pinnedMetric', similarTypeMetric && similarTypeMetric.get('id'));
|
||||
// if something in the current topo is not already selected, select it.
|
||||
if (!state.get('availableCanvasMetrics')
|
||||
if (!availableMetricsSelector(state)
|
||||
.map(m => m.get('id'))
|
||||
.toSet()
|
||||
.has(state.get('selectedMetric'))) {
|
||||
state = state.set('selectedMetric', state.get('pinnedMetric'));
|
||||
state = state.set('selectedMetric', pinnedMetricSelector(state));
|
||||
}
|
||||
|
||||
// update nodes cache
|
||||
return state.setIn(['nodesByTopology', state.get('currentTopologyId')], state.get('nodes'));
|
||||
// Update the nodes cache only if we're not in the resource view mode, as we
|
||||
// intentionally want to keep it static before we figure how to keep it up-to-date.
|
||||
if (!isResourceViewModeSelector(state)) {
|
||||
const nodesForCurrentTopologyKey = ['nodesByTopology', state.get('currentTopologyId')];
|
||||
state = state.setIn(nodesForCurrentTopologyKey, state.get('nodes'));
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
case ActionTypes.RECEIVE_NODES_FOR_TOPOLOGY: {
|
||||
@@ -684,7 +675,7 @@ export function rootReducer(state = initialState, action) {
|
||||
selectedNodeId: action.state.selectedNodeId,
|
||||
pinnedMetricType: action.state.pinnedMetricType
|
||||
});
|
||||
state = state.set('gridMode', action.state.topologyViewMode === 'grid');
|
||||
state = state.set('topologyViewMode', action.state.topologyViewMode);
|
||||
if (action.state.gridSortedBy) {
|
||||
state = state.set('gridSortedBy', action.state.gridSortedBy);
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { CANVAS_MARGINS, DETAILS_PANEL_WIDTH, DETAILS_PANEL_MARGINS } from '../constants/styles';
|
||||
|
||||
|
||||
export const viewportWidthSelector = createSelector(
|
||||
[
|
||||
state => state.getIn(['viewport', 'width']),
|
||||
],
|
||||
width => width - CANVAS_MARGINS.left - CANVAS_MARGINS.right
|
||||
);
|
||||
|
||||
export const viewportHeightSelector = createSelector(
|
||||
[
|
||||
state => state.getIn(['viewport', 'height']),
|
||||
],
|
||||
height => height - CANVAS_MARGINS.top - CANVAS_MARGINS.bottom
|
||||
);
|
||||
|
||||
const viewportFocusWidthSelector = createSelector(
|
||||
[
|
||||
viewportWidthSelector,
|
||||
],
|
||||
width => width - DETAILS_PANEL_WIDTH - DETAILS_PANEL_MARGINS.right
|
||||
);
|
||||
|
||||
export const viewportFocusHorizontalCenterSelector = createSelector(
|
||||
[
|
||||
viewportFocusWidthSelector,
|
||||
],
|
||||
width => (width / 2) + CANVAS_MARGINS.left
|
||||
);
|
||||
|
||||
export const viewportFocusVerticalCenterSelector = createSelector(
|
||||
[
|
||||
viewportHeightSelector,
|
||||
],
|
||||
height => (height / 2) + CANVAS_MARGINS.top
|
||||
);
|
||||
|
||||
// The narrower dimension of the viewport, used for the circular layout.
|
||||
export const viewportCircularExpanseSelector = createSelector(
|
||||
[
|
||||
viewportFocusWidthSelector,
|
||||
viewportHeightSelector,
|
||||
],
|
||||
(width, height) => Math.min(width, height)
|
||||
);
|
||||
63
client/app/scripts/selectors/canvas.js
Normal file
63
client/app/scripts/selectors/canvas.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import {
|
||||
CANVAS_MARGINS,
|
||||
DETAILS_PANEL_WIDTH,
|
||||
DETAILS_PANEL_MARGINS
|
||||
} from '../constants/styles';
|
||||
|
||||
|
||||
export const canvasMarginsSelector = createSelector(
|
||||
[
|
||||
state => state.get('topologyViewMode'),
|
||||
],
|
||||
viewMode => CANVAS_MARGINS[viewMode] || { top: 0, left: 0, right: 0, bottom: 0 }
|
||||
);
|
||||
|
||||
export const canvasWidthSelector = createSelector(
|
||||
[
|
||||
state => state.getIn(['viewport', 'width']),
|
||||
canvasMarginsSelector,
|
||||
],
|
||||
(width, margins) => width - margins.left - margins.right
|
||||
);
|
||||
|
||||
export const canvasHeightSelector = createSelector(
|
||||
[
|
||||
state => state.getIn(['viewport', 'height']),
|
||||
canvasMarginsSelector,
|
||||
],
|
||||
(height, margins) => height - margins.top - margins.bottom
|
||||
);
|
||||
|
||||
const canvasWithDetailsWidthSelector = createSelector(
|
||||
[
|
||||
canvasWidthSelector,
|
||||
],
|
||||
width => width - DETAILS_PANEL_WIDTH - DETAILS_PANEL_MARGINS.right
|
||||
);
|
||||
|
||||
export const canvasDetailsHorizontalCenterSelector = createSelector(
|
||||
[
|
||||
canvasWithDetailsWidthSelector,
|
||||
canvasMarginsSelector,
|
||||
],
|
||||
(width, margins) => (width / 2) + margins.left
|
||||
);
|
||||
|
||||
export const canvasDetailsVerticalCenterSelector = createSelector(
|
||||
[
|
||||
canvasHeightSelector,
|
||||
canvasMarginsSelector,
|
||||
],
|
||||
(height, margins) => (height / 2) + margins.top
|
||||
);
|
||||
|
||||
// The narrower dimension of the viewport, used for the circular layout.
|
||||
export const canvasCircularExpanseSelector = createSelector(
|
||||
[
|
||||
canvasWithDetailsWidthSelector,
|
||||
canvasHeightSelector,
|
||||
],
|
||||
(width, height) => Math.min(width, height)
|
||||
);
|
||||
@@ -2,12 +2,12 @@ import debug from 'debug';
|
||||
import { createSelector, createStructuredSelector } from 'reselect';
|
||||
import { Map as makeMap } from 'immutable';
|
||||
|
||||
import { initEdgesFromNodes } from '../utils/layouter-utils';
|
||||
import { viewportWidthSelector, viewportHeightSelector } from './canvas-viewport';
|
||||
import { activeTopologyOptionsSelector } from './topology';
|
||||
import { shownNodesSelector } from './node-filters';
|
||||
import { doLayout } from '../charts/nodes-layout';
|
||||
import timer from '../utils/timer-utils';
|
||||
import { initEdgesFromNodes } from '../../utils/layouter-utils';
|
||||
import { canvasWidthSelector, canvasHeightSelector } from '../canvas';
|
||||
import { activeTopologyOptionsSelector } from '../topology';
|
||||
import { shownNodesSelector } from '../node-filters';
|
||||
import { doLayout } from '../../charts/nodes-layout';
|
||||
import timer from '../../utils/timer-utils';
|
||||
|
||||
const log = debug('scope:nodes-chart');
|
||||
|
||||
@@ -16,8 +16,8 @@ const layoutOptionsSelector = createStructuredSelector({
|
||||
forceRelayout: state => state.get('forceRelayout'),
|
||||
topologyId: state => state.get('currentTopologyId'),
|
||||
topologyOptions: activeTopologyOptionsSelector,
|
||||
height: viewportHeightSelector,
|
||||
width: viewportWidthSelector,
|
||||
height: canvasHeightSelector,
|
||||
width: canvasWidthSelector,
|
||||
});
|
||||
|
||||
const graphLayoutSelector = createSelector(
|
||||
@@ -3,14 +3,14 @@ import { createSelector } from 'reselect';
|
||||
import { scaleThreshold } from 'd3-scale';
|
||||
import { fromJS, Set as makeSet, List as makeList } from 'immutable';
|
||||
|
||||
import { NODE_BASE_SIZE } from '../constants/styles';
|
||||
import { graphNodesSelector, graphEdgesSelector } from './nodes-chart-graph';
|
||||
import { activeLayoutZoomSelector } from './nodes-chart-zoom';
|
||||
import { NODE_BASE_SIZE } from '../../constants/styles';
|
||||
import { graphNodesSelector, graphEdgesSelector } from './graph';
|
||||
import { graphZoomStateSelector } from './zoom';
|
||||
import {
|
||||
viewportCircularExpanseSelector,
|
||||
viewportFocusHorizontalCenterSelector,
|
||||
viewportFocusVerticalCenterSelector,
|
||||
} from './canvas-viewport';
|
||||
canvasCircularExpanseSelector,
|
||||
canvasDetailsHorizontalCenterSelector,
|
||||
canvasDetailsVerticalCenterSelector,
|
||||
} from '../canvas';
|
||||
|
||||
|
||||
const circularOffsetAngle = Math.PI / 4;
|
||||
@@ -23,15 +23,15 @@ const radiusDensity = scaleThreshold()
|
||||
|
||||
const translationToViewportCenterSelector = createSelector(
|
||||
[
|
||||
viewportFocusHorizontalCenterSelector,
|
||||
viewportFocusVerticalCenterSelector,
|
||||
activeLayoutZoomSelector,
|
||||
canvasDetailsHorizontalCenterSelector,
|
||||
canvasDetailsVerticalCenterSelector,
|
||||
graphZoomStateSelector,
|
||||
],
|
||||
(centerX, centerY, zoomState) => {
|
||||
const { zoomScale, panTranslateX, panTranslateY } = zoomState.toJS();
|
||||
const { scaleX, scaleY, translateX, translateY } = zoomState.toJS();
|
||||
return {
|
||||
x: (-panTranslateX + centerX) / zoomScale,
|
||||
y: (-panTranslateY + centerY) / zoomScale,
|
||||
x: (-translateX + centerX) / scaleX,
|
||||
y: (-translateY + centerY) / scaleY,
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -75,9 +75,9 @@ const focusedNodesIdsSelector = createSelector(
|
||||
|
||||
const circularLayoutScalarsSelector = createSelector(
|
||||
[
|
||||
state => activeLayoutZoomSelector(state).get('zoomScale'),
|
||||
state => graphZoomStateSelector(state).get('scaleX'),
|
||||
state => focusedNodesIdsSelector(state).length - 1,
|
||||
viewportCircularExpanseSelector,
|
||||
canvasCircularExpanseSelector,
|
||||
],
|
||||
(scale, circularNodesCount, viewportExpanse) => {
|
||||
// Here we calculate the zoom factor of the nodes that get selected into focus.
|
||||
90
client/app/scripts/selectors/graph-view/zoom.js
Normal file
90
client/app/scripts/selectors/graph-view/zoom.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import { createSelector } from 'reselect';
|
||||
import { Map as makeMap } from 'immutable';
|
||||
|
||||
import { NODE_BASE_SIZE } from '../../constants/styles';
|
||||
import { canvasMarginsSelector, canvasWidthSelector, canvasHeightSelector } from '../canvas';
|
||||
import { activeLayoutCachedZoomSelector } from '../zooming';
|
||||
import { graphNodesSelector } from './graph';
|
||||
|
||||
|
||||
const graphBoundingRectangleSelector = createSelector(
|
||||
[
|
||||
graphNodesSelector,
|
||||
],
|
||||
(graphNodes) => {
|
||||
if (graphNodes.size === 0) return null;
|
||||
|
||||
const xMin = graphNodes.map(n => n.get('x') - NODE_BASE_SIZE).min();
|
||||
const yMin = graphNodes.map(n => n.get('y') - NODE_BASE_SIZE).min();
|
||||
const xMax = graphNodes.map(n => n.get('x') + NODE_BASE_SIZE).max();
|
||||
const yMax = graphNodes.map(n => n.get('y') + NODE_BASE_SIZE).max();
|
||||
|
||||
return makeMap({ xMin, yMin, xMax, yMax });
|
||||
}
|
||||
);
|
||||
|
||||
// Max scale limit will always be such that a node covers 1/5 of the viewport.
|
||||
const maxScaleSelector = createSelector(
|
||||
[
|
||||
canvasWidthSelector,
|
||||
canvasHeightSelector,
|
||||
],
|
||||
(width, height) => Math.min(width, height) / NODE_BASE_SIZE / 5
|
||||
);
|
||||
|
||||
// Compute the default zoom settings for the given graph.
|
||||
export const graphDefaultZoomSelector = createSelector(
|
||||
[
|
||||
graphBoundingRectangleSelector,
|
||||
canvasMarginsSelector,
|
||||
canvasWidthSelector,
|
||||
canvasHeightSelector,
|
||||
maxScaleSelector,
|
||||
],
|
||||
(boundingRectangle, canvasMargins, width, height, maxScale) => {
|
||||
if (!boundingRectangle) return makeMap();
|
||||
|
||||
const { xMin, xMax, yMin, yMax } = boundingRectangle.toJS();
|
||||
const xFactor = width / (xMax - xMin);
|
||||
const yFactor = height / (yMax - yMin);
|
||||
|
||||
// Initial zoom is such that the graph covers 90% of either the viewport,
|
||||
// or one half of maximal zoom constraint, whichever is smaller.
|
||||
const scale = Math.min(xFactor, yFactor, maxScale / 2) * 0.9;
|
||||
|
||||
// This translation puts the graph in the center of the viewport, respecting the margins.
|
||||
const translateX = ((width - ((xMax + xMin) * scale)) / 2) + canvasMargins.left;
|
||||
const translateY = ((height - ((yMax + yMin) * scale)) / 2) + canvasMargins.top;
|
||||
|
||||
return makeMap({
|
||||
translateX,
|
||||
translateY,
|
||||
scaleX: scale,
|
||||
scaleY: scale,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const graphZoomLimitsSelector = createSelector(
|
||||
[
|
||||
graphDefaultZoomSelector,
|
||||
maxScaleSelector,
|
||||
],
|
||||
(defaultZoom, maxScale) => {
|
||||
if (defaultZoom.isEmpty()) return makeMap();
|
||||
|
||||
// We always allow zooming out exactly 5x compared to the initial zoom.
|
||||
const minScale = defaultZoom.get('scaleX') / 5;
|
||||
|
||||
return makeMap({ minScale, maxScale });
|
||||
}
|
||||
);
|
||||
|
||||
export const graphZoomStateSelector = createSelector(
|
||||
[
|
||||
graphDefaultZoomSelector,
|
||||
activeLayoutCachedZoomSelector,
|
||||
],
|
||||
// All the cached fields override the calculated default ones.
|
||||
(graphDefaultZoom, cachedZoomState) => graphDefaultZoom.merge(cachedZoomState)
|
||||
);
|
||||
@@ -1,7 +1,54 @@
|
||||
import { createSelector } from 'reselect';
|
||||
import { createMapSelector } from 'reselect-map';
|
||||
import { fromJS } from 'immutable';
|
||||
import { fromJS, Map as makeMap, List as makeList } from 'immutable';
|
||||
|
||||
import { isGraphViewModeSelector, isResourceViewModeSelector } from '../selectors/topology';
|
||||
import { RESOURCE_VIEW_METRICS } from '../constants/resources';
|
||||
|
||||
|
||||
// Resource view uses the metrics of the nodes from the cache, while the graph and table
|
||||
// view are looking at the current nodes (which are among other things filtered by topology
|
||||
// options which are currently ignored in the resource view).
|
||||
export const availableMetricsSelector = createSelector(
|
||||
[
|
||||
isGraphViewModeSelector,
|
||||
isResourceViewModeSelector,
|
||||
state => state.get('nodes'),
|
||||
],
|
||||
(isGraphView, isResourceView, nodes) => {
|
||||
// In graph view, we always look through the fresh state
|
||||
// of topology nodes to get all the available metrics.
|
||||
if (isGraphView) {
|
||||
return nodes
|
||||
.valueSeq()
|
||||
.flatMap(n => n.get('metrics', makeList()))
|
||||
.map(m => makeMap({ id: m.get('id'), label: m.get('label') }))
|
||||
.toSet()
|
||||
.toList()
|
||||
.sortBy(m => m.get('label'));
|
||||
}
|
||||
|
||||
// In resource view, we're displaying only the hardcoded CPU and Memory metrics.
|
||||
// TODO: Make this dynamic as well.
|
||||
if (isResourceView) {
|
||||
return fromJS(RESOURCE_VIEW_METRICS);
|
||||
}
|
||||
|
||||
// Don't show any metrics in the table view mode.
|
||||
return makeList();
|
||||
}
|
||||
);
|
||||
|
||||
export const pinnedMetricSelector = createSelector(
|
||||
[
|
||||
availableMetricsSelector,
|
||||
state => state.get('pinnedMetricType'),
|
||||
],
|
||||
(availableMetrics, pinnedMetricType) => {
|
||||
const metric = availableMetrics.find(m => m.get('label') === pinnedMetricType);
|
||||
return metric && metric.get('id');
|
||||
}
|
||||
);
|
||||
|
||||
const topCardNodeSelector = createSelector(
|
||||
[
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
import { createSelector } from 'reselect';
|
||||
import { createMapSelector } from 'reselect-map';
|
||||
import { fromJS, Map as makeMap, List as makeList } from 'immutable';
|
||||
import { fromJS, List as makeList, Map as makeMap } from 'immutable';
|
||||
|
||||
|
||||
const extractNodeNetworksValue = (node) => {
|
||||
if (node.has('metadata')) {
|
||||
const networks = node.get('metadata')
|
||||
.find(field => field.get('id') === 'docker_container_networks');
|
||||
return networks && networks.get('value');
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const NETWORKS_ID = 'docker_container_networks';
|
||||
|
||||
// TODO: Move this setting of networks as toplevel node field to backend,
|
||||
// to not rely on field IDs here. should be determined by topology implementer.
|
||||
export const nodeNetworksSelector = createMapSelector(
|
||||
[
|
||||
state => state.get('nodes').map(extractNodeNetworksValue),
|
||||
state => state.get('nodes'),
|
||||
],
|
||||
(networksValue) => {
|
||||
if (!networksValue) {
|
||||
return makeList();
|
||||
}
|
||||
return fromJS(networksValue.split(', ').map(network => ({
|
||||
(node) => {
|
||||
const metadata = node.get('metadata', makeList());
|
||||
const networks = metadata.find(f => f.get('id') === NETWORKS_ID) || makeMap();
|
||||
const networkValues = networks.has('value') ? networks.get('value').split(', ') : [];
|
||||
|
||||
return fromJS(networkValues.map(network => ({
|
||||
id: network, label: network, colorKey: network
|
||||
})));
|
||||
}
|
||||
@@ -36,12 +30,19 @@ export const availableNetworksSelector = createSelector(
|
||||
.sortBy(m => m.get('label'))
|
||||
);
|
||||
|
||||
// NOTE: Don't use this selector directly in mapStateToProps
|
||||
// as it would get called too many times.
|
||||
export const selectedNetworkNodesIdsSelector = createSelector(
|
||||
[
|
||||
state => state.get('networkNodes'),
|
||||
nodeNetworksSelector,
|
||||
state => state.get('selectedNetwork'),
|
||||
],
|
||||
(networkNodes, selectedNetwork) => networkNodes.get(selectedNetwork, makeMap())
|
||||
(nodeNetworks, selectedNetworkId) => {
|
||||
const nodeIds = [];
|
||||
nodeNetworks.forEach((networks, nodeId) => {
|
||||
const networksIds = networks.map(n => n.get('id'));
|
||||
if (networksIds.contains(selectedNetworkId)) {
|
||||
nodeIds.push(nodeId);
|
||||
}
|
||||
});
|
||||
return fromJS(nodeIds);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { createSelector } from 'reselect';
|
||||
import { Map as makeMap } from 'immutable';
|
||||
|
||||
import { CANVAS_MARGINS, NODE_BASE_SIZE } from '../constants/styles';
|
||||
import { activeTopologyZoomCacheKeyPathSelector } from './topology';
|
||||
import { viewportWidthSelector, viewportHeightSelector } from './canvas-viewport';
|
||||
import { graphNodesSelector } from './nodes-chart-graph';
|
||||
|
||||
|
||||
// Compute the default zoom settings for the given graph layout.
|
||||
const defaultZoomSelector = createSelector(
|
||||
[
|
||||
graphNodesSelector,
|
||||
viewportWidthSelector,
|
||||
viewportHeightSelector,
|
||||
],
|
||||
(graphNodes, width, height) => {
|
||||
if (graphNodes.size === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const xMin = graphNodes.minBy(n => n.get('x')).get('x');
|
||||
const xMax = graphNodes.maxBy(n => n.get('x')).get('x');
|
||||
const yMin = graphNodes.minBy(n => n.get('y')).get('y');
|
||||
const yMax = graphNodes.maxBy(n => n.get('y')).get('y');
|
||||
|
||||
const xFactor = width / (xMax - xMin);
|
||||
const yFactor = height / (yMax - yMin);
|
||||
|
||||
// Maximal allowed zoom will always be such that a node covers 1/5 of the viewport.
|
||||
const maxZoomScale = Math.min(width, height) / NODE_BASE_SIZE / 5;
|
||||
|
||||
// Initial zoom is such that the graph covers 90% of either the viewport,
|
||||
// or one half of maximal zoom constraint, whichever is smaller.
|
||||
const zoomScale = Math.min(xFactor, yFactor, maxZoomScale / 2) * 0.9;
|
||||
|
||||
// Finally, we always allow zooming out exactly 5x compared to the initial zoom.
|
||||
const minZoomScale = zoomScale / 5;
|
||||
|
||||
// This translation puts the graph in the center of the viewport, respecting the margins.
|
||||
const panTranslateX = ((width - ((xMax + xMin) * zoomScale)) / 2) + CANVAS_MARGINS.left;
|
||||
const panTranslateY = ((height - ((yMax + yMin) * zoomScale)) / 2) + CANVAS_MARGINS.top;
|
||||
|
||||
return { zoomScale, minZoomScale, maxZoomScale, panTranslateX, panTranslateY };
|
||||
}
|
||||
);
|
||||
|
||||
const activeLayoutCachedZoomSelector = createSelector(
|
||||
[
|
||||
state => state.get('zoomCache'),
|
||||
activeTopologyZoomCacheKeyPathSelector,
|
||||
],
|
||||
(zoomCache, keyPath) => zoomCache.getIn(keyPath.slice(1))
|
||||
);
|
||||
|
||||
// Use the cache to get the last zoom state for the selected topology,
|
||||
// otherwise use the default zoom options computed from the graph layout.
|
||||
export const activeLayoutZoomSelector = createSelector(
|
||||
[
|
||||
activeLayoutCachedZoomSelector,
|
||||
defaultZoomSelector,
|
||||
],
|
||||
(cachedZoomState, defaultZoomState) => makeMap(cachedZoomState || defaultZoomState)
|
||||
);
|
||||
177
client/app/scripts/selectors/resource-view/layout.js
Normal file
177
client/app/scripts/selectors/resource-view/layout.js
Normal file
@@ -0,0 +1,177 @@
|
||||
import debug from 'debug';
|
||||
import { times } from 'lodash';
|
||||
import { fromJS, Map as makeMap } from 'immutable';
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import { RESOURCES_LAYER_PADDING, RESOURCES_LAYER_HEIGHT } from '../../constants/styles';
|
||||
import {
|
||||
RESOURCE_VIEW_MAX_LAYERS,
|
||||
RESOURCE_VIEW_LAYERS,
|
||||
TOPOLOGIES_WITH_CAPACITY,
|
||||
} from '../../constants/resources';
|
||||
import {
|
||||
nodeParentDecoratorByTopologyId,
|
||||
nodeMetricSummaryDecoratorByType,
|
||||
nodeResourceViewColorDecorator,
|
||||
nodeResourceBoxDecorator,
|
||||
} from '../../decorators/node';
|
||||
|
||||
|
||||
const log = debug('scope:nodes-layout');
|
||||
|
||||
// Used for ordering the resource nodes.
|
||||
const resourceNodeConsumptionComparator = (node) => {
|
||||
const metricSummary = node.get('metricSummary');
|
||||
return metricSummary.get('showCapacity') ?
|
||||
-metricSummary.get('relativeConsumption') :
|
||||
-metricSummary.get('absoluteConsumption');
|
||||
};
|
||||
|
||||
// A list of topologies shown in the resource view of the active topology (bottom to top).
|
||||
export const layersTopologyIdsSelector = createSelector(
|
||||
[
|
||||
state => state.get('currentTopologyId'),
|
||||
],
|
||||
topologyId => fromJS(RESOURCE_VIEW_LAYERS[topologyId] || [])
|
||||
);
|
||||
|
||||
// Calculates the resource view layer Y-coordinate for every topology in the resource view.
|
||||
export const layerVerticalPositionByTopologyIdSelector = createSelector(
|
||||
[
|
||||
layersTopologyIdsSelector,
|
||||
],
|
||||
(topologiesIds) => {
|
||||
let yPositions = makeMap();
|
||||
let currentY = RESOURCES_LAYER_PADDING;
|
||||
|
||||
topologiesIds.forEach((topologyId) => {
|
||||
currentY -= RESOURCES_LAYER_HEIGHT + RESOURCES_LAYER_PADDING;
|
||||
yPositions = yPositions.set(topologyId, currentY);
|
||||
});
|
||||
|
||||
return yPositions;
|
||||
}
|
||||
);
|
||||
|
||||
// Decorate and filter all the nodes to be displayed in the current resource view, except
|
||||
// for the exact node horizontal offsets which are calculated from the data created here.
|
||||
const decoratedNodesByTopologySelector = createSelector(
|
||||
[
|
||||
layersTopologyIdsSelector,
|
||||
state => state.get('pinnedMetricType'),
|
||||
// Generate the dependencies for this selector programmatically (because we want their
|
||||
// number to be customizable directly by changing the constant). The dependency functions
|
||||
// here depend on another selector, but this seems to work quite fine. For example, if
|
||||
// layersTopologyIdsSelector = ['hosts', 'containers'] and RESOURCE_VIEW_MAX_LAYERS = 3,
|
||||
// this code will generate:
|
||||
// [
|
||||
// state => state.getIn(['nodesByTopology', 'hosts'])
|
||||
// state => state.getIn(['nodesByTopology', 'containers'])
|
||||
// state => state.getIn(['nodesByTopology', undefined])
|
||||
// ]
|
||||
// which will all be captured by `topologiesNodes` and processed correctly (even for undefined).
|
||||
...times(RESOURCE_VIEW_MAX_LAYERS, index => (
|
||||
state => state.getIn(['nodesByTopology', layersTopologyIdsSelector(state).get(index)])
|
||||
))
|
||||
],
|
||||
(layersTopologyIds, pinnedMetricType, ...topologiesNodes) => {
|
||||
let nodesByTopology = makeMap();
|
||||
let parentLayerTopologyId = null;
|
||||
|
||||
topologiesNodes.forEach((topologyNodes, index) => {
|
||||
const layerTopologyId = layersTopologyIds.get(index);
|
||||
const parentTopologyNodes = nodesByTopology.get(parentLayerTopologyId, makeMap());
|
||||
const showCapacity = TOPOLOGIES_WITH_CAPACITY.includes(layerTopologyId);
|
||||
const isBaseLayer = (index === 0);
|
||||
|
||||
const nodeParentDecorator = nodeParentDecoratorByTopologyId(parentLayerTopologyId);
|
||||
const nodeMetricSummaryDecorator = nodeMetricSummaryDecoratorByType(
|
||||
pinnedMetricType, showCapacity);
|
||||
|
||||
// Color the node, deduce its anchor point, dimensions and info about its pinned metric.
|
||||
const decoratedTopologyNodes = (topologyNodes || makeMap())
|
||||
.map(nodeResourceViewColorDecorator)
|
||||
.map(nodeMetricSummaryDecorator)
|
||||
.map(nodeResourceBoxDecorator)
|
||||
.map(nodeParentDecorator);
|
||||
|
||||
const filteredTopologyNodes = decoratedTopologyNodes
|
||||
// Filter out the nodes with no parent in the topology of the previous layer, as their
|
||||
// positions in the layout could not be determined. The exception is the base layer.
|
||||
// TODO: Also make an exception for uncontained nodes (e.g. processes).
|
||||
.filter(node => parentTopologyNodes.has(node.get('parentNodeId')) || isBaseLayer)
|
||||
// Filter out the nodes with no metric summary data, which is needed to render the node.
|
||||
.filter(node => node.get('metricSummary'));
|
||||
|
||||
nodesByTopology = nodesByTopology.set(layerTopologyId, filteredTopologyNodes);
|
||||
parentLayerTopologyId = layerTopologyId;
|
||||
});
|
||||
|
||||
return nodesByTopology;
|
||||
}
|
||||
);
|
||||
|
||||
// Calculate (and fix) the offsets for all the displayed resource nodes.
|
||||
export const layoutNodesByTopologyIdSelector = createSelector(
|
||||
[
|
||||
layersTopologyIdsSelector,
|
||||
decoratedNodesByTopologySelector,
|
||||
],
|
||||
(layersTopologyIds, nodesByTopology) => {
|
||||
let layoutNodes = makeMap();
|
||||
let parentTopologyId = null;
|
||||
|
||||
// Calculate the offsets bottom-to top as each layer needs to know exact offsets of its parents.
|
||||
layersTopologyIds.forEach((layerTopologyId) => {
|
||||
let positionedNodes = makeMap();
|
||||
|
||||
// Get the nodes in the current layer grouped by their parent nodes.
|
||||
// Each of those buckets will be positioned and sorted independently.
|
||||
const nodesByParent = nodesByTopology
|
||||
.get(layerTopologyId, makeMap())
|
||||
.groupBy(n => n.get('parentNodeId'));
|
||||
|
||||
nodesByParent.forEach((nodesBucket, parentNodeId) => {
|
||||
// Set the initial offset to the offset of the parent (that has already been set).
|
||||
// If there is no offset information, i.e. we're processing the base layer, set it to 0.
|
||||
const parentNode = layoutNodes.getIn([parentTopologyId, parentNodeId], makeMap());
|
||||
let currentOffset = parentNode.get('offset', 0);
|
||||
|
||||
// Sort the nodes in the current bucket and lay them down one after another.
|
||||
nodesBucket.sortBy(resourceNodeConsumptionComparator).forEach((node, nodeId) => {
|
||||
const positionedNode = node.set('offset', currentOffset);
|
||||
positionedNodes = positionedNodes.set(nodeId, positionedNode);
|
||||
currentOffset += node.get('width');
|
||||
});
|
||||
|
||||
// TODO: This block of code checks for the overlaps which are caused by children
|
||||
// consuming more resources than their parent node. This happens due to inconsistent
|
||||
// data being sent from the backend and it needs to be fixed there.
|
||||
const parentOffset = parentNode.get('offset', 0);
|
||||
const parentWidth = parentNode.get('width', currentOffset);
|
||||
const totalChildrenWidth = currentOffset - parentOffset;
|
||||
// If the total width of the children exceeds the parent node box width, we have a problem.
|
||||
// We fix it by shrinking all the children to by a factor to perfectly fit into the parent.
|
||||
if (totalChildrenWidth > parentWidth) {
|
||||
const shrinkFactor = parentWidth / totalChildrenWidth;
|
||||
log(`Inconsistent data: Children of ${parentNodeId} reported to use more ` +
|
||||
`resource than the node itself - shrinking by factor ${shrinkFactor}`);
|
||||
// Shrink all the children.
|
||||
nodesBucket.forEach((_, nodeId) => {
|
||||
const node = positionedNodes.get(nodeId);
|
||||
positionedNodes = positionedNodes.mergeIn([nodeId], makeMap({
|
||||
offset: ((node.get('offset') - parentOffset) * shrinkFactor) + parentOffset,
|
||||
width: node.get('width') * shrinkFactor,
|
||||
}));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Update the layout with the positioned node from the current layer.
|
||||
layoutNodes = layoutNodes.mergeIn([layerTopologyId], positionedNodes);
|
||||
parentTopologyId = layerTopologyId;
|
||||
});
|
||||
|
||||
return layoutNodes;
|
||||
}
|
||||
);
|
||||
101
client/app/scripts/selectors/resource-view/zoom.js
Normal file
101
client/app/scripts/selectors/resource-view/zoom.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import { createSelector } from 'reselect';
|
||||
import { Map as makeMap } from 'immutable';
|
||||
|
||||
import { RESOURCES_LAYER_HEIGHT } from '../../constants/styles';
|
||||
import { canvasMarginsSelector, canvasWidthSelector, canvasHeightSelector } from '../canvas';
|
||||
import { activeLayoutCachedZoomSelector } from '../zooming';
|
||||
import {
|
||||
layerVerticalPositionByTopologyIdSelector,
|
||||
layoutNodesByTopologyIdSelector,
|
||||
} from './layout';
|
||||
|
||||
|
||||
// This is used to determine the maximal zoom factor.
|
||||
const minNodeWidthSelector = createSelector(
|
||||
[
|
||||
layoutNodesByTopologyIdSelector,
|
||||
],
|
||||
layoutNodes => layoutNodes.flatten(true).map(n => n.get('width')).min()
|
||||
);
|
||||
|
||||
const resourceNodesBoundingRectangleSelector = createSelector(
|
||||
[
|
||||
layerVerticalPositionByTopologyIdSelector,
|
||||
layoutNodesByTopologyIdSelector,
|
||||
],
|
||||
(verticalPositions, layoutNodes) => {
|
||||
if (layoutNodes.size === 0) return null;
|
||||
|
||||
const flattenedNodes = layoutNodes.flatten(true);
|
||||
const xMin = flattenedNodes.map(n => n.get('offset')).min();
|
||||
const yMin = verticalPositions.toList().min();
|
||||
const xMax = flattenedNodes.map(n => n.get('offset') + n.get('width')).max();
|
||||
const yMax = verticalPositions.toList().max() + RESOURCES_LAYER_HEIGHT;
|
||||
|
||||
return makeMap({ xMin, xMax, yMin, yMax });
|
||||
}
|
||||
);
|
||||
|
||||
// Compute the default zoom settings for given resources.
|
||||
export const resourcesDefaultZoomSelector = createSelector(
|
||||
[
|
||||
resourceNodesBoundingRectangleSelector,
|
||||
canvasMarginsSelector,
|
||||
canvasWidthSelector,
|
||||
canvasHeightSelector,
|
||||
],
|
||||
(boundingRectangle, canvasMargins, width, height) => {
|
||||
if (!boundingRectangle) return makeMap();
|
||||
|
||||
const { xMin, xMax, yMin, yMax } = boundingRectangle.toJS();
|
||||
|
||||
// The default scale takes all the available horizontal space and 70% of the vertical space.
|
||||
const scaleX = (width / (xMax - xMin)) * 1.0;
|
||||
const scaleY = (height / (yMax - yMin)) * 0.7;
|
||||
|
||||
// This translation puts the graph in the center of the viewport, respecting the margins.
|
||||
const translateX = ((width - ((xMax + xMin) * scaleX)) / 2) + canvasMargins.left;
|
||||
const translateY = ((height - ((yMax + yMin) * scaleY)) / 2) + canvasMargins.top;
|
||||
|
||||
return makeMap({
|
||||
translateX,
|
||||
translateY,
|
||||
scaleX,
|
||||
scaleY,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const resourcesZoomLimitsSelector = createSelector(
|
||||
[
|
||||
resourcesDefaultZoomSelector,
|
||||
resourceNodesBoundingRectangleSelector,
|
||||
minNodeWidthSelector,
|
||||
canvasWidthSelector,
|
||||
],
|
||||
(defaultZoom, boundingRectangle, minNodeWidth, width) => {
|
||||
if (defaultZoom.isEmpty()) return makeMap();
|
||||
|
||||
const { xMin, xMax, yMin, yMax } = boundingRectangle.toJS();
|
||||
|
||||
return makeMap({
|
||||
// Maximal zoom is such that the smallest box takes the whole canvas.
|
||||
maxScale: width / minNodeWidth,
|
||||
// Minimal zoom is equivalent to the initial one, where the whole layout matches the canvas.
|
||||
minScale: defaultZoom.get('scaleX'),
|
||||
minTranslateX: xMin,
|
||||
maxTranslateX: xMax,
|
||||
minTranslateY: yMin,
|
||||
maxTranslateY: yMax,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const resourcesZoomStateSelector = createSelector(
|
||||
[
|
||||
resourcesDefaultZoomSelector,
|
||||
activeLayoutCachedZoomSelector,
|
||||
],
|
||||
// All the cached fields override the calculated default ones.
|
||||
(resourcesDefaultZoom, cachedZoomState) => resourcesDefaultZoom.merge(cachedZoomState)
|
||||
);
|
||||
@@ -1,7 +1,35 @@
|
||||
import { createSelector } from 'reselect';
|
||||
|
||||
import {
|
||||
RESOURCE_VIEW_MODE,
|
||||
GRAPH_VIEW_MODE,
|
||||
TABLE_VIEW_MODE,
|
||||
} from '../constants/naming';
|
||||
|
||||
|
||||
// TODO: Consider moving more stuff from 'topology-utils' here.
|
||||
|
||||
export const isGraphViewModeSelector = createSelector(
|
||||
[
|
||||
state => state.get('topologyViewMode'),
|
||||
],
|
||||
viewMode => viewMode === GRAPH_VIEW_MODE
|
||||
);
|
||||
|
||||
export const isTableViewModeSelector = createSelector(
|
||||
[
|
||||
state => state.get('topologyViewMode'),
|
||||
],
|
||||
viewMode => viewMode === TABLE_VIEW_MODE
|
||||
);
|
||||
|
||||
export const isResourceViewModeSelector = createSelector(
|
||||
[
|
||||
state => state.get('topologyViewMode'),
|
||||
],
|
||||
viewMode => viewMode === RESOURCE_VIEW_MODE
|
||||
);
|
||||
|
||||
// Checks if graph complexity is high. Used to trigger
|
||||
// table view on page load and decide on animations.
|
||||
export const graphExceedsComplexityThreshSelector = createSelector(
|
||||
@@ -23,11 +51,3 @@ export const activeTopologyOptionsSelector = createSelector(
|
||||
topologyOptions.get(parentTopologyId || currentTopologyId)
|
||||
)
|
||||
);
|
||||
|
||||
export const activeTopologyZoomCacheKeyPathSelector = createSelector(
|
||||
[
|
||||
state => state.get('currentTopologyId'),
|
||||
activeTopologyOptionsSelector,
|
||||
],
|
||||
(topologyId, topologyOptions) => ['zoomCache', topologyId, JSON.stringify(topologyOptions)]
|
||||
);
|
||||
|
||||
33
client/app/scripts/selectors/zooming.js
Normal file
33
client/app/scripts/selectors/zooming.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createSelector } from 'reselect';
|
||||
import { Map as makeMap } from 'immutable';
|
||||
|
||||
import { isGraphViewModeSelector, activeTopologyOptionsSelector } from './topology';
|
||||
|
||||
|
||||
export const activeTopologyZoomCacheKeyPathSelector = createSelector(
|
||||
[
|
||||
isGraphViewModeSelector,
|
||||
state => state.get('topologyViewMode'),
|
||||
state => state.get('currentTopologyId'),
|
||||
state => state.get('pinnedMetricType'),
|
||||
state => JSON.stringify(activeTopologyOptionsSelector(state)),
|
||||
],
|
||||
(isGraphViewMode, viewMode, topologyId, pinnedMetricType, topologyOptions) => (
|
||||
isGraphViewMode ?
|
||||
// In graph view, selecting different options/filters produces a different layout.
|
||||
['zoomCache', viewMode, topologyId, topologyOptions] :
|
||||
// Otherwise we're in the resource view where the options are hidden (for now),
|
||||
// but pinning different metrics can result in very different layouts.
|
||||
// TODO: Take `topologyId` into account once the resource
|
||||
// view layouts start differing between the topologies.
|
||||
['zoomCache', viewMode, pinnedMetricType]
|
||||
)
|
||||
);
|
||||
|
||||
export const activeLayoutCachedZoomSelector = createSelector(
|
||||
[
|
||||
state => state.get('zoomCache'),
|
||||
activeTopologyZoomCacheKeyPathSelector,
|
||||
],
|
||||
(zoomCache, keyPath) => zoomCache.getIn(keyPath.slice(1), makeMap())
|
||||
);
|
||||
@@ -93,8 +93,12 @@ function download(source, name) {
|
||||
}, 10);
|
||||
}
|
||||
|
||||
function getSVGElement() {
|
||||
return document.getElementById('canvas');
|
||||
}
|
||||
|
||||
function getSVG(doc, emptySvgDeclarationComputed) {
|
||||
const svg = document.getElementById('nodes-chart-canvas');
|
||||
const svg = getSVGElement();
|
||||
const target = svg.cloneNode(true);
|
||||
|
||||
target.setAttribute('version', '1.1');
|
||||
@@ -127,7 +131,7 @@ function cleanup() {
|
||||
});
|
||||
|
||||
// hide embedded logo
|
||||
const svg = document.getElementById('nodes-chart-canvas');
|
||||
const svg = getSVGElement();
|
||||
svg.setAttribute('class', '');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { fromJS } from 'immutable';
|
||||
|
||||
import { nodeNetworksSelector } from '../selectors/node-networks';
|
||||
|
||||
export function getNetworkNodes(state) {
|
||||
const networksMap = {};
|
||||
nodeNetworksSelector(state).forEach((networks, nodeId) => {
|
||||
networks.forEach((network) => {
|
||||
const networkId = network.get('id');
|
||||
networksMap[networkId] = networksMap[networkId] || [];
|
||||
networksMap[networkId].push(nodeId);
|
||||
});
|
||||
});
|
||||
return fromJS(networksMap);
|
||||
}
|
||||
@@ -48,8 +48,8 @@ export function getUrlState(state) {
|
||||
|
||||
const urlState = {
|
||||
controlPipe: cp ? cp.toJS() : null,
|
||||
topologyViewMode: state.get('gridMode') ? 'grid' : 'topo',
|
||||
nodeDetails: nodeDetails.toJS(),
|
||||
topologyViewMode: state.get('topologyViewMode'),
|
||||
pinnedMetricType: state.get('pinnedMetricType'),
|
||||
pinnedSearches: state.get('pinnedSearches').toJS(),
|
||||
searchQuery: state.get('searchQuery'),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { endsWith } from 'lodash';
|
||||
import { Set as makeSet, List as makeList } from 'immutable';
|
||||
|
||||
import { isResourceViewModeSelector } from '../selectors/topology';
|
||||
import { pinnedMetricSelector } from '../selectors/node-metric';
|
||||
|
||||
//
|
||||
// top priority first
|
||||
@@ -132,8 +134,12 @@ export function getCurrentTopologyOptions(state) {
|
||||
}
|
||||
|
||||
export function isTopologyEmpty(state) {
|
||||
return state.getIn(['currentTopology', 'stats', 'node_count'], 0) === 0
|
||||
&& state.get('nodes').size === 0;
|
||||
// Consider a topology in the resource view empty if it has no pinned metric.
|
||||
const resourceViewEmpty = isResourceViewModeSelector(state) && !pinnedMetricSelector(state);
|
||||
// Otherwise (in graph and table view), we only look at the node count.
|
||||
const nodeCount = state.getIn(['currentTopology', 'stats', 'node_count'], 0);
|
||||
const nodesEmpty = nodeCount === 0 && state.get('nodes').size === 0;
|
||||
return resourceViewEmpty || nodesEmpty;
|
||||
}
|
||||
|
||||
|
||||
|
||||
16
client/app/scripts/utils/transform-utils.js
Normal file
16
client/app/scripts/utils/transform-utils.js
Normal file
@@ -0,0 +1,16 @@
|
||||
|
||||
const applyTranslateX = ({ scaleX = 1, translateX = 0 }, x) => (x * scaleX) + translateX;
|
||||
const applyTranslateY = ({ scaleY = 1, translateY = 0 }, y) => (y * scaleY) + translateY;
|
||||
const applyScaleX = ({ scaleX = 1 }, width) => width * scaleX;
|
||||
const applyScaleY = ({ scaleY = 1 }, height) => height * scaleY;
|
||||
|
||||
export const applyTransform = (transform, { width, height, x, y }) => ({
|
||||
x: applyTranslateX(transform, x),
|
||||
y: applyTranslateY(transform, y),
|
||||
width: applyScaleX(transform, width),
|
||||
height: applyScaleY(transform, height),
|
||||
});
|
||||
|
||||
export const transformToString = ({ translateX = 0, translateY = 0, scaleX = 1, scaleY = 1 }) => (
|
||||
`translate(${translateX},${translateY}) scale(${scaleX},${scaleY})`
|
||||
);
|
||||
@@ -2,6 +2,7 @@ import debug from 'debug';
|
||||
import reqwest from 'reqwest';
|
||||
import trimStart from 'lodash/trimStart';
|
||||
import defaults from 'lodash/defaults';
|
||||
import { Map as makeMap } from 'immutable';
|
||||
|
||||
import { blurSearch, clearControlError, closeWebsocket, openWebsocket, receiveError,
|
||||
receiveApiDetails, receiveNodesDelta, receiveNodeDetails, receiveControlError,
|
||||
@@ -9,6 +10,7 @@ import { blurSearch, clearControlError, closeWebsocket, openWebsocket, receiveEr
|
||||
receiveControlSuccess, receiveTopologies, receiveNotFound,
|
||||
receiveNodesForTopology } from '../actions/app-actions';
|
||||
|
||||
import { layersTopologyIdsSelector } from '../selectors/resource-view/layout';
|
||||
import { API_INTERVAL, TOPOLOGY_INTERVAL } from '../constants/timer';
|
||||
|
||||
const log = debug('scope:web-api-utils');
|
||||
@@ -157,13 +159,12 @@ function doRequest(opts) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets nodes for all topologies (for search)
|
||||
* Does a one-time fetch of all the nodes for a custom list of topologies.
|
||||
*/
|
||||
export function getAllNodes(getState, dispatch) {
|
||||
const state = getState();
|
||||
const topologyOptions = state.get('topologyOptions');
|
||||
function getNodesForTopologies(getState, dispatch, topologyIds, topologyOptions = makeMap()) {
|
||||
// fetch sequentially
|
||||
state.get('topologyUrlsById')
|
||||
getState().get('topologyUrlsById')
|
||||
.filter((_, topologyId) => topologyIds.contains(topologyId))
|
||||
.reduce((sequence, topologyUrl, topologyId) => sequence.then(() => {
|
||||
const optionsQuery = buildOptionsQuery(topologyOptions.get(topologyId));
|
||||
// Trim the leading slash from the url before requesting.
|
||||
@@ -175,6 +176,28 @@ export function getAllNodes(getState, dispatch) {
|
||||
Promise.resolve());
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets nodes for all topologies (for search).
|
||||
*/
|
||||
export function getAllNodes(getState, dispatch) {
|
||||
const state = getState();
|
||||
const topologyOptions = state.get('topologyOptions');
|
||||
const topologyIds = state.get('topologyUrlsById').keySeq();
|
||||
getNodesForTopologies(getState, dispatch, topologyIds, topologyOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time update of all the nodes of topologies that appear in the current resource view.
|
||||
*/
|
||||
export function getResourceViewNodesSnapshot(getState, dispatch) {
|
||||
const topologyIds = layersTopologyIdsSelector(getState());
|
||||
// TODO: Remove the timeout and replace it with normal polling once we figure how to make
|
||||
// resource view dynamic (from the UI point of view, the challenge is to make it stable).
|
||||
setTimeout(() => {
|
||||
getNodesForTopologies(getState, dispatch, topologyIds);
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
export function getTopologies(options, dispatch, initialPoll) {
|
||||
// Used to resume polling when navigating between pages in Weave Cloud.
|
||||
continuePolling = initialPoll === true ? true : continuePolling;
|
||||
@@ -204,6 +227,8 @@ export function getTopologies(options, dispatch, initialPoll) {
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: topologyUrl and options are always used for the current topology so they as arguments
|
||||
// can be replaced by the `state` and then retrieved here internally from selectors.
|
||||
export function getNodesDelta(topologyUrl, options, dispatch) {
|
||||
const optionsQuery = buildOptionsQuery(options);
|
||||
// Only recreate websocket if url changed or if forced (weave cloud instance reload);
|
||||
|
||||
@@ -52,7 +52,11 @@
|
||||
&-selected {
|
||||
opacity: $btn-opacity-selected;
|
||||
}
|
||||
&:hover {
|
||||
&[disabled] {
|
||||
cursor: default;
|
||||
opacity: $btn-opacity-disabled;
|
||||
}
|
||||
&:not([disabled]):hover {
|
||||
opacity: $btn-opacity-hover;
|
||||
}
|
||||
}
|
||||
@@ -245,7 +249,7 @@
|
||||
|
||||
}
|
||||
|
||||
.nodes-chart {
|
||||
.nodes-chart, .nodes-resources {
|
||||
|
||||
&-error, &-loading {
|
||||
@extend .hideable;
|
||||
@@ -303,11 +307,6 @@
|
||||
transition: opacity .2s $base-ease;
|
||||
text-align: center;
|
||||
|
||||
.node-network {
|
||||
// stroke: $background-lighter-color;
|
||||
// stroke-width: 4px;
|
||||
}
|
||||
|
||||
.node-label {
|
||||
color: $text-color;
|
||||
}
|
||||
@@ -937,6 +936,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
.node-resources {
|
||||
&-metric-box {
|
||||
fill: rgba(150, 150, 150, 0.4);
|
||||
|
||||
&-info {
|
||||
background-color: rgba(white, 0.6);
|
||||
border-radius: 2px;
|
||||
cursor: default;
|
||||
padding: 5px;
|
||||
|
||||
.wrapper {
|
||||
display: block;
|
||||
|
||||
&.label { font-size: 15px; }
|
||||
&.consumption { font-size: 12px; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-layer-topology {
|
||||
background-color: rgba(#eee, 0.95);
|
||||
border: 1px solid #ccc;
|
||||
color: $text-tertiary-color;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
padding-right: 20px;
|
||||
text-align: right;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
// This part sets the styles only for the 'real' node details table, not applying
|
||||
// them to the nodes grid, because there we control hovering from the JS.
|
||||
// NOTE: Maybe it would be nice to separate the class names between the two places
|
||||
@@ -1151,7 +1181,7 @@
|
||||
}
|
||||
|
||||
.error {
|
||||
animation: blinking 2.0s 60 $base-ease; // blink for 2 minute;
|
||||
animation: blinking 2.0s 60 $base-ease; // blink for 2 minutes
|
||||
color: $text-secondary-color;
|
||||
}
|
||||
|
||||
@@ -1181,7 +1211,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.topology-option, .metric-selector, .network-selector, .grid-mode-selector {
|
||||
.topology-option, .metric-selector, .network-selector, .view-mode-selector {
|
||||
color: $text-secondary-color;
|
||||
margin: 6px 0;
|
||||
|
||||
@@ -1208,7 +1238,7 @@
|
||||
display: inline-block;
|
||||
background-color: $background-color;
|
||||
|
||||
&-selected, &:hover {
|
||||
&-selected, &:not([disabled]):hover {
|
||||
color: $text-darker-color;
|
||||
background-color: $background-darker-color;
|
||||
}
|
||||
@@ -1226,10 +1256,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
.grid-mode-selector {
|
||||
|
||||
.view-mode-selector {
|
||||
margin-top: 8px;
|
||||
margin-left: 8px;
|
||||
margin-left: 20px;
|
||||
min-width: 161px;
|
||||
|
||||
&-wrapper {
|
||||
@@ -1240,7 +1269,7 @@
|
||||
|
||||
&:first-child,
|
||||
&:last-child {
|
||||
.grid-mode-selector-action {
|
||||
.view-mode-selector-action {
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
@@ -1249,9 +1278,13 @@
|
||||
background-color: transparent;
|
||||
text-transform: uppercase;
|
||||
|
||||
&-selected, &:hover {
|
||||
&-selected, &:not([disabled]):hover {
|
||||
background-color: $background-darker-secondary-color;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
border-right: 1px solid $background-darker-secondary-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1263,7 +1296,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.grid-mode-selector .fa {
|
||||
.view-mode-selector-wrapper .fa {
|
||||
margin-right: 4px;
|
||||
margin-left: 0;
|
||||
color: $text-secondary-color;
|
||||
@@ -1612,13 +1645,13 @@
|
||||
|
||||
&-term {
|
||||
flex: 1;
|
||||
color: #5b5b88;
|
||||
color: $text-secondary-color;
|
||||
}
|
||||
|
||||
&-term-label {
|
||||
flex: 1;
|
||||
b {
|
||||
color: #5b5b88;
|
||||
color: $text-secondary-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1650,7 +1683,7 @@
|
||||
// Notes: Firefox gets a bit messy if you try and bubble
|
||||
// heights + overflow up (min-height issue + still doesn't work v.well),
|
||||
// so this is a bit of a hack.
|
||||
max-height: calc(100vh - 160px - 160px - 160px);
|
||||
max-height: "calc(100vh - 160px - 160px - 160px)";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ $edge-opacity-blurred: 0;
|
||||
$btn-opacity-default: 1;
|
||||
$btn-opacity-hover: 1;
|
||||
$btn-opacity-selected: 1;
|
||||
$btn-opacity-disabled: 0.4;
|
||||
|
||||
$link-opacity-default: 1;
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ $edge-color: rgb(110, 110, 156);
|
||||
$btn-opacity-default: 0.7;
|
||||
$btn-opacity-hover: 1;
|
||||
$btn-opacity-selected: 0.9;
|
||||
$btn-opacity-disabled: 0.25;
|
||||
|
||||
$link-opacity-default: 0.8;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user