Transformations: Add frame source picker to allow transforming annotations (#77842)

Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
Leon Sorokin
2024-01-02 23:33:31 -06:00
committed by GitHub
parent a5957ba555
commit fb79be4a43
16 changed files with 165 additions and 56 deletions

View File

@@ -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"]

View File

@@ -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 thats 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

View File

@@ -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: _

View File

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

View File

@@ -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
*/

View 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")

View File

@@ -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
}
/**

View File

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

View File

@@ -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"`

View File

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

View File

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

View File

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

View File

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

View File

@@ -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({

View File

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

View File

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