fix(ui): improve single stat computation

The method we used to compute a single stat / gauge value previously did
account for missing data. If the latest value in a response was part of
a numeric column but was null/NaN/not defined, the single stat
computation would fail and a user would see an error message "Could not
display single stat because your values are non-numeric".

This commit updates the single stat computation to find the latest
*defined* numeric values.

If no latest valid numeric values are found, we will either:

- Display an error message if using the compuation within a single stat
  visualization
- Display nothing if using the computation within a within a line +
  single stat visualization (i.e. display the line vis only)

If multiple latest values are found, we make an arbitrary selection
(same as previous behavior). The goal is to eventually expose UI
elements to the user so they can make this selection themselves.

This commit also updates the single stat computation to use the
@influxdata/vis `Table` format as an intermediate/parsed representation
of a Flux CSV response. This unlocks the possibility for performance
gains in our CSV parsing. See #13852.

Closes #13824
pull/13885/head
Christopher Henn 2019-05-08 11:47:12 -07:00
parent 6e8a845714
commit adfb6a9b46
15 changed files with 372 additions and 499 deletions

View File

@ -11,6 +11,7 @@
1. [13800](https://github.com/influxdata/influxdb/pull/13800): Generate more idiomatic Flux in query builder
1. [13797](https://github.com/influxdata/influxdb/pull/13797): Expand tab key presses to 2 spaces in the Flux editor
1. [13823](https://github.com/influxdata/influxdb/pull/13823): Prevent dragging of Variable Dropdowns when dragging a scrollbar inside the dropdown
1. [13853](https://github.com/influxdata/influxdb/pull/13853): Improve single stat computation
### UI Improvements
1. [#13835](https://github.com/influxdata/influxdb/pull/13835): Render checkboxes in query builder tag selection lists

41
ui/package-lock.json generated
View File

@ -6046,8 +6046,7 @@
"version": "2.1.1",
"resolved": false,
"integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=",
"dev": true,
"optional": true
"dev": true
},
"aproba": {
"version": "1.2.0",
@ -6071,15 +6070,13 @@
"version": "1.0.0",
"resolved": false,
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=",
"dev": true,
"optional": true
"dev": true
},
"brace-expansion": {
"version": "1.1.11",
"resolved": false,
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dev": true,
"optional": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@ -6096,22 +6093,19 @@
"version": "1.1.0",
"resolved": false,
"integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=",
"dev": true,
"optional": true
"dev": true
},
"concat-map": {
"version": "0.0.1",
"resolved": false,
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
"dev": true,
"optional": true
"dev": true
},
"console-control-strings": {
"version": "1.1.0",
"resolved": false,
"integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=",
"dev": true,
"optional": true
"dev": true
},
"core-util-is": {
"version": "1.0.2",
@ -6242,8 +6236,7 @@
"version": "2.0.3",
"resolved": false,
"integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=",
"dev": true,
"optional": true
"dev": true
},
"ini": {
"version": "1.3.5",
@ -6257,7 +6250,6 @@
"resolved": false,
"integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
"dev": true,
"optional": true,
"requires": {
"number-is-nan": "^1.0.0"
}
@ -6274,7 +6266,6 @@
"resolved": false,
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
"dev": true,
"optional": true,
"requires": {
"brace-expansion": "^1.1.7"
}
@ -6283,15 +6274,13 @@
"version": "0.0.8",
"resolved": false,
"integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=",
"dev": true,
"optional": true
"dev": true
},
"minipass": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-2.2.4.tgz",
"integrity": "sha512-hzXIWWet/BzWhYs2b+u7dRHlruXhwdgvlTMDKC6Cb1U7ps6Ac6yQlR39xsbjWJE377YTCtKwIXIpJ5oP+j5y8g==",
"dev": true,
"optional": true,
"requires": {
"safe-buffer": "^5.1.1",
"yallist": "^3.0.0"
@ -6312,7 +6301,6 @@
"resolved": false,
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
"dev": true,
"optional": true,
"requires": {
"minimist": "0.0.8"
}
@ -6401,8 +6389,7 @@
"version": "1.0.1",
"resolved": false,
"integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=",
"dev": true,
"optional": true
"dev": true
},
"object-assign": {
"version": "4.1.1",
@ -6416,7 +6403,6 @@
"resolved": false,
"integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
"dev": true,
"optional": true,
"requires": {
"wrappy": "1"
}
@ -6512,8 +6498,7 @@
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz",
"integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==",
"dev": true,
"optional": true
"dev": true
},
"safer-buffer": {
"version": "2.1.2",
@ -6555,7 +6540,6 @@
"resolved": false,
"integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=",
"dev": true,
"optional": true,
"requires": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
@ -6577,7 +6561,6 @@
"resolved": false,
"integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
"dev": true,
"optional": true,
"requires": {
"ansi-regex": "^2.0.0"
}
@ -6626,15 +6609,13 @@
"version": "1.0.2",
"resolved": false,
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
"dev": true,
"optional": true
"dev": true
},
"yallist": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.2.tgz",
"integrity": "sha1-hFK0u36Dx8GI2AQcGoN8dz1ti7k=",
"dev": true,
"optional": true
"dev": true
}
}
},

View File

@ -4,29 +4,13 @@ import Gauge from 'src/shared/components/Gauge'
import GaugeChart from 'src/shared/components/GaugeChart'
import {ViewType, ViewShape, GaugeView} from 'src/types/dashboards'
const tables = [
{
id: '54797afd-734d-4ca3-94b6-3a7870c53b27',
data: [
['', 'result', 'table', '_time', 'mean', '_measurement'],
['', '', '0', '2018-09-27T16:50:10Z', '2', 'cpu'],
],
name: '_measurement=cpu',
groupKey: {
_measurement: 'cpu',
},
dataTypes: {
'': '#datatype',
result: 'string',
table: 'long',
_time: 'dateTime:RFC3339',
mean: 'double',
_measurement: 'string',
},
},
]
const properties: GaugeView = {
describe('GaugeChart', () => {
describe('render', () => {
describe('when data has a value', () => {
it('renders the correct number', () => {
const props = {
value: 2,
properties: {
queries: [],
colors: [],
shape: ViewShape.ChronografV2,
@ -39,36 +23,10 @@ const properties: GaugeView = {
digits: 10,
isEnforced: false,
},
}
const defaultProps = {
tables: [],
properties,
}
const setup = (overrides = {}) => {
const props = {
...defaultProps,
...overrides,
} as GaugeView,
}
return shallow(<GaugeChart {...props} />)
}
describe('GaugeChart', () => {
describe('render', () => {
describe('when data is empty', () => {
it('renders the correct number', () => {
const wrapper = setup()
expect(wrapper.find(Gauge).exists()).toBe(true)
expect(wrapper.find(Gauge).props().gaugePosition).toBe(0)
})
})
describe('when data has a value', () => {
it('renders the correct number', () => {
const wrapper = setup({tables})
const wrapper = shallow(<GaugeChart {...props} />)
expect(wrapper.find(Gauge).exists()).toBe(true)
expect(wrapper.find(Gauge).props().gaugePosition).toBe(2)

View File

@ -1,35 +1,26 @@
// Libraries
import React, {PureComponent} from 'react'
import memoizeOne from 'memoize-one'
import _ from 'lodash'
// Components
import Gauge from 'src/shared/components/Gauge'
// Parsing
import {lastValue} from 'src/shared/parsing/flux/lastValue'
// Types
import {FluxTable} from 'src/types'
import {GaugeView} from 'src/types/dashboards'
import {ErrorHandling} from 'src/shared/decorators/errors'
interface Props {
tables: FluxTable[]
value: number
properties: GaugeView
}
@ErrorHandling
class GaugeChart extends PureComponent<Props> {
private lastValue = memoizeOne(lastValue)
public render() {
const {tables} = this.props
const {value} = this.props
const {colors, prefix, suffix, decimalPlaces} = this.props.properties
const lastValue = this.lastValue(tables) || 0
return (
<div className="single-stat">
<Gauge
@ -38,7 +29,7 @@ class GaugeChart extends PureComponent<Props> {
colors={colors}
prefix={prefix}
suffix={suffix}
gaugePosition={lastValue}
gaugePosition={value}
decimalPlaces={decimalPlaces}
/>
</div>

View File

@ -0,0 +1,40 @@
// Libraries
import React, {useMemo, FunctionComponent} from 'react'
import {Table} from '@influxdata/vis'
// Components
import EmptyGraphMessage from 'src/shared/components/EmptyGraphMessage'
// Utils
import {latestValues as getLatestValues} from 'src/shared/utils/latestValues'
interface Props {
table: Table
children: (latestValue: number) => JSX.Element
// If `quiet` is set and a latest value can't be found, this component will
// display nothing instead of an empty graph error message
quiet?: boolean
}
const LatestValueTransform: FunctionComponent<Props> = ({
table,
quiet = false,
children,
}) => {
const latestValues = useMemo(() => getLatestValues(table), [table])
if (latestValues.length === 0 && quiet) {
return null
}
if (latestValues.length === 0) {
return <EmptyGraphMessage message="No latest value found" />
}
const latestValue = latestValues[0]
return children(latestValue)
}
export default LatestValueTransform

View File

@ -5,11 +5,11 @@ import {Plot} from '@influxdata/vis'
// Components
import GaugeChart from 'src/shared/components/GaugeChart'
import SingleStat from 'src/shared/components/SingleStat'
import SingleStatTransform from 'src/shared/components/SingleStatTransform'
import TableGraphs from 'src/shared/components/tables/TableGraphs'
import HistogramContainer from 'src/shared/components/HistogramContainer'
import VisTableTransform from 'src/shared/components/VisTableTransform'
import XYContainer from 'src/shared/components/XYContainer'
import LatestValueTransform from 'src/shared/components/LatestValueTransform'
// Types
import {
@ -37,14 +37,30 @@ const RefreshingViewSwitcher: FunctionComponent<Props> = ({
switch (properties.type) {
case ViewType.SingleStat:
return (
<SingleStatTransform tables={tables}>
{stat => <SingleStat stat={stat} properties={properties} />}
</SingleStatTransform>
<VisTableTransform files={files}>
{table => (
<LatestValueTransform table={table}>
{latestValue => (
<SingleStat stat={latestValue} properties={properties} />
)}
</LatestValueTransform>
)}
</VisTableTransform>
)
case ViewType.Table:
return <TableGraphs tables={tables} properties={properties} />
case ViewType.Gauge:
return <GaugeChart tables={tables} properties={properties} />
return (
<VisTableTransform files={files}>
{table => (
<LatestValueTransform table={table}>
{latestValue => (
<GaugeChart value={latestValue} properties={properties} />
)}
</LatestValueTransform>
)}
</VisTableTransform>
)
case ViewType.XY:
return (
<XYContainer
@ -77,11 +93,14 @@ const RefreshingViewSwitcher: FunctionComponent<Props> = ({
>
{config => (
<Plot config={config}>
<SingleStatTransform tables={tables}>
{stat => (
<SingleStat stat={stat} properties={singleStatProperties} />
<LatestValueTransform table={config.table} quiet={true}>
{latestValue => (
<SingleStat
stat={latestValue}
properties={singleStatProperties}
/>
)}
</SingleStatTransform>
</LatestValueTransform>
</Plot>
)}
</XYContainer>

View File

@ -1,36 +0,0 @@
// Libraries
import React, {PureComponent} from 'react'
import memoizeOne from 'memoize-one'
import _ from 'lodash'
// Components
import EmptyGraphMessage from 'src/shared/components/EmptyGraphMessage'
// Parsing
import {lastValue} from 'src/shared/parsing/flux/lastValue'
// Types
import {FluxTable} from 'src/types'
const NON_NUMERIC_ERROR =
'Could not display single stat because your values are non-numeric'
interface Props {
tables: FluxTable[]
children: (stat: number) => JSX.Element
}
export default class SingleStatTransform extends PureComponent<Props> {
private lastValue = memoizeOne(lastValue)
public render() {
const {tables} = this.props
const lastValue = this.lastValue(tables)
if (!_.isFinite(lastValue)) {
return <EmptyGraphMessage message={NON_NUMERIC_ERROR} />
}
return this.props.children(lastValue)
}
}

View File

@ -1,48 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`spreadTables it spreads multiple series into a single table 1`] = `
Object {
"seriesDescriptions": Array [
Object {
"key": "_value[result=max][_field=active][_measurement=mem][host=oox4k.local]",
"metaColumns": Object {
"_field": "active",
"_measurement": "mem",
"host": "oox4k.local",
"result": "max",
},
"valueColumnIndex": 6,
"valueColumnName": "_value",
},
Object {
"key": "_value[result=min][_field=active][_measurement=mem][host=oox4k.local]",
"metaColumns": Object {
"_field": "active",
"_measurement": "mem",
"host": "oox4k.local",
"result": "min",
},
"valueColumnIndex": 6,
"valueColumnName": "_value",
},
],
"table": Object {
"2018-12-10T18:29:48Z": Object {
"_value[result=min][_field=active][_measurement=mem][host=oox4k.local]": 4589981696,
},
"2018-12-10T18:29:58Z": Object {
"_value[result=max][_field=active][_measurement=mem][host=oox4k.local]": 4906213376,
},
"2018-12-10T18:40:18Z": Object {
"_value[result=min][_field=active][_measurement=mem][host=oox4k.local]": 4318040064,
},
"2018-12-10T18:54:08Z": Object {
"_value[result=max][_field=active][_measurement=mem][host=oox4k.local]": 5860683776,
},
"2018-12-10T19:11:58Z": Object {
"_value[result=max][_field=active][_measurement=mem][host=oox4k.local]": 5115428864,
"_value[result=min][_field=active][_measurement=mem][host=oox4k.local]": 4131692544,
},
},
}
`;

View File

@ -1,44 +0,0 @@
import {lastValue} from 'src/shared/parsing/flux/lastValue'
import {parseResponse} from 'src/shared/parsing/flux/response'
describe('lastValue', () => {
test('the last value returned does not depend on the ordering of series', () => {
const respA = `#group,false,false,false,false,false,false,true,true,true
#datatype,string,long,dateTime:RFC3339,dateTime:RFC3339,dateTime:RFC3339,long,string,string,string
#default,0,,,,,,,,
,result,table,_start,_stop,_time,_value,_field,_measurement,host
,,0,2018-12-10T18:21:52.748859Z,2018-12-10T18:30:00Z,2018-12-10T18:29:58Z,1,active,mem,oox4k.local
,,0,2018-12-10T18:30:00Z,2018-12-10T19:00:00Z,2018-12-10T18:54:08Z,2,active,mem,oox4k.local
#group,false,false,false,false,false,false,true,true,true
#datatype,string,long,dateTime:RFC3339,dateTime:RFC3339,dateTime:RFC3339,long,string,string,string
#default,1,,,,,,,,
,result,table,_start,_stop,_time,_value,_field,_measurement,host
,,0,2018-12-10T18:21:52.748859Z,2018-12-10T18:30:00Z,2018-12-10T18:29:48Z,3,active,mem,oox4k.local
,,0,2018-12-10T18:30:00Z,2018-12-10T19:00:00Z,2018-12-10T18:40:18Z,4,active,mem,oox4k.local
`
const respB = `#group,false,false,false,false,false,false,true,true,true
#datatype,string,long,dateTime:RFC3339,dateTime:RFC3339,dateTime:RFC3339,long,string,string,string
#default,1,,,,,,,,
,result,table,_start,_stop,_time,_value,_field,_measurement,host
,,0,2018-12-10T18:21:52.748859Z,2018-12-10T18:30:00Z,2018-12-10T18:29:48Z,3,active,mem,oox4k.local
,,0,2018-12-10T18:30:00Z,2018-12-10T19:00:00Z,2018-12-10T18:40:18Z,4,active,mem,oox4k.local
#group,false,false,false,false,false,false,true,true,true
#datatype,string,long,dateTime:RFC3339,dateTime:RFC3339,dateTime:RFC3339,long,string,string,string
#default,0,,,,,,,,
,result,table,_start,_stop,_time,_value,_field,_measurement,host
,,0,2018-12-10T18:21:52.748859Z,2018-12-10T18:30:00Z,2018-12-10T18:29:58Z,1,active,mem,oox4k.local
,,0,2018-12-10T18:30:00Z,2018-12-10T19:00:00Z,2018-12-10T18:54:08Z,2,active,mem,oox4k.local
`
const lastValueA = lastValue(parseResponse(respA))
const lastValueB = lastValue(parseResponse(respB))
expect(lastValueA).toEqual(2)
expect(lastValueB).toEqual(2)
})
})

View File

@ -1,21 +0,0 @@
import {spreadTables} from 'src/shared/parsing/flux/spreadTables'
import {FluxTable} from 'src/types'
export const lastValue = (tables: FluxTable[]): number => {
if (tables.every(table => !table.data.length)) {
return null
}
const {table, seriesDescriptions} = spreadTables(tables)
const seriesKeys = seriesDescriptions.map(d => d.key)
const times = Object.keys(table)
times.sort()
seriesKeys.sort()
const lastTime = times[times.length - 1]
const firstSeriesKey = seriesKeys[0]
return table[lastTime][firstSeriesKey]
}

View File

@ -1,28 +0,0 @@
import {parseResponse} from 'src/shared/parsing/flux/response'
import {spreadTables} from 'src/shared/parsing/flux/spreadTables'
describe('spreadTables', () => {
test('it spreads multiple series into a single table', () => {
const resp = `#group,false,false,false,false,false,false,true,true,true
#datatype,string,long,dateTime:RFC3339,dateTime:RFC3339,dateTime:RFC3339,long,string,string,string
#default,max,,,,,,,,
,result,table,_start,_stop,_time,_value,_field,_measurement,host
,,0,2018-12-10T18:21:52.748859Z,2018-12-10T18:30:00Z,2018-12-10T18:29:58Z,4906213376,active,mem,oox4k.local
,,0,2018-12-10T18:30:00Z,2018-12-10T19:00:00Z,2018-12-10T18:54:08Z,5860683776,active,mem,oox4k.local
,,0,2018-12-10T19:00:00Z,2018-12-10T19:21:52.748859Z,2018-12-10T19:11:58Z,5115428864,active,mem,oox4k.local
#group,false,false,false,false,false,false,true,true,true
#datatype,string,long,dateTime:RFC3339,dateTime:RFC3339,dateTime:RFC3339,long,string,string,string
#default,min,,,,,,,,
,result,table,_start,_stop,_time,_value,_field,_measurement,host
,,0,2018-12-10T18:21:52.748859Z,2018-12-10T18:30:00Z,2018-12-10T18:29:48Z,4589981696,active,mem,oox4k.local
,,0,2018-12-10T18:30:00Z,2018-12-10T19:00:00Z,2018-12-10T18:40:18Z,4318040064,active,mem,oox4k.local
,,0,2018-12-10T19:00:00Z,2018-12-10T19:21:52.748859Z,2018-12-10T19:11:58Z,4131692544,active,mem,oox4k.local
`
const result = spreadTables(parseResponse(resp))
expect(result).toMatchSnapshot()
})
})

View File

@ -1,133 +0,0 @@
import {FluxTable} from 'src/types'
export interface SeriesDescription {
// A key identifying a unique (column, table, result) triple for a particular
// Flux response—i.e. a single time series
key: string
// The name of the column that this series is extracted from (typically this
// is `_value`, but could be any column)
valueColumnName: string
// The index of the column that this series was extracted from
valueColumnIndex: number
// The names and values of columns in the group key, plus the result name.
// This provides the data for a user-recognizable label of the time series
metaColumns: {
[columnName: string]: string
}
}
interface SpreadTablesResult {
seriesDescriptions: SeriesDescription[]
table: {
[time: string]: {[seriesKey: string]: number}
}
}
// Given a collection of `FluxTable`s parsed from a single Flux response,
// `spreadTables` will place each unique series found within the response into
// a single table, indexed by time. This data munging operation is often
// referred to as as a “spread”, “cast”, “pivot”, or “unfold”.
export const spreadTables = (tables: FluxTable[]): SpreadTablesResult => {
const result: SpreadTablesResult = {
table: {},
seriesDescriptions: [],
}
for (const table of tables) {
const header = table.data[0]
if (!header) {
continue
}
const seriesDescriptions = getSeriesDescriptions(table)
const timeIndex = getTimeIndex(header)
for (let i = 1; i < table.data.length; i++) {
const row = table.data[i]
const time = row[timeIndex]
for (const {key, valueColumnIndex} of seriesDescriptions) {
if (!result.table[time]) {
result.table[time] = {}
}
result.table[time][key] = Number(row[valueColumnIndex])
}
}
result.seriesDescriptions.push(...seriesDescriptions)
}
return result
}
const EXCLUDED_SERIES_COLUMNS = new Set([
'_time',
'result',
'table',
'_start',
'_stop',
'',
])
const NUMERIC_DATATYPES = new Set(['double', 'long', 'int', 'float'])
const getSeriesDescriptions = (table: FluxTable): SeriesDescription[] => {
const seriesDescriptions = []
const header = table.data[0]
for (let i = 0; i < header.length; i++) {
const columnName = header[i]
const dataType = table.dataTypes[columnName]
if (EXCLUDED_SERIES_COLUMNS.has(columnName)) {
continue
}
if (table.groupKey[columnName]) {
continue
}
if (!NUMERIC_DATATYPES.has(dataType)) {
continue
}
const key = Object.entries(table.groupKey).reduce(
(acc, [k, v]) => acc + `[${k}=${v}]`,
`${columnName}[result=${table.result}]`
)
seriesDescriptions.push({
key,
valueColumnName: columnName,
valueColumnIndex: i,
metaColumns: {
...table.groupKey,
result: table.result,
},
})
}
return seriesDescriptions
}
const getTimeIndex = header => {
let timeIndex = header.indexOf('_time')
if (timeIndex >= 0) {
return timeIndex
}
timeIndex = header.indexOf('_start')
if (timeIndex >= 0) {
return timeIndex
}
timeIndex = header.indexOf('_end')
if (timeIndex >= 0) {
return timeIndex
}
return -1
}

View File

@ -0,0 +1,132 @@
import {fluxToTable} from '@influxdata/vis'
import {latestValues} from 'src/shared/utils/latestValues'
describe('latestValues', () => {
test('the last value returned does not depend on the ordering of tables in response', () => {
const respA = `#group,false,false,false,false
#datatype,string,long,dateTime:RFC3339,long
#default,1,,,
,result,table,_time,_value
,,0,2018-12-10T18:29:48Z,1
,,0,2018-12-10T18:54:18Z,2
#group,false,false,false,false
#datatype,string,long,dateTime:RFC3339,long
#default,1,,,
,result,table,_time,_value
,,1,2018-12-10T18:29:48Z,3
,,1,2018-12-10T18:40:18Z,4`
const respB = `#group,false,false,false,false
#datatype,string,long,dateTime:RFC3339,long
#default,1,,,
,result,table,_time,_value
,,0,2018-12-10T18:29:48Z,3
,,0,2018-12-10T18:40:18Z,4
#group,false,false,false,false
#datatype,string,long,dateTime:RFC3339,long
#default,1,,,
,result,table,_time,_value
,,1,2018-12-10T18:29:48Z,1
,,1,2018-12-10T18:54:18Z,2`
const latestValuesA = latestValues(fluxToTable(respA).table)
const latestValuesB = latestValues(fluxToTable(respB).table)
expect(latestValuesA).toEqual([2])
expect(latestValuesB).toEqual([2])
})
test('uses the latest time for which a value is defined', () => {
const resp = `#group,false,false,false,false
#datatype,string,long,dateTime:RFC3339,long
#default,1,,,
,result,table,_time,_value
,,0,2018-12-10T18:29:48Z,3
,,0,2018-12-10T18:40:18Z,4
#group,false,false,false,false
#datatype,string,long,dateTime:RFC3339,string
#default,1,,,
,result,table,_time,_value
,,1,2018-12-10T19:00:00Z,howdy
,,1,2018-12-10T20:00:00Z,howdy`
const result = latestValues(fluxToTable(resp).table)
expect(result).toEqual([4])
})
test('falls back to _stop column if _time column does not exist', () => {
const resp = `#group,false,false,true,true,false
#datatype,string,long,dateTime:RFC3339,dateTime:RFC3339,long
#default,1,,,,
,result,table,_start,_stop,_value
,,0,2018-12-10T18:29:48Z,2018-12-10T18:29:48Z,3
,,0,2018-12-10T18:40:18Z,2018-12-10T18:40:18Z,4`
const result = latestValues(fluxToTable(resp).table)
expect(result).toEqual([4])
})
test('returns no latest values if no time column exists and multiple rows', () => {
const resp = `#group,false,false,false
#datatype,string,long,long
#default,1,,
,result,table,_value
,,0,3
,,0,4`
const result = latestValues(fluxToTable(resp).table)
expect(result).toEqual([])
})
test('returns latest values if no time column exists but table has single row', () => {
const resp = `#group,false,false,false,false
#datatype,string,long,long,long
#default,1,,,
,result,table,_value,foo
,,0,3,4`
const result = latestValues(fluxToTable(resp).table)
expect(result).toEqual([3, 4])
})
test('returns no latest values if no numeric column exists', () => {
const resp = `#group,false,false,false,false
#datatype,string,long,dateTime:RFC3339,string
#default,1,,,
,result,table,_time,_value
,,1,2018-12-10T19:00:00Z,howdy
,,1,2018-12-10T20:00:00Z,howdy`
const result = latestValues(fluxToTable(resp).table)
expect(result).toEqual([])
})
test('returns latest values from multiple numeric value columns', () => {
const resp = `#group,false,false,false,false,false
#datatype,string,long,dateTime:RFC3339,long,double
#default,1,,,,
,result,table,_time,_value,foo
,,0,2018-12-10T18:29:48Z,3,5.0
,,0,2018-12-10T18:40:18Z,4,6.0
#group,false,false,false,false
#datatype,string,long,dateTime:RFC3339,long,double
#default,1,,,,
,result,table,_time,_value,foo
,,0,2018-12-10T18:29:48Z,1,7.0
,,0,2018-12-10T18:40:18Z,2,8.0`
const table = fluxToTable(resp).table
const result = latestValues(table)
expect(result).toEqual([4, 6.0, 2.0, 8.0])
})
})

View File

@ -0,0 +1,108 @@
import {get, range, flatMap} from 'lodash'
import {isNumeric, Table} from '@influxdata/vis'
/*
Return a list of the maximum elements in `xs`, where the magnitude of each
element is computed using the passed function `d`.
*/
const maxesBy = <X>(xs: X[], d: (x: X) => number): X[] => {
let maxes = []
let maxDist = -Infinity
for (const x of xs) {
const dist = d(x)
if (dist > maxDist) {
maxes = [x]
maxDist = dist
} else if (dist === maxDist && dist !== -Infinity) {
maxes.push(x)
}
}
return maxes
}
const EXCLUDED_COLUMNS = new Set([
'_start',
'_stop',
'_time',
'table',
'result',
'',
])
/*
Determine if the values in a column should be considered in `latestValues`.
*/
const isValueCol = (table: Table, colKey: string): boolean => {
const {name, type} = table.columns[colKey]
return isNumeric(type) && !EXCLUDED_COLUMNS.has(name)
}
/*
We sort the column keys that we pluck latest values from, so that:
- Columns named `_value` have precedence
- The returned latest values are in a somewhat stable order
*/
const sortTableKeys = (keyA: string, keyB: string): number => {
if (keyA.includes('_value')) {
return -1
} else if (keyB.includes('_value')) {
return 1
} else {
return keyA.localeCompare(keyB)
}
}
/*
Return a list of the most recent numeric values present in a `Table`.
This utility searches any numeric column to find values, and uses the `_time`
column as their associated timestamp.
If the table only has one row, then a time column is not needed.
*/
export const latestValues = (table: Table): number[] => {
const valueColsData = Object.keys(table.columns)
.sort((a, b) => sortTableKeys(a, b))
.filter(k => isValueCol(table, k))
.map(k => table.columns[k].data) as number[][]
if (!valueColsData.length) {
return []
}
const timeColData = get(
table,
'columns._time.data',
get(table, 'columns._stop.data') // Fallback to `_stop` column if `_time` column missing
)
if (!timeColData && table.length !== 1) {
return []
}
const d = i => {
const time = timeColData[i]
if (time && valueColsData.some(colData => !isNaN(colData[i]))) {
return time
}
return -Infinity
}
const latestRowIndices =
table.length === 1 ? [0] : maxesBy(range(table.length), d)
const latestValues = flatMap(latestRowIndices, i =>
valueColsData.map(colData => colData[i])
)
const definedLatestValues = latestValues.filter(x => !isNaN(x))
return definedLatestValues
}

View File

@ -6,13 +6,9 @@ import {Plot} from '@influxdata/vis'
// Components
import RawFluxDataTable from 'src/timeMachine/components/RawFluxDataTable'
import GaugeChart from 'src/shared/components/GaugeChart'
import SingleStat from 'src/shared/components/SingleStat'
import SingleStatTransform from 'src/shared/components/SingleStatTransform'
import TableGraphs from 'src/shared/components/tables/TableGraphs'
import HistogramContainer from 'src/shared/components/HistogramContainer'
import HistogramTransform from 'src/timeMachine/components/HistogramTransform'
import XYContainer from 'src/shared/components/XYContainer'
import RefreshingViewSwitcher from 'src/shared/components/RefreshingViewSwitcher'
// Utils
import {getActiveTimeMachine, getTables} from 'src/timeMachine/selectors'
@ -23,9 +19,6 @@ import {
QueryViewProperties,
FluxTable,
RemoteDataState,
XYView,
XYViewGeom,
SingleStatView,
AppState,
} from 'src/types'
@ -57,60 +50,13 @@ const VisSwitcher: FunctionComponent<StateProps> = ({
)
}
switch (properties.type) {
case ViewType.SingleStat:
return (
<SingleStatTransform tables={tables}>
{stat => <SingleStat stat={stat} properties={properties} />}
</SingleStatTransform>
)
case ViewType.Table:
return <TableGraphs tables={tables} properties={properties} />
case ViewType.Gauge:
return <GaugeChart tables={tables} properties={properties} />
case ViewType.XY:
return (
<XYContainer
files={files}
viewProperties={properties}
loading={loading}
>
{config => <Plot config={config} />}
</XYContainer>
)
case ViewType.LinePlusSingleStat:
const xyProperties: XYView = {
...properties,
colors: properties.colors.filter(c => c.type === 'scale'),
type: ViewType.XY,
geom: XYViewGeom.Line,
}
const singleStatProperties: SingleStatView = {
...properties,
colors: properties.colors.filter(c => c.type !== 'scale'),
type: ViewType.SingleStat,
}
return (
<XYContainer
files={files}
viewProperties={xyProperties}
loading={loading}
>
{config => (
<Plot config={config}>
<SingleStatTransform tables={tables}>
{stat => (
<SingleStat stat={stat} properties={singleStatProperties} />
)}
</SingleStatTransform>
</Plot>
)}
</XYContainer>
)
case ViewType.Histogram:
if (properties.type === ViewType.Histogram) {
// Histograms have special treatment when rendered within a time machine:
// if the backing view for the histogram has `xColumn` and `fillColumn`
// selections that are invalid given the current query response, then we
// fall back to using valid selections for those fields (if available).
// When a histogram is rendered on a dashboard, we use the selections
// stored in the view verbatim and display an error if they are invalid.
return (
<HistogramTransform>
{({table, xColumn, fillColumns}) => (
@ -124,9 +70,16 @@ const VisSwitcher: FunctionComponent<StateProps> = ({
)}
</HistogramTransform>
)
default:
return null
}
return (
<RefreshingViewSwitcher
tables={tables}
files={files}
loading={loading}
properties={properties}
/>
)
}
const mstp = (state: AppState) => {