Commit 7d32caea by Ryan McKinley Committed by Dominik Prokop

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

parent 205c0a58
......@@ -682,3 +682,7 @@ app_tls_skip_verify_insecure = false
[enterprise]
license_path =
[feature_toggles]
# enable features, separated by spaces
enable =
import { DataTransformerInfo, NoopDataTransformer } from './transformers';
import { DataTransformerInfo } from './transformers';
import { noopTransformer } from './noop';
import { DataFrame, Field } from '../../types/dataFrame';
import { FieldMatcherID } from '../matchers/ids';
import { DataTransformerID } from './ids';
......@@ -23,7 +24,7 @@ export const filterFieldsTransformer: DataTransformerInfo<FilterOptions> = {
*/
transformer: (options: FilterOptions) => {
if (!options.include && !options.exclude) {
return NoopDataTransformer;
return noopTransformer.transformer({});
}
const include = options.include ? getFieldMatcher(options.include) : null;
......@@ -75,7 +76,7 @@ export const filterFramesTransformer: DataTransformerInfo<FilterOptions> = {
*/
transformer: (options: FilterOptions) => {
if (!options.include && !options.exclude) {
return NoopDataTransformer;
return noopTransformer.transformer({});
}
const include = options.include ? getFrameMatchers(options.include) : null;
......
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');
});
});
});
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);
},
};
......@@ -5,5 +5,7 @@ export enum DataTransformerID {
reduce = 'reduce', // Run calculations on fields
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)
noop = 'noop', // Does nothing to the dataframe
}
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;
},
};
......@@ -8,26 +8,26 @@ import { KeyValue } from '../../types/data';
import { ArrayVector } from '../vector';
import { guessFieldTypeForField } from '../processDataFrame';
export interface ReduceOptions {
reducers: string[];
export interface ReduceTransformerOptions {
reducers: ReducerID[];
fields?: MatcherConfig; // Assume all fields
}
export const reduceTransformer: DataTransformerInfo<ReduceOptions> = {
export const reduceTransformer: DataTransformerInfo<ReduceTransformerOptions> = {
id: DataTransformerID.reduce,
name: 'Reducer',
description: 'Return a DataFrame with the reduction results',
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
* be applied, just return the input series
*/
transformer: (options: ReduceOptions) => {
transformer: (options: ReduceTransformerOptions) => {
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);
return (data: DataFrame[]) => {
......
......@@ -15,9 +15,6 @@ export interface DataTransformerConfig<TOptions = any> {
options: TOptions;
}
// Transformer that does nothing
export const NoopDataTransformer = (data: DataFrame[]) => data;
/**
* Apply configured transformations to the input data
*/
......@@ -49,8 +46,10 @@ export function transformDataFrame(options: DataTransformerConfig[], data: DataF
// Initalize the Registry
import { appendTransformer, AppendOptions } from './append';
import { reduceTransformer, ReduceOptions } from './reduce';
import { reduceTransformer, ReduceTransformerOptions } from './reduce';
import { filterFieldsTransformer, filterFramesTransformer } from './filter';
import { filterFieldsByNameTransformer, FilterFieldsByNameTransformerOptions } from './filterByName';
import { noopTransformer } from './noop';
/**
* 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];
}
reduce(data: DataFrame[], options: ReduceOptions): DataFrame[] {
reduce(data: DataFrame[], options: ReduceTransformerOptions): DataFrame[] {
return reduceTransformer.transformer(options)(data);
}
}
export const dataTransformers = new TransformerRegistry(() => [
noopTransformer,
filterFieldsTransformer,
filterFieldsByNameTransformer,
filterFramesTransformer,
appendTransformer,
reduceTransformer,
]);
export { ReduceTransformerOptions, FilterFieldsByNameTransformerOptions };
......@@ -10,6 +10,9 @@ export interface BuildInfo {
hasUpdate: boolean;
}
interface FeatureToggles {
transformations: boolean;
}
export class GrafanaBootConfig {
datasources: { [str: string]: DataSourceInstanceSettings } = {};
panels: { [key: string]: PanelPluginMeta } = {};
......@@ -41,6 +44,9 @@ export class GrafanaBootConfig {
disableSanitizeHtml = false;
theme: GrafanaTheme;
pluginsToPreload: string[] = [];
featureToggles: FeatureToggles = {
transformations: false,
};
constructor(options: GrafanaBootConfig) {
this.theme = options.bootData.user.lightTheme ? getTheme(GrafanaThemeType.Light) : getTheme(GrafanaThemeType.Dark);
......
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>
);
};
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 React, { PureComponent, createRef } from 'react';
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 {
className?: string;
......@@ -31,10 +31,13 @@ export class JSONFormatter extends PureComponent<Props> {
const { json, config, open, onDidRender } = this.props;
const wrapperEl = this.wrapperRef.current;
const formatter = new JsonExplorer(json, open, config);
// @ts-ignore
const hasChildren: boolean = wrapperEl.hasChildNodes();
if (hasChildren) {
// @ts-ignore
wrapperEl.replaceChild(formatter.render(), wrapperEl.lastChild);
} else {
// @ts-ignore
wrapperEl.appendChild(formatter.render());
}
......
......@@ -28,7 +28,6 @@ export interface JsonExplorerConfig {
const _defaultConfig: JsonExplorerConfig = {
animateOpen: true,
animateClose: true,
theme: null,
};
/**
......@@ -39,10 +38,10 @@ const _defaultConfig: JsonExplorerConfig = {
*/
export class JsonExplorer {
// 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
private element: Element;
private element: Element | null = null;
private skipChildren = false;
......@@ -366,7 +365,7 @@ export class JsonExplorer {
* Animated option is used when user triggers this via a click
*/
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) {
return;
......@@ -404,7 +403,8 @@ export class JsonExplorer {
* Animated option is used when user triggers this via a click
*/
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) {
let childrenRemoved = 0;
......
......@@ -2,7 +2,7 @@
import React, { FunctionComponent } from 'react';
interface Props {
title?: string;
title?: string | JSX.Element;
onClose?: () => void;
children: JSX.Element | JSX.Element[] | boolean;
onAdd?: () => void;
......
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',
};
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',
};
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>
);
};
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>
</>
);
}
}
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];
});
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;
}
......@@ -78,5 +78,10 @@ export { VariableSuggestion, VariableOrigin } from './DataLinks/DataLinkSuggesti
export { DataLinksEditor } from './DataLinks/DataLinksEditor';
export { DataLinksContextMenu } from './DataLinks/DataLinksContextMenu';
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 { AlphaNotice } from './AlphaNotice/AlphaNotice';
......@@ -195,6 +195,7 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *m.ReqContext) (map[string]interf
"env": setting.Env,
"isEnterprise": setting.IsEnterprise,
},
"featureToggles": hs.Cfg.FeatureToggles,
}
return jsonObj, nil
......
......@@ -266,6 +266,8 @@ type Cfg struct {
EditorsCanAdmin bool
ApiKeyMaxSecondsToLive int64
FeatureToggles map[string]bool
}
type CommandLineArgs struct {
......@@ -941,6 +943,17 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
cfg.PluginsEnableAlpha = pluginsSection.Key("enable_alpha").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
if panelsSection.Key("enable_alpha").MustBool(false) {
cfg.PluginsEnableAlpha = true
......
import coreModule from 'app/core/core_module';
import { JsonExplorer } from '../json_explorer/json_explorer';
import { JsonExplorer } from '@grafana/ui';
coreModule.directive('jsonTree', [
function jsonTreeDirective() {
......
......@@ -16,7 +16,7 @@ import './utils/outline';
import './components/colorpicker/spectrum_picker';
import './services/search_srv';
import './services/ng_react';
import { colors } from '@grafana/ui/';
import { colors, JsonExplorer } from '@grafana/ui/';
import { searchDirective } from './components/search/search';
import { infoPopover } from './components/info_popover';
......@@ -38,7 +38,6 @@ import { assignModelProperties } from './utils/model_utils';
import { contextSrv } from './services/context_srv';
import { KeybindingSrv } from './services/keybindingSrv';
import { helpModal } from './components/help/help';
import { JsonExplorer } from './components/json_explorer/json_explorer';
import { NavModelSrv } from './nav_model_srv';
import { geminiScrollbar } from './components/scroll/scroll';
import { orgSwitcher } from './components/org_switcher';
......
import React, { PureComponent } from 'react';
import { JSONFormatter } from 'app/core/components/JSONFormatter/JSONFormatter';
import appEvents from 'app/core/app_events';
import { CopyToClipboard } from 'app/core/components/CopyToClipboard/CopyToClipboard';
import { getBackendSrv } from '@grafana/runtime';
import { DashboardModel } from '../dashboard/state/DashboardModel';
import { LoadingPlaceholder } from '@grafana/ui';
import { LoadingPlaceholder, JSONFormatter } from '@grafana/ui';
export interface Props {
panelId: number;
......
......@@ -172,7 +172,6 @@ export class PanelChrome extends PureComponent<Props, State> {
if (!this.querySubscription) {
this.querySubscription = queryRunner.subscribe(this.panelDataObserver);
}
queryRunner.run({
datasource: panel.datasource,
queries: panel.targets,
......@@ -186,6 +185,7 @@ export class PanelChrome extends PureComponent<Props, State> {
minInterval: panel.interval,
scopedVars: panel.scopedVars,
cacheTimeout: panel.cacheTimeout,
transformations: panel.transformations,
});
}
};
......
// Libraries
import React, { PureComponent } from 'react';
import _ from 'lodash';
import { css } from 'emotion';
// Components
import { EditorTabBody, EditorToolbarView } from './EditorTabBody';
import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
import { QueryInspector } from './QueryInspector';
import { QueryOptions } from './QueryOptions';
import { PanelOptionsGroup } from '@grafana/ui';
import { PanelOptionsGroup, TransformationsEditor } from '@grafana/ui';
import { QueryEditorRow } from './QueryEditorRow';
// Services
......@@ -18,8 +19,8 @@ import config from 'app/core/config';
// Types
import { PanelModel } from '../state/PanelModel';
import { DashboardModel } from '../state/DashboardModel';
import { DataQuery, DataSourceSelectItem, PanelData } from '@grafana/ui';
import { LoadingState } from '@grafana/data';
import { DataQuery, DataSourceSelectItem, PanelData, AlphaNotice, PluginState } from '@grafana/ui';
import { LoadingState, DataTransformerConfig } from '@grafana/data';
import { PluginHelp } from 'app/core/components/PluginHelp/PluginHelp';
import { PanelQueryRunnerFormat } from '../state/PanelQueryRunner';
import { Unsubscribable } from 'rxjs';
......@@ -215,15 +216,24 @@ export class QueriesTab extends PureComponent<Props, State> {
this.forceUpdate();
};
onTransformersChange = (transformers: DataTransformerConfig[]) => {
this.props.panel.setTransformations(transformers);
this.forceUpdate();
};
setScrollTop = (event: React.MouseEvent<HTMLElement>) => {
const target = event.target as HTMLElement;
this.setState({ scrollTop: target.scrollTop });
};
getCurrentData = (applyTransformations = true) => {
const queryRunner = this.props.panel.getQueryRunner();
return queryRunner.getCurrentData(applyTransformations).series;
};
render() {
const { panel, dashboard } = this.props;
const { currentDS, scrollTop, data } = this.state;
const queryInspector: EditorToolbarView = {
title: 'Query Inspector',
render: this.renderQueryInspector,
......@@ -235,6 +245,8 @@ export class QueriesTab extends PureComponent<Props, State> {
render: this.renderHelp,
};
const enableTransformations = config.featureToggles.transformations;
return (
<EditorTabBody
heading="Query"
......@@ -243,6 +255,7 @@ export class QueriesTab extends PureComponent<Props, State> {
setScrollTop={this.setScrollTop}
scrollTop={scrollTop}
>
<>
{isSharedDashboardQuery(currentDS.name) ? (
<DashboardQueryEditor panel={panel} panelData={data} onChange={query => this.onQueryChange(query, 0)} />
) : (
......@@ -269,6 +282,31 @@ export class QueriesTab extends PureComponent<Props, State> {
</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}
/>
)}
</PanelOptionsGroup>
)}
</>
</EditorTabBody>
);
}
......
import React, { PureComponent } from 'react';
import { JSONFormatter } from 'app/core/components/JSONFormatter/JSONFormatter';
import appEvents from 'app/core/app_events';
import { CopyToClipboard } from 'app/core/components/CopyToClipboard/CopyToClipboard';
import { LoadingPlaceholder } from '@grafana/ui';
import { LoadingPlaceholder, JSONFormatter } from '@grafana/ui';
interface DsQuery {
isLoading: boolean;
......
......@@ -7,7 +7,7 @@ import { getNextRefIdChar } from 'app/core/utils/query';
// Types
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';
......@@ -66,6 +66,7 @@ const mustKeepProps: { [str: string]: boolean } = {
transparent: true,
pluginVersion: true,
queryRunner: true,
transformations: true,
};
const defaults: any = {
......@@ -93,6 +94,7 @@ export class PanelModel {
panels?: any;
soloMode?: boolean;
targets: DataQuery[];
transformations?: DataTransformerConfig[];
datasource: string;
thresholds?: any;
pluginVersion?: string;
......@@ -290,7 +292,6 @@ export class PanelModel {
} else if (oldOptions && oldOptions.options) {
old = oldOptions.options;
}
this.options = this.options || {};
Object.assign(this.options, newPlugin.onPanelTypeChanged(this.options, oldPluginId, old));
}
......@@ -344,6 +345,14 @@ export class PanelModel {
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 {
......
......@@ -13,7 +13,8 @@ import { isSharedDashboardQuery, SharedQueryRunner } from 'app/plugins/datasourc
// Types
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<
TQuery extends DataQuery = DataQuery,
......@@ -32,6 +33,7 @@ export interface QueryRunnerOptions<
scopedVars?: ScopedVars;
cacheTimeout?: string;
delayStateNotification?: number; // default 100ms.
transformations?: DataTransformerConfig[];
}
export enum PanelQueryRunnerFormat {
......@@ -49,6 +51,7 @@ export class PanelQueryRunner {
private subject?: Subject<PanelData>;
private state = new PanelQueryState();
private transformations?: DataTransformerConfig[];
// Listen to another panel for changes
private sharedQueryRunner: SharedQueryRunner;
......@@ -63,6 +66,27 @@ export class PanelQueryRunner {
}
/**
* 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,
* the results will be immediatly passed to the observer
*/
......@@ -78,7 +102,9 @@ export class PanelQueryRunner {
// Send the last result
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);
......@@ -98,9 +124,17 @@ export class PanelQueryRunner {
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> {
const { state } = this;
......@@ -200,13 +234,14 @@ export class PanelQueryRunner {
}
}, 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
clearTimeout(loadingStateTimeoutId);
// Broadcast results
this.subject.next(data);
this.subject.next(this.getCurrentData());
return data;
} catch (err) {
clearTimeout(loadingStateTimeoutId);
......@@ -223,7 +258,7 @@ export class PanelQueryRunner {
*/
onStreamingDataUpdated = throttle(
() => {
this.subject.next(this.state.validateStreamsAndGetPanelData());
this.subject.next(this.getCurrentData());
},
50,
{ trailing: true, leading: true }
......@@ -241,6 +276,14 @@ export class PanelQueryRunner {
// Will cancel and disconnect any open requets
this.state.cancel('destroy');
}
setState = (state: PanelQueryState) => {
this.state = state;
};
getState = () => {
return this.state;
};
}
async function getDataSource(
......
......@@ -212,6 +212,7 @@ class MetricsPanelCtrl extends PanelCtrl {
minInterval: panel.interval,
scopedVars: panel.scopedVars,
cacheTimeout: panel.cacheTimeout,
transformations: panel.transformations,
});
}
......
import React, { FC, useContext } from 'react';
import React, { FC } from 'react';
import { PluginState, AlphaNotice } from '@grafana/ui';
import { css } from 'emotion';
import { PluginState, Tooltip, ThemeContext } from '@grafana/ui';
import { PopoverContent } from '@grafana/ui/src/components/Tooltip/Tooltip';
interface Props {
state?: PluginState;
}
function getPluginStateInfoText(state?: PluginState): PopoverContent | null {
function getPluginStateInfoText(state?: PluginState): JSX.Element | null {
switch (state) {
case PluginState.alpha:
return (
......@@ -30,30 +29,15 @@ function getPluginStateInfoText(state?: PluginState): PopoverContent | null {
const PluginStateinfo: FC<Props> = props => {
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 (
<Tooltip content={text} theme={'info'} placement={'top'}>
<div className={styles}>
<i className="fa fa-warning" /> {props.state}
</div>
</Tooltip>
<AlphaNotice
state={props.state}
text={text}
className={css`
margin-left: 16px;
`}
/>
);
};
......
......@@ -57,6 +57,7 @@ export class SharedQueryRunner {
this.listenToPanelId = panelId;
this.listenToRunner = this.listenToPanel.getQueryRunner();
this.subscription = this.listenToRunner.chain(this.runner);
this.runner.setState(this.listenToRunner.getState());
console.log('Connecting panel: ', this.containerPanel.id, 'to:', this.listenToPanelId);
}
......
......@@ -149,7 +149,7 @@ class GraphCtrl extends MetricsPanelCtrl {
this.events.on('render', this.onRender.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-snapshot-load', this.onDataSnapshotLoad.bind(this));
this.events.on('init-edit-mode', this.onInitEditMode.bind(this));
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment