mirror of https://github.com/grafana/grafana
Transformers: configure result transformations after query(alpha) (#18740)
parent
205c0a58ac
commit
7d32caeac2
@ -0,0 +1,66 @@ |
||||
import { toDataFrame, transformDataFrame } from '../index'; |
||||
import { FieldType } from '../../index'; |
||||
import { DataTransformerID } from './ids'; |
||||
|
||||
export const seriesWithNamesToMatch = toDataFrame({ |
||||
fields: [ |
||||
{ name: 'startsWithA', type: FieldType.time, values: [1000, 2000] }, |
||||
{ name: 'B', type: FieldType.boolean, values: [true, false] }, |
||||
{ name: 'startsWithC', type: FieldType.string, values: ['a', 'b'] }, |
||||
{ name: 'D', type: FieldType.number, values: [1, 2] }, |
||||
], |
||||
}); |
||||
|
||||
describe('filterByName transformer', () => { |
||||
it('returns original series if no options provided', () => { |
||||
const cfg = { |
||||
id: DataTransformerID.filterFields, |
||||
options: {}, |
||||
}; |
||||
|
||||
const filtered = transformDataFrame([cfg], [seriesWithNamesToMatch])[0]; |
||||
expect(filtered.fields.length).toBe(4); |
||||
}); |
||||
|
||||
describe('respects', () => { |
||||
it('inclusion', () => { |
||||
const cfg = { |
||||
id: DataTransformerID.filterFieldsByName, |
||||
options: { |
||||
include: '/^(startsWith)/', |
||||
}, |
||||
}; |
||||
|
||||
const filtered = transformDataFrame([cfg], [seriesWithNamesToMatch])[0]; |
||||
expect(filtered.fields.length).toBe(2); |
||||
expect(filtered.fields[0].name).toBe('startsWithA'); |
||||
}); |
||||
|
||||
it('exclusion', () => { |
||||
const cfg = { |
||||
id: DataTransformerID.filterFieldsByName, |
||||
options: { |
||||
exclude: '/^(startsWith)/', |
||||
}, |
||||
}; |
||||
|
||||
const filtered = transformDataFrame([cfg], [seriesWithNamesToMatch])[0]; |
||||
expect(filtered.fields.length).toBe(2); |
||||
expect(filtered.fields[0].name).toBe('B'); |
||||
}); |
||||
|
||||
it('inclusion and exclusion', () => { |
||||
const cfg = { |
||||
id: DataTransformerID.filterFieldsByName, |
||||
options: { |
||||
exclude: '/^(startsWith)/', |
||||
include: `/^(B)$/`, |
||||
}, |
||||
}; |
||||
|
||||
const filtered = transformDataFrame([cfg], [seriesWithNamesToMatch])[0]; |
||||
expect(filtered.fields.length).toBe(1); |
||||
expect(filtered.fields[0].name).toBe('B'); |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1,38 @@ |
||||
import { DataTransformerInfo } from './transformers'; |
||||
import { FieldMatcherID } from '../matchers/ids'; |
||||
import { DataTransformerID } from './ids'; |
||||
import { filterFieldsTransformer, FilterOptions } from './filter'; |
||||
|
||||
export interface FilterFieldsByNameTransformerOptions { |
||||
include?: string; |
||||
exclude?: string; |
||||
} |
||||
|
||||
export const filterFieldsByNameTransformer: DataTransformerInfo<FilterFieldsByNameTransformerOptions> = { |
||||
id: DataTransformerID.filterFieldsByName, |
||||
name: 'Filter fields by name', |
||||
description: 'select a subset of fields', |
||||
defaultOptions: {}, |
||||
|
||||
/** |
||||
* Return a modified copy of the series. If the transform is not or should not |
||||
* be applied, just return the input series |
||||
*/ |
||||
transformer: (options: FilterFieldsByNameTransformerOptions) => { |
||||
const filterOptions: FilterOptions = {}; |
||||
if (options.include) { |
||||
filterOptions.include = { |
||||
id: FieldMatcherID.byName, |
||||
options: options.include, |
||||
}; |
||||
} |
||||
if (options.exclude) { |
||||
filterOptions.exclude = { |
||||
id: FieldMatcherID.byName, |
||||
options: options.exclude, |
||||
}; |
||||
} |
||||
|
||||
return filterFieldsTransformer.transformer(filterOptions); |
||||
}, |
||||
}; |
@ -0,0 +1,23 @@ |
||||
import { DataTransformerInfo } from './transformers'; |
||||
import { DataTransformerID } from './ids'; |
||||
import { DataFrame } from '../../types/dataFrame'; |
||||
|
||||
export interface NoopTransformerOptions { |
||||
include?: string; |
||||
exclude?: string; |
||||
} |
||||
|
||||
export const noopTransformer: DataTransformerInfo<NoopTransformerOptions> = { |
||||
id: DataTransformerID.noop, |
||||
name: 'noop', |
||||
description: 'No-operation transformer', |
||||
defaultOptions: {}, |
||||
|
||||
/** |
||||
* Return a modified copy of the series. If the transform is not or should not |
||||
* be applied, just return the input series |
||||
*/ |
||||
transformer: (options: NoopTransformerOptions) => { |
||||
return (data: DataFrame[]) => data; |
||||
}, |
||||
}; |
@ -0,0 +1,44 @@ |
||||
import React, { FC, useContext } from 'react'; |
||||
import { css, cx } from 'emotion'; |
||||
import { PluginState, ThemeContext } from '../../index'; |
||||
import { Tooltip } from '../index'; |
||||
|
||||
interface Props { |
||||
state?: PluginState; |
||||
text?: JSX.Element; |
||||
className?: string; |
||||
} |
||||
|
||||
export const AlphaNotice: FC<Props> = ({ state, text, className }) => { |
||||
const tooltipContent = text || ( |
||||
<div> |
||||
<h5>Alpha Feature</h5> |
||||
<p>This feature is a work in progress and updates may include breaking changes.</p> |
||||
</div> |
||||
); |
||||
|
||||
const theme = useContext(ThemeContext); |
||||
|
||||
const styles = cx( |
||||
className, |
||||
css` |
||||
background: linear-gradient(to bottom, ${theme.colors.blueBase}, ${theme.colors.blueShade}); |
||||
color: ${theme.colors.gray7}; |
||||
white-space: nowrap; |
||||
border-radius: 3px; |
||||
text-shadow: none; |
||||
font-size: 13px; |
||||
padding: 4px 8px; |
||||
cursor: help; |
||||
display: inline-block; |
||||
` |
||||
); |
||||
|
||||
return ( |
||||
<Tooltip content={tooltipContent} theme={'info'} placement={'top'}> |
||||
<div className={styles}> |
||||
<i className="fa fa-warning" /> {state} |
||||
</div> |
||||
</Tooltip> |
||||
); |
||||
}; |
@ -0,0 +1,163 @@ |
||||
import React, { useContext } from 'react'; |
||||
import { FilterFieldsByNameTransformerOptions, DataTransformerID, dataTransformers, KeyValue } from '@grafana/data'; |
||||
import { TransformerUIProps, TransformerUIRegistyItem } from './types'; |
||||
import { ThemeContext } from '../../themes/ThemeContext'; |
||||
import { css, cx } from 'emotion'; |
||||
import { InlineList } from '../List/InlineList'; |
||||
|
||||
interface FilterByNameTransformerEditorProps extends TransformerUIProps<FilterFieldsByNameTransformerOptions> {} |
||||
|
||||
interface FilterByNameTransformerEditorState { |
||||
include: string; |
||||
options: FieldNameInfo[]; |
||||
selected: string[]; |
||||
} |
||||
|
||||
interface FieldNameInfo { |
||||
name: string; |
||||
count: number; |
||||
} |
||||
export class FilterByNameTransformerEditor extends React.PureComponent< |
||||
FilterByNameTransformerEditorProps, |
||||
FilterByNameTransformerEditorState |
||||
> { |
||||
constructor(props: FilterByNameTransformerEditorProps) { |
||||
super(props); |
||||
this.state = { |
||||
include: props.options.include || '', |
||||
options: [], |
||||
selected: [], |
||||
}; |
||||
} |
||||
|
||||
componentDidMount() { |
||||
this.initOptions(); |
||||
} |
||||
|
||||
private initOptions() { |
||||
const { input, options } = this.props; |
||||
const configuredOptions = options.include ? options.include.split('|') : []; |
||||
|
||||
const allNames: FieldNameInfo[] = []; |
||||
const byName: KeyValue<FieldNameInfo> = {}; |
||||
for (const frame of input) { |
||||
for (const field of frame.fields) { |
||||
let v = byName[field.name]; |
||||
if (!v) { |
||||
v = byName[field.name] = { |
||||
name: field.name, |
||||
count: 0, |
||||
}; |
||||
allNames.push(v); |
||||
} |
||||
v.count++; |
||||
} |
||||
} |
||||
|
||||
if (configuredOptions.length) { |
||||
const options: FieldNameInfo[] = []; |
||||
const selected: FieldNameInfo[] = []; |
||||
for (const v of allNames) { |
||||
if (configuredOptions.includes(v.name)) { |
||||
selected.push(v); |
||||
} |
||||
options.push(v); |
||||
} |
||||
|
||||
this.setState({ |
||||
options, |
||||
selected: selected.map(s => s.name), |
||||
}); |
||||
} else { |
||||
this.setState({ options: allNames, selected: [] }); |
||||
} |
||||
} |
||||
|
||||
onFieldToggle = (fieldName: string) => { |
||||
const { selected } = this.state; |
||||
if (selected.indexOf(fieldName) > -1) { |
||||
this.onChange(selected.filter(s => s !== fieldName)); |
||||
} else { |
||||
this.onChange([...selected, fieldName]); |
||||
} |
||||
}; |
||||
|
||||
onChange = (selected: string[]) => { |
||||
this.setState({ selected }); |
||||
this.props.onChange({ |
||||
...this.props.options, |
||||
include: selected.join('|'), |
||||
}); |
||||
}; |
||||
|
||||
render() { |
||||
const { options, selected } = this.state; |
||||
return ( |
||||
<> |
||||
<InlineList |
||||
items={options} |
||||
renderItem={(o, i) => { |
||||
const label = `${o.name}${o.count > 1 ? ' (' + o.count + ')' : ''}`; |
||||
return ( |
||||
<span |
||||
className={css` |
||||
margin-right: ${i === options.length - 1 ? '0' : '10px'}; |
||||
`}
|
||||
> |
||||
<FilterPill |
||||
onClick={() => { |
||||
this.onFieldToggle(o.name); |
||||
}} |
||||
label={label} |
||||
selected={selected.indexOf(o.name) > -1} |
||||
/> |
||||
</span> |
||||
); |
||||
}} |
||||
/> |
||||
</> |
||||
); |
||||
} |
||||
} |
||||
|
||||
interface FilterPillProps { |
||||
selected: boolean; |
||||
label: string; |
||||
onClick: React.MouseEventHandler<HTMLElement>; |
||||
} |
||||
const FilterPill: React.FC<FilterPillProps> = ({ label, selected, onClick }) => { |
||||
const theme = useContext(ThemeContext); |
||||
return ( |
||||
<div |
||||
className={css` |
||||
padding: ${theme.spacing.xxs} ${theme.spacing.sm}; |
||||
color: white; |
||||
background: ${selected ? theme.colors.blueLight : theme.colors.blueShade}; |
||||
border-radius: 16px; |
||||
display: inline-block; |
||||
cursor: pointer; |
||||
`}
|
||||
onClick={onClick} |
||||
> |
||||
{selected && ( |
||||
<i |
||||
className={cx( |
||||
'fa fa-check', |
||||
css` |
||||
margin-right: 4px; |
||||
` |
||||
)} |
||||
/> |
||||
)} |
||||
{label} |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
export const filterFieldsByNameTransformRegistryItem: TransformerUIRegistyItem<FilterFieldsByNameTransformerOptions> = { |
||||
id: DataTransformerID.filterFieldsByName, |
||||
component: FilterByNameTransformerEditor, |
||||
transformer: dataTransformers.get(DataTransformerID.filterFieldsByName), |
||||
name: 'Filter by name', |
||||
description: 'UI for filter by name transformation', |
||||
}; |
@ -0,0 +1,35 @@ |
||||
import React from 'react'; |
||||
import { StatsPicker } from '../StatsPicker/StatsPicker'; |
||||
import { ReduceTransformerOptions, DataTransformerID, ReducerID } from '@grafana/data'; |
||||
import { TransformerUIRegistyItem, TransformerUIProps } from './types'; |
||||
import { dataTransformers } from '@grafana/data'; |
||||
|
||||
// TODO: Minimal implementation, needs some <3
|
||||
export const ReduceTransformerEditor: React.FC<TransformerUIProps<ReduceTransformerOptions>> = ({ |
||||
options, |
||||
onChange, |
||||
input, |
||||
}) => { |
||||
return ( |
||||
<StatsPicker |
||||
width={12} |
||||
placeholder="Choose Stat" |
||||
allowMultiple |
||||
stats={options.reducers || []} |
||||
onChange={stats => { |
||||
onChange({ |
||||
...options, |
||||
reducers: stats as ReducerID[], |
||||
}); |
||||
}} |
||||
/> |
||||
); |
||||
}; |
||||
|
||||
export const reduceTransformRegistryItem: TransformerUIRegistyItem<ReduceTransformerOptions> = { |
||||
id: DataTransformerID.reduce, |
||||
component: ReduceTransformerEditor, |
||||
transformer: dataTransformers.get(DataTransformerID.reduce), |
||||
name: 'Reduce', |
||||
description: 'UI for reduce transformation', |
||||
}; |
@ -0,0 +1,85 @@ |
||||
import React, { useContext, useState } from 'react'; |
||||
import { ThemeContext } from '../../themes/ThemeContext'; |
||||
import { css } from 'emotion'; |
||||
import { DataFrame } from '@grafana/data'; |
||||
import { JSONFormatter } from '../JSONFormatter/JSONFormatter'; |
||||
import { GrafanaTheme } from '../../types/theme'; |
||||
|
||||
interface TransformationRowProps { |
||||
name: string; |
||||
description: string; |
||||
editor?: JSX.Element; |
||||
onRemove: () => void; |
||||
input: DataFrame[]; |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme) => ({ |
||||
title: css` |
||||
display: flex; |
||||
padding: 4px 8px 4px 8px; |
||||
position: relative; |
||||
height: 35px; |
||||
background: ${theme.colors.textFaint}; |
||||
border-radius: 4px 4px 0 0; |
||||
flex-wrap: nowrap; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
`,
|
||||
name: css` |
||||
font-weight: ${theme.typography.weight.semibold}; |
||||
color: ${theme.colors.blue}; |
||||
`,
|
||||
iconRow: css` |
||||
display: flex; |
||||
`,
|
||||
icon: css` |
||||
background: transparent; |
||||
border: none; |
||||
box-shadow: none; |
||||
cursor: pointer; |
||||
color: ${theme.colors.textWeak}; |
||||
margin-left: ${theme.spacing.sm}; |
||||
&:hover { |
||||
color: ${theme.colors.text}; |
||||
} |
||||
`,
|
||||
editor: css` |
||||
border: 2px dashed ${theme.colors.textFaint}; |
||||
border-top: none; |
||||
border-radius: 0 0 4px 4px; |
||||
padding: 8px; |
||||
`,
|
||||
}); |
||||
|
||||
export const TransformationRow = ({ onRemove, editor, name, input }: TransformationRowProps) => { |
||||
const theme = useContext(ThemeContext); |
||||
const [viewDebug, setViewDebug] = useState(false); |
||||
const styles = getStyles(theme); |
||||
return ( |
||||
<div |
||||
className={css` |
||||
margin-bottom: 10px; |
||||
`}
|
||||
> |
||||
<div className={styles.title}> |
||||
<div className={styles.name}>{name}</div> |
||||
<div className={styles.iconRow}> |
||||
<div onClick={() => setViewDebug(!viewDebug)} className={styles.icon}> |
||||
<i className="fa fa-fw fa-bug" /> |
||||
</div> |
||||
<div onClick={onRemove} className={styles.icon}> |
||||
<i className="fa fa-fw fa-trash" /> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div className={styles.editor}> |
||||
{editor} |
||||
{viewDebug && ( |
||||
<div> |
||||
<JSONFormatter json={input} /> |
||||
</div> |
||||
)} |
||||
</div> |
||||
</div> |
||||
); |
||||
}; |
@ -0,0 +1,127 @@ |
||||
import { DataTransformerID, DataTransformerConfig, DataFrame, transformDataFrame } from '@grafana/data'; |
||||
import { Select } from '../Select/Select'; |
||||
import { transformersUIRegistry } from './transformers'; |
||||
import React from 'react'; |
||||
import { TransformationRow } from './TransformationRow'; |
||||
import { Button } from '../Button/Button'; |
||||
import { css } from 'emotion'; |
||||
|
||||
interface TransformationsEditorState { |
||||
updateCounter: number; |
||||
} |
||||
|
||||
interface TransformationsEditorProps { |
||||
onChange: (transformations: DataTransformerConfig[]) => void; |
||||
transformations: DataTransformerConfig[]; |
||||
getCurrentData: (applyTransformations?: boolean) => DataFrame[]; |
||||
} |
||||
|
||||
export class TransformationsEditor extends React.PureComponent<TransformationsEditorProps, TransformationsEditorState> { |
||||
state = { updateCounter: 0 }; |
||||
|
||||
onTransformationAdd = () => { |
||||
const { transformations, onChange } = this.props; |
||||
onChange([ |
||||
...transformations, |
||||
{ |
||||
id: DataTransformerID.noop, |
||||
options: {}, |
||||
}, |
||||
]); |
||||
this.setState({ updateCounter: this.state.updateCounter + 1 }); |
||||
}; |
||||
|
||||
onTransformationChange = (idx: number, config: DataTransformerConfig) => { |
||||
const { transformations, onChange } = this.props; |
||||
transformations[idx] = config; |
||||
onChange(transformations); |
||||
this.setState({ updateCounter: this.state.updateCounter + 1 }); |
||||
}; |
||||
|
||||
onTransformationRemove = (idx: number) => { |
||||
const { transformations, onChange } = this.props; |
||||
transformations.splice(idx, 1); |
||||
onChange(transformations); |
||||
this.setState({ updateCounter: this.state.updateCounter + 1 }); |
||||
}; |
||||
|
||||
renderTransformationEditors = () => { |
||||
const { transformations, getCurrentData } = this.props; |
||||
const hasTransformations = transformations.length > 0; |
||||
const preTransformData = getCurrentData(false); |
||||
|
||||
if (!hasTransformations) { |
||||
return undefined; |
||||
} |
||||
|
||||
const availableTransformers = transformersUIRegistry.list().map(t => { |
||||
return { |
||||
value: t.transformer.id, |
||||
label: t.transformer.name, |
||||
}; |
||||
}); |
||||
|
||||
return ( |
||||
<> |
||||
{transformations.map((t, i) => { |
||||
let editor, input; |
||||
if (t.id === DataTransformerID.noop) { |
||||
return ( |
||||
<Select |
||||
className={css` |
||||
margin-bottom: 10px; |
||||
`}
|
||||
key={`${t.id}-${i}`} |
||||
options={availableTransformers} |
||||
placeholder="Select transformation" |
||||
onChange={v => { |
||||
this.onTransformationChange(i, { |
||||
id: v.value as string, |
||||
options: {}, |
||||
}); |
||||
}} |
||||
/> |
||||
); |
||||
} |
||||
const transformationUI = transformersUIRegistry.getIfExists(t.id); |
||||
input = transformDataFrame(transformations.slice(0, i), preTransformData); |
||||
|
||||
if (transformationUI) { |
||||
editor = React.createElement(transformationUI.component, { |
||||
options: { ...transformationUI.transformer.defaultOptions, ...t.options }, |
||||
input, |
||||
onChange: (options: any) => { |
||||
this.onTransformationChange(i, { |
||||
id: t.id, |
||||
options, |
||||
}); |
||||
}, |
||||
}); |
||||
} |
||||
|
||||
return ( |
||||
<TransformationRow |
||||
key={`${t.id}-${i}`} |
||||
input={input || []} |
||||
onRemove={() => this.onTransformationRemove(i)} |
||||
editor={editor} |
||||
name={transformationUI ? transformationUI.name : ''} |
||||
description={transformationUI ? transformationUI.description : ''} |
||||
/> |
||||
); |
||||
})} |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
render() { |
||||
return ( |
||||
<> |
||||
{this.renderTransformationEditors()} |
||||
<Button variant="inverse" icon="fa fa-plus" onClick={this.onTransformationAdd}> |
||||
Add transformation |
||||
</Button> |
||||
</> |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,8 @@ |
||||
import { Registry } from '@grafana/data'; |
||||
import { reduceTransformRegistryItem } from './ReduceTransformerEditor'; |
||||
import { filterFieldsByNameTransformRegistryItem } from './FilterByNameTransformerEditor'; |
||||
import { TransformerUIRegistyItem } from './types'; |
||||
|
||||
export const transformersUIRegistry = new Registry<TransformerUIRegistyItem<any>>(() => { |
||||
return [reduceTransformRegistryItem, filterFieldsByNameTransformRegistryItem]; |
||||
}); |
@ -0,0 +1,15 @@ |
||||
import React from 'react'; |
||||
import { DataFrame, RegistryItem, DataTransformerInfo } from '@grafana/data'; |
||||
|
||||
export interface TransformerUIRegistyItem<TOptions> extends RegistryItem { |
||||
component: React.ComponentType<TransformerUIProps<TOptions>>; |
||||
transformer: DataTransformerInfo<TOptions>; |
||||
} |
||||
|
||||
export interface TransformerUIProps<T> { |
||||
// Transformer configuration, persisted on panel's model
|
||||
options: T; |
||||
// Pre-transformation data frame
|
||||
input: DataFrame[]; |
||||
onChange: (options: T) => void; |
||||
} |
Loading…
Reference in new issue