mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Transformers: configure result transformations after query(alpha) (#18740)
This commit is contained in:
parent
205c0a58ac
commit
7d32caeac2
@ -682,3 +682,7 @@ app_tls_skip_verify_insecure = false
|
||||
|
||||
[enterprise]
|
||||
license_path =
|
||||
|
||||
[feature_toggles]
|
||||
# enable features, separated by spaces
|
||||
enable =
|
||||
|
@ -1,4 +1,5 @@
|
||||
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;
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
38
packages/grafana-data/src/utils/transformers/filterByName.ts
Normal file
38
packages/grafana-data/src/utils/transformers/filterByName.ts
Normal 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);
|
||||
},
|
||||
};
|
@ -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
|
||||
}
|
||||
|
23
packages/grafana-data/src/utils/transformers/noop.ts
Normal file
23
packages/grafana-data/src/utils/transformers/noop.ts
Normal 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;
|
||||
},
|
||||
};
|
@ -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);
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -1,5 +1,5 @@
|
||||
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;
|
||||
|
@ -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];
|
||||
});
|
15
packages/grafana-ui/src/components/TransformersUI/types.ts
Normal file
15
packages/grafana-ui/src/components/TransformersUI/types.ts
Normal 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;
|
||||
}
|
@ -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
|
||||
|
@ -1,5 +1,5 @@
|
||||
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';
|
||||
|
@ -1,10 +1,9 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
@ -1,13 +1,14 @@
|
||||
// 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,32 +255,58 @@ 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)} />
|
||||
) : (
|
||||
<>
|
||||
<div className="query-editor-rows">
|
||||
{panel.targets.map((query, index) => (
|
||||
<QueryEditorRow
|
||||
dataSourceValue={query.datasource || panel.datasource}
|
||||
key={query.refId}
|
||||
panel={panel}
|
||||
dashboard={dashboard}
|
||||
data={data}
|
||||
query={query}
|
||||
onChange={query => this.onQueryChange(query, index)}
|
||||
onRemoveQuery={this.onRemoveQuery}
|
||||
onAddQuery={this.onAddQuery}
|
||||
onMoveQuery={this.onMoveQuery}
|
||||
inMixedMode={currentDS.meta.mixed}
|
||||
<>
|
||||
{isSharedDashboardQuery(currentDS.name) ? (
|
||||
<DashboardQueryEditor panel={panel} panelData={data} onChange={query => this.onQueryChange(query, 0)} />
|
||||
) : (
|
||||
<>
|
||||
<div className="query-editor-rows">
|
||||
{panel.targets.map((query, index) => (
|
||||
<QueryEditorRow
|
||||
dataSourceValue={query.datasource || panel.datasource}
|
||||
key={query.refId}
|
||||
panel={panel}
|
||||
dashboard={dashboard}
|
||||
data={data}
|
||||
query={query}
|
||||
onChange={query => this.onQueryChange(query, index)}
|
||||
onRemoveQuery={this.onRemoveQuery}
|
||||
onAddQuery={this.onAddQuery}
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
</>
|
||||
</EditorTabBody>
|
||||
);
|
||||
}
|
||||
|
@ -1,8 +1,7 @@
|
||||
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;
|
||||
@ -62,6 +65,27 @@ export class PanelQueryRunner {
|
||||
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,
|
||||
* 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,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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 { 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));
|
||||
|
Loading…
Reference in New Issue
Block a user