mirror of https://github.com/grafana/grafana
Merge branch 'table-reducer' of https://github.com/ryantxu/grafana into ryantxu-table-reducer
commit
e0ecbc4c68
@ -0,0 +1,79 @@ |
||||
import React, { PureComponent } from 'react'; |
||||
|
||||
import { storiesOf } from '@storybook/react'; |
||||
import { action } from '@storybook/addon-actions'; |
||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; |
||||
import { StatsPicker } from './StatsPicker'; |
||||
import { text, boolean } from '@storybook/addon-knobs'; |
||||
|
||||
const getKnobs = () => { |
||||
return { |
||||
placeholder: text('Placeholder Text', ''), |
||||
defaultStat: text('Default Stat', ''), |
||||
allowMultiple: boolean('Allow Multiple', false), |
||||
initialStats: text('Initial Stats', ''), |
||||
}; |
||||
}; |
||||
|
||||
interface State { |
||||
stats: string[]; |
||||
} |
||||
|
||||
export class WrapperWithState extends PureComponent<any, State> { |
||||
constructor(props: any) { |
||||
super(props); |
||||
this.state = { |
||||
stats: this.toStatsArray(props.initialReducers), |
||||
}; |
||||
} |
||||
|
||||
toStatsArray = (txt: string): string[] => { |
||||
if (!txt) { |
||||
return []; |
||||
} |
||||
return txt.split(',').map(v => v.trim()); |
||||
}; |
||||
|
||||
componentDidUpdate(prevProps: any) { |
||||
const { initialReducers } = this.props; |
||||
if (initialReducers !== prevProps.initialReducers) { |
||||
console.log('Changing initial reducers'); |
||||
this.setState({ stats: this.toStatsArray(initialReducers) }); |
||||
} |
||||
} |
||||
|
||||
render() { |
||||
const { placeholder, defaultStat, allowMultiple } = this.props; |
||||
const { stats } = this.state; |
||||
|
||||
return ( |
||||
<StatsPicker |
||||
placeholder={placeholder} |
||||
defaultStat={defaultStat} |
||||
allowMultiple={allowMultiple} |
||||
stats={stats} |
||||
onChange={(stats: string[]) => { |
||||
action('Picked:')(stats); |
||||
this.setState({ stats }); |
||||
}} |
||||
/> |
||||
); |
||||
} |
||||
} |
||||
|
||||
const story = storiesOf('UI/StatsPicker', module); |
||||
story.addDecorator(withCenteredStory); |
||||
story.add('picker', () => { |
||||
const { placeholder, defaultStat, allowMultiple, initialStats } = getKnobs(); |
||||
|
||||
return ( |
||||
<div> |
||||
<WrapperWithState |
||||
placeholder={placeholder} |
||||
defaultStat={defaultStat} |
||||
allowMultiple={allowMultiple} |
||||
initialStats={initialStats} |
||||
/> |
||||
</div> |
||||
); |
||||
}); |
@ -0,0 +1,98 @@ |
||||
import React, { PureComponent } from 'react'; |
||||
|
||||
import isArray from 'lodash/isArray'; |
||||
import difference from 'lodash/difference'; |
||||
|
||||
import { Select } from '../index'; |
||||
|
||||
import { getStatsCalculators } from '../../utils/statsCalculator'; |
||||
import { SelectOptionItem } from '../Select/Select'; |
||||
|
||||
interface Props { |
||||
placeholder?: string; |
||||
onChange: (stats: string[]) => void; |
||||
stats: string[]; |
||||
width?: number; |
||||
allowMultiple?: boolean; |
||||
defaultStat?: string; |
||||
} |
||||
|
||||
export class StatsPicker extends PureComponent<Props> { |
||||
static defaultProps = { |
||||
width: 12, |
||||
allowMultiple: false, |
||||
}; |
||||
|
||||
componentDidMount() { |
||||
this.checkInput(); |
||||
} |
||||
|
||||
componentDidUpdate(prevProps: Props) { |
||||
this.checkInput(); |
||||
} |
||||
|
||||
checkInput = () => { |
||||
const { stats, allowMultiple, defaultStat, onChange } = this.props; |
||||
|
||||
const current = getStatsCalculators(stats); |
||||
if (current.length !== stats.length) { |
||||
const found = current.map(v => v.id); |
||||
const notFound = difference(stats, found); |
||||
console.warn('Unknown stats', notFound, stats); |
||||
onChange(current.map(stat => stat.id)); |
||||
} |
||||
|
||||
// Make sure there is only one
|
||||
if (!allowMultiple && stats.length > 1) { |
||||
console.warn('Removing extra stat', stats); |
||||
onChange([stats[0]]); |
||||
} |
||||
|
||||
// Set the reducer from callback
|
||||
if (defaultStat && stats.length < 1) { |
||||
onChange([defaultStat]); |
||||
} |
||||
}; |
||||
|
||||
onSelectionChange = (item: SelectOptionItem) => { |
||||
const { onChange } = this.props; |
||||
if (isArray(item)) { |
||||
onChange(item.map(v => v.value)); |
||||
} else { |
||||
onChange([item.value]); |
||||
} |
||||
}; |
||||
|
||||
render() { |
||||
const { width, stats, allowMultiple, defaultStat, placeholder } = this.props; |
||||
const options = getStatsCalculators().map(s => { |
||||
return { |
||||
value: s.id, |
||||
label: s.name, |
||||
description: s.description, |
||||
}; |
||||
}); |
||||
|
||||
const value: SelectOptionItem[] = []; |
||||
stats.forEach(s => { |
||||
const o = options.find(v => v.value === s); |
||||
if (o) { |
||||
value.push(o); |
||||
} |
||||
}); |
||||
|
||||
//getStatsCalculators(stats);
|
||||
return ( |
||||
<Select |
||||
width={width} |
||||
value={value} |
||||
isClearable={!defaultStat} |
||||
isMulti={allowMultiple} |
||||
isSearchable={true} |
||||
options={options} |
||||
placeholder={placeholder} |
||||
onChange={this.onSelectionChange} |
||||
/> |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,92 @@ |
||||
import { parseCSV } from './processTableData'; |
||||
import { getStatsCalculators, StatID, calculateStats } from './statsCalculator'; |
||||
|
||||
import _ from 'lodash'; |
||||
|
||||
describe('Stats Calculators', () => { |
||||
const basicTable = parseCSV('a,b,c\n10,20,30\n20,30,40'); |
||||
|
||||
it('should load all standard stats', () => { |
||||
const names = [ |
||||
StatID.sum, |
||||
StatID.max, |
||||
StatID.min, |
||||
StatID.logmin, |
||||
StatID.mean, |
||||
StatID.last, |
||||
StatID.first, |
||||
StatID.count, |
||||
StatID.range, |
||||
StatID.diff, |
||||
StatID.step, |
||||
StatID.delta, |
||||
// StatID.allIsZero,
|
||||
// StatID.allIsNull,
|
||||
]; |
||||
const stats = getStatsCalculators(names); |
||||
expect(stats.length).toBe(names.length); |
||||
}); |
||||
|
||||
it('should fail to load unknown stats', () => { |
||||
const names = ['not a stat', StatID.max, StatID.min, 'also not a stat']; |
||||
const stats = getStatsCalculators(names); |
||||
expect(stats.length).toBe(2); |
||||
|
||||
const found = stats.map(v => v.id); |
||||
const notFound = _.difference(names, found); |
||||
expect(notFound.length).toBe(2); |
||||
|
||||
expect(notFound[0]).toBe('not a stat'); |
||||
}); |
||||
|
||||
it('should calculate basic stats', () => { |
||||
const stats = calculateStats({ |
||||
table: basicTable, |
||||
columnIndex: 0, |
||||
stats: ['first', 'last', 'mean'], |
||||
}); |
||||
|
||||
// First
|
||||
expect(stats.first).toEqual(10); |
||||
|
||||
// Last
|
||||
expect(stats.last).toEqual(20); |
||||
|
||||
// Mean
|
||||
expect(stats.mean).toEqual(15); |
||||
}); |
||||
|
||||
it('should support a single stat also', () => { |
||||
const stats = calculateStats({ |
||||
table: basicTable, |
||||
columnIndex: 0, |
||||
stats: ['first'], |
||||
}); |
||||
|
||||
// Should do the simple version that just looks up value
|
||||
expect(Object.keys(stats).length).toEqual(1); |
||||
expect(stats.first).toEqual(10); |
||||
}); |
||||
|
||||
it('should get non standard stats', () => { |
||||
const stats = calculateStats({ |
||||
table: basicTable, |
||||
columnIndex: 0, |
||||
stats: [StatID.distinctCount, StatID.changeCount], |
||||
}); |
||||
|
||||
expect(stats.distinctCount).toEqual(2); |
||||
expect(stats.changeCount).toEqual(1); |
||||
}); |
||||
|
||||
it('should calculate step', () => { |
||||
const stats = calculateStats({ |
||||
table: { columns: [{ text: 'A' }], rows: [[100], [200], [300], [400]] }, |
||||
columnIndex: 0, |
||||
stats: [StatID.step, StatID.delta], |
||||
}); |
||||
|
||||
expect(stats.step).toEqual(100); |
||||
expect(stats.delta).toEqual(300); |
||||
}); |
||||
}); |
@ -0,0 +1,403 @@ |
||||
// Libraries
|
||||
import isNumber from 'lodash/isNumber'; |
||||
|
||||
import { TableData, NullValueMode } from '../types/index'; |
||||
|
||||
export enum StatID { |
||||
sum = 'sum', |
||||
max = 'max', |
||||
min = 'min', |
||||
logmin = 'logmin', |
||||
mean = 'mean', |
||||
last = 'last', |
||||
first = 'first', |
||||
count = 'count', |
||||
range = 'range', |
||||
diff = 'diff', |
||||
delta = 'delta', |
||||
step = 'step', |
||||
|
||||
changeCount = 'changeCount', |
||||
distinctCount = 'distinctCount', |
||||
|
||||
allIsZero = 'allIsZero', |
||||
allIsNull = 'allIsNull', |
||||
} |
||||
|
||||
export interface ColumnStats { |
||||
[key: string]: any; |
||||
} |
||||
|
||||
// Internal function
|
||||
type StatCalculator = (table: TableData, columnIndex: number, ignoreNulls: boolean, nullAsZero: boolean) => ColumnStats; |
||||
|
||||
export interface StatCalculatorInfo { |
||||
id: string; |
||||
name: string; |
||||
description: string; |
||||
|
||||
alias?: string; // optional secondary key. 'avg' vs 'mean', 'total' vs 'sum'
|
||||
|
||||
// Internal details
|
||||
emptyInputResult?: any; // typically null, but some things like 'count' & 'sum' should be zero
|
||||
standard: boolean; // The most common stats can all be calculated in a single pass
|
||||
calculator?: StatCalculator; |
||||
} |
||||
|
||||
/** |
||||
* @param ids list of stat names or null to get all of them |
||||
*/ |
||||
export function getStatsCalculators(ids?: string[]): StatCalculatorInfo[] { |
||||
if (ids === null || ids === undefined) { |
||||
if (!hasBuiltIndex) { |
||||
getById(StatID.mean); |
||||
} |
||||
return listOfStats; |
||||
} |
||||
return ids.reduce((list, id) => { |
||||
const stat = getById(id); |
||||
if (stat) { |
||||
list.push(stat); |
||||
} |
||||
return list; |
||||
}, new Array<StatCalculatorInfo>()); |
||||
} |
||||
|
||||
export interface CalculateStatsOptions { |
||||
table: TableData; |
||||
columnIndex: number; |
||||
stats: string[]; // The stats to calculate
|
||||
nullValueMode?: NullValueMode; |
||||
} |
||||
|
||||
/** |
||||
* @returns an object with a key for each selected stat |
||||
*/ |
||||
export function calculateStats(options: CalculateStatsOptions): ColumnStats { |
||||
const { table, columnIndex, stats, nullValueMode } = options; |
||||
|
||||
if (!stats || stats.length < 1) { |
||||
return {}; |
||||
} |
||||
|
||||
const queue = getStatsCalculators(stats); |
||||
|
||||
// Return early for empty tables
|
||||
// This lets the concrete implementations assume at least one row
|
||||
if (!table.rows || table.rows.length < 1) { |
||||
const stats = {} as ColumnStats; |
||||
for (const stat of queue) { |
||||
stats[stat.id] = stat.emptyInputResult !== null ? stat.emptyInputResult : null; |
||||
} |
||||
return stats; |
||||
} |
||||
|
||||
const ignoreNulls = nullValueMode === NullValueMode.Ignore; |
||||
const nullAsZero = nullValueMode === NullValueMode.AsZero; |
||||
|
||||
// Avoid calculating all the standard stats if possible
|
||||
if (queue.length === 1 && queue[0].calculator) { |
||||
return queue[0].calculator(table, columnIndex, ignoreNulls, nullAsZero); |
||||
} |
||||
|
||||
// For now everything can use the standard stats
|
||||
let values = standardStatsStat(table, columnIndex, ignoreNulls, nullAsZero); |
||||
for (const calc of queue) { |
||||
if (!values.hasOwnProperty(calc.id) && calc.calculator) { |
||||
values = { |
||||
...values, |
||||
...calc.calculator(table, columnIndex, ignoreNulls, nullAsZero), |
||||
}; |
||||
} |
||||
} |
||||
return values; |
||||
} |
||||
|
||||
// ------------------------------------------------------------------------------
|
||||
//
|
||||
// No Exported symbols below here.
|
||||
//
|
||||
// ------------------------------------------------------------------------------
|
||||
|
||||
// private registry of all stats
|
||||
interface TableStatIndex { |
||||
[id: string]: StatCalculatorInfo; |
||||
} |
||||
const listOfStats: StatCalculatorInfo[] = []; |
||||
const index: TableStatIndex = {}; |
||||
let hasBuiltIndex = false; |
||||
|
||||
function getById(id: string): StatCalculatorInfo | undefined { |
||||
if (!hasBuiltIndex) { |
||||
[ |
||||
{ |
||||
id: StatID.last, |
||||
name: 'Last', |
||||
description: 'Last Value (current)', |
||||
standard: true, |
||||
alias: 'current', |
||||
calculator: calculateLast, |
||||
}, |
||||
{ id: StatID.first, name: 'First', description: 'First Value', standard: true, calculator: calculateFirst }, |
||||
{ id: StatID.min, name: 'Min', description: 'Minimum Value', standard: true }, |
||||
{ id: StatID.max, name: 'Max', description: 'Maximum Value', standard: true }, |
||||
{ id: StatID.mean, name: 'Mean', description: 'Average Value', standard: true, alias: 'avg' }, |
||||
{ |
||||
id: StatID.sum, |
||||
name: 'Total', |
||||
description: 'The sum of all values', |
||||
emptyInputResult: 0, |
||||
standard: true, |
||||
alias: 'total', |
||||
}, |
||||
{ |
||||
id: StatID.count, |
||||
name: 'Count', |
||||
description: 'Number of values in response', |
||||
emptyInputResult: 0, |
||||
standard: true, |
||||
}, |
||||
{ |
||||
id: StatID.range, |
||||
name: 'Range', |
||||
description: 'Difference between minimum and maximum values', |
||||
standard: true, |
||||
}, |
||||
{ |
||||
id: StatID.delta, |
||||
name: 'Delta', |
||||
description: 'Cumulative change in value (??? help not really sure ???)', |
||||
standard: true, |
||||
}, |
||||
{ |
||||
id: StatID.step, |
||||
name: 'Step', |
||||
description: 'Minimum interval between values', |
||||
standard: true, |
||||
}, |
||||
{ |
||||
id: StatID.diff, |
||||
name: 'Difference', |
||||
description: 'Difference between first and last values', |
||||
standard: true, |
||||
}, |
||||
{ |
||||
id: StatID.logmin, |
||||
name: 'Min (above zero)', |
||||
description: 'Used for log min scale', |
||||
standard: true, |
||||
}, |
||||
{ |
||||
id: StatID.changeCount, |
||||
name: 'Change Count', |
||||
description: 'Number of times the value changes', |
||||
standard: false, |
||||
calculator: calculateChangeCount, |
||||
}, |
||||
{ |
||||
id: StatID.distinctCount, |
||||
name: 'Distinct Count', |
||||
description: 'Number of distinct values', |
||||
standard: false, |
||||
calculator: calculateDistinctCount, |
||||
}, |
||||
].forEach(info => { |
||||
const { id, alias } = info; |
||||
if (index.hasOwnProperty(id)) { |
||||
console.warn('Duplicate Stat', id, info, index); |
||||
} |
||||
index[id] = info; |
||||
if (alias) { |
||||
if (index.hasOwnProperty(alias)) { |
||||
console.warn('Duplicate Stat (alias)', alias, info, index); |
||||
} |
||||
index[alias] = info; |
||||
} |
||||
listOfStats.push(info); |
||||
}); |
||||
hasBuiltIndex = true; |
||||
} |
||||
return index[id]; |
||||
} |
||||
|
||||
function standardStatsStat( |
||||
data: TableData, |
||||
columnIndex: number, |
||||
ignoreNulls: boolean, |
||||
nullAsZero: boolean |
||||
): ColumnStats { |
||||
const stats = { |
||||
sum: 0, |
||||
max: -Number.MAX_VALUE, |
||||
min: Number.MAX_VALUE, |
||||
logmin: Number.MAX_VALUE, |
||||
mean: null, |
||||
last: null, |
||||
first: null, |
||||
count: 0, |
||||
nonNullCount: 0, |
||||
allIsNull: true, |
||||
allIsZero: false, |
||||
range: null, |
||||
diff: null, |
||||
delta: 0, |
||||
step: Number.MAX_VALUE, |
||||
|
||||
// Just used for calcutations -- not exposed as a stat
|
||||
previousDeltaUp: true, |
||||
} as ColumnStats; |
||||
|
||||
for (let i = 0; i < data.rows.length; i++) { |
||||
let currentValue = data.rows[i][columnIndex]; |
||||
|
||||
if (currentValue === null) { |
||||
if (ignoreNulls) { |
||||
continue; |
||||
} |
||||
if (nullAsZero) { |
||||
currentValue = 0; |
||||
} |
||||
} |
||||
|
||||
if (currentValue !== null) { |
||||
const isFirst = stats.first === null; |
||||
if (isFirst) { |
||||
stats.first = currentValue; |
||||
} |
||||
|
||||
if (isNumber(currentValue)) { |
||||
stats.sum += currentValue; |
||||
stats.allIsNull = false; |
||||
stats.nonNullCount++; |
||||
|
||||
if (!isFirst) { |
||||
const step = currentValue - stats.last!; |
||||
if (stats.step > step) { |
||||
stats.step = step; // the minimum interval
|
||||
} |
||||
|
||||
if (stats.last! > currentValue) { |
||||
// counter reset
|
||||
stats.previousDeltaUp = false; |
||||
if (i === data.rows.length - 1) { |
||||
// reset on last
|
||||
stats.delta += currentValue; |
||||
} |
||||
} else { |
||||
if (stats.previousDeltaUp) { |
||||
stats.delta += step; // normal increment
|
||||
} else { |
||||
stats.delta += currentValue; // account for counter reset
|
||||
} |
||||
stats.previousDeltaUp = true; |
||||
} |
||||
} |
||||
|
||||
if (currentValue > stats.max) { |
||||
stats.max = currentValue; |
||||
} |
||||
|
||||
if (currentValue < stats.min) { |
||||
stats.min = currentValue; |
||||
} |
||||
|
||||
if (currentValue < stats.logmin && currentValue > 0) { |
||||
stats.logmin = currentValue; |
||||
} |
||||
} |
||||
|
||||
if (currentValue !== 0) { |
||||
stats.allIsZero = false; |
||||
} |
||||
|
||||
stats.last = currentValue; |
||||
} |
||||
} |
||||
|
||||
if (stats.max === -Number.MAX_VALUE) { |
||||
stats.max = null; |
||||
} |
||||
|
||||
if (stats.min === Number.MAX_VALUE) { |
||||
stats.min = null; |
||||
} |
||||
|
||||
if (stats.step === Number.MAX_VALUE) { |
||||
stats.step = null; |
||||
} |
||||
|
||||
if (stats.nonNullCount > 0) { |
||||
stats.mean = stats.sum! / stats.nonNullCount; |
||||
} |
||||
|
||||
if (stats.max !== null && stats.min !== null) { |
||||
stats.range = stats.max - stats.min; |
||||
} |
||||
|
||||
if (stats.first !== null && stats.last !== null) { |
||||
if (isNumber(stats.first) && isNumber(stats.last)) { |
||||
stats.diff = stats.last - stats.first; |
||||
} |
||||
} |
||||
|
||||
return stats; |
||||
} |
||||
|
||||
function calculateFirst(data: TableData, columnIndex: number, ignoreNulls: boolean, nullAsZero: boolean): ColumnStats { |
||||
return { first: data.rows[0][columnIndex] }; |
||||
} |
||||
|
||||
function calculateLast(data: TableData, columnIndex: number, ignoreNulls: boolean, nullAsZero: boolean): ColumnStats { |
||||
return { last: data.rows[data.rows.length - 1][columnIndex] }; |
||||
} |
||||
|
||||
function calculateChangeCount( |
||||
data: TableData, |
||||
columnIndex: number, |
||||
ignoreNulls: boolean, |
||||
nullAsZero: boolean |
||||
): ColumnStats { |
||||
let count = 0; |
||||
let first = true; |
||||
let last: any = null; |
||||
for (let i = 0; i < data.rows.length; i++) { |
||||
let currentValue = data.rows[i][columnIndex]; |
||||
if (currentValue === null) { |
||||
if (ignoreNulls) { |
||||
continue; |
||||
} |
||||
if (nullAsZero) { |
||||
currentValue = 0; |
||||
} |
||||
} |
||||
if (!first && last !== currentValue) { |
||||
count++; |
||||
} |
||||
first = false; |
||||
last = currentValue; |
||||
} |
||||
|
||||
return { changeCount: count }; |
||||
} |
||||
|
||||
function calculateDistinctCount( |
||||
data: TableData, |
||||
columnIndex: number, |
||||
ignoreNulls: boolean, |
||||
nullAsZero: boolean |
||||
): ColumnStats { |
||||
const distinct = new Set<any>(); |
||||
for (let i = 0; i < data.rows.length; i++) { |
||||
let currentValue = data.rows[i][columnIndex]; |
||||
if (currentValue === null) { |
||||
if (ignoreNulls) { |
||||
continue; |
||||
} |
||||
if (nullAsZero) { |
||||
currentValue = 0; |
||||
} |
||||
} |
||||
distinct.add(currentValue); |
||||
} |
||||
return { distinctCount: distinct.size }; |
||||
} |
Loading…
Reference in new issue