mirror of
https://github.com/weaveworks/scope.git
synced 2026-07-18 21:09:38 +00:00
Merge pull request #1105 from weaveworks/metrics-on-canvas
Metrics on canvas
This commit is contained in:
@@ -3,6 +3,7 @@ import debug from 'debug';
|
||||
import AppDispatcher from '../dispatcher/app-dispatcher';
|
||||
import ActionTypes from '../constants/action-types';
|
||||
import { saveGraph } from '../utils/file-utils';
|
||||
import { modulo } from '../utils/math-utils';
|
||||
import { updateRoute } from '../utils/router-utils';
|
||||
import { bufferDeltaUpdate, resumeUpdate,
|
||||
resetUpdateBuffer } from '../utils/update-buffer-utils';
|
||||
@@ -12,6 +13,41 @@ import AppStore from '../stores/app-store';
|
||||
|
||||
const log = debug('scope:app-actions');
|
||||
|
||||
export function selectMetric(metricId) {
|
||||
AppDispatcher.dispatch({
|
||||
type: ActionTypes.SELECT_METRIC,
|
||||
metricId
|
||||
});
|
||||
}
|
||||
|
||||
export function pinNextMetric(delta) {
|
||||
const metrics = AppStore.getAvailableCanvasMetrics().map(m => m.get('id'));
|
||||
const currentIndex = metrics.indexOf(AppStore.getSelectedMetric());
|
||||
const nextIndex = modulo(currentIndex + delta, metrics.count());
|
||||
const nextMetric = metrics.get(nextIndex);
|
||||
|
||||
AppDispatcher.dispatch({
|
||||
type: ActionTypes.PIN_METRIC,
|
||||
metricId: nextMetric,
|
||||
});
|
||||
updateRoute();
|
||||
}
|
||||
|
||||
export function pinMetric(metricId) {
|
||||
AppDispatcher.dispatch({
|
||||
type: ActionTypes.PIN_METRIC,
|
||||
metricId,
|
||||
});
|
||||
updateRoute();
|
||||
}
|
||||
|
||||
export function unpinMetric() {
|
||||
AppDispatcher.dispatch({
|
||||
type: ActionTypes.UNPIN_METRIC,
|
||||
});
|
||||
updateRoute();
|
||||
}
|
||||
|
||||
export function changeTopologyOption(option, value, topologyId) {
|
||||
AppDispatcher.dispatch({
|
||||
type: ActionTypes.CHANGE_TOPOLOGY_OPTION,
|
||||
@@ -243,6 +279,7 @@ export function receiveNodesDelta(delta) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function receiveTopologies(topologies) {
|
||||
AppDispatcher.dispatch({
|
||||
type: ActionTypes.RECEIVE_TOPOLOGIES,
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import {getMetricValue, getMetricColor, getClipPathDefinition} from '../utils/metric-utils.js';
|
||||
import {CANVAS_METRIC_FONT_SIZE} from '../constants/styles.js';
|
||||
|
||||
export default function NodeShapeCircle({onlyHighlight, highlighted, size, color}) {
|
||||
const hightlightNode = <circle r={size * 0.7} className="highlighted" />;
|
||||
|
||||
if (onlyHighlight) {
|
||||
return (
|
||||
<g className="shape">
|
||||
{highlighted && hightlightNode}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
export default function NodeShapeCircle({id, highlighted, size, color, metric}) {
|
||||
const clipId = `mask-${id}`;
|
||||
const {height, hasMetric, formattedValue} = getMetricValue(metric, size);
|
||||
const metricStyle = { fill: getMetricColor(metric) };
|
||||
const className = classNames('shape', { metrics: hasMetric });
|
||||
const fontSize = size * CANVAS_METRIC_FONT_SIZE;
|
||||
|
||||
return (
|
||||
<g className="shape">
|
||||
{highlighted && hightlightNode}
|
||||
<g className={className}>
|
||||
{hasMetric && getClipPathDefinition(clipId, size, height)}
|
||||
{highlighted && <circle r={size * 0.7} className="highlighted" />}
|
||||
<circle r={size * 0.5} className="border" stroke={color} />
|
||||
<circle r={size * 0.45} className="shadow" />
|
||||
<circle r={Math.max(2, size * 0.125)} className="node" />
|
||||
{hasMetric && <circle r={size * 0.45} className="metric-fill" style={metricStyle}
|
||||
clipPath={`url(#${clipId})`} />}
|
||||
{highlighted && hasMetric ?
|
||||
<text style={{fontSize}}>{formattedValue}</text> :
|
||||
<circle className="node" r={Math.max(2, (size * 0.125))} />}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import React from 'react';
|
||||
import d3 from 'd3';
|
||||
import classNames from 'classnames';
|
||||
import {getMetricValue, getMetricColor, getClipPathDefinition} from '../utils/metric-utils.js';
|
||||
import {CANVAS_METRIC_FONT_SIZE} from '../constants/styles.js';
|
||||
|
||||
|
||||
const line = d3.svg.line()
|
||||
.interpolate('cardinal-closed')
|
||||
.tension(0.25);
|
||||
|
||||
|
||||
function polygon(r, sides) {
|
||||
const a = (Math.PI * 2) / sides;
|
||||
const points = [[r, 0]];
|
||||
@@ -14,29 +19,31 @@ function polygon(r, sides) {
|
||||
return points;
|
||||
}
|
||||
|
||||
export default function NodeShapeHeptagon({onlyHighlight, highlighted, size, color}) {
|
||||
|
||||
export default function NodeShapeHeptagon({id, highlighted, size, color, metric}) {
|
||||
const scaledSize = size * 1.0;
|
||||
const pathProps = v => ({
|
||||
d: line(polygon(scaledSize * v, 7)),
|
||||
transform: 'rotate(90)'
|
||||
});
|
||||
|
||||
const hightlightNode = <path className="highlighted" {...pathProps(0.7)} />;
|
||||
|
||||
if (onlyHighlight) {
|
||||
return (
|
||||
<g className="shape">
|
||||
{highlighted && hightlightNode}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
const clipId = `mask-${id}`;
|
||||
const {height, hasMetric, formattedValue} = getMetricValue(metric, size);
|
||||
const metricStyle = { fill: getMetricColor(metric) };
|
||||
const className = classNames('shape', { metrics: hasMetric });
|
||||
const fontSize = size * CANVAS_METRIC_FONT_SIZE;
|
||||
|
||||
return (
|
||||
<g className="shape">
|
||||
{highlighted && hightlightNode}
|
||||
<g className={className}>
|
||||
{hasMetric && getClipPathDefinition(clipId, size, height, size * 0.5 - height, -size * 0.5)}
|
||||
{highlighted && <path className="highlighted" {...pathProps(0.7)} />}
|
||||
<path className="border" stroke={color} {...pathProps(0.5)} />
|
||||
<path className="shadow" {...pathProps(0.45)} />
|
||||
<circle className="node" r={Math.max(2, (scaledSize * 0.125))} />
|
||||
{hasMetric && <path className="metric-fill" clipPath={`url(#${clipId})`}
|
||||
style={metricStyle} {...pathProps(0.45)} />}
|
||||
{highlighted && hasMetric ?
|
||||
<text style={{fontSize}}>{formattedValue}</text> :
|
||||
<circle className="node" r={Math.max(2, (size * 0.125))} />}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import React from 'react';
|
||||
import d3 from 'd3';
|
||||
import classNames from 'classnames';
|
||||
import {getMetricValue, getMetricColor, getClipPathDefinition} from '../utils/metric-utils.js';
|
||||
import {CANVAS_METRIC_FONT_SIZE} from '../constants/styles.js';
|
||||
|
||||
|
||||
const line = d3.svg.line()
|
||||
.interpolate('cardinal-closed')
|
||||
.tension(0.25);
|
||||
|
||||
|
||||
function getWidth(h) {
|
||||
return (Math.sqrt(3) / 2) * h;
|
||||
}
|
||||
|
||||
|
||||
function getPoints(h) {
|
||||
const w = getWidth(h);
|
||||
const points = [
|
||||
@@ -24,28 +30,35 @@ function getPoints(h) {
|
||||
}
|
||||
|
||||
|
||||
export default function NodeShapeHex({onlyHighlight, highlighted, size, color}) {
|
||||
export default function NodeShapeHex({id, highlighted, size, color, metric}) {
|
||||
const pathProps = v => ({
|
||||
d: getPoints(size * v * 2),
|
||||
transform: `rotate(90) translate(-${size * getWidth(v)}, -${size * v})`
|
||||
});
|
||||
|
||||
const hightlightNode = <path className="highlighted" {...pathProps(0.7)} />;
|
||||
const shadowSize = 0.45;
|
||||
const upperHexBitHeight = -0.25 * size * shadowSize;
|
||||
|
||||
if (onlyHighlight) {
|
||||
return (
|
||||
<g className="shape">
|
||||
{highlighted && hightlightNode}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
const clipId = `mask-${id}`;
|
||||
const {height, hasMetric, formattedValue} = getMetricValue(metric, size);
|
||||
const metricStyle = { fill: getMetricColor(metric) };
|
||||
const className = classNames('shape', { metrics: hasMetric });
|
||||
const fontSize = size * CANVAS_METRIC_FONT_SIZE;
|
||||
|
||||
return (
|
||||
<g className="shape">
|
||||
{highlighted && hightlightNode}
|
||||
<g className={className}>
|
||||
{hasMetric && getClipPathDefinition(clipId, size, height, size - height +
|
||||
upperHexBitHeight, 0)}
|
||||
{highlighted && <path className="highlighted" {...pathProps(0.7)} />}
|
||||
<path className="border" stroke={color} {...pathProps(0.5)} />
|
||||
<path className="shadow" {...pathProps(0.45)} />
|
||||
<circle className="node" r={Math.max(2, (size * 0.125))} />
|
||||
<path className="shadow" {...pathProps(shadowSize)} />
|
||||
{hasMetric && <path className="metric-fill" style={metricStyle}
|
||||
clipPath={`url(#${clipId})`} {...pathProps(shadowSize)} />}
|
||||
{highlighted && hasMetric ?
|
||||
<text style={{fontSize}}>
|
||||
{formattedValue}
|
||||
</text> :
|
||||
<circle className="node" r={Math.max(2, (size * 0.125))} />}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import NodeShapeSquare from './node-shape-square';
|
||||
|
||||
// TODO how to express a cmp in terms of another cmp? (Rather than a sub-cmp as here).
|
||||
// HOC!
|
||||
|
||||
export default function NodeShapeRoundedSquare(props) {
|
||||
return (
|
||||
|
||||
@@ -1,30 +1,40 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import {getMetricValue, getMetricColor, getClipPathDefinition} from '../utils/metric-utils.js';
|
||||
import {CANVAS_METRIC_FONT_SIZE} from '../constants/styles.js';
|
||||
|
||||
export default function NodeShapeSquare({onlyHighlight, highlighted, size, color, rx = 0, ry = 0}) {
|
||||
const rectProps = v => ({
|
||||
width: v * size * 2,
|
||||
height: v * size * 2,
|
||||
rx: v * size * rx,
|
||||
ry: v * size * ry,
|
||||
transform: `translate(-${size * v}, -${size * v})`
|
||||
|
||||
export default function NodeShapeSquare({
|
||||
id, highlighted, size, color, rx = 0, ry = 0, metric
|
||||
}) {
|
||||
const rectProps = (scale, radiusScale) => ({
|
||||
width: scale * size * 2,
|
||||
height: scale * size * 2,
|
||||
rx: (radiusScale || scale) * size * rx,
|
||||
ry: (radiusScale || scale) * size * ry,
|
||||
x: -size * scale,
|
||||
y: -size * scale
|
||||
});
|
||||
|
||||
const hightlightNode = <rect className="highlighted" {...rectProps(0.7)} />;
|
||||
|
||||
if (onlyHighlight) {
|
||||
return (
|
||||
<g className="shape">
|
||||
{highlighted && hightlightNode}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
const clipId = `mask-${id}`;
|
||||
const {height, hasMetric, formattedValue} = getMetricValue(metric, size);
|
||||
const metricStyle = { fill: getMetricColor(metric) };
|
||||
const className = classNames('shape', { metrics: hasMetric });
|
||||
const fontSize = size * CANVAS_METRIC_FONT_SIZE;
|
||||
|
||||
return (
|
||||
<g className="shape">
|
||||
{highlighted && hightlightNode}
|
||||
<rect className="border" stroke={color} {...rectProps(0.5)} />
|
||||
<rect className="shadow" {...rectProps(0.45)} />
|
||||
<circle className="node" r={Math.max(2, (size * 0.125))} />
|
||||
<g className={className}>
|
||||
{hasMetric && getClipPathDefinition(clipId, size, height)}
|
||||
{highlighted && <rect className="highlighted" {...rectProps(0.7)} />}
|
||||
<rect className="border" stroke={color} {...rectProps(0.5, 0.5)} />
|
||||
<rect className="shadow" {...rectProps(0.45, 0.39)} />
|
||||
{hasMetric && <rect className="metric-fill" style={metricStyle}
|
||||
clipPath={`url(#${clipId})`} {...rectProps(0.45, 0.39)} />}
|
||||
{highlighted && hasMetric ?
|
||||
<text style={{fontSize}}>
|
||||
{formattedValue}
|
||||
</text> :
|
||||
<circle className="node" r={Math.max(2, (size * 0.125))} />}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
import React from 'react';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { isContrastMode } from '../utils/contrast-utils';
|
||||
|
||||
function dissoc(obj, key) {
|
||||
const newObj = _.clone(obj);
|
||||
delete newObj[key];
|
||||
return newObj;
|
||||
}
|
||||
|
||||
export default function NodeShapeStack(props) {
|
||||
const propsNoHighlight = dissoc(props, 'highlighted');
|
||||
const propsOnlyHighlight = Object.assign({}, props, {onlyHighlight: true});
|
||||
const contrastMode = isContrastMode();
|
||||
|
||||
const Shape = props.shape;
|
||||
const [dx, dy] = contrastMode ? [0, 8] : [0, 5];
|
||||
const dsx = (props.size * 2 + (dx * 2)) / (props.size * 2);
|
||||
@@ -22,16 +11,18 @@ export default function NodeShapeStack(props) {
|
||||
|
||||
return (
|
||||
<g transform={`translate(${dx * -1}, ${dy * -2.5})`} className="stack">
|
||||
<g transform={`scale(${hls})translate(${dx}, ${dy}) `}>
|
||||
<Shape {...propsOnlyHighlight} />
|
||||
<g transform={`scale(${hls})translate(${dx}, ${dy})`} className="onlyHighlight">
|
||||
<Shape {...props} />
|
||||
</g>
|
||||
<g transform={`translate(${dx * 2}, ${dy * 2})`}>
|
||||
<Shape {...propsNoHighlight} />
|
||||
<Shape {...props} />
|
||||
</g>
|
||||
<g transform={`translate(${dx * 1}, ${dy * 1})`}>
|
||||
<Shape {...propsNoHighlight} />
|
||||
<Shape {...props} />
|
||||
</g>
|
||||
<g className="onlyMetrics">
|
||||
<Shape {...props} />
|
||||
</g>
|
||||
<Shape {...propsNoHighlight} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,10 +74,13 @@ export default class NodesChart extends React.Component {
|
||||
edges: makeMap()
|
||||
});
|
||||
}
|
||||
//
|
||||
// FIXME add PureRenderMixin, Immutables, and move the following functions to render()
|
||||
// _.assign(state, this.updateGraphState(nextProps, state));
|
||||
if (nextProps.forceRelayout || nextProps.nodes !== this.props.nodes) {
|
||||
_.assign(state, this.updateGraphState(nextProps, state));
|
||||
}
|
||||
|
||||
if (this.props.selectedNodeId !== nextProps.selectedNodeId) {
|
||||
_.assign(state, this.restoreLayout(state));
|
||||
}
|
||||
@@ -131,6 +134,13 @@ export default class NodesChart extends React.Component {
|
||||
return 1;
|
||||
};
|
||||
|
||||
// TODO: think about pulling this up into the store.
|
||||
const metric = node => (
|
||||
node.get('metrics') && node.get('metrics')
|
||||
.filter(m => m.get('id') === this.props.selectedMetric)
|
||||
.first()
|
||||
);
|
||||
|
||||
return nodes
|
||||
.toIndexedSeq()
|
||||
.map(setHighlighted)
|
||||
@@ -151,6 +161,7 @@ export default class NodesChart extends React.Component {
|
||||
pseudo={node.get('pseudo')}
|
||||
nodeCount={node.get('nodeCount')}
|
||||
subLabel={node.get('subLabel')}
|
||||
metric={metric(node)}
|
||||
rank={node.get('rank')}
|
||||
selectedNodeScale={selectedNodeScale}
|
||||
nodeScale={nodeScale}
|
||||
@@ -246,6 +257,7 @@ export default class NodesChart extends React.Component {
|
||||
id,
|
||||
label: node.get('label'),
|
||||
pseudo: node.get('pseudo'),
|
||||
metrics: node.get('metrics'),
|
||||
subLabel: node.get('label_minor'),
|
||||
nodeCount: node.get('node_count'),
|
||||
rank: node.get('rank'),
|
||||
@@ -423,7 +435,9 @@ export default class NodesChart extends React.Component {
|
||||
if (!graph) {
|
||||
return {maxNodesExceeded: true};
|
||||
}
|
||||
stateNodes = graph.nodes;
|
||||
stateNodes = graph.nodes.mergeDeep(stateNodes.map(node => makeMap({
|
||||
metrics: node.get('metrics')
|
||||
})));
|
||||
stateEdges = graph.edges;
|
||||
|
||||
// save coordinates for restore
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import debug from 'debug';
|
||||
|
||||
import Logo from './logo';
|
||||
import AppStore from '../stores/app-store';
|
||||
@@ -8,14 +9,22 @@ import Status from './status.js';
|
||||
import Topologies from './topologies.js';
|
||||
import TopologyOptions from './topology-options.js';
|
||||
import { getApiDetails, getTopologies } from '../utils/web-api-utils';
|
||||
import { hitEsc } from '../actions/app-actions';
|
||||
import { pinNextMetric, hitEsc, unpinMetric,
|
||||
selectMetric } from '../actions/app-actions';
|
||||
import Details from './details';
|
||||
import Nodes from './nodes';
|
||||
import MetricSelector from './metric-selector';
|
||||
import EmbeddedTerminal from './embedded-terminal';
|
||||
import { getRouter } from '../utils/router-utils';
|
||||
import { showingDebugToolbar, DebugToolbar } from './debug-toolbar.js';
|
||||
import { showingDebugToolbar, toggleDebugToolbar,
|
||||
DebugToolbar } from './debug-toolbar.js';
|
||||
|
||||
const ESC_KEY_CODE = 27;
|
||||
const D_KEY_CODE = 68;
|
||||
const Q_KEY_CODE = 81;
|
||||
const RIGHT_ANGLE_KEY_IDENTIFIER = 'U+003C';
|
||||
const LEFT_ANGLE_KEY_IDENTIFIER = 'U+003E';
|
||||
const keyPressLog = debug('scope:app-key-press');
|
||||
|
||||
function getStateFromStores() {
|
||||
return {
|
||||
@@ -30,9 +39,12 @@ function getStateFromStores() {
|
||||
highlightedEdgeIds: AppStore.getHighlightedEdgeIds(),
|
||||
highlightedNodeIds: AppStore.getHighlightedNodeIds(),
|
||||
hostname: AppStore.getHostname(),
|
||||
pinnedMetric: AppStore.getPinnedMetric(),
|
||||
availableCanvasMetrics: AppStore.getAvailableCanvasMetrics(),
|
||||
nodeDetails: AppStore.getNodeDetails(),
|
||||
nodes: AppStore.getNodes(),
|
||||
selectedNodeId: AppStore.getSelectedNodeId(),
|
||||
selectedMetric: AppStore.getSelectedMetric(),
|
||||
topologies: AppStore.getTopologies(),
|
||||
topologiesLoaded: AppStore.isTopologiesLoaded(),
|
||||
updatePaused: AppStore.isUpdatePaused(),
|
||||
@@ -68,8 +80,19 @@ export default class App extends React.Component {
|
||||
}
|
||||
|
||||
onKeyPress(ev) {
|
||||
keyPressLog('onKeyPress', 'keyCode', ev.keyCode, ev);
|
||||
if (ev.keyCode === ESC_KEY_CODE) {
|
||||
hitEsc();
|
||||
} else if (ev.keyIdentifier === RIGHT_ANGLE_KEY_IDENTIFIER) {
|
||||
pinNextMetric(-1);
|
||||
} else if (ev.keyIdentifier === LEFT_ANGLE_KEY_IDENTIFIER) {
|
||||
pinNextMetric(1);
|
||||
} else if (ev.keyCode === Q_KEY_CODE) {
|
||||
unpinMetric();
|
||||
selectMetric(null);
|
||||
} else if (ev.keyCode === D_KEY_CODE) {
|
||||
toggleDebugToolbar();
|
||||
this.forceUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,9 +126,14 @@ export default class App extends React.Component {
|
||||
currentTopology={this.state.currentTopology} />
|
||||
</div>
|
||||
|
||||
<Nodes nodes={this.state.nodes} highlightedNodeIds={this.state.highlightedNodeIds}
|
||||
highlightedEdgeIds={this.state.highlightedEdgeIds} detailsWidth={detailsWidth}
|
||||
selectedNodeId={this.state.selectedNodeId} topMargin={topMargin}
|
||||
<Nodes
|
||||
nodes={this.state.nodes}
|
||||
highlightedNodeIds={this.state.highlightedNodeIds}
|
||||
highlightedEdgeIds={this.state.highlightedEdgeIds}
|
||||
detailsWidth={detailsWidth}
|
||||
selectedNodeId={this.state.selectedNodeId}
|
||||
topMargin={topMargin}
|
||||
selectedMetric={this.state.selectedMetric}
|
||||
forceRelayout={this.state.forceRelayout}
|
||||
topologyOptions={this.state.activeTopologyOptions}
|
||||
topologyId={this.state.currentTopologyId} />
|
||||
@@ -114,6 +142,11 @@ export default class App extends React.Component {
|
||||
<Status errorUrl={this.state.errorUrl} topology={this.state.currentTopology}
|
||||
topologiesLoaded={this.state.topologiesLoaded}
|
||||
websocketClosed={this.state.websocketClosed} />
|
||||
{this.state.availableCanvasMetrics.count() > 0 && <MetricSelector
|
||||
availableCanvasMetrics={this.state.availableCanvasMetrics}
|
||||
pinnedMetric={this.state.pinnedMetric}
|
||||
selectedMetric={this.state.selectedMetric}
|
||||
/>}
|
||||
<TopologyOptions options={this.state.currentTopologyOptions}
|
||||
topologyId={this.state.currentTopologyId}
|
||||
activeOptions={this.state.activeTopologyOptions} />
|
||||
|
||||
@@ -7,13 +7,33 @@ const log = debug('scope:debug-panel');
|
||||
|
||||
import { receiveNodesDelta } from '../actions/app-actions';
|
||||
import AppStore from '../stores/app-store';
|
||||
import { getNodeColor, getNodeColorDark } from '../utils/color-utils';
|
||||
|
||||
const SHAPES = ['circle', 'hexagon', 'square', 'heptagon'];
|
||||
|
||||
const SHAPES = ['square', 'hexagon', 'heptagon', 'circle'];
|
||||
const NODE_COUNTS = [1, 2, 3];
|
||||
const STACK_VARIANTS = [true, false];
|
||||
const STACK_VARIANTS = [false, true];
|
||||
const METRIC_FILLS = [0, 0.1, 50, 99.9, 100];
|
||||
|
||||
|
||||
const sample = (collection) => _.range(_.random(4)).map(() => _.sample(collection));
|
||||
|
||||
|
||||
const shapeTypes = {
|
||||
square: ['Process', 'Processes'],
|
||||
hexagon: ['Container', 'Containers'],
|
||||
heptagon: ['Pod', 'Pods'],
|
||||
circle: ['Host', 'Hosts']
|
||||
};
|
||||
|
||||
|
||||
const LABEL_PREFIXES = _.range('A'.charCodeAt(), 'Z'.charCodeAt() + 1)
|
||||
.map(n => String.fromCharCode(n));
|
||||
|
||||
|
||||
const randomLetter = () => _.sample(LABEL_PREFIXES);
|
||||
|
||||
|
||||
const deltaAdd = (name, adjacency = [], shape = 'circle', stack = false, nodeCount = 1) => ({
|
||||
adjacency,
|
||||
controls: {},
|
||||
@@ -22,41 +42,95 @@ const deltaAdd = (name, adjacency = [], shape = 'circle', stack = false, nodeCou
|
||||
node_count: nodeCount,
|
||||
id: name,
|
||||
label: name,
|
||||
label_minor: 'weave-1',
|
||||
label_minor: name,
|
||||
latest: {},
|
||||
metadata: {},
|
||||
origins: [],
|
||||
rank: 'alpine'
|
||||
rank: name
|
||||
});
|
||||
|
||||
|
||||
function addMetrics(node, v) {
|
||||
const availableMetrics = AppStore.getAvailableCanvasMetrics().toJS();
|
||||
const metrics = availableMetrics.length > 0 ? availableMetrics : [
|
||||
{id: 'host_cpu_usage_percent', label: 'CPU'}
|
||||
];
|
||||
|
||||
return Object.assign({}, node, {
|
||||
metrics: metrics.map(m => Object.assign({}, m, {max: 100, value: v}))
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function label(shape, stacked) {
|
||||
const type = shapeTypes[shape];
|
||||
return stacked ? `Group of ${type[1]}` : type[0];
|
||||
}
|
||||
|
||||
|
||||
function addAllVariants() {
|
||||
const newNodes = _.flattenDeep(SHAPES.map(s => STACK_VARIANTS.map(stack => {
|
||||
if (!stack) return [deltaAdd([s, 1, stack].join('-'), [], s, stack, 1)];
|
||||
return NODE_COUNTS.map(n => deltaAdd([s, n, stack].join('-'), [], s, stack, n));
|
||||
})));
|
||||
const newNodes = _.flattenDeep(STACK_VARIANTS.map(stack => (SHAPES.map(s => {
|
||||
if (!stack) return [deltaAdd(label(s, stack), [], s, stack, 1)];
|
||||
return NODE_COUNTS.map(n => deltaAdd(label(s, stack), [], s, stack, n));
|
||||
}))));
|
||||
|
||||
receiveNodesDelta({
|
||||
add: newNodes
|
||||
});
|
||||
}
|
||||
|
||||
function addNodes(n) {
|
||||
const ns = AppStore.getNodes();
|
||||
const nodeNames = ns.keySeq().toJS();
|
||||
const newNodeNames = _.range(ns.size, ns.size + n).map((i) => `zing${i}`);
|
||||
const allNodes = _(nodeNames).concat(newNodeNames).value();
|
||||
|
||||
function addAllMetricVariants() {
|
||||
const newNodes = _.flattenDeep(METRIC_FILLS.map((v, i) => (
|
||||
SHAPES.map(s => [addMetrics(deltaAdd(label(s) + i, [], s), v)])
|
||||
)));
|
||||
|
||||
receiveNodesDelta({
|
||||
add: newNodeNames.map((name) => deltaAdd(name,
|
||||
sample(allNodes)),
|
||||
_.sample(SHAPES),
|
||||
_.sample(STACK_VARIANTS),
|
||||
_.sample(NODE_COUNTS))
|
||||
add: newNodes
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function addNodes(n) {
|
||||
const ns = AppStore.getNodes();
|
||||
const nodeNames = ns.keySeq().toJS();
|
||||
const newNodeNames = _.range(ns.size, ns.size + n).map(() => (
|
||||
`${randomLetter()}${randomLetter()}-zing`
|
||||
));
|
||||
const allNodes = _(nodeNames).concat(newNodeNames).value();
|
||||
|
||||
receiveNodesDelta({
|
||||
add: newNodeNames.map((name) => deltaAdd(
|
||||
name,
|
||||
sample(allNodes),
|
||||
_.sample(SHAPES),
|
||||
_.sample(STACK_VARIANTS),
|
||||
_.sample(NODE_COUNTS)
|
||||
))
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function showingDebugToolbar() {
|
||||
return Boolean(localStorage.debugToolbar);
|
||||
return 'debugToolbar' in localStorage && JSON.parse(localStorage.debugToolbar);
|
||||
}
|
||||
|
||||
|
||||
export function toggleDebugToolbar() {
|
||||
if ('debugToolbar' in localStorage) {
|
||||
localStorage.debugToolbar = !showingDebugToolbar();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function enableLog(ns) {
|
||||
debug.enable(`scope:${ns}`);
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function disableLog() {
|
||||
debug.disable();
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
export class DebugToolbar extends React.Component {
|
||||
@@ -64,8 +138,10 @@ export class DebugToolbar extends React.Component {
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
this.onChange = this.onChange.bind(this);
|
||||
this.toggleColors = this.toggleColors.bind(this);
|
||||
this.state = {
|
||||
nodesToAdd: 30
|
||||
nodesToAdd: 30,
|
||||
showColors: false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,17 +149,53 @@ export class DebugToolbar extends React.Component {
|
||||
this.setState({nodesToAdd: parseInt(ev.target.value, 10)});
|
||||
}
|
||||
|
||||
toggleColors() {
|
||||
this.setState({
|
||||
showColors: !this.state.showColors
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
log('rending debug panel');
|
||||
|
||||
return (
|
||||
<div className="debug-panel">
|
||||
<label>Add nodes </label>
|
||||
<button onClick={() => addNodes(1)}>+1</button>
|
||||
<button onClick={() => addNodes(10)}>+10</button>
|
||||
<input type="number" onChange={this.onChange} value={this.state.nodesToAdd} />
|
||||
<button onClick={() => addNodes(this.state.nodesToAdd)}>+</button>
|
||||
<button onClick={() => addAllVariants()}>Variants</button>
|
||||
<div>
|
||||
<label>Add nodes </label>
|
||||
<button onClick={() => addNodes(1)}>+1</button>
|
||||
<button onClick={() => addNodes(10)}>+10</button>
|
||||
<input type="number" onChange={this.onChange} value={this.state.nodesToAdd} />
|
||||
<button onClick={() => addNodes(this.state.nodesToAdd)}>+</button>
|
||||
<button onClick={() => addAllVariants()}>Variants</button>
|
||||
<button onClick={() => addAllMetricVariants()}>Metric Variants</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>Logging</label>
|
||||
<button onClick={() => enableLog('*')}>scope:*</button>
|
||||
<button onClick={() => enableLog('dispatcher')}>scope:dispatcher</button>
|
||||
<button onClick={() => enableLog('app-key-press')}>scope:app-key-press</button>
|
||||
<button onClick={() => disableLog()}>Disable log</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>Colors</label>
|
||||
<button onClick={this.toggleColors}>toggle</button>
|
||||
</div>
|
||||
|
||||
{this.state.showColors && [getNodeColor, getNodeColorDark].map(fn => (
|
||||
<table>
|
||||
<tbody>
|
||||
{LABEL_PREFIXES.map(r => (
|
||||
<tr key={r}>
|
||||
{LABEL_PREFIXES.map(c => (
|
||||
<td key={c} title={`(${r}, ${c})`} style={{backgroundColor: fn(r, c)}}></td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
51
client/app/scripts/components/metric-selector-item.js
Normal file
51
client/app/scripts/components/metric-selector-item.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { selectMetric, pinMetric, unpinMetric } from '../actions/app-actions';
|
||||
|
||||
|
||||
export class MetricSelectorItem extends React.Component {
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.onMouseOver = this.onMouseOver.bind(this);
|
||||
this.onMouseClick = this.onMouseClick.bind(this);
|
||||
}
|
||||
|
||||
onMouseOver() {
|
||||
const k = this.props.metric.get('id');
|
||||
selectMetric(k);
|
||||
}
|
||||
|
||||
onMouseClick() {
|
||||
const k = this.props.metric.get('id');
|
||||
const pinnedMetric = this.props.pinnedMetric;
|
||||
|
||||
if (k === pinnedMetric) {
|
||||
unpinMetric(k);
|
||||
} else {
|
||||
pinMetric(k);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {metric, selectedMetric, pinnedMetric} = this.props;
|
||||
const id = metric.get('id');
|
||||
const isPinned = (id === pinnedMetric);
|
||||
const isSelected = (id === selectedMetric);
|
||||
const className = classNames('metric-selector-action', {
|
||||
'metric-selector-action-selected': isSelected
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className={className}
|
||||
onMouseOver={this.onMouseOver}
|
||||
onClick={this.onMouseClick}>
|
||||
{metric.get('label')}
|
||||
{isPinned && <span className="fa fa-thumb-tack"></span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
34
client/app/scripts/components/metric-selector.js
Normal file
34
client/app/scripts/components/metric-selector.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { selectMetric } from '../actions/app-actions';
|
||||
import { MetricSelectorItem } from './metric-selector-item';
|
||||
|
||||
|
||||
export default class MetricSelector extends React.Component {
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
this.onMouseOut = this.onMouseOut.bind(this);
|
||||
}
|
||||
|
||||
onMouseOut() {
|
||||
selectMetric(this.props.pinnedMetric);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {availableCanvasMetrics} = this.props;
|
||||
|
||||
const items = availableCanvasMetrics.map(metric => (
|
||||
<MetricSelectorItem key={metric.get('id')} metric={metric} {...this.props} />
|
||||
));
|
||||
|
||||
return (
|
||||
<div
|
||||
className="metric-selector">
|
||||
<div className="metric-selector-wrapper" onMouseLeave={this.onMouseOut}>
|
||||
{items}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ const ACTION_TYPES = [
|
||||
'ENTER_NODE',
|
||||
'LEAVE_EDGE',
|
||||
'LEAVE_NODE',
|
||||
'PIN_METRIC',
|
||||
'UNPIN_METRIC',
|
||||
'OPEN_WEBSOCKET',
|
||||
'RECEIVE_CONTROL_PIPE',
|
||||
'RECEIVE_CONTROL_PIPE_STATUS',
|
||||
@@ -33,7 +35,8 @@ const ACTION_TYPES = [
|
||||
'RECEIVE_TOPOLOGIES',
|
||||
'RECEIVE_API_DETAILS',
|
||||
'RECEIVE_ERROR',
|
||||
'ROUTE_TOPOLOGY'
|
||||
'ROUTE_TOPOLOGY',
|
||||
'SELECT_METRIC'
|
||||
];
|
||||
|
||||
export default _.zipObject(ACTION_TYPES, ACTION_TYPES);
|
||||
|
||||
@@ -8,3 +8,5 @@ export const DETAILS_PANEL_MARGINS = {
|
||||
};
|
||||
|
||||
export const DETAILS_PANEL_OFFSET = 8;
|
||||
|
||||
export const CANVAS_METRIC_FONT_SIZE = 0.19;
|
||||
|
||||
3
client/app/scripts/debug.js
Normal file
3
client/app/scripts/debug.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import Immutable from 'immutable';
|
||||
import installDevTools from 'immutable-devtools';
|
||||
installDevTools(Immutable);
|
||||
@@ -29,7 +29,8 @@ function makeNode(node) {
|
||||
pseudo: node.pseudo,
|
||||
stack: node.stack,
|
||||
shape: node.shape,
|
||||
adjacency: node.adjacency
|
||||
adjacency: node.adjacency,
|
||||
metrics: node.metrics
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,6 +59,14 @@ let controlPipes = makeOrderedMap(); // pipeId -> controlPipe
|
||||
let updatePausedAt = null; // Date
|
||||
let websocketClosed = true;
|
||||
|
||||
let selectedMetric = null;
|
||||
let pinnedMetric = selectedMetric;
|
||||
// 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.
|
||||
let pinnedMetricType = null;
|
||||
let availableCanvasMetrics = makeList();
|
||||
|
||||
|
||||
const topologySorter = topology => topology.get('rank');
|
||||
|
||||
// adds ID field to topology (based on last part of URL path) and save urls in
|
||||
@@ -137,6 +146,7 @@ export class AppStore extends Store {
|
||||
controlPipe: this.getControlPipe(),
|
||||
nodeDetails: this.getNodeDetailsState(),
|
||||
selectedNodeId,
|
||||
pinnedMetricType,
|
||||
topologyId: currentTopologyId,
|
||||
topologyOptions: topologyOptions.toJS() // all options
|
||||
};
|
||||
@@ -163,6 +173,22 @@ export class AppStore extends Store {
|
||||
return adjacentNodes;
|
||||
}
|
||||
|
||||
getPinnedMetric() {
|
||||
return pinnedMetric;
|
||||
}
|
||||
|
||||
getSelectedMetric() {
|
||||
return selectedMetric;
|
||||
}
|
||||
|
||||
getAvailableCanvasMetrics() {
|
||||
return availableCanvasMetrics;
|
||||
}
|
||||
|
||||
getAvailableCanvasMetricsTypes() {
|
||||
return makeMap(this.getAvailableCanvasMetrics().map(m => [m.get('id'), m.get('label')]));
|
||||
}
|
||||
|
||||
getControlStatus() {
|
||||
return controlStatus.toJS();
|
||||
}
|
||||
@@ -380,6 +406,7 @@ export class AppStore extends Store {
|
||||
setTopology(payload.topologyId);
|
||||
nodes = nodes.clear();
|
||||
}
|
||||
availableCanvasMetrics = makeList();
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
@@ -390,6 +417,7 @@ export class AppStore extends Store {
|
||||
setTopology(payload.topologyId);
|
||||
nodes = nodes.clear();
|
||||
}
|
||||
availableCanvasMetrics = makeList();
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
@@ -400,6 +428,24 @@ export class AppStore extends Store {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ActionTypes.SELECT_METRIC: {
|
||||
selectedMetric = payload.metricId;
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
case ActionTypes.PIN_METRIC: {
|
||||
pinnedMetric = payload.metricId;
|
||||
pinnedMetricType = this.getAvailableCanvasMetricsTypes().get(payload.metricId);
|
||||
selectedMetric = payload.metricId;
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
case ActionTypes.UNPIN_METRIC: {
|
||||
pinnedMetric = null;
|
||||
pinnedMetricType = null;
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
case ActionTypes.DESELECT_NODE: {
|
||||
closeNodeDetails();
|
||||
this.__emitChange();
|
||||
@@ -555,7 +601,7 @@ export class AppStore extends Store {
|
||||
// update existing nodes
|
||||
_.each(payload.delta.update, (node) => {
|
||||
if (nodes.has(node.id)) {
|
||||
nodes = nodes.set(node.id, nodes.get(node.id).merge(makeNode(node)));
|
||||
nodes = nodes.set(node.id, nodes.get(node.id).merge(fromJS(node)));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -564,6 +610,23 @@ export class AppStore extends Store {
|
||||
nodes = nodes.set(node.id, fromJS(makeNode(node)));
|
||||
});
|
||||
|
||||
availableCanvasMetrics = 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 = availableCanvasMetrics
|
||||
.find(m => m.get('label') === pinnedMetricType);
|
||||
pinnedMetric = similarTypeMetric && similarTypeMetric.get('id');
|
||||
// if something in the current topo is not already selected, select it.
|
||||
if (!availableCanvasMetrics.map(m => m.get('id')).toSet().has(selectedMetric)) {
|
||||
selectedMetric = pinnedMetric;
|
||||
}
|
||||
|
||||
if (emitChange) {
|
||||
this.__emitChange();
|
||||
}
|
||||
@@ -590,6 +653,7 @@ export class AppStore extends Store {
|
||||
setDefaultTopologyOptions(topologies);
|
||||
}
|
||||
topologiesLoaded = true;
|
||||
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
@@ -608,6 +672,7 @@ export class AppStore extends Store {
|
||||
setTopology(payload.state.topologyId);
|
||||
setDefaultTopologyOptions(topologies);
|
||||
selectedNodeId = payload.state.selectedNodeId;
|
||||
pinnedMetricType = payload.state.pinnedMetricType;
|
||||
if (payload.state.controlPipe) {
|
||||
controlPipes = makeOrderedMap({
|
||||
[payload.state.controlPipe.id]:
|
||||
|
||||
@@ -23,7 +23,7 @@ function text2degree(text) {
|
||||
return hueScale(num);
|
||||
}
|
||||
|
||||
function colors(text, secondText) {
|
||||
export function colors(text, secondText) {
|
||||
let hue = text2degree(text);
|
||||
// skip green and shift to the end of the color wheel
|
||||
if (hue > 70 && hue < 150) {
|
||||
|
||||
136
client/app/scripts/utils/data-generator-utils.js
Normal file
136
client/app/scripts/utils/data-generator-utils.js
Normal file
@@ -0,0 +1,136 @@
|
||||
import _ from 'lodash';
|
||||
import d3 from 'd3';
|
||||
|
||||
|
||||
// Inspired by Lee Byron's test data generator.
|
||||
/*eslint-disable */
|
||||
function bumpLayer(n, maxValue) {
|
||||
function bump(a) {
|
||||
const x = 1 / (0.1 + Math.random());
|
||||
const y = 2 * Math.random() - 0.5;
|
||||
const z = 10 / (0.1 + Math.random());
|
||||
for (let i = 0; i < n; i++) {
|
||||
const w = (i / n - y) * z;
|
||||
a[i] += x * Math.exp(-w * w);
|
||||
}
|
||||
}
|
||||
|
||||
const a = [];
|
||||
let i;
|
||||
for (i = 0; i < n; ++i) a[i] = 0;
|
||||
for (i = 0; i < 5; ++i) bump(a);
|
||||
const values = a.map(function(d) { return Math.max(0, d * maxValue); });
|
||||
const s = d3.scale.linear().domain(d3.extent(values)).range([0, maxValue]);
|
||||
return values.map(s);
|
||||
}
|
||||
/*eslint-enable */
|
||||
|
||||
|
||||
const nodeData = {};
|
||||
|
||||
|
||||
function getNextValue(keyValues, maxValue) {
|
||||
const key = keyValues.join('-');
|
||||
let series = nodeData[key];
|
||||
if (!series || !series.length) {
|
||||
series = nodeData[key] = bumpLayer(100, maxValue);
|
||||
}
|
||||
const v = series.shift();
|
||||
return v;
|
||||
}
|
||||
|
||||
export const METRIC_LABELS = {
|
||||
docker_cpu_total_usage: 'CPU',
|
||||
docker_memory_usage: 'Memory',
|
||||
host_cpu_usage_percent: 'CPU',
|
||||
host_mem_usage_bytes: 'Memory',
|
||||
load1: 'Load 1',
|
||||
load15: 'Load 15',
|
||||
load5: 'Load 5',
|
||||
open_files_count: 'Open files',
|
||||
process_cpu_usage_percent: 'CPU',
|
||||
process_memory_usage_bytes: 'Memory'
|
||||
};
|
||||
|
||||
|
||||
export function label(m) {
|
||||
return METRIC_LABELS[m.id];
|
||||
}
|
||||
|
||||
|
||||
const memoryMetric = (node, name, max = 1024 * 1024 * 1024) => ({
|
||||
samples: [{value: getNextValue([node.id, name], max)}],
|
||||
max
|
||||
});
|
||||
|
||||
const cpuMetric = (node, name, max = 100) => ({
|
||||
samples: [{value: getNextValue([node.id, name], max)}],
|
||||
max
|
||||
});
|
||||
|
||||
const fileMetric = (node, name, max = 1000) => ({
|
||||
samples: [{value: getNextValue([node.id, name], max)}],
|
||||
max
|
||||
});
|
||||
|
||||
const loadMetric = (node, name, max = 10) => ({
|
||||
samples: [{value: getNextValue([node.id, name], max)}],
|
||||
max
|
||||
});
|
||||
|
||||
const metrics = {
|
||||
// process
|
||||
square: {
|
||||
process_cpu_usage_percent: cpuMetric,
|
||||
process_memory_usage_bytes: memoryMetric,
|
||||
open_files_count: fileMetric
|
||||
},
|
||||
// container
|
||||
hexagon: {
|
||||
docker_cpu_total_usage: cpuMetric,
|
||||
docker_memory_usage: memoryMetric
|
||||
},
|
||||
// host
|
||||
circle: {
|
||||
load5: loadMetric,
|
||||
host_cpu_usage_percent: cpuMetric,
|
||||
host_mem_usage_bytes: memoryMetric
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
function mergeMetrics(node) {
|
||||
if (node.pseudo || node.stack) {
|
||||
return node;
|
||||
}
|
||||
return Object.assign({}, node, {
|
||||
metrics: _(metrics[node.shape])
|
||||
.map((fn, name) => [name, fn(node)])
|
||||
.fromPairs()
|
||||
.value()
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function handleAdd(nodes) {
|
||||
if (!nodes) {
|
||||
return nodes;
|
||||
}
|
||||
return nodes.map(mergeMetrics);
|
||||
}
|
||||
|
||||
|
||||
function handleUpdated(updatedNodes, prevNodes) {
|
||||
const modifiedNodesIndex = _.zipObject((updatedNodes || []).map(n => [n.id, n]));
|
||||
return prevNodes.toIndexedSeq().toJS().map(n => (
|
||||
Object.assign({}, mergeMetrics(n), modifiedNodesIndex[n.id])
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
export function addMetrics(delta, prevNodes) {
|
||||
return Object.assign({}, delta, {
|
||||
add: handleAdd(delta.add),
|
||||
update: handleUpdated(delta.update, prevNodes)
|
||||
});
|
||||
}
|
||||
22
client/app/scripts/utils/math-utils.js
Normal file
22
client/app/scripts/utils/math-utils.js
Normal file
@@ -0,0 +1,22 @@
|
||||
|
||||
// http://stackoverflow.com/questions/4467539/javascript-modulo-not-behaving
|
||||
//
|
||||
// A modulo that "behaves" w/ negatives.
|
||||
//
|
||||
// modulo(5, 5) => 0
|
||||
// modulo(4, 5) => 4
|
||||
// modulo(3, 5) => 3
|
||||
// modulo(2, 5) => 2
|
||||
// modulo(1, 5) => 1
|
||||
// modulo(0, 5) => 0
|
||||
// modulo(-1, 5) => 4
|
||||
// modulo(-2, 5) => 3
|
||||
// modulo(-2, 5) => 3
|
||||
// modulo(-3, 5) => 2
|
||||
// modulo(-4, 5) => 1
|
||||
// modulo(-5, 5) => 0
|
||||
//
|
||||
export function modulo(i, n) {
|
||||
return ((i % n) + n) % n;
|
||||
}
|
||||
|
||||
80
client/app/scripts/utils/metric-utils.js
Normal file
80
client/app/scripts/utils/metric-utils.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import _ from 'lodash';
|
||||
import d3 from 'd3';
|
||||
import { formatMetricSvg } from './string-utils';
|
||||
import { colors } from './color-utils';
|
||||
import React from 'react';
|
||||
|
||||
|
||||
export function getClipPathDefinition(clipId, size, height,
|
||||
x = -size * 0.5, y = size * 0.5 - height) {
|
||||
return (
|
||||
<defs>
|
||||
<clipPath id={clipId}>
|
||||
<rect
|
||||
width={size}
|
||||
height={size}
|
||||
x={x}
|
||||
y={y}
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// Open files, 100k should be enought for anyone?
|
||||
const openFilesScale = d3.scale.log().domain([1, 100000]).range([0, 1]);
|
||||
//
|
||||
// loadScale(1) == 0.5; E.g. a nicely balanced system :).
|
||||
const loadScale = d3.scale.log().domain([0.01, 100]).range([0, 1]);
|
||||
|
||||
|
||||
export function getMetricValue(metric, size) {
|
||||
if (!metric) {
|
||||
return {height: 0, value: null, formattedValue: 'n/a'};
|
||||
}
|
||||
const m = metric.toJS();
|
||||
const value = m.value;
|
||||
|
||||
let valuePercentage = value === 0 ? 0 : value / m.max;
|
||||
let max = m.max;
|
||||
if (m.id === 'open_files_count') {
|
||||
valuePercentage = openFilesScale(value);
|
||||
max = null;
|
||||
} else if (_.includes(['load1', 'load5', 'load15'], m.id)) {
|
||||
valuePercentage = loadScale(value);
|
||||
max = null;
|
||||
}
|
||||
|
||||
let displayedValue = Number(value).toFixed(1);
|
||||
if (displayedValue > 0 && (!max || displayedValue < max)) {
|
||||
const baseline = 0.1;
|
||||
displayedValue = valuePercentage * (1 - baseline * 2) + baseline;
|
||||
} else if (displayedValue >= m.max && displayedValue > 0) {
|
||||
displayedValue = 1;
|
||||
}
|
||||
const height = size * displayedValue;
|
||||
|
||||
return {
|
||||
height,
|
||||
hasMetric: value !== null,
|
||||
formattedValue: formatMetricSvg(value, m)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export function getMetricColor(metric) {
|
||||
const selectedMetric = metric && metric.get('id');
|
||||
if (/mem/.test(selectedMetric)) {
|
||||
return 'steelBlue';
|
||||
} else if (/cpu/.test(selectedMetric)) {
|
||||
return colors('cpu');
|
||||
} else if (/files/.test(selectedMetric)) {
|
||||
// purple
|
||||
return '#9467bd';
|
||||
} else if (/load/.test(selectedMetric)) {
|
||||
return colors('load');
|
||||
}
|
||||
return 'steelBlue';
|
||||
}
|
||||
@@ -2,45 +2,65 @@ import React from 'react';
|
||||
import filesize from 'filesize';
|
||||
import d3 from 'd3';
|
||||
|
||||
|
||||
const formatLargeValue = d3.format('s');
|
||||
|
||||
const formatters = {
|
||||
filesize(value) {
|
||||
const obj = filesize(value, {output: 'object'});
|
||||
return formatters.metric(obj.value, obj.suffix);
|
||||
},
|
||||
|
||||
integer(value) {
|
||||
if (value < 1100 && value >= 0) {
|
||||
return Number(value).toFixed(0);
|
||||
}
|
||||
return formatLargeValue(value);
|
||||
},
|
||||
|
||||
number(value) {
|
||||
if (value < 1100 && value >= 0) {
|
||||
return Number(value).toFixed(2);
|
||||
}
|
||||
return formatLargeValue(value);
|
||||
},
|
||||
|
||||
percent(value) {
|
||||
return formatters.metric(formatters.number(value), '%');
|
||||
},
|
||||
|
||||
metric(text, unit) {
|
||||
return (
|
||||
<span className="metric-formatted">
|
||||
<span className="metric-value">{text}</span>
|
||||
<span className="metric-unit">{unit}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export function formatMetric(value, opts) {
|
||||
const formatter = opts && formatters[opts.format] ? opts.format : 'number';
|
||||
return formatters[formatter](value);
|
||||
function renderHtml(text, unit) {
|
||||
return (
|
||||
<span className="metric-formatted">
|
||||
<span className="metric-value">{text}</span>
|
||||
<span className="metric-unit">{unit}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function renderSvg(text, unit) {
|
||||
return `${text}${unit}`;
|
||||
}
|
||||
|
||||
|
||||
function makeFormatters(renderFn) {
|
||||
const formatters = {
|
||||
filesize(value) {
|
||||
const obj = filesize(value, {output: 'object', round: 1});
|
||||
return renderFn(obj.value, obj.suffix);
|
||||
},
|
||||
|
||||
integer(value) {
|
||||
const intNumber = Number(value).toFixed(0);
|
||||
if (value < 1100 && value >= 0) {
|
||||
return intNumber;
|
||||
}
|
||||
return formatLargeValue(intNumber);
|
||||
},
|
||||
|
||||
number(value) {
|
||||
if (value < 1100 && value >= 0) {
|
||||
return Number(value).toFixed(2);
|
||||
}
|
||||
return formatLargeValue(value);
|
||||
},
|
||||
|
||||
percent(value) {
|
||||
return renderFn(formatters.number(value), '%');
|
||||
}
|
||||
};
|
||||
|
||||
return formatters;
|
||||
}
|
||||
|
||||
|
||||
function makeFormatMetric(renderFn) {
|
||||
const formatters = makeFormatters(renderFn);
|
||||
return (value, opts) => {
|
||||
const formatter = opts && formatters[opts.format] ? opts.format : 'number';
|
||||
return formatters[formatter](value);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export const formatMetric = makeFormatMetric(renderHtml);
|
||||
export const formatMetricSvg = makeFormatMetric(renderSvg);
|
||||
export const formatDate = d3.time.format.iso;
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
@base-font: "Roboto", sans-serif;
|
||||
@mono-font: "Menlo", "DejaVu Sans Mono", "Liberation Mono", monospace;
|
||||
|
||||
@base-ease: ease-in-out;
|
||||
|
||||
@primary-color: @weave-charcoal-blue;
|
||||
@background-color: lighten(@primary-color, 66%);
|
||||
@background-lighter-color: lighten(@background-color, 8%);
|
||||
@@ -65,15 +67,15 @@
|
||||
}
|
||||
|
||||
.colorable {
|
||||
transition: background-color .3s ease-in-out;
|
||||
transition: background-color .3s @base-ease;
|
||||
}
|
||||
|
||||
.palable {
|
||||
transition: all .2s ease-in-out;
|
||||
transition: all .2s @base-ease;
|
||||
}
|
||||
|
||||
.hideable {
|
||||
transition: opacity .5s ease-in-out;
|
||||
transition: opacity .5s @base-ease;
|
||||
}
|
||||
|
||||
.hang-around {
|
||||
@@ -221,7 +223,7 @@ h2 {
|
||||
|
||||
&-active {
|
||||
border: 1px solid @text-tertiary-color;
|
||||
animation: blinking 1.5s infinite ease-in-out;
|
||||
animation: blinking 1.5s infinite @base-ease;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,7 +335,7 @@ h2 {
|
||||
|
||||
.nodes > .node {
|
||||
cursor: pointer;
|
||||
transition: opacity .5s ease-in-out;
|
||||
transition: opacity .5s @base-ease;
|
||||
|
||||
&.pseudo {
|
||||
cursor: default;
|
||||
@@ -362,7 +364,7 @@ h2 {
|
||||
}
|
||||
|
||||
.edge {
|
||||
transition: opacity .5s ease-in-out;
|
||||
transition: opacity .5s @base-ease;
|
||||
|
||||
&.blurred {
|
||||
opacity: @edge-opacity-blurred;
|
||||
@@ -387,12 +389,39 @@ h2 {
|
||||
}
|
||||
}
|
||||
|
||||
.shape {
|
||||
.stack .shape .highlighted {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stack .onlyHighlight .shape {
|
||||
.border { display: none; }
|
||||
.shadow { display: none; }
|
||||
.node { display: none; }
|
||||
.highlighted { display: inline; }
|
||||
}
|
||||
|
||||
.stack .shape .metric-fill {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shape {
|
||||
/* cloud paths have stroke-width set dynamically */
|
||||
&:not(.shape-cloud) .border {
|
||||
stroke-width: @node-border-stroke-width;
|
||||
fill: @background-color;
|
||||
transition: stroke-opacity 0.333s @base-ease, fill 0.333s @base-ease;
|
||||
stroke-opacity: 1;
|
||||
}
|
||||
|
||||
&.metrics .border {
|
||||
fill: @background-lighter-color;
|
||||
stroke-opacity: 0.3;
|
||||
}
|
||||
|
||||
.metric-fill {
|
||||
stroke: none;
|
||||
fill: #A0BE7E;
|
||||
fill-opacity: 0.7;
|
||||
}
|
||||
|
||||
.shadow {
|
||||
@@ -402,6 +431,14 @@ h2 {
|
||||
|
||||
.node {
|
||||
fill: @text-color;
|
||||
stroke: @background-lighter-color;
|
||||
stroke-width: 2px;
|
||||
}
|
||||
|
||||
text {
|
||||
font-size: 12px;
|
||||
dominant-baseline: middle;
|
||||
text-anchor: middle;
|
||||
}
|
||||
|
||||
.highlighted {
|
||||
@@ -569,7 +606,7 @@ h2 {
|
||||
|
||||
&-icon {
|
||||
margin-right: 0.5em;
|
||||
animation: blinking 2.0s infinite ease-in-out;
|
||||
animation: blinking 2.0s infinite @base-ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -957,56 +994,61 @@ h2 {
|
||||
}
|
||||
|
||||
&.status-loading {
|
||||
animation: blinking 2.0s infinite ease-in-out;
|
||||
animation: blinking 2.0s infinite @base-ease;
|
||||
text-transform: none;
|
||||
color: @text-color;
|
||||
}
|
||||
}
|
||||
|
||||
.topology-options {
|
||||
.topology-option, .metric-selector {
|
||||
color: @text-secondary-color;
|
||||
margin: 6px 0;
|
||||
|
||||
.topology-option {
|
||||
color: @text-secondary-color;
|
||||
margin: 6px 0;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&-wrapper {
|
||||
border-radius: @border-radius;
|
||||
border: 1px solid @background-darker-color;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&-action {
|
||||
.btn-opacity;
|
||||
padding: 3px 12px;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
|
||||
&-selected, &:hover {
|
||||
color: @text-darker-color;
|
||||
background-color: @background-darker-color;
|
||||
}
|
||||
|
||||
&-selected {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-left: none;
|
||||
border-top-left-radius: @border-radius;
|
||||
border-bottom-left-radius: @border-radius;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-top-right-radius: @border-radius;
|
||||
border-bottom-right-radius: @border-radius;
|
||||
}
|
||||
}
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.fa {
|
||||
margin-left: 4px;
|
||||
color: darkred;
|
||||
}
|
||||
|
||||
&-wrapper {
|
||||
border-radius: @border-radius;
|
||||
border: 1px solid @background-darker-color;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&-action {
|
||||
.btn-opacity;
|
||||
padding: 3px 12px;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
|
||||
&-selected, &:hover {
|
||||
color: @text-darker-color;
|
||||
background-color: @background-darker-color;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-left: none;
|
||||
border-top-left-radius: @border-radius;
|
||||
border-bottom-left-radius: @border-radius;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-top-right-radius: @border-radius;
|
||||
border-bottom-right-radius: @border-radius;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.topology-option {
|
||||
&-action {
|
||||
&-selected {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
@@ -1031,9 +1073,26 @@ h2 {
|
||||
.debug-panel {
|
||||
.shadow-2;
|
||||
background-color: #fff;
|
||||
top: 10px;
|
||||
top: 80px;
|
||||
position: absolute;
|
||||
padding: 10px;
|
||||
left: 10px;
|
||||
z-index: 10000;
|
||||
|
||||
opacity: 0.3;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
table {
|
||||
display: inline-block;
|
||||
border-collapse: collapse;
|
||||
margin: 4px 2px;
|
||||
|
||||
td {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"eslint-plugin-react": "4.2.2",
|
||||
"file-loader": "0.8.5",
|
||||
"http-proxy-rules": "^1.0.1",
|
||||
"immutable-devtools": "0.0.6",
|
||||
"jest-cli": "~0.9.2",
|
||||
"json-loader": "0.5.4",
|
||||
"less": "~2.6.1",
|
||||
@@ -67,7 +68,9 @@
|
||||
"test": "jest",
|
||||
"coveralls": "cat coverage/lcov.info | coveralls",
|
||||
"lint": "eslint app",
|
||||
"clean": "rm build/app.js"
|
||||
"clean": "rm build/app.js",
|
||||
"noprobe": "../scope stop && ../scope launch --no-probe --app.window 8760h",
|
||||
"loadreport": "npm run noprobe && sleep 1 && curl -X POST -H \"Content-Type: application/json\" http://$BACKEND_HOST/api/report -d"
|
||||
},
|
||||
"jest": {
|
||||
"scriptPreprocessor": "<rootDir>/node_modules/babel-jest",
|
||||
|
||||
@@ -26,7 +26,8 @@ module.exports = {
|
||||
'app': [
|
||||
'./app/scripts/main',
|
||||
'webpack-dev-server/client?http://localhost:4041',
|
||||
'webpack/hot/only-dev-server'
|
||||
'webpack/hot/only-dev-server',
|
||||
'./app/scripts/debug'
|
||||
],
|
||||
'contrast-app': [
|
||||
'./app/scripts/contrast-main',
|
||||
|
||||
Reference in New Issue
Block a user