Initial version of the resource view (#2296)

* Added resource view selector button

* Showing resource boxes in the resource view

* Crude CPU resource view prototype

* Improved the viewMode state logic

* Extracted zooming into a separate wrapper component

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

* Proper zooming logic for the resource view

* Moved all node networks utils to selectors

* Improved the zoom caching logic

* Further refactoring of selectors

* Added sticky labels to the resource boxes

* Added panning translation limits in the resource view

* Renamed GridModeSelector -> ViewModeSelector

* Polished the topology resource view selection logic

* Search bar hidden in the resource view

* Added per-layer topology names to the resource view

* Made metric selectors work for the resource view

* Adjusted the viewport selectors

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

* Showing more useful metric info in the resource box labels

* Fetching only necessary nodes for the resource view

* Refactored the resource view layer component

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

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

* Renamed and moved resource view components

* Polished all the resource view components

* Changing the available metrics selection

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

* Separated zoom limits from the zoom active state

* Renaming and bunch of comments

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

* Made graph view selectors independent from resource view selectors
This commit is contained in:
Filip Barl
2017-03-24 14:51:53 +01:00
committed by GitHub
parent 8814e856e0
commit 69fd397217
50 changed files with 1592 additions and 568 deletions

View File

@@ -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);
}
};
}

View File

@@ -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}

View File

@@ -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);

View File

@@ -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']),
};
}

View File

@@ -16,13 +16,18 @@ import { focusSearch, pinNextMetric, hitBackspace, hitEnter, hitEsc, unpinMetric
selectMetric, toggleHelp, toggleGridMode, shutdown } from '../actions/app-actions';
import Details from './details';
import Nodes from './nodes';
import GridModeSelector from './grid-mode-selector';
import MetricSelector from './metric-selector';
import ViewModeSelector from './view-mode-selector';
import NetworkSelector from './networks-selector';
import DebugToolbar, { showingDebugToolbar, toggleDebugToolbar } from './debug-toolbar';
import { getRouter, getUrlState } from '../utils/router-utils';
import { activeTopologyOptionsSelector } from '../selectors/topology';
import { availableNetworksSelector } from '../selectors/node-networks';
import {
activeTopologyOptionsSelector,
isResourceViewModeSelector,
isTableViewModeSelector,
isGraphViewModeSelector,
} from '../selectors/topology';
const BACKSPACE_KEY_CODE = 8;
const ENTER_KEY_CODE = 13;
@@ -102,7 +107,7 @@ class App extends React.Component {
}
render() {
const { gridMode, showingDetails, showingHelp, showingMetricsSelector,
const { isTableViewMode, isGraphViewMode, isResourceViewMode, showingDetails, showingHelp,
showingNetworkSelector, showingTroubleshootingMenu } = this.props;
const isIframe = window !== window.top;
@@ -124,16 +129,15 @@ class App extends React.Component {
</div>
<Search />
<Topologies />
<GridModeSelector />
<ViewModeSelector />
</div>
<Nodes />
<Sidebar classNames={gridMode ? 'sidebar-gridmode' : ''}>
{showingMetricsSelector && !gridMode && <MetricSelector />}
{showingNetworkSelector && !gridMode && <NetworkSelector />}
<Status />
<TopologyOptions />
<Sidebar classNames={isTableViewMode ? 'sidebar-gridmode' : ''}>
{showingNetworkSelector && isGraphViewMode && <NetworkSelector />}
{!isResourceViewMode && <Status />}
{!isResourceViewMode && <TopologyOptions />}
</Sidebar>
<Footer />
@@ -146,14 +150,15 @@ class App extends React.Component {
function mapStateToProps(state) {
return {
activeTopologyOptions: activeTopologyOptionsSelector(state),
gridMode: state.get('gridMode'),
isResourceViewMode: isResourceViewModeSelector(state),
isTableViewMode: isTableViewModeSelector(state),
isGraphViewMode: isGraphViewModeSelector(state),
routeSet: state.get('routeSet'),
searchFocused: state.get('searchFocused'),
searchQuery: state.get('searchQuery'),
showingDetails: state.get('nodeDetails').size > 0,
showingHelp: state.get('showingHelp'),
showingTroubleshootingMenu: state.get('showingTroubleshootingMenu'),
showingMetricsSelector: state.get('availableCanvasMetrics').count() > 0,
showingNetworkSelector: availableNetworksSelector(state).count() > 0,
showingTerminal: state.get('controlPipes').size > 0,
urlState: getUrlState(state)

View File

@@ -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),
};
}

View File

@@ -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);

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { connect } from 'react-redux';
import { searchableFieldsSelector } from '../selectors/search';
import { CANVAS_MARGINS } from '../constants/styles';
import { canvasMarginsSelector } from '../selectors/canvas';
import { hideHelp } from '../actions/app-actions';
@@ -149,10 +149,10 @@ function renderFieldsPanel(currentTopologyName, searchableFields) {
}
function HelpPanel({currentTopologyName, searchableFields, onClickClose}) {
function HelpPanel({ currentTopologyName, searchableFields, onClickClose, canvasMargins }) {
return (
<div className="help-panel-wrapper">
<div className="help-panel" style={{marginTop: CANVAS_MARGINS.top}}>
<div className="help-panel" style={{marginTop: canvasMargins.top}}>
<div className="help-panel-header">
<h2>Help</h2>
</div>
@@ -176,6 +176,7 @@ function HelpPanel({currentTopologyName, searchableFields, onClickClose}) {
function mapStateToProps(state) {
return {
canvasMargins: canvasMarginsSelector(state),
searchableFields: searchableFieldsSelector(state),
currentTopologyName: state.getIn(['currentTopology', 'fullName'])
};

View File

@@ -2,9 +2,9 @@
/* eslint max-len: "off" */
import React from 'react';
export default function Logo() {
export default function Logo({ transform = '' }) {
return (
<g className="logo">
<g className="logo" transform={transform}>
<path fill="#32324B" d="M114.937,118.165l75.419-67.366c-5.989-4.707-12.71-8.52-19.981-11.211l-55.438,49.52V118.165z" />
<path fill="#32324B" d="M93.265,108.465l-20.431,18.25c1.86,7.57,4.88,14.683,8.87,21.135l11.561-10.326V108.465z" />
<path fill="#00D2FF" d="M155.276,53.074V35.768C151.815,35.27,148.282,35,144.685,35c-3.766,0-7.465,0.286-11.079,0.828v36.604

View File

@@ -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),
};
}

View File

@@ -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),
};
}

View 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);

View File

@@ -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>
);
}
}

View File

@@ -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);

View File

@@ -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>
);
}
}

View File

@@ -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);

View File

@@ -4,17 +4,21 @@ import { debounce } from 'lodash';
import NodesChart from '../charts/nodes-chart';
import NodesGrid from '../charts/nodes-grid';
import NodesResources from '../components/nodes-resources';
import NodesError from '../charts/nodes-error';
import DelayedShow from '../utils/delayed-show';
import { Loading, getNodeType } from './loading';
import { isTopologyEmpty } from '../utils/topology-utils';
import { setViewportDimensions } from '../actions/app-actions';
import {
isGraphViewModeSelector,
isTableViewModeSelector,
isResourceViewModeSelector,
} from '../selectors/topology';
import { VIEWPORT_RESIZE_DEBOUNCE_INTERVAL } from '../constants/timer';
const navbarHeight = 194;
const marginTop = 0;
const EmptyTopologyError = show => (
<NodesError faIconClass="fa-circle-thin" hidden={!show}>
<div className="heading">Nothing to show. This can have any of these reasons:</div>
@@ -47,9 +51,10 @@ class Nodes extends React.Component {
}
render() {
const { topologyEmpty, gridMode, topologiesLoaded, nodesLoaded, topologies,
currentTopology } = this.props;
const { topologyEmpty, topologiesLoaded, nodesLoaded, topologies, currentTopology,
isGraphViewMode, isTableViewMode, isResourceViewMode } = this.props;
// TODO: Rename view mode components.
return (
<div className="nodes-wrapper">
<DelayedShow delay={1000} show={!topologiesLoaded || (topologiesLoaded && !nodesLoaded)}>
@@ -60,23 +65,25 @@ class Nodes extends React.Component {
</DelayedShow>
{EmptyTopologyError(topologiesLoaded && nodesLoaded && topologyEmpty)}
{gridMode ? <NodesGrid /> : <NodesChart />}
{isGraphViewMode && <NodesChart />}
{isTableViewMode && <NodesGrid />}
{isResourceViewMode && <NodesResources />}
</div>
);
}
setDimensions() {
const width = window.innerWidth;
const height = window.innerHeight - navbarHeight - marginTop;
this.props.setViewportDimensions(width, height);
this.props.setViewportDimensions(window.innerWidth, window.innerHeight);
}
}
function mapStateToProps(state) {
return {
isGraphViewMode: isGraphViewModeSelector(state),
isTableViewMode: isTableViewModeSelector(state),
isResourceViewMode: isResourceViewModeSelector(state),
currentTopology: state.get('currentTopology'),
gridMode: state.get('gridMode'),
nodesLoaded: state.get('nodesLoaded'),
topologies: state.get('topologies'),
topologiesLoaded: state.get('topologiesLoaded'),

View File

@@ -5,6 +5,7 @@ import { debounce } from 'lodash';
import { blurSearch, doSearch, focusSearch, showHelp } from '../actions/app-actions';
import { searchMatchCountByTopologySelector } from '../selectors/search';
import { isResourceViewModeSelector } from '../selectors/topology';
import { slugify } from '../utils/string-utils';
import { isTopologyEmpty } from '../utils/topology-utils';
import SearchItem from './search-item';
@@ -89,7 +90,7 @@ class Search extends React.Component {
componentWillReceiveProps(nextProps) {
// when cleared from the outside, reset internal state
if (this.props.searchQuery !== nextProps.searchQuery && nextProps.searchQuery === '') {
this.setState({value: ''});
this.setState({ value: '' });
}
}
@@ -102,16 +103,17 @@ class Search extends React.Component {
}
render() {
const { nodes, pinnedSearches, searchFocused, searchMatchCountByTopology,
const { nodes, pinnedSearches, searchFocused, searchMatchCountByTopology, isResourceViewMode,
searchQuery, topologiesLoaded, onClickHelp, inputId = 'search' } = this.props;
const disabled = this.props.isTopologyEmpty;
const hidden = !topologiesLoaded || isResourceViewMode;
const disabled = this.props.isTopologyEmpty && !hidden;
const matchCount = searchMatchCountByTopology
.reduce((count, topologyMatchCount) => count + topologyMatchCount, 0);
const showPinnedSearches = pinnedSearches.size > 0;
// manual clear (null) has priority, then props, then state
const value = this.state.value === null ? '' : this.state.value || searchQuery || '';
const classNames = classnames('search', 'hideable', {
hide: !topologiesLoaded,
hide: hidden,
'search-pinned': showPinnedSearches,
'search-matched': matchCount,
'search-filled': value,
@@ -153,6 +155,7 @@ class Search extends React.Component {
export default connect(
state => ({
nodes: state.get('nodes'),
isResourceViewMode: isResourceViewModeSelector(state),
isTopologyEmpty: isTopologyEmpty(state),
topologiesLoaded: state.get('topologiesLoaded'),
pinnedSearches: state.get('pinnedSearches'),

View File

@@ -3,6 +3,7 @@ import { connect } from 'react-redux';
import classnames from 'classnames';
import { searchMatchCountByTopologySelector } from '../selectors/search';
import { isResourceViewModeSelector } from '../selectors/topology';
import { clickTopology } from '../actions/app-actions';
@@ -35,8 +36,9 @@ class Topologies extends React.Component {
const searchMatchCount = this.props.searchMatchCountByTopology.get(topologyId) || 0;
const title = basicTopologyInfo(subTopology, searchMatchCount);
const className = classnames('topologies-sub-item', {
// Don't show matches in the resource view as searching is not supported there yet.
'topologies-sub-item-matched': !this.props.isResourceViewMode && searchMatchCount,
'topologies-sub-item-active': isActive,
'topologies-sub-item-matched': searchMatchCount
});
return (
@@ -54,8 +56,9 @@ class Topologies extends React.Component {
const isActive = topology === this.props.currentTopology;
const searchMatchCount = this.props.searchMatchCountByTopology.get(topology.get('id')) || 0;
const className = classnames('topologies-item-main', {
// Don't show matches in the resource view as searching is not supported there yet.
'topologies-item-main-matched': !this.props.isResourceViewMode && searchMatchCount,
'topologies-item-main-active': isActive,
'topologies-item-main-matched': searchMatchCount
});
const topologyId = topology.get('id');
const title = basicTopologyInfo(topology, searchMatchCount);
@@ -91,6 +94,7 @@ function mapStateToProps(state) {
topologies: state.get('topologies'),
currentTopology: state.get('currentTopology'),
searchMatchCountByTopology: searchMatchCountByTopologySelector(state),
isResourceViewMode: isResourceViewModeSelector(state),
};
}

View File

@@ -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

View 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);

View 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);

View File

@@ -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'

View File

@@ -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';

View 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' },
];

View File

@@ -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',

View 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;
};
}

View File

@@ -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', () => {

View File

@@ -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);
}

View File

@@ -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)
);

View 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)
);

View File

@@ -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(

View File

@@ -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.

View 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)
);

View File

@@ -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(
[

View File

@@ -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);
}
);

View File

@@ -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)
);

View 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;
}
);

View 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)
);

View File

@@ -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)]
);

View 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())
);

View File

@@ -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', '');
}

View File

@@ -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);
}

View File

@@ -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'),

View File

@@ -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;
}

View 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})`
);

View File

@@ -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);

View File

@@ -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)";
}
}
}

View File

@@ -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;

View File

@@ -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;