diff --git a/CHANGELOG.md b/CHANGELOG.md index ce8c79611..2e2df798b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,12 @@ ## v1.3.8.0 [unreleased] ### Bug Fixes 1. [#1886](https://github.com/influxdata/chronograf/pull/1886): Fix limit of 100 alert rules on alert rules page -1. [#1930](https://github.com/influxdata/chronograf/pull/1930): Fix graphs when values are of constant values +1. [#1930](https://github.com/influxdata/chronograf/pull/1930): Fix graphs when y-values are constant + ### Features +1. [#1928](https://github.com/influxdata/chronograf/pull/1928): Add prefix, suffix, scale, and other y-axis formatting +1. [#1886](https://github.com/influxdata/chronograf/pull/1886): Fix limit of 100 alert rules on alert rules page ### UI Improvements @@ -19,11 +22,21 @@ 1. [#1872](https://github.com/influxdata/chronograf/pull/1872): Prevent stats in the legend from wrapping line 1. [#1899](https://github.com/influxdata/chronograf/pull/1899): Fix raw query editor in Data Explorer not using selected time 1. [#1922](https://github.com/influxdata/chronograf/pull/1922): Fix Safari display issues in the Cell Editor display options +1. [#1715](https://github.com/influxdata/chronograf/pull/1715): Chronograf now renders on IE11. +1. [#1866](https://github.com/influxdata/chronograf/pull/1866): Fix missing cell type (and consequently single-stat) +1. [#1866](https://github.com/influxdata/chronograf/pull/1866): Fix data corruption issue with dashboard graph types + **Note**: If you upgraded to 1.3.6.0 and visited any dashboard, you will need to manually reset the graph type for every cell via the cell's caret -> Edit -> Display Options. +1. [#1870](https://github.com/influxdata/chronograf/pull/1870): Fix console error for placing prop on div +1. [#1845](https://github.com/influxdata/chronograf/pull/1845): Fix inaccessible scroll bar in Data Explorer table +1. [#1866](https://github.com/influxdata/chronograf/pull/1866): Fix non-persistence of dashboard graph types +1. [#1872](https://github.com/influxdata/chronograf/pull/1872): Prevent stats in the legend from wrapping line + ### Features 1. [#1863](https://github.com/influxdata/chronograf/pull/1863): Improve 'new-sources' server flag example by adding 'type' key 1. [#1898](https://github.com/influxdata/chronograf/pull/1898): Add an input and validation to custom time range calendar dropdowns 1. [#1904](https://github.com/influxdata/chronograf/pull/1904): Add support for selecting template variables with URL params +1. [#1859](https://github.com/influxdata/chronograf/pull/1859): Add y-axis controls to the API for layouts ### UI Improvements 1. [#1862](https://github.com/influxdata/chronograf/pull/1862): Show "Add Graph" button on cells with no queries @@ -46,11 +59,15 @@ 1. [#1798](https://github.com/influxdata/chronograf/pull/1798): Fix domain not updating in visualizations when changing time range manually 1. [#1799](https://github.com/influxdata/chronograf/pull/1799): Prevent console error spam from Dygraph's synchronize method when a dashboard has only one graph 1. [#1813](https://github.com/influxdata/chronograf/pull/1813): Guarantee UUID for each Alert Table key to prevent dropping items when keys overlap +1. [#1795](https://github.com/influxdata/chronograf/pull/1795): Fix uptime status on Windows hosts running Telegraf +1. [#1715](https://github.com/influxdata/chronograf/pull/1715): Chronograf now renders properly on IE11. ### Features 1. [#1744](https://github.com/influxdata/chronograf/pull/1744): Add a few time range shortcuts to the custom time range menu 1. [#1714](https://github.com/influxdata/chronograf/pull/1714): Add ability to edit a dashboard graph's y-axis bounds 1. [#1714](https://github.com/influxdata/chronograf/pull/1714): Add ability to edit a dashboard graph's y-axis label +1. [#1744](https://github.com/influxdata/chronograf/pull/1744): Add a few pre-set time range selections to the custom time range menu-- be sure to add a sensible GROUP BY +1. [#1744](https://github.com/influxdata/chronograf/pull/1744): Add a few time range shortcuts to the custom time range menu ### UI Improvements 1. [#1796](https://github.com/influxdata/chronograf/pull/1796): Add spinner write data modal to indicate data is being written diff --git a/bolt/internal/internal.go b/bolt/internal/internal.go index fefa93e78..ca6df1827 100644 --- a/bolt/internal/internal.go +++ b/bolt/internal/internal.go @@ -203,6 +203,10 @@ func MarshalDashboard(d chronograf.Dashboard) ([]byte, error) { axes[a] = &Axis{ Bounds: r.Bounds, Label: r.Label, + Prefix: r.Prefix, + Suffix: r.Suffix, + Base: r.Base, + Scale: r.Scale, } } @@ -281,14 +285,30 @@ func UnmarshalDashboard(data []byte, d *chronograf.Dashboard) error { axes := make(map[string]chronograf.Axis, len(c.Axes)) for a, r := range c.Axes { + // axis base defaults to 10 + if r.Base == "" { + r.Base = "10" + } + + if r.Scale == "" { + r.Scale = "linear" + } + if r.Bounds != nil { axes[a] = chronograf.Axis{ Bounds: r.Bounds, Label: r.Label, + Prefix: r.Prefix, + Suffix: r.Suffix, + Base: r.Base, + Scale: r.Scale, } + } else { axes[a] = chronograf.Axis{ Bounds: []string{}, + Base: r.Base, + Scale: r.Scale, } } } diff --git a/bolt/internal/internal.pb.go b/bolt/internal/internal.pb.go index 19a6c8d44..4af4f8fd0 100644 --- a/bolt/internal/internal.pb.go +++ b/bolt/internal/internal.pb.go @@ -121,6 +121,10 @@ type Axis struct { LegacyBounds []int64 `protobuf:"varint,1,rep,name=legacyBounds" json:"legacyBounds,omitempty"` Bounds []string `protobuf:"bytes,2,rep,name=bounds" json:"bounds,omitempty"` Label string `protobuf:"bytes,3,opt,name=label,proto3" json:"label,omitempty"` + Prefix string `protobuf:"bytes,4,opt,name=prefix,proto3" json:"prefix,omitempty"` + Suffix string `protobuf:"bytes,5,opt,name=suffix,proto3" json:"suffix,omitempty"` + Base string `protobuf:"bytes,6,opt,name=base,proto3" json:"base,omitempty"` + Scale string `protobuf:"bytes,7,opt,name=scale,proto3" json:"scale,omitempty"` } func (m *Axis) Reset() { *m = Axis{} } diff --git a/bolt/internal/internal.proto b/bolt/internal/internal.proto index b146cfd0a..aa4fe470b 100644 --- a/bolt/internal/internal.proto +++ b/bolt/internal/internal.proto @@ -38,6 +38,10 @@ message Axis { repeated int64 legacyBounds = 1; // legacyBounds are an ordered 2-tuple consisting of lower and upper axis extents, respectively repeated string bounds = 2; // bounds are an arbitrary list of client-defined bounds. string label = 3; // label is a description of this axis + string prefix = 4; // specifies the prefix for axis values + string suffix = 5; // specifies the suffix for axis values + string base = 6; // defines the base for axis values + string scale = 7; // represents the magnitude of the numbers on this axis } message Template { diff --git a/bolt/internal/internal_test.go b/bolt/internal/internal_test.go index 7a53243d2..937a192d4 100644 --- a/bolt/internal/internal_test.go +++ b/bolt/internal/internal_test.go @@ -168,6 +168,10 @@ func Test_MarshalDashboard(t *testing.T) { "y": chronograf.Axis{ Bounds: []string{"0", "3", "1-7", "foo"}, Label: "foo", + Prefix: "M", + Suffix: "m", + Base: "2", + Scale: "roflscale", }, }, Type: "line", @@ -241,6 +245,8 @@ func Test_MarshalDashboard_WithLegacyBounds(t *testing.T) { Axes: map[string]chronograf.Axis{ "y": chronograf.Axis{ Bounds: []string{}, + Base: "10", + Scale: "linear", }, }, Type: "line", @@ -260,7 +266,7 @@ func Test_MarshalDashboard_WithLegacyBounds(t *testing.T) { } } -func Test_MarshalDashboard_WithNoLegacyBounds(t *testing.T) { +func Test_MarshalDashboard_WithEmptyLegacyBounds(t *testing.T) { dashboard := chronograf.Dashboard{ ID: 1, Cells: []chronograf.DashboardCell{ @@ -314,6 +320,8 @@ func Test_MarshalDashboard_WithNoLegacyBounds(t *testing.T) { Axes: map[string]chronograf.Axis{ "y": chronograf.Axis{ Bounds: []string{}, + Base: "10", + Scale: "linear", }, }, Type: "line", diff --git a/chronograf.go b/chronograf.go index 535adfe1e..af329eb8b 100644 --- a/chronograf.go +++ b/chronograf.go @@ -644,6 +644,10 @@ type Axis struct { Bounds []string `json:"bounds"` // bounds are an arbitrary list of client-defined strings that specify the viewport for a cell LegacyBounds [2]int64 `json:"-"` // legacy bounds are for testing a migration from an earlier version of axis Label string `json:"label"` // label is a description of this Axis + Prefix string `json:"prefix"` // Prefix represents a label prefix for formatting axis values + Suffix string `json:"suffix"` // Suffix represents a label suffix for formatting axis values + Base string `json:"base"` // Base represents the radix for formatting axis values + Scale string `json:"scale"` // Scale is the axis formatting scale. Supported: "log", "linear" } // DashboardCell holds visual and query information for a cell diff --git a/server/cells.go b/server/cells.go index 29d78b000..2d01b406c 100644 --- a/server/cells.go +++ b/server/cells.go @@ -76,17 +76,34 @@ func ValidDashboardCellRequest(c *chronograf.DashboardCell) error { // HasCorrectAxes verifies that only permitted axes exist within a DashboardCell func HasCorrectAxes(c *chronograf.DashboardCell) error { - for axis, _ := range c.Axes { - switch axis { - case "x", "y", "y2": - // no-op - default: + for label, axis := range c.Axes { + if !oneOf(label, "x", "y", "y2") { + return chronograf.ErrInvalidAxis + } + + if !oneOf(axis.Scale, "linear", "log", "") { + return chronograf.ErrInvalidAxis + } + + if !oneOf(axis.Base, "10", "2", "") { return chronograf.ErrInvalidAxis } } + return nil } +// oneOf reports whether a provided string is a member of a variadic list of +// valid options +func oneOf(prop string, validOpts ...string) bool { + for _, valid := range validOpts { + if prop == valid { + return true + } + } + return false +} + // CorrectWidthHeight changes the cell to have at least the // minimum width and height func CorrectWidthHeight(c *chronograf.DashboardCell) { diff --git a/server/cells_test.go b/server/cells_test.go index c0ade6f5f..12b109679 100644 --- a/server/cells_test.go +++ b/server/cells_test.go @@ -55,6 +55,78 @@ func Test_Cells_CorrectAxis(t *testing.T) { }, true, }, + { + "linear scale value", + &chronograf.DashboardCell{ + Axes: map[string]chronograf.Axis{ + "x": chronograf.Axis{ + Scale: "linear", + Bounds: []string{"0", "100"}, + }, + }, + }, + false, + }, + { + "log scale value", + &chronograf.DashboardCell{ + Axes: map[string]chronograf.Axis{ + "x": chronograf.Axis{ + Scale: "log", + Bounds: []string{"0", "100"}, + }, + }, + }, + false, + }, + { + "invalid scale value", + &chronograf.DashboardCell{ + Axes: map[string]chronograf.Axis{ + "x": chronograf.Axis{ + Scale: "potatoes", + Bounds: []string{"0", "100"}, + }, + }, + }, + true, + }, + { + "base 10 axis", + &chronograf.DashboardCell{ + Axes: map[string]chronograf.Axis{ + "x": chronograf.Axis{ + Base: "10", + Bounds: []string{"0", "100"}, + }, + }, + }, + false, + }, + { + "base 2 axis", + &chronograf.DashboardCell{ + Axes: map[string]chronograf.Axis{ + "x": chronograf.Axis{ + Base: "2", + Bounds: []string{"0", "100"}, + }, + }, + }, + false, + }, + { + "invalid base", + &chronograf.DashboardCell{ + Axes: map[string]chronograf.Axis{ + "x": chronograf.Axis{ + Base: "all your base are belong to us", + Bounds: []string{"0", "100"}, + }, + }, + }, + true, + }, } for _, test := range axisTests { diff --git a/server/swagger.json b/server/swagger.json index 8141f7142..d0a4f0de1 100644 --- a/server/swagger.json +++ b/server/swagger.json @@ -3720,13 +3720,13 @@ "type": "object", "properties": { "x": { - "$ref": "#/definitions/DashboardRange" + "$ref": "#/definitions/Axis" }, "y": { - "$ref": "#/definitions/DashboardRange" + "$ref": "#/definitions/Axis" }, "y2": { - "$ref": "#/definitions/DashboardRange" + "$ref": "#/definitions/Axis" } } }, @@ -3811,7 +3811,7 @@ } } }, - "DashboardRange": { + "Axis": { "type": "object", "description": "A description of a particular axis for a visualization", "properties": { @@ -3824,6 +3824,26 @@ "type": "integer", "format": "int64" } + }, + "label": { + "description": "label is a description of this Axis", + "type": "string" + }, + "prefix": { + "description": "Prefix represents a label prefix for formatting axis values.", + "type": "string" + }, + "suffix": { + "description": "Suffix represents a label suffix for formatting axis values.", + "type": "string" + }, + "base": { + "description": "Base represents the radix for formatting axis values.", + "type": "string" + }, + "scale": { + "description": "Scale is the axis formatting scale. Supported: \"log\", \"linear\"", + "type": "string" } } }, diff --git a/ui/src/dashboards/components/AxesOptions.js b/ui/src/dashboards/components/AxesOptions.js index aea060b67..bcdd746e1 100644 --- a/ui/src/dashboards/components/AxesOptions.js +++ b/ui/src/dashboards/components/AxesOptions.js @@ -1,19 +1,22 @@ import React, {PropTypes} from 'react' -import _ from 'lodash' import OptIn from 'shared/components/OptIn' +import Input from 'src/dashboards/components/DisplayOptionsInput' +import {Tabber, Tab} from 'src/dashboards/components/Tabber' +import {DISPLAY_OPTIONS, TOOLTIP_CONTENT} from 'src/dashboards/constants' + +const {LINEAR, LOG, BASE_2, BASE_10} = DISPLAY_OPTIONS -// TODO: add logic for for Prefix, Suffix, Scale, and Multiplier const AxesOptions = ({ + axes: {y: {bounds, label, prefix, suffix, base, scale, defaultYLabel}}, + onSetBase, + onSetScale, + onSetLabel, + onSetPrefixSuffix, onSetYAxisBoundMin, onSetYAxisBoundMax, - onSetLabel, - axes, }) => { - const min = _.get(axes, ['y', 'bounds', '0'], '') - const max = _.get(axes, ['y', 'bounds', '1'], '') - const label = _.get(axes, ['y', 'label'], '') - const defaultYLabel = _.get(axes, ['y', 'defaultYLabel'], '') + const [min, max] = bounds return (
@@ -46,38 +49,48 @@ const AxesOptions = ({ type="number" />
- {/*
- - + + + -
-
- - -
-
- - -
-
- - -
*/} + + + + + ) @@ -85,10 +98,26 @@ const AxesOptions = ({ const {arrayOf, func, shape, string} = PropTypes +AxesOptions.defaultProps = { + axes: { + y: { + bounds: ['', ''], + prefix: '', + suffix: '', + base: BASE_10, + scale: LINEAR, + defaultYLabel: '', + }, + }, +} + AxesOptions.propTypes = { + onSetPrefixSuffix: func.isRequired, onSetYAxisBoundMin: func.isRequired, onSetYAxisBoundMax: func.isRequired, onSetLabel: func.isRequired, + onSetScale: func.isRequired, + onSetBase: func.isRequired, axes: shape({ y: shape({ bounds: arrayOf(string), diff --git a/ui/src/dashboards/components/CellEditorOverlay.js b/ui/src/dashboards/components/CellEditorOverlay.js index 3ee8d0d4d..026d0bda6 100644 --- a/ui/src/dashboards/components/CellEditorOverlay.js +++ b/ui/src/dashboards/components/CellEditorOverlay.js @@ -88,6 +88,22 @@ class CellEditorOverlay extends Component { this.setState({axes: {...axes, y: {...axes.y, label}}}) } + handleSetPrefixSuffix = e => { + const {axes} = this.state + const {prefix, suffix} = e.target.form + + this.setState({ + axes: { + ...axes, + y: { + ...axes.y, + prefix: prefix.value, + suffix: suffix.value, + }, + }, + }) + } + handleAddQuery = () => { const {queriesWorkingDraft} = this.state const newIndex = queriesWorkingDraft.length @@ -149,6 +165,34 @@ class CellEditorOverlay extends Component { this.setState({activeQueryIndex}) } + handleSetBase = base => () => { + const {axes} = this.state + + this.setState({ + axes: { + ...axes, + y: { + ...axes.y, + base, + }, + }, + }) + } + + handleSetScale = scale => () => { + const {axes} = this.state + + this.setState({ + axes: { + ...axes, + y: { + ...axes.y, + scale, + }, + }, + }) + } + getActiveQuery = () => { const {queriesWorkingDraft, activeQueryIndex} = this.state const activeQuery = queriesWorkingDraft[activeQueryIndex] @@ -230,13 +274,16 @@ class CellEditorOverlay extends Component { /> {isDisplayOptionsTabActive ? : + - ) } @@ -62,9 +68,12 @@ const {arrayOf, func, shape, string} = PropTypes DisplayOptions.propTypes = { selectedGraphType: string.isRequired, onSelectGraphType: func.isRequired, + onSetPrefixSuffix: func.isRequired, onSetYAxisBoundMin: func.isRequired, onSetYAxisBoundMax: func.isRequired, + onSetScale: func.isRequired, onSetLabel: func.isRequired, + onSetBase: func.isRequired, axes: shape({}).isRequired, queryConfigs: arrayOf(shape()).isRequired, } diff --git a/ui/src/dashboards/components/DisplayOptionsInput.js b/ui/src/dashboards/components/DisplayOptionsInput.js new file mode 100644 index 000000000..f0f5a61b4 --- /dev/null +++ b/ui/src/dashboards/components/DisplayOptionsInput.js @@ -0,0 +1,32 @@ +import React, {PropTypes} from 'react' + +const DisplayOptionsInput = ({id, name, value, onChange, labelText}) => +
+ + +
+ +const {func, string} = PropTypes + +DisplayOptionsInput.defaultProps = { + value: '', +} + +DisplayOptionsInput.propTypes = { + name: string.isRequired, + id: string.isRequired, + value: string.isRequired, + onChange: func.isRequired, + labelText: string, +} + +export default DisplayOptionsInput diff --git a/ui/src/dashboards/components/Tabber.js b/ui/src/dashboards/components/Tabber.js new file mode 100644 index 000000000..99bfb88bd --- /dev/null +++ b/ui/src/dashboards/components/Tabber.js @@ -0,0 +1,35 @@ +import React, {PropTypes} from 'react' +import QuestionMarkTooltip from 'src/shared/components/QuestionMarkTooltip' + +export const Tabber = ({labelText, children, tipID, tipContent}) => +
+ +
    + {children} +
+
+ +export const Tab = ({isActive, onClickTab, text}) => +
  • + {text} +
  • + +const {bool, func, node, string} = PropTypes + +Tabber.propTypes = { + children: node.isRequired, + labelText: string, + tipID: string, + tipContent: string, +} + +Tab.propTypes = { + onClickTab: func.isRequired, + isActive: bool.isRequired, + text: string.isRequired, +} diff --git a/ui/src/dashboards/constants/index.js b/ui/src/dashboards/constants/index.js index 109234758..b458fd737 100644 --- a/ui/src/dashboards/constants/index.js +++ b/ui/src/dashboards/constants/index.js @@ -94,3 +94,15 @@ export const removeUnselectedTemplateValues = templates => { return {...template, values: selectedValues} }) } + +export const DISPLAY_OPTIONS = { + LINEAR: 'linear', + LOG: 'log', + BASE_2: '2', + BASE_10: '10', +} + +export const TOOLTIP_CONTENT = { + FORMAT: + '

    K/M/B = Thousand / Million / Billion

    K/M/G = Kilo / Mega / Giga

    ', +} diff --git a/ui/src/data_explorer/components/Table.js b/ui/src/data_explorer/components/Table.js index 978c99d74..88c1ca555 100644 --- a/ui/src/data_explorer/components/Table.js +++ b/ui/src/data_explorer/components/Table.js @@ -103,7 +103,7 @@ class ChronoTable extends Component { const minWidth = 70 const rowHeight = 34 const headerHeight = 30 - const stylePixelOffset = 125 + const stylePixelOffset = 130 const defaultColumnWidth = 200 const styleAdjustedHeight = height - stylePixelOffset const width = diff --git a/ui/src/shared/components/Dygraph.js b/ui/src/shared/components/Dygraph.js index ea0f7b925..627641085 100644 --- a/ui/src/shared/components/Dygraph.js +++ b/ui/src/shared/components/Dygraph.js @@ -7,9 +7,15 @@ import _ from 'lodash' import Dygraphs from 'src/external/dygraph' import getRange from 'shared/parsing/getRangeForDygraph' +import {DISPLAY_OPTIONS} from 'src/dashboards/constants' import {LINE_COLORS, multiColumnBarPlotter} from 'src/shared/graphs/helpers' import DygraphLegend from 'src/shared/components/DygraphLegend' import {buildDefaultYLabel} from 'shared/presenters' +import {numberValueFormatter} from 'src/utils/formatting' + +const {LINEAR, LOG, BASE_10} = DISPLAY_OPTIONS +const labelWidth = 60 +const avgCharPixels = 7 const hasherino = (str, len) => str @@ -35,18 +41,11 @@ export default class Dygraph extends Component { } } - static defaultProps = { - containerStyle: {}, - isGraphFilled: true, - overrideLineColors: null, - dygraphRef: () => {}, - } - componentDidMount() { const timeSeries = this.getTimeSeries() // dygraphSeries is a legend label and its corresponding y-axis e.g. {legendLabel1: 'y', legendLabel2: 'y2'}; const { - axes, + axes: {y, y2}, dygraphSeries, ruleValues, overrideLineColors, @@ -69,8 +68,10 @@ export default class Dygraph extends Component { hashColorDygraphSeries[seriesName] = {...series, color} } - const yAxis = _.get(axes, ['y', 'bounds'], [null, null]) - const y2Axis = _.get(axes, ['y2', 'bounds'], undefined) + const axisLabelWidth = + labelWidth + + y.prefix.length * avgCharPixels + + y.suffix.length * avgCharPixels const defaultOptions = { plugins: isBarGraph @@ -80,6 +81,7 @@ export default class Dygraph extends Component { direction: 'vertical', }), ], + logscale: y.scale === LOG, labelsSeparateLines: false, labelsKMB: true, rightGap: 0, @@ -95,10 +97,15 @@ export default class Dygraph extends Component { series: hashColorDygraphSeries, axes: { y: { - valueRange: getRange(timeSeries, yAxis, ruleValues), + valueRange: getRange(timeSeries, y.bounds, ruleValues), + axisLabelFormatter: (yval, __, opts) => + numberValueFormatter(yval, opts, y.prefix, y.suffix), + axisLabelWidth, + labelsKMB: y.base === '10', + labelsKMG2: y.base === '2', }, y2: { - valueRange: getRange(timeSeries, y2Axis), + valueRange: getRange(timeSeries, y2.bounds), }, }, highlightSeriesOpts: { @@ -114,10 +121,10 @@ export default class Dygraph extends Component { const highlighted = legend.series.find(s => s.isHighlighted) const prevHighlighted = prevLegend.series.find(s => s.isHighlighted) - const y = highlighted && highlighted.y + const yVal = highlighted && highlighted.y const prevY = prevHighlighted && prevHighlighted.y - if (legend.x === prevLegend.x && y === prevY) { + if (legend.x === prevLegend.x && yVal === prevY) { return '' } @@ -222,7 +229,7 @@ export default class Dygraph extends Component { componentDidUpdate() { const { labels, - axes, + axes: {y, y2}, options, dygraphSeries, ruleValues, @@ -237,8 +244,6 @@ export default class Dygraph extends Component { ) } - const y = _.get(axes, ['y', 'bounds'], [null, null]) - const y2 = _.get(axes, ['y2', 'bounds'], undefined) const timeSeries = this.getTimeSeries() const ylabel = this.getLabel('y') const finalLineColors = [...(overrideLineColors || LINE_COLORS)] @@ -253,16 +258,27 @@ export default class Dygraph extends Component { hashColorDygraphSeries[seriesName] = {...series, color} } + const axisLabelWidth = + labelWidth + + y.prefix.length * avgCharPixels + + y.suffix.length * avgCharPixels + const updateOptions = { labels, file: timeSeries, ylabel, + logscale: y.scale === LOG, axes: { y: { - valueRange: getRange(timeSeries, y, ruleValues), + valueRange: getRange(timeSeries, y.bounds, ruleValues), + axisLabelFormatter: (yval, __, opts) => + numberValueFormatter(yval, opts, y.prefix, y.suffix), + axisLabelWidth, + labelsKMB: y.base === '10', + labelsKMG2: y.base === '2', }, y2: { - valueRange: getRange(timeSeries, y2), + valueRange: getRange(timeSeries, y2.bounds), }, }, stepPlot: options.stepPlot, @@ -275,8 +291,10 @@ export default class Dygraph extends Component { } dygraph.updateOptions(updateOptions) - dygraph.resize() + const {w} = this.dygraph.getArea() + this.resize() + this.dygraph.resize() this.props.setResolution(w) } @@ -361,6 +379,11 @@ export default class Dygraph extends Component { handleLegendRef = el => (this.legendRef = el) + resize = () => { + this.dygraph.resizeElements_() + this.dygraph.predraw_() + } + render() { const { legend, @@ -386,16 +409,16 @@ export default class Dygraph extends Component { onSnip={this.handleSnipLabel} onSort={this.handleSortLegend} legendRef={this.handleLegendRef} - onInputChange={this.handleLegendInputChange} onToggleFilter={this.handleToggleFilter} + onInputChange={this.handleLegendInputChange} />
    { this.graphRef = r this.props.dygraphRef(r) }} - style={this.props.containerStyle} className="dygraph-child-container" + style={this.props.containerStyle} />
    ) @@ -404,6 +427,27 @@ export default class Dygraph extends Component { const {array, arrayOf, bool, func, shape, string} = PropTypes +Dygraph.defaultProps = { + axes: { + y: { + bounds: [null, null], + prefix: '', + suffix: '', + base: BASE_10, + scale: LINEAR, + }, + y2: { + bounds: undefined, + prefix: '', + suffix: '', + }, + }, + containerStyle: {}, + isGraphFilled: true, + overrideLineColors: null, + dygraphRef: () => {}, +} + Dygraph.propTypes = { axes: shape({ y: shape({ diff --git a/ui/src/shared/components/LineGraph.js b/ui/src/shared/components/LineGraph.js index 3bcd69115..26a2ab732 100644 --- a/ui/src/shared/components/LineGraph.js +++ b/ui/src/shared/components/LineGraph.js @@ -159,12 +159,11 @@ export default React.createClass({ : overrideLineColors return ( -
    +
    {isRefreshing ? this.renderSpinner() : null} diff --git a/ui/src/shared/parsing/getRangeForDygraph.js b/ui/src/shared/parsing/getRangeForDygraph.js index 2a92529ae..d012b64f6 100644 --- a/ui/src/shared/parsing/getRangeForDygraph.js +++ b/ui/src/shared/parsing/getRangeForDygraph.js @@ -11,7 +11,7 @@ const considerEmpty = (userNumber, number) => { const getRange = ( timeSeries, userSelectedRange = [null, null], - ruleValues = {value: null, rangeValue: null} + ruleValues = {value: null, rangeValue: null, operator: ''} ) => { const {value, rangeValue, operator} = ruleValues const [userMin, userMax] = userSelectedRange diff --git a/ui/src/utils/formatting.js b/ui/src/utils/formatting.js index 652d90c95..49f8fd72f 100644 --- a/ui/src/utils/formatting.js +++ b/ui/src/utils/formatting.js @@ -1,3 +1,114 @@ +const KMB_LABELS = ['K', 'M', 'B', 'T', 'Q'] +const KMG2_BIG_LABELS = ['k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'] +const KMG2_SMALL_LABELS = ['m', 'u', 'n', 'p', 'f', 'a', 'z', 'y'] + +const pow = (base, exp) => { + if (exp < 0) { + return 1.0 / Math.pow(base, -exp) + } + + return Math.pow(base, exp) +} + +const round_ = (num, places) => { + const shift = Math.pow(10, places) + return Math.round(num * shift) / shift +} + +const floatFormat = (x, optPrecision) => { + // Avoid invalid precision values; [1, 21] is the valid range. + const p = Math.min(Math.max(1, optPrecision || 2), 21) + + // This is deceptively simple. The actual algorithm comes from: + // + // Max allowed length = p + 4 + // where 4 comes from 'e+n' and '.'. + // + // Length of fixed format = 2 + y + p + // where 2 comes from '0.' and y = # of leading zeroes. + // + // Equating the two and solving for y yields y = 2, or 0.00xxxx which is + // 1.0e-3. + // + // Since the behavior of toPrecision() is identical for larger numbers, we + // don't have to worry about the other bound. + // + // Finally, the argument for toExponential() is the number of trailing digits, + // so we take off 1 for the value before the '.'. + return Math.abs(x) < 1.0e-3 && x !== 0.0 + ? x.toExponential(p - 1) + : x.toPrecision(p) +} + +// taken from https://github.com/danvk/dygraphs/blob/aaec6de56dba8ed712fd7b9d949de47b46a76ccd/src/dygraph-utils.js#L1103 +export const numberValueFormatter = (x, opts, prefix, suffix) => { + const sigFigs = opts('sigFigs') + + if (sigFigs !== null) { + // User has opted for a fixed number of significant figures. + return floatFormat(x, sigFigs) + } + + const digits = opts('digitsAfterDecimal') + const maxNumberWidth = opts('maxNumberWidth') + + const kmb = opts('labelsKMB') + const kmg2 = opts('labelsKMG2') + + let label + + // switch to scientific notation if we underflow or overflow fixed display. + if ( + x !== 0.0 && + (Math.abs(x) >= Math.pow(10, maxNumberWidth) || + Math.abs(x) < Math.pow(10, -digits)) + ) { + label = x.toExponential(digits) + } else { + label = `${round_(x, digits)}` + } + + if (kmb || kmg2) { + let k + let kLabels = [] + let mLabels = [] + if (kmb) { + k = 1000 + kLabels = KMB_LABELS + } + if (kmg2) { + if (kmb) { + console.error('Setting both labelsKMB and labelsKMG2. Pick one!') + } + k = 1024 + kLabels = KMG2_BIG_LABELS + mLabels = KMG2_SMALL_LABELS + } + + const absx = Math.abs(x) + let n = pow(k, kLabels.length) + for (let j = kLabels.length - 1; j >= 0; j -= 1, n /= k) { + if (absx >= n) { + label = round_(x / n, digits) + kLabels[j] + break + } + } + if (kmg2) { + const xParts = String(x.toExponential()).split('e-') + if (xParts.length === 2 && xParts[1] >= 3 && xParts[1] <= 24) { + if (xParts[1] % 3 > 0) { + label = round_(xParts[0] / pow(10, xParts[1] % 3), digits) + } else { + label = Number(xParts[0]).toFixed(2) + } + label += mLabels[Math.floor(xParts[1] / 3) - 1] + } + } + } + + return `${prefix}${label}${suffix}` +} + export const formatBytes = bytes => { if (bytes === 0) { return '0 Bytes'