diff --git a/.betterer.results b/.betterer.results index bda1b17be1e..c1391a5232a 100644 --- a/.betterer.results +++ b/.betterer.results @@ -3361,23 +3361,7 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "2"], [0, 0, 0, "Unexpected any. Specify a different type.", "3"], [0, 0, 0, "Styles should be written using objects.", "4"], - [0, 0, 0, "Styles should be written using objects.", "5"], - [0, 0, 0, "Styles should be written using objects.", "6"], - [0, 0, 0, "Styles should be written using objects.", "7"], - [0, 0, 0, "Styles should be written using objects.", "8"], - [0, 0, 0, "Styles should be written using objects.", "9"], - [0, 0, 0, "Styles should be written using objects.", "10"], - [0, 0, 0, "Styles should be written using objects.", "11"], - [0, 0, 0, "Styles should be written using objects.", "12"], - [0, 0, 0, "Styles should be written using objects.", "13"], - [0, 0, 0, "Styles should be written using objects.", "14"], - [0, 0, 0, "Styles should be written using objects.", "15"], - [0, 0, 0, "Styles should be written using objects.", "16"], - [0, 0, 0, "Styles should be written using objects.", "17"], - [0, 0, 0, "Styles should be written using objects.", "18"], - [0, 0, 0, "Styles should be written using objects.", "19"], - [0, 0, 0, "Styles should be written using objects.", "20"], - [0, 0, 0, "Unexpected any. Specify a different type.", "21"] + [0, 0, 0, "Unexpected any. Specify a different type.", "5"] ], "public/app/features/dashboard/components/VersionHistory/DiffGroup.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], diff --git a/packages/grafana-data/src/transformations/transformers/formatTime.ts b/packages/grafana-data/src/transformations/transformers/formatTime.ts index 662fcf153fa..df7ca872a84 100644 --- a/packages/grafana-data/src/transformations/transformers/formatTime.ts +++ b/packages/grafana-data/src/transformations/transformers/formatTime.ts @@ -2,7 +2,7 @@ import { map } from 'rxjs/operators'; import { TimeZone } from '@grafana/schema'; -import { Field } from '../../types'; +import { DataFrame, Field, TransformationApplicabilityLevels } from '../../types'; import { DataTransformerInfo } from '../../types/transformations'; import { fieldToStringField } from './convertFieldType'; @@ -19,6 +19,21 @@ export const formatTimeTransformer: DataTransformerInfo { + // Search for a time field + // if there is one then we can use this transformation + for (const frame of data) { + for (const field of frame.fields) { + if (field.type === 'time') { + return TransformationApplicabilityLevels.Applicable; + } + } + } + + return TransformationApplicabilityLevels.NotApplicable; + }, + isApplicableDescription: + 'The Format time transformation requires a time field to work. No time field could be found.', operator: (options) => (source) => source.pipe( map((data) => { diff --git a/packages/grafana-data/src/transformations/transformers/groupBy.ts b/packages/grafana-data/src/transformations/transformers/groupBy.ts index 61b68a98508..e57d15ed9b3 100644 --- a/packages/grafana-data/src/transformations/transformers/groupBy.ts +++ b/packages/grafana-data/src/transformations/transformers/groupBy.ts @@ -2,7 +2,7 @@ import { map } from 'rxjs/operators'; import { guessFieldTypeForField } from '../../dataframe/processDataFrame'; import { getFieldDisplayName } from '../../field/fieldState'; -import { DataFrame, Field, FieldType } from '../../types/dataFrame'; +import { DataFrame, Field, FieldType, TransformationApplicabilityLevels } from '../../types'; import { DataTransformerInfo } from '../../types/transformations'; import { reduceField, ReducerID } from '../fieldReducer'; @@ -29,7 +29,34 @@ export const groupByTransformer: DataTransformerInfo defaultOptions: { fields: {}, }, + isApplicable: (data: DataFrame[]) => { + let maxFields = 0; + // Group by needs at least two fields + // a field to group on and a field to aggregate + // We make sure that at least one frame has at + // least two fields + for (const frame of data) { + if (frame.fields.length > maxFields) { + maxFields = frame.fields.length; + } + } + + return maxFields >= 2 + ? TransformationApplicabilityLevels.Applicable + : TransformationApplicabilityLevels.NotApplicable; + }, + isApplicableDescription: (data: DataFrame[]) => { + let maxFields = 0; + + for (const frame of data) { + if (frame.fields.length > maxFields) { + maxFields = frame.fields.length; + } + } + + return `The Group by transformation requires a series with at least two fields to work. The maximum number of fields found on a series is ${maxFields}`; + }, /** * Return a modified copy of the series. If the transform is not or should not * be applied, just return the input series diff --git a/packages/grafana-data/src/transformations/transformers/groupingToMatrix.ts b/packages/grafana-data/src/transformations/transformers/groupingToMatrix.ts index 741c2a50adf..f600b5ee7ba 100644 --- a/packages/grafana-data/src/transformations/transformers/groupingToMatrix.ts +++ b/packages/grafana-data/src/transformations/transformers/groupingToMatrix.ts @@ -1,7 +1,14 @@ import { map } from 'rxjs/operators'; import { getFieldDisplayName } from '../../field/fieldState'; -import { DataFrame, DataTransformerInfo, Field, FieldType, SpecialValue } from '../../types'; +import { + DataFrame, + DataTransformerInfo, + Field, + FieldType, + SpecialValue, + TransformationApplicabilityLevels, +} from '../../types'; import { fieldMatchers } from '../matchers'; import { FieldMatcherID } from '../matchers/ids'; @@ -33,7 +40,29 @@ export const groupingToMatrixTransformer: DataTransformerInfo { + let numFields = 0; + for (const frame of data) { + numFields += frame.fields.length; + } + + return numFields >= 3 + ? TransformationApplicabilityLevels.Applicable + : TransformationApplicabilityLevels.NotApplicable; + }, + isApplicableDescription: (data: DataFrame[]) => { + let numFields = 0; + + for (const frame of data) { + numFields += frame.fields.length; + } + + return `Grouping to matrix requiers at least 3 fields to work. Currently there are ${numFields} fields.`; + }, operator: (options) => (source) => source.pipe( map((data) => { diff --git a/packages/grafana-data/src/transformations/transformers/merge.ts b/packages/grafana-data/src/transformations/transformers/merge.ts index d1f23459922..5abf270b6e5 100644 --- a/packages/grafana-data/src/transformations/transformers/merge.ts +++ b/packages/grafana-data/src/transformations/transformers/merge.ts @@ -3,7 +3,7 @@ import { map } from 'rxjs/operators'; import { MutableDataFrame } from '../../dataframe'; import { DataFrame, Field } from '../../types/dataFrame'; -import { DataTransformerInfo } from '../../types/transformations'; +import { DataTransformerInfo, TransformationApplicabilityLevels } from '../../types/transformations'; import { DataTransformerID } from './ids'; @@ -19,6 +19,14 @@ export const mergeTransformer: DataTransformerInfo = { name: 'Merge series/tables', description: 'Merges multiple series/tables into a single serie/table', defaultOptions: {}, + isApplicable: (data: DataFrame[]) => { + return data.length > 1 + ? TransformationApplicabilityLevels.Applicable + : TransformationApplicabilityLevels.NotApplicable; + }, + isApplicableDescription: (data: DataFrame[]) => { + return `The merge transformation requires at least 2 data series to work. There is currently ${data.length} data series.`; + }, operator: (options) => (source) => source.pipe( map((dataFrames) => { diff --git a/packages/grafana-data/src/transformations/transformers/organize.ts b/packages/grafana-data/src/transformations/transformers/organize.ts index f910c600e18..3eb8b04e709 100644 --- a/packages/grafana-data/src/transformations/transformers/organize.ts +++ b/packages/grafana-data/src/transformations/transformers/organize.ts @@ -1,4 +1,4 @@ -import { DataTransformerInfo } from '../../types/transformations'; +import { DataFrame, DataTransformerInfo, TransformationApplicabilityLevels } from '../../types'; import { filterFieldsByNameTransformer } from './filterByName'; import { DataTransformerID } from './ids'; @@ -20,7 +20,11 @@ export const organizeFieldsTransformer: DataTransformerInfo { + return data.length > 1 + ? TransformationApplicabilityLevels.NotPossible + : TransformationApplicabilityLevels.Applicable; + }, /** * Return a modified copy of the series. If the transform is not or should not * be applied, just return the input series diff --git a/packages/grafana-data/src/types/transformations.ts b/packages/grafana-data/src/types/transformations.ts index 1f9b3be2c47..37501f7d982 100644 --- a/packages/grafana-data/src/types/transformations.ts +++ b/packages/grafana-data/src/types/transformations.ts @@ -17,6 +17,21 @@ export interface DataTransformContext { interpolate: InterpolateFunction; } +/** + * We score for how applicable a given transformation is. + * Currently : + * 0 is considered as not-applicable + * 1 is considered applicable + * 2 is considered as highly applicable (i.e. should be highlighted) + */ +export type TransformationApplicabilityScore = number; +export enum TransformationApplicabilityLevels { + NotPossible = -1, + NotApplicable = 0, + Applicable = 1, + HighlyApplicable = 2, +} + /** * Function that transform data frames (AKA transformer) * @@ -28,6 +43,18 @@ export interface DataTransformerInfo extends RegistryItemWithOpt * @param options */ operator: (options: TOptions, context: DataTransformContext) => MonoTypeOperatorFunction; + /** + * Function that is present will indicate whether a transformation is applicable + * given the current data. + * @param options + */ + isApplicable?: (data: DataFrame[]) => TransformationApplicabilityScore; + /** + * A description of the applicator. Can either simply be a string + * or function which when given the current dataset returns a string. + * This way descriptions can be tailored relative to the underlying data. + */ + isApplicableDescription?: string | ((data: DataFrame[]) => string); } /** diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx index 5a6f7fbcf6a..0a714b49c5a 100644 --- a/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationsEditor.tsx @@ -14,6 +14,7 @@ import { TransformerRegistryItem, TransformerCategory, DataTransformerID, + TransformationApplicabilityLevels, } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { reportInteraction } from '@grafana/runtime'; @@ -486,6 +487,7 @@ class UnThemedTransformationsEditor extends React.PureComponent { this.onTransformationAdd({ value: id }); }} @@ -589,90 +591,98 @@ function TransformationCard({ transform, onClick }: TransformationCardProps) { const getStyles = (theme: GrafanaTheme2) => { return { - hide: css` - display: none; - `, - card: css` - margin: 0; - padding: ${theme.spacing(1)}; - `, - grid: css` - display: grid; - grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); - grid-auto-rows: 1fr; - gap: ${theme.spacing(2)} ${theme.spacing(1)}; - width: 100%; - `, - newCard: css` - grid-template-rows: min-content 0 1fr 0; - `, + hide: css({ + display: 'none', + }), + card: css({ + margin: '0', + padding: `${theme.spacing(1)}`, + }), + grid: css({ + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', + gridAutoRows: '1fr', + gap: `${theme.spacing(2)} ${theme.spacing(1)}`, + width: '100%', + }), + newCard: css({ + gridTemplateRows: 'min-content 0 1fr 0', + }), + cardDisabled: css({ + backgroundColor: 'rgb(204, 204, 220, 0.045)', + color: `${theme.colors.text.disabled} !important`, + }), heading: css` - font-weight: 400; - - > button { - width: 100%; - display: flex; - justify-content: space-between; - align-items: center; - flex-wrap: no-wrap; - } - `, - description: css` - font-size: 12px; - display: flex; - flex-direction: column; - justify-content: space-between; - `, - image: css` - display: block; - max-width: 100%; - margin-top: ${theme.spacing(2)}; - `, - searchWrapper: css` - display: flex; - flex-wrap: wrap; - column-gap: 27px; - row-gap: 16px; - width: 100%; - `, - searchInput: css` - flex-grow: 1; - width: initial; - `, - showImages: css` - flex-basis: 0; - display: flex; - gap: 8px; - align-items: center; - `, - pickerInformationLine: css` - font-size: 16px; - margin-bottom: ${theme.spacing(2)}; - `, - pickerInformationLineHighlight: css` - vertical-align: middle; - `, - illustationSwitchLabel: css` - white-space: nowrap; - `, - filterWrapper: css` - padding: ${theme.spacing(1)} 0; - display: flex; - flex-wrap: wrap; - row-gap: ${theme.spacing(1)}; - column-gap: ${theme.spacing(0.5)}; - `, - listInformationLineWrapper: css` - display: flex; - justify-content: space-between; - margin-bottom: 24px; - `, - listInformationLineText: css` - font-size: 16px; - `, - pluginStateInfoWrapper: css` - margin-left: 5px; + font-weight: 400, + > button: { + width: '100%', + display: 'flex', + justify-content: 'space-between', + align-items: 'center', + flex-wrap: 'no-wrap', + }, `, + description: css({ + fontSize: '12px', + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-between', + }), + image: css({ + display: 'block', + maxEidth: '100%`', + marginTop: `${theme.spacing(2)}`, + }), + searchWrapper: css({ + display: 'flex', + flexWrap: 'wrap', + columnGap: '27px', + rowGap: '16px', + width: '100%', + }), + searchInput: css({ + flexGrow: '1', + width: 'initial', + }), + showImages: css({ + flexBasis: '0', + display: 'flex', + gap: '8px', + alignItems: 'center', + }), + pickerInformationLine: css({ + fontSize: '16px', + marginBottom: `${theme.spacing(2)}`, + }), + pickerInformationLineHighlight: css({ + verticalAlign: 'middle', + }), + illustationSwitchLabel: css({ + whiteSpace: 'nowrap', + }), + filterWrapper: css({ + padding: `${theme.spacing(1)} 0`, + display: 'flex', + flexWrap: 'wrap', + rowGap: `${theme.spacing(1)}`, + columnGap: `${theme.spacing(0.5)}`, + }), + listInformationLineWrapper: css({ + display: 'flex', + justifyContent: 'space-between', + marginBottom: '24px', + }), + listInformationLineText: css({ + fontSize: '16px', + }), + pluginStateInfoWrapper: css({ + marginLeft: '5px', + }), + cardApplicableInfo: css({ + position: 'absolute', + bottom: `${theme.spacing(1)}`, + right: `${theme.spacing(1)}`, + }), }; }; @@ -680,46 +690,88 @@ interface TransformationsGridProps { transformations: Array>; showIllustrations?: boolean; onClick: (id: string) => void; + data: DataFrame[]; } -function TransformationsGrid({ showIllustrations, transformations, onClick }: TransformationsGridProps) { +function TransformationsGrid({ showIllustrations, transformations, onClick, data }: TransformationsGridProps) { const styles = useStyles2(getStyles); return (
- {transformations.map((transform) => ( - onClick(transform.id)} - > - - <> - {transform.name} - - - - - - - <> - {getTransformationsRedesignDescriptions(transform.id)} - {showIllustrations && ( - - {transform.name} + {transformations.map((transform) => { + // Check to see if the transform + // is applicable to the given data + let applicabilityScore = TransformationApplicabilityLevels.Applicable; + if (transform.transformation.isApplicable !== undefined) { + applicabilityScore = transform.transformation.isApplicable(data); + } + const isApplicable = applicabilityScore > 0; + + let applicabilityDescription = null; + if (transform.transformation.isApplicableDescription !== undefined) { + if (typeof transform.transformation.isApplicableDescription === 'function') { + applicabilityDescription = transform.transformation.isApplicableDescription(data); + } else { + applicabilityDescription = transform.transformation.isApplicableDescription; + } + } + + // Add disabled styles to disabled + let cardClasses = styles.newCard; + if (!isApplicable) { + cardClasses = cx(styles.newCard, styles.cardDisabled); + } + + return ( + onClick(transform.id)} + key={transform.id} + > + + <> + {transform.name} + + - )} - - - - ))} + + + + <> + {getTransformationsRedesignDescriptions(transform.id)} + {showIllustrations && ( + + {transform.name} + + )} + {!isApplicable && applicabilityDescription !== null && ( + + )} + + + + ); + })}
); } -const getImagePath = (id: string) => { - const folder = config.theme2.isDark ? 'dark' : 'light'; +const getImagePath = (id: string, disabled: boolean) => { + let folder = null; + if (!disabled) { + folder = config.theme2.isDark ? 'dark' : 'light'; + } else { + folder = 'disabled'; + } return `public/img/transformations/${folder}/${id}.svg`; }; diff --git a/public/app/features/transformers/calculateHeatmap/heatmap.ts b/public/app/features/transformers/calculateHeatmap/heatmap.ts index 32b5b2c6007..a5c770fe9e8 100644 --- a/public/app/features/transformers/calculateHeatmap/heatmap.ts +++ b/public/app/features/transformers/calculateHeatmap/heatmap.ts @@ -14,6 +14,7 @@ import { formattedValueToString, durationToMilliseconds, parseDuration, + TransformationApplicabilityLevels, } from '@grafana/data'; import { isLikelyAscendingVector } from '@grafana/data/src/transformations/transformers/joinDataFrames'; import { config } from '@grafana/runtime'; @@ -36,7 +37,21 @@ export const heatmapTransformer: SynchronousDataTransformerInfo { + const { xField, yField, xs, ys } = findHeatmapFields(data); + if (xField || yField) { + return TransformationApplicabilityLevels.NotPossible; + } + + if (!xs.length || !ys.length) { + return TransformationApplicabilityLevels.NotPossible; + } + + return TransformationApplicabilityLevels.Applicable; + }, + isApplicableDescription: + 'The Heatmap transformation requires fields with Heatmap compatible data. No fields with Heatmap data could be found.', operator: (options, ctx) => (source) => source.pipe( map((data) => { @@ -278,56 +293,8 @@ export function prepBucketFrames(frames: DataFrame[]): DataFrame[] { } export function calculateHeatmapFromData(frames: DataFrame[], options: HeatmapCalculationOptions): DataFrame { - //console.time('calculateHeatmapFromData'); - - // optimization - //let xMin = Infinity; - //let xMax = -Infinity; - - let xField: Field | undefined = undefined; - let yField: Field | undefined = undefined; - - let dataLen = 0; - // pre-allocate arrays - for (let frame of frames) { - // TODO: assumes numeric timestamps, ordered asc, without nulls - const x = frame.fields.find((f) => f.type === FieldType.time); - if (x) { - dataLen += frame.length; - } - } - - let xs: number[] = Array(dataLen); - let ys: number[] = Array(dataLen); - let j = 0; - - for (let frame of frames) { - // TODO: assumes numeric timestamps, ordered asc, without nulls - const x = frame.fields.find((f) => f.type === FieldType.time); - if (!x) { - continue; - } - - if (!xField) { - xField = x; // the first X - } - - const xValues = x.values; - for (let field of frame.fields) { - if (field !== x && field.type === FieldType.number) { - const yValues = field.values; - - for (let i = 0; i < xValues.length; i++, j++) { - xs[j] = xValues[i]; - ys[j] = yValues[i]; - } - - if (!yField) { - yField = field; - } - } - } - } + // Find fields in the heatmap + const { xField, yField, xs, ys } = findHeatmapFields(frames); if (!xField || !yField) { throw 'no heatmap fields found'; @@ -398,10 +365,64 @@ export function calculateHeatmapFromData(frames: DataFrame[], options: HeatmapCa ], }; - //console.timeEnd('calculateHeatmapFromData'); return frame; } +/** + * Find fields that can be used within a heatmap + * + * @param frames + * An array of DataFrames + */ +function findHeatmapFields(frames: DataFrame[]) { + let xField: Field | undefined = undefined; + let yField: Field | undefined = undefined; + let dataLen = 0; + + // pre-allocate arrays + for (let frame of frames) { + // TODO: assumes numeric timestamps, ordered asc, without nulls + const x = frame.fields.find((f) => f.type === FieldType.time); + if (x) { + dataLen += frame.length; + } + } + + let xs: number[] = Array(dataLen); + let ys: number[] = Array(dataLen); + let j = 0; + + for (let frame of frames) { + // TODO: assumes numeric timestamps, ordered asc, without nulls + const x = frame.fields.find((f) => f.type === FieldType.time); + if (!x) { + continue; + } + + if (!xField) { + xField = x; // the first X + } + + const xValues = x.values; + for (let field of frame.fields) { + if (field !== x && field.type === FieldType.number) { + const yValues = field.values; + + for (let i = 0; i < xValues.length; i++, j++) { + xs[j] = xValues[i]; + ys[j] = yValues[i]; + } + + if (!yField) { + yField = field; + } + } + } + } + + return { xField, yField, xs, ys }; +} + interface HeatmapOpts { // default is 10% of data range, snapped to a "nice" increment xMode?: HeatmapCalculationMode; diff --git a/public/img/transformations/disabled/formatTime.svg b/public/img/transformations/disabled/formatTime.svg new file mode 100644 index 00000000000..b9a2635478b --- /dev/null +++ b/public/img/transformations/disabled/formatTime.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/img/transformations/disabled/groupBy.svg b/public/img/transformations/disabled/groupBy.svg new file mode 100644 index 00000000000..737996e2a75 --- /dev/null +++ b/public/img/transformations/disabled/groupBy.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/img/transformations/disabled/groupingToMatrix.svg b/public/img/transformations/disabled/groupingToMatrix.svg new file mode 100644 index 00000000000..d0c6e07de66 --- /dev/null +++ b/public/img/transformations/disabled/groupingToMatrix.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/img/transformations/disabled/heatmap.svg b/public/img/transformations/disabled/heatmap.svg new file mode 100644 index 00000000000..775794a8e85 --- /dev/null +++ b/public/img/transformations/disabled/heatmap.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/img/transformations/disabled/merge.svg b/public/img/transformations/disabled/merge.svg new file mode 100644 index 00000000000..f8561735deb --- /dev/null +++ b/public/img/transformations/disabled/merge.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/img/transformations/disabled/organize.svg b/public/img/transformations/disabled/organize.svg new file mode 100644 index 00000000000..fc3ed53d19e --- /dev/null +++ b/public/img/transformations/disabled/organize.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +