diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index 563046f88..7387cbc10 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -238,7 +238,7 @@ export function receiveNodesDelta(delta) { } else { AppDispatcher.dispatch({ type: ActionTypes.RECEIVE_NODES_DELTA, - delta + delta: delta }); } } diff --git a/client/app/scripts/charts/node-shape-circle.js b/client/app/scripts/charts/node-shape-circle.js index e4c32ade8..53d0c7dcd 100644 --- a/client/app/scripts/charts/node-shape-circle.js +++ b/client/app/scripts/charts/node-shape-circle.js @@ -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 = ; - - if (onlyHighlight) { - return ( - - {highlighted && hightlightNode} - - ); - } + const clipId = `mask-${Math.random()}`; + const {height, vp} = getMetricValue(metrics, size); return ( + + + + + {highlighted && hightlightNode} - + + {highlighted ? + + {formatCanvasMetric(vp)} + : + } ); } diff --git a/client/app/scripts/charts/node-shape-heptagon.js b/client/app/scripts/charts/node-shape-heptagon.js index 98652e5f1..41e1ce189 100644 --- a/client/app/scripts/charts/node-shape-heptagon.js +++ b/client/app/scripts/charts/node-shape-heptagon.js @@ -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 = ; - - if (onlyHighlight) { - return ( - - {highlighted && hightlightNode} - - ); - } + const clipId = `mask-${Math.random()}`; + const {height, vp} = getMetricValue(metrics, size); return ( - {highlighted && hightlightNode} + + + + + + {highlighted && } - + + {highlighted ? + + {formatCanvasMetric(vp)} + : + } ); } diff --git a/client/app/scripts/charts/node-shape-hex.js b/client/app/scripts/charts/node-shape-hex.js index 42236d8b5..dd4ab6cfb 100644 --- a/client/app/scripts/charts/node-shape-hex.js +++ b/client/app/scripts/charts/node-shape-hex.js @@ -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 = ; + const shadowSize = 0.45; + const upperHexBitHeight = -0.25 * size * shadowSize; - if (onlyHighlight) { - return ( - - {highlighted && hightlightNode} - - ); - } + const clipId = `mask-${Math.random()}`; + const {height, vp} = getMetricValue(metrics, size); return ( - {highlighted && hightlightNode} + + + + + + {highlighted && } - - + + + {highlighted ? + + {formatCanvasMetric(vp)} + : + } ); } diff --git a/client/app/scripts/charts/node-shape-rounded-square.js b/client/app/scripts/charts/node-shape-rounded-square.js index 4542595b7..dbbe4da81 100644 --- a/client/app/scripts/charts/node-shape-rounded-square.js +++ b/client/app/scripts/charts/node-shape-rounded-square.js @@ -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 ( diff --git a/client/app/scripts/charts/node-shape-square.js b/client/app/scripts/charts/node-shape-square.js index 5308a72a5..2c28e82bd 100644 --- a/client/app/scripts/charts/node-shape-square.js +++ b/client/app/scripts/charts/node-shape-square.js @@ -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 = ; - - if (onlyHighlight) { - return ( - - {highlighted && hightlightNode} - - ); - } + const clipId = `mask-${Math.random()}`; + const {height, vp} = getMetricValue(metrics, size); return ( - {highlighted && hightlightNode} + + + + + + {highlighted && } - + + {highlighted ? + + {formatCanvasMetric(vp)} + : + } ); } diff --git a/client/app/scripts/charts/node-shape-stack.js b/client/app/scripts/charts/node-shape-stack.js index e669a0d61..13b6c0f58 100644 --- a/client/app/scripts/charts/node-shape-stack.js +++ b/client/app/scripts/charts/node-shape-stack.js @@ -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 ( - - + + - + - + - + ); } diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index ee131c663..a9e411b06 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -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 diff --git a/client/app/scripts/components/debug-toolbar.js b/client/app/scripts/components/debug-toolbar.js index bdb545ec5..5c880053a 100644 --- a/client/app/scripts/components/debug-toolbar.js +++ b/client/app/scripts/components/debug-toolbar.js @@ -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)); + }) }); } diff --git a/client/app/scripts/stores/app-store.js b/client/app/scripts/stores/app-store.js index d6b266884..ebfef2e41 100644 --- a/client/app/scripts/stores/app-store.js +++ b/client/app/scripts/stores/app-store.js @@ -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))); } }); diff --git a/client/app/scripts/utils/data-utils.js b/client/app/scripts/utils/data-utils.js new file mode 100644 index 000000000..600abaffc --- /dev/null +++ b/client/app/scripts/utils/data-utils.js @@ -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)) + '%'; +} diff --git a/client/app/styles/main.less b/client/app/styles/main.less index ff19b8562..e9d80b75b 100644 --- a/client/app/styles/main.less +++ b/client/app/styles/main.less @@ -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 {