TrendPanel: Add new trend panel (Alpha) (#65740)

This commit is contained in:
Ryan McKinley 2023-04-04 20:52:20 -07:00 committed by GitHub
parent 313b3dd2af
commit d974e5f25a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 354 additions and 18 deletions

View File

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

1
.github/CODEOWNERS vendored
View File

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

View File

@ -156,6 +156,11 @@ export class UPlotScaleBuilder extends PlotConfigBuilder<ScaleProps, Scale> {
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;

View File

@ -189,6 +189,7 @@ func verifyCorePluginCatalogue(t *testing.T, ctx context.Context, ps *store.Serv
"table-old": {},
"text": {},
"timeseries": {},
"trend": {},
"welcome": {},
"xychart": {},
}

View File

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

View File

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

View File

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

View File

@ -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<PanelOptions, GraphFieldConfig>(TimeSeriesPanel)
export const plugin = new PanelPlugin<PanelOptions, PanelFieldConfig>(TimeSeriesPanel)
.setPanelChangeHandler(graphPanelChangedHandler)
.useFieldConfig(getGraphFieldConfig(defaultGraphConfig))
.setPanelOptions((builder) => {

View File

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

View File

@ -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<PanelOptions>) => {
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 (
<PanelDataErrorView
panelId={id}
fieldConfig={fieldConfig}
data={data}
message={info.warning}
needsNumberField={true}
/>
);
}
return (
<TimeSeries // Name change!
frames={info.frames}
structureRev={data.structureRev}
timeRange={timeRange}
timeZone={timeZone}
width={width}
height={height}
legend={options.legend}
options={options}
>
{(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 (
<>
<KeyboardPlugin config={config} />
{options.tooltip.mode === TooltipDisplayMode.None || (
<TooltipPlugin
frames={info.frames!}
data={alignedDataFrame}
config={config}
mode={options.tooltip.mode}
sortOrder={options.tooltip.sort}
sync={sync}
timeZone={timeZone}
/>
)}
<ContextMenuPlugin
data={alignedDataFrame}
frames={info.frames!}
config={config}
timeZone={timeZone}
replaceVariables={replaceVariables}
defaultItems={[]}
/>
</>
);
}}
</TimeSeries>
);
};

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 82.48 77.59">
<defs><style>.cls-1{fill:url(#linear-gradient);}.cls-2{fill:url(#linear-gradient-2);}.cls-3{fill:#84aff1;}</style>
<linearGradient id="linear-gradient" y1="12" x2="82.48" y2="12" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f2cc0c"/><stop offset="1" stop-color="#ff9830"/></linearGradient><linearGradient id="linear-gradient-2" x1="41.14" y1="77.98" x2="41.91" y2="13.73" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1f60c4" stop-opacity="0"/><stop offset="1" stop-color="#3865ab"/></linearGradient>
</defs>
<g id="Icons">
<path class="cls-1" d="M33.26,24a2,2,0,0,1-.83-.18l-14.7-6.66-15,5.71a2,2,0,1,1-1.42-3.74l15.8-6a2,2,0,0,1,1.53,0l14.16,6.41,15-16A2,2,0,0,1,50,3.16L65.15,9.73,79.38.33a2,2,0,1,1,2.2,3.34l-15.13,10a2,2,0,0,1-1.9.16L49.72,7.39l-15,16A2,2,0,0,1,33.26,24Z"/>
<path class="cls-3" d="M2.11,45.72H2A2,2,0,0,1,.73,42.18l15.8-13a2,2,0,0,1,1.65-.42l14.59,2.83L48,20.12a2,2,0,0,1,2.4,0l14.7,10.94L79,16.35a2,2,0,1,1,2.9,2.75L66.8,35.1a2,2,0,0,1-2.64.23L49.23,24.22,34.46,35.32a2,2,0,0,1-1.58.37L18.34,32.87l-14.6,12A2,2,0,0,1,2.11,45.72Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

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

View File

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

View File

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

View File

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

View File

@ -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<PanelOptions, GraphFieldConfig>({
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;
}
}