diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index 563046f88..401ad9a54 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -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, diff --git a/client/app/scripts/charts/node-shape-circle.js b/client/app/scripts/charts/node-shape-circle.js index e4c32ade8..dcf2327eb 100644 --- a/client/app/scripts/charts/node-shape-circle.js +++ b/client/app/scripts/charts/node-shape-circle.js @@ -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 = ; - if (onlyHighlight) { - return ( - - {highlighted && hightlightNode} - - ); - } +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 ( - - {highlighted && hightlightNode} + + {hasMetric && getClipPathDefinition(clipId, size, height)} + {highlighted && } - + {hasMetric && } + {highlighted && hasMetric ? + {formattedValue} : + } ); } diff --git a/client/app/scripts/charts/node-shape-heptagon.js b/client/app/scripts/charts/node-shape-heptagon.js index 98652e5f1..f4f4b715a 100644 --- a/client/app/scripts/charts/node-shape-heptagon.js +++ b/client/app/scripts/charts/node-shape-heptagon.js @@ -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 = ; - - if (onlyHighlight) { - return ( - - {highlighted && hightlightNode} - - ); - } + 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 ( - - {highlighted && hightlightNode} + + {hasMetric && getClipPathDefinition(clipId, size, height, size * 0.5 - height, -size * 0.5)} + {highlighted && } - + {hasMetric && } + {highlighted && hasMetric ? + {formattedValue} : + } ); } diff --git a/client/app/scripts/charts/node-shape-hex.js b/client/app/scripts/charts/node-shape-hex.js index 42236d8b5..d7045c9ab 100644 --- a/client/app/scripts/charts/node-shape-hex.js +++ b/client/app/scripts/charts/node-shape-hex.js @@ -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 = ; + const shadowSize = 0.45; + const upperHexBitHeight = -0.25 * size * shadowSize; - if (onlyHighlight) { - return ( - - {highlighted && hightlightNode} - - ); - } + 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 ( - - {highlighted && hightlightNode} + + {hasMetric && getClipPathDefinition(clipId, size, height, size - height + + upperHexBitHeight, 0)} + {highlighted && } - - + + {hasMetric && } + {highlighted && hasMetric ? + + {formattedValue} + : + } ); } 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..d4cd116bc 100644 --- a/client/app/scripts/charts/node-shape-square.js +++ b/client/app/scripts/charts/node-shape-square.js @@ -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 = ; - - if (onlyHighlight) { - return ( - - {highlighted && hightlightNode} - - ); - } + 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 ( - - {highlighted && hightlightNode} - - - + + {hasMetric && getClipPathDefinition(clipId, size, height)} + {highlighted && } + + + {hasMetric && } + {highlighted && hasMetric ? + + {formattedValue} + : + } ); } diff --git a/client/app/scripts/charts/node-shape-stack.js b/client/app/scripts/charts/node-shape-stack.js index e669a0d61..3d691888c 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,18 @@ 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..8260aa17e 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -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 diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index d2e37fa58..c759948d4 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -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} /> - @@ -114,6 +142,11 @@ export default class App extends React.Component { + {this.state.availableCanvasMetrics.count() > 0 && } diff --git a/client/app/scripts/components/debug-toolbar.js b/client/app/scripts/components/debug-toolbar.js index bdb545ec5..e5e5e95de 100644 --- a/client/app/scripts/components/debug-toolbar.js +++ b/client/app/scripts/components/debug-toolbar.js @@ -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 (
- - - - - - +
+ + + + + + + +
+ +
+ + + + + +
+ +
+ + +
+ + {this.state.showColors && [getNodeColor, getNodeColorDark].map(fn => ( + + + {LABEL_PREFIXES.map(r => ( + + {LABEL_PREFIXES.map(c => ( + + ))} + + ))} + +
+ ))}
); } diff --git a/client/app/scripts/components/metric-selector-item.js b/client/app/scripts/components/metric-selector-item.js new file mode 100644 index 000000000..d3890e0a4 --- /dev/null +++ b/client/app/scripts/components/metric-selector-item.js @@ -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 ( +
+ {metric.get('label')} + {isPinned && } +
+ ); + } +} diff --git a/client/app/scripts/components/metric-selector.js b/client/app/scripts/components/metric-selector.js new file mode 100644 index 000000000..a00578e33 --- /dev/null +++ b/client/app/scripts/components/metric-selector.js @@ -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 => ( + + )); + + return ( +
+
+ {items} +
+
+ ); + } +} + diff --git a/client/app/scripts/constants/action-types.js b/client/app/scripts/constants/action-types.js index 047dc8274..a6b3f5912 100644 --- a/client/app/scripts/constants/action-types.js +++ b/client/app/scripts/constants/action-types.js @@ -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); diff --git a/client/app/scripts/constants/styles.js b/client/app/scripts/constants/styles.js index 44d3afc85..f626c6733 100644 --- a/client/app/scripts/constants/styles.js +++ b/client/app/scripts/constants/styles.js @@ -8,3 +8,5 @@ export const DETAILS_PANEL_MARGINS = { }; export const DETAILS_PANEL_OFFSET = 8; + +export const CANVAS_METRIC_FONT_SIZE = 0.19; diff --git a/client/app/scripts/debug.js b/client/app/scripts/debug.js new file mode 100644 index 000000000..a546827ee --- /dev/null +++ b/client/app/scripts/debug.js @@ -0,0 +1,3 @@ +import Immutable from 'immutable'; +import installDevTools from 'immutable-devtools'; +installDevTools(Immutable); diff --git a/client/app/scripts/stores/app-store.js b/client/app/scripts/stores/app-store.js index d6b266884..31e99bbb1 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 }; } @@ -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]: diff --git a/client/app/scripts/utils/color-utils.js b/client/app/scripts/utils/color-utils.js index e8d2af804..7d997d405 100644 --- a/client/app/scripts/utils/color-utils.js +++ b/client/app/scripts/utils/color-utils.js @@ -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) { diff --git a/client/app/scripts/utils/data-generator-utils.js b/client/app/scripts/utils/data-generator-utils.js new file mode 100644 index 000000000..e79717eeb --- /dev/null +++ b/client/app/scripts/utils/data-generator-utils.js @@ -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) + }); +} diff --git a/client/app/scripts/utils/math-utils.js b/client/app/scripts/utils/math-utils.js new file mode 100644 index 000000000..31911ade8 --- /dev/null +++ b/client/app/scripts/utils/math-utils.js @@ -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; +} + diff --git a/client/app/scripts/utils/metric-utils.js b/client/app/scripts/utils/metric-utils.js new file mode 100644 index 000000000..3a9f8ee62 --- /dev/null +++ b/client/app/scripts/utils/metric-utils.js @@ -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 ( + + + + + + ); +} + + +// +// 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'; +} diff --git a/client/app/scripts/utils/string-utils.js b/client/app/scripts/utils/string-utils.js index e4f3b9bdb..9b7c22df4 100644 --- a/client/app/scripts/utils/string-utils.js +++ b/client/app/scripts/utils/string-utils.js @@ -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 ( - - {text} - {unit} - - ); - } -}; - -export function formatMetric(value, opts) { - const formatter = opts && formatters[opts.format] ? opts.format : 'number'; - return formatters[formatter](value); +function renderHtml(text, unit) { + return ( + + {text} + {unit} + + ); } + +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; diff --git a/client/app/styles/main.less b/client/app/styles/main.less index ff19b8562..eb2162b1f 100644 --- a/client/app/styles/main.less +++ b/client/app/styles/main.less @@ -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; + } + } } diff --git a/client/package.json b/client/package.json index a32a4b084..746925294 100644 --- a/client/package.json +++ b/client/package.json @@ -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": "/node_modules/babel-jest", diff --git a/client/webpack.local.config.js b/client/webpack.local.config.js index 51604fcc3..0decb0c70 100644 --- a/client/webpack.local.config.js +++ b/client/webpack.local.config.js @@ -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',