diff --git a/decoder/timeseries.go b/decoder/timeseries.go index 33f3675b..33d915b1 100644 --- a/decoder/timeseries.go +++ b/decoder/timeseries.go @@ -6,6 +6,7 @@ import ( "github.com/K-Phoen/grabana/row" "github.com/K-Phoen/grabana/timeseries" "github.com/K-Phoen/grabana/timeseries/axis" + "github.com/K-Phoen/grabana/timeseries/fields" ) var ErrInvalidGradientMode = fmt.Errorf("invalid gradient mode") @@ -14,6 +15,7 @@ var ErrInvalidTooltipMode = fmt.Errorf("invalid tooltip mode") var ErrInvalidStackMode = fmt.Errorf("invalid stack mode") var ErrInvalidAxisDisplay = fmt.Errorf("invalid axis display") var ErrInvalidAxisScale = fmt.Errorf("invalid axis scale") +var ErrInvalidOverrideMatcher = fmt.Errorf("invalid override matcher") type DashboardTimeSeries struct { Title string @@ -29,6 +31,7 @@ type DashboardTimeSeries struct { Alert *Alert `yaml:",omitempty"` Visualization *TimeSeriesVisualization `yaml:",omitempty"` Axis *TimeSeriesAxis `yaml:",omitempty"` + Overrides []TimeSeriesOverride `yaml:",omitempty"` } func (timeseriesPanel DashboardTimeSeries) toOption() (row.Option, error) { @@ -88,6 +91,15 @@ func (timeseriesPanel DashboardTimeSeries) toOption() (row.Option, error) { opts = append(opts, timeseries.Axis(axisOpts...)) } + for _, override := range timeseriesPanel.Overrides { + opt, err := override.toOption() + if err != nil { + return nil, err + } + + opts = append(opts, opt) + } + for _, t := range timeseriesPanel.Targets { opt, err := timeseriesPanel.target(t) if err != nil { @@ -367,22 +379,29 @@ func (tsAxis *TimeSeriesAxis) toOptions() ([]axis.Option, error) { } func (tsAxis *TimeSeriesAxis) placementOption() (axis.Option, error) { - var placementMode axis.PlacementMode + placementMode, err := axisPlacementFromString(tsAxis.Display) + if err != nil { + return nil, err + } + + return axis.Placement(placementMode), nil +} - switch tsAxis.Display { +func axisPlacementFromString(input string) (axis.PlacementMode, error) { + switch input { case "none": - placementMode = axis.Hidden + return axis.Hidden, nil + case "hidden": + return axis.Hidden, nil case "auto": - placementMode = axis.Auto + return axis.Auto, nil case "left": - placementMode = axis.Left + return axis.Left, nil case "right": - placementMode = axis.Right + return axis.Right, nil default: - return nil, ErrInvalidAxisDisplay + return axis.Auto, ErrInvalidAxisDisplay } - - return axis.Placement(placementMode), nil } func (tsAxis *TimeSeriesAxis) scaleOption() (axis.Option, error) { @@ -401,3 +420,85 @@ func (tsAxis *TimeSeriesAxis) scaleOption() (axis.Option, error) { return axis.Scale(scaleMode), nil } + +type TimeSeriesOverride struct { + Matcher TimeSeriesOverrideMatcher `yaml:"match,flow"` + Properties TimeSeriesOverrideProperties +} + +func (override TimeSeriesOverride) toOption() (timeseries.Option, error) { + matcher, err := override.Matcher.toOption() + if err != nil { + return nil, err + } + + overrideOpts, err := override.Properties.toOptions() + if err != nil { + return nil, err + } + + return timeseries.FieldOverride(matcher, overrideOpts...), nil +} + +type TimeSeriesOverrideMatcher struct { + FieldName *string `yaml:"field_name,omitempty"` + QueryRef *string `yaml:"query_ref,omitempty"` + Regex *string `yaml:"regex,omitempty"` + Type *string `yaml:"field_type,omitempty"` +} + +func (matcher TimeSeriesOverrideMatcher) toOption() (fields.Matcher, error) { + if matcher.FieldName != nil { + return fields.ByName(*matcher.FieldName), nil + } + if matcher.QueryRef != nil { + return fields.ByQuery(*matcher.QueryRef), nil + } + if matcher.Regex != nil { + return fields.ByRegex(*matcher.Regex), nil + } + if matcher.Type != nil { + return fields.ByType(fields.FieldType(*matcher.Type)), nil + } + + return nil, ErrInvalidOverrideMatcher +} + +type TimeSeriesOverrideProperties struct { + Unit *string `yaml:",omitempty"` + Color *string `yaml:"color,omitempty"` + FillOpacity *int `yaml:"fill_opacity,omitempty"` + NegativeY *bool `yaml:"negative_Y,omitempty"` + AxisDisplay *string `yaml:"axis_display,omitempty"` + Stack *string `yaml:",omitempty"` +} + +func (properties TimeSeriesOverrideProperties) toOptions() ([]fields.OverrideOption, error) { + var opts []fields.OverrideOption + + if properties.Unit != nil { + opts = append(opts, fields.Unit(*properties.Unit)) + } + if properties.Color != nil { + opts = append(opts, fields.FixedColorScheme(*properties.Color)) + } + if properties.FillOpacity != nil { + opts = append(opts, fields.FillOpacity(*properties.FillOpacity)) + } + if properties.NegativeY != nil && *properties.NegativeY { + opts = append(opts, fields.NegativeY()) + } + if properties.AxisDisplay != nil { + axisPlacement, err := axisPlacementFromString(*properties.AxisDisplay) + if err != nil { + return nil, err + } + + opts = append(opts, fields.AxisPlacement(axisPlacement)) + } + if properties.Stack != nil { + opts = append(opts, fields.Stack(fields.StackMode(*properties.Stack))) + } + + return opts, nil +} diff --git a/decoder/timeseries_test.go b/decoder/timeseries_test.go index b9a7299e..e0fe6499 100644 --- a/decoder/timeseries_test.go +++ b/decoder/timeseries_test.go @@ -396,6 +396,10 @@ func TestTimeSeriesAxisSupportsDisplay(t *testing.T) { value: "none", expected: axis.Hidden, }, + { + value: "hidden", + expected: axis.Hidden, + }, { value: "auto", expected: axis.Auto, diff --git a/timeseries/axis/axis.go b/timeseries/axis/axis.go index 3fcf2027..58077b9b 100644 --- a/timeseries/axis/axis.go +++ b/timeseries/axis/axis.go @@ -83,7 +83,7 @@ func Min(value float64) Option { } } -// SoftMax defines a hard maximum value for the axis. +// Max defines a hard maximum value for the axis. func Max(value float64) Option { return func(axis *Axis) error { axis.fieldConfig.Defaults.Max = &value diff --git a/timeseries/fields/matcher.go b/timeseries/fields/matcher.go index 77e98639..906b81ab 100644 --- a/timeseries/fields/matcher.go +++ b/timeseries/fields/matcher.go @@ -6,6 +6,12 @@ import ( type Matcher func(field *sdk.FieldConfigOverride) +type FieldType string + +const ( + FieldTypeTime FieldType = "time" +) + // ByName matches a specific field name. func ByName(name string) Matcher { return func(field *sdk.FieldConfigOverride) { @@ -21,3 +27,19 @@ func ByQuery(ref string) Matcher { field.Matcher.Options = ref } } + +// ByRegex matches fields names using a regex. +func ByRegex(regex string) Matcher { + return func(field *sdk.FieldConfigOverride) { + field.Matcher.ID = "byRegexp" + field.Matcher.Options = regex + } +} + +// ByType matches fields with a specific type. +func ByType(fieldType FieldType) Matcher { + return func(field *sdk.FieldConfigOverride) { + field.Matcher.ID = "byType" + field.Matcher.Options = string(fieldType) + } +} diff --git a/timeseries/fields/matcher_test.go b/timeseries/fields/matcher_test.go index 7c62e9fc..af7f0872 100644 --- a/timeseries/fields/matcher_test.go +++ b/timeseries/fields/matcher_test.go @@ -26,3 +26,23 @@ func TestByQuery(t *testing.T) { req.Equal("byFrameRefID", overrideCfg.Matcher.ID) req.Equal("A", overrideCfg.Matcher.Options) } + +func TestByRegex(t *testing.T) { + req := require.New(t) + + overrideCfg := &sdk.FieldConfigOverride{} + ByRegex("/.*trans.*/")(overrideCfg) + + req.Equal("byRegexp", overrideCfg.Matcher.ID) + req.Equal("/.*trans.*/", overrideCfg.Matcher.Options) +} + +func TestByType(t *testing.T) { + req := require.New(t) + + overrideCfg := &sdk.FieldConfigOverride{} + ByType(FieldTypeTime)(overrideCfg) + + req.Equal("byType", overrideCfg.Matcher.ID) + req.Equal("time", overrideCfg.Matcher.Options) +} diff --git a/timeseries/fields/override.go b/timeseries/fields/override.go index a594141b..e0bdb228 100644 --- a/timeseries/fields/override.go +++ b/timeseries/fields/override.go @@ -1,6 +1,22 @@ package fields -import "github.com/K-Phoen/sdk" +import ( + "github.com/K-Phoen/grabana/timeseries/axis" + "github.com/K-Phoen/sdk" +) + +// StackMode configures mode of series stacking. +// FIXME: copied here to avoid circular imports with parent package +type StackMode string + +const ( + // Unstacked will not stack series + Unstacked StackMode = "none" + // NormalStack will stack series as absolute numbers + NormalStack StackMode = "normal" + // PercentStack will stack series as percents + PercentStack StackMode = "percent" +) type OverrideOption func(field *sdk.FieldConfigOverride) @@ -39,3 +55,39 @@ func FixedColorScheme(color string) OverrideOption { }) } } + +// NegativeY flips the results to negative values on the Y axis. +func NegativeY() OverrideOption { + return func(field *sdk.FieldConfigOverride) { + field.Properties = append(field.Properties, + sdk.FieldConfigOverrideProperty{ + ID: "custom.transform", + Value: "negative-Y", + }) + } +} + +// AxisPlacement overrides how the axis should be placed in the panel. +func AxisPlacement(placement axis.PlacementMode) OverrideOption { + return func(field *sdk.FieldConfigOverride) { + field.Properties = append(field.Properties, + sdk.FieldConfigOverrideProperty{ + ID: "custom.axisPlacement", + Value: string(placement), + }) + } +} + +// Stack overrides if the series should be stacked and using which mode (default not stacked). +func Stack(mode StackMode) OverrideOption { + return func(field *sdk.FieldConfigOverride) { + field.Properties = append(field.Properties, + sdk.FieldConfigOverrideProperty{ + ID: "custom.stacking", + Value: map[string]interface{}{ + "group": false, + "mode": string(mode), + }, + }) + } +} diff --git a/timeseries/fields/override_test.go b/timeseries/fields/override_test.go index 8048b5c0..f658d95f 100644 --- a/timeseries/fields/override_test.go +++ b/timeseries/fields/override_test.go @@ -3,6 +3,7 @@ package fields import ( "testing" + "github.com/K-Phoen/grabana/timeseries/axis" "github.com/K-Phoen/sdk" "github.com/stretchr/testify/require" ) @@ -42,3 +43,39 @@ func TestFixedColorScheme(t *testing.T) { req.Equal("fixed", values["mode"]) req.Equal("dark-blue", values["fixedColor"]) } + +func TestNegativeY(t *testing.T) { + req := require.New(t) + + overrideCfg := &sdk.FieldConfigOverride{} + NegativeY()(overrideCfg) + + req.Len(overrideCfg.Properties, 1) + req.Equal("custom.transform", overrideCfg.Properties[0].ID) + req.Equal("negative-Y", overrideCfg.Properties[0].Value) +} + +func TestAxisPlacement(t *testing.T) { + req := require.New(t) + + overrideCfg := &sdk.FieldConfigOverride{} + AxisPlacement(axis.Hidden)(overrideCfg) + + req.Len(overrideCfg.Properties, 1) + req.Equal("custom.axisPlacement", overrideCfg.Properties[0].ID) + req.Equal("hidden", overrideCfg.Properties[0].Value) +} + +func TestStack(t *testing.T) { + req := require.New(t) + + overrideCfg := &sdk.FieldConfigOverride{} + Stack(PercentStack)(overrideCfg) + + req.Len(overrideCfg.Properties, 1) + req.Equal("custom.stacking", overrideCfg.Properties[0].ID) + + values := overrideCfg.Properties[0].Value.(map[string]interface{}) + req.Equal("percent", values["mode"]) + req.Equal(false, values["group"]) +} diff --git a/timeseries/timeseries.go b/timeseries/timeseries.go index b73b9503..02d14b11 100644 --- a/timeseries/timeseries.go +++ b/timeseries/timeseries.go @@ -202,7 +202,7 @@ func LineWidth(value int) Option { } } -// Stack defines if the series should be stacked and using which mode (default not stacked). the opacity level of the series. The lower the value, the more transparent. +// Stack defines if the series should be stacked and using which mode (default not stacked). func Stack(value StackMode) Option { return func(timeseries *TimeSeries) error { timeseries.Builder.TimeseriesPanel.FieldConfig.Defaults.Custom.Stacking.Mode = string(value)