From c6e27e00b4f1320c06bf1bfb8e7a631c46cb1ba8 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Wed, 1 Nov 2023 21:59:55 -0700 Subject: [PATCH 01/30] Chore: Move internal GraphNG+Timeseries components into core (#77525) * move to core where possible * update imports * ignore import order for now * add graveyard files * update codeowners --- .betterer.results | 99 ++- .github/CODEOWNERS | 6 +- .../grafana-data/src/transformations/index.ts | 3 + .../nulls}/nullInsertThreshold.test.ts | 0 .../transformers/nulls/nullInsertThreshold.ts | 186 +++++ .../nulls}/nullToUndefThreshold.ts | 0 .../transformers/nulls}/nullToValue.test.ts | 0 .../transformers/nulls/nullToValue.ts | 27 + .../src/components/Sparkline/utils.ts | 3 +- packages/grafana-ui/src/components/index.ts | 13 +- .../src/components/uPlot/internal.ts | 40 ++ .../grafana-ui/src/components/uPlot/types.ts | 5 + .../src/components/uPlot/utils.test.ts | 2 +- .../grafana-ui/src/components/uPlot/utils.ts | 3 +- .../src/graveyard/GraphNG/GraphNG.tsx | 275 +++++++ .../GraphNG/__snapshots__/utils.test.ts.snap | 0 .../grafana-ui/src/graveyard/GraphNG/hooks.ts | 41 ++ .../GraphNG/nullInsertThreshold.test.ts | 334 +++++++++ .../GraphNG/nullInsertThreshold.ts | 1 + .../graveyard/GraphNG/nullToUndefThreshold.ts | 33 + .../src/graveyard/GraphNG/nullToValue.test.ts | 94 +++ .../GraphNG/nullToValue.ts | 1 + .../grafana-ui/src/graveyard/GraphNG/types.ts | 18 + .../GraphNG/utils.test.ts | 0 .../GraphNG/utils.ts | 3 +- packages/grafana-ui/src/graveyard/README.md | 1 + .../TimeSeries/TimeSeries.tsx | 6 +- .../TimeSeries/utils.test.ts | 0 .../TimeSeries/utils.ts | 6 +- .../app/core}/components/GraphNG/GraphNG.tsx | 20 +- .../GraphNG/__snapshots__/utils.test.ts.snap | 245 +++++++ .../app/core}/components/GraphNG/hooks.ts | 0 .../app/core}/components/GraphNG/types.ts | 3 +- .../app/core/components/GraphNG/utils.test.ts | 522 ++++++++++++++ public/app/core/components/GraphNG/utils.ts | 140 ++++ .../core/components/TimeSeries/TimeSeries.tsx | 63 ++ .../core/components/TimeSeries/utils.test.ts | 274 +++++++ .../app/core/components/TimeSeries/utils.ts | 669 ++++++++++++++++++ .../TimelineChart/TimelineChart.tsx | 13 +- .../core/components/TimelineChart/timeline.ts | 2 +- .../core/components/TimelineChart/utils.ts | 4 +- .../plugins/panel/barchart/BarChartPanel.tsx | 4 +- .../panel/candlestick/CandlestickPanel.tsx | 3 +- .../app/plugins/panel/graph/data_processor.ts | 2 +- .../panel/timeseries/TimeSeriesPanel.tsx | 3 +- .../timeseries/plugins/ExemplarsPlugin.tsx | 2 +- .../plugins/ThresholdControlsPlugin.tsx | 3 +- public/app/plugins/panel/timeseries/utils.ts | 6 +- public/app/plugins/panel/trend/TrendPanel.tsx | 13 +- public/app/plugins/panel/xychart/dims.ts | 2 +- 50 files changed, 3091 insertions(+), 102 deletions(-) rename packages/{grafana-ui/src/components/GraphNG => grafana-data/src/transformations/transformers/nulls}/nullInsertThreshold.test.ts (100%) create mode 100644 packages/grafana-data/src/transformations/transformers/nulls/nullInsertThreshold.ts rename packages/{grafana-ui/src/components/GraphNG => grafana-data/src/transformations/transformers/nulls}/nullToUndefThreshold.ts (100%) rename packages/{grafana-ui/src/components/GraphNG => grafana-data/src/transformations/transformers/nulls}/nullToValue.test.ts (100%) create mode 100644 packages/grafana-data/src/transformations/transformers/nulls/nullToValue.ts create mode 100644 packages/grafana-ui/src/components/uPlot/internal.ts create mode 100644 packages/grafana-ui/src/graveyard/GraphNG/GraphNG.tsx rename packages/grafana-ui/src/{components => graveyard}/GraphNG/__snapshots__/utils.test.ts.snap (100%) create mode 100644 packages/grafana-ui/src/graveyard/GraphNG/hooks.ts create mode 100644 packages/grafana-ui/src/graveyard/GraphNG/nullInsertThreshold.test.ts rename packages/grafana-ui/src/{components => graveyard}/GraphNG/nullInsertThreshold.ts (99%) create mode 100644 packages/grafana-ui/src/graveyard/GraphNG/nullToUndefThreshold.ts create mode 100644 packages/grafana-ui/src/graveyard/GraphNG/nullToValue.test.ts rename packages/grafana-ui/src/{components => graveyard}/GraphNG/nullToValue.ts (96%) create mode 100644 packages/grafana-ui/src/graveyard/GraphNG/types.ts rename packages/grafana-ui/src/{components => graveyard}/GraphNG/utils.test.ts (100%) rename packages/grafana-ui/src/{components => graveyard}/GraphNG/utils.ts (98%) create mode 100644 packages/grafana-ui/src/graveyard/README.md rename packages/grafana-ui/src/{components => graveyard}/TimeSeries/TimeSeries.tsx (87%) rename packages/grafana-ui/src/{components => graveyard}/TimeSeries/utils.test.ts (100%) rename packages/grafana-ui/src/{components => graveyard}/TimeSeries/utils.ts (98%) rename {packages/grafana-ui/src => public/app/core}/components/GraphNG/GraphNG.tsx (92%) create mode 100644 public/app/core/components/GraphNG/__snapshots__/utils.test.ts.snap rename {packages/grafana-ui/src => public/app/core}/components/GraphNG/hooks.ts (100%) rename {packages/grafana-ui/src => public/app/core}/components/GraphNG/types.ts (85%) create mode 100644 public/app/core/components/GraphNG/utils.test.ts create mode 100644 public/app/core/components/GraphNG/utils.ts create mode 100644 public/app/core/components/TimeSeries/TimeSeries.tsx create mode 100644 public/app/core/components/TimeSeries/utils.test.ts create mode 100644 public/app/core/components/TimeSeries/utils.ts diff --git a/.betterer.results b/.betterer.results index d9446d6b110..27b2fd82df3 100644 --- a/.betterer.results +++ b/.betterer.results @@ -256,6 +256,17 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "3"], [0, 0, 0, "Unexpected any. Specify a different type.", "4"] ], + "packages/grafana-data/src/transformations/transformers/nulls/nullInsertThreshold.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"], + [0, 0, 0, "Unexpected any. Specify a different type.", "3"] + ], + "packages/grafana-data/src/transformations/transformers/nulls/nullToUndefThreshold.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Do not use any type assertions.", "2"] + ], "packages/grafana-data/src/transformations/transformers/reduce.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] @@ -802,34 +813,6 @@ exports[`better eslint`] = { "packages/grafana-ui/src/components/Graph/utils.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "packages/grafana-ui/src/components/GraphNG/GraphNG.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"], - [0, 0, 0, "Do not use any type assertions.", "5"], - [0, 0, 0, "Do not use any type assertions.", "6"], - [0, 0, 0, "Do not use any type assertions.", "7"], - [0, 0, 0, "Unexpected any. Specify a different type.", "8"], - [0, 0, 0, "Do not use any type assertions.", "9"], - [0, 0, 0, "Do not use any type assertions.", "10"], - [0, 0, 0, "Do not use any type assertions.", "11"] - ], - "packages/grafana-ui/src/components/GraphNG/hooks.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], - "packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"] - ], - "packages/grafana-ui/src/components/GraphNG/nullToUndefThreshold.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Do not use any type assertions.", "2"] - ], "packages/grafana-ui/src/components/InfoBox/InfoBox.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], @@ -981,11 +964,6 @@ exports[`better eslint`] = { "packages/grafana-ui/src/components/Tags/Tag.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], - "packages/grafana-ui/src/components/TimeSeries/utils.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"] - ], "packages/grafana-ui/src/components/ValuePicker/ValuePicker.tsx:5381": [ [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"], [0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "1"] @@ -1032,6 +1010,39 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] ], + "packages/grafana-ui/src/graveyard/GraphNG/GraphNG.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"], + [0, 0, 0, "Unexpected any. Specify a different type.", "3"], + [0, 0, 0, "Unexpected any. Specify a different type.", "4"], + [0, 0, 0, "Do not use any type assertions.", "5"], + [0, 0, 0, "Do not use any type assertions.", "6"], + [0, 0, 0, "Do not use any type assertions.", "7"], + [0, 0, 0, "Unexpected any. Specify a different type.", "8"], + [0, 0, 0, "Do not use any type assertions.", "9"], + [0, 0, 0, "Do not use any type assertions.", "10"], + [0, 0, 0, "Do not use any type assertions.", "11"] + ], + "packages/grafana-ui/src/graveyard/GraphNG/hooks.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], + "packages/grafana-ui/src/graveyard/GraphNG/nullInsertThreshold.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"], + [0, 0, 0, "Unexpected any. Specify a different type.", "3"] + ], + "packages/grafana-ui/src/graveyard/GraphNG/nullToUndefThreshold.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Do not use any type assertions.", "2"] + ], + "packages/grafana-ui/src/graveyard/TimeSeries/utils.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"] + ], "packages/grafana-ui/src/options/builder/axis.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], @@ -1177,6 +1188,23 @@ exports[`better eslint`] = { "public/app/core/components/ForgottenPassword/ForgottenPassword.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] ], + "public/app/core/components/GraphNG/GraphNG.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"], + [0, 0, 0, "Unexpected any. Specify a different type.", "3"], + [0, 0, 0, "Unexpected any. Specify a different type.", "4"], + [0, 0, 0, "Do not use any type assertions.", "5"], + [0, 0, 0, "Do not use any type assertions.", "6"], + [0, 0, 0, "Do not use any type assertions.", "7"], + [0, 0, 0, "Unexpected any. Specify a different type.", "8"], + [0, 0, 0, "Do not use any type assertions.", "9"], + [0, 0, 0, "Do not use any type assertions.", "10"], + [0, 0, 0, "Do not use any type assertions.", "11"] + ], + "public/app/core/components/GraphNG/hooks.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "public/app/core/components/Layers/LayerDragDropList.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], @@ -1411,6 +1439,11 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "1"], [0, 0, 0, "Styles should be written using objects.", "2"] ], + "public/app/core/components/TimeSeries/utils.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"] + ], "public/app/core/components/TraceToLogs/TagMappingInput.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"], diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 81b5d3e891b..fbbf20a345b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -312,9 +312,7 @@ /packages/grafana-ui/src/components/Table/ @grafana/grafana-bi-squad /packages/grafana-ui/src/components/Gauge/ @grafana/dataviz-squad /packages/grafana-ui/src/components/BarGauge/ @grafana/dataviz-squad -/packages/grafana-ui/src/components/GraphNG/ @grafana/dataviz-squad /packages/grafana-ui/src/components/Graph/ @grafana/dataviz-squad -/packages/grafana-ui/src/components/TimeSeries/ @grafana/dataviz-squad /packages/grafana-ui/src/components/uPlot/ @grafana/dataviz-squad /packages/grafana-ui/src/components/DataLinks/ @grafana/dataviz-squad /packages/grafana-ui/src/components/ValuePicker/ @grafana/dataviz-squad @@ -322,6 +320,8 @@ /packages/grafana-ui/src/components/VizLegend/ @grafana/dataviz-squad /packages/grafana-ui/src/components/VizRepeater/ @grafana/dataviz-squad /packages/grafana-ui/src/components/VizTooltip/ @grafana/dataviz-squad +/packages/grafana-ui/src/graveyard/GraphNG/ @grafana/dataviz-squad +/packages/grafana-ui/src/graveyard/TimeSeries/ @grafana/dataviz-squad /packages/grafana-ui/src/utils/storybook/ @grafana/plugins-platform-frontend /packages/grafana-data/src/transformations/ @grafana/grafana-bi-squad /packages/grafana-data/src/**/*logs* @grafana/observability-logs @@ -366,6 +366,8 @@ cypress.config.js @grafana/grafana-frontend-platform /public/app/core/components/TimePicker/ @grafana/grafana-frontend-platform /public/app/core/components/Layers/ @grafana/dataviz-squad /public/app/core/components/TraceToLogs @grafana/observability-traces-and-profiling +/public/app/core/components/GraphNG/ @grafana/dataviz-squad +/public/app/core/components/TimeSeries/ @grafana/dataviz-squad /public/app/features/all.ts @grafana/grafana-frontend-platform /public/app/features/admin/ @grafana/grafana-authnz-team /public/app/features/auth-config/ @grafana/grafana-authnz-team diff --git a/packages/grafana-data/src/transformations/index.ts b/packages/grafana-data/src/transformations/index.ts index 1fd3229f897..4cdd4ea2f8b 100644 --- a/packages/grafana-data/src/transformations/index.ts +++ b/packages/grafana-data/src/transformations/index.ts @@ -20,3 +20,6 @@ export type { RenameByRegexTransformerOptions } from './transformers/renameByReg export { joinDataFrames as outerJoinDataFrames, isLikelyAscendingVector } from './transformers/joinDataFrames'; export * from './transformers/histogram'; export { ensureTimeField } from './transformers/convertFieldType'; + +// Required for Sparklines util to work in @grafana/data, but ideally kept internal +export { applyNullInsertThreshold } from './transformers/nulls/nullInsertThreshold'; diff --git a/packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.test.ts b/packages/grafana-data/src/transformations/transformers/nulls/nullInsertThreshold.test.ts similarity index 100% rename from packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.test.ts rename to packages/grafana-data/src/transformations/transformers/nulls/nullInsertThreshold.test.ts diff --git a/packages/grafana-data/src/transformations/transformers/nulls/nullInsertThreshold.ts b/packages/grafana-data/src/transformations/transformers/nulls/nullInsertThreshold.ts new file mode 100644 index 00000000000..94d8dd2e3c0 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/nulls/nullInsertThreshold.ts @@ -0,0 +1,186 @@ +import { DataFrame, FieldType } from '../../../types'; + +type InsertMode = (prev: number, next: number, threshold: number) => number; + +const INSERT_MODES = { + threshold: (prev: number, next: number, threshold: number) => prev + threshold, + midpoint: (prev: number, next: number, threshold: number) => (prev + next) / 2, + // previous time + 1ms to prevent StateTimeline from forward-interpolating prior state + plusone: (prev: number, next: number, threshold: number) => prev + 1, +}; + +interface NullInsertOptions { + frame: DataFrame; + refFieldName?: string | null; + refFieldPseudoMax?: number; + refFieldPseudoMin?: number; + insertMode?: InsertMode; +} + +function getRefField(frame: DataFrame, refFieldName?: string | null) { + return frame.fields.find((field) => { + // note: getFieldDisplayName() would require full DF[] + return refFieldName != null ? field.name === refFieldName : field.type === FieldType.time; + }); +} + +/** @internal */ +export function applyNullInsertThreshold(opts: NullInsertOptions): DataFrame { + if (opts.frame.length === 0) { + return opts.frame; + } + + let thorough = true; + let { frame, refFieldName, refFieldPseudoMax, refFieldPseudoMin, insertMode } = opts; + + if (!insertMode) { + insertMode = INSERT_MODES.threshold; + } + + const refField = getRefField(frame, refFieldName); + + if (refField == null) { + return frame; + } + + refField.state = { + ...refField.state, + nullThresholdApplied: true, + }; + + const thresholds = frame.fields.map((field) => field.config.custom?.insertNulls || refField.config.interval || null); + + const uniqueThresholds = new Set(thresholds); + + uniqueThresholds.delete(null as any); + + if (uniqueThresholds.size === 0) { + return frame; + } + + if (uniqueThresholds.size === 1) { + const threshold = uniqueThresholds.values().next().value; + + if (threshold <= 0) { + return frame; + } + + const refValues = refField.values; + + const frameValues = frame.fields.map((field) => field.values); + + const filledFieldValues = nullInsertThreshold( + refValues, + frameValues, + threshold, + refFieldPseudoMin, + refFieldPseudoMax, + insertMode, + thorough + ); + + if (filledFieldValues === frameValues) { + return frame; + } + + return { + ...frame, + length: filledFieldValues[0].length, + fields: frame.fields.map((field, i) => ({ + ...field, + values: filledFieldValues[i], + })), + }; + } + + // TODO: unique threshold-per-field (via overrides) is unimplemented + // should be done by processing each (refField + thresholdA-field1 + thresholdA-field2...) + // as a separate nullInsertThreshold() dataset, then re-join into single dataset via join() + return frame; +} + +function nullInsertThreshold( + refValues: number[], + frameValues: any[][], + threshold: number, + refFieldPseudoMin: number | null = null, + // will insert a trailing null when refFieldPseudoMax > last datapoint + threshold + refFieldPseudoMax: number | null = null, + getInsertValue: InsertMode, + // will insert the value at every missing interval + thorough: boolean +) { + const len = refValues.length; + const refValuesNew: number[] = []; + + // Continuously subtract the threshold from the first data point, filling in insert values accordingly + if (refFieldPseudoMin != null && refFieldPseudoMin < refValues[0]) { + let preFillCount = Math.ceil((refValues[0] - refFieldPseudoMin) / threshold); + // this will be 0 or 1 threshold increment left of visible range + let prevSlot = refValues[0] - preFillCount * threshold; + + while (prevSlot < refValues[0]) { + // (prevSlot - threshold) is used to simulate the previous 'real' data point, as getInsertValue expects + refValuesNew.push(getInsertValue(prevSlot - threshold, prevSlot, threshold)); + prevSlot += threshold; + } + } + + // Insert initial value + refValuesNew.push(refValues[0]); + + let prevValue: number = refValues[0]; + + // Fill nulls when a value is greater than the threshold value + for (let i = 1; i < len; i++) { + const curValue = refValues[i]; + + while (curValue - prevValue > threshold) { + refValuesNew.push(getInsertValue(prevValue, curValue, threshold)); + + prevValue += threshold; + + if (!thorough) { + break; + } + } + + refValuesNew.push(curValue); + + prevValue = curValue; + } + + // At the end of the sequence + if (refFieldPseudoMax != null && refFieldPseudoMax > prevValue) { + while (prevValue + threshold < refFieldPseudoMax) { + refValuesNew.push(getInsertValue(prevValue, refFieldPseudoMax, threshold)); + prevValue += threshold; + } + } + + const filledLen = refValuesNew.length; + + if (filledLen === len) { + return frameValues; + } + + const filledFieldValues: any[][] = []; + + for (let fieldValues of frameValues) { + let filledValues; + + if (fieldValues !== refValues) { + filledValues = Array(filledLen); + + for (let i = 0, j = 0; i < filledLen; i++) { + filledValues[i] = refValues[j] === refValuesNew[i] ? fieldValues[j++] : null; + } + } else { + filledValues = refValuesNew; + } + + filledFieldValues.push(filledValues); + } + + return filledFieldValues; +} diff --git a/packages/grafana-ui/src/components/GraphNG/nullToUndefThreshold.ts b/packages/grafana-data/src/transformations/transformers/nulls/nullToUndefThreshold.ts similarity index 100% rename from packages/grafana-ui/src/components/GraphNG/nullToUndefThreshold.ts rename to packages/grafana-data/src/transformations/transformers/nulls/nullToUndefThreshold.ts diff --git a/packages/grafana-ui/src/components/GraphNG/nullToValue.test.ts b/packages/grafana-data/src/transformations/transformers/nulls/nullToValue.test.ts similarity index 100% rename from packages/grafana-ui/src/components/GraphNG/nullToValue.test.ts rename to packages/grafana-data/src/transformations/transformers/nulls/nullToValue.test.ts diff --git a/packages/grafana-data/src/transformations/transformers/nulls/nullToValue.ts b/packages/grafana-data/src/transformations/transformers/nulls/nullToValue.ts new file mode 100644 index 00000000000..4c3ba5a81e6 --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/nulls/nullToValue.ts @@ -0,0 +1,27 @@ +import { DataFrame } from '../../../types'; + +export function nullToValue(frame: DataFrame) { + return { + ...frame, + fields: frame.fields.map((field) => { + const noValue = +field.config?.noValue!; + + if (!Number.isNaN(noValue)) { + const transformedVals = field.values.slice(); + + for (let i = 0; i < transformedVals.length; i++) { + if (transformedVals[i] === null) { + transformedVals[i] = noValue; + } + } + + return { + ...field, + values: transformedVals, + }; + } else { + return field; + } + }), + }; +} diff --git a/packages/grafana-ui/src/components/Sparkline/utils.ts b/packages/grafana-ui/src/components/Sparkline/utils.ts index c643d375a37..aa77fd64dc4 100644 --- a/packages/grafana-ui/src/components/Sparkline/utils.ts +++ b/packages/grafana-ui/src/components/Sparkline/utils.ts @@ -5,11 +5,10 @@ import { FieldType, isLikelyAscendingVector, sortDataFrame, + applyNullInsertThreshold, } from '@grafana/data'; import { GraphFieldConfig } from '@grafana/schema'; -import { applyNullInsertThreshold } from '../GraphNG/nullInsertThreshold'; - /** @internal * Given a sparkline config returns a DataFrame ready to be turned into Plot data set **/ diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 0f5dd38ea02..78cd9b5321e 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -289,13 +289,14 @@ export { UPlotChart } from './uPlot/Plot'; export { PlotLegend } from './uPlot/PlotLegend'; export * from './uPlot/geometries'; export * from './uPlot/plugins'; -export { type PlotTooltipInterpolator, type PlotSelection } from './uPlot/types'; +export { type PlotTooltipInterpolator, type PlotSelection, FIXED_UNIT } from './uPlot/types'; export { type UPlotConfigPrepFn } from './uPlot/config/UPlotConfigBuilder'; -export { GraphNG, type GraphNGProps, FIXED_UNIT } from './GraphNG/GraphNG'; -export { TimeSeries } from './TimeSeries/TimeSeries'; -export { useGraphNGContext } from './GraphNG/hooks'; -export { preparePlotFrame, buildScaleKey } from './GraphNG/utils'; -export { type GraphNGLegendEvent } from './GraphNG/types'; export * from './PanelChrome/types'; export { Label as BrowserLabel } from './BrowserLabel/Label'; export { PanelContainer } from './PanelContainer/PanelContainer'; + +export { GraphNG, type GraphNGProps } from '../graveyard/GraphNG/GraphNG'; +export { TimeSeries } from '../graveyard/TimeSeries/TimeSeries'; +export { useGraphNGContext } from '../graveyard/GraphNG/hooks'; +export { preparePlotFrame, buildScaleKey } from '../graveyard/GraphNG/utils'; +export { type GraphNGLegendEvent } from '../graveyard/GraphNG/types'; diff --git a/packages/grafana-ui/src/components/uPlot/internal.ts b/packages/grafana-ui/src/components/uPlot/internal.ts new file mode 100644 index 00000000000..e7a9c6a14d1 --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/internal.ts @@ -0,0 +1,40 @@ +import { FieldConfig, FieldType } from '@grafana/data'; +import { AxisPlacement, GraphFieldConfig, ScaleDistribution, ScaleDistributionConfig } from '@grafana/schema'; + +import { FIXED_UNIT } from './types'; + +/** + * @internal -- not a public API + */ +export function buildScaleKey(config: FieldConfig, fieldType: FieldType) { + const defaultPart = 'na'; + + const scaleRange = `${config.min !== undefined ? config.min : defaultPart}-${ + config.max !== undefined ? config.max : defaultPart + }`; + + const scaleSoftRange = `${config.custom?.axisSoftMin !== undefined ? config.custom.axisSoftMin : defaultPart}-${ + config.custom?.axisSoftMax !== undefined ? config.custom.axisSoftMax : defaultPart + }`; + + const scalePlacement = `${ + config.custom?.axisPlacement !== undefined ? config.custom?.axisPlacement : AxisPlacement.Auto + }`; + + const scaleUnit = config.unit ?? FIXED_UNIT; + + const scaleDistribution = config.custom?.scaleDistribution + ? getScaleDistributionPart(config.custom.scaleDistribution) + : ScaleDistribution.Linear; + + const scaleLabel = Boolean(config.custom?.axisLabel) ? config.custom!.axisLabel : defaultPart; + + return `${scaleUnit}/${scaleRange}/${scaleSoftRange}/${scalePlacement}/${scaleDistribution}/${scaleLabel}/${fieldType}`; +} + +function getScaleDistributionPart(config: ScaleDistributionConfig) { + if (config.type === ScaleDistribution.Log) { + return `${config.type}${config.log}`; + } + return config.type; +} diff --git a/packages/grafana-ui/src/components/uPlot/types.ts b/packages/grafana-ui/src/components/uPlot/types.ts index 959787608d7..86f3c99c277 100644 --- a/packages/grafana-ui/src/components/uPlot/types.ts +++ b/packages/grafana-ui/src/components/uPlot/types.ts @@ -3,6 +3,11 @@ import uPlot, { Options, AlignedData } from 'uplot'; import { UPlotConfigBuilder } from './config/UPlotConfigBuilder'; +/** + * @internal -- not a public API + */ +export const FIXED_UNIT = '__fixed'; + export type PlotConfig = Pick< Options, 'mode' | 'series' | 'scales' | 'axes' | 'cursor' | 'bands' | 'hooks' | 'select' | 'tzDate' | 'padding' diff --git a/packages/grafana-ui/src/components/uPlot/utils.test.ts b/packages/grafana-ui/src/components/uPlot/utils.test.ts index 605c8649e63..fc6224ac596 100644 --- a/packages/grafana-ui/src/components/uPlot/utils.test.ts +++ b/packages/grafana-ui/src/components/uPlot/utils.test.ts @@ -1,7 +1,7 @@ import { FieldMatcherID, fieldMatchers, FieldType, MutableDataFrame } from '@grafana/data'; import { BarAlignment, GraphDrawStyle, GraphTransform, LineInterpolation, StackingMode } from '@grafana/schema'; -import { preparePlotFrame } from '../GraphNG/utils'; +import { preparePlotFrame } from '../../../../../public/app/core/components/GraphNG/utils'; import { getStackingGroups, preparePlotData2, timeFormatToTemplate } from './utils'; diff --git a/packages/grafana-ui/src/components/uPlot/utils.ts b/packages/grafana-ui/src/components/uPlot/utils.ts index b9312097b66..d5d1dd7356b 100644 --- a/packages/grafana-ui/src/components/uPlot/utils.ts +++ b/packages/grafana-ui/src/components/uPlot/utils.ts @@ -5,7 +5,8 @@ import { BarAlignment, GraphDrawStyle, GraphTransform, LineInterpolation, Stacki import { attachDebugger } from '../../utils'; import { createLogger } from '../../utils/logger'; -import { buildScaleKey } from '../GraphNG/utils'; + +import { buildScaleKey } from './internal'; const ALLOWED_FORMAT_STRINGS_REGEX = /\b(YYYY|YY|MMMM|MMM|MM|M|DD|D|WWWW|WWW|HH|H|h|AA|aa|a|mm|m|ss|s|fff)\b/g; diff --git a/packages/grafana-ui/src/graveyard/GraphNG/GraphNG.tsx b/packages/grafana-ui/src/graveyard/GraphNG/GraphNG.tsx new file mode 100644 index 00000000000..52dfadb27a6 --- /dev/null +++ b/packages/grafana-ui/src/graveyard/GraphNG/GraphNG.tsx @@ -0,0 +1,275 @@ +import React, { Component } from 'react'; +import { Subscription } from 'rxjs'; +import { throttleTime } from 'rxjs/operators'; +import uPlot, { AlignedData } from 'uplot'; + +import { + DataFrame, + DataHoverClearEvent, + DataHoverEvent, + Field, + FieldMatcherID, + fieldMatchers, + FieldType, + LegacyGraphHoverEvent, + TimeRange, + TimeZone, +} from '@grafana/data'; +import { VizLegendOptions } from '@grafana/schema'; + +import { PanelContext, PanelContextRoot } from '../../components/PanelChrome/PanelContext'; +import { VizLayout } from '../../components/VizLayout/VizLayout'; +import { UPlotChart } from '../../components/uPlot/Plot'; +import { AxisProps } from '../../components/uPlot/config/UPlotAxisBuilder'; +import { Renderers, UPlotConfigBuilder } from '../../components/uPlot/config/UPlotConfigBuilder'; +import { ScaleProps } from '../../components/uPlot/config/UPlotScaleBuilder'; +import { findMidPointYPosition, pluginLog } from '../../components/uPlot/utils'; +import { Themeable2 } from '../../types'; + +import { GraphNGLegendEvent, XYFieldMatchers } from './types'; +import { preparePlotFrame as defaultPreparePlotFrame } from './utils'; + +/** + * @deprecated + * @internal -- not a public API + */ +export type PropDiffFn = (prev: T, next: T) => boolean; + +/** @deprecated */ +export interface GraphNGProps extends Themeable2 { + frames: DataFrame[]; + structureRev?: number; // a number that will change when the frames[] structure changes + width: number; + height: number; + timeRange: TimeRange; + timeZone: TimeZone[] | TimeZone; + legend: VizLegendOptions; + fields?: XYFieldMatchers; // default will assume timeseries data + renderers?: Renderers; + tweakScale?: (opts: ScaleProps, forField: Field) => ScaleProps; + tweakAxis?: (opts: AxisProps, forField: Field) => AxisProps; + onLegendClick?: (event: GraphNGLegendEvent) => void; + children?: (builder: UPlotConfigBuilder, alignedFrame: DataFrame) => React.ReactNode; + prepConfig: (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => UPlotConfigBuilder; + propsToDiff?: Array; + preparePlotFrame?: (frames: DataFrame[], dimFields: XYFieldMatchers) => DataFrame | null; + renderLegend: (config: UPlotConfigBuilder) => React.ReactElement | null; + + /** + * needed for propsToDiff to re-init the plot & config + * this is a generic approach to plot re-init, without having to specify which panel-level options + * should cause invalidation. we can drop this in favor of something like panelOptionsRev that gets passed in + * similar to structureRev. then we can drop propsToDiff entirely. + */ + options?: Record; +} + +function sameProps(prevProps: any, nextProps: any, propsToDiff: Array = []) { + for (const propName of propsToDiff) { + if (typeof propName === 'function') { + if (!propName(prevProps, nextProps)) { + return false; + } + } else if (nextProps[propName] !== prevProps[propName]) { + return false; + } + } + + return true; +} + +/** + * @internal -- not a public API + * @deprecated + */ +export interface GraphNGState { + alignedFrame: DataFrame; + alignedData?: AlignedData; + config?: UPlotConfigBuilder; +} + +/** + * "Time as X" core component, expects ascending x + * @deprecated + */ +export class GraphNG extends Component { + static contextType = PanelContextRoot; + panelContext: PanelContext = {} as PanelContext; + private plotInstance: React.RefObject; + + private subscription = new Subscription(); + + constructor(props: GraphNGProps) { + super(props); + let state = this.prepState(props); + state.alignedData = state.config!.prepData!([state.alignedFrame]) as AlignedData; + this.state = state; + this.plotInstance = React.createRef(); + } + + getTimeRange = () => this.props.timeRange; + + prepState(props: GraphNGProps, withConfig = true) { + let state: GraphNGState = null as any; + + const { frames, fields, preparePlotFrame } = props; + + const preparePlotFrameFn = preparePlotFrame || defaultPreparePlotFrame; + + const alignedFrame = preparePlotFrameFn( + frames, + fields || { + x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), + y: fieldMatchers.get(FieldMatcherID.byTypes).get(new Set([FieldType.number, FieldType.enum])), + }, + props.timeRange + ); + pluginLog('GraphNG', false, 'data aligned', alignedFrame); + + if (alignedFrame) { + let config = this.state?.config; + + if (withConfig) { + config = props.prepConfig(alignedFrame, this.props.frames, this.getTimeRange); + pluginLog('GraphNG', false, 'config prepared', config); + } + + state = { + alignedFrame, + config, + }; + + pluginLog('GraphNG', false, 'data prepared', state.alignedData); + } + + return state; + } + + handleCursorUpdate(evt: DataHoverEvent | LegacyGraphHoverEvent) { + const time = evt.payload?.point?.time; + const u = this.plotInstance.current; + if (u && time) { + // Try finding left position on time axis + const left = u.valToPos(time, 'x'); + let top; + if (left) { + // find midpoint between points at current idx + top = findMidPointYPosition(u, u.posToIdx(left)); + } + + if (!top || !left) { + return; + } + + u.setCursor({ + left, + top, + }); + } + } + + componentDidMount() { + this.panelContext = this.context as PanelContext; + const { eventBus } = this.panelContext; + + this.subscription.add( + eventBus + .getStream(DataHoverEvent) + .pipe(throttleTime(50)) + .subscribe({ + next: (evt) => { + if (eventBus === evt.origin) { + return; + } + this.handleCursorUpdate(evt); + }, + }) + ); + + // Legacy events (from flot graph) + this.subscription.add( + eventBus + .getStream(LegacyGraphHoverEvent) + .pipe(throttleTime(50)) + .subscribe({ + next: (evt) => this.handleCursorUpdate(evt), + }) + ); + + this.subscription.add( + eventBus + .getStream(DataHoverClearEvent) + .pipe(throttleTime(50)) + .subscribe({ + next: () => { + const u = this.plotInstance?.current; + + // @ts-ignore + if (u && !u.cursor._lock) { + u.setCursor({ + left: -10, + top: -10, + }); + } + }, + }) + ); + } + + componentDidUpdate(prevProps: GraphNGProps) { + const { frames, structureRev, timeZone, propsToDiff } = this.props; + + const propsChanged = !sameProps(prevProps, this.props, propsToDiff); + + if (frames !== prevProps.frames || propsChanged || timeZone !== prevProps.timeZone) { + let newState = this.prepState(this.props, false); + + if (newState) { + const shouldReconfig = + this.state.config === undefined || + timeZone !== prevProps.timeZone || + structureRev !== prevProps.structureRev || + !structureRev || + propsChanged; + + if (shouldReconfig) { + newState.config = this.props.prepConfig(newState.alignedFrame, this.props.frames, this.getTimeRange); + pluginLog('GraphNG', false, 'config recreated', newState.config); + } + + newState.alignedData = newState.config!.prepData!([newState.alignedFrame]) as AlignedData; + + this.setState(newState); + } + } + } + + componentWillUnmount() { + this.subscription.unsubscribe(); + } + + render() { + const { width, height, children, renderLegend } = this.props; + const { config, alignedFrame, alignedData } = this.state; + + if (!config) { + return null; + } + + return ( + + {(vizWidth: number, vizHeight: number) => ( + ((this.plotInstance as React.MutableRefObject).current = u)} + > + {children ? children(config, alignedFrame) : null} + + )} + + ); + } +} diff --git a/packages/grafana-ui/src/components/GraphNG/__snapshots__/utils.test.ts.snap b/packages/grafana-ui/src/graveyard/GraphNG/__snapshots__/utils.test.ts.snap similarity index 100% rename from packages/grafana-ui/src/components/GraphNG/__snapshots__/utils.test.ts.snap rename to packages/grafana-ui/src/graveyard/GraphNG/__snapshots__/utils.test.ts.snap diff --git a/packages/grafana-ui/src/graveyard/GraphNG/hooks.ts b/packages/grafana-ui/src/graveyard/GraphNG/hooks.ts new file mode 100644 index 00000000000..e4f1d46550a --- /dev/null +++ b/packages/grafana-ui/src/graveyard/GraphNG/hooks.ts @@ -0,0 +1,41 @@ +import React, { useCallback, useContext } from 'react'; + +import { DataFrame, DataFrameFieldIndex, Field } from '@grafana/data'; + +import { XYFieldMatchers } from './types'; + +/** @deprecated */ +interface GraphNGContextType { + mapSeriesIndexToDataFrameFieldIndex: (index: number) => DataFrameFieldIndex; + dimFields: XYFieldMatchers; + data: DataFrame; +} + +/** @deprecated */ +export const GraphNGContext = React.createContext({} as GraphNGContextType); + +/** @deprecated */ +export const useGraphNGContext = () => { + const { data, dimFields, mapSeriesIndexToDataFrameFieldIndex } = useContext(GraphNGContext); + + const getXAxisField = useCallback(() => { + const xFieldMatcher = dimFields.x; + let xField: Field | null = null; + + for (let j = 0; j < data.fields.length; j++) { + if (xFieldMatcher(data.fields[j], data, [data])) { + xField = data.fields[j]; + break; + } + } + + return xField; + }, [data, dimFields]); + + return { + dimFields, + mapSeriesIndexToDataFrameFieldIndex, + getXAxisField, + alignedData: data, + }; +}; diff --git a/packages/grafana-ui/src/graveyard/GraphNG/nullInsertThreshold.test.ts b/packages/grafana-ui/src/graveyard/GraphNG/nullInsertThreshold.test.ts new file mode 100644 index 00000000000..7a2ceb15e8d --- /dev/null +++ b/packages/grafana-ui/src/graveyard/GraphNG/nullInsertThreshold.test.ts @@ -0,0 +1,334 @@ +import { FieldType, createDataFrame } from '@grafana/data'; + +import { applyNullInsertThreshold } from './nullInsertThreshold'; + +function randInt(min: number, max: number) { + return Math.floor(Math.random() * (max - min + 1) + min); +} + +function genFrame() { + let fieldCount = 10; + let valueCount = 3000; + let step = 1000; + let skipProb = 0.5; + let skipSteps = [1, 5]; // min, max + + let allValues = Array(fieldCount); + + allValues[0] = Array(valueCount); + + for (let i = 0, curStep = Date.now(); i < valueCount; i++) { + curStep = allValues[0][i] = curStep + step * (Math.random() < skipProb ? randInt(skipSteps[0], skipSteps[1]) : 1); + } + + for (let fi = 1; fi < fieldCount; fi++) { + let values = Array(valueCount); + + for (let i = 0; i < valueCount; i++) { + values[i] = Math.random() * 100; + } + + allValues[fi] = values; + } + + return { + length: valueCount, + fields: allValues.map((values, i) => { + return { + name: 'A-' + i, + type: i === 0 ? FieldType.time : FieldType.number, + config: { + interval: i === 0 ? step : null, + }, + values: values, + }; + }), + }; +} + +describe('nullInsertThreshold Transformer', () => { + test('should insert nulls at +threshold between adjacent > threshold: 1', () => { + const df = createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [1, 3, 10] }, + { name: 'One', type: FieldType.number, config: { custom: { insertNulls: 1 } }, values: [4, 6, 8] }, + { name: 'Two', type: FieldType.string, config: { custom: { insertNulls: 1 } }, values: ['a', 'b', 'c'] }, + ], + }); + + const result = applyNullInsertThreshold({ frame: df }); + + expect(result.fields[0].values).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + expect(result.fields[1].values).toEqual([4, null, 6, null, null, null, null, null, null, 8]); + expect(result.fields[2].values).toEqual(['a', null, 'b', null, null, null, null, null, null, 'c']); + }); + + test('should insert nulls at +threshold between adjacent > threshold: 2', () => { + const df = createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [5, 7, 11] }, + { name: 'One', type: FieldType.number, config: { custom: { insertNulls: 2 } }, values: [4, 6, 8] }, + { name: 'Two', type: FieldType.string, config: { custom: { insertNulls: 2 } }, values: ['a', 'b', 'c'] }, + ], + }); + + const result = applyNullInsertThreshold({ frame: df }); + + expect(result.fields[0].values).toEqual([5, 7, 9, 11]); + expect(result.fields[1].values).toEqual([4, 6, null, 8]); + expect(result.fields[2].values).toEqual(['a', 'b', null, 'c']); + }); + + test('should insert nulls at +interval between adjacent > interval: 1', () => { + const df = createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, config: { interval: 1 }, values: [1, 3, 10] }, + { name: 'One', type: FieldType.number, values: [4, 6, 8] }, + { name: 'Two', type: FieldType.string, values: ['a', 'b', 'c'] }, + ], + }); + + const result = applyNullInsertThreshold({ frame: df }); + + expect(result.fields[0].values).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + expect(result.fields[1].values).toEqual([4, null, 6, null, null, null, null, null, null, 8]); + expect(result.fields[2].values).toEqual(['a', null, 'b', null, null, null, null, null, null, 'c']); + }); + + test('should insert leading null at beginning +interval when timeRange.from.valueOf() exceeds threshold', () => { + const df = createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, config: { interval: 1 }, values: [4, 6, 13] }, + { name: 'One', type: FieldType.number, values: [4, 6, 8] }, + { name: 'Two', type: FieldType.string, values: ['a', 'b', 'c'] }, + ], + }); + + const result = applyNullInsertThreshold({ + frame: df, + refFieldName: null, + refFieldPseudoMin: -0.5, + refFieldPseudoMax: 13, + }); + + expect(result.fields[0].values).toEqual([-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]); + expect(result.fields[1].values).toEqual([ + null, + null, + null, + null, + null, + 4, + null, + 6, + null, + null, + null, + null, + null, + null, + 8, + ]); + expect(result.fields[2].values).toEqual([ + null, + null, + null, + null, + null, + 'a', + null, + 'b', + null, + null, + null, + null, + null, + null, + 'c', + ]); + }); + + // this tests that intervals at 24hr but starting not at 12am UTC are not always snapped to 12am UTC + test('should insert leading null at beginning +interval when timeRange.from.valueOf() exceeds threshold 11PM UTC', () => { + const df = createDataFrame({ + refId: 'A', + fields: [ + { + name: 'Time', + type: FieldType.time, + config: { interval: 86400000 }, + values: [1679439600000, 1679526000000, 1679612400000, 1679698800000, 1679785200000], + }, + { name: 'One', type: FieldType.number, values: [0, 1, 2, 3, 4] }, + ], + }); + + const result = applyNullInsertThreshold({ + frame: df, + refFieldName: null, + refFieldPseudoMin: 1679320395828, + refFieldPseudoMax: 1679815217157, + }); + + expect(result.fields[0].values).toEqual([ + 1679266800000, 1679353200000, 1679439600000, 1679526000000, 1679612400000, 1679698800000, 1679785200000, + ]); + expect(result.fields[1].values).toEqual([null, null, 0, 1, 2, 3, 4]); + }); + + test('should insert trailing null at end +interval when timeRange.to.valueOf() exceeds threshold', () => { + const df = createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, config: { interval: 1 }, values: [1, 3, 10] }, + { name: 'One', type: FieldType.number, values: [4, 6, 8] }, + { name: 'Two', type: FieldType.string, values: ['a', 'b', 'c'] }, + ], + }); + + const result = applyNullInsertThreshold({ frame: df, refFieldName: null, refFieldPseudoMax: 13 }); + + expect(result.fields[0].values).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); + expect(result.fields[1].values).toEqual([4, null, 6, null, null, null, null, null, null, 8, null, null]); + expect(result.fields[2].values).toEqual(['a', null, 'b', null, null, null, null, null, null, 'c', null, null]); + + // should work for frames with 1 datapoint + const df2 = createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, config: { interval: 1 }, values: [1] }, + { name: 'One', type: FieldType.number, values: [1] }, + { name: 'Two', type: FieldType.string, values: ['a'] }, + ], + }); + + // Max is 2.5 as opposed to the above 13 otherwise + // we get 12 nulls instead of the additional 1 + const result2 = applyNullInsertThreshold({ frame: df2, refFieldName: null, refFieldPseudoMax: 2.5 }); + + expect(result2.fields[0].values).toEqual([1, 2]); + expect(result2.fields[1].values).toEqual([1, null]); + expect(result2.fields[2].values).toEqual(['a', null]); + }); + + test('should not insert trailing null at end +interval when timeRange.to.valueOf() equals threshold', () => { + const df = createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, config: { interval: 1 }, values: [1] }, + { name: 'One', type: FieldType.number, values: [1] }, + { name: 'Two', type: FieldType.string, values: ['a'] }, + ], + }); + + const result = applyNullInsertThreshold({ frame: df, refFieldName: null, refFieldPseudoMax: 2 }); + + expect(result.fields[0].values).toEqual([1]); + expect(result.fields[1].values).toEqual([1]); + expect(result.fields[2].values).toEqual(['a']); + }); + + // TODO: make this work + test.skip('should insert nulls at +threshold (when defined) instead of +interval', () => { + const df = createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, config: { interval: 2 }, values: [5, 7, 11] }, + { name: 'One', type: FieldType.number, config: { custom: { insertNulls: 1 } }, values: [4, 6, 8] }, + { name: 'Two', type: FieldType.string, config: { custom: { insertNulls: 1 } }, values: ['a', 'b', 'c'] }, + ], + }); + + const result = applyNullInsertThreshold({ frame: df }); + + expect(result.fields[0].values).toEqual([5, 6, 7, 8, 11]); + expect(result.fields[1].values).toEqual([4, null, 6, null, 8]); + expect(result.fields[2].values).toEqual(['a', null, 'b', null, 'c']); + }); + + test('should noop on 0 datapoints', () => { + const df = createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, config: { interval: 1 }, values: [] }, + { name: 'Value', type: FieldType.number, values: [] }, + ], + }); + + const result = applyNullInsertThreshold({ frame: df }); + + expect(result).toBe(df); + }); + + test('should noop on invalid threshold', () => { + const df = createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [1, 2, 4] }, + { name: 'Value', type: FieldType.number, config: { custom: { insertNulls: -1 } }, values: [1, 1, 1] }, + ], + }); + + const result = applyNullInsertThreshold({ frame: df }); + + expect(result).toBe(df); + }); + + test('should noop on invalid interval', () => { + const df = createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, config: { interval: -1 }, values: [1, 2, 4] }, + { name: 'Value', type: FieldType.number, values: [1, 1, 1] }, + ], + }); + + const result = applyNullInsertThreshold({ frame: df }); + + expect(result).toBe(df); + }); + + test('should noop when no missing steps', () => { + const df = createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, config: { interval: 1 }, values: [1, 2, 3] }, + { name: 'Value', type: FieldType.number, values: [1, 1, 1] }, + ], + }); + + const result = applyNullInsertThreshold({ frame: df }); + + expect(result).toBe(df); + }); + + test('should noop when refFieldName not found', () => { + const df = createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, config: { interval: 1 }, values: [1, 2, 5] }, + { name: 'Value', type: FieldType.number, values: [1, 1, 1] }, + ], + }); + + const result = applyNullInsertThreshold({ frame: df, refFieldName: 'Time2' }); + + expect(result).toBe(df); + }); + + // Leave this test skipped - it should be run manually + test.skip('perf stress test should be <= 10ms', () => { + // 10 fields x 3,000 values with 50% skip (output = 10 fields x 6,000 values) + let bigFrameA = genFrame(); + + // eslint-disable-next-line no-console + console.time('insertValues-10x3k'); + applyNullInsertThreshold({ frame: bigFrameA }); + // eslint-disable-next-line no-console + console.timeEnd('insertValues-10x3k'); + }); +}); diff --git a/packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.ts b/packages/grafana-ui/src/graveyard/GraphNG/nullInsertThreshold.ts similarity index 99% rename from packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.ts rename to packages/grafana-ui/src/graveyard/GraphNG/nullInsertThreshold.ts index 352f2f0660b..9361a6bab8d 100644 --- a/packages/grafana-ui/src/components/GraphNG/nullInsertThreshold.ts +++ b/packages/grafana-ui/src/graveyard/GraphNG/nullInsertThreshold.ts @@ -19,6 +19,7 @@ interface NullInsertOptions { insertMode?: InsertMode; } +/** @deprecated */ export function applyNullInsertThreshold(opts: NullInsertOptions): DataFrame { if (opts.frame.length === 0) { return opts.frame; diff --git a/packages/grafana-ui/src/graveyard/GraphNG/nullToUndefThreshold.ts b/packages/grafana-ui/src/graveyard/GraphNG/nullToUndefThreshold.ts new file mode 100644 index 00000000000..bcc52e5e1b9 --- /dev/null +++ b/packages/grafana-ui/src/graveyard/GraphNG/nullToUndefThreshold.ts @@ -0,0 +1,33 @@ +/** + * mutates all nulls -> undefineds in the fieldValues array for value-less refValues ranges below maxThreshold + * refValues is typically a time array and maxThreshold is the allowable distance between in time + * @deprecated + */ +export function nullToUndefThreshold(refValues: number[], fieldValues: any[], maxThreshold: number): any[] { + let prevRef; + let nullIdx; + + for (let i = 0; i < fieldValues.length; i++) { + let fieldVal = fieldValues[i]; + + if (fieldVal == null) { + if (nullIdx == null && prevRef != null) { + nullIdx = i; + } + } else { + if (nullIdx != null) { + if (refValues[i] - (prevRef as number) < maxThreshold) { + while (nullIdx < i) { + fieldValues[nullIdx++] = undefined; + } + } + + nullIdx = null; + } + + prevRef = refValues[i]; + } + } + + return fieldValues; +} diff --git a/packages/grafana-ui/src/graveyard/GraphNG/nullToValue.test.ts b/packages/grafana-ui/src/graveyard/GraphNG/nullToValue.test.ts new file mode 100644 index 00000000000..e14f64e1710 --- /dev/null +++ b/packages/grafana-ui/src/graveyard/GraphNG/nullToValue.test.ts @@ -0,0 +1,94 @@ +import { FieldType, createDataFrame } from '@grafana/data'; + +import { applyNullInsertThreshold } from './nullInsertThreshold'; +import { nullToValue } from './nullToValue'; + +describe('nullToValue Transformer', () => { + test('should change all nulls to configured zero value', () => { + const df = createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [1, 3, 10] }, + { + name: 'One', + type: FieldType.number, + config: { custom: { insertNulls: 1 }, noValue: '0' }, + values: [4, 6, 8], + }, + { + name: 'Two', + type: FieldType.string, + config: { custom: { insertNulls: 1 }, noValue: '0' }, + values: ['a', 'b', 'c'], + }, + ], + }); + + const result = nullToValue(applyNullInsertThreshold({ frame: df })); + + expect(result.fields[0].values).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + expect(result.fields[1].values).toEqual([4, 0, 6, 0, 0, 0, 0, 0, 0, 8]); + expect(result.fields[2].values).toEqual(['a', 0, 'b', 0, 0, 0, 0, 0, 0, 'c']); + }); + + test('should change all nulls to configured positive value', () => { + const df = createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, values: [5, 7, 11] }, + { + name: 'One', + type: FieldType.number, + config: { custom: { insertNulls: 2 }, noValue: '1' }, + values: [4, 6, 8], + }, + { + name: 'Two', + type: FieldType.string, + config: { custom: { insertNulls: 2 }, noValue: '1' }, + values: ['a', 'b', 'c'], + }, + ], + }); + + const result = nullToValue(applyNullInsertThreshold({ frame: df })); + + expect(result.fields[0].values).toEqual([5, 7, 9, 11]); + expect(result.fields[1].values).toEqual([4, 6, 1, 8]); + expect(result.fields[2].values).toEqual(['a', 'b', 1, 'c']); + }); + + test('should change all nulls to configured negative value', () => { + const df = createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, config: { interval: 1 }, values: [1, 3, 10] }, + { name: 'One', type: FieldType.number, config: { noValue: '-1' }, values: [4, 6, 8] }, + { name: 'Two', type: FieldType.string, config: { noValue: '-1' }, values: ['a', 'b', 'c'] }, + ], + }); + + const result = nullToValue(applyNullInsertThreshold({ frame: df })); + + expect(result.fields[0].values).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + expect(result.fields[1].values).toEqual([4, -1, 6, -1, -1, -1, -1, -1, -1, 8]); + expect(result.fields[2].values).toEqual(['a', -1, 'b', -1, -1, -1, -1, -1, -1, 'c']); + }); + + test('should have no effect without nulls', () => { + const df = createDataFrame({ + refId: 'A', + fields: [ + { name: 'Time', type: FieldType.time, config: { interval: 1 }, values: [1, 2, 3] }, + { name: 'One', type: FieldType.number, values: [4, 6, 8] }, + { name: 'Two', type: FieldType.string, values: ['a', 'b', 'c'] }, + ], + }); + + const result = nullToValue(applyNullInsertThreshold({ frame: df, refFieldName: null })); + + expect(result.fields[0].values).toEqual([1, 2, 3]); + expect(result.fields[1].values).toEqual([4, 6, 8]); + expect(result.fields[2].values).toEqual(['a', 'b', 'c']); + }); +}); diff --git a/packages/grafana-ui/src/components/GraphNG/nullToValue.ts b/packages/grafana-ui/src/graveyard/GraphNG/nullToValue.ts similarity index 96% rename from packages/grafana-ui/src/components/GraphNG/nullToValue.ts rename to packages/grafana-ui/src/graveyard/GraphNG/nullToValue.ts index 62aea31b837..7ad8e3705aa 100644 --- a/packages/grafana-ui/src/components/GraphNG/nullToValue.ts +++ b/packages/grafana-ui/src/graveyard/GraphNG/nullToValue.ts @@ -1,5 +1,6 @@ import { DataFrame } from '@grafana/data'; +/** @deprecated */ export function nullToValue(frame: DataFrame) { return { ...frame, diff --git a/packages/grafana-ui/src/graveyard/GraphNG/types.ts b/packages/grafana-ui/src/graveyard/GraphNG/types.ts new file mode 100644 index 00000000000..91dd83fb5a8 --- /dev/null +++ b/packages/grafana-ui/src/graveyard/GraphNG/types.ts @@ -0,0 +1,18 @@ +import { DataFrameFieldIndex, FieldMatcher } from '@grafana/data'; + +import { SeriesVisibilityChangeMode } from '../../components/PanelChrome'; + +/** + * Event being triggered when the user interact with the Graph legend. + * @deprecated + */ +export interface GraphNGLegendEvent { + fieldIndex: DataFrameFieldIndex; + mode: SeriesVisibilityChangeMode; +} + +/** @deprecated */ +export interface XYFieldMatchers { + x: FieldMatcher; // first match + y: FieldMatcher; +} diff --git a/packages/grafana-ui/src/components/GraphNG/utils.test.ts b/packages/grafana-ui/src/graveyard/GraphNG/utils.test.ts similarity index 100% rename from packages/grafana-ui/src/components/GraphNG/utils.test.ts rename to packages/grafana-ui/src/graveyard/GraphNG/utils.test.ts diff --git a/packages/grafana-ui/src/components/GraphNG/utils.ts b/packages/grafana-ui/src/graveyard/GraphNG/utils.ts similarity index 98% rename from packages/grafana-ui/src/components/GraphNG/utils.ts rename to packages/grafana-ui/src/graveyard/GraphNG/utils.ts index 9922b85d9df..742031446ac 100644 --- a/packages/grafana-ui/src/components/GraphNG/utils.ts +++ b/packages/grafana-ui/src/graveyard/GraphNG/utils.ts @@ -7,7 +7,8 @@ import { ScaleDistributionConfig, } from '@grafana/schema'; -import { FIXED_UNIT } from './GraphNG'; +import { FIXED_UNIT } from '../../components/uPlot/types'; + import { applyNullInsertThreshold } from './nullInsertThreshold'; import { nullToUndefThreshold } from './nullToUndefThreshold'; import { XYFieldMatchers } from './types'; diff --git a/packages/grafana-ui/src/graveyard/README.md b/packages/grafana-ui/src/graveyard/README.md new file mode 100644 index 00000000000..2715ecec38a --- /dev/null +++ b/packages/grafana-ui/src/graveyard/README.md @@ -0,0 +1 @@ +Items in this folder are all deprecated and will be removed in the future diff --git a/packages/grafana-ui/src/components/TimeSeries/TimeSeries.tsx b/packages/grafana-ui/src/graveyard/TimeSeries/TimeSeries.tsx similarity index 87% rename from packages/grafana-ui/src/components/TimeSeries/TimeSeries.tsx rename to packages/grafana-ui/src/graveyard/TimeSeries/TimeSeries.tsx index 019671e2b6a..9a748236d77 100644 --- a/packages/grafana-ui/src/components/TimeSeries/TimeSeries.tsx +++ b/packages/grafana-ui/src/graveyard/TimeSeries/TimeSeries.tsx @@ -2,11 +2,11 @@ import React, { Component } from 'react'; import { DataFrame, TimeRange } from '@grafana/data'; +import { PanelContextRoot } from '../../components/PanelChrome/PanelContext'; +import { hasVisibleLegendSeries, PlotLegend } from '../../components/uPlot/PlotLegend'; +import { UPlotConfigBuilder } from '../../components/uPlot/config/UPlotConfigBuilder'; import { withTheme2 } from '../../themes/ThemeContext'; import { GraphNG, GraphNGProps, PropDiffFn } from '../GraphNG/GraphNG'; -import { PanelContextRoot } from '../PanelChrome/PanelContext'; -import { hasVisibleLegendSeries, PlotLegend } from '../uPlot/PlotLegend'; -import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder'; import { preparePlotConfigBuilder } from './utils'; diff --git a/packages/grafana-ui/src/components/TimeSeries/utils.test.ts b/packages/grafana-ui/src/graveyard/TimeSeries/utils.test.ts similarity index 100% rename from packages/grafana-ui/src/components/TimeSeries/utils.test.ts rename to packages/grafana-ui/src/graveyard/TimeSeries/utils.test.ts diff --git a/packages/grafana-ui/src/components/TimeSeries/utils.ts b/packages/grafana-ui/src/graveyard/TimeSeries/utils.ts similarity index 98% rename from packages/grafana-ui/src/components/TimeSeries/utils.ts rename to packages/grafana-ui/src/graveyard/TimeSeries/utils.ts index 9d2d373b33d..ad029097ba5 100644 --- a/packages/grafana-ui/src/components/TimeSeries/utils.ts +++ b/packages/grafana-ui/src/graveyard/TimeSeries/utils.ts @@ -61,10 +61,10 @@ for (let i = 0; i < BIN_INCRS.length; i++) { BIN_INCRS[i] = 2 ** i; } +import { UPlotConfigBuilder, UPlotConfigPrepFn } from '../../components/uPlot/config/UPlotConfigBuilder'; +import { getScaleGradientFn } from '../../components/uPlot/config/gradientFills'; +import { getStackingGroups, preparePlotData2 } from '../../components/uPlot/utils'; import { buildScaleKey } from '../GraphNG/utils'; -import { UPlotConfigBuilder, UPlotConfigPrepFn } from '../uPlot/config/UPlotConfigBuilder'; -import { getScaleGradientFn } from '../uPlot/config/gradientFills'; -import { getStackingGroups, preparePlotData2 } from '../uPlot/utils'; const defaultFormatter = (v: any, decimals: DecimalCount = 1) => (v == null ? '-' : v.toFixed(decimals)); diff --git a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx b/public/app/core/components/GraphNG/GraphNG.tsx similarity index 92% rename from packages/grafana-ui/src/components/GraphNG/GraphNG.tsx rename to public/app/core/components/GraphNG/GraphNG.tsx index d75ab59252b..9ebfa499c81 100644 --- a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx +++ b/public/app/core/components/GraphNG/GraphNG.tsx @@ -16,24 +16,16 @@ import { TimeZone, } from '@grafana/data'; import { VizLegendOptions } from '@grafana/schema'; - -import { Themeable2 } from '../../types'; -import { PanelContext, PanelContextRoot } from '../PanelChrome/PanelContext'; -import { VizLayout } from '../VizLayout/VizLayout'; -import { UPlotChart } from '../uPlot/Plot'; -import { AxisProps } from '../uPlot/config/UPlotAxisBuilder'; -import { Renderers, UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder'; -import { ScaleProps } from '../uPlot/config/UPlotScaleBuilder'; -import { findMidPointYPosition, pluginLog } from '../uPlot/utils'; +import { Themeable2, PanelContext, PanelContextRoot, VizLayout } from '@grafana/ui'; +import { UPlotChart } from '@grafana/ui/src/components/uPlot/Plot'; +import { AxisProps } from '@grafana/ui/src/components/uPlot/config/UPlotAxisBuilder'; +import { Renderers, UPlotConfigBuilder } from '@grafana/ui/src/components/uPlot/config/UPlotConfigBuilder'; +import { ScaleProps } from '@grafana/ui/src/components/uPlot/config/UPlotScaleBuilder'; +import { findMidPointYPosition, pluginLog } from '@grafana/ui/src/components/uPlot/utils'; import { GraphNGLegendEvent, XYFieldMatchers } from './types'; import { preparePlotFrame as defaultPreparePlotFrame } from './utils'; -/** - * @internal -- not a public API - */ -export const FIXED_UNIT = '__fixed'; - /** * @internal -- not a public API */ diff --git a/public/app/core/components/GraphNG/__snapshots__/utils.test.ts.snap b/public/app/core/components/GraphNG/__snapshots__/utils.test.ts.snap new file mode 100644 index 00000000000..09f70e81c44 --- /dev/null +++ b/public/app/core/components/GraphNG/__snapshots__/utils.test.ts.snap @@ -0,0 +1,245 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GraphNG utils preparePlotConfigBuilder 1`] = ` +{ + "axes": [ + { + "filter": undefined, + "font": "12px "Inter", "Helvetica", "Arial", sans-serif", + "gap": 5, + "grid": { + "show": true, + "stroke": "rgba(240, 250, 255, 0.09)", + "width": 1, + }, + "incrs": undefined, + "labelGap": 0, + "rotate": undefined, + "scale": "x", + "show": true, + "side": 2, + "size": [Function], + "space": [Function], + "splits": undefined, + "stroke": "rgb(204, 204, 220)", + "ticks": { + "show": true, + "size": 4, + "stroke": "rgba(240, 250, 255, 0.09)", + "width": 1, + }, + "timeZone": "utc", + "values": [Function], + }, + { + "filter": undefined, + "font": "12px "Inter", "Helvetica", "Arial", sans-serif", + "gap": 5, + "grid": { + "show": true, + "stroke": "rgba(240, 250, 255, 0.09)", + "width": 1, + }, + "incrs": undefined, + "labelGap": 0, + "rotate": undefined, + "scale": "__fixed/na-na/na-na/auto/linear/na/number", + "show": true, + "side": 3, + "size": [Function], + "space": [Function], + "splits": undefined, + "stroke": "rgb(204, 204, 220)", + "ticks": { + "show": false, + "size": 4, + "stroke": "rgb(204, 204, 220)", + "width": 1, + }, + "timeZone": undefined, + "values": [Function], + }, + ], + "cursor": { + "dataIdx": [Function], + "drag": { + "setScale": false, + }, + "focus": { + "prox": 30, + }, + "points": { + "fill": [Function], + "size": [Function], + "stroke": [Function], + "width": [Function], + }, + "sync": { + "filters": { + "pub": [Function], + }, + "key": "__global_", + "scales": [ + "x", + "__fixed/na-na/na-na/auto/linear/na/number", + ], + }, + }, + "focus": { + "alpha": 1, + }, + "hooks": {}, + "legend": { + "show": false, + }, + "mode": 1, + "ms": 1, + "padding": [ + [Function], + [Function], + [Function], + [Function], + ], + "scales": { + "__fixed/na-na/na-na/auto/linear/na/number": { + "asinh": undefined, + "auto": true, + "dir": 1, + "distr": 1, + "log": undefined, + "ori": 1, + "range": [Function], + "time": undefined, + }, + "x": { + "auto": false, + "dir": 1, + "ori": 0, + "range": [Function], + "time": true, + }, + }, + "select": undefined, + "series": [ + { + "value": [Function], + }, + { + "dash": [ + 1, + 2, + ], + "facets": undefined, + "fill": [Function], + "paths": [Function], + "points": { + "fill": "#ff0000", + "filter": [Function], + "show": true, + "size": undefined, + "stroke": "#ff0000", + }, + "pxAlign": undefined, + "scale": "__fixed/na-na/na-na/auto/linear/na/number", + "show": true, + "spanGaps": false, + "stroke": "#ff0000", + "value": [Function], + "width": 2, + }, + { + "dash": [ + 1, + 2, + ], + "facets": undefined, + "fill": [Function], + "paths": [Function], + "points": { + "fill": "#ff0000", + "filter": [Function], + "show": true, + "size": undefined, + "stroke": "#ff0000", + }, + "pxAlign": undefined, + "scale": "__fixed/na-na/na-na/auto/linear/na/number", + "show": true, + "spanGaps": false, + "stroke": "#ff0000", + "value": [Function], + "width": 2, + }, + { + "dash": [ + 1, + 2, + ], + "facets": undefined, + "fill": [Function], + "paths": [Function], + "points": { + "fill": "#ff0000", + "filter": [Function], + "show": true, + "size": undefined, + "stroke": "#ff0000", + }, + "pxAlign": undefined, + "scale": "__fixed/na-na/na-na/auto/linear/na/number", + "show": true, + "spanGaps": false, + "stroke": "#ff0000", + "value": [Function], + "width": 2, + }, + { + "dash": [ + 1, + 2, + ], + "facets": undefined, + "fill": [Function], + "paths": [Function], + "points": { + "fill": "#ff0000", + "filter": [Function], + "show": true, + "size": undefined, + "stroke": "#ff0000", + }, + "pxAlign": undefined, + "scale": "__fixed/na-na/na-na/auto/linear/na/number", + "show": true, + "spanGaps": false, + "stroke": "#ff0000", + "value": [Function], + "width": 2, + }, + { + "dash": [ + 1, + 2, + ], + "facets": undefined, + "fill": [Function], + "paths": [Function], + "points": { + "fill": "#ff0000", + "filter": [Function], + "show": true, + "size": undefined, + "stroke": "#ff0000", + }, + "pxAlign": undefined, + "scale": "__fixed/na-na/na-na/auto/linear/na/number", + "show": true, + "spanGaps": false, + "stroke": "#ff0000", + "value": [Function], + "width": 2, + }, + ], + "tzDate": [Function], +} +`; diff --git a/packages/grafana-ui/src/components/GraphNG/hooks.ts b/public/app/core/components/GraphNG/hooks.ts similarity index 100% rename from packages/grafana-ui/src/components/GraphNG/hooks.ts rename to public/app/core/components/GraphNG/hooks.ts diff --git a/packages/grafana-ui/src/components/GraphNG/types.ts b/public/app/core/components/GraphNG/types.ts similarity index 85% rename from packages/grafana-ui/src/components/GraphNG/types.ts rename to public/app/core/components/GraphNG/types.ts index 534cd9edf4d..e642fbd8aac 100644 --- a/packages/grafana-ui/src/components/GraphNG/types.ts +++ b/public/app/core/components/GraphNG/types.ts @@ -1,6 +1,5 @@ import { DataFrameFieldIndex, FieldMatcher } from '@grafana/data'; - -import { SeriesVisibilityChangeMode } from '../PanelChrome'; +import { SeriesVisibilityChangeMode } from '@grafana/ui'; /** * Event being triggered when the user interact with the Graph legend. diff --git a/public/app/core/components/GraphNG/utils.test.ts b/public/app/core/components/GraphNG/utils.test.ts new file mode 100644 index 00000000000..9675cd7ca56 --- /dev/null +++ b/public/app/core/components/GraphNG/utils.test.ts @@ -0,0 +1,522 @@ +import { + createTheme, + DashboardCursorSync, + DataFrame, + DefaultTimeZone, + EventBusSrv, + FieldColorModeId, + FieldConfig, + FieldMatcherID, + fieldMatchers, + FieldType, + getDefaultTimeRange, + MutableDataFrame, +} from '@grafana/data'; +import { + BarAlignment, + GraphDrawStyle, + GraphFieldConfig, + GraphGradientMode, + LineInterpolation, + VisibilityMode, + StackingMode, +} from '@grafana/schema'; + +import { preparePlotConfigBuilder } from '../TimeSeries/utils'; + +import { preparePlotFrame } from './utils'; + +function mockDataFrame() { + const df1 = new MutableDataFrame({ + refId: 'A', + fields: [{ name: 'ts', type: FieldType.time, values: [1, 2, 3] }], + }); + const df2 = new MutableDataFrame({ + refId: 'B', + fields: [{ name: 'ts', type: FieldType.time, values: [1, 2, 4] }], + }); + + const f1Config: FieldConfig = { + displayName: 'Metric 1', + color: { + mode: FieldColorModeId.Fixed, + }, + decimals: 2, + custom: { + drawStyle: GraphDrawStyle.Line, + gradientMode: GraphGradientMode.Opacity, + lineColor: '#ff0000', + lineWidth: 2, + lineInterpolation: LineInterpolation.Linear, + lineStyle: { + fill: 'dash', + dash: [1, 2], + }, + spanNulls: false, + fillColor: '#ff0000', + fillOpacity: 0.1, + showPoints: VisibilityMode.Always, + stacking: { + group: 'A', + mode: StackingMode.Normal, + }, + }, + }; + + const f2Config: FieldConfig = { + displayName: 'Metric 2', + color: { + mode: FieldColorModeId.Fixed, + }, + decimals: 2, + custom: { + drawStyle: GraphDrawStyle.Bars, + gradientMode: GraphGradientMode.Hue, + lineColor: '#ff0000', + lineWidth: 2, + lineInterpolation: LineInterpolation.Linear, + lineStyle: { + fill: 'dash', + dash: [1, 2], + }, + barAlignment: BarAlignment.Before, + fillColor: '#ff0000', + fillOpacity: 0.1, + showPoints: VisibilityMode.Always, + stacking: { + group: 'A', + mode: StackingMode.Normal, + }, + }, + }; + + const f3Config: FieldConfig = { + displayName: 'Metric 3', + decimals: 2, + color: { + mode: FieldColorModeId.Fixed, + }, + custom: { + drawStyle: GraphDrawStyle.Line, + gradientMode: GraphGradientMode.Opacity, + lineColor: '#ff0000', + lineWidth: 2, + lineInterpolation: LineInterpolation.Linear, + lineStyle: { + fill: 'dash', + dash: [1, 2], + }, + spanNulls: false, + fillColor: '#ff0000', + fillOpacity: 0.1, + showPoints: VisibilityMode.Always, + stacking: { + group: 'B', + mode: StackingMode.Normal, + }, + }, + }; + const f4Config: FieldConfig = { + displayName: 'Metric 4', + decimals: 2, + color: { + mode: FieldColorModeId.Fixed, + }, + custom: { + drawStyle: GraphDrawStyle.Bars, + gradientMode: GraphGradientMode.Hue, + lineColor: '#ff0000', + lineWidth: 2, + lineInterpolation: LineInterpolation.Linear, + lineStyle: { + fill: 'dash', + dash: [1, 2], + }, + barAlignment: BarAlignment.Before, + fillColor: '#ff0000', + fillOpacity: 0.1, + showPoints: VisibilityMode.Always, + stacking: { + group: 'B', + mode: StackingMode.Normal, + }, + }, + }; + const f5Config: FieldConfig = { + displayName: 'Metric 4', + decimals: 2, + color: { + mode: FieldColorModeId.Fixed, + }, + custom: { + drawStyle: GraphDrawStyle.Bars, + gradientMode: GraphGradientMode.Hue, + lineColor: '#ff0000', + lineWidth: 2, + lineInterpolation: LineInterpolation.Linear, + lineStyle: { + fill: 'dash', + dash: [1, 2], + }, + barAlignment: BarAlignment.Before, + fillColor: '#ff0000', + fillOpacity: 0.1, + showPoints: VisibilityMode.Always, + stacking: { + group: 'B', + mode: StackingMode.None, + }, + }, + }; + + df1.addField({ + name: 'metric1', + type: FieldType.number, + config: f1Config, + }); + + df2.addField({ + name: 'metric2', + type: FieldType.number, + config: f2Config, + }); + df2.addField({ + name: 'metric3', + type: FieldType.number, + config: f3Config, + }); + df2.addField({ + name: 'metric4', + type: FieldType.number, + config: f4Config, + }); + df2.addField({ + name: 'metric5', + type: FieldType.number, + config: f5Config, + }); + + return preparePlotFrame([df1, df2], { + x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), + y: fieldMatchers.get(FieldMatcherID.numeric).get({}), + }); +} + +jest.mock('@grafana/data', () => ({ + ...jest.requireActual('@grafana/data'), + DefaultTimeZone: 'utc', +})); + +describe('GraphNG utils', () => { + test('preparePlotConfigBuilder', () => { + const frame = mockDataFrame(); + const result = preparePlotConfigBuilder({ + frame: frame!, + theme: createTheme(), + timeZones: [DefaultTimeZone], + getTimeRange: getDefaultTimeRange, + eventBus: new EventBusSrv(), + sync: () => DashboardCursorSync.Tooltip, + allFrames: [frame!], + }).getConfig(); + expect(result).toMatchSnapshot(); + }); + + test('preparePlotFrame appends min bar spaced nulls when > 1 bar series', () => { + const df1: DataFrame = { + name: 'A', + length: 5, + fields: [ + { + name: 'time', + type: FieldType.time, + config: {}, + values: [1, 2, 4, 6, 100], // should find smallest delta === 1 from here + }, + { + name: 'value', + type: FieldType.number, + config: { + custom: { + drawStyle: GraphDrawStyle.Bars, + }, + }, + values: [1, 1, 1, 1, 1], + }, + ], + }; + + const df2: DataFrame = { + name: 'B', + length: 5, + fields: [ + { + name: 'time', + type: FieldType.time, + config: {}, + values: [30, 40, 50, 90, 100], // should be appended with two smallest-delta increments + }, + { + name: 'value', + type: FieldType.number, + config: { + custom: { + drawStyle: GraphDrawStyle.Bars, + }, + }, + values: [2, 2, 2, 2, 2], // bar series should be appended with nulls + }, + { + name: 'value', + type: FieldType.number, + config: { + custom: { + drawStyle: GraphDrawStyle.Line, + }, + }, + values: [3, 3, 3, 3, 3], // line series should be appended with undefineds + }, + ], + }; + + const df3: DataFrame = { + name: 'C', + length: 2, + fields: [ + { + name: 'time', + type: FieldType.time, + config: {}, + values: [1, 1.1], // should not trip up on smaller deltas of non-bars + }, + { + name: 'value', + type: FieldType.number, + config: { + custom: { + drawStyle: GraphDrawStyle.Line, + }, + }, + values: [4, 4], + }, + { + name: 'value', + type: FieldType.number, + config: { + custom: { + drawStyle: GraphDrawStyle.Bars, + hideFrom: { + viz: true, // should ignore hidden bar series + }, + }, + }, + values: [4, 4], + }, + ], + }; + + let aligndFrame = preparePlotFrame([df1, df2, df3], { + x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), + y: fieldMatchers.get(FieldMatcherID.numeric).get({}), + }); + + expect(aligndFrame).toMatchInlineSnapshot(` + { + "fields": [ + { + "config": {}, + "name": "time", + "state": { + "nullThresholdApplied": true, + "origin": { + "fieldIndex": 0, + "frameIndex": 0, + }, + }, + "type": "time", + "values": [ + 1, + 1.1, + 2, + 4, + 6, + 30, + 40, + 50, + 90, + 100, + 101, + 102, + ], + }, + { + "config": { + "custom": { + "drawStyle": "bars", + "spanNulls": -1, + }, + }, + "labels": { + "name": "A", + }, + "name": "value", + "state": { + "origin": { + "fieldIndex": 1, + "frameIndex": 0, + }, + }, + "type": "number", + "values": [ + 1, + undefined, + 1, + 1, + 1, + undefined, + undefined, + undefined, + undefined, + 1, + null, + null, + ], + }, + { + "config": { + "custom": { + "drawStyle": "bars", + "spanNulls": -1, + }, + }, + "labels": { + "name": "B", + }, + "name": "value", + "state": { + "origin": { + "fieldIndex": 1, + "frameIndex": 1, + }, + }, + "type": "number", + "values": [ + undefined, + undefined, + undefined, + undefined, + undefined, + 2, + 2, + 2, + 2, + 2, + null, + null, + ], + }, + { + "config": { + "custom": { + "drawStyle": "line", + }, + }, + "labels": { + "name": "B", + }, + "name": "value", + "state": { + "origin": { + "fieldIndex": 2, + "frameIndex": 1, + }, + }, + "type": "number", + "values": [ + undefined, + undefined, + undefined, + undefined, + undefined, + 3, + 3, + 3, + 3, + 3, + undefined, + undefined, + ], + }, + { + "config": { + "custom": { + "drawStyle": "line", + }, + }, + "labels": { + "name": "C", + }, + "name": "value", + "state": { + "origin": { + "fieldIndex": 1, + "frameIndex": 2, + }, + }, + "type": "number", + "values": [ + 4, + 4, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ], + }, + { + "config": { + "custom": { + "drawStyle": "bars", + "hideFrom": { + "viz": true, + }, + }, + }, + "labels": { + "name": "C", + }, + "name": "value", + "state": { + "origin": { + "fieldIndex": 2, + "frameIndex": 2, + }, + }, + "type": "number", + "values": [ + 4, + 4, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ], + }, + ], + "length": 12, + } + `); + }); +}); diff --git a/public/app/core/components/GraphNG/utils.ts b/public/app/core/components/GraphNG/utils.ts new file mode 100644 index 00000000000..030ea9722db --- /dev/null +++ b/public/app/core/components/GraphNG/utils.ts @@ -0,0 +1,140 @@ +import { DataFrame, Field, FieldType, outerJoinDataFrames, TimeRange } from '@grafana/data'; +import { applyNullInsertThreshold } from '@grafana/data/src/transformations/transformers/nulls/nullInsertThreshold'; +import { nullToUndefThreshold } from '@grafana/data/src/transformations/transformers/nulls/nullToUndefThreshold'; +import { GraphDrawStyle } from '@grafana/schema'; + +import { XYFieldMatchers } from './types'; + +function isVisibleBarField(f: Field) { + return ( + f.type === FieldType.number && f.config.custom?.drawStyle === GraphDrawStyle.Bars && !f.config.custom?.hideFrom?.viz + ); +} + +export function getRefField(frame: DataFrame, refFieldName?: string | null) { + return frame.fields.find((field) => { + // note: getFieldDisplayName() would require full DF[] + return refFieldName != null ? field.name === refFieldName : field.type === FieldType.time; + }); +} + +// will mutate the DataFrame's fields' values +function applySpanNullsThresholds(frame: DataFrame, refFieldName?: string | null) { + const refField = getRefField(frame, refFieldName); + + let refValues = refField?.values; + + for (let i = 0; i < frame.fields.length; i++) { + let field = frame.fields[i]; + + if (field === refField || isVisibleBarField(field)) { + continue; + } + + let spanNulls = field.config.custom?.spanNulls; + + if (typeof spanNulls === 'number') { + if (spanNulls !== -1 && refValues) { + field.values = nullToUndefThreshold(refValues, field.values, spanNulls); + } + } + } + + return frame; +} + +export function preparePlotFrame(frames: DataFrame[], dimFields: XYFieldMatchers, timeRange?: TimeRange | null) { + let xField: Field; + loop: for (let frame of frames) { + for (let field of frame.fields) { + if (dimFields.x(field, frame, frames)) { + xField = field; + break loop; + } + } + } + + // apply null insertions at interval + frames = frames.map((frame) => { + if (!xField?.state?.nullThresholdApplied) { + return applyNullInsertThreshold({ + frame, + refFieldName: xField.name, + refFieldPseudoMin: timeRange?.from.valueOf(), + refFieldPseudoMax: timeRange?.to.valueOf(), + }); + } else { + return frame; + } + }); + + let numBarSeries = 0; + + frames.forEach((frame) => { + frame.fields.forEach((f) => { + if (isVisibleBarField(f)) { + // prevent minesweeper-expansion of nulls (gaps) when joining bars + // since bar width is determined from the minimum distance between non-undefined values + // (this strategy will still retain any original pre-join nulls, though) + f.config.custom = { + ...f.config.custom, + spanNulls: -1, + }; + + numBarSeries++; + } + }); + }); + + // to make bar widths of all series uniform (equal to narrowest bar series), find smallest distance between x points + let minXDelta = Infinity; + + if (numBarSeries > 1) { + frames.forEach((frame) => { + if (!frame.fields.some(isVisibleBarField)) { + return; + } + + const xVals = xField.values; + + for (let i = 0; i < xVals.length; i++) { + if (i > 0) { + minXDelta = Math.min(minXDelta, xVals[i] - xVals[i - 1]); + } + } + }); + } + + let alignedFrame = outerJoinDataFrames({ + frames, + joinBy: dimFields.x, + keep: dimFields.y, + keepOriginIndices: true, + }); + + if (alignedFrame) { + alignedFrame = applySpanNullsThresholds(alignedFrame, xField!.name); + + // append 2 null vals at minXDelta to bar series + if (minXDelta !== Infinity) { + alignedFrame.fields.forEach((f, fi) => { + let vals = f.values; + + if (fi === 0) { + let lastVal = vals[vals.length - 1]; + vals.push(lastVal + minXDelta, lastVal + 2 * minXDelta); + } else if (isVisibleBarField(f)) { + vals.push(null, null); + } else { + vals.push(undefined, undefined); + } + }); + + alignedFrame.length += 2; + } + + return alignedFrame; + } + + return null; +} diff --git a/public/app/core/components/TimeSeries/TimeSeries.tsx b/public/app/core/components/TimeSeries/TimeSeries.tsx new file mode 100644 index 00000000000..4441bca98f3 --- /dev/null +++ b/public/app/core/components/TimeSeries/TimeSeries.tsx @@ -0,0 +1,63 @@ +import React, { Component } from 'react'; + +import { DataFrame, TimeRange } from '@grafana/data'; +import { PanelContextRoot } from '@grafana/ui/src/components/PanelChrome/PanelContext'; +import { hasVisibleLegendSeries, PlotLegend } from '@grafana/ui/src/components/uPlot/PlotLegend'; +import { UPlotConfigBuilder } from '@grafana/ui/src/components/uPlot/config/UPlotConfigBuilder'; +import { withTheme2 } from '@grafana/ui/src/themes/ThemeContext'; + +import { GraphNG, GraphNGProps, PropDiffFn } from '../GraphNG/GraphNG'; + +import { preparePlotConfigBuilder } from './utils'; + +const propsToDiff: Array = ['legend', 'options', 'theme']; + +type TimeSeriesProps = Omit; + +export class UnthemedTimeSeries extends Component { + static contextType = PanelContextRoot; + declare context: React.ContextType; + + prepConfig = (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => { + const { eventBus, eventsScope, sync } = this.context; + const { theme, timeZone, renderers, tweakAxis, tweakScale } = this.props; + + return preparePlotConfigBuilder({ + frame: alignedFrame, + theme, + timeZones: Array.isArray(timeZone) ? timeZone : [timeZone], + getTimeRange, + eventBus, + sync, + allFrames, + renderers, + tweakScale, + tweakAxis, + eventsScope, + }); + }; + + renderLegend = (config: UPlotConfigBuilder) => { + const { legend, frames } = this.props; + + if (!config || (legend && !legend.showLegend) || !hasVisibleLegendSeries(config, frames)) { + return null; + } + + return ; + }; + + render() { + return ( + + ); + } +} + +export const TimeSeries = withTheme2(UnthemedTimeSeries); +TimeSeries.displayName = 'TimeSeries'; diff --git a/public/app/core/components/TimeSeries/utils.test.ts b/public/app/core/components/TimeSeries/utils.test.ts new file mode 100644 index 00000000000..583358c7c4c --- /dev/null +++ b/public/app/core/components/TimeSeries/utils.test.ts @@ -0,0 +1,274 @@ +import { EventBus, FieldType } from '@grafana/data'; +import { getTheme } from '@grafana/ui'; + +import { preparePlotConfigBuilder } from './utils'; + +describe('when fill below to option is used', () => { + let eventBus: EventBus; + // eslint-disable-next-line + let renderers: any[]; + // eslint-disable-next-line + let tests: any; + + beforeEach(() => { + eventBus = { + publish: jest.fn(), + getStream: jest.fn(), + subscribe: jest.fn(), + removeAllListeners: jest.fn(), + newScopedBus: jest.fn(), + }; + renderers = []; + + tests = [ + { + alignedFrame: { + fields: [ + { + config: {}, + values: [1667406900000, 1667407170000, 1667407185000], + name: 'Time', + state: { multipleFrames: true, displayName: 'Time', origin: { fieldIndex: 0, frameIndex: 0 } }, + type: FieldType.time, + }, + { + config: { displayNameFromDS: 'Test1', custom: { fillBelowTo: 'Test2' }, min: 0, max: 100 }, + values: [1, 2, 3], + name: 'Value', + state: { multipleFrames: true, displayName: 'Test1', origin: { fieldIndex: 1, frameIndex: 0 } }, + type: FieldType.number, + }, + { + config: { displayNameFromDS: 'Test2', min: 0, max: 100 }, + values: [4, 5, 6], + name: 'Value', + state: { multipleFrames: true, displayName: 'Test2', origin: { fieldIndex: 1, frameIndex: 1 } }, + type: FieldType.number, + }, + ], + length: 3, + }, + allFrames: [ + { + name: 'Test1', + refId: 'A', + fields: [ + { + config: {}, + values: [1667406900000, 1667407170000, 1667407185000], + name: 'Time', + state: { multipleFrames: true, displayName: 'Time', origin: { fieldIndex: 0, frameIndex: 0 } }, + type: FieldType.time, + }, + { + config: { displayNameFromDS: 'Test1', custom: { fillBelowTo: 'Test2' }, min: 0, max: 100 }, + values: [1, 2, 3], + name: 'Value', + state: { multipleFrames: true, displayName: 'Test1', origin: { fieldIndex: 1, frameIndex: 0 } }, + type: FieldType.number, + }, + ], + length: 2, + }, + { + name: 'Test2', + refId: 'B', + fields: [ + { + config: {}, + values: [1667406900000, 1667407170000, 1667407185000], + name: 'Time', + state: { multipleFrames: true, displayName: 'Time', origin: { fieldIndex: 0, frameIndex: 1 } }, + type: FieldType.time, + }, + { + config: { displayNameFromDS: 'Test2', min: 0, max: 100 }, + values: [1, 2, 3], + name: 'Value', + state: { multipleFrames: true, displayName: 'Test2', origin: { fieldIndex: 1, frameIndex: 1 } }, + type: FieldType.number, + }, + ], + length: 2, + }, + ], + expectedResult: 1, + }, + { + alignedFrame: { + fields: [ + { + config: {}, + values: [1667406900000, 1667407170000, 1667407185000], + name: 'time', + state: { multipleFrames: true, displayName: 'time', origin: { fieldIndex: 0, frameIndex: 0 } }, + type: FieldType.time, + }, + { + config: { custom: { fillBelowTo: 'below_value1' } }, + values: [1, 2, 3], + name: 'value1', + state: { multipleFrames: true, displayName: 'value1', origin: { fieldIndex: 1, frameIndex: 0 } }, + type: FieldType.number, + }, + { + config: { custom: { fillBelowTo: 'below_value2' } }, + values: [4, 5, 6], + name: 'value2', + state: { multipleFrames: true, displayName: 'value2', origin: { fieldIndex: 2, frameIndex: 0 } }, + type: FieldType.number, + }, + { + config: {}, + values: [4, 5, 6], + name: 'below_value1', + state: { multipleFrames: true, displayName: 'below_value1', origin: { fieldIndex: 1, frameIndex: 1 } }, + type: FieldType.number, + }, + { + config: {}, + values: [4, 5, 6], + name: 'below_value2', + state: { multipleFrames: true, displayName: 'below_value2', origin: { fieldIndex: 2, frameIndex: 1 } }, + type: FieldType.number, + }, + ], + length: 5, + }, + allFrames: [ + { + refId: 'A', + fields: [ + { + config: {}, + values: [1667406900000, 1667407170000, 1667407185000], + name: 'time', + state: { multipleFrames: true, displayName: 'time', origin: { fieldIndex: 0, frameIndex: 0 } }, + type: FieldType.time, + }, + { + config: { custom: { fillBelowTo: 'below_value1' } }, + values: [1, 2, 3], + name: 'value1', + state: { multipleFrames: true, displayName: 'value1', origin: { fieldIndex: 1, frameIndex: 0 } }, + type: FieldType.number, + }, + { + config: { custom: { fillBelowTo: 'below_value2' } }, + values: [4, 5, 6], + name: 'value2', + state: { multipleFrames: true, displayName: 'value2', origin: { fieldIndex: 2, frameIndex: 0 } }, + type: FieldType.number, + }, + ], + length: 3, + }, + { + refId: 'B', + fields: [ + { + config: {}, + values: [1667406900000, 1667407170000, 1667407185000], + name: 'time', + state: { multipleFrames: true, displayName: 'time', origin: { fieldIndex: 0, frameIndex: 1 } }, + type: FieldType.time, + }, + { + config: {}, + values: [4, 5, 6], + name: 'below_value1', + state: { multipleFrames: true, displayName: 'below_value1', origin: { fieldIndex: 1, frameIndex: 1 } }, + type: FieldType.number, + }, + { + config: {}, + values: [4, 5, 6], + name: 'below_value2', + state: { multipleFrames: true, displayName: 'below_value2', origin: { fieldIndex: 2, frameIndex: 1 } }, + type: FieldType.number, + }, + ], + length: 3, + }, + ], + expectedResult: 2, + }, + ]; + }); + + it('should verify if fill below to is set then builder bands are set', () => { + for (const test of tests) { + const builder = preparePlotConfigBuilder({ + frame: test.alignedFrame, + //@ts-ignore + theme: getTheme(), + timeZones: ['browser'], + getTimeRange: jest.fn(), + eventBus, + sync: jest.fn(), + allFrames: test.allFrames, + renderers, + }); + + //@ts-ignore + expect(builder.bands.length).toBe(test.expectedResult); + } + }); + + it('should verify if fill below to is not set then builder bands are empty', () => { + tests[0].alignedFrame.fields[1].config.custom.fillBelowTo = undefined; + tests[0].allFrames[0].fields[1].config.custom.fillBelowTo = undefined; + tests[1].alignedFrame.fields[1].config.custom.fillBelowTo = undefined; + tests[1].alignedFrame.fields[2].config.custom.fillBelowTo = undefined; + tests[1].allFrames[0].fields[1].config.custom.fillBelowTo = undefined; + tests[1].allFrames[0].fields[2].config.custom.fillBelowTo = undefined; + tests[0].expectedResult = 0; + tests[1].expectedResult = 0; + + for (const test of tests) { + const builder = preparePlotConfigBuilder({ + frame: test.alignedFrame, + //@ts-ignore + theme: getTheme(), + timeZones: ['browser'], + getTimeRange: jest.fn(), + eventBus, + sync: jest.fn(), + allFrames: test.allFrames, + renderers, + }); + + //@ts-ignore + expect(builder.bands.length).toBe(test.expectedResult); + } + }); + + it('should verify if fill below to is set and field name is overriden then builder bands are set', () => { + tests[0].alignedFrame.fields[2].config.displayName = 'newName'; + tests[0].alignedFrame.fields[2].state.displayName = 'newName'; + tests[0].allFrames[1].fields[1].config.displayName = 'newName'; + tests[0].allFrames[1].fields[1].state.displayName = 'newName'; + + tests[1].alignedFrame.fields[3].config.displayName = 'newName'; + tests[1].alignedFrame.fields[3].state.displayName = 'newName'; + tests[1].allFrames[1].fields[1].config.displayName = 'newName'; + tests[1].allFrames[1].fields[1].state.displayName = 'newName'; + + for (const test of tests) { + const builder = preparePlotConfigBuilder({ + frame: test.alignedFrame, + //@ts-ignore + theme: getTheme(), + timeZones: ['browser'], + getTimeRange: jest.fn(), + eventBus, + sync: jest.fn(), + allFrames: test.allFrames, + renderers, + }); + + //@ts-ignore + expect(builder.bands.length).toBe(test.expectedResult); + } + }); +}); diff --git a/public/app/core/components/TimeSeries/utils.ts b/public/app/core/components/TimeSeries/utils.ts new file mode 100644 index 00000000000..36da06299da --- /dev/null +++ b/public/app/core/components/TimeSeries/utils.ts @@ -0,0 +1,669 @@ +import { isNumber } from 'lodash'; +import uPlot from 'uplot'; + +import { + DashboardCursorSync, + DataFrame, + DataHoverClearEvent, + DataHoverEvent, + DataHoverPayload, + FieldConfig, + FieldType, + formattedValueToString, + getFieldColorModeForField, + getFieldSeriesColor, + getFieldDisplayName, + getDisplayProcessor, + FieldColorModeId, + DecimalCount, +} from '@grafana/data'; +// eslint-disable-next-line import/order +import { + AxisPlacement, + GraphDrawStyle, + GraphFieldConfig, + GraphTresholdsStyleMode, + VisibilityMode, + ScaleDirection, + ScaleOrientation, + StackingMode, + GraphTransform, + AxisColorMode, + GraphGradientMode, +} from '@grafana/schema'; + +// unit lookup needed to determine if we want power-of-2 or power-of-10 axis ticks +// see categories.ts is @grafana/data +const IEC_UNITS = new Set([ + 'bytes', + 'bits', + 'kbytes', + 'mbytes', + 'gbytes', + 'tbytes', + 'pbytes', + 'binBps', + 'binbps', + 'KiBs', + 'Kibits', + 'MiBs', + 'Mibits', + 'GiBs', + 'Gibits', + 'TiBs', + 'Tibits', + 'PiBs', + 'Pibits', +]); + +const BIN_INCRS = Array(53); + +for (let i = 0; i < BIN_INCRS.length; i++) { + BIN_INCRS[i] = 2 ** i; +} + +import { UPlotConfigBuilder, UPlotConfigPrepFn } from '@grafana/ui/src/components/uPlot/config/UPlotConfigBuilder'; +import { getScaleGradientFn } from '@grafana/ui/src/components/uPlot/config/gradientFills'; +import { buildScaleKey } from '@grafana/ui/src/components/uPlot/internal'; +import { getStackingGroups, preparePlotData2 } from '@grafana/ui/src/components/uPlot/utils'; + +const defaultFormatter = (v: any, decimals: DecimalCount = 1) => (v == null ? '-' : v.toFixed(decimals)); + +const defaultConfig: GraphFieldConfig = { + drawStyle: GraphDrawStyle.Line, + showPoints: VisibilityMode.Auto, + axisPlacement: AxisPlacement.Auto, +}; + +export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ + sync?: () => DashboardCursorSync; +}> = ({ + frame, + theme, + timeZones, + getTimeRange, + eventBus, + sync, + allFrames, + renderers, + tweakScale = (opts) => opts, + tweakAxis = (opts) => opts, + eventsScope = '__global_', +}) => { + const builder = new UPlotConfigBuilder(timeZones[0]); + + let alignedFrame: DataFrame; + + builder.setPrepData((frames) => { + // cache alignedFrame + alignedFrame = frames[0]; + + return preparePlotData2(frames[0], builder.getStackingGroups()); + }); + + // X is the first field in the aligned frame + const xField = frame.fields[0]; + if (!xField) { + return builder; // empty frame with no options + } + + const xScaleKey = 'x'; + let xScaleUnit = '_x'; + let yScaleKey = ''; + + const xFieldAxisPlacement = + xField.config.custom?.axisPlacement !== AxisPlacement.Hidden ? AxisPlacement.Bottom : AxisPlacement.Hidden; + const xFieldAxisShow = xField.config.custom?.axisPlacement !== AxisPlacement.Hidden; + + if (xField.type === FieldType.time) { + xScaleUnit = 'time'; + builder.addScale({ + scaleKey: xScaleKey, + orientation: ScaleOrientation.Horizontal, + direction: ScaleDirection.Right, + isTime: true, + range: () => { + const r = getTimeRange(); + return [r.from.valueOf(), r.to.valueOf()]; + }, + }); + + // filters first 2 ticks to make space for timezone labels + const filterTicks: uPlot.Axis.Filter | undefined = + timeZones.length > 1 + ? (u, splits) => { + return splits.map((v, i) => (i < 2 ? null : v)); + } + : undefined; + + for (let i = 0; i < timeZones.length; i++) { + const timeZone = timeZones[i]; + builder.addAxis({ + scaleKey: xScaleKey, + isTime: true, + placement: xFieldAxisPlacement, + show: xFieldAxisShow, + label: xField.config.custom?.axisLabel, + timeZone, + theme, + grid: { show: i === 0 && xField.config.custom?.axisGridShow }, + filter: filterTicks, + }); + } + + // render timezone labels + if (timeZones.length > 1) { + builder.addHook('drawAxes', (u: uPlot) => { + u.ctx.save(); + + u.ctx.fillStyle = theme.colors.text.primary; + u.ctx.textAlign = 'left'; + u.ctx.textBaseline = 'bottom'; + + let i = 0; + u.axes.forEach((a) => { + if (a.side === 2) { + //@ts-ignore + let cssBaseline: number = a._pos + a._size; + u.ctx.fillText(timeZones[i], u.bbox.left, cssBaseline * uPlot.pxRatio); + i++; + } + }); + + u.ctx.restore(); + }); + } + } else { + // Not time! + if (xField.config.unit) { + xScaleUnit = xField.config.unit; + } + + builder.addScale({ + scaleKey: xScaleKey, + orientation: ScaleOrientation.Horizontal, + direction: ScaleDirection.Right, + range: (u, dataMin, dataMax) => [xField.config.min ?? dataMin, xField.config.max ?? dataMax], + }); + + builder.addAxis({ + scaleKey: xScaleKey, + placement: xFieldAxisPlacement, + show: xFieldAxisShow, + label: xField.config.custom?.axisLabel, + theme, + grid: { show: xField.config.custom?.axisGridShow }, + formatValue: (v, decimals) => formattedValueToString(xField.display!(v, decimals)), + }); + } + + let customRenderedFields = + renderers?.flatMap((r) => Object.values(r.fieldMap).filter((name) => r.indicesOnly.indexOf(name) === -1)) ?? []; + + let indexByName: Map | undefined; + + for (let i = 1; i < frame.fields.length; i++) { + const field = frame.fields[i]; + + const config: FieldConfig = { + ...field.config, + custom: { + ...defaultConfig, + ...field.config.custom, + }, + }; + + const customConfig: GraphFieldConfig = config.custom!; + + if (field === xField || (field.type !== FieldType.number && field.type !== FieldType.enum)) { + continue; + } + + let fmt = field.display ?? defaultFormatter; + if (field.config.custom?.stacking?.mode === StackingMode.Percent) { + fmt = getDisplayProcessor({ + field: { + ...field, + config: { + ...field.config, + unit: 'percentunit', + }, + }, + theme, + }); + } + const scaleKey = buildScaleKey(config, field.type); + const colorMode = getFieldColorModeForField(field); + const scaleColor = getFieldSeriesColor(field, theme); + const seriesColor = scaleColor.color; + + // The builder will manage unique scaleKeys and combine where appropriate + builder.addScale( + tweakScale( + { + scaleKey, + orientation: ScaleOrientation.Vertical, + direction: ScaleDirection.Up, + distribution: customConfig.scaleDistribution?.type, + log: customConfig.scaleDistribution?.log, + linearThreshold: customConfig.scaleDistribution?.linearThreshold, + min: field.config.min, + max: field.config.max, + softMin: customConfig.axisSoftMin, + softMax: customConfig.axisSoftMax, + centeredZero: customConfig.axisCenteredZero, + range: + customConfig.stacking?.mode === StackingMode.Percent + ? (u: uPlot, dataMin: number, dataMax: number) => { + dataMin = dataMin < 0 ? -1 : 0; + dataMax = dataMax > 0 ? 1 : 0; + return [dataMin, dataMax]; + } + : field.type === FieldType.enum + ? (u: uPlot, dataMin: number, dataMax: number) => { + // this is the exhaustive enum (stable) + let len = field.config.type!.enum!.text!.length; + + return [-1, len]; + + // these are only values that are present + // return [dataMin - 1, dataMax + 1] + } + : undefined, + decimals: field.config.decimals, + }, + field + ) + ); + + if (!yScaleKey) { + yScaleKey = scaleKey; + } + + if (customConfig.axisPlacement !== AxisPlacement.Hidden) { + let axisColor: uPlot.Axis.Stroke | undefined; + + if (customConfig.axisColorMode === AxisColorMode.Series) { + if ( + colorMode.isByValue && + field.config.custom?.gradientMode === GraphGradientMode.Scheme && + colorMode.id === FieldColorModeId.Thresholds + ) { + axisColor = getScaleGradientFn(1, theme, colorMode, field.config.thresholds); + } else { + axisColor = seriesColor; + } + } + + const axisDisplayOptions = { + border: { + show: customConfig.axisBorderShow || false, + width: 1 / devicePixelRatio, + stroke: axisColor || theme.colors.text.primary, + }, + ticks: { + show: customConfig.axisBorderShow || false, + stroke: axisColor || theme.colors.text.primary, + }, + color: axisColor || theme.colors.text.primary, + }; + + let incrs: uPlot.Axis.Incrs | undefined; + + // TODO: these will be dynamic with frame updates, so need to accept getYTickLabels() + let values: uPlot.Axis.Values | undefined; + let splits: uPlot.Axis.Splits | undefined; + + if (IEC_UNITS.has(config.unit!)) { + incrs = BIN_INCRS; + } else if (field.type === FieldType.enum) { + let text = field.config.type!.enum!.text!; + splits = text.map((v: string, i: number) => i); + values = text; + } + + builder.addAxis( + tweakAxis( + { + scaleKey, + label: customConfig.axisLabel, + size: customConfig.axisWidth, + placement: customConfig.axisPlacement ?? AxisPlacement.Auto, + formatValue: (v, decimals) => formattedValueToString(fmt(v, decimals)), + theme, + grid: { show: customConfig.axisGridShow }, + decimals: field.config.decimals, + distr: customConfig.scaleDistribution?.type, + splits, + values, + incrs, + ...axisDisplayOptions, + }, + field + ) + ); + } + + const showPoints = + customConfig.drawStyle === GraphDrawStyle.Points ? VisibilityMode.Always : customConfig.showPoints; + + let pointsFilter: uPlot.Series.Points.Filter = () => null; + + if (customConfig.spanNulls !== true) { + pointsFilter = (u, seriesIdx, show, gaps) => { + let filtered = []; + + let series = u.series[seriesIdx]; + + if (!show && gaps && gaps.length) { + const [firstIdx, lastIdx] = series.idxs!; + const xData = u.data[0]; + const yData = u.data[seriesIdx]; + const firstPos = Math.round(u.valToPos(xData[firstIdx], 'x', true)); + const lastPos = Math.round(u.valToPos(xData[lastIdx], 'x', true)); + + if (gaps[0][0] === firstPos) { + filtered.push(firstIdx); + } + + // show single points between consecutive gaps that share end/start + for (let i = 0; i < gaps.length; i++) { + let thisGap = gaps[i]; + let nextGap = gaps[i + 1]; + + if (nextGap && thisGap[1] === nextGap[0]) { + // approx when data density is > 1pt/px, since gap start/end pixels are rounded + let approxIdx = u.posToIdx(thisGap[1], true); + + if (yData[approxIdx] == null) { + // scan left/right alternating to find closest index with non-null value + for (let j = 1; j < 100; j++) { + if (yData[approxIdx + j] != null) { + approxIdx += j; + break; + } + if (yData[approxIdx - j] != null) { + approxIdx -= j; + break; + } + } + } + + filtered.push(approxIdx); + } + } + + if (gaps[gaps.length - 1][1] === lastPos) { + filtered.push(lastIdx); + } + } + + return filtered.length ? filtered : null; + }; + } + + let { fillOpacity } = customConfig; + + let pathBuilder: uPlot.Series.PathBuilder | null = null; + let pointsBuilder: uPlot.Series.Points.Show | null = null; + + if (field.state?.origin) { + if (!indexByName) { + indexByName = getNamesToFieldIndex(frame, allFrames); + } + + const originFrame = allFrames[field.state.origin.frameIndex]; + const originField = originFrame?.fields[field.state.origin.fieldIndex]; + + const dispName = getFieldDisplayName(originField ?? field, originFrame, allFrames); + + // disable default renderers + if (customRenderedFields.indexOf(dispName) >= 0) { + pathBuilder = () => null; + pointsBuilder = () => undefined; + } else if (customConfig.transform === GraphTransform.Constant) { + // patch some monkeys! + const defaultBuilder = uPlot.paths!.linear!(); + + pathBuilder = (u, seriesIdx) => { + //eslint-disable-next-line + const _data: any[] = (u as any)._data; // uplot.AlignedData not exposed in types + + // the data we want the line renderer to pull is x at each plot edge with paired flat y values + + const r = getTimeRange(); + let xData = [r.from.valueOf(), r.to.valueOf()]; + let firstY = _data[seriesIdx].find((v: number | null | undefined) => v != null); + let yData = [firstY, firstY]; + let fauxData = _data.slice(); + fauxData[0] = xData; + fauxData[seriesIdx] = yData; + + //eslint-disable-next-line + return defaultBuilder( + { + ...u, + _data: fauxData, + } as any, + seriesIdx, + 0, + 1 + ); + }; + } + + if (customConfig.fillBelowTo) { + const fillBelowToField = frame.fields.find( + (f) => + customConfig.fillBelowTo === f.name || + customConfig.fillBelowTo === f.config?.displayNameFromDS || + customConfig.fillBelowTo === getFieldDisplayName(f, frame, allFrames) + ); + + const fillBelowDispName = fillBelowToField + ? getFieldDisplayName(fillBelowToField, frame, allFrames) + : customConfig.fillBelowTo; + + const t = indexByName.get(dispName); + const b = indexByName.get(fillBelowDispName); + if (isNumber(b) && isNumber(t)) { + builder.addBand({ + series: [t, b], + fill: undefined, // using null will have the band use fill options from `t` + }); + + if (!fillOpacity) { + fillOpacity = 35; // default from flot + } + } else { + fillOpacity = 0; + } + } + } + + let dynamicSeriesColor: ((seriesIdx: number) => string | undefined) | undefined = undefined; + + if (colorMode.id === FieldColorModeId.Thresholds) { + dynamicSeriesColor = (seriesIdx) => getFieldSeriesColor(alignedFrame.fields[seriesIdx], theme).color; + } + + builder.addSeries({ + pathBuilder, + pointsBuilder, + scaleKey, + showPoints, + pointsFilter, + colorMode, + fillOpacity, + theme, + dynamicSeriesColor, + drawStyle: customConfig.drawStyle!, + lineColor: customConfig.lineColor ?? seriesColor, + lineWidth: customConfig.lineWidth, + lineInterpolation: customConfig.lineInterpolation, + lineStyle: customConfig.lineStyle, + barAlignment: customConfig.barAlignment, + barWidthFactor: customConfig.barWidthFactor, + barMaxWidth: customConfig.barMaxWidth, + pointSize: customConfig.pointSize, + spanNulls: customConfig.spanNulls || false, + show: !customConfig.hideFrom?.viz, + gradientMode: customConfig.gradientMode, + thresholds: config.thresholds, + hardMin: field.config.min, + hardMax: field.config.max, + softMin: customConfig.axisSoftMin, + softMax: customConfig.axisSoftMax, + // The following properties are not used in the uPlot config, but are utilized as transport for legend config + dataFrameFieldIndex: field.state?.origin, + }); + + // Render thresholds in graph + if (customConfig.thresholdsStyle && config.thresholds) { + const thresholdDisplay = customConfig.thresholdsStyle.mode ?? GraphTresholdsStyleMode.Off; + if (thresholdDisplay !== GraphTresholdsStyleMode.Off) { + builder.addThresholds({ + config: customConfig.thresholdsStyle, + thresholds: config.thresholds, + scaleKey, + theme, + hardMin: field.config.min, + hardMax: field.config.max, + softMin: customConfig.axisSoftMin, + softMax: customConfig.axisSoftMax, + }); + } + } + } + + let stackingGroups = getStackingGroups(frame); + + builder.setStackingGroups(stackingGroups); + + // hook up custom/composite renderers + renderers?.forEach((r) => { + if (!indexByName) { + indexByName = getNamesToFieldIndex(frame, allFrames); + } + let fieldIndices: Record = {}; + + for (let key in r.fieldMap) { + let dispName = r.fieldMap[key]; + fieldIndices[key] = indexByName.get(dispName)!; + } + + r.init(builder, fieldIndices); + }); + + builder.scaleKeys = [xScaleKey, yScaleKey]; + + // if hovered value is null, how far we may scan left/right to hover nearest non-null + const hoverProximityPx = 15; + + let cursor: Partial = { + // this scans left and right from cursor position to find nearest data index with value != null + // TODO: do we want to only scan past undefined values, but halt at explicit null values? + dataIdx: (self, seriesIdx, hoveredIdx, cursorXVal) => { + let seriesData = self.data[seriesIdx]; + + if (seriesData[hoveredIdx] == null) { + let nonNullLft = null, + nonNullRgt = null, + i; + + i = hoveredIdx; + while (nonNullLft == null && i-- > 0) { + if (seriesData[i] != null) { + nonNullLft = i; + } + } + + i = hoveredIdx; + while (nonNullRgt == null && i++ < seriesData.length) { + if (seriesData[i] != null) { + nonNullRgt = i; + } + } + + let xVals = self.data[0]; + + let curPos = self.valToPos(cursorXVal, 'x'); + let rgtPos = nonNullRgt == null ? Infinity : self.valToPos(xVals[nonNullRgt], 'x'); + let lftPos = nonNullLft == null ? -Infinity : self.valToPos(xVals[nonNullLft], 'x'); + + let lftDelta = curPos - lftPos; + let rgtDelta = rgtPos - curPos; + + if (lftDelta <= rgtDelta) { + if (lftDelta <= hoverProximityPx) { + hoveredIdx = nonNullLft!; + } + } else { + if (rgtDelta <= hoverProximityPx) { + hoveredIdx = nonNullRgt!; + } + } + } + + return hoveredIdx; + }, + }; + + if (sync && sync() !== DashboardCursorSync.Off) { + const payload: DataHoverPayload = { + point: { + [xScaleKey]: null, + [yScaleKey]: null, + }, + data: frame, + }; + + const hoverEvent = new DataHoverEvent(payload); + cursor.sync = { + key: eventsScope, + filters: { + pub: (type: string, src: uPlot, x: number, y: number, w: number, h: number, dataIdx: number) => { + if (sync && sync() === DashboardCursorSync.Off) { + return false; + } + + payload.rowIndex = dataIdx; + if (x < 0 && y < 0) { + payload.point[xScaleUnit] = null; + payload.point[yScaleKey] = null; + eventBus.publish(new DataHoverClearEvent()); + } else { + // convert the points + payload.point[xScaleUnit] = src.posToVal(x, xScaleKey); + payload.point[yScaleKey] = src.posToVal(y, yScaleKey); + payload.point.panelRelY = y > 0 ? y / h : 1; // used by old graph panel to position tooltip + eventBus.publish(hoverEvent); + hoverEvent.payload.down = undefined; + } + return true; + }, + }, + scales: [xScaleKey, yScaleKey], + // match: [() => true, (a, b) => a === b], + }; + } + + builder.setSync(); + builder.setCursor(cursor); + + return builder; +}; + +export function getNamesToFieldIndex(frame: DataFrame, allFrames: DataFrame[]): Map { + const originNames = new Map(); + frame.fields.forEach((field, i) => { + const origin = field.state?.origin; + if (origin) { + const origField = allFrames[origin.frameIndex]?.fields[origin.fieldIndex]; + if (origField) { + originNames.set(getFieldDisplayName(origField, allFrames[origin.frameIndex], allFrames), i); + } + } + }); + return originNames; +} diff --git a/public/app/core/components/TimelineChart/TimelineChart.tsx b/public/app/core/components/TimelineChart/TimelineChart.tsx index ac5e2e62769..e904530a82e 100644 --- a/public/app/core/components/TimelineChart/TimelineChart.tsx +++ b/public/app/core/components/TimelineChart/TimelineChart.tsx @@ -2,16 +2,9 @@ import React from 'react'; import { DataFrame, FALLBACK_COLOR, FieldType, TimeRange } from '@grafana/data'; import { VisibilityMode, TimelineValueAlignment } from '@grafana/schema'; -import { - PanelContext, - PanelContextRoot, - GraphNG, - GraphNGProps, - UPlotConfigBuilder, - VizLayout, - VizLegend, - VizLegendItem, -} from '@grafana/ui'; +import { PanelContext, PanelContextRoot, UPlotConfigBuilder, VizLayout, VizLegend, VizLegendItem } from '@grafana/ui'; + +import { GraphNG, GraphNGProps } from '../GraphNG/GraphNG'; import { preparePlotConfigBuilder, TimelineMode } from './utils'; diff --git a/public/app/core/components/TimelineChart/timeline.ts b/public/app/core/components/TimelineChart/timeline.ts index 18ecfdbf33d..13fdd76fb4d 100644 --- a/public/app/core/components/TimelineChart/timeline.ts +++ b/public/app/core/components/TimelineChart/timeline.ts @@ -3,7 +3,7 @@ import uPlot, { Series } from 'uplot'; import { GrafanaTheme2, TimeRange } from '@grafana/data'; import { alpha } from '@grafana/data/src/themes/colorManipulator'; import { VisibilityMode, TimelineValueAlignment } from '@grafana/schema'; -import { FIXED_UNIT } from '@grafana/ui/src/components/GraphNG/GraphNG'; +import { FIXED_UNIT } from '@grafana/ui'; import { distribute, SPACE_BETWEEN } from 'app/plugins/panel/barchart/distribute'; import { pointWithin, Quadtree, Rect } from 'app/plugins/panel/barchart/quadtree'; import { FieldConfig as StateTimeLineFieldConfig } from 'app/plugins/panel/state-timeline/panelcfg.gen'; diff --git a/public/app/core/components/TimelineChart/utils.ts b/public/app/core/components/TimelineChart/utils.ts index 02048cca457..a372190209b 100644 --- a/public/app/core/components/TimelineChart/utils.ts +++ b/public/app/core/components/TimelineChart/utils.ts @@ -23,6 +23,8 @@ import { TimeRange, } from '@grafana/data'; import { maybeSortFrame } from '@grafana/data/src/transformations/transformers/joinDataFrames'; +import { applyNullInsertThreshold } from '@grafana/data/src/transformations/transformers/nulls/nullInsertThreshold'; +import { nullToValue } from '@grafana/data/src/transformations/transformers/nulls/nullToValue'; import { VizLegendOptions, AxisPlacement, @@ -40,8 +42,6 @@ import { UPlotConfigPrepFn, VizLegendItem, } from '@grafana/ui'; -import { applyNullInsertThreshold } from '@grafana/ui/src/components/GraphNG/nullInsertThreshold'; -import { nullToValue } from '@grafana/ui/src/components/GraphNG/nullToValue'; import { PlotTooltipInterpolator } from '@grafana/ui/src/components/uPlot/types'; import { preparePlotData2, getStackingGroups } from '@grafana/ui/src/components/uPlot/utils'; diff --git a/public/app/plugins/panel/barchart/BarChartPanel.tsx b/public/app/plugins/panel/barchart/BarChartPanel.tsx index c3ea72f4b21..27d7a366e17 100644 --- a/public/app/plugins/panel/barchart/BarChartPanel.tsx +++ b/public/app/plugins/panel/barchart/BarChartPanel.tsx @@ -16,8 +16,6 @@ import { PanelDataErrorView } from '@grafana/runtime'; import { SortOrder } from '@grafana/schema'; import { GraphGradientMode, - GraphNG, - GraphNGProps, measureText, PlotLegend, Portal, @@ -31,9 +29,9 @@ import { VizLegend, VizTooltipContainer, } from '@grafana/ui'; -import { PropDiffFn } from '@grafana/ui/src/components/GraphNG/GraphNG'; import { HoverEvent, addTooltipSupport } from '@grafana/ui/src/components/uPlot/config/addTooltipSupport'; import { CloseButton } from 'app/core/components/CloseButton/CloseButton'; +import { GraphNG, GraphNGProps, PropDiffFn } from 'app/core/components/GraphNG/GraphNG'; import { getFieldLegendItem } from 'app/core/components/TimelineChart/utils'; import { DataHoverView } from 'app/features/visualization/data-hover/DataHoverView'; diff --git a/public/app/plugins/panel/candlestick/CandlestickPanel.tsx b/public/app/plugins/panel/candlestick/CandlestickPanel.tsx index e0707a3820f..a0396c4a01b 100644 --- a/public/app/plugins/panel/candlestick/CandlestickPanel.tsx +++ b/public/app/plugins/panel/candlestick/CandlestickPanel.tsx @@ -7,9 +7,10 @@ import uPlot from 'uplot'; import { Field, getDisplayProcessor, getLinksSupplier, PanelProps } from '@grafana/data'; import { PanelDataErrorView } from '@grafana/runtime'; import { TooltipDisplayMode } from '@grafana/schema'; -import { TimeSeries, TooltipPlugin, UPlotConfigBuilder, usePanelContext, useTheme2, ZoomPlugin } from '@grafana/ui'; +import { TooltipPlugin, UPlotConfigBuilder, usePanelContext, useTheme2, ZoomPlugin } from '@grafana/ui'; import { AxisProps } from '@grafana/ui/src/components/uPlot/config/UPlotAxisBuilder'; import { ScaleProps } from '@grafana/ui/src/components/uPlot/config/UPlotScaleBuilder'; +import { TimeSeries } from 'app/core/components/TimeSeries/TimeSeries'; import { config } from 'app/core/config'; import { AnnotationEditorPlugin } from '../timeseries/plugins/AnnotationEditorPlugin'; diff --git a/public/app/plugins/panel/graph/data_processor.ts b/public/app/plugins/panel/graph/data_processor.ts index 8f67e3abb5c..21afccf65d3 100644 --- a/public/app/plugins/panel/graph/data_processor.ts +++ b/public/app/plugins/panel/graph/data_processor.ts @@ -1,8 +1,8 @@ import { find } from 'lodash'; import { DataFrame, dateTime, Field, FieldType, getFieldDisplayName, getTimeField, TimeRange } from '@grafana/data'; +import { applyNullInsertThreshold } from '@grafana/data/src/transformations/transformers/nulls/nullInsertThreshold'; import { colors } from '@grafana/ui'; -import { applyNullInsertThreshold } from '@grafana/ui/src/components/GraphNG/nullInsertThreshold'; import config from 'app/core/config'; import TimeSeries from 'app/core/time_series2'; diff --git a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx index 752fc49ba3f..f9d57f04578 100644 --- a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx +++ b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx @@ -3,7 +3,8 @@ import React, { useMemo } from 'react'; import { PanelProps, DataFrameType } from '@grafana/data'; import { PanelDataErrorView } from '@grafana/runtime'; import { TooltipDisplayMode } from '@grafana/schema'; -import { KeyboardPlugin, TimeSeries, TooltipPlugin, usePanelContext, ZoomPlugin } from '@grafana/ui'; +import { KeyboardPlugin, TooltipPlugin, usePanelContext, ZoomPlugin } from '@grafana/ui'; +import { TimeSeries } from 'app/core/components/TimeSeries/TimeSeries'; import { config } from 'app/core/config'; import { Options } from './panelcfg.gen'; diff --git a/public/app/plugins/panel/timeseries/plugins/ExemplarsPlugin.tsx b/public/app/plugins/panel/timeseries/plugins/ExemplarsPlugin.tsx index 321dbfb1cec..754e052c7b9 100644 --- a/public/app/plugins/panel/timeseries/plugins/ExemplarsPlugin.tsx +++ b/public/app/plugins/panel/timeseries/plugins/ExemplarsPlugin.tsx @@ -9,7 +9,7 @@ import { TIME_SERIES_VALUE_FIELD_NAME, TimeZone, } from '@grafana/data'; -import { EventsCanvas, FIXED_UNIT, UPlotConfigBuilder } from '@grafana/ui'; +import { FIXED_UNIT, EventsCanvas, UPlotConfigBuilder } from '@grafana/ui'; import { ExemplarMarker } from './ExemplarMarker'; diff --git a/public/app/plugins/panel/timeseries/plugins/ThresholdControlsPlugin.tsx b/public/app/plugins/panel/timeseries/plugins/ThresholdControlsPlugin.tsx index 3db8c039e8d..a286ce7612f 100644 --- a/public/app/plugins/panel/timeseries/plugins/ThresholdControlsPlugin.tsx +++ b/public/app/plugins/panel/timeseries/plugins/ThresholdControlsPlugin.tsx @@ -2,7 +2,8 @@ import React, { useState, useLayoutEffect, useMemo, useRef } from 'react'; import uPlot from 'uplot'; import { FieldConfigSource, ThresholdsConfig, getValueFormat, FieldType } from '@grafana/data'; -import { UPlotConfigBuilder, buildScaleKey } from '@grafana/ui'; +import { UPlotConfigBuilder } from '@grafana/ui'; +import { buildScaleKey } from '@grafana/ui/src/components/uPlot/internal'; import { ThresholdDragHandle } from './ThresholdDragHandle'; diff --git a/public/app/plugins/panel/timeseries/utils.ts b/public/app/plugins/panel/timeseries/utils.ts index 27cb4845a6a..871cf62b599 100644 --- a/public/app/plugins/panel/timeseries/utils.ts +++ b/public/app/plugins/panel/timeseries/utils.ts @@ -13,10 +13,10 @@ import { TimeRange, } from '@grafana/data'; import { convertFieldType } from '@grafana/data/src/transformations/transformers/convertFieldType'; +import { applyNullInsertThreshold } from '@grafana/data/src/transformations/transformers/nulls/nullInsertThreshold'; +import { nullToValue } from '@grafana/data/src/transformations/transformers/nulls/nullToValue'; import { GraphFieldConfig, LineInterpolation } from '@grafana/schema'; -import { applyNullInsertThreshold } from '@grafana/ui/src/components/GraphNG/nullInsertThreshold'; -import { nullToValue } from '@grafana/ui/src/components/GraphNG/nullToValue'; -import { buildScaleKey } from '@grafana/ui/src/components/GraphNG/utils'; +import { buildScaleKey } from '@grafana/ui/src/components/uPlot/internal'; type ScaleKey = string; diff --git a/public/app/plugins/panel/trend/TrendPanel.tsx b/public/app/plugins/panel/trend/TrendPanel.tsx index 371b0679a65..e5371dca263 100644 --- a/public/app/plugins/panel/trend/TrendPanel.tsx +++ b/public/app/plugins/panel/trend/TrendPanel.tsx @@ -3,15 +3,10 @@ import React, { useMemo } from 'react'; import { DataFrame, FieldMatcherID, fieldMatchers, FieldType, PanelProps, TimeRange } from '@grafana/data'; import { isLikelyAscendingVector } from '@grafana/data/src/transformations/transformers/joinDataFrames'; import { config, PanelDataErrorView } from '@grafana/runtime'; -import { - KeyboardPlugin, - preparePlotFrame, - TimeSeries, - TooltipDisplayMode, - TooltipPlugin, - usePanelContext, -} from '@grafana/ui'; -import { XYFieldMatchers } from '@grafana/ui/src/components/GraphNG/types'; +import { KeyboardPlugin, TooltipDisplayMode, TooltipPlugin, usePanelContext } from '@grafana/ui'; +import { XYFieldMatchers } from 'app/core/components/GraphNG/types'; +import { preparePlotFrame } from 'app/core/components/GraphNG/utils'; +import { TimeSeries } from 'app/core/components/TimeSeries/TimeSeries'; import { findFieldIndex } from 'app/features/dimensions'; import { prepareGraphableFields, regenerateLinksSupplier } from '../timeseries/utils'; diff --git a/public/app/plugins/panel/xychart/dims.ts b/public/app/plugins/panel/xychart/dims.ts index 30320448330..9b2619b64ab 100644 --- a/public/app/plugins/panel/xychart/dims.ts +++ b/public/app/plugins/panel/xychart/dims.ts @@ -1,5 +1,5 @@ import { DataFrame, Field, FieldMatcher, FieldType, getFieldDisplayName } from '@grafana/data'; -import { XYFieldMatchers } from '@grafana/ui/src/components/GraphNG/types'; +import { XYFieldMatchers } from 'app/core/components/GraphNG/types'; import { XYDimensionConfig } from './panelcfg.gen'; From 41572238ac6e84057c9caffb11c983ab66a9960f Mon Sep 17 00:00:00 2001 From: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Thu, 2 Nov 2023 10:46:43 +0100 Subject: [PATCH 02/30] Tempo: Fix support for `statusMessage` (#77438) --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 1745d51f78c..646f1927e51 100644 --- a/package.json +++ b/package.json @@ -252,7 +252,7 @@ "@grafana/flamegraph": "workspace:*", "@grafana/google-sdk": "0.1.1", "@grafana/lezer-logql": "0.2.1", - "@grafana/lezer-traceql": "0.0.8", + "@grafana/lezer-traceql": "0.0.9", "@grafana/monaco-logql": "^0.0.7", "@grafana/runtime": "workspace:*", "@grafana/scenes": "^1.20.1", diff --git a/yarn.lock b/yarn.lock index 3adbcf55166..e111cff3ffb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3242,12 +3242,12 @@ __metadata: languageName: node linkType: hard -"@grafana/lezer-traceql@npm:0.0.8": - version: 0.0.8 - resolution: "@grafana/lezer-traceql@npm:0.0.8" +"@grafana/lezer-traceql@npm:0.0.9": + version: 0.0.9 + resolution: "@grafana/lezer-traceql@npm:0.0.9" peerDependencies: "@lezer/lr": ^1.3.0 - checksum: 9a85e4e6a3d77c2075643d179727855f3b7d49e76e93053048b56c86bcfaec040e654012f7ab5f8cf00cd0464220b9afbb1166a1f927b0ce9d2bb724af8f5133 + checksum: 1511e34d47466a9bd4880cd04f817d263dc01c0d4cd4dea0ad1bd7829d59e3d6b15c723e9ba4b240d812fc5d288ec0c4726928a2da031ee3d60573a8861d21a3 languageName: node linkType: hard @@ -17099,7 +17099,7 @@ __metadata: "@grafana/flamegraph": "workspace:*" "@grafana/google-sdk": "npm:0.1.1" "@grafana/lezer-logql": "npm:0.2.1" - "@grafana/lezer-traceql": "npm:0.0.8" + "@grafana/lezer-traceql": "npm:0.0.9" "@grafana/monaco-logql": "npm:^0.0.7" "@grafana/runtime": "workspace:*" "@grafana/scenes": "npm:^1.20.1" From 1e065580ac07df747d421e46090a23de81cd02c1 Mon Sep 17 00:00:00 2001 From: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com> Date: Thu, 2 Nov 2023 11:02:00 +0100 Subject: [PATCH 03/30] Loki: Remove duplicated methods in languageProvider (#77456) Loki: Remove fuplicated methods in languageProvider --- .../datasource/loki/LanguageProvider.ts | 21 ------------------- .../loki/components/LokiCheatSheet.tsx | 2 +- .../loki/components/LokiLabelBrowser.test.tsx | 2 +- .../loki/components/LokiLabelBrowser.tsx | 2 +- .../CompletionDataProvider.test.ts | 4 ++-- .../CompletionDataProvider.ts | 4 ++-- 6 files changed, 7 insertions(+), 28 deletions(-) diff --git a/public/app/plugins/datasource/loki/LanguageProvider.ts b/public/app/plugins/datasource/loki/LanguageProvider.ts index 070ec781161..960dfcbfa89 100644 --- a/public/app/plugins/datasource/loki/LanguageProvider.ts +++ b/public/app/plugins/datasource/loki/LanguageProvider.ts @@ -96,20 +96,6 @@ export default class LokiLanguageProvider extends LanguageProvider { }; } - /** - * Wrapper method over fetchSeriesLabels to retrieve series labels and handle errors. - * @todo remove this in favor of fetchSeriesLabels as we already in this.request do the same thing - */ - async getSeriesLabels(selector: string) { - try { - return await this.fetchSeriesLabels(selector); - } catch (error) { - // TODO: better error handling - console.error(error); - return undefined; - } - } - /** * Fetch all label keys * This asynchronous function returns all available label keys from the data source. @@ -186,13 +172,6 @@ export default class LokiLanguageProvider extends LanguageProvider { return nanoseconds ? Math.floor(nanoseconds / NS_IN_MS / 1000 / 60 / 5) : 0; } - /** - * @todo remove this in favor of fetchLabelValues as it is the same thing - */ - async getLabelValues(key: string): Promise { - return await this.fetchLabelValues(key); - } - /** * Fetch label values * diff --git a/public/app/plugins/datasource/loki/components/LokiCheatSheet.tsx b/public/app/plugins/datasource/loki/components/LokiCheatSheet.tsx index b6c75f526a4..0f7a0b99dd1 100644 --- a/public/app/plugins/datasource/loki/components/LokiCheatSheet.tsx +++ b/public/app/plugins/datasource/loki/components/LokiCheatSheet.tsx @@ -62,7 +62,7 @@ export default class LokiCheatSheet extends PureComponent labels.includes(l)); if (preferredLabel) { - const values = await provider.getLabelValues(preferredLabel); + const values = await provider.fetchLabelValues(preferredLabel); const userExamples = shuffle(values) .slice(0, EXAMPLES_LIMIT) .map((value) => `{${preferredLabel}="${value}"}`); diff --git a/public/app/plugins/datasource/loki/components/LokiLabelBrowser.test.tsx b/public/app/plugins/datasource/loki/components/LokiLabelBrowser.test.tsx index fc158203f65..5090172f64e 100644 --- a/public/app/plugins/datasource/loki/components/LokiLabelBrowser.test.tsx +++ b/public/app/plugins/datasource/loki/components/LokiLabelBrowser.test.tsx @@ -85,7 +85,7 @@ describe('LokiLabelBrowser', () => { const setupProps = (): BrowserProps => { const mockLanguageProvider = { start: () => Promise.resolve(), - getLabelValues: (name: string) => { + fetchLabelValues: (name: string) => { switch (name) { case 'label1': return ['value1-1', 'value1-2']; diff --git a/public/app/plugins/datasource/loki/components/LokiLabelBrowser.tsx b/public/app/plugins/datasource/loki/components/LokiLabelBrowser.tsx index 936255b4a90..2c32e36549d 100644 --- a/public/app/plugins/datasource/loki/components/LokiLabelBrowser.tsx +++ b/public/app/plugins/datasource/loki/components/LokiLabelBrowser.tsx @@ -350,7 +350,7 @@ export class UnthemedLokiLabelBrowser extends React.Component { completionProvider = new CompletionDataProvider(languageProvider, historyRef); jest.spyOn(languageProvider, 'getLabelKeys').mockReturnValue(labelKeys); - jest.spyOn(languageProvider, 'getLabelValues').mockResolvedValue(labelValues); - jest.spyOn(languageProvider, 'getSeriesLabels').mockResolvedValue(seriesLabels); + jest.spyOn(languageProvider, 'fetchLabelValues').mockResolvedValue(labelValues); + jest.spyOn(languageProvider, 'fetchSeriesLabels').mockResolvedValue(seriesLabels); jest.spyOn(languageProvider, 'getParserAndLabelKeys').mockResolvedValue(parserAndLabelKeys); }); diff --git a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/CompletionDataProvider.ts b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/CompletionDataProvider.ts index 03064d5a74a..f8934ff1fc2 100644 --- a/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/CompletionDataProvider.ts +++ b/public/app/plugins/datasource/loki/components/monaco-query-field/monaco-completion-provider/CompletionDataProvider.ts @@ -51,7 +51,7 @@ export class CompletionDataProvider { async getLabelValues(labelName: string, otherLabels: Label[]) { if (otherLabels.length === 0) { // if there is no filtering, we have to use a special endpoint - return await this.languageProvider.getLabelValues(labelName); + return await this.languageProvider.fetchLabelValues(labelName); } const data = await this.getSeriesLabels(otherLabels); @@ -90,6 +90,6 @@ export class CompletionDataProvider { } async getSeriesLabels(labels: Label[]) { - return await this.languageProvider.getSeriesLabels(this.buildSelector(labels)).then((data) => data ?? {}); + return await this.languageProvider.fetchSeriesLabels(this.buildSelector(labels)).then((data) => data ?? {}); } } From d62170e4ce24ce2def6ca531ca183a91e1cc4de7 Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Thu, 2 Nov 2023 11:22:57 +0100 Subject: [PATCH 04/30] Grafana/ui: Move the Stack component out of unstable (#77495) * grafana/ui: Move Stack out of unstable * grafana/ui: Replace imports --- packages/grafana-ui/src/components/index.ts | 1 + packages/grafana-ui/src/unstable.ts | 1 - .../AppChrome/DockedMegaMenu/MegaMenu.tsx | 3 +-- public/app/features/admin/Users/OrgUnits.tsx | 3 +-- .../features/admin/Users/OrgUsersTable.tsx | 22 +++++++++---------- .../app/features/admin/Users/UsersTable.tsx | 14 ++++++------ .../dashboard/dashgrid/DashboardEmpty.tsx | 3 +-- .../ServiceAccountsListPage.tsx | 2 +- public/app/features/teams/TeamList.tsx | 22 +++++++++---------- 9 files changed, 34 insertions(+), 37 deletions(-) diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 78cd9b5321e..d5abea2d4c1 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -215,6 +215,7 @@ export { Link } from './Link/Link'; export { TextLink } from './Link/TextLink'; export { Text } from './Text/Text'; export { Box } from './Layout/Box/Box'; +export { Stack } from './Layout/Stack/Stack'; export { Label } from './Forms/Label'; export { Field, type FieldProps } from './Forms/Field'; diff --git a/packages/grafana-ui/src/unstable.ts b/packages/grafana-ui/src/unstable.ts index f90909a1517..2e08df72642 100644 --- a/packages/grafana-ui/src/unstable.ts +++ b/packages/grafana-ui/src/unstable.ts @@ -10,4 +10,3 @@ */ export { Grid } from './components/Layout/Grid/Grid'; -export { Stack } from './components/Layout/Stack/Stack'; diff --git a/public/app/core/components/AppChrome/DockedMegaMenu/MegaMenu.tsx b/public/app/core/components/AppChrome/DockedMegaMenu/MegaMenu.tsx index dff065c4242..fae86e4e576 100644 --- a/public/app/core/components/AppChrome/DockedMegaMenu/MegaMenu.tsx +++ b/public/app/core/components/AppChrome/DockedMegaMenu/MegaMenu.tsx @@ -5,8 +5,7 @@ import { useLocation } from 'react-router-dom'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { CustomScrollbar, Icon, IconButton, useStyles2 } from '@grafana/ui'; -import { Stack } from '@grafana/ui/src/unstable'; +import { CustomScrollbar, Icon, IconButton, useStyles2, Stack } from '@grafana/ui'; import { useGrafana } from 'app/core/context/GrafanaContext'; import { t } from 'app/core/internationalization'; import { useSelector } from 'app/types'; diff --git a/public/app/features/admin/Users/OrgUnits.tsx b/public/app/features/admin/Users/OrgUnits.tsx index 08a58be8854..5da20ac22c3 100644 --- a/public/app/features/admin/Users/OrgUnits.tsx +++ b/public/app/features/admin/Users/OrgUnits.tsx @@ -1,8 +1,7 @@ import React, { forwardRef, PropsWithChildren } from 'react'; import { IconName } from '@grafana/data'; -import { Icon, Tooltip, Box } from '@grafana/ui'; -import { Stack } from '@grafana/ui/src/unstable'; +import { Icon, Tooltip, Box, Stack } from '@grafana/ui'; import { Unit } from 'app/types'; type OrgUnitProps = { units?: Unit[]; icon: IconName }; diff --git a/public/app/features/admin/Users/OrgUsersTable.tsx b/public/app/features/admin/Users/OrgUsersTable.tsx index 0e3df7d60a7..34a58210fab 100644 --- a/public/app/features/admin/Users/OrgUsersTable.tsx +++ b/public/app/features/admin/Users/OrgUsersTable.tsx @@ -3,20 +3,20 @@ import React, { useEffect, useMemo, useState } from 'react'; import { OrgRole } from '@grafana/data'; import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; import { - Button, - ConfirmModal, - Icon, - Tooltip, - CellProps, - Tag, - InteractiveTable, - Column, - FetchDataFunc, - Pagination, Avatar, Box, + Button, + CellProps, + Column, + ConfirmModal, + FetchDataFunc, + Icon, + InteractiveTable, + Pagination, + Stack, + Tag, + Tooltip, } from '@grafana/ui'; -import { Stack } from '@grafana/ui/src/unstable'; import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker'; import { fetchRoleOptions } from 'app/core/components/RolePicker/api'; import { TagBadge } from 'app/core/components/TagFilter/TagBadge'; diff --git a/public/app/features/admin/Users/UsersTable.tsx b/public/app/features/admin/Users/UsersTable.tsx index 7713855e4e1..745efbd925a 100644 --- a/public/app/features/admin/Users/UsersTable.tsx +++ b/public/app/features/admin/Users/UsersTable.tsx @@ -1,18 +1,18 @@ import React, { useMemo } from 'react'; import { - InteractiveTable, + Avatar, CellProps, - Tooltip, - Icon, - Tag, - Pagination, Column, FetchDataFunc, + Icon, + InteractiveTable, + Pagination, + Stack, + Tag, Text, - Avatar, + Tooltip, } from '@grafana/ui'; -import { Stack } from '@grafana/ui/src/unstable'; import { TagBadge } from 'app/core/components/TagFilter/TagBadge'; import { UserDTO } from 'app/types'; diff --git a/public/app/features/dashboard/dashgrid/DashboardEmpty.tsx b/public/app/features/dashboard/dashgrid/DashboardEmpty.tsx index 0a521767cc6..2d087b41b0c 100644 --- a/public/app/features/dashboard/dashgrid/DashboardEmpty.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardEmpty.tsx @@ -4,8 +4,7 @@ import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { config, locationService, reportInteraction } from '@grafana/runtime'; -import { Button, useStyles2, Text, Box } from '@grafana/ui'; -import { Stack } from '@grafana/ui/src/unstable'; +import { Button, useStyles2, Text, Box, Stack } from '@grafana/ui'; import { Trans } from 'app/core/internationalization'; import { DashboardModel } from 'app/features/dashboard/state'; import { onAddLibraryPanel, onCreateNewPanel, onImportDashboard } from 'app/features/dashboard/utils/dashboard'; diff --git a/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx b/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx index b943e841cb1..d6aedc26be6 100644 --- a/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx @@ -12,8 +12,8 @@ import { useStyles2, InlineField, Pagination, + Stack, } from '@grafana/ui'; -import { Stack } from '@grafana/ui/src/unstable'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; import { Page } from 'app/core/components/Page/Page'; import PageLoader from 'app/core/components/PageLoader/PageLoader'; diff --git a/public/app/features/teams/TeamList.tsx b/public/app/features/teams/TeamList.tsx index bd762baf1d0..4211a462ec1 100644 --- a/public/app/features/teams/TeamList.tsx +++ b/public/app/features/teams/TeamList.tsx @@ -2,19 +2,19 @@ import React, { useEffect, useMemo, useState } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { - LinkButton, - FilterInput, - InlineField, - CellProps, - DeleteButton, - InteractiveTable, - Icon, - Tooltip, - Column, - Pagination, Avatar, + CellProps, + Column, + DeleteButton, + FilterInput, + Icon, + InlineField, + InteractiveTable, + LinkButton, + Pagination, + Stack, + Tooltip, } from '@grafana/ui'; -import { Stack } from '@grafana/ui/src/unstable'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; import { Page } from 'app/core/components/Page/Page'; import { fetchRoleOptions } from 'app/core/components/RolePicker/api'; From b13395afbccde52d02ac926919234032237794e1 Mon Sep 17 00:00:00 2001 From: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Thu, 2 Nov 2023 11:23:18 +0100 Subject: [PATCH 05/30] Tempo: Handle empty responses in ServiceGraph (#77539) --- .../datasource/tempo/graphTransform.test.ts | 21 +++++++++++++++++++ .../datasource/tempo/graphTransform.ts | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/public/app/plugins/datasource/tempo/graphTransform.test.ts b/public/app/plugins/datasource/tempo/graphTransform.test.ts index e6acd91ddd1..efebee60cd6 100644 --- a/public/app/plugins/datasource/tempo/graphTransform.test.ts +++ b/public/app/plugins/datasource/tempo/graphTransform.test.ts @@ -80,6 +80,27 @@ it('assigns correct field type even if values are numbers', async () => { ]); }); +it('do not fail on response with empty list', async () => { + const range = { + from: dateTime('2000-01-01T00:00:00'), + to: dateTime('2000-01-01T00:01:00'), + }; + const { nodes } = mapPromMetricsToServiceMap([], { + ...range, + raw: range, + }); + + expect(nodes.fields).toMatchObject([ + { name: 'id', values: [], type: FieldType.string }, + { name: 'title', values: [], type: FieldType.string }, + { name: 'subtitle', type: FieldType.string, values: [] }, + { name: 'mainstat', values: [], type: FieldType.number }, + { name: 'secondarystat', values: [], type: FieldType.number }, + { name: 'arc__success', values: [], type: FieldType.number }, + { name: 'arc__failed', values: [], type: FieldType.number }, + ]); +}); + describe('mapPromMetricsToServiceMap', () => { it('transforms prom metrics to service graph', async () => { const range = { diff --git a/public/app/plugins/datasource/tempo/graphTransform.ts b/public/app/plugins/datasource/tempo/graphTransform.ts index 4b7b4b56fed..08cbbf19c6b 100644 --- a/public/app/plugins/datasource/tempo/graphTransform.ts +++ b/public/app/plugins/datasource/tempo/graphTransform.ts @@ -249,7 +249,7 @@ function createServiceMapDataFrames() { * @param responses */ function getMetricFrames(responses: DataQueryResponse[]): Record { - return responses[0].data.reduce>((acc, frameDTO) => { + return (responses[0]?.data || []).reduce>((acc, frameDTO) => { const frame = toDataFrame(frameDTO); acc[frame.refId ?? 'A'] = new DataFrameView(frame); return acc; From 82a7e1229aace381487645256ad8cee4f26bb0d3 Mon Sep 17 00:00:00 2001 From: Andres Martinez Gotor Date: Thu, 2 Nov 2023 11:27:17 +0100 Subject: [PATCH 06/30] Bug Fix: Respect data source version when provisioning (#77428) --- docs/sources/administration/provisioning/index.md | 2 ++ pkg/services/provisioning/datasources/datasources.go | 6 +++++- pkg/services/provisioning/datasources/types.go | 1 + pkg/services/provisioning/datasources/types_test.go | 8 ++++++++ 4 files changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/sources/administration/provisioning/index.md b/docs/sources/administration/provisioning/index.md index 0f3c5f1e846..3a96f6e3111 100644 --- a/docs/sources/administration/provisioning/index.md +++ b/docs/sources/administration/provisioning/index.md @@ -157,6 +157,8 @@ datasources: password: # Sets the basic authorization password. basicAuthPassword: + # Sets the version. Used to compare versions when + # updating. Ignored when creating a new data source. version: 1 # Allows users to edit data sources from the # Grafana UI. diff --git a/pkg/services/provisioning/datasources/datasources.go b/pkg/services/provisioning/datasources/datasources.go index 3093e39db1b..83d1288b4ab 100644 --- a/pkg/services/provisioning/datasources/datasources.go +++ b/pkg/services/provisioning/datasources/datasources.go @@ -80,7 +80,11 @@ func (dc *DatasourceProvisioner) provisionDataSources(ctx context.Context, cfg * updateCmd := createUpdateCommand(ds, dataSource.ID) dc.log.Debug("updating datasource from configuration", "name", updateCmd.Name, "uid", updateCmd.UID) if _, err := dc.store.UpdateDataSource(ctx, updateCmd); err != nil { - return err + if errors.Is(err, datasources.ErrDataSourceUpdatingOldVersion) { + dc.log.Debug("ignoring old version of datasource", "name", updateCmd.Name, "uid", updateCmd.UID) + } else { + return err + } } } } diff --git a/pkg/services/provisioning/datasources/types.go b/pkg/services/provisioning/datasources/types.go index 2e58df3a016..5088278cc85 100644 --- a/pkg/services/provisioning/datasources/types.go +++ b/pkg/services/provisioning/datasources/types.go @@ -243,6 +243,7 @@ func createUpdateCommand(ds *upsertDataSourceFromConfig, id int64) *datasources. return &datasources.UpdateDataSourceCommand{ ID: id, + Version: ds.Version, UID: ds.UID, OrgID: ds.OrgID, Name: ds.Name, diff --git a/pkg/services/provisioning/datasources/types_test.go b/pkg/services/provisioning/datasources/types_test.go index 75820d6b128..5cf0592f6b7 100644 --- a/pkg/services/provisioning/datasources/types_test.go +++ b/pkg/services/provisioning/datasources/types_test.go @@ -13,3 +13,11 @@ func TestUIDFromNames(t *testing.T) { require.Equal(t, safeUIDFromName("AAA"), "PCB1AD2119D8FAFB6") }) } + +func TestCreateUpdateCommand(t *testing.T) { + t.Run("includes the version in the command", func(t *testing.T) { + ds := &upsertDataSourceFromConfig{OrgID: 1, Version: 1, Name: "test"} + cmd := createUpdateCommand(ds, 1) + require.Equal(t, 1, cmd.Version) + }) +} From 774a8a889a95c299a7c452d4dfddf8cae19a8ffd Mon Sep 17 00:00:00 2001 From: Krishna Dhakal <7krishna7dhakal7@gmail.com> Date: Thu, 2 Nov 2023 17:08:59 +0545 Subject: [PATCH 07/30] Grafana-UI: Create fast path in Text component (#76167) Text component fast path Truncated text an isolated component --- .../grafana-ui/src/components/Text/Text.tsx | 92 ++++++------------- .../src/components/Text/TruncatedText.tsx | 64 +++++++++++++ 2 files changed, 92 insertions(+), 64 deletions(-) create mode 100644 packages/grafana-ui/src/components/Text/TruncatedText.tsx diff --git a/packages/grafana-ui/src/components/Text/Text.tsx b/packages/grafana-ui/src/components/Text/Text.tsx index eb036f186e7..29f3a46c113 100644 --- a/packages/grafana-ui/src/components/Text/Text.tsx +++ b/packages/grafana-ui/src/components/Text/Text.tsx @@ -1,12 +1,11 @@ import { css } from '@emotion/css'; -import React, { createElement, CSSProperties, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; -import ReactDomServer from 'react-dom/server'; +import React, { createElement, CSSProperties } from 'react'; import { GrafanaTheme2, ThemeTypographyVariantTypes } from '@grafana/data'; import { useStyles2 } from '../../themes'; -import { Tooltip } from '../Tooltip/Tooltip'; +import { TruncatedText } from './TruncatedText'; import { customWeight, customColor, customVariant } from './utils'; export interface TextProps extends Omit, 'className' | 'style'> { @@ -30,70 +29,35 @@ export interface TextProps extends Omit, 'clas export const Text = React.forwardRef( ({ element = 'span', variant, weight, color, truncate, italic, textAlignment, children, ...restProps }, ref) => { const styles = useStyles2(getTextStyles, element, variant, color, weight, truncate, italic, textAlignment); - const [isOverflowing, setIsOverflowing] = useState(false); - const internalRef = useRef(null); - // wire up the forwarded ref to the internal ref - useImperativeHandle(ref, () => internalRef.current); - - const childElement = createElement( - element, - { - ...restProps, - style: undefined, // remove style prop to avoid overriding the styles - className: styles, - // when overflowing, the internalRef is passed to the tooltip which forwards it on to the child element - ref: isOverflowing ? undefined : internalRef, - }, - children - ); - - const resizeObserver = useMemo( - () => - new ResizeObserver((entries) => { - for (const entry of entries) { - if (entry.target.clientWidth && entry.target.scrollWidth) { - if (entry.target.scrollWidth > entry.target.clientWidth) { - setIsOverflowing(true); - } - if (entry.target.scrollWidth <= entry.target.clientWidth) { - setIsOverflowing(false); - } - } - } - }), - [] - ); - - useEffect(() => { - const { current } = internalRef; - if (current && truncate) { - resizeObserver.observe(current); - } - return () => { - resizeObserver.disconnect(); - }; - }, [isOverflowing, resizeObserver, truncate]); - - const getTooltipText = (children: NonNullable) => { - if (typeof children === 'string') { - return children; - } - const html = ReactDomServer.renderToStaticMarkup(<>{children}); - const getRidOfTags = html.replace(/(<([^>]+)>)/gi, ''); - return getRidOfTags; - }; - // A 'span' is an inline element therefore it can't be truncated - // and it should be wrapped in a parent element that is the one that will show the tooltip - if (truncate && isOverflowing && element !== 'span') { - return ( - - {childElement} - + const childElement = (ref: React.ForwardedRef | undefined) => { + return createElement( + element, + { + ...restProps, + style: undefined, // Remove the style prop to avoid overriding the styles + className: styles, + // When overflowing, the internalRef is passed to the tooltip, which forwards it to the child element + ref, + }, + children ); - } else { - return childElement; + }; + + // A 'span' is an inline element, so it can't be truncated + // and it should be wrapped in a parent element that will show the tooltip + if (!truncate || element === 'span') { + return childElement(undefined); } + + return ( + + ); } ); diff --git a/packages/grafana-ui/src/components/Text/TruncatedText.tsx b/packages/grafana-ui/src/components/Text/TruncatedText.tsx new file mode 100644 index 00000000000..e74cbd36bb7 --- /dev/null +++ b/packages/grafana-ui/src/components/Text/TruncatedText.tsx @@ -0,0 +1,64 @@ +import React, { useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; +import ReactDOMServer from 'react-dom/server'; + +import { Tooltip } from '../Tooltip/Tooltip'; + +interface TruncatedTextProps { + childElement: (ref: React.ForwardedRef | undefined) => React.ReactElement; + children: NonNullable; +} + +export const TruncatedText = React.forwardRef(({ childElement, children }, ref) => { + const [isOverflowing, setIsOverflowing] = useState(false); + const internalRef = useRef(null); + + // Wire up the forwarded ref to the internal ref + useImperativeHandle(ref, () => internalRef.current); + + const resizeObserver = useMemo( + () => + new ResizeObserver((entries) => { + for (const entry of entries) { + if (entry.target.clientWidth && entry.target.scrollWidth) { + if (entry.target.scrollWidth > entry.target.clientWidth) { + setIsOverflowing(true); + } + if (entry.target.scrollWidth <= entry.target.clientWidth) { + setIsOverflowing(false); + } + } + } + }), + [] + ); + + useEffect(() => { + const { current } = internalRef; + if (current) { + resizeObserver.observe(current); + } + return () => { + resizeObserver.disconnect(); + }; + }, [setIsOverflowing, resizeObserver]); + + const getTooltipText = (children: NonNullable) => { + if (typeof children === 'string') { + return children; + } + const html = ReactDOMServer.renderToStaticMarkup(<>{children}); + return html.replace(/(<([^>]+)>)/gi, ''); + }; + + if (isOverflowing) { + return ( + + {childElement(undefined)} + + ); + } else { + return childElement(internalRef); + } +}); + +TruncatedText.displayName = 'TruncatedText'; From 34eb29c3bf6441dfc4baad6d9561530ef24eaafc Mon Sep 17 00:00:00 2001 From: brendamuir <100768211+brendamuir@users.noreply.github.com> Date: Thu, 2 Nov 2023 12:31:47 +0100 Subject: [PATCH 08/30] Adds alerts from panels feature to cloud whats new (#77547) --- docs/sources/whatsnew/whats-new-next/index.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/sources/whatsnew/whats-new-next/index.md b/docs/sources/whatsnew/whats-new-next/index.md index 743f5457d0c..61e4375d39d 100644 --- a/docs/sources/whatsnew/whats-new-next/index.md +++ b/docs/sources/whatsnew/whats-new-next/index.md @@ -229,6 +229,17 @@ The Grafana Assume Role authentication provider lets Grafana Cloud users of the To learn more, refer to the [CloudWatch authentication documentation](/docs/grafana/next/datasources/aws-cloudwatch/aws-authentication). +## Create alerts from panels + + + + +October 23, 2023 + +_Generally available in Grafana Cloud_ + +Create alerts from dashboard panels. You can reuse the panel queries and create alerts based on them. + ## No basic role From 00a596b2e058c6f1b2a375e5df46fecda97e9c36 Mon Sep 17 00:00:00 2001 From: Andres Martinez Gotor Date: Thu, 2 Nov 2023 13:26:16 +0100 Subject: [PATCH 09/30] Chore: Add app URL to the plugin config (#77455) --- go.mod | 2 +- go.sum | 2 ++ pkg/plugins/envvars/envvars.go | 5 +++++ pkg/plugins/envvars/envvars_test.go | 11 +++++++++++ 4 files changed, 19 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 14b37ffc1c7..cf38d15efaf 100644 --- a/go.mod +++ b/go.mod @@ -65,7 +65,7 @@ require ( github.com/grafana/cuetsy v0.1.10 // @grafana/grafana-as-code github.com/grafana/grafana-aws-sdk v0.19.1 // @grafana/aws-datasources github.com/grafana/grafana-azure-sdk-go v1.9.0 // @grafana/backend-platform - github.com/grafana/grafana-plugin-sdk-go v0.187.0 // @grafana/plugins-platform-backend + github.com/grafana/grafana-plugin-sdk-go v0.189.0 // @grafana/plugins-platform-backend github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // @grafana/backend-platform github.com/hashicorp/go-hclog v1.5.0 // @grafana/plugins-platform-backend github.com/hashicorp/go-plugin v1.4.9 // @grafana/plugins-platform-backend diff --git a/go.sum b/go.sum index 9de11b6e870..94188a7f9f9 100644 --- a/go.sum +++ b/go.sum @@ -1841,6 +1841,8 @@ github.com/grafana/grafana-plugin-sdk-go v0.94.0/go.mod h1:3VXz4nCv6wH5SfgB3mlW3 github.com/grafana/grafana-plugin-sdk-go v0.114.0/go.mod h1:D7x3ah+1d4phNXpbnOaxa/osSaZlwh9/ZUnGGzegRbk= github.com/grafana/grafana-plugin-sdk-go v0.187.0 h1:lOwoFbbTs27KqR3F32GvOX9Et3Ek8p8qsFw+SUJtAAM= github.com/grafana/grafana-plugin-sdk-go v0.187.0/go.mod h1:PHK8eQOz3ES28RmImdTHNOTxBZaH6mb/ytJGxk7VVJc= +github.com/grafana/grafana-plugin-sdk-go v0.189.0 h1:30n0dtehLT0Z0DdRfatk1INwXOKfCb9LKaSFl8zBj9g= +github.com/grafana/grafana-plugin-sdk-go v0.189.0/go.mod h1:nctofuR6fyhx3Stbnh5ha6setroeqqBgO0Rj9s4t86o= github.com/grafana/kindsys v0.0.0-20230508162304-452481b63482 h1:1YNoeIhii4UIIQpCPU+EXidnqf449d0C3ZntAEt4KSo= github.com/grafana/kindsys v0.0.0-20230508162304-452481b63482/go.mod h1:GNcfpy5+SY6RVbNGQW264gC0r336Dm+0zgQ5vt6+M8Y= github.com/grafana/prometheus-alertmanager v0.25.1-0.20231027171310-70c52bf65758 h1:ATUhvJSJwzdzhnmzUI92fxVFqyqmcnzJ47wtHTK3LW4= diff --git a/pkg/plugins/envvars/envvars.go b/pkg/plugins/envvars/envvars.go index 57259ca28db..8951b49e46a 100644 --- a/pkg/plugins/envvars/envvars.go +++ b/pkg/plugins/envvars/envvars.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana-aws-sdk/pkg/awsds" "github.com/grafana/grafana-azure-sdk-go/azsettings" + "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/proxy" "github.com/grafana/grafana-plugin-sdk-go/experimental/featuretoggles" @@ -79,6 +80,10 @@ func (s *Service) Get(ctx context.Context, p *plugins.Plugin) []string { func (s *Service) GetConfigMap(ctx context.Context, _ string, _ *auth.ExternalService) map[string]string { m := make(map[string]string) + if s.cfg.GrafanaAppURL != "" { + m[backend.AppURL] = s.cfg.GrafanaAppURL + } + // TODO add support via plugin SDK //if externalService != nil { // m[oauthtokenretriever.AppURL] = s.cfg.GrafanaAppURL diff --git a/pkg/plugins/envvars/envvars_test.go b/pkg/plugins/envvars/envvars_test.go index 46cc8b56e52..cdee809c0b4 100644 --- a/pkg/plugins/envvars/envvars_test.go +++ b/pkg/plugins/envvars/envvars_test.go @@ -500,3 +500,14 @@ func TestService_GetConfigMap_featureToggles(t *testing.T) { } }) } + +func TestService_GetConfigMap_appURL(t *testing.T) { + t.Run("Uses the configured app URL", func(t *testing.T) { + s := &Service{ + cfg: &config.Cfg{ + GrafanaAppURL: "https://myorg.com/", + }, + } + require.Equal(t, map[string]string{"GF_APP_URL": "https://myorg.com/"}, s.GetConfigMap(context.Background(), "", nil)) + }) +} From c73a2bde9cda93c9bec1adf55c47fc8ec2fb1ef8 Mon Sep 17 00:00:00 2001 From: Stephen Yeargin Date: Thu, 2 Nov 2023 08:50:32 -0500 Subject: [PATCH 10/30] Documentation: Update Hubot Integration documentation (#76925) * Update Hubot Integration documentation The script package has improved over the years to allow for direct uploading to Slack, etc. This updates the documentation to reflect that. * Apply suggestions from code review Co-authored-by: lwandz13 <126723338+lwandz13@users.noreply.github.com> --------- Co-authored-by: lwandz13 <126723338+lwandz13@users.noreply.github.com> --- .../tutorials/integrate-hubot/index.md | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/docs/sources/tutorials/integrate-hubot/index.md b/docs/sources/tutorials/integrate-hubot/index.md index d6b769987f1..35299df80b6 100644 --- a/docs/sources/tutorials/integrate-hubot/index.md +++ b/docs/sources/tutorials/integrate-hubot/index.md @@ -25,10 +25,7 @@ Grafana 2.0 shipped with a great feature that enables it to render any graph or No matter what data source you are using, the PNG image of the Graph will look the same as it does in your browser. -This guide will show you how to install and configure the [Hubot-Grafana](https://github.com/stephenyeargin/hubot-grafana) plugin. This plugin allows you to tell hubot to render any dashboard or graph right from a channel in Slack, Hipchat or Basecamp. The bot will respond with an image of the graph and a link that will take you to the graph. - -> _Amazon S3 Required_: The hubot-grafana script will upload the rendered graphs to Amazon S3. This -> is so Hipchat and Slack can show them reliably (they require the image to be publicly available). +This guide shows you how to install and configure the [Hubot-Grafana](https://github.com/stephenyeargin/hubot-grafana) plugin. This plugin allows you to tell Hubot to render any dashboard or graph right from a channel in Slack, Basecamp, or any other supported Hubot adapter. The bot will respond with an image of the graph and a link that will take you to the graph. {{< figure src="/static/img/docs/tutorials/hubot_grafana.png" max-width="800px" >}} @@ -40,7 +37,7 @@ This guide will show you how to install and configure the [Hubot-Grafana](https: Hubot is very easy to install and host. If you do not already have a bot up and running please read the official [Getting Started With Hubot](https://hubot.github.com/docs/) guide. -## Install Hubot-Grafana script +## Install the Hubot-Grafana script In your Hubot project repo install the Grafana plugin using `npm`: @@ -56,18 +53,15 @@ Edit the file external-scripts.json, and add hubot-grafana to the list of plugin ## Configure -The `hubot-grafana` plugin requires a number of environment variables to be set in order to work properly. +The Hubot-Grafana plugin requires two environment variables to be set in order to work properly. ```bash export HUBOT_GRAFANA_HOST=https://play.grafana.org export HUBOT_GRAFANA_API_KEY=abcd01234deadbeef01234 -export HUBOT_GRAFANA_S3_BUCKET=mybucket -export HUBOT_GRAFANA_S3_ACCESS_KEY_ID=ABCDEF123456XYZ -export HUBOT_GRAFANA_S3_SECRET_ACCESS_KEY=aBcD01234dEaDbEef01234 -export HUBOT_GRAFANA_S3_PREFIX=graphs -export HUBOT_GRAFANA_S3_REGION=us-standard ``` +There are [additional environment variables](https://github.com/stephenyeargin/hubot-grafana?tab=readme-ov-file#general-settings) that you can set to control the appearance of the graphs. + ### Grafana server side rendering The hubot plugin will take advantage of the Grafana server side rendering feature that can render any panel on the server using phantomjs. Grafana ships with a phantomjs binary (Linux only). @@ -80,9 +74,9 @@ To verify that this feature works try the `Direct link to rendered image` link i You need to set the environment variable `HUBOT_GRAFANA_API_KEY` to a Grafana API Key. You can add these from the API Keys page which you find in the Organization dropdown. -### Amazon S3 +### Image uploading -The `S3` options are optional but for the images to work properly in services like Slack and Hipchat they need to publicly available. By specifying the `S3` options the hubot-grafana script will publish the rendered panel to `S3` and it will use that URL when it posts to Slack or Hipchat. +There are several approaches to uploading the rendered graphs. If you are using Slack, Rocket.Chat, or Telegram, the adapter's native uploader will take care of sending it through their respective API. If your Hubot is hosted on a platform that doesn't support uploads (such as IRC), you can use the [built-in S3 uploader](https://github.com/stephenyeargin/hubot-grafana/wiki/Amazon-S3-Image-Hosting). Note if you configure S3, it will not use the adapter's upload features. ## Hubot commands From d5f749482a92454c2e7b41d3eb1dc2871d0ead31 Mon Sep 17 00:00:00 2001 From: Andres Martinez Gotor Date: Thu, 2 Nov 2023 14:56:47 +0100 Subject: [PATCH 11/30] Ignore dist folder for core plugin (#77549) --- pkg/plugins/manager/loader/finder/local.go | 10 +++++-- .../manager/loader/finder/local_test.go | 30 +++++++++++++++---- pkg/util/filepath.go | 13 ++++---- 3 files changed, 38 insertions(+), 15 deletions(-) diff --git a/pkg/plugins/manager/loader/finder/local.go b/pkg/plugins/manager/loader/finder/local.go index a46ab30bc70..9428a4bac2b 100644 --- a/pkg/plugins/manager/loader/finder/local.go +++ b/pkg/plugins/manager/loader/finder/local.go @@ -57,7 +57,11 @@ func (l *Local) Find(ctx context.Context, src plugins.PluginSource) ([]*plugins. continue } - paths, err := l.getAbsPluginJSONPaths(path) + followDistFolder := true + if src.PluginClass(ctx) == plugins.ClassCore { + followDistFolder = false + } + paths, err := l.getAbsPluginJSONPaths(path, followDistFolder) if err != nil { return nil, err } @@ -154,7 +158,7 @@ func (l *Local) readPluginJSON(pluginJSONPath string) (plugins.JSONData, error) return plugin, nil } -func (l *Local) getAbsPluginJSONPaths(path string) ([]string, error) { +func (l *Local) getAbsPluginJSONPaths(path string, followDistFolder bool) ([]string, error) { var pluginJSONPaths []string var err error @@ -163,7 +167,7 @@ func (l *Local) getAbsPluginJSONPaths(path string) ([]string, error) { return []string{}, err } - if err = walk(path, true, true, + if err = walk(path, true, true, followDistFolder, func(currentPath string, fi os.FileInfo, err error) error { if err != nil { if errors.Is(err, os.ErrNotExist) { diff --git a/pkg/plugins/manager/loader/finder/local_test.go b/pkg/plugins/manager/loader/finder/local_test.go index 42cefb50a1a..8b2be26565e 100644 --- a/pkg/plugins/manager/loader/finder/local_test.go +++ b/pkg/plugins/manager/loader/finder/local_test.go @@ -274,7 +274,7 @@ func TestFinder_Find(t *testing.T) { func TestFinder_getAbsPluginJSONPaths(t *testing.T) { t.Run("When scanning a folder that doesn't exists shouldn't return an error", func(t *testing.T) { origWalk := walk - walk = func(path string, followSymlinks, detectSymlinkInfiniteLoop bool, walkFn util.WalkFunc) error { + walk = func(path string, followSymlinks, detectSymlinkInfiniteLoop, followDistFolder bool, walkFn util.WalkFunc) error { return walkFn(path, nil, os.ErrNotExist) } t.Cleanup(func() { @@ -282,14 +282,14 @@ func TestFinder_getAbsPluginJSONPaths(t *testing.T) { }) finder := NewLocalFinder(false) - paths, err := finder.getAbsPluginJSONPaths("test") + paths, err := finder.getAbsPluginJSONPaths("test", true) require.NoError(t, err) require.Empty(t, paths) }) t.Run("When scanning a folder that lacks permission shouldn't return an error", func(t *testing.T) { origWalk := walk - walk = func(path string, followSymlinks, detectSymlinkInfiniteLoop bool, walkFn util.WalkFunc) error { + walk = func(path string, followSymlinks, detectSymlinkInfiniteLoop, followDistFolder bool, walkFn util.WalkFunc) error { return walkFn(path, nil, os.ErrPermission) } t.Cleanup(func() { @@ -297,14 +297,14 @@ func TestFinder_getAbsPluginJSONPaths(t *testing.T) { }) finder := NewLocalFinder(false) - paths, err := finder.getAbsPluginJSONPaths("test") + paths, err := finder.getAbsPluginJSONPaths("test", true) require.NoError(t, err) require.Empty(t, paths) }) t.Run("When scanning a folder that returns a non-handled error should return that error", func(t *testing.T) { origWalk := walk - walk = func(path string, followSymlinks, detectSymlinkInfiniteLoop bool, walkFn util.WalkFunc) error { + walk = func(path string, followSymlinks, detectSymlinkInfiniteLoop, followDistFolder bool, walkFn util.WalkFunc) error { return walkFn(path, nil, errors.New("random error")) } t.Cleanup(func() { @@ -312,10 +312,28 @@ func TestFinder_getAbsPluginJSONPaths(t *testing.T) { }) finder := NewLocalFinder(false) - paths, err := finder.getAbsPluginJSONPaths("test") + paths, err := finder.getAbsPluginJSONPaths("test", true) require.Error(t, err) require.Empty(t, paths) }) + + t.Run("should forward if the dist folder should be evaluated", func(t *testing.T) { + origWalk := walk + walk = func(path string, followSymlinks, detectSymlinkInfiniteLoop, followDistFolder bool, walkFn util.WalkFunc) error { + if followDistFolder { + return walkFn(path, nil, errors.New("unexpected followDistFolder")) + } + return walkFn(path, nil, filepath.SkipDir) + } + t.Cleanup(func() { + walk = origWalk + }) + + finder := NewLocalFinder(false) + paths, err := finder.getAbsPluginJSONPaths("test", false) + require.ErrorIs(t, err, filepath.SkipDir) + require.Empty(t, paths) + }) } var fsComparer = cmp.Comparer(func(fs1 plugins.FS, fs2 plugins.FS) bool { diff --git a/pkg/util/filepath.go b/pkg/util/filepath.go index 1034245ac59..527457aadbb 100644 --- a/pkg/util/filepath.go +++ b/pkg/util/filepath.go @@ -21,7 +21,7 @@ type WalkFunc func(resolvedPath string, info os.FileInfo, err error) error // can detect infinite loops while following sym links. // It solves the issue where your WalkFunc needs a path relative to the symbolic link // (resolving links within walkfunc loses the path to the symbolic link for each traversal). -func Walk(path string, followSymlinks bool, detectSymlinkInfiniteLoop bool, walkFn WalkFunc) error { +func Walk(path string, followSymlinks bool, detectSymlinkInfiniteLoop bool, followDistFolder bool, walkFn WalkFunc) error { info, err := os.Lstat(path) if err != nil { return err @@ -34,7 +34,7 @@ func Walk(path string, followSymlinks bool, detectSymlinkInfiniteLoop bool, walk symlinkPathsFollowed = make(map[string]bool, 8) } } - return walk(path, info, resolvedPath, symlinkPathsFollowed, walkFn) + return walk(path, info, resolvedPath, symlinkPathsFollowed, followDistFolder, walkFn) } // walk walks the path. It is a helper/sibling function to Walk. @@ -43,7 +43,7 @@ func Walk(path string, followSymlinks bool, detectSymlinkInfiniteLoop bool, walk // // If resolvedPath is "", then we are not following symbolic links. // If symlinkPathsFollowed is not nil, then we need to detect infinite loop. -func walk(path string, info os.FileInfo, resolvedPath string, symlinkPathsFollowed map[string]bool, walkFn WalkFunc) error { +func walk(path string, info os.FileInfo, resolvedPath string, symlinkPathsFollowed map[string]bool, followDistFolder bool, walkFn WalkFunc) error { if info == nil { return errors.New("walk: Nil FileInfo passed") } @@ -81,7 +81,7 @@ func walk(path string, info os.FileInfo, resolvedPath string, symlinkPathsFollow if err != nil { return err } - return walk(path, info2, path2, symlinkPathsFollowed, walkFn) + return walk(path, info2, path2, symlinkPathsFollowed, followDistFolder, walkFn) } else if info.IsDir() { list, err := os.ReadDir(path) if err != nil { @@ -102,12 +102,13 @@ func walk(path string, info os.FileInfo, resolvedPath string, symlinkPathsFollow subFiles = append(subFiles, subFile{path: path2, resolvedPath: resolvedPath2, fileInfo: fileInfo}) } - if containsDistFolder(subFiles) { + if containsDistFolder(subFiles) && followDistFolder { err := walk( filepath.Join(path, "dist"), info, filepath.Join(resolvedPath, "dist"), symlinkPathsFollowed, + followDistFolder, walkFn) if err != nil { @@ -115,7 +116,7 @@ func walk(path string, info os.FileInfo, resolvedPath string, symlinkPathsFollow } } else { for _, p := range subFiles { - err = walk(p.path, p.fileInfo, p.resolvedPath, symlinkPathsFollowed, walkFn) + err = walk(p.path, p.fileInfo, p.resolvedPath, symlinkPathsFollowed, followDistFolder, walkFn) if err != nil { return err From 9a905b6312b614700dd63183575f5d15935882d8 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Thu, 2 Nov 2023 14:23:19 +0000 Subject: [PATCH 12/30] Navigation: Updates to the docs for admin subsections (#77500) updates to the docs for admin subsections --- docs/sources/administration/api-keys/index.md | 6 +++--- .../enterprise-licensing/_index.md | 4 +++- .../activate-license-on-ecs/index.md | 2 +- .../activate-license-on-eks/index.md | 2 +- .../manage-license-in-aws-marketplace/index.md | 2 +- .../organization-management/index.md | 8 ++++---- .../organization-preferences/index.md | 16 +++++++++++----- .../administration/plugin-management/index.md | 10 +++++----- .../access-control/assign-rbac-roles/index.md | 4 ++-- .../administration/stats-and-license/index.md | 4 ++-- .../administration/team-management/index.md | 12 ++++++------ .../user-management/manage-org-users/index.md | 10 +++++----- .../server-user-management/_index.md | 10 +++++----- .../add-remove-user-to-org/index.md | 4 ++-- .../index.md | 2 +- .../change-user-org-permissions/index.md | 2 +- 16 files changed, 53 insertions(+), 45 deletions(-) diff --git a/docs/sources/administration/api-keys/index.md b/docs/sources/administration/api-keys/index.md index f2e1be18e75..6ea11889724 100644 --- a/docs/sources/administration/api-keys/index.md +++ b/docs/sources/administration/api-keys/index.md @@ -48,7 +48,7 @@ To follow these instructions, you need at least one of the following: To create an API, complete the following steps: 1. Sign in to Grafana. -1. Click **Administration** in the left-side menu and select **API Keys**. +1. Click **Administration** in the left-side menu, **Users and access**, and select **API Keys**. 1. Click **Add API key**. 1. Enter a unique name for the key. 1. In the **Role** field, select one of the following access levels you want to assign to the key. @@ -101,7 +101,7 @@ For more information about permissions, refer to [Roles and permissions]({{< rel To migrate all API keys to service accounts, complete the following steps: -1. Sign in to Grafana, point to **Configuration** (the gear icon), and click **API Keys**. +1. Sign in to Grafana, point to **Administration**, **Users and access**, and click **API Keys**. 1. In the top of the page, find the section which says **Switch from API keys to service accounts** 1. Click **Migrate to service accounts now**. 1. A confirmation window will appear, asking to confirm the migration. Click **Yes, migrate now** if you are willing to continue. @@ -110,7 +110,7 @@ To migrate all API keys to service accounts, complete the following steps: To migrate a single API key to a service account, complete the following steps: 1. Sign in to Grafana. -1. Click **Administration** in the left-side menu and select **API Keys**. +1. Click **Administration** in the left-side menu, **Users and access**, and select **API Keys**. 1. Find the API Key you want to migrate. 1. Click **Migrate to service account**. diff --git a/docs/sources/administration/enterprise-licensing/_index.md b/docs/sources/administration/enterprise-licensing/_index.md index 966e0889d72..fc8e07e7123 100644 --- a/docs/sources/administration/enterprise-licensing/_index.md +++ b/docs/sources/administration/enterprise-licensing/_index.md @@ -52,7 +52,7 @@ There is more than one way to add the license to a Grafana instance: This is the preferred option for single instance installations of Grafana Enterprise. 1. Sign in as a Grafana server administrator. -1. Click **Administration > Stats and license** in the side navigation menu. +1. Click **Administration > General > Stats and license** in the side navigation menu. 1. Click **Upload a new token**. 1. Select your license file, and upload it. @@ -207,6 +207,8 @@ To determine the number of active users: 1. Click **Administration** in the side navigation menu. +1. Click **General**. + 1. Click **Stats and license**. 1. Review the utilization count on the **Utilization** panel. diff --git a/docs/sources/administration/enterprise-licensing/activate-aws-marketplace-license/activate-license-on-ecs/index.md b/docs/sources/administration/enterprise-licensing/activate-aws-marketplace-license/activate-license-on-ecs/index.md index 6f6a35e5eea..fa06cd3aa02 100644 --- a/docs/sources/administration/enterprise-licensing/activate-aws-marketplace-license/activate-license-on-ecs/index.md +++ b/docs/sources/administration/enterprise-licensing/activate-aws-marketplace-license/activate-license-on-ecs/index.md @@ -112,6 +112,6 @@ In this task you configure Grafana Enterprise to validate the license with AWS i ### Task 4: Start or restart Grafana 1. To restart Grafana and activate your license, update the service running Grafana to use the latest revision of the task definition that you created. -1. After you update the service, navigate to your Grafana instance, sign in with Grafana Admin credentials, and navigate to **Administration > Stats and license** to validate that your license is active. +1. After you update the service, navigate to your Grafana instance, sign in with Grafana Admin credentials, and navigate to **Administration > General > Stats and license** to validate that your license is active. For more information about validating that your license is active, refer to [Grafana Enterprise license restrictions]({{< relref "../../#grafana-enterprise-license-restrictions" >}}). diff --git a/docs/sources/administration/enterprise-licensing/activate-aws-marketplace-license/activate-license-on-eks/index.md b/docs/sources/administration/enterprise-licensing/activate-aws-marketplace-license/activate-license-on-eks/index.md index a63c1af461d..49060c0989d 100644 --- a/docs/sources/administration/enterprise-licensing/activate-aws-marketplace-license/activate-license-on-eks/index.md +++ b/docs/sources/administration/enterprise-licensing/activate-aws-marketplace-license/activate-license-on-eks/index.md @@ -123,7 +123,7 @@ To restart Grafana on a Kubernetes cluster, 1. Run the command `kubectl rollout restart deployment my-release`. -1. After you update the service, navigate to your Grafana instance, sign in with Grafana Admin credentials, and navigate to **Administration > Stats and license** to validate that your license is active. +1. After you update the service, navigate to your Grafana instance, sign in with Grafana Admin credentials, and navigate to **Administration > General > Stats and license** to validate that your license is active. For more information about restarting Grafana, refer to [Restart Grafana]({{< relref "../../../../setup-grafana/start-restart-grafana/" >}}). diff --git a/docs/sources/administration/enterprise-licensing/activate-aws-marketplace-license/manage-license-in-aws-marketplace/index.md b/docs/sources/administration/enterprise-licensing/activate-aws-marketplace-license/manage-license-in-aws-marketplace/index.md index 74a1152a1a3..56ba176742d 100644 --- a/docs/sources/administration/enterprise-licensing/activate-aws-marketplace-license/manage-license-in-aws-marketplace/index.md +++ b/docs/sources/administration/enterprise-licensing/activate-aws-marketplace-license/manage-license-in-aws-marketplace/index.md @@ -36,7 +36,7 @@ You can use AWS Marketplace to make the following modifications to your Grafana 1. Sign in to Grafana as a Server Administrator. -1. Click **Administration** in the side navigation menu, and then **Stats and license**. +1. Click **Administration** in the side navigation menu, **General**, and then **Stats and license**. 1. In the **Token** section under **Enterprise License**, click **Renew token**. diff --git a/docs/sources/administration/organization-management/index.md b/docs/sources/administration/organization-management/index.md index 0d93b93e5d6..a332c8110db 100644 --- a/docs/sources/administration/organization-management/index.md +++ b/docs/sources/administration/organization-management/index.md @@ -59,7 +59,7 @@ Complete this task when you want to view a list of existing organizations. **To view a list of organizations:** 1. Sign in to Grafana as a server administrator. -1. Click **Administration** in the left-side menu, and then **Organizations**. +1. Click **Administration** in the left-side menu, **General**, and then **Organizations**. ## Create an organization @@ -72,7 +72,7 @@ Create an organization when you want to isolate dashboards and other resources f **To create an organization:** 1. Sign in to Grafana as a server administrator. -1. Click **Administration** in the left-side menu, and then **Organizations**. +1. Click **Administration** in the left-side menu, **General**, and then **Organizations**. 1. Click **+ New org**. 1. Enter the name of the new organization and click **Create**. @@ -100,7 +100,7 @@ Deleting the organization also deletes all teams and dashboards associated the o **To delete an organization:** 1. Sign in to Grafana as a server administrator. -1. Click **Administration** in the left-side menu, and then **Organizations**. +1. Click **Administration** in the left-side menu, **General**, and then **Organizations**. 1. Click the red **X** next to the organization that you want to delete. 1. Click **Delete**. @@ -115,6 +115,6 @@ Edit an organization when you want to change its name. **To edit an organization:** 1. Sign in to Grafana as a server administrator. -1. Click **Administration** in the left-side menu, and then **Organizations**. +1. Click **Administration** in the left-side menu, **General**, and then **Organizations**. 1. Click the organization you want to edit. 1. Update the organization name and click **Update**. diff --git a/docs/sources/administration/organization-preferences/index.md b/docs/sources/administration/organization-preferences/index.md index 211deadfa17..913f6ca8345 100644 --- a/docs/sources/administration/organization-preferences/index.md +++ b/docs/sources/administration/organization-preferences/index.md @@ -44,6 +44,7 @@ Grafana server administrators and organization administrators can change organiz Follow these instructions if you are a Grafana Server Admin. 1. Click **Administration** in the left-side menu. +1. Click **General**. 1. Click **Organizations**. 1. In the organization list, click the name of the organization that you want to change. 1. In **Name**, enter the new organization name. @@ -54,6 +55,7 @@ Follow these instructions if you are a Grafana Server Admin. If you are an Organization Admin, follow these steps: 1. Click **Administration** in the left-side menu. +1. Click **General**. 1. Click **Default preferences**. 1. In **Organization name**, enter the new name. 1. Click **Update organization name**. @@ -63,7 +65,7 @@ If you are an Organization Admin, follow these steps: Organization administrators and team administrators can change team names and email addresses. To change the team name or email, follow these steps: -1. Click **Administration** in the left-side menu and select **Team**. +1. Click **Administration** in the left-side menu, **Users and access**, and select **Team**. 1. In the team list, click the name of the team that you want to change. 1. Click the **Settings** tab. 1. In the Team details section, you can edit the following: @@ -112,6 +114,7 @@ To see what the current settings are, refer to [View server settings]({{< relref Organization administrators can change the UI theme for all users in an organization. 1. Click **Administration** in the left-side menu. +1. Click **General**. 1. Click **Default preferences**. 1. In the Preferences section, select the UI theme. 1. Click **Save**. @@ -120,7 +123,7 @@ Organization administrators can change the UI theme for all users in an organiza Organization and team administrators can change the UI theme for all users on a team. -1. Click **Administration** in the left-side menu and select **Teams**. +1. Click **Administration** in the left-side menu, **Users and access**, and select **Teams**. 1. Click the team for which you want to change the UI theme. 1. Click the **Settings** tab. 1. In the Preferences section, select the UI theme. @@ -149,6 +152,7 @@ Grafana server administrators can choose a default timezone for all users on the Organization administrators can choose a default timezone for their organization. 1. Click **Administration** in the left-side menu. +1. Click **General**. 1. Click **Default preferences**. 1. Click to select an option in the **Timezone** list. **Default** is either the browser local timezone or the timezone selected at a higher level. 1. Click **Save**. @@ -157,7 +161,7 @@ Organization administrators can choose a default timezone for their organization Organization administrators and team administrators can choose a default timezone for all users on a team. -1. Click **Administration** in the left-side menu and select **Teams**. +1. Click **Administration** in the left-side menu, **Users and access**, and select **Teams**. 1. Click the team for which you want to change the timezone. 1. Click the **Settings** tab. 1. Click to select an option in the **Timezone** list. **Default** is either the browser local timezone or the timezone selected at a higher level. @@ -209,6 +213,7 @@ Organization administrators can choose a default home dashboard for their organi 1. Navigate to the dashboard you want to set as the home dashboard. 1. Click the star next to the dashboard title to mark the dashboard as a favorite if it is not already. 1. Click **Administration** in the left-side menu. +1. Click **General**. 1. Click **Default preferences**. 1. In the **Home Dashboard** field, select the dashboard that you want to use for your home dashboard. Options include all starred dashboards. 1. Click **Save**. @@ -219,7 +224,7 @@ Organization administrators and Team Admins can set a default home dashboard for 1. Navigate to the dashboard you want to set as the home dashboard. 1. Click the star next to the dashboard title to mark the dashboard as a favorite if it is not already. -1. Click **Administration** in the left-side menu and select **Teams**. +1. Click **Administration** in the left-side menu, **Users and access**, and select **Teams**. 1. Click the team for which you want to change the home dashboard. 1. Click the **Settings** tab. 1. In the **Home Dashboard** field, select the dashboard that you want to use for your home dashboard. Options include all starred dashboards. @@ -246,6 +251,7 @@ Grafana server administrators can change the default Grafana UI language for all Organization administrators can change the language for all users in an organization. 1. Click **Administration** in the left-side menu. +1. Click **General**. 1. Click **Default preferences**. 1. In the Preferences section, select an option in the **Language** dropdown. 1. Click **Save**. @@ -254,7 +260,7 @@ Organization administrators can change the language for all users in an organiza Organization and team administrators can set a default language for all users on a team. -1. Click **Administration** in the left-side menu and select **Teams**. +1. Click **Administration** in the left-side menu, **Users and access**, and select **Teams**. 1. Click the team for which you want to change the language. 1. Click the **Settings** tab. 1. In the Preferences section, select an option in the **Language** dropdown. diff --git a/docs/sources/administration/plugin-management/index.md b/docs/sources/administration/plugin-management/index.md index bf84e299280..2fd0da04432 100644 --- a/docs/sources/administration/plugin-management/index.md +++ b/docs/sources/administration/plugin-management/index.md @@ -85,13 +85,13 @@ Before following the steps below, make sure you are logged in as a Grafana admin -Administrators can find the Plugin catalog at **Administration > Plugins**. +Administrators can find the Plugin catalog at **Administration > Plugins and data > Plugins**. ### Browse plugins To browse for available plugins: -1. In Grafana, click **Administration > Plugins** in the side navigation menu to view installed plugins. +1. In Grafana, click **Administration > Plugins and data > Plugins** in the side navigation menu to view installed plugins. 1. Click the **All** filter to browse all available plugins. 1. Click the **Data sources**, **Panels**, or **Applications** buttons to filter by plugin type. @@ -99,7 +99,7 @@ To browse for available plugins: To install a plugin: -1. In Grafana, click **Administration > Plugins** in the side navigation menu to view installed plugins. +1. In Grafana, click **Administration > Plugins and data > Plugins** in the side navigation menu to view installed plugins. 1. Click the **All** filter to browse all available plugins. 1. Browse and find a plugin. 1. Click on the plugin logo. @@ -111,7 +111,7 @@ When the update is complete, you see a confirmation message that the installatio To update a plugin: -1. In Grafana, click **Administration > Plugins** in the side navigation menu to view installed plugins. +1. In Grafana, click **Administration > Plugins and data > Plugins** in the side navigation menu to view installed plugins. 1. Click on the plugin logo. 1. Click **Update**. @@ -121,7 +121,7 @@ When the update is complete, you see a confirmation message that the update was To uninstall a plugin: -1. In Grafana, click **Administration > Plugins** in the side navigation menu to view installed plugins. +1. In Grafana, click **Administration > Plugins and data > Plugins** in the side navigation menu to view installed plugins. 1. Click on the plugin logo. 1. Click **Uninstall**. diff --git a/docs/sources/administration/roles-and-permissions/access-control/assign-rbac-roles/index.md b/docs/sources/administration/roles-and-permissions/access-control/assign-rbac-roles/index.md index a86cc311720..5f442a00dfa 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/assign-rbac-roles/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/assign-rbac-roles/index.md @@ -54,14 +54,14 @@ In both cases, the assignment applies only to the user, team or service account For more information about switching organizations, refer to [Switch organizations]({{< relref "../../../user-management/user-preferences/_index.md#switch-organizations" >}}). -3. In the left-side menu, click **Administration** and then **Users**, **Teams**, or **Service accounts**. +3. In the left-side menu, click **Administration**, **Users and access**, and then **Users**, **Teams**, or **Service accounts**. 4. In the **Role** column, select the fixed role that you want to assign to the user, team, or service account. 5. Click **Update**. **To assign a fixed role as a server administrator:** 1. Sign in to Grafana as a server administrator. -1. Click **Administration** in the left-side menu, and then **Users**. +1. Click **Administration** in the left-side menu, **Users and access**, and then **Users**. 1. Click a user. 1. In the Organizations section, click **Change role**. 1. Select a role within an organization that you want to assign to the user. diff --git a/docs/sources/administration/stats-and-license/index.md b/docs/sources/administration/stats-and-license/index.md index 52255d2e021..1643fd505cf 100644 --- a/docs/sources/administration/stats-and-license/index.md +++ b/docs/sources/administration/stats-and-license/index.md @@ -34,7 +34,7 @@ If you are a Grafana server administrator, use the Settings tab to view the sett ### View server settings 1. Log in to your Grafana server with an account that has the Grafana Admin flag set. -1. Click **Administration** in the left-side menu, and then **Settings**. +1. Click **Administration** in the left-side menu, **General**, and then **Settings**. ### Available settings @@ -51,7 +51,7 @@ If you are a Grafana server admin, then you can view useful statistics about you ### View server stats 1. Log in to your Grafana server with an account that has the Grafana Admin flag set. -1. Click **Administration** in the left-side menu, and then **Stats and license**. +1. Click **Administration** in the left-side menu, **General**, and then **Stats and license**. ### Available stats diff --git a/docs/sources/administration/team-management/index.md b/docs/sources/administration/team-management/index.md index 9ade42e2555..747f55e1971 100644 --- a/docs/sources/administration/team-management/index.md +++ b/docs/sources/administration/team-management/index.md @@ -45,7 +45,7 @@ A user can belong to multiple teams. To create a team: 1. Sign in to Grafana as an organization administrator or team administrator. -1. Click **Administration** in the left-side menu and select **Teams**. +1. Click **Administration** in the left-side menu, **Users and access**, and select **Teams**. 1. Click **New Team**. 1. Complete the fields and click **Create**. 1. Click **Add member**. @@ -59,7 +59,7 @@ Add a team member to an existing team whenever you want to provide access to tea To add a team member: 1. Sign in to Grafana as an organization administrator. -1. Click **Administration** in the left-side menu and select **Teams**. +1. Click **Administration** in the left-side menu, **Users and access**, and select **Teams**. 1. Click the name of the team to which you want to add members, and click **Add member**. 1. Locate and select a user. 1. Choose if you want to add the user as a team Member or an Admin. @@ -72,7 +72,7 @@ Complete this task when you want to add or modify team member permissions. To grant team member permissions: 1. Sign in to Grafana as an organization administrator or a team administrator. -1. Click **Administration** in the left-side menu and select **Teams**. +1. Click **Administration** in the left-side menu, **Users and access**, and select **Teams**. 1. Click the name of the team for which you want to add or modify team member permissions. 1. In the team member list, find and click the user that you want to change. You can use the search field to filter the list if necessary. 1. In the Permission column, select the new user permission level. @@ -84,7 +84,7 @@ You can remove a team member when you no longer want to apply team permissions t To remove a team member: 1. Sign in to Grafana as an organization administrator or team administrator. -1. Click **Administration** in the left-side menu and select **Teams**. +1. Click **Administration** in the left-side menu, **Users and access**, and select **Teams**. 1. Click a team from which you want to remove a user. 1. Click the **X** next to the name of the user. @@ -95,7 +95,7 @@ Delete a team when you no longer need it. This action permanently deletes the te To delete a team: 1. Sign in to Grafana as an organization administrator. -1. Click **Administration** in the left-side menu and select **Teams**. +1. Click **Administration** in the left-side menu, **Users and access**, and select **Teams**. 1. Click the **X** next to the name of the team. 1. Click **Delete**. @@ -106,7 +106,7 @@ See the complete list of teams in your Grafana organization. To view a list of teams: 1. Sign in to Grafana as an organization administrator or a team administrator. -1. Click **Administration** in the left-side menu and select **Teams**. +1. Click **Administration** in the left-side menu, **Users and access**, and select **Teams**. The role you use to sign in to Grafana determines how you see team lists. diff --git a/docs/sources/administration/user-management/manage-org-users/index.md b/docs/sources/administration/user-management/manage-org-users/index.md index c01c8546679..85c8a8bb882 100644 --- a/docs/sources/administration/user-management/manage-org-users/index.md +++ b/docs/sources/administration/user-management/manage-org-users/index.md @@ -38,7 +38,7 @@ You can see a list of users with accounts in your Grafana organization. If neces **To view a list of organization users**: 1. Sign in to Grafana as an organization administrator. -1. Navigate to **Administration > Users**. +1. Navigate to **Administration > Users and access > Users**. {{% admonition type="note" %}} If you have [server administrator]({{< relref "../../roles-and-permissions/#grafana-server-administrators" >}}) permissions, you can also [view a global list of users]({{< relref "../server-user-management#view-a-list-of-users" >}}) in the Server Admin section of Grafana. @@ -59,7 +59,7 @@ Organization roles sync from the authentication provider on user sign-in. To pre **To change the organization role of a user**: 1. Sign in to Grafana as an organization administrator. -1. Navigate to **Administration > Users**. +1. Navigate to **Administration > Users and access > Users**. 1. Find the user account for which you want to change the role. If necessary, use the search field to filter the list. @@ -96,7 +96,7 @@ If you have [server administrator]({{< relref "../../roles-and-permissions/#graf > **Note**: It might be that you are currently in the proper organization and don't need to switch organizations. -1. Navigate to **Administration > Users**. +1. Navigate to **Administration > Users and access > Users**. 1. Click **Organization users**. 1. Click **Invite**. 1. Enter the following information: @@ -127,7 +127,7 @@ The **Pending Invites** button is only visible if there are unanswered invitatio **To manage a pending invitation**: 1. Sign in to Grafana as an organization administrator. -1. Navigate to **Administration > Users**. +1. Navigate to **Administration > Users and access > Users**. 1. Click **Pending Invites**. The **Pending Invites** button appears only when there are unaccepted invitations. @@ -149,7 +149,7 @@ This action does not remove the user account from the Grafana server. **To remove a user from an organization**: 1. Sign in to Grafana as an organization administrator. -1. Navigate to **Administration > Users**. +1. Navigate to **Administration > Users and access > Users**. 1. Find the user account that you want to remove from the organization. Use the search field to filter the list, if necessary. diff --git a/docs/sources/administration/user-management/server-user-management/_index.md b/docs/sources/administration/user-management/server-user-management/_index.md index 50a38bed569..f5d14efcd25 100644 --- a/docs/sources/administration/user-management/server-user-management/_index.md +++ b/docs/sources/administration/user-management/server-user-management/_index.md @@ -39,7 +39,7 @@ You can see a list of users with accounts on your Grafana server. This action mi **To view a list of users**: 1. Sign in to Grafana as a server administrator. -1. Click **Administration** in the left-side menu, and then **Users**. +1. Click **Administration** in the left-side menu, **Users and access**, and then **Users**. {{% admonition type="note" %}} If you have [organization administrator]({{< relref "../../roles-and-permissions/#organization-roles" >}}) permissions and _not_ [server administrator]({{< relref "../../roles-and-permissions/#grafana-server-administrators" >}}) permissions, you can still [view of list of users in a given organization]({{< relref "../manage-org-users/#view-a-list-of-organization-users" >}}). @@ -56,7 +56,7 @@ View user details when you want to see login, and organizations and permissions **To view user details**: 1. Sign in to Grafana as a server administrator. -1. Click **Administration** in the left-side menu, and then **Users**. +1. Click **Administration** in the left-side menu, **Users and access**, and then **Users**. 1. Click a user. A user account contains the following sections. @@ -88,7 +88,7 @@ Edit a user account when you want to modify user login credentials, or delete, d **To edit a user account**: 1. Sign in to Grafana as a server administrator. -1. Click **Administration** in the left-side menu, and then **Users**. +1. Click **Administration** in the left-side menu, **Users and access**, and then **Users**. 1. Click a user. 1. Complete any of the following actions, as necessary. @@ -115,7 +115,7 @@ When you configure advanced authentication using Oauth, SAML, LDAP, or the Auth **To add a user**: 1. Sign in to Grafana as a server administrator. -1. Click **Administration** in the left-side menu, and then **Users**. +1. Click **Administration** in the left-side menu, **Users and access**, and then **Users**. 1. Click **New user**. 1. Complete the fields and click **Create user**. @@ -136,7 +136,7 @@ The force logout action can apply to one device that is logged in to Grafana, or - Ensure you have Grafana server administrator privileges 1. Sign in to Grafana as a server administrator. -1. Click **Administration** in the left-side menu, and then **Users**. +1. Click **Administration** in the left-side menu, **Users and access**, and then **Users**. 1. Click a user. 1. Scroll down to the Sessions section. 1. Perform one of the following actions: diff --git a/docs/sources/administration/user-management/server-user-management/add-remove-user-to-org/index.md b/docs/sources/administration/user-management/server-user-management/add-remove-user-to-org/index.md index 1d8f5a70ac3..04ddd0dc490 100644 --- a/docs/sources/administration/user-management/server-user-management/add-remove-user-to-org/index.md +++ b/docs/sources/administration/user-management/server-user-management/add-remove-user-to-org/index.md @@ -31,7 +31,7 @@ You are required to specify an Admin role for each organization. The first user **To add a user to an organization**: 1. Sign in to Grafana as a server administrator. -1. Click **Administration** in the left-side menu, and then **Users**. +1. Click **Administration** in the left-side menu, **Users and access**, and then **Users**. 1. Click a user. 1. In the Organizations section, click **Add user to organization**. 1. Select an organization and a role. @@ -57,7 +57,7 @@ Remove a user from an organization when they no longer require access to the das **To remove a user from an organization**: 1. Sign in to Grafana as a server administrator. -1. Click **Administration** in the left-side menu, and then **Users**. +1. Click **Administration** in the left-side menu, **Users and access**, and then **Users**. 1. Click a user. 1. In the Organization section, click **Remove from organization** next to the organization from which you want to remove the user. 1. Click **Confirm removal**. diff --git a/docs/sources/administration/user-management/server-user-management/assign-remove-server-admin-privileges/index.md b/docs/sources/administration/user-management/server-user-management/assign-remove-server-admin-privileges/index.md index d02cc9c19a3..c017f104bdd 100644 --- a/docs/sources/administration/user-management/server-user-management/assign-remove-server-admin-privileges/index.md +++ b/docs/sources/administration/user-management/server-user-management/assign-remove-server-admin-privileges/index.md @@ -27,7 +27,7 @@ Server administrators are "super-admins" with full permissions to create, read, **To assign or remove Grafana administrator privileges**: 1. Sign in to Grafana as a server administrator. -1. Click **Administration** in the left-side menu, and then **Users**. +1. Click **Administration** in the left-side menu, **Users and access**, and then **Users**. 1. Click a user. 1. In the Permissions section, next to Grafana Admin, click **Change**. 1. Click **Yes** or **No**, depending on whether or not you want this user to have the Grafana server administrator role. diff --git a/docs/sources/administration/user-management/server-user-management/change-user-org-permissions/index.md b/docs/sources/administration/user-management/server-user-management/change-user-org-permissions/index.md index 3a72c6b15b1..4569a5ac8f2 100644 --- a/docs/sources/administration/user-management/server-user-management/change-user-org-permissions/index.md +++ b/docs/sources/administration/user-management/server-user-management/change-user-org-permissions/index.md @@ -21,7 +21,7 @@ Update organization permissions when you want to enhance or restrict a user's ac **To change a user's organization permissions**: 1. Sign in to Grafana as a server administrator. -1. Click **Administration** in the left-side menu, and then **Users**. +1. Click **Administration** in the left-side menu, **Users and access**, and then **Users**. 1. Click a user. 1. In the Organizations section, click **Change role** for the role you want to change 1. Select another role. From 5892a64e9fe42fdd36be505400dfabcf83d5eedb Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Thu, 2 Nov 2023 15:21:36 +0000 Subject: [PATCH 13/30] CustomScrollbar: Remove chevrons from scroll indicators (#77498) remove chevrons from scroll indicators --- .../CustomScrollbar/ScrollIndicators.tsx | 21 ++----------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/packages/grafana-ui/src/components/CustomScrollbar/ScrollIndicators.tsx b/packages/grafana-ui/src/components/CustomScrollbar/ScrollIndicators.tsx index eed018c3648..b22711457ff 100644 --- a/packages/grafana-ui/src/components/CustomScrollbar/ScrollIndicators.tsx +++ b/packages/grafana-ui/src/components/CustomScrollbar/ScrollIndicators.tsx @@ -1,11 +1,9 @@ import { css, cx } from '@emotion/css'; -import classNames from 'classnames'; import React, { useEffect, useRef, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '../../themes'; -import { Icon } from '../Icon/Icon'; export const ScrollIndicators = ({ children }: React.PropsWithChildren<{}>) => { const [showScrollTopIndicator, setShowTopScrollIndicator] = useState(false); @@ -39,9 +37,7 @@ export const ScrollIndicators = ({ children }: React.PropsWithChildren<{}>) => { className={cx(styles.scrollIndicator, styles.scrollTopIndicator, { [styles.scrollIndicatorVisible]: showScrollTopIndicator, })} - > - - + />
{children} @@ -51,9 +47,7 @@ export const ScrollIndicators = ({ children }: React.PropsWithChildren<{}>) => { className={cx(styles.scrollIndicator, styles.scrollBottomIndicator, { [styles.scrollIndicatorVisible]: showScrollBottomIndicator, })} - > - -
+ /> ); }; @@ -85,16 +79,5 @@ const getStyles = (theme: GrafanaTheme2) => { scrollIndicatorVisible: css({ opacity: 1, }), - scrollIcon: css({ - left: '50%', - position: 'absolute', - transform: 'translateX(-50%)', - }), - scrollTopIcon: css({ - top: 0, - }), - scrollBottomIcon: css({ - bottom: 0, - }), }; }; From e714c9303e73024dbf4689aa6f44d89654dad248 Mon Sep 17 00:00:00 2001 From: Kyle Cunningham Date: Thu, 2 Nov 2023 10:25:48 -0500 Subject: [PATCH 14/30] Timeseries to table transformation: Update Output Changes (#77415) * Break out labels into separate fields * More Updates * Minor test changes * Use 'A' for transformed refId * Make sure tests pass * Add additional test * Prettier * Remove dead comment * Update time field selection options * remove console.log --------- Co-authored-by: Victor Marin --- .../TimeSeriesTableTransformEditor.tsx | 68 ++++---- .../timeSeriesTableTransformer.test.ts | 85 ++++++---- .../timeSeriesTableTransformer.ts | 148 ++++++++++-------- 3 files changed, 171 insertions(+), 130 deletions(-) diff --git a/public/app/features/transformers/timeSeriesTable/TimeSeriesTableTransformEditor.tsx b/public/app/features/transformers/timeSeriesTable/TimeSeriesTableTransformEditor.tsx index 13eefd29ef4..6e996eed2a9 100644 --- a/public/app/features/transformers/timeSeriesTable/TimeSeriesTableTransformEditor.tsx +++ b/public/app/features/transformers/timeSeriesTable/TimeSeriesTableTransformEditor.tsx @@ -7,9 +7,10 @@ import { ReducerID, isReducerID, SelectableValue, - getFieldDisplayName, + Field, + FieldType, } from '@grafana/data'; -import { InlineFieldRow, InlineField, StatsPicker, InlineSwitch, Select } from '@grafana/ui'; +import { InlineFieldRow, InlineField, StatsPicker, Select, InlineLabel } from '@grafana/ui'; import { timeSeriesTableTransformer, @@ -22,19 +23,8 @@ export function TimeSeriesTableTransformEditor({ options, onChange, }: TransformerUIProps) { - const timeFields: Array> = []; const refIdMap = getRefData(input); - // Retrieve time fields - for (const frame of input) { - for (const field of frame.fields) { - if (field.type === 'time') { - const name = getFieldDisplayName(field, frame, input); - timeFields.push({ label: name, value: name }); - } - } - } - const onSelectTimefield = useCallback( (refId: string, value: SelectableValue) => { const val = value?.value !== undefined ? value.value : ''; @@ -65,32 +55,45 @@ export function TimeSeriesTableTransformEditor({ [onChange, options] ); - const onMergeSeriesToggle = useCallback( - (refId: string) => { - const mergeSeries = options[refId]?.mergeSeries !== undefined ? !options[refId].mergeSeries : false; - onChange({ - ...options, - [refId]: { - ...options[refId], - mergeSeries, - }, - }); - }, - [onChange, options] - ); - let configRows = []; for (const refId of Object.keys(refIdMap)) { + // Get time fields for the current refId + const timeFields: Record> = {}; + const timeValues: Array> = []; + + // Get a map of time fields, we map + // by field name and assume that time fields + // in the same query with the same name + // are the same + for (const frame of input) { + if (frame.refId === refId) { + for (const field of frame.fields) { + if (field.type === 'time') { + timeFields[field.name] = field; + } + } + } + } + + for (const timeField of Object.values(timeFields)) { + const { name } = timeField; + timeValues.push({ label: name, value: name }); + } + configRows.push( + + {`Trend #${refId}`} + { readOnly={readOnly} disabled={ !getSupportedTransTypeDetails(watch(`config.transformations.${index}.type`)) - .showExpression + .expressionDetails.show } id={`config.transformations.${fieldVal.id}.expression`} /> @@ -221,7 +223,8 @@ export const TransformationsEditor = (props: Props) => { defaultValue={fieldVal.mapValue} readOnly={readOnly} disabled={ - !getSupportedTransTypeDetails(watch(`config.transformations.${index}.type`)).showMapValue + !getSupportedTransTypeDetails(watch(`config.transformations.${index}.type`)) + .mapValueDetails.show } id={`config.transformations.${fieldVal.id}.mapValue`} /> @@ -266,48 +269,3 @@ export const TransformationsEditor = (props: Props) => { ); }; - -interface SupportedTransformationTypeDetails { - label: string; - value: string; - description?: string; - showExpression: boolean; - showMapValue: boolean; - requireExpression?: boolean; -} - -function getSupportedTransTypeDetails(transType: SupportedTransformationType): SupportedTransformationTypeDetails { - switch (transType) { - case SupportedTransformationType.Logfmt: - return { - label: 'Logfmt', - value: SupportedTransformationType.Logfmt, - description: 'Parse provided field with logfmt to get variables', - showExpression: false, - showMapValue: false, - }; - case SupportedTransformationType.Regex: - return { - label: 'Regular expression', - value: SupportedTransformationType.Regex, - description: - 'Field will be parsed with regex. Use named capture groups to return multiple variables, or a single unnamed capture group to add variable to named map value.', - showExpression: true, - showMapValue: true, - requireExpression: true, - }; - default: - return { label: transType, value: transType, showExpression: false, showMapValue: false }; - } -} - -const getTransformOptions = () => { - return Object.values(SupportedTransformationType).map((transformationType) => { - const transType = getSupportedTransTypeDetails(transformationType); - return { - label: transType.label, - value: transType.value, - description: transType.description, - }; - }); -}; diff --git a/public/app/features/correlations/Forms/types.ts b/public/app/features/correlations/Forms/types.ts index d3e10e581c4..747ef8f97ba 100644 --- a/public/app/features/correlations/Forms/types.ts +++ b/public/app/features/correlations/Forms/types.ts @@ -17,3 +17,67 @@ export type TransformationDTO = { expression?: string; mapValue?: string; }; + +export interface TransformationFieldDetails { + show: boolean; + required?: boolean; + helpText?: string; +} + +interface SupportedTransformationTypeDetails { + label: string; + value: SupportedTransformationType; + description?: string; + expressionDetails: TransformationFieldDetails; + mapValueDetails: TransformationFieldDetails; +} + +export function getSupportedTransTypeDetails( + transType: SupportedTransformationType +): SupportedTransformationTypeDetails { + switch (transType) { + case SupportedTransformationType.Logfmt: + return { + label: 'Logfmt', + value: SupportedTransformationType.Logfmt, + description: 'Parse provided field with logfmt to get variables', + expressionDetails: { show: false }, + mapValueDetails: { show: false }, + }; + case SupportedTransformationType.Regex: + return { + label: 'Regular expression', + value: SupportedTransformationType.Regex, + description: + 'Field will be parsed with regex. Use named capture groups to return multiple variables, or a single unnamed capture group to add variable to named map value. Regex is case insensitive.', + expressionDetails: { + show: true, + required: true, + helpText: 'Use capture groups to extract a portion of the field.', + }, + mapValueDetails: { + show: true, + required: false, + helpText: 'Defines the name of the variable if the capture group is not named.', + }, + }; + default: + return { + label: transType, + value: transType, + expressionDetails: { show: false }, + mapValueDetails: { show: false }, + }; + } +} + +export const getTransformOptions = () => { + return Object.values(SupportedTransformationType).map((transformationType) => { + const transType = getSupportedTransTypeDetails(transformationType); + return { + label: transType.label, + value: transType.value, + description: transType.description, + }; + }); +}; diff --git a/public/app/features/correlations/utils.ts b/public/app/features/correlations/utils.ts index cba91591e92..14abefccc0d 100644 --- a/public/app/features/correlations/utils.ts +++ b/public/app/features/correlations/utils.ts @@ -1,7 +1,8 @@ import { lastValueFrom } from 'rxjs'; import { DataFrame, DataLinkConfigOrigin } from '@grafana/data'; -import { getBackendSrv } from '@grafana/runtime'; +import { getBackendSrv, getDataSourceSrv } from '@grafana/runtime'; +import { ExploreItemState } from 'app/types'; import { formatValueName } from '../explore/PrometheusListView/ItemLabels'; @@ -90,3 +91,19 @@ export const createCorrelation = async ( ): Promise => { return getBackendSrv().post(`/api/datasources/uid/${sourceUID}/correlations`, correlation); }; + +const getDSInstanceForPane = async (pane: ExploreItemState) => { + if (pane.datasourceInstance?.meta.mixed) { + return await getDataSourceSrv().get(pane.queries[0].datasource); + } else { + return pane.datasourceInstance; + } +}; + +export const generateDefaultLabel = async (sourcePane: ExploreItemState, targetPane: ExploreItemState) => { + return Promise.all([getDSInstanceForPane(sourcePane), getDSInstanceForPane(targetPane)]).then((dsInstances) => { + return dsInstances[0]?.name !== undefined && dsInstances[1]?.name !== undefined + ? `${dsInstances[0]?.name} to ${dsInstances[1]?.name}` + : ''; + }); +}; diff --git a/public/app/features/explore/CorrelationEditorModeBar.tsx b/public/app/features/explore/CorrelationEditorModeBar.tsx index e4f4a761b9e..c714b951c07 100644 --- a/public/app/features/explore/CorrelationEditorModeBar.tsx +++ b/public/app/features/explore/CorrelationEditorModeBar.tsx @@ -9,36 +9,84 @@ import { Button, HorizontalGroup, Icon, Tooltip, useStyles2 } from '@grafana/ui' import { CORRELATION_EDITOR_POST_CONFIRM_ACTION, ExploreItemState, useDispatch, useSelector } from 'app/types'; import { CorrelationUnsavedChangesModal } from './CorrelationUnsavedChangesModal'; +import { showModalMessage } from './correlationEditLogic'; import { saveCurrentCorrelation } from './state/correlations'; import { changeDatasource } from './state/datasource'; import { changeCorrelationHelperData } from './state/explorePane'; import { changeCorrelationEditorDetails, splitClose } from './state/main'; import { runQueries } from './state/query'; -import { selectCorrelationDetails } from './state/selectors'; +import { selectCorrelationDetails, selectIsHelperShowing } from './state/selectors'; export const CorrelationEditorModeBar = ({ panes }: { panes: Array<[string, ExploreItemState]> }) => { const dispatch = useDispatch(); const styles = useStyles2(getStyles); const correlationDetails = useSelector(selectCorrelationDetails); - const [showSavePrompt, setShowSavePrompt] = useState(false); + const isHelperShowing = useSelector(selectIsHelperShowing); + const [saveMessage, setSaveMessage] = useState(undefined); // undefined means do not show // handle refreshing and closing the tab - useBeforeUnload(correlationDetails?.dirty || false, 'Save correlation?'); + useBeforeUnload(correlationDetails?.correlationDirty || false, 'Save correlation?'); + useBeforeUnload( + (!correlationDetails?.correlationDirty && correlationDetails?.queryEditorDirty) || false, + 'The query editor was changed. Save correlation before continuing?' + ); - // handle exiting (staying within explore) + // decide if we are displaying prompt, perform action if not useEffect(() => { - if (correlationDetails?.isExiting && correlationDetails?.dirty) { - setShowSavePrompt(true); - } else if (correlationDetails?.isExiting && !correlationDetails?.dirty) { - dispatch( - changeCorrelationEditorDetails({ - editorMode: false, - dirty: false, - isExiting: false, - }) - ); + if (correlationDetails?.isExiting) { + const { correlationDirty, queryEditorDirty } = correlationDetails; + let isActionLeft = undefined; + let action = undefined; + if (correlationDetails.postConfirmAction) { + isActionLeft = correlationDetails.postConfirmAction.isActionLeft; + action = correlationDetails.postConfirmAction.action; + } else { + // closing the editor only + action = CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_EDITOR; + isActionLeft = false; + } + + const modalMessage = showModalMessage(action, isActionLeft, correlationDirty, queryEditorDirty); + if (modalMessage !== undefined) { + setSaveMessage(modalMessage); + } else { + // if no prompt, perform action + if ( + action === CORRELATION_EDITOR_POST_CONFIRM_ACTION.CHANGE_DATASOURCE && + correlationDetails.postConfirmAction + ) { + const { exploreId, changeDatasourceUid } = correlationDetails?.postConfirmAction; + if (exploreId && changeDatasourceUid) { + dispatch(changeDatasource(exploreId, changeDatasourceUid, { importQueries: true })); + dispatch( + changeCorrelationEditorDetails({ + isExiting: false, + }) + ); + } + } else if ( + action === CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_PANE && + correlationDetails.postConfirmAction + ) { + const { exploreId } = correlationDetails?.postConfirmAction; + if (exploreId !== undefined) { + dispatch(splitClose(exploreId)); + dispatch( + changeCorrelationEditorDetails({ + isExiting: false, + }) + ); + } + } else if (action === CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_EDITOR) { + dispatch( + changeCorrelationEditorDetails({ + editorMode: false, + }) + ); + } + } } - }, [correlationDetails?.dirty, correlationDetails?.isExiting, dispatch]); + }, [correlationDetails, dispatch, isHelperShowing]); // clear data when unmounted useUnmount(() => { @@ -46,7 +94,7 @@ export const CorrelationEditorModeBar = ({ panes }: { panes: Array<[string, Expl changeCorrelationEditorDetails({ editorMode: false, isExiting: false, - dirty: false, + correlationDirty: false, label: undefined, description: undefined, canSave: false, @@ -64,15 +112,12 @@ export const CorrelationEditorModeBar = ({ panes }: { panes: Array<[string, Expl }); }); - const closePaneAndReset = (exploreId: string) => { - setShowSavePrompt(false); - dispatch(splitClose(exploreId)); - reportInteraction('grafana_explore_split_view_closed'); + const resetEditor = () => { dispatch( changeCorrelationEditorDetails({ editorMode: true, isExiting: false, - dirty: false, + correlationDirty: false, label: undefined, description: undefined, canSave: false, @@ -90,43 +135,39 @@ export const CorrelationEditorModeBar = ({ panes }: { panes: Array<[string, Expl }); }; - const changeDatasourceAndReset = (exploreId: string, datasourceUid: string) => { - setShowSavePrompt(false); - dispatch(changeDatasource(exploreId, datasourceUid, { importQueries: true })); - dispatch( - changeCorrelationEditorDetails({ - editorMode: true, - isExiting: false, - dirty: false, - label: undefined, - description: undefined, - canSave: false, - }) - ); - panes.forEach((pane) => { - dispatch( - changeCorrelationHelperData({ - exploreId: pane[0], - correlationEditorHelperData: undefined, - }) - ); - }); + const closePane = (exploreId: string) => { + setSaveMessage(undefined); + dispatch(splitClose(exploreId)); + reportInteraction('grafana_explore_split_view_closed'); }; - const saveCorrelation = (skipPostConfirmAction: boolean) => { - dispatch(saveCurrentCorrelation(correlationDetails?.label, correlationDetails?.description)); + const changeDatasourcePostAction = (exploreId: string, datasourceUid: string) => { + setSaveMessage(undefined); + dispatch(changeDatasource(exploreId, datasourceUid, { importQueries: true })); + }; + + const saveCorrelationPostAction = (skipPostConfirmAction: boolean) => { + dispatch( + saveCurrentCorrelation( + correlationDetails?.label, + correlationDetails?.description, + correlationDetails?.transformations + ) + ); if (!skipPostConfirmAction && correlationDetails?.postConfirmAction !== undefined) { const { exploreId, action, changeDatasourceUid } = correlationDetails?.postConfirmAction; if (action === CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_PANE) { - closePaneAndReset(exploreId); + closePane(exploreId); + resetEditor(); } else if ( action === CORRELATION_EDITOR_POST_CONFIRM_ACTION.CHANGE_DATASOURCE && changeDatasourceUid !== undefined ) { - changeDatasourceAndReset(exploreId, changeDatasourceUid); + changeDatasource(exploreId, changeDatasourceUid); + resetEditor(); } } else { - dispatch(changeCorrelationEditorDetails({ editorMode: false, dirty: false, isExiting: false })); + dispatch(changeCorrelationEditorDetails({ editorMode: false, correlationDirty: false, isExiting: false })); } }; @@ -138,7 +179,7 @@ export const CorrelationEditorModeBar = ({ panes }: { panes: Array<[string, Expl if ( location.pathname !== '/explore' && (correlationDetails?.editorMode || false) && - (correlationDetails?.dirty || false) + (correlationDetails?.correlationDirty || false) ) { return 'You have unsaved correlation data. Continue?'; } else { @@ -147,19 +188,20 @@ export const CorrelationEditorModeBar = ({ panes }: { panes: Array<[string, Expl }} /> - {showSavePrompt && ( + {saveMessage !== undefined && ( { if (correlationDetails?.postConfirmAction !== undefined) { const { exploreId, action, changeDatasourceUid } = correlationDetails?.postConfirmAction; if (action === CORRELATION_EDITOR_POST_CONFIRM_ACTION.CLOSE_PANE) { - closePaneAndReset(exploreId); + closePane(exploreId); } else if ( action === CORRELATION_EDITOR_POST_CONFIRM_ACTION.CHANGE_DATASOURCE && changeDatasourceUid !== undefined ) { - changeDatasourceAndReset(exploreId, changeDatasourceUid); + changeDatasourcePostAction(exploreId, changeDatasourceUid); } + dispatch(changeCorrelationEditorDetails({ isExiting: false })); } else { // exit correlations mode // if we are discarding the in progress correlation, reset everything @@ -167,7 +209,7 @@ export const CorrelationEditorModeBar = ({ panes }: { panes: Array<[string, Expl dispatch( changeCorrelationEditorDetails({ editorMode: false, - dirty: false, + correlationDirty: false, isExiting: false, }) ); @@ -176,11 +218,12 @@ export const CorrelationEditorModeBar = ({ panes }: { panes: Array<[string, Expl onCancel={() => { // if we are cancelling the exit, set the editor mode back to true and hide the prompt dispatch(changeCorrelationEditorDetails({ isExiting: false })); - setShowSavePrompt(false); + setSaveMessage(undefined); }} onSave={() => { - saveCorrelation(false); + saveCorrelationPostAction(false); }} + message={saveMessage} /> )}
@@ -194,7 +237,7 @@ export const CorrelationEditorModeBar = ({ panes }: { panes: Array<[string, Expl fill="outline" className={correlationDetails?.canSave ? styles.buttonColor : styles.disabledButtonColor} onClick={() => { - saveCorrelation(true); + saveCorrelationPostAction(true); }} > Save diff --git a/public/app/features/explore/CorrelationHelper.tsx b/public/app/features/explore/CorrelationHelper.tsx index fecf3d03af5..c0b704c3502 100644 --- a/public/app/features/explore/CorrelationHelper.tsx +++ b/public/app/features/explore/CorrelationHelper.tsx @@ -1,14 +1,35 @@ +import { css } from '@emotion/css'; import React, { useState, useEffect, useId } from 'react'; import { useForm } from 'react-hook-form'; +import { useAsync } from 'react-use'; -import { ExploreCorrelationHelperData } from '@grafana/data'; -import { Collapse, Alert, Field, Input } from '@grafana/ui'; +import { DataLinkTransformationConfig, ExploreCorrelationHelperData, GrafanaTheme2 } from '@grafana/data'; +import { + Collapse, + Alert, + Field, + Input, + Button, + Card, + IconButton, + useStyles2, + DeleteButton, + Tooltip, + Icon, + Stack, +} from '@grafana/ui'; import { useDispatch, useSelector } from 'app/types'; +import { getTransformationVars } from '../correlations/transformations'; +import { generateDefaultLabel } from '../correlations/utils'; + +import { CorrelationTransformationAddModal } from './CorrelationTransformationAddModal'; +import { changeCorrelationHelperData } from './state/explorePane'; import { changeCorrelationEditorDetails } from './state/main'; -import { selectCorrelationDetails } from './state/selectors'; +import { selectCorrelationDetails, selectPanes } from './state/selectors'; interface Props { + exploreId: string; correlations: ExploreCorrelationHelperData; } @@ -17,60 +38,242 @@ interface FormValues { description: string; } -export const CorrelationHelper = ({ correlations }: Props) => { +export const CorrelationHelper = ({ exploreId, correlations }: Props) => { const dispatch = useDispatch(); - const { register, watch } = useForm(); - const [isOpen, setIsOpen] = useState(false); + const styles = useStyles2(getStyles); + const panes = useSelector(selectPanes); + const panesVals = Object.values(panes); + const { value: defaultLabel, loading: loadingLabel } = useAsync( + async () => await generateDefaultLabel(panesVals[0]!, panesVals[1]!), + [ + panesVals[0]?.datasourceInstance, + panesVals[0]?.queries[0].datasource, + panesVals[1]?.datasourceInstance, + panesVals[1]?.queries[0].datasource, + ] + ); + + const { register, watch, getValues, setValue } = useForm(); + const [isLabelDescOpen, setIsLabelDescOpen] = useState(false); + const [isTransformOpen, setIsTransformOpen] = useState(false); + const [showTransformationAddModal, setShowTransformationAddModal] = useState(false); + const [transformations, setTransformations] = useState([]); + const [transformationIdxToEdit, setTransformationIdxToEdit] = useState(undefined); const correlationDetails = useSelector(selectCorrelationDetails); const id = useId(); - useEffect(() => { - const subscription = watch((value) => { - let dirty = false; - - if (!correlationDetails?.dirty && (value.label !== '' || value.description !== '')) { - dirty = true; - } else if (correlationDetails?.dirty && value.label.trim() === '' && value.description.trim() === '') { - dirty = false; - } - dispatch(changeCorrelationEditorDetails({ label: value.label, description: value.description, dirty: dirty })); - }); - return () => subscription.unsubscribe(); - }, [correlationDetails?.dirty, dispatch, watch]); - // only fire once on mount to allow save button to enable / disable when unmounted useEffect(() => { dispatch(changeCorrelationEditorDetails({ canSave: true })); - return () => { dispatch(changeCorrelationEditorDetails({ canSave: false })); }; }, [dispatch]); + useEffect(() => { + if ( + !loadingLabel && + defaultLabel !== undefined && + !correlationDetails?.correlationDirty && + getValues('label') !== '' + ) { + setValue('label', defaultLabel); + } + }, [correlationDetails?.correlationDirty, defaultLabel, getValues, loadingLabel, setValue]); + + useEffect(() => { + const subscription = watch((value) => { + let dirty = correlationDetails?.correlationDirty || false; + + if (!dirty && (value.label !== defaultLabel || value.description !== '')) { + dirty = true; + } else if (dirty && value.label === defaultLabel && value.description.trim() === '') { + dirty = false; + } + dispatch( + changeCorrelationEditorDetails({ label: value.label, description: value.description, correlationDirty: dirty }) + ); + }); + return () => subscription.unsubscribe(); + }, [correlationDetails?.correlationDirty, defaultLabel, dispatch, watch]); + + useEffect(() => { + const dirty = + !correlationDetails?.correlationDirty && transformations.length > 0 ? true : correlationDetails?.correlationDirty; + dispatch(changeCorrelationEditorDetails({ transformations: transformations, correlationDirty: dirty })); + let transVarRecords: Record = {}; + transformations.forEach((transformation) => { + const transformationVars = getTransformationVars( + { + type: transformation.type, + expression: transformation.expression, + mapValue: transformation.mapValue, + }, + correlations.vars[transformation.field!], + transformation.field! + ); + + Object.keys(transformationVars).forEach((key) => { + transVarRecords[key] = transformationVars[key]?.value; + }); + }); + + dispatch( + changeCorrelationHelperData({ + exploreId: exploreId, + correlationEditorHelperData: { + resultField: correlations.resultField, + origVars: correlations.origVars, + vars: { ...correlations.origVars, ...transVarRecords }, + }, + }) + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dispatch, transformations]); + return ( - - The correlation link will appear by the {correlations.resultField} field. You can use the following - variables to set up your correlations: -
-        {Object.entries(correlations.vars).map((entry) => {
-          return `\$\{${entry[0]}\} = ${entry[1]}\n`;
-        })}
-      
- { - setIsOpen(!isOpen); - }} - label="Label/Description" - > - - - - - - - -
+ <> + {showTransformationAddModal && ( + { + setTransformationIdxToEdit(undefined); + setShowTransformationAddModal(false); + }} + onSave={(transformation: DataLinkTransformationConfig) => { + if (transformationIdxToEdit !== undefined) { + const editTransformations = [...transformations]; + editTransformations[transformationIdxToEdit] = transformation; + setTransformations(editTransformations); + setTransformationIdxToEdit(undefined); + } else { + setTransformations([...transformations, transformation]); + } + setShowTransformationAddModal(false); + }} + fieldList={correlations.vars} + transformationToEdit={ + transformationIdxToEdit !== undefined ? transformations[transformationIdxToEdit] : undefined + } + /> + )} + + The correlation link will appear by the {correlations.resultField} field. You can use the following + variables to set up your correlations: +
+          {Object.entries(correlations.vars).map((entry) => {
+            return `\$\{${entry[0]}\} = ${entry[1]}\n`;
+          })}
+        
+ { + setIsLabelDescOpen(!isLabelDescOpen); + }} + label={ + + Label / Description + {!isLabelDescOpen && !loadingLabel && ( + {`Label: ${getValues('label') || defaultLabel}`} + )} + + } + > + + { + if (getValues('label') === '' && defaultLabel !== undefined) { + setValue('label', defaultLabel); + } + }} + /> + + + + + + { + setIsTransformOpen(!isTransformOpen); + }} + label={ + + Transformations + + + + + } + > + + {transformations.map((transformation, i) => { + const { type, field, expression, mapValue } = transformation; + const detailsString = [ + (mapValue ?? '').length > 0 ? `Variable name: ${mapValue}` : undefined, + (expression ?? '').length > 0 ? ( + <> + Expression: {expression} + + ) : undefined, + ].filter((val) => val); + return ( + + + {field}: {type} + + {detailsString.length > 0 && ( + {detailsString} + )} + + { + setTransformationIdxToEdit(i); + setShowTransformationAddModal(true); + }} + /> + setTransformations(transformations.filter((_, idx) => i !== idx))} + closeOnConfirm + /> + + + ); + })} + +
+ ); }; + +const getStyles = (theme: GrafanaTheme2) => { + return { + labelCollapseDetails: css({ + marginLeft: theme.spacing(2), + ...theme.typography['bodySmall'], + fontStyle: 'italic', + }), + transformationAction: css({ + marginBottom: theme.spacing(2), + }), + transformationMeta: css({ + alignItems: 'baseline', + }), + }; +}; diff --git a/public/app/features/explore/CorrelationTransformationAddModal.tsx b/public/app/features/explore/CorrelationTransformationAddModal.tsx new file mode 100644 index 00000000000..63388852e3e --- /dev/null +++ b/public/app/features/explore/CorrelationTransformationAddModal.tsx @@ -0,0 +1,240 @@ +import { css } from '@emotion/css'; +import React, { useId, useState, useMemo, useEffect } from 'react'; +import Highlighter from 'react-highlight-words'; +import { useForm } from 'react-hook-form'; + +import { DataLinkTransformationConfig, ScopedVars } from '@grafana/data'; +import { Button, Field, Icon, Input, InputControl, Label, Modal, Select, Tooltip, Stack } from '@grafana/ui'; + +import { + getSupportedTransTypeDetails, + getTransformOptions, + TransformationFieldDetails, +} from '../correlations/Forms/types'; +import { getTransformationVars } from '../correlations/transformations'; + +interface CorrelationTransformationAddModalProps { + onCancel: () => void; + onSave: (transformation: DataLinkTransformationConfig) => void; + fieldList: Record; + transformationToEdit?: DataLinkTransformationConfig; +} + +interface ShowFormFields { + expressionDetails: TransformationFieldDetails; + mapValueDetails: TransformationFieldDetails; +} + +const LabelWithTooltip = ({ label, tooltipText }: { label: string; tooltipText: string }) => ( + + + + + + +); + +export const CorrelationTransformationAddModal = ({ + onSave, + onCancel, + fieldList, + transformationToEdit, +}: CorrelationTransformationAddModalProps) => { + const [exampleValue, setExampleValue] = useState(undefined); + const [transformationVars, setTransformationVars] = useState({}); + const [formFieldsVis, setFormFieldsVis] = useState({ + mapValueDetails: { show: false }, + expressionDetails: { show: false }, + }); + const [isExpValid, setIsExpValid] = useState(false); // keep the highlighter from erroring on bad expressions + const [validToSave, setValidToSave] = useState(false); + const { getValues, control, register, watch } = useForm({ + defaultValues: useMemo(() => { + if (transformationToEdit) { + const exampleVal = fieldList[transformationToEdit?.field!]; + setExampleValue(exampleVal); + if (transformationToEdit?.expression) { + setIsExpValid(true); + } + const transformationTypeDetails = getSupportedTransTypeDetails(transformationToEdit?.type!); + setFormFieldsVis({ + mapValueDetails: transformationTypeDetails.mapValueDetails, + expressionDetails: transformationTypeDetails.expressionDetails, + }); + + const transformationVars = getTransformationVars( + { + type: transformationToEdit?.type!, + expression: transformationToEdit?.expression, + mapValue: transformationToEdit?.mapValue, + }, + exampleVal || '', + transformationToEdit?.field! + ); + setTransformationVars({ ...transformationVars }); + setValidToSave(true); + return { + type: transformationToEdit?.type, + field: transformationToEdit?.field, + mapValue: transformationToEdit?.mapValue, + expression: transformationToEdit?.expression, + }; + } else { + return undefined; + } + }, [fieldList, transformationToEdit]), + }); + const id = useId(); + + useEffect(() => { + const subscription = watch((formValues) => { + const expression = formValues.expression; + let isExpressionValid = false; + if (expression !== undefined) { + isExpressionValid = true; + try { + new RegExp(expression); + } catch (e) { + isExpressionValid = false; + } + } else { + isExpressionValid = !formFieldsVis.expressionDetails.show; + } + setIsExpValid(isExpressionValid); + const transformationVars = getTransformationVars( + { + type: formValues.type, + expression: isExpressionValid ? expression : '', + mapValue: formValues.mapValue, + }, + fieldList[formValues.field!] || '', + formValues.field! + ); + + const transKeys = Object.keys(transformationVars); + setTransformationVars(transKeys.length > 0 ? { ...transformationVars } : {}); + + if (transKeys.length === 0 || !isExpressionValid) { + setValidToSave(false); + } else { + setValidToSave(true); + } + }); + return () => subscription.unsubscribe(); + }, [fieldList, formFieldsVis.expressionDetails.show, watch]); + + return ( + +

+ A transformation extracts variables out of a single field. These variables will be available along with your + field variables. +

+ + ( + { + onChange(value.value); + const transformationTypeDetails = getSupportedTransTypeDetails(value.value!); + setFormFieldsVis({ + mapValueDetails: transformationTypeDetails.mapValueDetails, + expressionDetails: transformationTypeDetails.expressionDetails, + }); + }} + options={getTransformOptions()} + aria-label="type" + /> + )} + name={`type` as const} + /> + + {formFieldsVis.expressionDetails.show && ( + + ) : ( + 'Expression' + ) + } + htmlFor={`${id}-expression`} + required={formFieldsVis.expressionDetails.required} + > + + + )} + {formFieldsVis.mapValueDetails.show && ( + + ) : ( + 'Variable Name' + ) + } + htmlFor={`${id}-mapValue`} + > + + + )} + {Object.entries(transformationVars).length > 0 && ( + <> + This transformation will add the following variables: +
+                {Object.entries(transformationVars).map((entry) => {
+                  return `\$\{${entry[0]}\} = ${entry[1]?.value}\n`;
+                })}
+              
+ + )} + + )} + + + + +
+ ); +}; diff --git a/public/app/features/explore/CorrelationUnsavedChangesModal.tsx b/public/app/features/explore/CorrelationUnsavedChangesModal.tsx index 19100150fd9..45883d7a87b 100644 --- a/public/app/features/explore/CorrelationUnsavedChangesModal.tsx +++ b/public/app/features/explore/CorrelationUnsavedChangesModal.tsx @@ -4,27 +4,28 @@ import React from 'react'; import { Button, Modal } from '@grafana/ui'; interface UnsavedChangesModalProps { + message: string; onDiscard: () => void; onCancel: () => void; onSave: () => void; } -export const CorrelationUnsavedChangesModal = ({ onSave, onDiscard, onCancel }: UnsavedChangesModalProps) => { +export const CorrelationUnsavedChangesModal = ({ onSave, onDiscard, onCancel, message }: UnsavedChangesModalProps) => { return ( -
Do you want to save changes to this Correlation?
+
{message}