Transformers: configure result transformations after query(alpha) (#18740)

This commit is contained in:
Ryan McKinley 2019-09-08 23:58:57 -07:00 committed by Dominik Prokop
parent 205c0a58ac
commit 7d32caeac2
35 changed files with 811 additions and 96 deletions

View File

@ -682,3 +682,7 @@ app_tls_skip_verify_insecure = false
[enterprise] [enterprise]
license_path = license_path =
[feature_toggles]
# enable features, separated by spaces
enable =

View File

@ -1,4 +1,5 @@
import { DataTransformerInfo, NoopDataTransformer } from './transformers'; import { DataTransformerInfo } from './transformers';
import { noopTransformer } from './noop';
import { DataFrame, Field } from '../../types/dataFrame'; import { DataFrame, Field } from '../../types/dataFrame';
import { FieldMatcherID } from '../matchers/ids'; import { FieldMatcherID } from '../matchers/ids';
import { DataTransformerID } from './ids'; import { DataTransformerID } from './ids';
@ -23,7 +24,7 @@ export const filterFieldsTransformer: DataTransformerInfo<FilterOptions> = {
*/ */
transformer: (options: FilterOptions) => { transformer: (options: FilterOptions) => {
if (!options.include && !options.exclude) { if (!options.include && !options.exclude) {
return NoopDataTransformer; return noopTransformer.transformer({});
} }
const include = options.include ? getFieldMatcher(options.include) : null; const include = options.include ? getFieldMatcher(options.include) : null;
@ -75,7 +76,7 @@ export const filterFramesTransformer: DataTransformerInfo<FilterOptions> = {
*/ */
transformer: (options: FilterOptions) => { transformer: (options: FilterOptions) => {
if (!options.include && !options.exclude) { if (!options.include && !options.exclude) {
return NoopDataTransformer; return noopTransformer.transformer({});
} }
const include = options.include ? getFrameMatchers(options.include) : null; const include = options.include ? getFrameMatchers(options.include) : null;

View File

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

View File

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

View File

@ -5,5 +5,7 @@ export enum DataTransformerID {
reduce = 'reduce', // Run calculations on fields reduce = 'reduce', // Run calculations on fields
filterFields = 'filterFields', // Pick some fields (keep all frames) filterFields = 'filterFields', // Pick some fields (keep all frames)
filterFieldsByName = 'filterFieldsByName', // Pick fields with name matching regex (keep all frames)
filterFrames = 'filterFrames', // Pick some frames (keep all fields) filterFrames = 'filterFrames', // Pick some frames (keep all fields)
noop = 'noop', // Does nothing to the dataframe
} }

View File

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

View File

@ -8,26 +8,26 @@ import { KeyValue } from '../../types/data';
import { ArrayVector } from '../vector'; import { ArrayVector } from '../vector';
import { guessFieldTypeForField } from '../processDataFrame'; import { guessFieldTypeForField } from '../processDataFrame';
export interface ReduceOptions { export interface ReduceTransformerOptions {
reducers: string[]; reducers: ReducerID[];
fields?: MatcherConfig; // Assume all fields fields?: MatcherConfig; // Assume all fields
} }
export const reduceTransformer: DataTransformerInfo<ReduceOptions> = { export const reduceTransformer: DataTransformerInfo<ReduceTransformerOptions> = {
id: DataTransformerID.reduce, id: DataTransformerID.reduce,
name: 'Reducer', name: 'Reducer',
description: 'Return a DataFrame with the reduction results', description: 'Return a DataFrame with the reduction results',
defaultOptions: { defaultOptions: {
calcs: [ReducerID.min, ReducerID.max, ReducerID.mean, ReducerID.last], reducers: [ReducerID.min, ReducerID.max, ReducerID.mean, ReducerID.last],
}, },
/** /**
* Return a modified copy of the series. If the transform is not or should not * Return a modified copy of the series. If the transform is not or should not
* be applied, just return the input series * be applied, just return the input series
*/ */
transformer: (options: ReduceOptions) => { transformer: (options: ReduceTransformerOptions) => {
const matcher = options.fields ? getFieldMatcher(options.fields) : alwaysFieldMatcher; const matcher = options.fields ? getFieldMatcher(options.fields) : alwaysFieldMatcher;
const calculators = fieldReducers.list(options.reducers); const calculators = options.reducers && options.reducers.length ? fieldReducers.list(options.reducers) : [];
const reducers = calculators.map(c => c.id); const reducers = calculators.map(c => c.id);
return (data: DataFrame[]) => { return (data: DataFrame[]) => {

View File

@ -15,9 +15,6 @@ export interface DataTransformerConfig<TOptions = any> {
options: TOptions; options: TOptions;
} }
// Transformer that does nothing
export const NoopDataTransformer = (data: DataFrame[]) => data;
/** /**
* Apply configured transformations to the input data * Apply configured transformations to the input data
*/ */
@ -49,8 +46,10 @@ export function transformDataFrame(options: DataTransformerConfig[], data: DataF
// Initalize the Registry // Initalize the Registry
import { appendTransformer, AppendOptions } from './append'; import { appendTransformer, AppendOptions } from './append';
import { reduceTransformer, ReduceOptions } from './reduce'; import { reduceTransformer, ReduceTransformerOptions } from './reduce';
import { filterFieldsTransformer, filterFramesTransformer } from './filter'; import { filterFieldsTransformer, filterFramesTransformer } from './filter';
import { filterFieldsByNameTransformer, FilterFieldsByNameTransformerOptions } from './filterByName';
import { noopTransformer } from './noop';
/** /**
* Registry of transformation options that can be driven by * Registry of transformation options that can be driven by
@ -69,14 +68,18 @@ class TransformerRegistry extends Registry<DataTransformerInfo> {
return appendTransformer.transformer(options || appendTransformer.defaultOptions)(data)[0]; return appendTransformer.transformer(options || appendTransformer.defaultOptions)(data)[0];
} }
reduce(data: DataFrame[], options: ReduceOptions): DataFrame[] { reduce(data: DataFrame[], options: ReduceTransformerOptions): DataFrame[] {
return reduceTransformer.transformer(options)(data); return reduceTransformer.transformer(options)(data);
} }
} }
export const dataTransformers = new TransformerRegistry(() => [ export const dataTransformers = new TransformerRegistry(() => [
noopTransformer,
filterFieldsTransformer, filterFieldsTransformer,
filterFieldsByNameTransformer,
filterFramesTransformer, filterFramesTransformer,
appendTransformer, appendTransformer,
reduceTransformer, reduceTransformer,
]); ]);
export { ReduceTransformerOptions, FilterFieldsByNameTransformerOptions };

View File

@ -10,6 +10,9 @@ export interface BuildInfo {
hasUpdate: boolean; hasUpdate: boolean;
} }
interface FeatureToggles {
transformations: boolean;
}
export class GrafanaBootConfig { export class GrafanaBootConfig {
datasources: { [str: string]: DataSourceInstanceSettings } = {}; datasources: { [str: string]: DataSourceInstanceSettings } = {};
panels: { [key: string]: PanelPluginMeta } = {}; panels: { [key: string]: PanelPluginMeta } = {};
@ -41,6 +44,9 @@ export class GrafanaBootConfig {
disableSanitizeHtml = false; disableSanitizeHtml = false;
theme: GrafanaTheme; theme: GrafanaTheme;
pluginsToPreload: string[] = []; pluginsToPreload: string[] = [];
featureToggles: FeatureToggles = {
transformations: false,
};
constructor(options: GrafanaBootConfig) { constructor(options: GrafanaBootConfig) {
this.theme = options.bootData.user.lightTheme ? getTheme(GrafanaThemeType.Light) : getTheme(GrafanaThemeType.Dark); this.theme = options.bootData.user.lightTheme ? getTheme(GrafanaThemeType.Light) : getTheme(GrafanaThemeType.Dark);

View File

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

View File

@ -1,5 +1,5 @@
import React, { PureComponent, createRef } from 'react'; import React, { PureComponent, createRef } from 'react';
import { JsonExplorer } from 'app/core/core'; // We have made some monkey-patching of json-formatter-js so we can't switch right now import { JsonExplorer } from './json_explorer/json_explorer'; // We have made some monkey-patching of json-formatter-js so we can't switch right now
interface Props { interface Props {
className?: string; className?: string;
@ -31,10 +31,13 @@ export class JSONFormatter extends PureComponent<Props> {
const { json, config, open, onDidRender } = this.props; const { json, config, open, onDidRender } = this.props;
const wrapperEl = this.wrapperRef.current; const wrapperEl = this.wrapperRef.current;
const formatter = new JsonExplorer(json, open, config); const formatter = new JsonExplorer(json, open, config);
// @ts-ignore
const hasChildren: boolean = wrapperEl.hasChildNodes(); const hasChildren: boolean = wrapperEl.hasChildNodes();
if (hasChildren) { if (hasChildren) {
// @ts-ignore
wrapperEl.replaceChild(formatter.render(), wrapperEl.lastChild); wrapperEl.replaceChild(formatter.render(), wrapperEl.lastChild);
} else { } else {
// @ts-ignore
wrapperEl.appendChild(formatter.render()); wrapperEl.appendChild(formatter.render());
} }

View File

@ -28,7 +28,6 @@ export interface JsonExplorerConfig {
const _defaultConfig: JsonExplorerConfig = { const _defaultConfig: JsonExplorerConfig = {
animateOpen: true, animateOpen: true,
animateClose: true, animateClose: true,
theme: null,
}; };
/** /**
@ -39,10 +38,10 @@ const _defaultConfig: JsonExplorerConfig = {
*/ */
export class JsonExplorer { export class JsonExplorer {
// Hold the open state after the toggler is used // Hold the open state after the toggler is used
private _isOpen: boolean = null; private _isOpen: boolean | null = null;
// A reference to the element that we render to // A reference to the element that we render to
private element: Element; private element: Element | null = null;
private skipChildren = false; private skipChildren = false;
@ -366,7 +365,7 @@ export class JsonExplorer {
* Animated option is used when user triggers this via a click * Animated option is used when user triggers this via a click
*/ */
appendChildren(animated = false) { appendChildren(animated = false) {
const children = this.element.querySelector(`div.${cssClass('children')}`); const children = this.element && this.element.querySelector(`div.${cssClass('children')}`);
if (!children || this.isEmpty) { if (!children || this.isEmpty) {
return; return;
@ -404,7 +403,8 @@ export class JsonExplorer {
* Animated option is used when user triggers this via a click * Animated option is used when user triggers this via a click
*/ */
removeChildren(animated = false) { removeChildren(animated = false) {
const childrenElement = this.element.querySelector(`div.${cssClass('children')}`) as HTMLDivElement; const childrenElement =
this.element && (this.element.querySelector(`div.${cssClass('children')}`) as HTMLDivElement);
if (animated) { if (animated) {
let childrenRemoved = 0; let childrenRemoved = 0;

View File

@ -2,7 +2,7 @@
import React, { FunctionComponent } from 'react'; import React, { FunctionComponent } from 'react';
interface Props { interface Props {
title?: string; title?: string | JSX.Element;
onClose?: () => void; onClose?: () => void;
children: JSX.Element | JSX.Element[] | boolean; children: JSX.Element | JSX.Element[] | boolean;
onAdd?: () => void; onAdd?: () => void;

View File

@ -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',
};

View File

@ -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',
};

View File

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

View File

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

View File

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

View File

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

View File

@ -78,5 +78,10 @@ export { VariableSuggestion, VariableOrigin } from './DataLinks/DataLinkSuggesti
export { DataLinksEditor } from './DataLinks/DataLinksEditor'; export { DataLinksEditor } from './DataLinks/DataLinksEditor';
export { DataLinksContextMenu } from './DataLinks/DataLinksContextMenu'; export { DataLinksContextMenu } from './DataLinks/DataLinksContextMenu';
export { SeriesIcon } from './Legend/SeriesIcon'; export { SeriesIcon } from './Legend/SeriesIcon';
export { transformersUIRegistry } from './TransformersUI/transformers';
export { TransformationRow } from './TransformersUI/TransformationRow';
export { TransformationsEditor } from './TransformersUI/TransformationsEditor';
export { JSONFormatter } from './JSONFormatter/JSONFormatter';
export { JsonExplorer } from './JSONFormatter/json_explorer/json_explorer';
export { ErrorBoundary, ErrorBoundaryAlert } from './ErrorBoundary/ErrorBoundary'; export { ErrorBoundary, ErrorBoundaryAlert } from './ErrorBoundary/ErrorBoundary';
export { AlphaNotice } from './AlphaNotice/AlphaNotice';

View File

@ -195,6 +195,7 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *m.ReqContext) (map[string]interf
"env": setting.Env, "env": setting.Env,
"isEnterprise": setting.IsEnterprise, "isEnterprise": setting.IsEnterprise,
}, },
"featureToggles": hs.Cfg.FeatureToggles,
} }
return jsonObj, nil return jsonObj, nil

View File

@ -266,6 +266,8 @@ type Cfg struct {
EditorsCanAdmin bool EditorsCanAdmin bool
ApiKeyMaxSecondsToLive int64 ApiKeyMaxSecondsToLive int64
FeatureToggles map[string]bool
} }
type CommandLineArgs struct { type CommandLineArgs struct {
@ -941,6 +943,17 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
cfg.PluginsEnableAlpha = pluginsSection.Key("enable_alpha").MustBool(false) cfg.PluginsEnableAlpha = pluginsSection.Key("enable_alpha").MustBool(false)
cfg.PluginsAppsSkipVerifyTLS = pluginsSection.Key("app_tls_skip_verify_insecure").MustBool(false) cfg.PluginsAppsSkipVerifyTLS = pluginsSection.Key("app_tls_skip_verify_insecure").MustBool(false)
// Read and populate feature toggles list
featureTogglesSection := iniFile.Section("feature_toggles")
cfg.FeatureToggles = make(map[string]bool)
featuresTogglesStr, err := valueAsString(featureTogglesSection, "enable", "")
if err != nil {
return err
}
for _, feature := range util.SplitString(featuresTogglesStr) {
cfg.FeatureToggles[feature] = true
}
// check old location for this option // check old location for this option
if panelsSection.Key("enable_alpha").MustBool(false) { if panelsSection.Key("enable_alpha").MustBool(false) {
cfg.PluginsEnableAlpha = true cfg.PluginsEnableAlpha = true

View File

@ -1,5 +1,5 @@
import coreModule from 'app/core/core_module'; import coreModule from 'app/core/core_module';
import { JsonExplorer } from '../json_explorer/json_explorer'; import { JsonExplorer } from '@grafana/ui';
coreModule.directive('jsonTree', [ coreModule.directive('jsonTree', [
function jsonTreeDirective() { function jsonTreeDirective() {

View File

@ -16,7 +16,7 @@ import './utils/outline';
import './components/colorpicker/spectrum_picker'; import './components/colorpicker/spectrum_picker';
import './services/search_srv'; import './services/search_srv';
import './services/ng_react'; import './services/ng_react';
import { colors } from '@grafana/ui/'; import { colors, JsonExplorer } from '@grafana/ui/';
import { searchDirective } from './components/search/search'; import { searchDirective } from './components/search/search';
import { infoPopover } from './components/info_popover'; import { infoPopover } from './components/info_popover';
@ -38,7 +38,6 @@ import { assignModelProperties } from './utils/model_utils';
import { contextSrv } from './services/context_srv'; import { contextSrv } from './services/context_srv';
import { KeybindingSrv } from './services/keybindingSrv'; import { KeybindingSrv } from './services/keybindingSrv';
import { helpModal } from './components/help/help'; import { helpModal } from './components/help/help';
import { JsonExplorer } from './components/json_explorer/json_explorer';
import { NavModelSrv } from './nav_model_srv'; import { NavModelSrv } from './nav_model_srv';
import { geminiScrollbar } from './components/scroll/scroll'; import { geminiScrollbar } from './components/scroll/scroll';
import { orgSwitcher } from './components/org_switcher'; import { orgSwitcher } from './components/org_switcher';

View File

@ -1,10 +1,9 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import { JSONFormatter } from 'app/core/components/JSONFormatter/JSONFormatter';
import appEvents from 'app/core/app_events'; import appEvents from 'app/core/app_events';
import { CopyToClipboard } from 'app/core/components/CopyToClipboard/CopyToClipboard'; import { CopyToClipboard } from 'app/core/components/CopyToClipboard/CopyToClipboard';
import { getBackendSrv } from '@grafana/runtime'; import { getBackendSrv } from '@grafana/runtime';
import { DashboardModel } from '../dashboard/state/DashboardModel'; import { DashboardModel } from '../dashboard/state/DashboardModel';
import { LoadingPlaceholder } from '@grafana/ui'; import { LoadingPlaceholder, JSONFormatter } from '@grafana/ui';
export interface Props { export interface Props {
panelId: number; panelId: number;

View File

@ -172,7 +172,6 @@ export class PanelChrome extends PureComponent<Props, State> {
if (!this.querySubscription) { if (!this.querySubscription) {
this.querySubscription = queryRunner.subscribe(this.panelDataObserver); this.querySubscription = queryRunner.subscribe(this.panelDataObserver);
} }
queryRunner.run({ queryRunner.run({
datasource: panel.datasource, datasource: panel.datasource,
queries: panel.targets, queries: panel.targets,
@ -186,6 +185,7 @@ export class PanelChrome extends PureComponent<Props, State> {
minInterval: panel.interval, minInterval: panel.interval,
scopedVars: panel.scopedVars, scopedVars: panel.scopedVars,
cacheTimeout: panel.cacheTimeout, cacheTimeout: panel.cacheTimeout,
transformations: panel.transformations,
}); });
} }
}; };

View File

@ -1,13 +1,14 @@
// Libraries // Libraries
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import _ from 'lodash'; import _ from 'lodash';
import { css } from 'emotion';
// Components // Components
import { EditorTabBody, EditorToolbarView } from './EditorTabBody'; import { EditorTabBody, EditorToolbarView } from './EditorTabBody';
import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker'; import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
import { QueryInspector } from './QueryInspector'; import { QueryInspector } from './QueryInspector';
import { QueryOptions } from './QueryOptions'; import { QueryOptions } from './QueryOptions';
import { PanelOptionsGroup } from '@grafana/ui'; import { PanelOptionsGroup, TransformationsEditor } from '@grafana/ui';
import { QueryEditorRow } from './QueryEditorRow'; import { QueryEditorRow } from './QueryEditorRow';
// Services // Services
@ -18,8 +19,8 @@ import config from 'app/core/config';
// Types // Types
import { PanelModel } from '../state/PanelModel'; import { PanelModel } from '../state/PanelModel';
import { DashboardModel } from '../state/DashboardModel'; import { DashboardModel } from '../state/DashboardModel';
import { DataQuery, DataSourceSelectItem, PanelData } from '@grafana/ui'; import { DataQuery, DataSourceSelectItem, PanelData, AlphaNotice, PluginState } from '@grafana/ui';
import { LoadingState } from '@grafana/data'; import { LoadingState, DataTransformerConfig } from '@grafana/data';
import { PluginHelp } from 'app/core/components/PluginHelp/PluginHelp'; import { PluginHelp } from 'app/core/components/PluginHelp/PluginHelp';
import { PanelQueryRunnerFormat } from '../state/PanelQueryRunner'; import { PanelQueryRunnerFormat } from '../state/PanelQueryRunner';
import { Unsubscribable } from 'rxjs'; import { Unsubscribable } from 'rxjs';
@ -215,15 +216,24 @@ export class QueriesTab extends PureComponent<Props, State> {
this.forceUpdate(); this.forceUpdate();
}; };
onTransformersChange = (transformers: DataTransformerConfig[]) => {
this.props.panel.setTransformations(transformers);
this.forceUpdate();
};
setScrollTop = (event: React.MouseEvent<HTMLElement>) => { setScrollTop = (event: React.MouseEvent<HTMLElement>) => {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
this.setState({ scrollTop: target.scrollTop }); this.setState({ scrollTop: target.scrollTop });
}; };
getCurrentData = (applyTransformations = true) => {
const queryRunner = this.props.panel.getQueryRunner();
return queryRunner.getCurrentData(applyTransformations).series;
};
render() { render() {
const { panel, dashboard } = this.props; const { panel, dashboard } = this.props;
const { currentDS, scrollTop, data } = this.state; const { currentDS, scrollTop, data } = this.state;
const queryInspector: EditorToolbarView = { const queryInspector: EditorToolbarView = {
title: 'Query Inspector', title: 'Query Inspector',
render: this.renderQueryInspector, render: this.renderQueryInspector,
@ -235,6 +245,8 @@ export class QueriesTab extends PureComponent<Props, State> {
render: this.renderHelp, render: this.renderHelp,
}; };
const enableTransformations = config.featureToggles.transformations;
return ( return (
<EditorTabBody <EditorTabBody
heading="Query" heading="Query"
@ -243,32 +255,58 @@ export class QueriesTab extends PureComponent<Props, State> {
setScrollTop={this.setScrollTop} setScrollTop={this.setScrollTop}
scrollTop={scrollTop} scrollTop={scrollTop}
> >
{isSharedDashboardQuery(currentDS.name) ? ( <>
<DashboardQueryEditor panel={panel} panelData={data} onChange={query => this.onQueryChange(query, 0)} /> {isSharedDashboardQuery(currentDS.name) ? (
) : ( <DashboardQueryEditor panel={panel} panelData={data} onChange={query => this.onQueryChange(query, 0)} />
<> ) : (
<div className="query-editor-rows"> <>
{panel.targets.map((query, index) => ( <div className="query-editor-rows">
<QueryEditorRow {panel.targets.map((query, index) => (
dataSourceValue={query.datasource || panel.datasource} <QueryEditorRow
key={query.refId} dataSourceValue={query.datasource || panel.datasource}
panel={panel} key={query.refId}
dashboard={dashboard} panel={panel}
data={data} dashboard={dashboard}
query={query} data={data}
onChange={query => this.onQueryChange(query, index)} query={query}
onRemoveQuery={this.onRemoveQuery} onChange={query => this.onQueryChange(query, index)}
onAddQuery={this.onAddQuery} onRemoveQuery={this.onRemoveQuery}
onMoveQuery={this.onMoveQuery} onAddQuery={this.onAddQuery}
inMixedMode={currentDS.meta.mixed} onMoveQuery={this.onMoveQuery}
inMixedMode={currentDS.meta.mixed}
/>
))}
</div>
<PanelOptionsGroup>
<QueryOptions panel={panel} datasource={currentDS} />
</PanelOptionsGroup>
</>
)}
{enableTransformations && (
<PanelOptionsGroup
title={
<>
Transform query results
<AlphaNotice
state={PluginState.alpha}
className={css`
margin-left: 16px;
`}
/>
</>
}
>
{this.state.data.state !== LoadingState.NotStarted && (
<TransformationsEditor
transformations={this.props.panel.transformations || []}
onChange={this.onTransformersChange}
getCurrentData={this.getCurrentData}
/> />
))} )}
</div>
<PanelOptionsGroup>
<QueryOptions panel={panel} datasource={currentDS} />
</PanelOptionsGroup> </PanelOptionsGroup>
</> )}
)} </>
</EditorTabBody> </EditorTabBody>
); );
} }

View File

@ -1,8 +1,7 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import { JSONFormatter } from 'app/core/components/JSONFormatter/JSONFormatter';
import appEvents from 'app/core/app_events'; import appEvents from 'app/core/app_events';
import { CopyToClipboard } from 'app/core/components/CopyToClipboard/CopyToClipboard'; import { CopyToClipboard } from 'app/core/components/CopyToClipboard/CopyToClipboard';
import { LoadingPlaceholder } from '@grafana/ui'; import { LoadingPlaceholder, JSONFormatter } from '@grafana/ui';
interface DsQuery { interface DsQuery {
isLoading: boolean; isLoading: boolean;

View File

@ -7,7 +7,7 @@ import { getNextRefIdChar } from 'app/core/utils/query';
// Types // Types
import { DataQuery, ScopedVars, DataQueryResponseData, PanelPlugin } from '@grafana/ui'; import { DataQuery, ScopedVars, DataQueryResponseData, PanelPlugin } from '@grafana/ui';
import { DataLink } from '@grafana/data'; import { DataLink, DataTransformerConfig } from '@grafana/data';
import config from 'app/core/config'; import config from 'app/core/config';
@ -66,6 +66,7 @@ const mustKeepProps: { [str: string]: boolean } = {
transparent: true, transparent: true,
pluginVersion: true, pluginVersion: true,
queryRunner: true, queryRunner: true,
transformations: true,
}; };
const defaults: any = { const defaults: any = {
@ -93,6 +94,7 @@ export class PanelModel {
panels?: any; panels?: any;
soloMode?: boolean; soloMode?: boolean;
targets: DataQuery[]; targets: DataQuery[];
transformations?: DataTransformerConfig[];
datasource: string; datasource: string;
thresholds?: any; thresholds?: any;
pluginVersion?: string; pluginVersion?: string;
@ -290,7 +292,6 @@ export class PanelModel {
} else if (oldOptions && oldOptions.options) { } else if (oldOptions && oldOptions.options) {
old = oldOptions.options; old = oldOptions.options;
} }
this.options = this.options || {}; this.options = this.options || {};
Object.assign(this.options, newPlugin.onPanelTypeChanged(this.options, oldPluginId, old)); Object.assign(this.options, newPlugin.onPanelTypeChanged(this.options, oldPluginId, old));
} }
@ -344,6 +345,14 @@ export class PanelModel {
this.queryRunner = null; this.queryRunner = null;
} }
} }
setTransformations(transformations: DataTransformerConfig[]) {
// save for persistence
this.transformations = transformations;
// update query runner transformers
this.getQueryRunner().setTransform(transformations);
}
} }
function getPluginVersion(plugin: PanelPlugin): string { function getPluginVersion(plugin: PanelPlugin): string {

View File

@ -13,7 +13,8 @@ import { isSharedDashboardQuery, SharedQueryRunner } from 'app/plugins/datasourc
// Types // Types
import { PanelData, DataQuery, ScopedVars, DataQueryRequest, DataSourceApi, DataSourceJsonData } from '@grafana/ui'; import { PanelData, DataQuery, ScopedVars, DataQueryRequest, DataSourceApi, DataSourceJsonData } from '@grafana/ui';
import { TimeRange } from '@grafana/data'; import { TimeRange, DataTransformerConfig, transformDataFrame, toLegacyResponseData } from '@grafana/data';
import config from 'app/core/config';
export interface QueryRunnerOptions< export interface QueryRunnerOptions<
TQuery extends DataQuery = DataQuery, TQuery extends DataQuery = DataQuery,
@ -32,6 +33,7 @@ export interface QueryRunnerOptions<
scopedVars?: ScopedVars; scopedVars?: ScopedVars;
cacheTimeout?: string; cacheTimeout?: string;
delayStateNotification?: number; // default 100ms. delayStateNotification?: number; // default 100ms.
transformations?: DataTransformerConfig[];
} }
export enum PanelQueryRunnerFormat { export enum PanelQueryRunnerFormat {
@ -49,6 +51,7 @@ export class PanelQueryRunner {
private subject?: Subject<PanelData>; private subject?: Subject<PanelData>;
private state = new PanelQueryState(); private state = new PanelQueryState();
private transformations?: DataTransformerConfig[];
// Listen to another panel for changes // Listen to another panel for changes
private sharedQueryRunner: SharedQueryRunner; private sharedQueryRunner: SharedQueryRunner;
@ -62,6 +65,27 @@ export class PanelQueryRunner {
return this.panelId; return this.panelId;
} }
/**
* Get the last result -- optionally skip the transformation
*/
// TODO: add tests
getCurrentData(transform = true): PanelData {
const v = this.state.validateStreamsAndGetPanelData();
const transformData = config.featureToggles.transformations && transform;
const hasTransformations = this.transformations && this.transformations.length;
if (transformData && hasTransformations) {
const processed = transformDataFrame(this.transformations, v.series);
return {
...v,
series: processed,
legacy: processed.map(p => toLegacyResponseData(p)),
};
}
return v;
}
/** /**
* Listen for updates to the PanelData. If a query has already run for this panel, * Listen for updates to the PanelData. If a query has already run for this panel,
* the results will be immediatly passed to the observer * the results will be immediatly passed to the observer
@ -78,7 +102,9 @@ export class PanelQueryRunner {
// Send the last result // Send the last result
if (this.state.isStarted()) { if (this.state.isStarted()) {
observer.next(this.state.getDataAfterCheckingFormats()); // Force check formats again?
this.state.getDataAfterCheckingFormats();
observer.next(this.getCurrentData()); // transformed
} }
return this.subject.subscribe(observer); return this.subject.subscribe(observer);
@ -98,9 +124,17 @@ export class PanelQueryRunner {
return this.subscribe(runner.subject, format); return this.subscribe(runner.subject, format);
} }
getCurrentData(): PanelData { /**
return this.state.validateStreamsAndGetPanelData(); * Change the current transformation and notify all listeners
} * Should be used only by panel editor to update the transformers
*/
setTransform = (transformations?: DataTransformerConfig[]) => {
this.transformations = transformations;
if (this.state.isStarted()) {
this.onStreamingDataUpdated();
}
};
async run(options: QueryRunnerOptions): Promise<PanelData> { async run(options: QueryRunnerOptions): Promise<PanelData> {
const { state } = this; const { state } = this;
@ -200,13 +234,14 @@ export class PanelQueryRunner {
} }
}, delayStateNotification || 500); }, delayStateNotification || 500);
const data = await state.execute(ds, request); this.transformations = options.transformations;
const data = await state.execute(ds, request);
// Clear the delayed loading state timeout // Clear the delayed loading state timeout
clearTimeout(loadingStateTimeoutId); clearTimeout(loadingStateTimeoutId);
// Broadcast results // Broadcast results
this.subject.next(data); this.subject.next(this.getCurrentData());
return data; return data;
} catch (err) { } catch (err) {
clearTimeout(loadingStateTimeoutId); clearTimeout(loadingStateTimeoutId);
@ -223,7 +258,7 @@ export class PanelQueryRunner {
*/ */
onStreamingDataUpdated = throttle( onStreamingDataUpdated = throttle(
() => { () => {
this.subject.next(this.state.validateStreamsAndGetPanelData()); this.subject.next(this.getCurrentData());
}, },
50, 50,
{ trailing: true, leading: true } { trailing: true, leading: true }
@ -241,6 +276,14 @@ export class PanelQueryRunner {
// Will cancel and disconnect any open requets // Will cancel and disconnect any open requets
this.state.cancel('destroy'); this.state.cancel('destroy');
} }
setState = (state: PanelQueryState) => {
this.state = state;
};
getState = () => {
return this.state;
};
} }
async function getDataSource( async function getDataSource(

View File

@ -212,6 +212,7 @@ class MetricsPanelCtrl extends PanelCtrl {
minInterval: panel.interval, minInterval: panel.interval,
scopedVars: panel.scopedVars, scopedVars: panel.scopedVars,
cacheTimeout: panel.cacheTimeout, cacheTimeout: panel.cacheTimeout,
transformations: panel.transformations,
}); });
} }

View File

@ -1,13 +1,12 @@
import React, { FC, useContext } from 'react'; import React, { FC } from 'react';
import { PluginState, AlphaNotice } from '@grafana/ui';
import { css } from 'emotion'; import { css } from 'emotion';
import { PluginState, Tooltip, ThemeContext } from '@grafana/ui';
import { PopoverContent } from '@grafana/ui/src/components/Tooltip/Tooltip';
interface Props { interface Props {
state?: PluginState; state?: PluginState;
} }
function getPluginStateInfoText(state?: PluginState): PopoverContent | null { function getPluginStateInfoText(state?: PluginState): JSX.Element | null {
switch (state) { switch (state) {
case PluginState.alpha: case PluginState.alpha:
return ( return (
@ -30,30 +29,15 @@ function getPluginStateInfoText(state?: PluginState): PopoverContent | null {
const PluginStateinfo: FC<Props> = props => { const PluginStateinfo: FC<Props> = props => {
const text = getPluginStateInfoText(props.state); const text = getPluginStateInfoText(props.state);
if (!text) {
return null;
}
const theme = useContext(ThemeContext);
const styles = 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;
margin-left: 16px;
cursor: help;
`;
return ( return (
<Tooltip content={text} theme={'info'} placement={'top'}> <AlphaNotice
<div className={styles}> state={props.state}
<i className="fa fa-warning" /> {props.state} text={text}
</div> className={css`
</Tooltip> margin-left: 16px;
`}
/>
); );
}; };

View File

@ -57,6 +57,7 @@ export class SharedQueryRunner {
this.listenToPanelId = panelId; this.listenToPanelId = panelId;
this.listenToRunner = this.listenToPanel.getQueryRunner(); this.listenToRunner = this.listenToPanel.getQueryRunner();
this.subscription = this.listenToRunner.chain(this.runner); this.subscription = this.listenToRunner.chain(this.runner);
this.runner.setState(this.listenToRunner.getState());
console.log('Connecting panel: ', this.containerPanel.id, 'to:', this.listenToPanelId); console.log('Connecting panel: ', this.containerPanel.id, 'to:', this.listenToPanelId);
} }

View File

@ -149,7 +149,7 @@ class GraphCtrl extends MetricsPanelCtrl {
this.events.on('render', this.onRender.bind(this)); this.events.on('render', this.onRender.bind(this));
this.events.on('data-received', this.onDataReceived.bind(this)); this.events.on('data-received', this.onDataReceived.bind(this));
this.events.on('data-frames-received', this.onDataReceived.bind(this)); this.events.on('data-frames-received', this.onDataFramesReceived.bind(this));
this.events.on('data-error', this.onDataError.bind(this)); this.events.on('data-error', this.onDataError.bind(this));
this.events.on('data-snapshot-load', this.onDataSnapshotLoad.bind(this)); this.events.on('data-snapshot-load', this.onDataSnapshotLoad.bind(this));
this.events.on('init-edit-mode', this.onInitEditMode.bind(this)); this.events.on('init-edit-mode', this.onInitEditMode.bind(this));