mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Transformations: Add frame source picker to allow transforming annotations (#77842)
Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
@@ -2799,9 +2799,6 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "Styles should be written using objects.", "9"],
|
||||
[0, 0, 0, "Styles should be written using objects.", "10"]
|
||||
],
|
||||
"public/app/features/dashboard/components/TransformationsEditor/TransformationFilter.tsx:5381": [
|
||||
[0, 0, 0, "Styles should be written using objects.", "0"]
|
||||
],
|
||||
"public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx:5381": [
|
||||
[0, 0, 0, "Do not use any type assertions.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"]
|
||||
|
||||
@@ -219,6 +219,7 @@ use the output of one transformation as the input to another transformation, etc
|
||||
| `options` | | **Yes** | | Options to be passed to the transformer<br/>Valid options depend on the transformer id |
|
||||
| `disabled` | boolean | No | | Disabled transformations are skipped |
|
||||
| `filter` | [MatcherConfig](#matcherconfig) | No | | Matcher is a predicate configuration. Based on the config a set of field(s) or values is filtered in order to apply override / transformation.<br/>It comes with in id ( to resolve implementation from registry) and a configuration that’s specific to a particular matcher type. |
|
||||
| `topic` | string | No | | Where to pull DataFrames from as input to transformation<br/>Possible values are: `series`, `annotations`, `alertStates`. |
|
||||
|
||||
### MatcherConfig
|
||||
|
||||
|
||||
@@ -438,6 +438,8 @@ lineage: schemas: [{
|
||||
disabled?: bool
|
||||
// Optional frame matcher. When missing it will be applied to all results
|
||||
filter?: #MatcherConfig
|
||||
// Where to pull DataFrames from as input to transformation
|
||||
topic?: "series" | "annotations" | "alertStates" // replaced with common.DataTopic
|
||||
// Options to be passed to the transformer
|
||||
// Valid options depend on the transformer id
|
||||
options: _
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { DataQuery as SchemaDataQuery, DataSourceRef as SchemaDataSourceRef } from '@grafana/schema';
|
||||
import {
|
||||
DataQuery as SchemaDataQuery,
|
||||
DataSourceRef as SchemaDataSourceRef,
|
||||
DataTopic as SchemaDataTopic,
|
||||
} from '@grafana/schema';
|
||||
|
||||
/**
|
||||
* @deprecated use the type from @grafana/schema
|
||||
@@ -13,12 +17,9 @@ export interface DataSourceRef extends SchemaDataSourceRef {}
|
||||
/**
|
||||
* Attached to query results (not persisted)
|
||||
*
|
||||
* @public
|
||||
* @deprecated use the type from @grafana/schema
|
||||
*/
|
||||
export enum DataTopic {
|
||||
Annotations = 'annotations',
|
||||
AlertStates = 'alertStates',
|
||||
}
|
||||
export { SchemaDataTopic as DataTopic };
|
||||
|
||||
/**
|
||||
* Abstract representation of any label-based query
|
||||
|
||||
@@ -8,6 +8,16 @@
|
||||
// Run 'make gen-cue' from repository root to regenerate.
|
||||
|
||||
|
||||
/**
|
||||
* A topic is attached to DataFrame metadata in query results.
|
||||
* This specifies where the data should be used.
|
||||
*/
|
||||
export enum DataTopic {
|
||||
AlertStates = 'alertStates',
|
||||
Annotations = 'annotations',
|
||||
Series = 'series',
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO docs
|
||||
*/
|
||||
|
||||
5
packages/grafana-schema/src/common/data.cue
Normal file
5
packages/grafana-schema/src/common/data.cue
Normal file
@@ -0,0 +1,5 @@
|
||||
package common
|
||||
|
||||
// A topic is attached to DataFrame metadata in query results.
|
||||
// This specifies where the data should be used.
|
||||
DataTopic: "series" | "annotations" | "alertStates" @cuetsy(kind="enum",memberNames="Series|Annotations|AlertStates")
|
||||
@@ -623,6 +623,10 @@ export interface DataTransformerConfig {
|
||||
* Valid options depend on the transformer id
|
||||
*/
|
||||
options: unknown;
|
||||
/**
|
||||
* Where to pull DataFrames from as input to transformation
|
||||
*/
|
||||
topic?: ('series' | 'annotations' | 'alertStates'); // replaced with common.DataTopic
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DataSourceRef as CommonDataSourceRef, DataSourceRef } from '../common/common.gen';
|
||||
import { DataSourceRef as CommonDataSourceRef, DataSourceRef, DataTopic } from '../common/common.gen';
|
||||
import * as raw from '../raw/dashboard/x/dashboard_types.gen';
|
||||
|
||||
import { DataQuery } from './common.types';
|
||||
@@ -59,6 +59,7 @@ export interface MatcherConfig<TConfig = any> extends raw.MatcherConfig {
|
||||
|
||||
export interface DataTransformerConfig<TOptions = any> extends raw.DataTransformerConfig {
|
||||
options: TOptions;
|
||||
topic?: DataTopic;
|
||||
}
|
||||
|
||||
export interface TimePickerConfig extends raw.TimePickerConfig {}
|
||||
|
||||
@@ -26,6 +26,13 @@ const (
|
||||
LinkTypeLink LinkType = "link"
|
||||
)
|
||||
|
||||
// Defines values for DataTransformerConfigTopic.
|
||||
const (
|
||||
DataTransformerConfigTopicAlertStates DataTransformerConfigTopic = "alertStates"
|
||||
DataTransformerConfigTopicAnnotations DataTransformerConfigTopic = "annotations"
|
||||
DataTransformerConfigTopicSeries DataTransformerConfigTopic = "series"
|
||||
)
|
||||
|
||||
// Defines values for FieldColorModeId.
|
||||
const (
|
||||
FieldColorModeIdContinuousBlPu FieldColorModeId = "continuous-BlPu"
|
||||
@@ -294,8 +301,14 @@ type DataTransformerConfig struct {
|
||||
// Options to be passed to the transformer
|
||||
// Valid options depend on the transformer id
|
||||
Options any `json:"options"`
|
||||
|
||||
// Where to pull DataFrames from as input to transformation
|
||||
Topic *DataTransformerConfigTopic `json:"topic,omitempty"`
|
||||
}
|
||||
|
||||
// Where to pull DataFrames from as input to transformation
|
||||
type DataTransformerConfigTopic string
|
||||
|
||||
// DynamicConfigValue defines model for DynamicConfigValue.
|
||||
type DynamicConfigValue struct {
|
||||
Id string `json:"id"`
|
||||
|
||||
@@ -2,39 +2,69 @@ import { css } from '@emotion/css';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import {
|
||||
DataFrame,
|
||||
DataTransformerConfig,
|
||||
GrafanaTheme2,
|
||||
StandardEditorContext,
|
||||
StandardEditorsRegistryItem,
|
||||
} from '@grafana/data';
|
||||
import { Field, useStyles2 } from '@grafana/ui';
|
||||
import { DataTopic } from '@grafana/schema';
|
||||
import { Field, Select, useStyles2 } from '@grafana/ui';
|
||||
import { FrameSelectionEditor } from 'app/plugins/panel/geomap/editor/FrameSelectionEditor';
|
||||
|
||||
import { TransformationData } from './TransformationsEditor';
|
||||
|
||||
interface TransformationFilterProps {
|
||||
index: number;
|
||||
config: DataTransformerConfig;
|
||||
data: DataFrame[];
|
||||
data: TransformationData;
|
||||
onChange: (index: number, config: DataTransformerConfig) => void;
|
||||
}
|
||||
|
||||
export const TransformationFilter = ({ index, data, config, onChange }: TransformationFilterProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const context = useMemo(() => {
|
||||
// eslint-disable-next-line
|
||||
return { data } as StandardEditorContext<unknown>;
|
||||
}, [data]);
|
||||
|
||||
const opts = useMemo(() => {
|
||||
return {
|
||||
// eslint-disable-next-line
|
||||
context: { data: data.series } as StandardEditorContext<unknown>,
|
||||
showTopic: true || data.annotations?.length || config.topic?.length,
|
||||
showFilter: config.topic !== DataTopic.Annotations,
|
||||
source: [
|
||||
{ value: DataTopic.Series, label: `Query results` },
|
||||
{ value: DataTopic.Annotations, label: `Annotation data` },
|
||||
],
|
||||
};
|
||||
}, [data, config.topic]);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<Field label="Apply transformation to">
|
||||
<FrameSelectionEditor
|
||||
value={config.filter!}
|
||||
context={context}
|
||||
// eslint-disable-next-line
|
||||
item={{} as StandardEditorsRegistryItem}
|
||||
onChange={(filter) => onChange(index, { ...config, filter })}
|
||||
/>
|
||||
<>
|
||||
{opts.showTopic && (
|
||||
<Select
|
||||
isClearable={true}
|
||||
options={opts.source}
|
||||
value={opts.source.find((v) => v.value === config.topic)}
|
||||
placeholder={opts.source[0].label}
|
||||
className={styles.padded}
|
||||
onChange={(option) => {
|
||||
onChange(index, {
|
||||
...config,
|
||||
topic: option?.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{opts.showFilter && (
|
||||
<FrameSelectionEditor
|
||||
value={config.filter!}
|
||||
context={opts.context}
|
||||
// eslint-disable-next-line
|
||||
item={{} as StandardEditorsRegistryItem}
|
||||
onChange={(filter) => onChange(index, { ...config, filter })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
@@ -44,13 +74,16 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
const borderRadius = theme.shape.radius.default;
|
||||
|
||||
return {
|
||||
wrapper: css`
|
||||
padding: ${theme.spacing(2)};
|
||||
border: 2px solid ${theme.colors.background.secondary};
|
||||
border-top: none;
|
||||
border-radius: 0 0 ${borderRadius} ${borderRadius};
|
||||
position: relative;
|
||||
top: -4px;
|
||||
`,
|
||||
wrapper: css({
|
||||
padding: theme.spacing(2),
|
||||
border: `2px solid ${theme.colors.background.secondary}`,
|
||||
borderTop: `none`,
|
||||
borderRadius: `0 0 ${borderRadius} ${borderRadius}`,
|
||||
position: `relative`,
|
||||
top: `-4px`,
|
||||
}),
|
||||
padded: css({
|
||||
marginBottom: theme.spacing(1),
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useToggle } from 'react-use';
|
||||
|
||||
import { DataFrame, DataTransformerConfig, TransformerRegistryItem, FrameMatcherID } from '@grafana/data';
|
||||
import { DataTransformerConfig, TransformerRegistryItem, FrameMatcherID, DataTopic } from '@grafana/data';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
import { ConfirmModal } from '@grafana/ui';
|
||||
import {
|
||||
@@ -15,12 +15,13 @@ import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo
|
||||
import { TransformationEditor } from './TransformationEditor';
|
||||
import { TransformationEditorHelperModal } from './TransformationEditorHelperModal';
|
||||
import { TransformationFilter } from './TransformationFilter';
|
||||
import { TransformationData } from './TransformationsEditor';
|
||||
import { TransformationsEditorTransformation } from './types';
|
||||
|
||||
interface TransformationOperationRowProps {
|
||||
id: string;
|
||||
index: number;
|
||||
data: DataFrame[];
|
||||
data: TransformationData;
|
||||
uiConfig: TransformerRegistryItem<null>;
|
||||
configs: TransformationsEditorTransformation[];
|
||||
onRemove: (index: number) => void;
|
||||
@@ -40,8 +41,9 @@ export const TransformationOperationRow = ({
|
||||
const [showDebug, toggleShowDebug] = useToggle(false);
|
||||
const [showHelp, toggleShowHelp] = useToggle(false);
|
||||
const disabled = !!configs[index].transformation.disabled;
|
||||
const filter = configs[index].transformation.filter != null;
|
||||
const showFilter = filter || data.length > 1;
|
||||
const topic = configs[index].transformation.topic;
|
||||
const showFilterEditor = configs[index].transformation.filter != null || topic != null;
|
||||
const showFilterToggle = showFilterEditor || data.series.length > 1 || (data.annotations?.length ?? 0) > 0;
|
||||
|
||||
const onDisableToggle = useCallback(
|
||||
(index: number) => {
|
||||
@@ -99,12 +101,12 @@ export const TransformationOperationRow = ({
|
||||
onClick={instrumentToggleCallback(toggleShowHelp, 'help', showHelp)}
|
||||
active={showHelp}
|
||||
/>
|
||||
{showFilter && (
|
||||
{showFilterToggle && (
|
||||
<QueryOperationToggleAction
|
||||
title="Filter"
|
||||
icon="filter"
|
||||
onClick={instrumentToggleCallback(toggleFilter, 'filter', filter)}
|
||||
active={filter}
|
||||
onClick={instrumentToggleCallback(toggleFilter, 'filter', showFilterEditor)}
|
||||
active={showFilterEditor}
|
||||
/>
|
||||
)}
|
||||
<QueryOperationToggleAction
|
||||
@@ -156,13 +158,14 @@ export const TransformationOperationRow = ({
|
||||
open: 'Expand transformation row',
|
||||
}}
|
||||
>
|
||||
{filter && (
|
||||
{showFilterEditor && (
|
||||
<TransformationFilter index={index} config={configs[index].transformation} data={data} onChange={onChange} />
|
||||
)}
|
||||
|
||||
<TransformationEditor
|
||||
debugMode={showDebug}
|
||||
index={index}
|
||||
data={data}
|
||||
data={topic === DataTopic.Annotations ? data.annotations ?? [] : data.series}
|
||||
configs={configs}
|
||||
uiConfig={uiConfig}
|
||||
onChange={onChange}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React from 'react';
|
||||
|
||||
import { DataFrame, DataTransformerConfig, standardTransformersRegistry } from '@grafana/data';
|
||||
import { DataTransformerConfig, standardTransformersRegistry } from '@grafana/data';
|
||||
|
||||
import { TransformationOperationRow } from './TransformationOperationRow';
|
||||
import { TransformationData } from './TransformationsEditor';
|
||||
import { TransformationsEditorTransformation } from './types';
|
||||
|
||||
interface TransformationOperationRowsProps {
|
||||
data: DataFrame[];
|
||||
data: TransformationData;
|
||||
configs: TransformationsEditorTransformation[];
|
||||
onRemove: (index: number) => void;
|
||||
onChange: (index: number, config: DataTransformerConfig) => void;
|
||||
|
||||
@@ -44,8 +44,13 @@ const VIEW_ALL_VALUE = 'viewAll';
|
||||
export type viewAllType = 'viewAll';
|
||||
export type FilterCategory = TransformerCategory | viewAllType;
|
||||
|
||||
export interface TransformationData {
|
||||
series: DataFrame[];
|
||||
annotations?: DataFrame[];
|
||||
}
|
||||
|
||||
interface State {
|
||||
data: DataFrame[];
|
||||
data: TransformationData;
|
||||
transformations: TransformationsEditorTransformation[];
|
||||
search: string;
|
||||
showPicker?: boolean;
|
||||
@@ -68,7 +73,9 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
|
||||
transformation: t,
|
||||
id: ids[i],
|
||||
})),
|
||||
data: [],
|
||||
data: {
|
||||
series: [],
|
||||
},
|
||||
search: '',
|
||||
selectedFilter: VIEW_ALL_VALUE,
|
||||
showIllustrations: true,
|
||||
@@ -120,7 +127,7 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
|
||||
.getQueryRunner()
|
||||
.getData({ withTransforms: false, withFieldConfig: false })
|
||||
.subscribe({
|
||||
next: (panelData: PanelData) => this.setState({ data: panelData.series }),
|
||||
next: (panelData: PanelData) => this.setState({ data: panelData }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -384,7 +391,7 @@ class UnThemedTransformationsEditor extends React.PureComponent<TransformationsE
|
||||
onSearchChange={this.onSearchChange}
|
||||
onSearchKeyDown={this.onSearchKeyDown}
|
||||
onTransformationAdd={this.onTransformationAdd}
|
||||
data={this.state.data}
|
||||
data={this.state.data.series}
|
||||
selectedFilter={this.state.selectedFilter}
|
||||
showIllustrations={this.state.showIllustrations}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { cloneDeep, merge } from 'lodash';
|
||||
import { Observable, of, ReplaySubject, Unsubscribable } from 'rxjs';
|
||||
import { map, mergeMap, catchError } from 'rxjs/operators';
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
preProcessPanelData,
|
||||
ApplyFieldOverrideOptions,
|
||||
StreamingDataFrame,
|
||||
DataTopic,
|
||||
} from '@grafana/data';
|
||||
import { toDataQueryError } from '@grafana/runtime';
|
||||
import { ExpressionDatasourceRef } from '@grafana/runtime/src/utils/DataSourceWithBackend';
|
||||
@@ -221,8 +222,18 @@ export class PanelQueryRunner {
|
||||
interpolate: (v: string) => this.templateSrv.replace(v, data?.request?.scopedVars),
|
||||
};
|
||||
|
||||
return transformDataFrame(transformations, data.series, ctx).pipe(
|
||||
map((series) => ({ ...data, series })),
|
||||
let seriesTransformations = transformations.filter((t) => t.topic == null || t.topic === DataTopic.Series);
|
||||
let annotationsTransformations = transformations.filter((t) => t.topic === DataTopic.Annotations);
|
||||
|
||||
let seriesStream = transformDataFrame(seriesTransformations, data.series, ctx);
|
||||
let annotationsStream = transformDataFrame(annotationsTransformations, data.annotations ?? [], ctx);
|
||||
|
||||
return merge(seriesStream, annotationsStream).pipe(
|
||||
map((frames) => {
|
||||
let isAnnotations = frames.some((f) => f.meta?.dataTopic === DataTopic.Annotations);
|
||||
let transformed = isAnnotations ? { annotations: frames } : { series: frames };
|
||||
return { ...data, ...transformed };
|
||||
}),
|
||||
catchError((err) => {
|
||||
console.warn('Error running transformation:', err);
|
||||
return of({
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { TestScheduler } from 'rxjs/testing';
|
||||
|
||||
import { AlertState, getDefaultTimeRange, LoadingState, PanelData, toDataFrame } from '@grafana/data';
|
||||
import { AlertState, DataTopic, getDefaultTimeRange, LoadingState, PanelData, toDataFrame } from '@grafana/data';
|
||||
|
||||
import { mergePanelAndDashData } from './mergePanelAndDashData';
|
||||
|
||||
function toAnnotationFrame(data: Array<Record<string, string>>) {
|
||||
let frame = toDataFrame(data);
|
||||
frame.meta = { dataTopic: DataTopic.Annotations };
|
||||
return frame;
|
||||
}
|
||||
|
||||
function getTestContext() {
|
||||
const timeRange = getDefaultTimeRange();
|
||||
const panelData: PanelData = {
|
||||
@@ -34,7 +40,7 @@ describe('mergePanelAndDashboardData', () => {
|
||||
a: {
|
||||
state: LoadingState.Done,
|
||||
series: [],
|
||||
annotations: [toDataFrame([{ id: 'panelData' }]), toDataFrame([{ id: 'dashData' }])],
|
||||
annotations: [toAnnotationFrame([{ id: 'panelData' }]), toAnnotationFrame([{ id: 'dashData' }])],
|
||||
timeRange,
|
||||
},
|
||||
});
|
||||
@@ -63,7 +69,7 @@ describe('mergePanelAndDashboardData', () => {
|
||||
a: {
|
||||
state: LoadingState.Done,
|
||||
series: [],
|
||||
annotations: [toDataFrame([{ id: 'panelData' }]), toDataFrame([])],
|
||||
annotations: [toAnnotationFrame([{ id: 'panelData' }]), toAnnotationFrame([])],
|
||||
alertState: { id: 1, state: AlertState.OK, dashboardId: 1, panelId: 1, newStateDate: '' },
|
||||
timeRange,
|
||||
},
|
||||
@@ -92,7 +98,7 @@ describe('mergePanelAndDashboardData', () => {
|
||||
a: {
|
||||
state: LoadingState.Done,
|
||||
series: [],
|
||||
annotations: [toDataFrame([{ id: 'panelData' }])],
|
||||
annotations: [toAnnotationFrame([{ id: 'panelData' }])],
|
||||
timeRange,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { combineLatest, Observable, of } from 'rxjs';
|
||||
import { mergeMap } from 'rxjs/operators';
|
||||
|
||||
import { ArrayDataFrame, PanelData } from '@grafana/data';
|
||||
import { arrayToDataFrame, DataFrame, DataTopic, PanelData } from '@grafana/data';
|
||||
|
||||
import { DashboardQueryRunnerResult } from './DashboardQueryRunner/types';
|
||||
|
||||
function addAnnoDataTopic(annotations: DataFrame[] = []) {
|
||||
annotations.forEach((f) => {
|
||||
f.meta = {
|
||||
...f.meta,
|
||||
dataTopic: DataTopic.Annotations,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function mergePanelAndDashData(
|
||||
panelObservable: Observable<PanelData>,
|
||||
dashObservable: Observable<DashboardQueryRunnerResult>
|
||||
@@ -18,11 +27,16 @@ export function mergePanelAndDashData(
|
||||
panelData.annotations = [];
|
||||
}
|
||||
|
||||
const annotations = panelData.annotations.concat(new ArrayDataFrame(dashData.annotations));
|
||||
const annotations = panelData.annotations.concat(arrayToDataFrame(dashData.annotations));
|
||||
|
||||
addAnnoDataTopic(annotations);
|
||||
|
||||
const alertState = dashData.alertState;
|
||||
return of({ ...panelData, annotations, alertState });
|
||||
}
|
||||
|
||||
addAnnoDataTopic(panelData.annotations);
|
||||
|
||||
return of(panelData);
|
||||
})
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user