Merge pull request #1105 from weaveworks/metrics-on-canvas

Metrics on canvas
This commit is contained in:
Simon
2016-04-04 21:05:22 +02:00
24 changed files with 902 additions and 200 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -8,3 +8,5 @@ export const DETAILS_PANEL_MARGINS = {
};
export const DETAILS_PANEL_OFFSET = 8;
export const CANVAS_METRIC_FONT_SIZE = 0.19;

View File

@@ -0,0 +1,3 @@
import Immutable from 'immutable';
import installDevTools from 'immutable-devtools';
installDevTools(Immutable);

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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