diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0c5ee12d3d..0c2b230a69 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@
1. [#3215](https://github.com/influxdata/chronograf/pull/3215): Fix Template Variables Control Bar to top of dashboard page
1. [#3214](https://github.com/influxdata/chronograf/pull/3214): Remove extra click when creating dashboard cell
1. [#3256](https://github.com/influxdata/chronograf/pull/3256): Reduce font sizes in dashboards for increased space efficiency
+1. [#3245](https://github.com/influxdata/chronograf/pull/3245): Display 'no results' on cells without results
### Bug Fixes
diff --git a/ui/src/shared/apis/query.ts b/ui/src/shared/apis/query.ts
new file mode 100644
index 0000000000..03eab77837
--- /dev/null
+++ b/ui/src/shared/apis/query.ts
@@ -0,0 +1,74 @@
+import _ from 'lodash'
+import {fetchTimeSeriesAsync} from 'src/shared/actions/timeSeries'
+import {removeUnselectedTemplateValues} from 'src/dashboards/constants'
+
+import {intervalValuesPoints} from 'src/shared/constants'
+
+interface TemplateQuery {
+ db: string
+ rp: string
+ influxql: string
+}
+
+interface TemplateValue {
+ type: string
+ value: string
+ selected: boolean
+}
+
+interface Template {
+ type: string
+ tempVar: string
+ query: TemplateQuery
+ values: TemplateValue[]
+}
+
+interface Query {
+ host: string | string[]
+ text: string
+ database: string
+ db: string
+ rp: string
+}
+
+export const fetchTimeSeries = async (
+ queries: Query[],
+ resolution: number,
+ templates: Template[],
+ editQueryStatus: () => void
+) => {
+ const timeSeriesPromises = queries.map(query => {
+ const {host, database, rp} = query
+ // the key `database` was used upstream in HostPage.js, and since as of this writing
+ // the codebase has not been fully converted to TypeScript, it's not clear where else
+ // it may be used, but this slight modification is intended to allow for the use of
+ // `database` while moving over to `db` for consistency over time
+ const db = _.get(query, 'db', database)
+
+ const templatesWithIntervalVals = templates.map(temp => {
+ if (temp.tempVar === ':interval:') {
+ if (resolution) {
+ const values = temp.values.map(v => ({
+ ...v,
+ value: `${_.toInteger(Number(resolution) / 3)}`,
+ }))
+
+ return {...temp, values}
+ }
+
+ return {...temp, values: intervalValuesPoints}
+ }
+ return temp
+ })
+
+ const tempVars = removeUnselectedTemplateValues(templatesWithIntervalVals)
+
+ const source = host
+ return fetchTimeSeriesAsync(
+ {source, db, rp, query, tempVars, resolution},
+ editQueryStatus
+ )
+ })
+
+ return Promise.all(timeSeriesPromises)
+}
diff --git a/ui/src/shared/components/AutoRefresh.js b/ui/src/shared/components/AutoRefresh.js
deleted file mode 100644
index dda8bc5d00..0000000000
--- a/ui/src/shared/components/AutoRefresh.js
+++ /dev/null
@@ -1,294 +0,0 @@
-import React, {Component} from 'react'
-import PropTypes from 'prop-types'
-import _ from 'lodash'
-
-import {fetchTimeSeriesAsync} from 'shared/actions/timeSeries'
-import {removeUnselectedTemplateValues} from 'src/dashboards/constants'
-import {intervalValuesPoints} from 'src/shared/constants'
-import {getQueryConfig} from 'shared/apis'
-
-const AutoRefresh = ComposedComponent => {
- class wrapper extends Component {
- constructor() {
- super()
- this.state = {
- lastQuerySuccessful: true,
- timeSeries: [],
- resolution: null,
- queryASTs: [],
- }
- }
-
- async componentDidMount() {
- const {queries, templates, autoRefresh, type} = this.props
- this.executeQueries(queries, templates)
- if (type === 'table') {
- const queryASTs = await this.getQueryASTs(queries, templates)
- this.setState({queryASTs})
- }
- if (autoRefresh) {
- this.intervalID = setInterval(
- () => this.executeQueries(queries, templates),
- autoRefresh
- )
- }
- }
-
- getQueryASTs = async (queries, templates) => {
- return await Promise.all(
- queries.map(async q => {
- const host = _.isArray(q.host) ? q.host[0] : q.host
- const url = host.replace('proxy', 'queries')
- const text = q.text
- const {data} = await getQueryConfig(url, [{query: text}], templates)
- return data.queries[0].queryAST
- })
- )
- }
-
- async componentWillReceiveProps(nextProps) {
- const inViewDidUpdate = this.props.inView !== nextProps.inView
-
- const queriesDidUpdate = this.queryDifference(
- this.props.queries,
- nextProps.queries
- ).length
-
- const tempVarsDidUpdate = !_.isEqual(
- this.props.templates,
- nextProps.templates
- )
-
- const shouldRefetch =
- queriesDidUpdate || tempVarsDidUpdate || inViewDidUpdate
-
- if (shouldRefetch) {
- if (this.props.type === 'table') {
- const queryASTs = await this.getQueryASTs(
- nextProps.queries,
- nextProps.templates
- )
- this.setState({queryASTs})
- }
-
- this.executeQueries(
- nextProps.queries,
- nextProps.templates,
- nextProps.inView
- )
- }
-
- if (this.props.autoRefresh !== nextProps.autoRefresh || shouldRefetch) {
- clearInterval(this.intervalID)
-
- if (nextProps.autoRefresh) {
- this.intervalID = setInterval(
- () =>
- this.executeQueries(
- nextProps.queries,
- nextProps.templates,
- nextProps.inView
- ),
- nextProps.autoRefresh
- )
- }
- }
- }
-
- queryDifference = (left, right) => {
- const leftStrs = left.map(q => `${q.host}${q.text}`)
- const rightStrs = right.map(q => `${q.host}${q.text}`)
- return _.difference(
- _.union(leftStrs, rightStrs),
- _.intersection(leftStrs, rightStrs)
- )
- }
-
- executeQueries = async (
- queries,
- templates = [],
- inView = this.props.inView
- ) => {
- const {editQueryStatus, grabDataForDownload} = this.props
- const {resolution} = this.state
- if (!inView) {
- return
- }
- if (!queries.length) {
- this.setState({timeSeries: []})
- return
- }
-
- this.setState({isFetching: true})
-
- const timeSeriesPromises = queries.map(query => {
- const {host, database, rp} = query
- // the key `database` was used upstream in HostPage.js, and since as of this writing
- // the codebase has not been fully converted to TypeScript, it's not clear where else
- // it may be used, but this slight modification is intended to allow for the use of
- // `database` while moving over to `db` for consistency over time
- const db = _.get(query, 'db', database)
-
- const templatesWithIntervalVals = templates.map(temp => {
- if (temp.tempVar === ':interval:') {
- if (resolution) {
- // resize event
- return {
- ...temp,
- values: temp.values.map(v => ({
- ...v,
- value: `${_.toInteger(Number(resolution) / 3)}`,
- })),
- }
- }
-
- return {
- ...temp,
- values: intervalValuesPoints,
- }
- }
- return temp
- })
-
- const tempVars = removeUnselectedTemplateValues(
- templatesWithIntervalVals
- )
- return fetchTimeSeriesAsync(
- {
- source: host,
- db,
- rp,
- query,
- tempVars,
- resolution,
- },
- editQueryStatus
- )
- })
-
- try {
- const timeSeries = await Promise.all(timeSeriesPromises)
- const newSeries = timeSeries.map(response => ({response}))
- const lastQuerySuccessful = this._resultsForQuery(newSeries)
-
- this.setState({
- timeSeries: newSeries,
- lastQuerySuccessful,
- isFetching: false,
- })
-
- if (grabDataForDownload) {
- grabDataForDownload(timeSeries)
- }
- } catch (err) {
- console.error(err)
- }
- }
-
- componentWillUnmount() {
- clearInterval(this.intervalID)
- this.intervalID = false
- }
-
- setResolution = resolution => {
- if (resolution !== this.state.resolution) {
- this.setState({resolution})
- }
- }
-
- render() {
- const {timeSeries, queryASTs} = this.state
- if (this.state.isFetching && this.state.lastQuerySuccessful) {
- return (
-
- )
- }
-
- return (
-
- )
- }
-
- _resultsForQuery = data =>
- data.length
- ? data.every(({response}) =>
- _.get(response, 'results', []).every(
- result =>
- Object.keys(result).filter(k => k !== 'statement_id').length !==
- 0
- )
- )
- : false
- }
-
- wrapper.defaultProps = {
- inView: true,
- }
-
- const {
- array,
- arrayOf,
- bool,
- element,
- func,
- number,
- oneOfType,
- shape,
- string,
- } = PropTypes
-
- wrapper.propTypes = {
- type: string.isRequired,
- children: element,
- autoRefresh: number.isRequired,
- inView: bool,
- templates: arrayOf(
- shape({
- type: string.isRequired,
- tempVar: string.isRequired,
- query: shape({
- db: string,
- rp: string,
- influxql: string,
- }),
- values: arrayOf(
- shape({
- type: string.isRequired,
- value: string.isRequired,
- selected: bool,
- })
- ).isRequired,
- })
- ),
- queries: arrayOf(
- shape({
- host: oneOfType([string, arrayOf(string)]),
- text: string,
- }).isRequired
- ).isRequired,
- axes: shape({
- bounds: shape({
- y: array,
- y2: array,
- }),
- }),
- editQueryStatus: func,
- grabDataForDownload: func,
- }
-
- return wrapper
-}
-
-export default AutoRefresh
diff --git a/ui/src/shared/components/AutoRefresh.tsx b/ui/src/shared/components/AutoRefresh.tsx
new file mode 100644
index 0000000000..b1df696c09
--- /dev/null
+++ b/ui/src/shared/components/AutoRefresh.tsx
@@ -0,0 +1,288 @@
+import React, {Component, ComponentClass} from 'react'
+import _ from 'lodash'
+
+import {getQueryConfig} from 'src/shared/apis'
+import {fetchTimeSeries} from 'src/shared/apis/query'
+import {DEFAULT_TIME_SERIES} from 'src/shared/constants/series'
+import {TimeSeriesServerResponse, TimeSeriesResponse} from 'src/types/series'
+
+interface Axes {
+ bounds: {
+ y: number[]
+ y2: number[]
+ }
+}
+
+interface Query {
+ host: string | string[]
+ text: string
+ database: string
+ db: string
+ rp: string
+}
+
+interface TemplateQuery {
+ db: string
+ rp: string
+ influxql: string
+}
+
+interface TemplateValue {
+ type: string
+ value: string
+ selected: boolean
+}
+
+interface Template {
+ type: string
+ tempVar: string
+ query: TemplateQuery
+ values: TemplateValue[]
+}
+
+export interface Props {
+ type: string
+ autoRefresh: number
+ inView: boolean
+ templates: Template[]
+ queries: Query[]
+ axes: Axes
+ editQueryStatus: () => void
+ grabDataForDownload: (timeSeries: TimeSeriesServerResponse[]) => void
+}
+
+interface QueryAST {
+ groupBy?: {
+ tags: string[]
+ }
+}
+
+interface State {
+ isFetching: boolean
+ isLastQuerySuccessful: boolean
+ timeSeries: TimeSeriesServerResponse[]
+ resolution: number | null
+ queryASTs?: QueryAST[]
+}
+
+export interface OriginalProps {
+ data: TimeSeriesServerResponse[]
+ setResolution: (resolution: number) => void
+ isFetchingInitially?: boolean
+ isRefreshing?: boolean
+ queryASTs?: QueryAST[]
+}
+
+const AutoRefresh = (
+ ComposedComponent: ComponentClass
+) => {
+ class Wrapper extends Component {
+ public static defaultProps = {
+ inView: true,
+ }
+
+ private intervalID: NodeJS.Timer | null
+
+ constructor(props: Props) {
+ super(props)
+ this.state = {
+ isFetching: false,
+ isLastQuerySuccessful: true,
+ timeSeries: DEFAULT_TIME_SERIES,
+ resolution: null,
+ queryASTs: [],
+ }
+ }
+
+ public async componentDidMount() {
+ if (this.isTable) {
+ const queryASTs = await this.getQueryASTs()
+ this.setState({queryASTs})
+ }
+
+ this.startNewPolling()
+ }
+
+ public async componentDidUpdate(prevProps: Props) {
+ if (!this.isPropsDifferent(prevProps)) {
+ return
+ }
+
+ if (this.isTable) {
+ const queryASTs = await this.getQueryASTs()
+ this.setState({queryASTs})
+ }
+
+ this.startNewPolling()
+ }
+
+ public executeQueries = async () => {
+ const {editQueryStatus, grabDataForDownload, inView, queries} = this.props
+ const {resolution} = this.state
+
+ if (!inView) {
+ return
+ }
+
+ if (!queries.length) {
+ this.setState({timeSeries: DEFAULT_TIME_SERIES})
+ return
+ }
+
+ this.setState({isFetching: true})
+ const templates: Template[] = _.get(this.props, 'templates', [])
+
+ try {
+ const timeSeries = await fetchTimeSeries(
+ queries,
+ resolution,
+ templates,
+ editQueryStatus
+ )
+ const newSeries = timeSeries.map((response: TimeSeriesResponse) => ({
+ response,
+ }))
+ const isLastQuerySuccessful = this.hasResultsForQuery(newSeries)
+
+ this.setState({
+ timeSeries: newSeries,
+ isLastQuerySuccessful,
+ isFetching: false,
+ })
+
+ if (grabDataForDownload) {
+ grabDataForDownload(newSeries)
+ }
+ } catch (err) {
+ console.error(err)
+ }
+ }
+
+ public componentWillUnmount() {
+ this.clearInterval()
+ }
+
+ public render() {
+ const {
+ timeSeries,
+ queryASTs,
+ isFetching,
+ isLastQuerySuccessful,
+ } = this.state
+
+ const hasValues = _.some(timeSeries, s => {
+ const results = _.get(s, 'response.results', [])
+ const v = _.some(results, r => r.series)
+ return v
+ })
+
+ if (!hasValues) {
+ return (
+
+ )
+ }
+
+ if (isFetching && isLastQuerySuccessful) {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+ }
+
+ private setResolution = resolution => {
+ if (resolution !== this.state.resolution) {
+ this.setState({resolution})
+ }
+ }
+
+ private clearInterval() {
+ if (!this.intervalID) {
+ return
+ }
+
+ clearInterval(this.intervalID)
+ this.intervalID = null
+ }
+
+ private isPropsDifferent(nextProps: Props) {
+ return (
+ this.props.inView !== nextProps.inView ||
+ !!this.queryDifference(this.props.queries, nextProps.queries).length ||
+ !_.isEqual(this.props.templates, nextProps.templates) ||
+ this.props.autoRefresh !== nextProps.autoRefresh
+ )
+ }
+
+ private startNewPolling() {
+ this.clearInterval()
+
+ const {autoRefresh} = this.props
+
+ this.executeQueries()
+
+ if (autoRefresh) {
+ this.intervalID = setInterval(this.executeQueries, autoRefresh)
+ }
+ }
+
+ private queryDifference = (left, right) => {
+ const mapper = q => `${q.host}${q.text}`
+ const leftStrs = left.map(mapper)
+ const rightStrs = right.map(mapper)
+ return _.difference(
+ _.union(leftStrs, rightStrs),
+ _.intersection(leftStrs, rightStrs)
+ )
+ }
+
+ private get isTable(): boolean {
+ return this.props.type === 'table'
+ }
+
+ private getQueryASTs = async (): Promise => {
+ const {queries, templates} = this.props
+
+ return await Promise.all(
+ queries.map(async q => {
+ const host = _.isArray(q.host) ? q.host[0] : q.host
+ const url = host.replace('proxy', 'queries')
+ const text = q.text
+ const {data} = await getQueryConfig(url, [{query: text}], templates)
+ return data.queries[0].queryAST
+ })
+ )
+ }
+
+ private hasResultsForQuery = (data): boolean => {
+ if (!data.length) {
+ return false
+ }
+
+ data.every(({resp}) =>
+ _.get(resp, 'results', []).every(r => Object.keys(r).length > 1)
+ )
+ }
+ }
+
+ return Wrapper
+}
+
+export default AutoRefresh
diff --git a/ui/src/shared/components/Crosshair.tsx b/ui/src/shared/components/Crosshair.tsx
index fbd972a262..79b63af286 100644
--- a/ui/src/shared/components/Crosshair.tsx
+++ b/ui/src/shared/components/Crosshair.tsx
@@ -1,3 +1,4 @@
+import _ from 'lodash'
import React, {PureComponent} from 'react'
import Dygraph from 'dygraphs'
import {connect} from 'react-redux'
@@ -35,7 +36,7 @@ class Crosshair extends PureComponent {
private get isVisible() {
const {hoverTime} = this.props
- return hoverTime !== 0
+ return hoverTime !== 0 && _.isFinite(hoverTime)
}
private get crosshairLeft(): number {
diff --git a/ui/src/shared/constants/series.ts b/ui/src/shared/constants/series.ts
new file mode 100644
index 0000000000..3177b6d3e3
--- /dev/null
+++ b/ui/src/shared/constants/series.ts
@@ -0,0 +1,7 @@
+export const DEFAULT_TIME_SERIES = [
+ {
+ response: {
+ results: [],
+ },
+ },
+]
diff --git a/ui/src/types/series.ts b/ui/src/types/series.ts
new file mode 100644
index 0000000000..3293f9a95e
--- /dev/null
+++ b/ui/src/types/series.ts
@@ -0,0 +1,20 @@
+export type TimeSeriesValue = string | number | Date | null
+
+export interface Series {
+ name: string
+ columns: string[]
+ values: TimeSeriesValue[]
+}
+
+export interface Result {
+ series: Series[]
+ statement_id: number
+}
+
+export interface TimeSeriesResponse {
+ results: Result[]
+}
+
+export interface TimeSeriesServerResponse {
+ response: TimeSeriesResponse
+}
diff --git a/ui/test/shared/components/AutoRefresh.test.tsx b/ui/test/shared/components/AutoRefresh.test.tsx
new file mode 100644
index 0000000000..bf750e6684
--- /dev/null
+++ b/ui/test/shared/components/AutoRefresh.test.tsx
@@ -0,0 +1,74 @@
+import AutoRefresh, {
+ Props,
+ OriginalProps,
+} from 'src/shared/components/AutoRefresh'
+import React, {Component} from 'react'
+import {shallow} from 'enzyme'
+
+type ComponentProps = Props & OriginalProps
+
+class MyComponent extends Component {
+ public render(): JSX.Element {
+ return Here
+ }
+}
+
+const axes = {
+ bounds: {
+ y: [1],
+ y2: [2],
+ },
+}
+
+const defaultProps = {
+ type: 'table',
+ autoRefresh: 1,
+ inView: true,
+ templates: [],
+ queries: [],
+ axes,
+ editQueryStatus: () => {},
+ grabDataForDownload: () => {},
+ data: [],
+ setResolution: () => {},
+ isFetchingInitially: false,
+ isRefreshing: false,
+ queryASTs: [],
+}
+
+const setup = (overrides: Partial = {}) => {
+ const ARComponent = AutoRefresh(MyComponent)
+
+ const props = {...defaultProps, ...overrides}
+
+ return shallow()
+}
+
+describe('Shared.Components.AutoRefresh', () => {
+ describe('render', () => {
+ describe('when there are no results', () => {
+ it('renders the no results component', () => {
+ const wrapped = setup()
+ expect(wrapped.find('.graph-empty').exists()).toBe(true)
+ })
+ })
+
+ describe('when there are results', () => {
+ it('renderes the wrapped component', () => {
+ const wrapped = setup()
+ const timeSeries = [
+ {
+ response: {
+ results: [{series: [1]}],
+ },
+ },
+ ]
+ wrapped.update()
+ wrapped.setState({timeSeries})
+ process.nextTick(() => {
+ expect(wrapped.find(MyComponent).exists()).toBe(true)
+ })
+ })
+ })
+ })
+})