From d974e5f25a0f1fcbde565d38c530d25bcfea441b Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Tue, 4 Apr 2023 20:52:20 -0700 Subject: [PATCH] TrendPanel: Add new trend panel (Alpha) (#65740) --- .betterer.results | 4 + .github/CODEOWNERS | 1 + .../uPlot/config/UPlotScaleBuilder.ts | 5 + .../manager/manager_integration_test.go | 1 + pkg/plugins/pfs/corelist/corelist_load_gen.go | 1 + pkg/plugins/plugindef/plugindef.cue | 2 +- .../app/features/plugins/built_in_plugins.ts | 2 + .../app/plugins/panel/timeseries/module.tsx | 5 +- public/app/plugins/panel/timeseries/utils.ts | 47 ++++-- public/app/plugins/panel/trend/TrendPanel.tsx | 137 ++++++++++++++++++ public/app/plugins/panel/trend/img/trend.svg | 9 ++ public/app/plugins/panel/trend/module.tsx | 30 ++++ public/app/plugins/panel/trend/panelcfg.cue | 42 ++++++ .../app/plugins/panel/trend/panelcfg.gen.ts | 27 ++++ public/app/plugins/panel/trend/plugin.json | 19 +++ public/app/plugins/panel/trend/suggestions.ts | 40 +++++ 16 files changed, 354 insertions(+), 18 deletions(-) create mode 100644 public/app/plugins/panel/trend/TrendPanel.tsx create mode 100644 public/app/plugins/panel/trend/img/trend.svg create mode 100644 public/app/plugins/panel/trend/module.tsx create mode 100644 public/app/plugins/panel/trend/panelcfg.cue create mode 100644 public/app/plugins/panel/trend/panelcfg.gen.ts create mode 100644 public/app/plugins/panel/trend/plugin.json create mode 100644 public/app/plugins/panel/trend/suggestions.ts diff --git a/.betterer.results b/.betterer.results index 50a78b99fb8..91f06e90952 100644 --- a/.betterer.results +++ b/.betterer.results @@ -6009,6 +6009,10 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], + "public/app/plugins/panel/trend/suggestions.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] + ], "public/app/plugins/panel/xychart/AutoEditor.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 11503dd8765..59d3c75414e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -407,6 +407,7 @@ lerna.json @grafana/frontend-ops /public/app/plugins/panel/table/ @grafana/grafana-bi-squad /public/app/plugins/panel/table-old/ @grafana/grafana-bi-squad /public/app/plugins/panel/timeseries/ @grafana/dataviz-squad +/public/app/plugins/panel/trend/ @grafana/dataviz-squad /public/app/plugins/panel/geomap/ @grafana/dataviz-squad /public/app/plugins/panel/canvas/ @grafana/dataviz-squad /public/app/plugins/panel/candlestick/ @grafana/dataviz-squad diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotScaleBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotScaleBuilder.ts index dd7bb67e13c..f07a9c8c0cd 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotScaleBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotScaleBuilder.ts @@ -156,6 +156,11 @@ export class UPlotScaleBuilder extends PlotConfigBuilder { let minMax: uPlot.Range.MinMax = [dataMin, dataMax]; + // don't pad numeric x scales + if (scaleKey === 'x' && !isTime) { + return minMax; + } + // happens when all series on a scale are `show: false`, re-returning nulls will auto-disable axis if (!hasFixedRange && dataMin == null && dataMax == null) { return minMax; diff --git a/pkg/plugins/manager/manager_integration_test.go b/pkg/plugins/manager/manager_integration_test.go index 60d65a5d18c..2b987205375 100644 --- a/pkg/plugins/manager/manager_integration_test.go +++ b/pkg/plugins/manager/manager_integration_test.go @@ -189,6 +189,7 @@ func verifyCorePluginCatalogue(t *testing.T, ctx context.Context, ps *store.Serv "table-old": {}, "text": {}, "timeseries": {}, + "trend": {}, "welcome": {}, "xychart": {}, } diff --git a/pkg/plugins/pfs/corelist/corelist_load_gen.go b/pkg/plugins/pfs/corelist/corelist_load_gen.go index e9f631cec22..6215b3177b1 100644 --- a/pkg/plugins/pfs/corelist/corelist_load_gen.go +++ b/pkg/plugins/pfs/corelist/corelist_load_gen.go @@ -79,6 +79,7 @@ func corePlugins(rt *thema.Runtime) []pfs.ParsedPlugin { parsePluginOrPanic("public/app/plugins/panel/text", "text", rt), parsePluginOrPanic("public/app/plugins/panel/timeseries", "timeseries", rt), parsePluginOrPanic("public/app/plugins/panel/traces", "traces", rt), + parsePluginOrPanic("public/app/plugins/panel/trend", "trend", rt), parsePluginOrPanic("public/app/plugins/panel/welcome", "welcome", rt), parsePluginOrPanic("public/app/plugins/panel/xychart", "xychart", rt), } diff --git a/pkg/plugins/plugindef/plugindef.cue b/pkg/plugins/plugindef/plugindef.cue index 69c53b9a1c2..75f75f5d94d 100644 --- a/pkg/plugins/plugindef/plugindef.cue +++ b/pkg/plugins/plugindef/plugindef.cue @@ -17,7 +17,7 @@ seqs: [ // grafana.com, then the plugin `id` has to follow the naming // conventions. id: string & strings.MinRunes(1) - id: =~"^([0-9a-z]+\\-([0-9a-z]+\\-)?(\(strings.Join([ for t in _types {t}], "|"))))|(alertGroups|alertlist|annolist|barchart|bargauge|candlestick|canvas|dashlist|debug|gauge|geomap|gettingstarted|graph|heatmap|histogram|icon|live|logs|news|nodeGraph|piechart|pluginlist|stat|state-timeline|status-history|table|table-old|text|timeseries|traces|welcome|xychart|alertmanager|cloudwatch|dashboard|elasticsearch|grafana|grafana-azure-monitor-datasource|graphite|influxdb|jaeger|loki|mixed|mssql|mysql|opentsdb|postgres|prometheus|stackdriver|tempo|testdata|zipkin|phlare|parca)$" + id: =~"^([0-9a-z]+\\-([0-9a-z]+\\-)?(\(strings.Join([ for t in _types {t}], "|"))))|(alertGroups|alertlist|annolist|barchart|bargauge|candlestick|canvas|dashlist|debug|gauge|geomap|gettingstarted|graph|heatmap|histogram|icon|live|logs|news|nodeGraph|piechart|pluginlist|stat|state-timeline|status-history|table|table-old|text|timeseries|trend|traces|welcome|xychart|alertmanager|cloudwatch|dashboard|elasticsearch|grafana|grafana-azure-monitor-datasource|graphite|influxdb|jaeger|loki|mixed|mssql|mysql|opentsdb|postgres|prometheus|stackdriver|tempo|testdata|zipkin|phlare|parca)$" // Human-readable name of the plugin that is shown to the user in // the UI. diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index 6fa3d13ef45..b1acdb9214e 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -66,6 +66,7 @@ import * as tablePanel from 'app/plugins/panel/table/module'; import * as textPanel from 'app/plugins/panel/text/module'; import * as timeseriesPanel from 'app/plugins/panel/timeseries/module'; import * as tracesPanel from 'app/plugins/panel/traces/module'; +import * as trendPanel from 'app/plugins/panel/trend/module'; import * as welcomeBanner from 'app/plugins/panel/welcome/module'; import * as xyChartPanel from 'app/plugins/panel/xychart/module'; @@ -106,6 +107,7 @@ const builtInPlugins: any = { 'app/plugins/panel/text/module': textPanel, 'app/plugins/panel/timeseries/module': timeseriesPanel, + 'app/plugins/panel/trend/module': trendPanel, 'app/plugins/panel/state-timeline/module': stateTimelinePanel, 'app/plugins/panel/status-history/module': statusHistoryPanel, 'app/plugins/panel/candlestick/module': candlestickPanel, diff --git a/public/app/plugins/panel/timeseries/module.tsx b/public/app/plugins/panel/timeseries/module.tsx index da9a6ba6ed0..fc8e5802fa7 100644 --- a/public/app/plugins/panel/timeseries/module.tsx +++ b/public/app/plugins/panel/timeseries/module.tsx @@ -1,15 +1,14 @@ import { PanelPlugin } from '@grafana/data'; -import { GraphFieldConfig } from '@grafana/schema'; import { commonOptionsBuilder } from '@grafana/ui'; import { TimeSeriesPanel } from './TimeSeriesPanel'; import { TimezonesEditor } from './TimezonesEditor'; import { defaultGraphConfig, getGraphFieldConfig } from './config'; import { graphPanelChangedHandler } from './migrations'; -import { PanelOptions } from './panelcfg.gen'; +import { PanelFieldConfig, PanelOptions } from './panelcfg.gen'; import { TimeSeriesSuggestionsSupplier } from './suggestions'; -export const plugin = new PanelPlugin(TimeSeriesPanel) +export const plugin = new PanelPlugin(TimeSeriesPanel) .setPanelChangeHandler(graphPanelChangedHandler) .useFieldConfig(getGraphFieldConfig(defaultGraphConfig)) .setPanelOptions((builder) => { diff --git a/public/app/plugins/panel/timeseries/utils.ts b/public/app/plugins/panel/timeseries/utils.ts index 74720cc1890..e2257c6abfc 100644 --- a/public/app/plugins/panel/timeseries/utils.ts +++ b/public/app/plugins/panel/timeseries/utils.ts @@ -22,12 +22,26 @@ import { nullToValue } from '@grafana/ui/src/components/GraphNG/nullToValue'; export function prepareGraphableFields( series: DataFrame[], theme: GrafanaTheme2, - timeRange?: TimeRange + timeRange?: TimeRange, + // numeric X requires a single frame where the first field is numeric + xNumFieldIdx?: number ): DataFrame[] | null { if (!series?.length) { return null; } + let useNumericX = xNumFieldIdx != null; + + // Make sure the numeric x field is first in the frame + if (xNumFieldIdx != null && xNumFieldIdx > 0) { + series = [ + { + ...series[0], + fields: [series[0].fields[xNumFieldIdx], ...series[0].fields.filter((f, i) => i !== xNumFieldIdx)], + }, + ]; + } + // some datasources simply tag the field as time, but don't convert to milli epochs // so we're stuck with doing the parsing here to avoid Moment slowness everywhere later // this mutates (once) @@ -49,20 +63,26 @@ export function prepareGraphableFields( let hasTimeField = false; let hasValueField = false; - let nulledFrame = applyNullInsertThreshold({ - frame, - refFieldPseudoMin: timeRange?.from.valueOf(), - refFieldPseudoMax: timeRange?.to.valueOf(), - }); + let nulledFrame = useNumericX + ? frame + : applyNullInsertThreshold({ + frame, + refFieldPseudoMin: timeRange?.from.valueOf(), + refFieldPseudoMax: timeRange?.to.valueOf(), + }); + + const frameFields = nullToValue(nulledFrame).fields; + + for (let fieldIdx = 0; fieldIdx < frameFields?.length ?? 0; fieldIdx++) { + const field = frameFields[fieldIdx]; - for (const field of nullToValue(nulledFrame).fields) { switch (field.type) { case FieldType.time: hasTimeField = true; fields.push(field); break; case FieldType.number: - hasValueField = true; + hasValueField = useNumericX ? fieldIdx > 0 : true; copy = { ...field, values: new ArrayVector( @@ -124,7 +144,7 @@ export function prepareGraphableFields( } } - if (hasTimeField && hasValueField) { + if ((useNumericX || hasTimeField) && hasValueField) { frames.push({ ...frame, length: nulledFrame.length, @@ -134,20 +154,19 @@ export function prepareGraphableFields( } if (frames.length) { - setClassicPaletteIdxs(frames, theme); + setClassicPaletteIdxs(frames, theme, 0); return frames; } return null; } -const setClassicPaletteIdxs = (frames: DataFrame[], theme: GrafanaTheme2) => { +const setClassicPaletteIdxs = (frames: DataFrame[], theme: GrafanaTheme2, skipFieldIdx?: number) => { let seriesIndex = 0; - frames.forEach((frame) => { - frame.fields.forEach((field) => { + frame.fields.forEach((field, fieldIdx) => { // TODO: also add FieldType.enum type here after https://github.com/grafana/grafana/pull/60491 - if (field.type === FieldType.number || field.type === FieldType.boolean) { + if (fieldIdx !== skipFieldIdx && (field.type === FieldType.number || field.type === FieldType.boolean)) { field.state = { ...field.state, seriesIndex: seriesIndex++, // TODO: skip this for fields with custom renderers (e.g. Candlestick)? diff --git a/public/app/plugins/panel/trend/TrendPanel.tsx b/public/app/plugins/panel/trend/TrendPanel.tsx new file mode 100644 index 00000000000..f899555ff06 --- /dev/null +++ b/public/app/plugins/panel/trend/TrendPanel.tsx @@ -0,0 +1,137 @@ +import React, { useMemo } from 'react'; + +import { FieldType, PanelProps } from '@grafana/data'; +import { config, PanelDataErrorView } from '@grafana/runtime'; +import { KeyboardPlugin, TimeSeries, TooltipDisplayMode, TooltipPlugin, usePanelContext } from '@grafana/ui'; +import { findFieldIndex } from 'app/features/dimensions'; + +import { ContextMenuPlugin } from '../timeseries/plugins/ContextMenuPlugin'; +import { prepareGraphableFields, regenerateLinksSupplier } from '../timeseries/utils'; + +import { PanelOptions } from './panelcfg.gen'; + +export const TrendPanel = ({ + data, + timeRange, + timeZone, + width, + height, + options, + fieldConfig, + replaceVariables, + id, +}: PanelProps) => { + const { sync } = usePanelContext(); + + const info = useMemo(() => { + if (data.series.length > 1) { + return { + warning: 'Only one frame is supported, consider adding a join transformation', + frames: data.series, + }; + } + + let frames = data.series; + let xFieldIdx: number | undefined; + if (options.xField) { + xFieldIdx = findFieldIndex(frames[0], options.xField); + if (xFieldIdx == null) { + return { + warning: 'Unable to find field: ' + options.xField, + frames: data.series, + }; + } + } else { + // first number field + // Perhaps we can/should support any ordinal rather than an error here + xFieldIdx = frames[0].fields.findIndex((f) => f.type === FieldType.number); + if (xFieldIdx === -1) { + return { + warning: 'No numeric fields found for X axis', + frames, + }; + } + } + + // Make sure values are ascending + if (xFieldIdx != null) { + const field = frames[0].fields[xFieldIdx]; + if (field.type === FieldType.number) { + // we may support ordinal soon + let last = Number.NEGATIVE_INFINITY; + const values = field.values.toArray(); + for (let i = 0; i < values.length; i++) { + const v = values[i]; + if (last > v) { + return { + warning: `Values must be in ascending order (index: ${i}, ${last} > ${v})`, + frames, + }; + } + last = v; + } + } + } + + return { frames: prepareGraphableFields(frames, config.theme2, undefined, xFieldIdx) }; + }, [data, options.xField]); + + if (info.warning || !info.frames) { + return ( + + ); + } + + return ( + + {(config, alignedDataFrame) => { + if ( + alignedDataFrame.fields.filter((f) => f.config.links !== undefined && f.config.links.length > 0).length > 0 + ) { + alignedDataFrame = regenerateLinksSupplier(alignedDataFrame, info.frames!, replaceVariables, timeZone); + } + + return ( + <> + + {options.tooltip.mode === TooltipDisplayMode.None || ( + + )} + + + + ); + }} + + ); +}; diff --git a/public/app/plugins/panel/trend/img/trend.svg b/public/app/plugins/panel/trend/img/trend.svg new file mode 100644 index 00000000000..f8f286dca06 --- /dev/null +++ b/public/app/plugins/panel/trend/img/trend.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/public/app/plugins/panel/trend/module.tsx b/public/app/plugins/panel/trend/module.tsx new file mode 100644 index 00000000000..177dcb04b1d --- /dev/null +++ b/public/app/plugins/panel/trend/module.tsx @@ -0,0 +1,30 @@ +import { PanelPlugin } from '@grafana/data'; +import { commonOptionsBuilder } from '@grafana/ui'; + +import { defaultGraphConfig, getGraphFieldConfig } from '../timeseries/config'; + +import { TrendPanel } from './TrendPanel'; +import { PanelFieldConfig, PanelOptions } from './panelcfg.gen'; +import { TrendSuggestionsSupplier } from './suggestions'; + +export const plugin = new PanelPlugin(TrendPanel) + .useFieldConfig(getGraphFieldConfig(defaultGraphConfig)) + .setPanelOptions((builder) => { + const category = ['X Axis']; + builder.addFieldNamePicker({ + path: 'xField', + name: 'X Field', + description: 'An increasing numeric value', + category, + defaultValue: undefined, + settings: { + isClearable: true, + placeholderText: 'First numeric value', + }, + }); + + commonOptionsBuilder.addTooltipOptions(builder); + commonOptionsBuilder.addLegendOptions(builder); + }) + .setSuggestionsSupplier(new TrendSuggestionsSupplier()); +//.setDataSupport({ annotations: true, alertStates: true }); diff --git a/public/app/plugins/panel/trend/panelcfg.cue b/public/app/plugins/panel/trend/panelcfg.cue new file mode 100644 index 00000000000..a56352c17fe --- /dev/null +++ b/public/app/plugins/panel/trend/panelcfg.cue @@ -0,0 +1,42 @@ +// Copyright 2021 Grafana Labs +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package grafanaplugin + +import ( + "github.com/grafana/grafana/packages/grafana-schema/src/common" +) + +composableKinds: PanelCfg: { + lineage: { + seqs: [ + { + schemas: [ + { + // Identical to timeseries... except it does not have timezone settings + PanelOptions: { + legend: common.VizLegendOptions + tooltip: common.VizTooltipOptions + + // Name of the x field to use (defaults to first number) + xField?: string + } @cuetsy(kind="interface") + + PanelFieldConfig: common.GraphFieldConfig & {} @cuetsy(kind="interface") + }, + ] + }, + ] + } +} diff --git a/public/app/plugins/panel/trend/panelcfg.gen.ts b/public/app/plugins/panel/trend/panelcfg.gen.ts new file mode 100644 index 00000000000..d72f0b5a583 --- /dev/null +++ b/public/app/plugins/panel/trend/panelcfg.gen.ts @@ -0,0 +1,27 @@ +// Code generated - EDITING IS FUTILE. DO NOT EDIT. +// +// Generated by: +// public/app/plugins/gen.go +// Using jennies: +// TSTypesJenny +// PluginTSTypesJenny +// +// Run 'make gen-cue' from repository root to regenerate. + +import * as common from '@grafana/schema'; + +export const PanelCfgModelVersion = Object.freeze([0, 0]); + +/** + * Identical to timeseries... except it does not have timezone settings + */ +export interface PanelOptions { + legend: common.VizLegendOptions; + tooltip: common.VizTooltipOptions; + /** + * Name of the x field to use (defaults to first number) + */ + xField?: string; +} + +export interface PanelFieldConfig extends common.GraphFieldConfig {} diff --git a/public/app/plugins/panel/trend/plugin.json b/public/app/plugins/panel/trend/plugin.json new file mode 100644 index 00000000000..91717e503bd --- /dev/null +++ b/public/app/plugins/panel/trend/plugin.json @@ -0,0 +1,19 @@ +{ + "type": "panel", + "name": "Trend", + "id": "trend", + + "state": "alpha", + + "info": { + "description": "Like timeseries, but when x != time", + "author": { + "name": "Grafana Labs", + "url": "https://grafana.com" + }, + "logos": { + "small": "img/trend.svg", + "large": "img/trend.svg" + } + } +} diff --git a/public/app/plugins/panel/trend/suggestions.ts b/public/app/plugins/panel/trend/suggestions.ts new file mode 100644 index 00000000000..4306d46b18b --- /dev/null +++ b/public/app/plugins/panel/trend/suggestions.ts @@ -0,0 +1,40 @@ +import { VisualizationSuggestionsBuilder } from '@grafana/data'; +import { GraphDrawStyle, GraphFieldConfig } from '@grafana/schema'; +import { SuggestionName } from 'app/types/suggestions'; + +import { PanelOptions } from './panelcfg.gen'; + +export class TrendSuggestionsSupplier { + getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { + const { dataSummary } = builder; + + if (dataSummary.numberFieldCount < 2 || dataSummary.rowCountTotal < 2 || dataSummary.rowCountTotal < 2) { + return; + } + + // Super basic + const list = builder.getListAppender({ + name: SuggestionName.LineChart, + pluginId: 'trend', + options: { + legend: {} as any, + }, + fieldConfig: { + defaults: { + custom: {}, + }, + overrides: [], + }, + cardOptions: { + previewModifier: (s) => { + s.options!.legend.showLegend = false; + + if (s.fieldConfig?.defaults.custom?.drawStyle !== GraphDrawStyle.Bars) { + s.fieldConfig!.defaults.custom!.lineWidth = Math.max(s.fieldConfig!.defaults.custom!.lineWidth ?? 1, 2); + } + }, + }, + }); + return list; + } +}