diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d9be8cc1..75e655107 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,18 +2,48 @@ ### 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 y-values are constant - +1. [#1951](https://github.com/influxdata/chronograf/pull/1951): Fix crosshair not being removed when user leaves graph +1. [#1943](https://github.com/influxdata/chronograf/pull/1943): Fix inability to add kapacitor from source page on fresh install +1. [#1947](https://github.com/influxdata/chronograf/pull/1947): Fix DataExplorer crash if field property not present on queryConfig +1. [#1957](https://github.com/influxdata/chronograf/pull/1957): Fix stacked graphs not being fully displayed ### 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 +1. [#1934](https://github.com/influxdata/chronograf/pull/1943): Zoom syncronization and enhancement ### UI Improvements 1. [#1933](https://github.com/influxdata/chronograf/pull/1933): Use line-stacked graph type for memory information - thank you, @Joxit! 1. [#1940](https://github.com/influxdata/chronograf/pull/1940): Improve cell sizes in Admin Database tables 1. [#1942](https://github.com/influxdata/chronograf/pull/1942): Polish appearance of optional alert parameters in Kapacitor rule builder +1. [#1944](https://github.com/influxdata/chronograf/pull/1944): Add active state for Status page navbar icon +1. [#1944](https://github.com/influxdata/chronograf/pull/1944): Improve UX of navigation to a sub-nav item in the navbar ## v1.3.7.0 [2017-08-23] +## v1.3.7.0 [unreleased] +### Features +1. [#1928](https://github.com/influxdata/chronograf/pull/1928): Add prefix, suffix, scale, and other y-axis formatting + +### UI Improvements + +## v1.3.7.0 +### Bug Fixes +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 on IE11. +1. [#1870](https://github.com/influxdata/chronograf/pull/1870): Fix console error for placing prop on div +1. [#1864](https://github.com/influxdata/chronograf/pull/1864): Fix Write Data form upload button and add `onDragExit` handler +1. [#1891](https://github.com/influxdata/chronograf/pull/1891): Fix Kapacitor config for PagerDuty via the UI +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 + +### UI Improvements +1. [#1862](https://github.com/influxdata/chronograf/pull/1862): Show "Add Graph" button on cells with no queries + +## v1.3.6.1 [2017-08-14] +**Upgrade Note** This release (1.3.6.1) fixes a possibly data corruption issue with dashboard cells' graph types. If you upgraded to 1.3.6.0 and visited any dashboard, once you have then upgraded to this release (1.3.6.1) you will need to manually reset the graph type for every cell via the cell's caret --> Edit --> Display Options. If you upgraded directly to 1.3.6.1, you should not experience this issue. + ### Bug Fixes 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 on IE11. diff --git a/chronograf.go b/chronograf.go index af329eb8b..77f1f8684 100644 --- a/chronograf.go +++ b/chronograf.go @@ -260,7 +260,7 @@ func (g *GroupByVar) parseRelative(fragment string) (time.Duration, error) { // example, the fragement "time > '1985-10-25T00:01:21-0800 and time < // '1985-10-25T00:01:22-0800'" would yield a duration of 1m' func (g *GroupByVar) parseAbsolute(fragment string) (time.Duration, error) { - timePtn := `time\s[>|<]\s'([0-9\-TZ\:]+)'` // Playground: http://gobular.com/x/41a45095-c384-46ea-b73c-54ef91ab93af + timePtn := `time\s[>|<]\s'([0-9\-T\:\.Z]+)'` // Playground: http://gobular.com/x/208f66bd-1889-4269-ab47-1efdfeeb63f0 re, err := regexp.Compile(timePtn) if err != nil { // this is a developer error and should complain loudly diff --git a/chronograf_test.go b/chronograf_test.go index 60164d588..f08e14c6d 100644 --- a/chronograf_test.go +++ b/chronograf_test.go @@ -29,6 +29,13 @@ func Test_GroupByVar(t *testing.T) { 1000, 10 * time.Second, }, + { + "absolute time with nano", + "SELECT mean(usage_idle) FROM cpu WHERE time > '2017-07-24T15:33:42.994Z' and time < '2017-08-24T15:33:42.994Z' GROUP BY :interval:", + 744 * time.Hour, + 1000, + 10 * time.Second, + }, } for _, test := range gbvTests { diff --git a/ui/spec/data_explorer/reducers/queryConfigSpec.js b/ui/spec/data_explorer/reducers/queryConfigSpec.js index 183229aaf..379f4814a 100644 --- a/ui/spec/data_explorer/reducers/queryConfigSpec.js +++ b/ui/spec/data_explorer/reducers/queryConfigSpec.js @@ -146,6 +146,20 @@ describe('Chronograf.Reducers.DataExplorer.queryConfigs', () => { expect(newState[queryId].fields[1].funcs.length).to.equal(1) expect(newState[queryId].fields[1].funcs[0]).to.equal('func1') }) + + it('adds the field property to query config if not found', () => { + delete state[queryId].fields + expect(state[queryId].fields).to.equal(undefined) + + const field = 'fk1' + const newState = reducer( + state, + toggleField(queryId, {field: 'fk1', funcs: []}) + ) + + expect(newState[queryId].fields.length).to.equal(1) + expect(newState[queryId].fields[0].field).to.equal(field) + }) }) }) diff --git a/ui/src/alerts/components/AlertsTable.js b/ui/src/alerts/components/AlertsTable.js index 01207e392..c813889b9 100644 --- a/ui/src/alerts/components/AlertsTable.js +++ b/ui/src/alerts/components/AlertsTable.js @@ -28,15 +28,11 @@ class AlertsTable extends Component { filterAlerts = (searchTerm, newAlerts) => { const alerts = newAlerts || this.props.alerts const filterText = searchTerm.toLowerCase() - const filteredAlerts = alerts.filter(h => { - if (h.host === null || h.name === null || h.level === null) { - return false - } - + const filteredAlerts = alerts.filter(({name, host, level}) => { return ( - h.name.toLowerCase().includes(filterText) || - h.host.toLowerCase().includes(filterText) || - h.level.toLowerCase().includes(filterText) + (name && name.toLowerCase().includes(filterText)) || + (host && host.toLowerCase().includes(filterText)) || + (level && level.toLowerCase().includes(filterText)) ) }) this.setState({searchTerm, filteredAlerts}) diff --git a/ui/src/dashboards/components/Dashboard.js b/ui/src/dashboards/components/Dashboard.js index 1ac4c02ad..ddbe4436b 100644 --- a/ui/src/dashboards/components/Dashboard.js +++ b/ui/src/dashboards/components/Dashboard.js @@ -7,6 +7,7 @@ import FancyScrollbar from 'shared/components/FancyScrollbar' const Dashboard = ({ source, + onZoom, dashboard, onAddCell, onEditCell, @@ -71,6 +72,7 @@ const Dashboard = ({ onDeleteCell={onDeleteCell} onSummonOverlayTechnologies={onSummonOverlayTechnologies} synchronizer={synchronizer} + onZoom={onZoom} /> :

This Dashboard has no Cells

@@ -127,6 +129,7 @@ Dashboard.propTypes = { onSelectTemplate: func.isRequired, showTemplateControlBar: bool, onCancelEditCell: func, + onZoom: func, } export default Dashboard diff --git a/ui/src/dashboards/components/DashboardHeader.js b/ui/src/dashboards/components/DashboardHeader.js index 6977d98d1..a7bd5436a 100644 --- a/ui/src/dashboards/components/DashboardHeader.js +++ b/ui/src/dashboards/components/DashboardHeader.js @@ -11,7 +11,8 @@ const DashboardHeader = ({ buttonText, dashboard, headerText, - timeRange, + timeRange: {upper, lower}, + zoomedTimeRange: {zoomedLower, zoomedUpper}, autoRefresh, isHidden, handleChooseTimeRange, @@ -81,7 +82,10 @@ const DashboardHeader = ({ />
{ + this.setState({zoomedTimeRange: {zoomedLower, zoomedUpper}}) + } + getActiveDashboard() { const {params: {dashboardID}, dashboards} = this.props return dashboards.find(d => d.id === +dashboardID) } render() { + const {zoomedTimeRange} = this.state + const {zoomedLower, zoomedUpper} = zoomedTimeRange + const { source, timeRange, @@ -217,8 +225,11 @@ class DashboardPage extends Component { params: {sourceID, dashboardID}, } = this.props - const lowerType = lower && lower.includes('Z') ? 'timeStamp' : 'constant' - const upperType = upper && upper.includes('Z') ? 'timeStamp' : 'constant' + const low = zoomedLower ? zoomedLower : lower + const up = zoomedUpper ? zoomedUpper : upper + + const lowerType = low && low.includes(':') ? 'timeStamp' : 'constant' + const upperType = up && up.includes(':') ? 'timeStamp' : 'constant' const dashboardTime = { id: 'dashtime', @@ -226,7 +237,7 @@ class DashboardPage extends Component { type: lowerType, values: [ { - value: lower, + value: low, type: lowerType, selected: true, }, @@ -239,7 +250,7 @@ class DashboardPage extends Component { type: upperType, values: [ { - value: upper || 'now()', + value: up || 'now()', type: upperType, selected: true, }, @@ -310,6 +321,7 @@ class DashboardPage extends Component { sourceID={sourceID} dashboard={dashboard} timeRange={timeRange} + zoomedTimeRange={zoomedTimeRange} autoRefresh={autoRefresh} isHidden={inPresentationMode} onAddCell={this.handleAddCell} @@ -337,6 +349,7 @@ class DashboardPage extends Component { dashboard={dashboard} timeRange={timeRange} autoRefresh={autoRefresh} + onZoom={this.handleZoomedTimeRange} onAddCell={this.handleAddCell} synchronizer={this.synchronizer} inPresentationMode={inPresentationMode} diff --git a/ui/src/external/dygraph.js b/ui/src/external/dygraph.js index 66ce45214..698bef2b4 100644 --- a/ui/src/external/dygraph.js +++ b/ui/src/external/dygraph.js @@ -288,22 +288,17 @@ Dygraph.Plugins.Crosshair = (function() { var width = e.dygraph.width_ var height = e.dygraph.height_ + var xLabelPixels = 20 + this.canvas_.width = width - this.canvas_.height = height + this.canvas_.height = height - xLabelPixels this.canvas_.style.width = width + 'px' // for IE - this.canvas_.style.height = height + 'px' // for IE + this.canvas_.style.height = height - xLabelPixels + 'px' // for IE var ctx = this.canvas_.getContext('2d') ctx.clearRect(0, 0, width, height) - - const gradient = ctx.createLinearGradient(0, 0, 0, height) - gradient.addColorStop(0, 'rgba(255, 255, 255, 0.0)') - gradient.addColorStop(0.11, 'rgba(255, 255, 255, 1.0)') - gradient.addColorStop(0.89, 'rgba(255, 255, 255, 1.0)') - gradient.addColorStop(1, 'rgba(255, 255, 255, 0.0)') - - ctx.strokeStyle = gradient - ctx.lineWidth = 1.5 + ctx.strokeStyle = '#C6CAD3' + ctx.lineWidth = 1 // If graphs have different time ranges, it's possible to select a point on // one graph that doesn't exist in another, resulting in an exception. diff --git a/ui/src/kapacitor/containers/KapacitorPage.js b/ui/src/kapacitor/containers/KapacitorPage.js index b5f26c5f8..50cb443cd 100644 --- a/ui/src/kapacitor/containers/KapacitorPage.js +++ b/ui/src/kapacitor/containers/KapacitorPage.js @@ -68,11 +68,18 @@ class KapacitorPage extends Component { handleSubmit(e) { e.preventDefault() - const {addFlashMessage, source, params, router} = this.props + const { + addFlashMessage, + source, + source: {kapacitors = []}, + params, + router, + } = this.props const {kapacitor} = this.state - const kapNames = source.kapacitors.map(k => k.name) - if (kapNames.includes(kapacitor.name)) { + const isNameTaken = kapacitors.some(k => k.name === kapacitor.name) + + if (isNameTaken) { addFlashMessage({ type: 'error', text: `There is already a Kapacitor configuration named "${kapacitor.name}"`, diff --git a/ui/src/shared/components/Dygraph.js b/ui/src/shared/components/Dygraph.js index 627641085..3dc1dfb5c 100644 --- a/ui/src/shared/components/Dygraph.js +++ b/ui/src/shared/components/Dygraph.js @@ -3,25 +3,24 @@ import React, {Component, PropTypes} from 'react' import shallowCompare from 'react-addons-shallow-compare' import _ from 'lodash' +import moment from 'moment' 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 getRange, {getStackedRange} from 'shared/parsing/getRangeForDygraph' import DygraphLegend from 'src/shared/components/DygraphLegend' +import {DISPLAY_OPTIONS} from 'src/dashboards/constants' 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 - .split('') - .map(char => char.charCodeAt(0)) - .reduce((hash, code) => hash + code, 0) % len +import { + OPTIONS, + LINE_COLORS, + LABEL_WIDTH, + CHAR_PIXELS, + barPlotter, + hasherino, + highlightSeriesOpts, +} from 'src/shared/graphs/helpers' +const {LINEAR, LOG, BASE_10, BASE_2} = DISPLAY_OPTIONS export default class Dygraph extends Component { constructor(props) { @@ -42,157 +41,61 @@ export default class Dygraph extends Component { } componentDidMount() { - const timeSeries = this.getTimeSeries() - // dygraphSeries is a legend label and its corresponding y-axis e.g. {legendLabel1: 'y', legendLabel2: 'y2'}; const { axes: {y, y2}, - dygraphSeries, ruleValues, - overrideLineColors, - isGraphFilled, + isGraphFilled: fillGraph, isBarGraph, options, } = this.props + const timeSeries = this.getTimeSeries() const graphRef = this.graphRef - const legendRef = this.legendRef - const finalLineColors = [...(overrideLineColors || LINE_COLORS)] - const hashColorDygraphSeries = {} - const {length} = finalLineColors - - for (const seriesName in dygraphSeries) { - const series = dygraphSeries[seriesName] - const hashIndex = hasherino(seriesName, length) - const color = finalLineColors[hashIndex] - hashColorDygraphSeries[seriesName] = {...series, color} - } - - const axisLabelWidth = - labelWidth + - y.prefix.length * avgCharPixels + - y.suffix.length * avgCharPixels - - const defaultOptions = { - plugins: isBarGraph - ? [] - : [ - new Dygraphs.Plugins.Crosshair({ - direction: 'vertical', - }), - ], + let defaultOptions = { + fillGraph, logscale: y.scale === LOG, - labelsSeparateLines: false, - labelsKMB: true, - rightGap: 0, - highlightSeriesBackgroundAlpha: 1.0, - highlightSeriesBackgroundColor: 'rgb(41, 41, 51)', - fillGraph: isGraphFilled, - axisLineWidth: 2, - gridLineWidth: 1, - highlightCircleSize: isBarGraph ? 0 : 3, - animatedZooms: true, - hideOverlayOnMouseOut: false, - colors: finalLineColors, - series: hashColorDygraphSeries, + colors: this.getLineColors(), + series: this.hashColorDygraphSeries(), + legendFormatter: this.legendFormatter, + highlightCallback: this.highlightCallback, + unhighlightCallback: this.unhighlightCallback, + plugins: [new Dygraphs.Plugins.Crosshair({direction: 'vertical'})], axes: { y: { - valueRange: getRange(timeSeries, y.bounds, ruleValues), + valueRange: options.stackedGraph + ? getStackedRange(y.bounds) + : getRange(timeSeries, y.bounds, ruleValues), axisLabelFormatter: (yval, __, opts) => numberValueFormatter(yval, opts, y.prefix, y.suffix), - axisLabelWidth, - labelsKMB: y.base === '10', - labelsKMG2: y.base === '2', + axisLabelWidth: this.getLabelWidth(), + labelsKMB: y.base === BASE_10, + labelsKMG2: y.base === BASE_2, }, y2: { valueRange: getRange(timeSeries, y2.bounds), }, }, - highlightSeriesOpts: { - strokeWidth: 2, - highlightCircleSize: isBarGraph ? 0 : 5, - }, - legendFormatter: legend => { - if (!legend.x) { - return '' - } - - const {state: {legend: prevLegend}} = this - const highlighted = legend.series.find(s => s.isHighlighted) - const prevHighlighted = prevLegend.series.find(s => s.isHighlighted) - - const yVal = highlighted && highlighted.y - const prevY = prevHighlighted && prevHighlighted.y - - if (legend.x === prevLegend.x && yVal === prevY) { - return '' - } - - this.setState({legend}) - return '' - }, - highlightCallback: e => { - // Move the Legend on hover - const graphRect = graphRef.getBoundingClientRect() - const legendRect = legendRef.getBoundingClientRect() - - const graphWidth = graphRect.width + 32 // Factoring in padding from parent - const graphHeight = graphRect.height - const graphBottom = graphRect.bottom - const legendWidth = legendRect.width - const legendHeight = legendRect.height - const screenHeight = window.innerHeight - const legendMaxLeft = graphWidth - legendWidth / 2 - const trueGraphX = e.pageX - graphRect.left - - let legendLeft = trueGraphX - - // Enforcing max & min legend offsets - if (trueGraphX < legendWidth / 2) { - legendLeft = legendWidth / 2 - } else if (trueGraphX > legendMaxLeft) { - legendLeft = legendMaxLeft - } - - // Disallow screen overflow of legend - const isLegendBottomClipped = graphBottom + legendHeight > screenHeight - - const legendTop = isLegendBottomClipped - ? graphHeight + 8 - legendHeight - : graphHeight + 8 - - legendRef.style.left = `${legendLeft}px` - legendRef.style.top = `${legendTop}px` - - this.setState({isHidden: false}) - }, - unhighlightCallback: e => { - const {top, bottom, left, right} = legendRef.getBoundingClientRect() - - const mouseY = e.clientY - const mouseX = e.clientX - - const mouseInLegendY = mouseY <= bottom && mouseY >= top - const mouseInLegendX = mouseX <= right && mouseX >= left - const isMouseHoveringLegend = mouseInLegendY && mouseInLegendX - - if (!isMouseHoveringLegend) { - this.setState({isHidden: true}) - - if (!this.visibility().find(bool => bool === true)) { - this.setState({filterText: ''}) - } - } - }, + highlightSeriesOpts, + zoomCallback: (lower, upper) => this.handleZoom(lower, upper), } if (isBarGraph) { - defaultOptions.plotter = multiColumnBarPlotter + defaultOptions = { + ...defaultOptions, + plotter: barPlotter, + plugins: [], + highlightSeriesOpts: { + ...highlightSeriesOpts, + highlightCircleSize: 0, + }, + } } this.dygraph = new Dygraphs(graphRef, timeSeries, { ...defaultOptions, ...options, + ...OPTIONS, }) const {w} = this.dygraph.getArea() @@ -227,15 +130,7 @@ export default class Dygraph extends Component { } componentDidUpdate() { - const { - labels, - axes: {y, y2}, - options, - dygraphSeries, - ruleValues, - isBarGraph, - overrideLineColors, - } = this.props + const {labels, axes: {y, y2}, options, ruleValues, isBarGraph} = this.props const dygraph = this.dygraph if (!dygraph) { @@ -245,48 +140,31 @@ export default class Dygraph extends Component { } const timeSeries = this.getTimeSeries() - const ylabel = this.getLabel('y') - const finalLineColors = [...(overrideLineColors || LINE_COLORS)] - - const hashColorDygraphSeries = {} - const {length} = finalLineColors - - for (const seriesName in dygraphSeries) { - const series = dygraphSeries[seriesName] - const hashIndex = hasherino(seriesName, length) - const color = finalLineColors[hashIndex] - hashColorDygraphSeries[seriesName] = {...series, color} - } - - const axisLabelWidth = - labelWidth + - y.prefix.length * avgCharPixels + - y.suffix.length * avgCharPixels const updateOptions = { + ...options, labels, file: timeSeries, - ylabel, logscale: y.scale === LOG, + ylabel: this.getLabel('y'), axes: { y: { - valueRange: getRange(timeSeries, y.bounds, ruleValues), + valueRange: options.stackedGraph + ? getStackedRange(y.bounds) + : getRange(timeSeries, y.bounds, ruleValues), axisLabelFormatter: (yval, __, opts) => numberValueFormatter(yval, opts, y.prefix, y.suffix), - axisLabelWidth, - labelsKMB: y.base === '10', - labelsKMG2: y.base === '2', + axisLabelWidth: this.getLabelWidth(), + labelsKMB: y.base === BASE_10, + labelsKMG2: y.base === BASE_2, }, y2: { valueRange: getRange(timeSeries, y2.bounds), }, }, - stepPlot: options.stepPlot, - stackedGraph: options.stackedGraph, - underlayCallback: options.underlayCallback, - colors: finalLineColors, - series: hashColorDygraphSeries, - plotter: isBarGraph ? multiColumnBarPlotter : null, + colors: this.getLineColors(), + series: this.hashColorDygraphSeries(), + plotter: isBarGraph ? barPlotter : null, visibility: this.visibility(), } @@ -298,6 +176,31 @@ export default class Dygraph extends Component { this.props.setResolution(w) } + handleZoom = (lower, upper) => { + const {onZoom} = this.props + + if (this.dygraph.isZoomed() === false) { + return onZoom(null, null) + } + + onZoom(this.formatTimeRange(lower), this.formatTimeRange(upper)) + } + + hashColorDygraphSeries = () => { + const {dygraphSeries} = this.props + const colors = this.getLineColors() + const hashColorDygraphSeries = {} + + for (const seriesName in dygraphSeries) { + const series = dygraphSeries[seriesName] + const hashIndex = hasherino(seriesName, colors.length) + const color = colors[hashIndex] + hashColorDygraphSeries[seriesName] = {...series, color} + } + + return hashColorDygraphSeries + } + sync = () => { if (!this.state.isSynced) { this.props.synchronizer(this.dygraph) @@ -342,6 +245,19 @@ export default class Dygraph extends Component { } } + getLineColors = () => { + return [...(this.props.overrideLineColors || LINE_COLORS)] + } + + getLabelWidth = () => { + const {axes: {y}} = this.props + return ( + LABEL_WIDTH + + y.prefix.length * CHAR_PIXELS + + y.suffix.length * CHAR_PIXELS + ) + } + visibility = () => { const timeSeries = this.getTimeSeries() const {filterText, legend} = this.state @@ -384,6 +300,103 @@ export default class Dygraph extends Component { this.dygraph.predraw_() } + formatTimeRange = timeRange => { + if (!timeRange) { + return '' + } + + return moment(timeRange).utc().format() + } + + deselectCrosshair = () => { + const plugins = this.dygraph.plugins_ + const crosshair = plugins.find( + ({plugin}) => plugin.toString() === 'Crosshair Plugin' + ) + + if (!crosshair || this.props.isBarGraph) { + return + } + + crosshair.plugin.deselect() + } + + unhighlightCallback = e => { + const {top, bottom, left, right} = this.legendRef.getBoundingClientRect() + + const mouseY = e.clientY + const mouseX = e.clientX + + const mouseBuffer = 5 + const mouseInLegendY = mouseY <= bottom && mouseY >= top - mouseBuffer + const mouseInLegendX = mouseX <= right && mouseX >= left + const isMouseHoveringLegend = mouseInLegendY && mouseInLegendX + + if (!isMouseHoveringLegend) { + this.setState({isHidden: true}) + + if (!this.visibility().find(bool => bool === true)) { + this.setState({filterText: ''}) + } + } + } + + highlightCallback = e => { + // Move the Legend on hover + const graphRect = this.graphRef.getBoundingClientRect() + const legendRect = this.legendRef.getBoundingClientRect() + + const graphWidth = graphRect.width + 32 // Factoring in padding from parent + const graphHeight = graphRect.height + const graphBottom = graphRect.bottom + const legendWidth = legendRect.width + const legendHeight = legendRect.height + const screenHeight = window.innerHeight + const legendMaxLeft = graphWidth - legendWidth / 2 + const trueGraphX = e.pageX - graphRect.left + + let legendLeft = trueGraphX + + // Enforcing max & min legend offsets + if (trueGraphX < legendWidth / 2) { + legendLeft = legendWidth / 2 + } else if (trueGraphX > legendMaxLeft) { + legendLeft = legendMaxLeft + } + + // Disallow screen overflow of legend + const isLegendBottomClipped = graphBottom + legendHeight > screenHeight + + const legendTop = isLegendBottomClipped + ? graphHeight + 8 - legendHeight + : graphHeight + 8 + + this.legendRef.style.left = `${legendLeft}px` + this.legendRef.style.top = `${legendTop}px` + + this.setState({isHidden: false}) + } + + legendFormatter = legend => { + if (!legend.x) { + return '' + } + + const {state: {legend: prevLegend}} = this + const highlighted = legend.series.find(s => s.isHighlighted) + const prevHighlighted = prevLegend.series.find(s => s.isHighlighted) + + const yVal = highlighted && highlighted.y + const prevY = prevHighlighted && prevHighlighted.y + + if (legend.x === prevLegend.x && yVal === prevY) { + return '' + } + + this.setState({legend}) + return '' + } + render() { const { legend, @@ -396,7 +409,7 @@ export default class Dygraph extends Component { } = this.state return ( -
+
{}, + onZoom: () => {}, } Dygraph.propTypes = { @@ -477,4 +491,5 @@ Dygraph.propTypes = { synchronizer: func, setResolution: func, dygraphRef: func, + onZoom: func, } diff --git a/ui/src/shared/components/FieldList.js b/ui/src/shared/components/FieldList.js index 8b5b13b3f..3f72b7edb 100644 --- a/ui/src/shared/components/FieldList.js +++ b/ui/src/shared/components/FieldList.js @@ -1,4 +1,4 @@ -import React, {PropTypes} from 'react' +import React, {PropTypes, Component} from 'react' import FieldListItem from 'src/data_explorer/components/FieldListItem' import GroupByTimeDropdown from 'src/data_explorer/components/GroupByTimeDropdown' @@ -8,42 +8,13 @@ import FancyScrollbar from 'shared/components/FancyScrollbar' import {showFieldKeys} from 'shared/apis/metaQuery' import showFieldKeysParser from 'shared/parsing/showFieldKeys' -const {bool, func, shape, string} = PropTypes - -const FieldList = React.createClass({ - propTypes: { - query: shape({ - database: string, - retentionPolicy: string, - measurement: string, - }).isRequired, - onToggleField: func.isRequired, - onGroupByTime: func.isRequired, - onFill: func.isRequired, - applyFuncsToField: func.isRequired, - isKapacitorRule: bool, - isInDataExplorer: bool, - }, - - getDefaultProps() { - return { - isKapacitorRule: false, - } - }, - - contextTypes: { - source: shape({ - links: shape({ - proxy: string.isRequired, - }).isRequired, - }).isRequired, - }, - - getInitialState() { - return { +class FieldList extends Component { + constructor(props) { + super(props) + this.state = { fields: [], } - }, + } componentDidMount() { const {database, measurement} = this.props.query @@ -52,7 +23,7 @@ const FieldList = React.createClass({ } this._getFields() - }, + } componentDidUpdate(prevProps) { const {database, measurement, retentionPolicy} = this.props.query @@ -74,20 +45,47 @@ const FieldList = React.createClass({ } this._getFields() - }, + } - handleFill(value) { - this.props.onFill(value) - }, - - handleGroupByTime(groupBy) { + handleGroupByTime = groupBy => { this.props.onGroupByTime(groupBy.menuOption) - }, + } + + handleFill = fill => { + this.props.onFill(fill) + } + + _getFields = () => { + const {database, measurement, retentionPolicy} = this.props.query + const {source} = this.context + const proxySource = source.links.proxy + + showFieldKeys( + proxySource, + database, + measurement, + retentionPolicy + ).then(resp => { + const {errors, fieldSets} = showFieldKeysParser(resp.data) + if (errors.length) { + console.error('Error parsing fields keys: ', errors) + } + + this.setState({ + fields: fieldSets[measurement].map(f => ({field: f, funcs: []})), + }) + }) + } render() { - const {query, isKapacitorRule, isInDataExplorer} = this.props - const hasAggregates = query.fields.some(f => f.funcs && f.funcs.length) - const hasGroupByTime = query.groupBy.time + const { + query: {fields = [], groupBy, fill}, + isKapacitorRule, + isInDataExplorer, + } = this.props + + const hasAggregates = fields.some(f => f.funcs && f.funcs.length) + const hasGroupByTime = groupBy.time return (
@@ -97,27 +95,24 @@ const FieldList = React.createClass({ ?
{isKapacitorRule ? null - : } + : }
: null}
{this.renderList()}
) - }, + } renderList() { - const {database, measurement} = this.props.query + const {database, measurement, fields = []} = this.props.query if (!database || !measurement) { return (
@@ -132,9 +127,7 @@ const FieldList = React.createClass({
{this.state.fields.map(fieldFunc => { - const selectedField = this.props.query.fields.find( - f => f.field === fieldFunc.field - ) + const selectedField = fields.find(f => f.field === fieldFunc.field) return (
) - }, + } +} - _getFields() { - const {database, measurement, retentionPolicy} = this.props.query - const {source} = this.context - const proxySource = source.links.proxy +const {bool, func, shape, string} = PropTypes - showFieldKeys( - proxySource, - database, - measurement, - retentionPolicy - ).then(resp => { - const {errors, fieldSets} = showFieldKeysParser(resp.data) - if (errors.length) { - // TODO: do something - } +FieldList.defaultProps = { + isKapacitorRule: false, +} - this.setState({ - fields: fieldSets[measurement].map(f => { - return {field: f, funcs: []} - }), - }) - }) - }, -}) +FieldList.contextTypes = { + source: shape({ + links: shape({ + proxy: string.isRequired, + }).isRequired, + }).isRequired, +} + +FieldList.propTypes = { + query: shape({ + database: string, + retentionPolicy: string, + measurement: string, + }).isRequired, + onToggleField: func.isRequired, + onGroupByTime: func.isRequired, + onFill: func.isRequired, + applyFuncsToField: func.isRequired, + isKapacitorRule: bool, + isInDataExplorer: bool, +} export default FieldList diff --git a/ui/src/shared/components/LayoutRenderer.js b/ui/src/shared/components/LayoutRenderer.js index f43e6c7fb..91a84ffbc 100644 --- a/ui/src/shared/components/LayoutRenderer.js +++ b/ui/src/shared/components/LayoutRenderer.js @@ -148,6 +148,7 @@ class LayoutRenderer extends Component { templates, synchronizer, isEditable, + onZoom, } = this.props return cells.map(cell => { @@ -176,6 +177,7 @@ class LayoutRenderer extends Component { queries={this.standardizeQueries(cell, source)} cellHeight={h} axes={axes} + onZoom={onZoom} />}
@@ -303,6 +305,7 @@ LayoutRenderer.propTypes = { isStatusPage: bool, isEditable: bool, onCancelEditCell: func, + onZoom: func, } export default LayoutRenderer diff --git a/ui/src/shared/components/LineGraph.js b/ui/src/shared/components/LineGraph.js index 26a2ab732..1ed333933 100644 --- a/ui/src/shared/components/LineGraph.js +++ b/ui/src/shared/components/LineGraph.js @@ -46,6 +46,7 @@ export default React.createClass({ synchronizer: func, setResolution: func, cellHeight: number, + onZoom: func, }, getDefaultProps() { @@ -101,6 +102,7 @@ export default React.createClass({ synchronizer, timeRange, cellHeight, + onZoom, } = this.props const {labels, timeSeries, dygraphSeries} = this._timeSeries @@ -176,6 +178,7 @@ export default React.createClass({ synchronizer={synchronizer} timeRange={timeRange} setResolution={this.props.setResolution} + onZoom={onZoom} /> {showSingleStat ?
diff --git a/ui/src/shared/components/RefreshingGraph.js b/ui/src/shared/components/RefreshingGraph.js index e70ff5b5f..bda40a781 100644 --- a/ui/src/shared/components/RefreshingGraph.js +++ b/ui/src/shared/components/RefreshingGraph.js @@ -10,6 +10,7 @@ const RefreshingSingleStat = AutoRefresh(SingleStat) const RefreshingGraph = ({ axes, type, + onZoom, queries, templates, timeRange, @@ -46,6 +47,7 @@ const RefreshingGraph = ({ synchronizer={synchronizer} editQueryStatus={editQueryStatus} axes={axes} + onZoom={onZoom} /> ) } @@ -64,6 +66,7 @@ RefreshingGraph.propTypes = { axes: shape(), queries: arrayOf(shape()).isRequired, editQueryStatus: func, + onZoom: func, } export default RefreshingGraph diff --git a/ui/src/shared/components/TimeRangeDropdown.js b/ui/src/shared/components/TimeRangeDropdown.js index becd9f3bb..46223cdb7 100644 --- a/ui/src/shared/components/TimeRangeDropdown.js +++ b/ui/src/shared/components/TimeRangeDropdown.js @@ -9,27 +9,21 @@ import CustomTimeRangeOverlay from 'shared/components/CustomTimeRangeOverlay' import timeRanges from 'hson!shared/data/timeRanges.hson' import {DROPDOWN_MENU_MAX_HEIGHT} from 'shared/constants/index' +const emptyTime = {lower: '', upper: ''} + class TimeRangeDropdown extends Component { constructor(props) { + super(props) const {lower, upper} = props.selected - super(props) + const isTimeValid = moment(upper).isValid() && moment(lower).isValid() + const customTimeRange = isTimeValid ? {lower, upper} : emptyTime this.state = { autobind: false, isOpen: false, isCustomTimeRangeOpen: false, - customTimeRange: - moment(props.selected.upper).isValid() && - moment(props.selected.lower).isValid() - ? { - lower, - upper, - } - : { - lower: '', - upper: '', - }, + customTimeRange, } } diff --git a/ui/src/shared/graphs/helpers.js b/ui/src/shared/graphs/helpers.js index 33786c8b5..c98911262 100644 --- a/ui/src/shared/graphs/helpers.js +++ b/ui/src/shared/graphs/helpers.js @@ -27,7 +27,7 @@ export const darkenColor = colorStr => { } // Bar Graph code below is adapted from http://dygraphs.com/tests/plotters.html -export const multiColumnBarPlotter = e => { +export const barPlotter = e => { // We need to handle all the series simultaneously. if (e.seriesIndex !== 0) { return @@ -99,3 +99,28 @@ export const multiColumnBarPlotter = e => { } } } + +export const OPTIONS = { + rightGap: 0, + axisLineWidth: 2, + gridLineWidth: 1, + animatedZooms: true, + labelsSeparateLines: false, + hideOverlayOnMouseOut: false, + highlightSeriesBackgroundAlpha: 1.0, + highlightSeriesBackgroundColor: 'rgb(41, 41, 51)', +} + +export const highlightSeriesOpts = { + strokeWidth: 2, + highlightCircleSize: 5, +} + +export const hasherino = (str, len) => + str + .split('') + .map(char => char.charCodeAt(0)) + .reduce((hash, code) => hash + code, 0) % len + +export const LABEL_WIDTH = 60 +export const CHAR_PIXELS = 7 diff --git a/ui/src/shared/parsing/getRangeForDygraph.js b/ui/src/shared/parsing/getRangeForDygraph.js index 06364a926..eec5dda7c 100644 --- a/ui/src/shared/parsing/getRangeForDygraph.js +++ b/ui/src/shared/parsing/getRangeForDygraph.js @@ -79,4 +79,10 @@ const getRange = ( return [min, max] } +const coerceToNum = str => (str ? +str : null) +export const getStackedRange = (bounds = [null, null]) => [ + coerceToNum(bounds[0]), + coerceToNum(bounds[1]), +] + export default getRange diff --git a/ui/src/side_nav/components/NavItems.js b/ui/src/side_nav/components/NavItems.js index 0752f29ef..4977b4b35 100644 --- a/ui/src/side_nav/components/NavItems.js +++ b/ui/src/side_nav/components/NavItems.js @@ -86,6 +86,7 @@ const NavBlock = React.createClass({ {this.renderSquare()}
{children} +
) diff --git a/ui/src/side_nav/containers/SideNav.js b/ui/src/side_nav/containers/SideNav.js index e782bd404..7b8597470 100644 --- a/ui/src/side_nav/containers/SideNav.js +++ b/ui/src/side_nav/containers/SideNav.js @@ -69,10 +69,14 @@ const SideNav = React.createClass({ const dataExplorerLink = `${sourcePrefix}/chronograf/data-explorer` const isUsingAuth = !!logoutLink + const isDefaultPage = location.split('/').includes(DEFAULT_HOME_PAGE) + return isHidden ? null : -
+
{ - const isSelected = query.fields.find(f => f.field === field) + const {fields, groupBy} = query + + if (!fields) { + return { + ...query, + fields: [{field, funcs: ['mean']}], + } + } + + const isSelected = fields.find(f => f.field === field) if (isSelected) { - const nextFields = query.fields.filter(f => f.field !== field) + const nextFields = fields.filter(f => f.field !== field) if (!nextFields.length) { - const nextGroupBy = {...query.groupBy, time: null} return { ...query, fields: nextFields, - groupBy: nextGroupBy, + groupBy: {...groupBy, time: null}, } }