Metrics on canvas!

- Adds metric on canvas support for more shapes
- More variation from the debug bar.
This commit is contained in:
Simon Howe
2016-03-01 17:27:29 +01:00
parent 7643d7a620
commit d31aadf7b1
12 changed files with 269 additions and 84 deletions

View File

@@ -238,7 +238,7 @@ export function receiveNodesDelta(delta) {
} else {
AppDispatcher.dispatch({
type: ActionTypes.RECEIVE_NODES_DELTA,
delta
delta: delta
});
}
}

View File

@@ -1,22 +1,32 @@
import React from 'react';
import {formatCanvasMetric, getMetricValue} from '../utils/data-utils.js';
export default function NodeShapeCircle({onlyHighlight, highlighted, size, color}) {
export default function NodeShapeCircle({highlighted, size, color, metrics}) {
const hightlightNode = <circle r={size * 0.7} className="highlighted" />;
if (onlyHighlight) {
return (
<g className="shape">
{highlighted && hightlightNode}
</g>
);
}
const clipId = `mask-${Math.random()}`;
const {height, vp} = getMetricValue(metrics, size);
return (
<g className="shape">
<defs>
<clipPath id={clipId}>
<rect
width={size}
height={size}
x={-size * 0.5}
y={size * 0.5 - height}
/>
</clipPath>
</defs>
{highlighted && hightlightNode}
<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" />
<circle r={size * 0.45} className="metric-fill" clipPath={`url(#${clipId})`} />
{highlighted ?
<text dy="0.35em" style={{'textAnchor': 'middle'}}>
{formatCanvasMetric(vp)}
</text> :
<circle className="node" r={Math.max(2, (size * 0.125))} />}
</g>
);
}

View File

@@ -1,5 +1,6 @@
import React from 'react';
import d3 from 'd3';
import {formatCanvasMetric, getMetricValue} from '../utils/data-utils.js';
const line = d3.svg.line()
.interpolate('cardinal-closed')
@@ -14,29 +15,37 @@ function polygon(r, sides) {
return points;
}
export default function NodeShapeHeptagon({onlyHighlight, highlighted, size, color}) {
export default function NodeShapeHeptagon({highlighted, size, color, metrics}) {
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-${Math.random()}`;
const {height, vp} = getMetricValue(metrics, size);
return (
<g className="shape">
{highlighted && hightlightNode}
<defs>
<clipPath id={clipId}>
<rect
width={size}
height={size}
y={-size * 0.5}
x={size * 0.5 - height}
/>
</clipPath>
</defs>
{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))} />
<path className="metric-fill" clipPath={`url(#${clipId})`} {...pathProps(0.45)} />
{highlighted ?
<text dy="0.35em" style={{'textAnchor': 'middle'}}>
{formatCanvasMetric(vp)}
</text> :
<circle className="node" r={Math.max(2, (size * 0.125))} />}
</g>
);
}

View File

@@ -1,5 +1,6 @@
import React from 'react';
import d3 from 'd3';
import {formatCanvasMetric, getMetricValue} from '../utils/data-utils.js';
const line = d3.svg.line()
.interpolate('cardinal-closed')
@@ -24,28 +25,38 @@ function getPoints(h) {
}
export default function NodeShapeHex({onlyHighlight, highlighted, size, color}) {
export default function NodeShapeHex({highlighted, size, color, metrics}) {
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-${Math.random()}`;
const {height, vp} = getMetricValue(metrics, size);
return (
<g className="shape">
{highlighted && hightlightNode}
<defs>
<clipPath id={clipId}>
<rect
width={size}
height={size}
x={size - height + upperHexBitHeight}
/>
</clipPath>
</defs>
{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)} />
<path className="metric-fill" clipPath={`url(#${clipId})`} {...pathProps(shadowSize)} />
{highlighted ?
<text dy="0.35em" style={{'textAnchor': 'middle'}}>
{formatCanvasMetric(vp)}
</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,42 @@
import React from 'react';
import {formatCanvasMetric, getMetricValue} from '../utils/data-utils.js';
export default function NodeShapeSquare({onlyHighlight, highlighted, size, color, rx = 0, ry = 0}) {
export default function NodeShapeSquare({
highlighted, size, color, rx = 0, ry = 0, metrics
}) {
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})`
x: -size * v,
y: -size * v
});
const hightlightNode = <rect className="highlighted" {...rectProps(0.7)} />;
if (onlyHighlight) {
return (
<g className="shape">
{highlighted && hightlightNode}
</g>
);
}
const clipId = `mask-${Math.random()}`;
const {height, vp} = getMetricValue(metrics, size);
return (
<g className="shape">
{highlighted && hightlightNode}
<defs>
<clipPath id={clipId}>
<rect
width={size}
height={size}
x={-size * 0.5}
y={size * 0.5 - height}
/>
</clipPath>
</defs>
{highlighted && <rect className="highlighted" {...rectProps(0.7)} />}
<rect className="border" stroke={color} {...rectProps(0.5)} />
<rect className="shadow" {...rectProps(0.45)} />
<circle className="node" r={Math.max(2, (size * 0.125))} />
<rect className="metric-fill" clipPath={`url(#${clipId})`} {...rectProps(0.45)} />
{highlighted ?
<text dy="0.35em" style={{'textAnchor': 'middle'}}>
{formatCanvasMetric(vp)}
</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,16 @@ 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>
<Shape {...propsNoHighlight} />
<Shape {...props} />
</g>
);
}

View File

@@ -74,10 +74,10 @@ export default class NodesChart extends React.Component {
edges: makeMap()
});
}
//
// FIXME add PureRenderMixin, Immutables, and move the following functions to render()
if (nextProps.forceRelayout || nextProps.nodes !== this.props.nodes) {
_.assign(state, this.updateGraphState(nextProps, state));
}
_.assign(state, this.updateGraphState(nextProps, state));
if (this.props.selectedNodeId !== nextProps.selectedNodeId) {
_.assign(state, this.restoreLayout(state));
}
@@ -151,6 +151,7 @@ export default class NodesChart extends React.Component {
pseudo={node.get('pseudo')}
nodeCount={node.get('nodeCount')}
subLabel={node.get('subLabel')}
metrics={node.get('metrics')}
rank={node.get('rank')}
selectedNodeScale={selectedNodeScale}
nodeScale={nodeScale}
@@ -243,9 +244,10 @@ export default class NodesChart extends React.Component {
initNodes(topology) {
// copy relevant fields to state nodes
return topology.map((node, id) => makeMap({
id,
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 +425,9 @@ export default class NodesChart extends React.Component {
if (!graph) {
return {maxNodesExceeded: true};
}
stateNodes = graph.nodes;
stateNodes = graph.nodes.mergeDeep(stateNodes.map(node => {
return makeMap({metrics: node.get('metrics')});
}));
stateEdges = graph.edges;
// save coordinates for restore

View File

@@ -8,12 +8,21 @@ const log = debug('scope:debug-panel');
import { receiveNodesDelta } from '../actions/app-actions';
import AppStore from '../stores/app-store';
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 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 deltaAdd = (name, adjacency = [], shape = 'circle', stack = false, nodeCount = 1) => ({
adjacency,
controls: {},
@@ -29,11 +38,20 @@ const deltaAdd = (name, adjacency = [], shape = 'circle', stack = false, nodeCou
rank: 'alpine'
});
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 => {
return SHAPES.map(s => {
if (!stack) return [deltaAdd(label(s, stack), [], s, stack, 1)];
return NODE_COUNTS.map(n => {
return deltaAdd(label(s, stack), [], s, stack, n);
});
});
}));
receiveNodesDelta({
add: newNodes
@@ -43,15 +61,20 @@ function addAllVariants() {
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 newNodeNames = _.range(ns.size, ns.size + n).map(() => {
return _.sample(LABEL_PREFIXES, 2).join('') + '-zing';
});
const allNodes = _(nodeNames).concat(newNodeNames).value();
receiveNodesDelta({
add: newNodeNames.map((name) => deltaAdd(name,
sample(allNodes)),
_.sample(SHAPES),
_.sample(STACK_VARIANTS),
_.sample(NODE_COUNTS))
add: newNodeNames.map((name) => {
return deltaAdd(
name,
sample(allNodes),
_.sample(SHAPES),
_.sample(STACK_VARIANTS),
_.sample(NODE_COUNTS));
})
});
}

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
};
}
@@ -553,9 +554,9 @@ export class AppStore extends Store {
});
// update existing nodes
_.each(payload.delta.update, (node) => {
_.each(payload.delta.update, function(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(Immutable.fromJS(node)));
}
});

View File

@@ -0,0 +1,102 @@
import _ from 'lodash';
import d3 from 'd3';
// Inspired by Lee Byron's test data generator.
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);
}
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;
}
function mergeMetrics(node) {
return Object.assign({}, node, {
metrics: {
'process_cpu_usage_percent': {
samples: [{value: getNextValue([node.id, 'cpu'], 100)}],
max: 100
},
'memory': {
samples: [{value: getNextValue([node.id, 'memory'], 1024)}],
max: 1024
}
}
});
}
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 => {
return 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)
});
}
export function getMetricValue(metrics, size) {
if (!metrics) {
return {height: 0, vp: null};
}
const max = 100;
const v = metrics.getIn(['process_cpu_usage_percent', 'samples', 0, 'value']);
const vp = v === 0 ? 0 : v / max;
const baseline = 0.00;
const displayedValue = vp * (1 - baseline) + baseline;
const height = size * displayedValue;
return {height, vp};
}
const formatLargeValue = d3.format('s');
export function formatCanvasMetric(v) {
if (v === null) {
return 'n/a';
}
return formatLargeValue(Number(v * 100).toFixed(1)) + '%';
}

View File

@@ -389,12 +389,29 @@ h2 {
.shape {
.stack .shape .highlighted {
display: none;
}
.stack .onlyHighlight .shape {
.border { display: none; }
.shadow { display: none; }
.node { display: none; }
.highlighted { display: inline; }
}
.shape {
/* cloud paths have stroke-width set dynamically */
&:not(.shape-cloud) .border {
stroke-width: @node-border-stroke-width;
fill: @background-color;
}
.metric-fill {
stroke: none;
fill: yellowgreen;
}
.shadow {
stroke: none;
fill: @background-lighter-color;
@@ -402,6 +419,12 @@ h2 {
.node {
fill: @text-color;
stroke: @background-lighter-color;
stroke-width: 2px;
}
text {
font-size: 12px;
}
.highlighted {