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 ( +
+

No Results

+
+ ) + } + + 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) + }) + }) + }) + }) +})