mirror of
https://github.com/weaveworks/scope.git
synced 2026-07-28 17:51:21 +00:00
Initial version of the resource view (#2296)
* Added resource view selector button * Showing resource boxes in the resource view * Crude CPU resource view prototype * Improved the viewMode state logic * Extracted zooming into a separate wrapper component * Split the layout selectors between graph-view and resource-view * Proper zooming logic for the resource view * Moved all node networks utils to selectors * Improved the zoom caching logic * Further refactoring of selectors * Added sticky labels to the resource boxes * Added panning translation limits in the resource view * Renamed GridModeSelector -> ViewModeSelector * Polished the topology resource view selection logic * Search bar hidden in the resource view * Added per-layer topology names to the resource view * Made metric selectors work for the resource view * Adjusted the viewport selectors * Renamed viewport selector to canvas (+ maximal zoom fix) * Showing more useful metric info in the resource box labels * Fetching only necessary nodes for the resource view * Refactored the resource view layer component * Addressed first batch UI comments (from the Scope meeting) * Switch to deep zooming transform in the resource view to avoid SVG precision errors * Renamed and moved resource view components * Polished all the resource view components * Changing the available metrics selection * Improved and polished the state transition logic for the resource view * Separated zoom limits from the zoom active state * Renaming and bunch of comments * Addressed all the UI comments (@davkal + @fons) * Made graph view selectors independent from resource view selectors
This commit is contained in:
@@ -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())
|
||||
);
|
||||
Reference in New Issue
Block a user