diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index ddc79977b..401ad9a54 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -21,14 +21,14 @@ export function selectMetric(metricId) { } export function pinNextMetric(delta) { - const metrics = AppStore.getAvailableCanvasMetrics().map(m => m.id); + const metrics = AppStore.getAvailableCanvasMetrics().map(m => m.get('id')); const currentIndex = metrics.indexOf(AppStore.getSelectedMetric()); - const nextMetric = metrics[modulo(currentIndex + delta, metrics.length)]; + const nextIndex = modulo(currentIndex + delta, metrics.count()); + const nextMetric = metrics.get(nextIndex); AppDispatcher.dispatch({ type: ActionTypes.PIN_METRIC, metricId: nextMetric, - metricType: AppStore.getAvailableCanvasMetricsTypes()[nextMetric] }); updateRoute(); } @@ -37,7 +37,6 @@ export function pinMetric(metricId) { AppDispatcher.dispatch({ type: ActionTypes.PIN_METRIC, metricId, - metricType: AppStore.getAvailableCanvasMetricsTypes()[metricId] }); updateRoute(); } diff --git a/client/app/scripts/charts/node-shape-circle.js b/client/app/scripts/charts/node-shape-circle.js index 997dfebdd..dcf2327eb 100644 --- a/client/app/scripts/charts/node-shape-circle.js +++ b/client/app/scripts/charts/node-shape-circle.js @@ -1,39 +1,25 @@ import React from 'react'; import classNames from 'classnames'; -import {getMetricValue, getMetricColor} from '../utils/metric-utils.js'; +import {getMetricValue, getMetricColor, getClipPathDefinition} from '../utils/metric-utils.js'; import {CANVAS_METRIC_FONT_SIZE} from '../constants/styles.js'; -export default function NodeShapeCircle({id, highlighted, size, color, metric}) { - const hightlightNode = ; - const clipId = `mask-${id}`; - const {height, value, formattedValue} = getMetricValue(metric, size); - const className = classNames('shape', { - metrics: value !== null - }); - const metricStyle = { - fill: getMetricColor(metric) - }; +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 && } - - {highlighted && value !== null ? + {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 d807e843c..f4f4b715a 100644 --- a/client/app/scripts/charts/node-shape-heptagon.js +++ b/client/app/scripts/charts/node-shape-heptagon.js @@ -1,13 +1,15 @@ import React from 'react'; import d3 from 'd3'; import classNames from 'classnames'; -import {getMetricValue, getMetricColor} from '../utils/metric-utils.js'; +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]]; @@ -17,6 +19,7 @@ function polygon(r, sides) { return points; } + export default function NodeShapeHeptagon({id, highlighted, size, color, metric}) { const scaledSize = size * 1.0; const pathProps = v => ({ @@ -25,34 +28,20 @@ export default function NodeShapeHeptagon({id, highlighted, size, color, metric} }); const clipId = `mask-${id}`; - const {height, value, formattedValue} = getMetricValue(metric, size); - - const className = classNames('shape', { - metrics: value !== null - }); + 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; - const metricStyle = { - fill: getMetricColor(metric) - }; return ( - - - - - + {hasMetric && getClipPathDefinition(clipId, size, height, size * 0.5 - height, -size * 0.5)} {highlighted && } - - {highlighted && value !== null ? + {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 4ae2fa39d..d7045c9ab 100644 --- a/client/app/scripts/charts/node-shape-hex.js +++ b/client/app/scripts/charts/node-shape-hex.js @@ -1,17 +1,20 @@ import React from 'react'; import d3 from 'd3'; import classNames from 'classnames'; -import {getMetricValue, getMetricColor} from '../utils/metric-utils.js'; +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 = [ @@ -35,34 +38,23 @@ export default function NodeShapeHex({id, highlighted, size, color, metric}) { const shadowSize = 0.45; const upperHexBitHeight = -0.25 * size * shadowSize; - const fontSize = size * CANVAS_METRIC_FONT_SIZE; const clipId = `mask-${id}`; - const {height, value, formattedValue} = getMetricValue(metric, size); - const className = classNames('shape', { - metrics: value !== null - }); - const metricStyle = { - fill: getMetricColor(metric) - }; + 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 ( - - - - - + {hasMetric && getClipPathDefinition(clipId, size, height, size - height + + upperHexBitHeight, 0)} {highlighted && } - - {highlighted && value !== null ? + {hasMetric && } + {highlighted && hasMetric ? {formattedValue} : diff --git a/client/app/scripts/charts/node-shape-square.js b/client/app/scripts/charts/node-shape-square.js index adc4c0240..d4cd116bc 100644 --- a/client/app/scripts/charts/node-shape-square.js +++ b/client/app/scripts/charts/node-shape-square.js @@ -1,8 +1,9 @@ import React from 'react'; import classNames from 'classnames'; -import {getMetricValue, getMetricColor} from '../utils/metric-utils.js'; +import {getMetricValue, getMetricColor, getClipPathDefinition} from '../utils/metric-utils.js'; import {CANVAS_METRIC_FONT_SIZE} from '../constants/styles.js'; + export default function NodeShapeSquare({ id, highlighted, size, color, rx = 0, ry = 0, metric }) { @@ -16,33 +17,20 @@ export default function NodeShapeSquare({ }); const clipId = `mask-${id}`; - const {height, value, formattedValue} = getMetricValue(metric, size); - const className = classNames('shape', { - metrics: value !== null - }); + 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; - const metricStyle = { - fill: getMetricColor(metric) - }; return ( - - - - - + {hasMetric && getClipPathDefinition(clipId, size, height)} {highlighted && } - - {highlighted && value !== null ? + {hasMetric && } + {highlighted && hasMetric ? {formattedValue} : diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index ac8578872..8260aa17e 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -76,7 +76,10 @@ export default class NodesChart extends React.Component { } // // FIXME add PureRenderMixin, Immutables, and move the following functions to render() - _.assign(state, this.updateGraphState(nextProps, state)); + // _.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,12 +134,12 @@ export default class NodesChart extends React.Component { return 1; }; - const metric = node => { - const met = node.get('metrics') && node.get('metrics') + // 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 met; - }; + .first() + ); return nodes .toIndexedSeq() diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index b257142af..c759948d4 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -142,7 +142,7 @@ export default class App extends React.Component { - {this.state.availableCanvasMetrics.length > 0 && 0 && _.range(_.random(4)).map(() => _.sample(collection)); @@ -26,8 +29,11 @@ const shapeTypes = { 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: {}, @@ -44,6 +50,18 @@ const deltaAdd = (name, adjacency = [], shape = 'circle', stack = false, nodeCou }); +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]; @@ -62,6 +80,17 @@ function addAllVariants() { } +function addAllMetricVariants() { + const newNodes = _.flattenDeep(METRIC_FILLS.map((v, i) => ( + SHAPES.map(s => [addMetrics(deltaAdd(label(s) + i, [], s), v)]) + ))); + + receiveNodesDelta({ + add: newNodes + }); +} + + function addNodes(n) { const ns = AppStore.getNodes(); const nodeNames = ns.keySeq().toJS(); @@ -109,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 }; } @@ -118,6 +149,12 @@ 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'); @@ -130,6 +167,7 @@ export class DebugToolbar extends React.Component { +
@@ -139,6 +177,25 @@ export class DebugToolbar extends React.Component {
+ +
+ + +
+ + {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 index 2fa3708ad..d3890e0a4 100644 --- a/client/app/scripts/components/metric-selector-item.js +++ b/client/app/scripts/components/metric-selector-item.js @@ -13,12 +13,12 @@ export class MetricSelectorItem extends React.Component { } onMouseOver() { - const k = this.props.metric.id; + const k = this.props.metric.get('id'); selectMetric(k); } onMouseClick() { - const k = this.props.metric.id; + const k = this.props.metric.get('id'); const pinnedMetric = this.props.pinnedMetric; if (k === pinnedMetric) { @@ -30,7 +30,7 @@ export class MetricSelectorItem extends React.Component { render() { const {metric, selectedMetric, pinnedMetric} = this.props; - const id = metric.id; + const id = metric.get('id'); const isPinned = (id === pinnedMetric); const isSelected = (id === selectedMetric); const className = classNames('metric-selector-action', { @@ -43,7 +43,7 @@ export class MetricSelectorItem extends React.Component { className={className} onMouseOver={this.onMouseOver} onClick={this.onMouseClick}> - {metric.label} + {metric.get('label')} {isPinned && } ); diff --git a/client/app/scripts/components/metric-selector.js b/client/app/scripts/components/metric-selector.js index 76878019c..a00578e33 100644 --- a/client/app/scripts/components/metric-selector.js +++ b/client/app/scripts/components/metric-selector.js @@ -2,11 +2,6 @@ import React from 'react'; import { selectMetric } from '../actions/app-actions'; import { MetricSelectorItem } from './metric-selector-item'; -// const CROSS = '\u274C'; -// const MINUS = '\u2212'; -// const DOT = '\u2022'; -// - export default class MetricSelector extends React.Component { @@ -23,7 +18,7 @@ export default class MetricSelector extends React.Component { const {availableCanvasMetrics} = this.props; const items = availableCanvasMetrics.map(metric => ( - + )); return ( diff --git a/client/app/scripts/stores/app-store.js b/client/app/scripts/stores/app-store.js index 1f4012ab2..31e99bbb1 100644 --- a/client/app/scripts/stores/app-store.js +++ b/client/app/scripts/stores/app-store.js @@ -61,8 +61,10 @@ 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 = []; +let availableCanvasMetrics = makeList(); const topologySorter = topology => topology.get('rank'); @@ -184,7 +186,7 @@ export class AppStore extends Store { } getAvailableCanvasMetricsTypes() { - return _.fromPairs(this.getAvailableCanvasMetrics().map(m => [m.id, m.label])); + return makeMap(this.getAvailableCanvasMetrics().map(m => [m.get('id'), m.get('label')])); } getControlStatus() { @@ -404,7 +406,7 @@ export class AppStore extends Store { setTopology(payload.topologyId); nodes = nodes.clear(); } - availableCanvasMetrics = []; + availableCanvasMetrics = makeList(); this.__emitChange(); break; } @@ -415,7 +417,7 @@ export class AppStore extends Store { setTopology(payload.topologyId); nodes = nodes.clear(); } - availableCanvasMetrics = []; + availableCanvasMetrics = makeList(); this.__emitChange(); break; } @@ -433,7 +435,7 @@ export class AppStore extends Store { } case ActionTypes.PIN_METRIC: { pinnedMetric = payload.metricId; - pinnedMetricType = payload.metricType; + pinnedMetricType = this.getAvailableCanvasMetricsTypes().get(payload.metricId); selectedMetric = payload.metricId; this.__emitChange(); break; @@ -614,13 +616,14 @@ export class AppStore extends Store { makeMap({id: m.get('id'), label: m.get('label')}) ))) .toSet() - .sortBy(m => m.get('label')) - .toJS(); + .toList() + .sortBy(m => m.get('label')); - const similarTypeMetric = availableCanvasMetrics.find(m => m.label === pinnedMetricType); - pinnedMetric = similarTypeMetric && similarTypeMetric.id; + 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.id).indexOf(selectedMetric) === -1) { + if (!availableCanvasMetrics.map(m => m.get('id')).toSet().has(selectedMetric)) { selectedMetric = pinnedMetric; } diff --git a/client/app/scripts/utils/metric-utils.js b/client/app/scripts/utils/metric-utils.js index 148c2e022..12650b77b 100644 --- a/client/app/scripts/utils/metric-utils.js +++ b/client/app/scripts/utils/metric-utils.js @@ -1,14 +1,35 @@ import _ from 'lodash'; import d3 from 'd3'; -import { formatMetric } from './string-utils'; -import { colors } from './color-utils'; +import { formatMetricSvg } from './string-utils'; +import { getNodeColorDark as 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'}; @@ -17,40 +38,42 @@ export function getMetricValue(metric, size) { 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) { + if (displayedValue > 0 && (!max || displayedValue < max)) { const baseline = 0.1; - displayedValue = valuePercentage * (1 - baseline) + baseline; + displayedValue = valuePercentage * (1 - baseline * 2) + baseline; + } else if (displayedValue >= m.max && displayedValue > 0) { + displayedValue = 1; } const height = size * displayedValue; return { height, - value, - formattedValue: formatMetric(value, m, true) + hasMetric: value !== null, + formattedValue: formatMetricSvg(value, m) }; } export function getMetricColor(metric) { const selectedMetric = metric && metric.get('id'); - // bluey - if (/memory/.test(selectedMetric)) { - return '#1f77b4'; + if (/mem/.test(selectedMetric)) { + return colors('p', 'a'); } else if (/cpu/.test(selectedMetric)) { - return colors('cpu'); + return colors('z', 'a'); } else if (/files/.test(selectedMetric)) { - // return colors('files'); - // purple - return '#9467bd'; + return colors('t', 'a'); } else if (/load/.test(selectedMetric)) { - return colors('load'); + return colors('a', 'a'); } return 'steelBlue'; } diff --git a/client/app/scripts/utils/string-utils.js b/client/app/scripts/utils/string-utils.js index f75677583..9b7c22df4 100644 --- a/client/app/scripts/utils/string-utils.js +++ b/client/app/scripts/utils/string-utils.js @@ -2,8 +2,10 @@ import React from 'react'; import filesize from 'filesize'; import d3 from 'd3'; + const formatLargeValue = d3.format('s'); + function renderHtml(text, unit) { return ( @@ -14,6 +16,11 @@ function renderHtml(text, unit) { } +function renderSvg(text, unit) { + return `${text}${unit}`; +} + + function makeFormatters(renderFn) { const formatters = { filesize(value) { @@ -45,13 +52,15 @@ function makeFormatters(renderFn) { } -const formatters = makeFormatters(renderHtml); -const svgFormatters = makeFormatters((text, unit) => `${text}${unit}`); - -export function formatMetric(value, opts, svg) { - const formatterBase = svg ? svgFormatters : formatters; - const formatter = opts && formatterBase[opts.format] ? opts.format : 'number'; - return formatterBase[formatter](value); +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 14b79d5b2..8505871b1 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; @@ -402,16 +404,12 @@ h2 { display: none; } - .stack .onlyMetrics .shape .metric-fill { - display: inline-block; - } - .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.5s cubic-bezier(0,0,0.21,1), fill 0.5s cubic-bezier(0,0,0.21,1); + transition: stroke-opacity 0.333s @base-ease, fill 0.333s @base-ease; stroke-opacity: 1; } @@ -423,7 +421,7 @@ h2 { .metric-fill { stroke: none; fill: #A0BE7E; - fill-opacity: 0.7; + fill-opacity: 0.5; } .shadow { @@ -608,7 +606,7 @@ h2 { &-icon { margin-right: 0.5em; - animation: blinking 2.0s infinite ease-in-out; + animation: blinking 2.0s infinite @base-ease; } } } @@ -996,7 +994,7 @@ h2 { } &.status-loading { - animation: blinking 2.0s infinite ease-in-out; + animation: blinking 2.0s infinite @base-ease; text-transform: none; color: @text-color; } @@ -1086,4 +1084,15 @@ h2 { &: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 5554ad976..746925294 100644 --- a/client/package.json +++ b/client/package.json @@ -69,9 +69,8 @@ "coveralls": "cat coverage/lcov.info | coveralls", "lint": "eslint app", "clean": "rm build/app.js", - "noprobe": "../scope stop && ../scope launch --no-probe --app.window 24h", - "loadreport": "npm run noprobe && sleep 1 && curl -X POST -H \"Content-Type: application/json\" http://$BACKEND_HOST/api/report", - "loadreportjson": "npm run loadreport -- -d @../k8s_report.json" + "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",