Merge pull request #3325 from influxdata/migrate-auto-refresh

Migrate auto refresh
pull/10616/head
Brandon Farmer 2018-04-30 09:35:08 -07:00 committed by GitHub
commit 8267df972a
8 changed files with 466 additions and 295 deletions

View File

@ -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

View File

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

View File

@ -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 (
<ComposedComponent
{...this.props}
data={timeSeries}
setResolution={this.setResolution}
isFetchingInitially={false}
isRefreshing={true}
queryASTs={queryASTs}
/>
)
}
return (
<ComposedComponent
{...this.props}
data={timeSeries}
setResolution={this.setResolution}
queryASTs={queryASTs}
/>
)
}
_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

View File

@ -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<OriginalProps & Props>
) => {
class Wrapper extends Component<Props, State> {
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 (
<div className="graph-empty">
<p>No Results</p>
</div>
)
}
if (isFetching && isLastQuerySuccessful) {
return (
<ComposedComponent
{...this.props}
data={timeSeries}
setResolution={this.setResolution}
isFetchingInitially={false}
isRefreshing={true}
queryASTs={queryASTs}
/>
)
}
return (
<ComposedComponent
{...this.props}
data={timeSeries}
setResolution={this.setResolution}
queryASTs={queryASTs}
/>
)
}
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<QueryAST[]> => {
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

View File

@ -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<Props> {
private get isVisible() {
const {hoverTime} = this.props
return hoverTime !== 0
return hoverTime !== 0 && _.isFinite(hoverTime)
}
private get crosshairLeft(): number {

View File

@ -0,0 +1,7 @@
export const DEFAULT_TIME_SERIES = [
{
response: {
results: [],
},
},
]

20
ui/src/types/series.ts Normal file
View File

@ -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
}

View File

@ -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<ComponentProps> {
public render(): JSX.Element {
return <p>Here</p>
}
}
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<ComponentProps> = {}) => {
const ARComponent = AutoRefresh(MyComponent)
const props = {...defaultProps, ...overrides}
return shallow(<ARComponent {...props} />)
}
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)
})
})
})
})
})