diff --git a/go.mod b/go.mod index 60900a32..a3ef67d5 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/K-Phoen/dark go 1.17 require ( - github.com/K-Phoen/grabana v0.18.1 + github.com/K-Phoen/grabana v0.19.0 github.com/K-Phoen/sdk v0.0.0-20211119151408-adef1e5fdd11 github.com/spf13/cobra v1.2.1 github.com/stretchr/testify v1.7.0 diff --git a/go.sum b/go.sum index 4fda64a3..48dd88d7 100644 --- a/go.sum +++ b/go.sum @@ -51,8 +51,8 @@ github.com/Azure/go-autorest/logger v0.1.0/go.mod h1:oExouG+K6PryycPJfVSxi/koC6L github.com/Azure/go-autorest/tracing v0.5.0/go.mod h1:r/s2XiOKccPW3HrqB+W0TQzfbtp2fGCgRFtBroKn4Dk= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/K-Phoen/grabana v0.18.1 h1:L9kFCCRyaH6EucYZ2sepgLUXicNEtrdPa0l84DbRb+M= -github.com/K-Phoen/grabana v0.18.1/go.mod h1:e6GwAnk6L0g+tba2yhKdzWvDah8cPPlwPptyrnkK8ng= +github.com/K-Phoen/grabana v0.19.0 h1:/rjWWZt3qkuze3EBXxlNjGxc9ZkBfPj4nZJCmu8RkOs= +github.com/K-Phoen/grabana v0.19.0/go.mod h1:e6GwAnk6L0g+tba2yhKdzWvDah8cPPlwPptyrnkK8ng= github.com/K-Phoen/sdk v0.0.0-20211119151408-adef1e5fdd11 h1:bcJqfzSQge+pyJ1beMzUmf3bh9bRyIzEFu8PXUx8Hgk= github.com/K-Phoen/sdk v0.0.0-20211119151408-adef1e5fdd11/go.mod h1:fnbOsbRksULSfcXjOI6W1/HISz5o/u1iEhF/fLedqTg= github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= diff --git a/internal/pkg/converter/alert.go b/internal/pkg/converter/alert.go new file mode 100644 index 00000000..e921b4d2 --- /dev/null +++ b/internal/pkg/converter/alert.go @@ -0,0 +1,71 @@ +package converter + +import ( + grabana "github.com/K-Phoen/grabana/decoder" + "github.com/K-Phoen/sdk" +) + +func (converter *JSON) convertAlert(panel sdk.Panel) *grabana.Alert { + if panel.Alert == nil { + return nil + } + + sdkAlert := panel.Alert + + notifications := make([]string, 0, len(sdkAlert.Notifications)) + for _, notification := range sdkAlert.Notifications { + notifications = append(notifications, notification.UID) + } + + alert := &grabana.Alert{ + Title: sdkAlert.Name, + Message: sdkAlert.Message, + EvaluateEvery: sdkAlert.Frequency, + For: sdkAlert.For, + Tags: sdkAlert.AlertRuleTags, + OnNoData: sdkAlert.NoDataState, + OnExecutionError: sdkAlert.ExecutionErrorState, + Notifications: notifications, + If: converter.convertAlertConditions(sdkAlert), + } + + return alert +} + +func (converter *JSON) convertAlertConditions(sdkAlert *sdk.Alert) []grabana.AlertCondition { + conditions := make([]grabana.AlertCondition, 0, len(sdkAlert.Conditions)) + + for _, condition := range sdkAlert.Conditions { + conditions = append(conditions, grabana.AlertCondition{ + Operand: condition.Operator.Type, + Value: grabana.AlertValue{ + Func: condition.Reducer.Type, + QueryRef: condition.Query.Params[0], + From: condition.Query.Params[1], + To: condition.Query.Params[2], + }, + Threshold: converter.convertAlertThreshold(condition), + }) + } + + return conditions +} + +func (converter *JSON) convertAlertThreshold(sdkCondition sdk.AlertCondition) grabana.AlertThreshold { + threshold := grabana.AlertThreshold{} + + switch sdkCondition.Evaluator.Type { + case "no_value": + threshold.HasNoValue = true + case "lt": + threshold.Below = &sdkCondition.Evaluator.Params[0] + case "gt": + threshold.Above = &sdkCondition.Evaluator.Params[0] + case "outside_range": + threshold.OutsideRange = [2]float64{sdkCondition.Evaluator.Params[0], sdkCondition.Evaluator.Params[1]} + case "within_range": + threshold.WithinRange = [2]float64{sdkCondition.Evaluator.Params[0], sdkCondition.Evaluator.Params[1]} + } + + return threshold +} diff --git a/internal/pkg/converter/annotation.go b/internal/pkg/converter/annotation.go new file mode 100644 index 00000000..e04e5ebc --- /dev/null +++ b/internal/pkg/converter/annotation.go @@ -0,0 +1,39 @@ +package converter + +import ( + grabanaDashboard "github.com/K-Phoen/grabana/dashboard" + grabana "github.com/K-Phoen/grabana/decoder" + "github.com/K-Phoen/sdk" + "go.uber.org/zap" +) + +func (converter *JSON) convertAnnotations(annotations []sdk.Annotation, dashboard *grabana.DashboardModel) { + for _, annotation := range annotations { + // grafana-sdk doesn't expose the "builtIn" field, so we work around that by skipping + // the annotation we know to be built-in by its name + if annotation.Name == "Annotations & Alerts" { + continue + } + + if annotation.Type != "tags" { + converter.logger.Warn("unhandled annotation type: skipped", zap.String("type", annotation.Type), zap.String("name", annotation.Name)) + continue + } + + converter.convertTagAnnotation(annotation, dashboard) + } +} + +func (converter *JSON) convertTagAnnotation(annotation sdk.Annotation, dashboard *grabana.DashboardModel) { + datasource := "" + if annotation.Datasource != nil { + datasource = *annotation.Datasource + } + + dashboard.TagsAnnotation = append(dashboard.TagsAnnotation, grabanaDashboard.TagAnnotation{ + Name: annotation.Name, + Datasource: datasource, + IconColor: annotation.IconColor, + Tags: annotation.Tags, + }) +} diff --git a/internal/pkg/converter/annotation_test.go b/internal/pkg/converter/annotation_test.go new file mode 100644 index 00000000..a12dce94 --- /dev/null +++ b/internal/pkg/converter/annotation_test.go @@ -0,0 +1,56 @@ +package converter + +import ( + "testing" + + grabana "github.com/K-Phoen/grabana/decoder" + "github.com/K-Phoen/sdk" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestConvertTagAnnotationIgnoresBuiltIn(t *testing.T) { + req := require.New(t) + + annotation := sdk.Annotation{Name: "Annotations & Alerts"} + dashboard := &grabana.DashboardModel{} + + NewJSON(zap.NewNop()).convertAnnotations([]sdk.Annotation{annotation}, dashboard) + + req.Len(dashboard.TagsAnnotation, 0) +} + +func TestConvertTagAnnotationIgnoresUnknownTypes(t *testing.T) { + req := require.New(t) + + annotation := sdk.Annotation{Name: "Will be ignored", Type: "dashboard"} + dashboard := &grabana.DashboardModel{} + + NewJSON(zap.NewNop()).convertAnnotations([]sdk.Annotation{annotation}, dashboard) + + req.Len(dashboard.TagsAnnotation, 0) +} + +func TestConvertTagAnnotation(t *testing.T) { + req := require.New(t) + + converter := NewJSON(zap.NewNop()) + + datasource := "-- Grafana --" + annotation := sdk.Annotation{ + Type: "tags", + Datasource: &datasource, + IconColor: "#5794F2", + Name: "Deployments", + Tags: []string{"deploy"}, + } + dashboard := &grabana.DashboardModel{} + + converter.convertAnnotations([]sdk.Annotation{annotation}, dashboard) + + req.Len(dashboard.TagsAnnotation, 1) + req.Equal("Deployments", dashboard.TagsAnnotation[0].Name) + req.ElementsMatch([]string{"deploy"}, dashboard.TagsAnnotation[0].Tags) + req.Equal("#5794F2", dashboard.TagsAnnotation[0].IconColor) + req.Equal(datasource, dashboard.TagsAnnotation[0].Datasource) +} diff --git a/internal/pkg/converter/externallink.go b/internal/pkg/converter/externallink.go new file mode 100644 index 00000000..ec606056 --- /dev/null +++ b/internal/pkg/converter/externallink.go @@ -0,0 +1,51 @@ +package converter + +import ( + grabana "github.com/K-Phoen/grabana/decoder" + "github.com/K-Phoen/sdk" + "go.uber.org/zap" +) + +func (converter *JSON) convertExternalLinks(links []sdk.Link, dashboard *grabana.DashboardModel) { + for _, link := range links { + extLink := converter.convertExternalLink(link) + if extLink == nil { + continue + } + + dashboard.ExternalLinks = append(dashboard.ExternalLinks, *extLink) + } +} + +func (converter *JSON) convertExternalLink(link sdk.Link) *grabana.DashboardExternalLink { + if link.Type != "link" { + converter.logger.Warn("unhandled link type: skipped", zap.String("type", link.Type), zap.String("title", link.Title)) + return nil + } + + if link.URL == nil || *link.URL == "" { + converter.logger.Warn("link URL empty: skipped", zap.String("title", link.Title)) + return nil + } + + extLink := &grabana.DashboardExternalLink{ + Title: link.Title, + URL: *link.URL, + IncludeVariableValues: link.IncludeVars, + } + + if link.Tooltip != nil && *link.Tooltip != "" { + extLink.Description = *link.Tooltip + } + if link.Icon != nil && *link.Icon != "" { + extLink.Icon = *link.Icon + } + if link.TargetBlank != nil { + extLink.OpenInNewTab = *link.TargetBlank + } + if link.KeepTime != nil { + extLink.IncludeTimeRange = *link.KeepTime + } + + return extLink +} diff --git a/internal/pkg/converter/externallink_test.go b/internal/pkg/converter/externallink_test.go new file mode 100644 index 00000000..6971e7bd --- /dev/null +++ b/internal/pkg/converter/externallink_test.go @@ -0,0 +1,45 @@ +package converter + +import ( + "testing" + + grabana "github.com/K-Phoen/grabana/decoder" + "github.com/K-Phoen/sdk" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestConvertExternalLink(t *testing.T) { + req := require.New(t) + + externalLink := sdk.Link{ + Title: "joe", + Type: "link", + Icon: strPtr("cloud"), + IncludeVars: true, + KeepTime: boolPtr(true), + TargetBlank: boolPtr(true), + Tooltip: strPtr("description"), + URL: strPtr("http://lala"), + } + dashLink := sdk.Link{ + Title: "not link", + } + + converter := NewJSON(zap.NewNop()) + + dashboard := &grabana.DashboardModel{} + converter.convertExternalLinks([]sdk.Link{externalLink, dashLink}, dashboard) + + req.Len(dashboard.ExternalLinks, 1) + + link := dashboard.ExternalLinks[0] + + req.Equal("joe", link.Title) + req.Equal("description", link.Description) + req.Equal("cloud", link.Icon) + req.Equal("http://lala", link.URL) + req.True(link.OpenInNewTab) + req.True(link.IncludeTimeRange) + req.True(link.IncludeVariableValues) +} diff --git a/internal/pkg/converter/graph.go b/internal/pkg/converter/graph.go new file mode 100644 index 00000000..3ff0d2f2 --- /dev/null +++ b/internal/pkg/converter/graph.go @@ -0,0 +1,144 @@ +package converter + +import ( + grabana "github.com/K-Phoen/grabana/decoder" + "github.com/K-Phoen/sdk" +) + +func (converter *JSON) convertGraph(panel sdk.Panel) grabana.DashboardPanel { + graph := &grabana.DashboardGraph{ + Title: panel.Title, + Span: panelSpan(panel), + Transparent: panel.Transparent, + Axes: &grabana.GraphAxes{ + Bottom: converter.convertGraphAxis(panel.Xaxis), + }, + Legend: converter.convertGraphLegend(panel.GraphPanel.Legend), + Visualization: converter.convertGraphVisualization(panel), + Alert: converter.convertAlert(panel), + } + + if panel.Description != nil { + graph.Description = *panel.Description + } + if panel.Repeat != nil { + graph.Repeat = *panel.Repeat + } + if panel.Height != nil { + graph.Height = *(panel.Height).(*string) + } + if panel.Datasource != nil { + graph.Datasource = *panel.Datasource + } + + if len(panel.Yaxes) == 2 { + graph.Axes.Left = converter.convertGraphAxis(panel.Yaxes[0]) + graph.Axes.Right = converter.convertGraphAxis(panel.Yaxes[1]) + } + + for _, target := range panel.GraphPanel.Targets { + graphTarget := converter.convertTarget(target) + if graphTarget == nil { + continue + } + + graph.Targets = append(graph.Targets, *graphTarget) + } + + return grabana.DashboardPanel{Graph: graph} +} + +func (converter *JSON) convertGraphVisualization(panel sdk.Panel) *grabana.GraphVisualization { + graphViz := &grabana.GraphVisualization{ + NullValue: panel.GraphPanel.NullPointMode, + Staircase: panel.GraphPanel.SteppedLine, + Overrides: converter.convertGraphOverrides(panel), + } + + return graphViz +} + +func (converter *JSON) convertGraphOverrides(panel sdk.Panel) []grabana.GraphSeriesOverride { + if len(panel.GraphPanel.SeriesOverrides) == 0 { + return nil + } + + overrides := make([]grabana.GraphSeriesOverride, 0, len(panel.GraphPanel.SeriesOverrides)) + + for _, sdkOverride := range panel.GraphPanel.SeriesOverrides { + color := "" + if sdkOverride.Color != nil { + color = *sdkOverride.Color + } + + overrides = append(overrides, grabana.GraphSeriesOverride{ + Alias: sdkOverride.Alias, + Color: color, + Dashes: sdkOverride.Dashes, + Lines: sdkOverride.Lines, + Fill: sdkOverride.Fill, + LineWidth: sdkOverride.LineWidth, + }) + } + + return overrides +} + +func (converter *JSON) convertGraphLegend(sdkLegend sdk.Legend) []string { + var legend []string + + if !sdkLegend.Show { + legend = append(legend, "hide") + } + if sdkLegend.AlignAsTable { + legend = append(legend, "as_table") + } + if sdkLegend.RightSide { + legend = append(legend, "to_the_right") + } + if sdkLegend.Min { + legend = append(legend, "min") + } + if sdkLegend.Max { + legend = append(legend, "max") + } + if sdkLegend.Avg { + legend = append(legend, "avg") + } + if sdkLegend.Current { + legend = append(legend, "current") + } + if sdkLegend.Total { + legend = append(legend, "total") + } + if sdkLegend.HideEmpty { + legend = append(legend, "no_null_series") + } + if sdkLegend.HideZero { + legend = append(legend, "no_zero_series") + } + + return legend +} + +func (converter *JSON) convertGraphAxis(sdkAxis sdk.Axis) *grabana.GraphAxis { + hidden := !sdkAxis.Show + var min *float64 + var max *float64 + + if sdkAxis.Min != nil { + min = &sdkAxis.Min.Value + } + if sdkAxis.Max != nil { + max = &sdkAxis.Max.Value + } + + return &grabana.GraphAxis{ + Hidden: &hidden, + Label: sdkAxis.Label, + Unit: &sdkAxis.Format, + Min: min, + Max: max, + LogBase: sdkAxis.LogBase, + } +} diff --git a/internal/pkg/converter/graph_test.go b/internal/pkg/converter/graph_test.go new file mode 100644 index 00000000..19f649c6 --- /dev/null +++ b/internal/pkg/converter/graph_test.go @@ -0,0 +1,182 @@ +package converter + +import ( + "testing" + + "github.com/K-Phoen/sdk" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestConvertGraphPanel(t *testing.T) { + req := require.New(t) + + converter := NewJSON(zap.NewNop()) + height := "400px" + datasource := "prometheus" + + graphPanel := sdk.Panel{ + CommonPanel: sdk.CommonPanel{ + Title: "graph panel", + Type: "graph", + Description: strPtr("graph description"), + Transparent: true, + Height: &height, + Datasource: &datasource, + }, + GraphPanel: &sdk.GraphPanel{}, + } + + converted, ok := converter.convertDataPanel(graphPanel) + + req.True(ok) + req.NotNil(converted.Graph) + + graph := converted.Graph + req.True(graph.Transparent) + req.Equal("graph panel", graph.Title) + req.Equal("graph description", graph.Description) + req.Equal(height, graph.Height) + req.Equal(datasource, graph.Datasource) +} + +func TestConvertGraphLegend(t *testing.T) { + req := require.New(t) + + converter := NewJSON(zap.NewNop()) + + rawLegend := sdk.Legend{ + AlignAsTable: true, + Avg: true, + Current: true, + HideEmpty: true, + HideZero: true, + Max: true, + Min: true, + RightSide: true, + Show: true, + Total: true, + } + + legend := converter.convertGraphLegend(rawLegend) + + req.ElementsMatch( + []string{"as_table", "to_the_right", "min", "max", "avg", "current", "total", "no_null_series", "no_zero_series"}, + legend, + ) +} + +func TestConvertGraphCanHideLegend(t *testing.T) { + req := require.New(t) + converter := NewJSON(zap.NewNop()) + + legend := converter.convertGraphLegend(sdk.Legend{Show: false}) + req.ElementsMatch([]string{"hide"}, legend) +} + +func TestConvertGraphAxis(t *testing.T) { + req := require.New(t) + + converter := NewJSON(zap.NewNop()) + + rawAxis := sdk.Axis{ + Format: "bytes", + LogBase: 2, + Min: &sdk.FloatString{Value: 0}, + Max: &sdk.FloatString{Value: 42}, + Show: true, + Label: "Axis", + } + + axis := converter.convertGraphAxis(rawAxis) + + req.Equal("bytes", *axis.Unit) + req.Equal("Axis", axis.Label) + req.EqualValues(0, *axis.Min) + req.EqualValues(42, *axis.Max) + req.False(*axis.Hidden) +} + +func TestConvertGraphVisualization(t *testing.T) { + req := require.New(t) + converter := NewJSON(zap.NewNop()) + enabled := true + + graphPanel := sdk.Panel{ + CommonPanel: sdk.CommonPanel{ + Title: "graph panel", + Type: "graph", + }, + GraphPanel: &sdk.GraphPanel{ + NullPointMode: "connected", + SteppedLine: true, + SeriesOverrides: []sdk.SeriesOverride{ + { + Alias: "alias", + Dashes: &enabled, + }, + }, + }, + } + + visualization := converter.convertGraphVisualization(graphPanel) + + req.True(visualization.Staircase) + req.Equal("connected", visualization.NullValue) + req.Len(visualization.Overrides, 1) +} + +func TestConvertGraphOverridesWithNoOverride(t *testing.T) { + req := require.New(t) + converter := NewJSON(zap.NewNop()) + + graphPanel := sdk.Panel{ + CommonPanel: sdk.CommonPanel{ + Title: "graph panel", + Type: "graph", + }, + GraphPanel: &sdk.GraphPanel{}, + } + + overrides := converter.convertGraphOverrides(graphPanel) + + req.Len(overrides, 0) +} + +func TestConvertGraphOverridesWithOneOverride(t *testing.T) { + req := require.New(t) + converter := NewJSON(zap.NewNop()) + color := "red" + enabled := true + number := 2 + + graphPanel := sdk.Panel{ + CommonPanel: sdk.CommonPanel{ + Title: "heatmap panel", + Type: "graph", + }, + GraphPanel: &sdk.GraphPanel{ + SeriesOverrides: []sdk.SeriesOverride{ + { + Alias: "alias", + Color: &color, + Dashes: &enabled, + Fill: &number, + Lines: &enabled, + }, + }, + }, + } + + overrides := converter.convertGraphOverrides(graphPanel) + + req.Len(overrides, 1) + + override := overrides[0] + + req.Equal("alias", override.Alias) + req.Equal(color, override.Color) + req.True(*override.Dashes) + req.True(*override.Lines) + req.Equal(number, *override.Fill) +} diff --git a/internal/pkg/converter/heatmap.go b/internal/pkg/converter/heatmap.go new file mode 100644 index 00000000..075c26e7 --- /dev/null +++ b/internal/pkg/converter/heatmap.go @@ -0,0 +1,89 @@ +package converter + +import ( + "strconv" + + grabana "github.com/K-Phoen/grabana/decoder" + "github.com/K-Phoen/sdk" + "go.uber.org/zap" +) + +func (converter *JSON) convertHeatmap(panel sdk.Panel) grabana.DashboardPanel { + heatmap := &grabana.DashboardHeatmap{ + Title: panel.Title, + Span: panelSpan(panel), + Transparent: panel.Transparent, + HideZeroBuckets: panel.HeatmapPanel.HideZeroBuckets, + HighlightCards: panel.HeatmapPanel.HighlightCards, + ReverseYBuckets: panel.HeatmapPanel.ReverseYBuckets, + Tooltip: &grabana.HeatmapTooltip{ + Show: panel.HeatmapPanel.Tooltip.Show, + ShowHistogram: panel.HeatmapPanel.Tooltip.ShowHistogram, + Decimals: &panel.HeatmapPanel.TooltipDecimals, + }, + YAxis: converter.convertHeatmapYAxis(panel), + } + + if panel.Description != nil { + heatmap.Description = *panel.Description + } + if panel.Repeat != nil { + heatmap.Repeat = *panel.Repeat + } + if panel.Height != nil { + heatmap.Height = *(panel.Height).(*string) + } + if panel.Datasource != nil { + heatmap.Datasource = *panel.Datasource + } + if panel.HeatmapPanel.DataFormat != "" { + switch panel.HeatmapPanel.DataFormat { + case "tsbuckets": + heatmap.DataFormat = "time_series_buckets" + case "time_series": + heatmap.DataFormat = "time_series" + default: + converter.logger.Warn("unknown data format: skipping heatmap", zap.String("data_format", panel.HeatmapPanel.DataFormat), zap.String("heatmap_title", panel.Title)) + } + } + + for _, target := range panel.HeatmapPanel.Targets { + heatmapTarget := converter.convertTarget(target) + if heatmapTarget == nil { + continue + } + + heatmap.Targets = append(heatmap.Targets, *heatmapTarget) + } + + return grabana.DashboardPanel{Heatmap: heatmap} +} + +func (converter *JSON) convertHeatmapYAxis(panel sdk.Panel) *grabana.HeatmapYAxis { + panelAxis := panel.HeatmapPanel.YAxis + + axis := &grabana.HeatmapYAxis{ + Decimals: panelAxis.Decimals, + Unit: panelAxis.Format, + } + + if panelAxis.Max != nil { + max, err := strconv.ParseFloat(*panelAxis.Max, 64) + if err != nil { + converter.logger.Warn("could not parse max value on heatmap Y axis %s: %s", zap.String("value", *panelAxis.Max), zap.Error(err)) + } else { + axis.Max = &max + } + } + + if panelAxis.Min != nil { + min, err := strconv.ParseFloat(*panelAxis.Min, 64) + if err != nil { + converter.logger.Warn("could not parse min value on heatmap Y axis %s: %s", zap.String("value", *panelAxis.Min), zap.Error(err)) + } else { + axis.Min = &min + } + } + + return axis +} diff --git a/internal/pkg/converter/heatmap_test.go b/internal/pkg/converter/heatmap_test.go new file mode 100644 index 00000000..420ed9b4 --- /dev/null +++ b/internal/pkg/converter/heatmap_test.go @@ -0,0 +1,47 @@ +package converter + +import ( + "testing" + + "github.com/K-Phoen/sdk" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestConvertHeatmapPanel(t *testing.T) { + req := require.New(t) + + converter := NewJSON(zap.NewNop()) + height := "400px" + datasource := "prometheus" + + heatmapPanel := sdk.Panel{ + CommonPanel: sdk.CommonPanel{ + Title: "heatmap panel", + Type: "heatmap", + Description: strPtr("heatmap description"), + Transparent: true, + Height: &height, + Datasource: &datasource, + }, + HeatmapPanel: &sdk.HeatmapPanel{ + HideZeroBuckets: true, + HighlightCards: true, + ReverseYBuckets: true, + DataFormat: "tsbuckets", + }, + } + + converted, ok := converter.convertDataPanel(heatmapPanel) + + req.True(ok) + req.True(converted.Heatmap.Transparent) + req.Equal("heatmap panel", converted.Heatmap.Title) + req.Equal("heatmap description", converted.Heatmap.Description) + req.Equal(height, converted.Heatmap.Height) + req.Equal(datasource, converted.Heatmap.Datasource) + req.True(converted.Heatmap.ReverseYBuckets) + req.True(converted.Heatmap.HideZeroBuckets) + req.True(converted.Heatmap.HighlightCards) + req.Equal("time_series_buckets", converted.Heatmap.DataFormat) +} diff --git a/internal/pkg/converter/json.go b/internal/pkg/converter/json.go index 96768b0b..04d8c459 100644 --- a/internal/pkg/converter/json.go +++ b/internal/pkg/converter/json.go @@ -5,15 +5,9 @@ import ( "fmt" "io" "io/ioutil" - "strconv" - "strings" v1 "github.com/K-Phoen/dark/internal/pkg/apis/controller/v1" - grabanaDashboard "github.com/K-Phoen/grabana/dashboard" grabana "github.com/K-Phoen/grabana/decoder" - "github.com/K-Phoen/grabana/singlestat" - grabanaTable "github.com/K-Phoen/grabana/table" - "github.com/K-Phoen/grabana/target/stackdriver" "github.com/K-Phoen/sdk" "go.uber.org/zap" "gopkg.in/yaml.v3" @@ -144,199 +138,6 @@ func (converter *JSON) convertGeneralSettings(board *sdk.Board, dashboard *graba } } -func (converter *JSON) convertExternalLinks(links []sdk.Link, dashboard *grabana.DashboardModel) { - for _, link := range links { - if link.Type != "link" { - converter.logger.Warn("unhandled link type: skipped", zap.String("type", link.Type), zap.String("title", link.Title)) - continue - } - - if link.URL == nil || *link.URL == "" { - converter.logger.Warn("link URL empty: skipped", zap.String("title", link.Title)) - continue - } - - extLink := grabana.DashboardExternalLink{ - Title: link.Title, - URL: *link.URL, - IncludeVariableValues: link.IncludeVars, - } - - if link.Tooltip != nil && *link.Tooltip != "" { - extLink.Description = *link.Tooltip - } - if link.Icon != nil && *link.Icon != "" { - extLink.Icon = *link.Icon - } - if link.TargetBlank != nil { - extLink.OpenInNewTab = *link.TargetBlank - } - if link.KeepTime != nil { - extLink.IncludeTimeRange = *link.KeepTime - } - - dashboard.ExternalLinks = append(dashboard.ExternalLinks, extLink) - } -} - -func (converter *JSON) convertAnnotations(annotations []sdk.Annotation, dashboard *grabana.DashboardModel) { - for _, annotation := range annotations { - // grafana-sdk doesn't expose the "builtIn" field, so we work around that by skipping - // the annotation we know to be built-in by its name - if annotation.Name == "Annotations & Alerts" { - continue - } - - if annotation.Type != "tags" { - converter.logger.Warn("unhandled annotation type: skipped", zap.String("type", annotation.Type), zap.String("name", annotation.Name)) - continue - } - - converter.convertTagAnnotation(annotation, dashboard) - } -} - -func (converter *JSON) convertTagAnnotation(annotation sdk.Annotation, dashboard *grabana.DashboardModel) { - datasource := "" - if annotation.Datasource != nil { - datasource = *annotation.Datasource - } - - dashboard.TagsAnnotation = append(dashboard.TagsAnnotation, grabanaDashboard.TagAnnotation{ - Name: annotation.Name, - Datasource: datasource, - IconColor: annotation.IconColor, - Tags: annotation.Tags, - }) -} - -func (converter *JSON) convertVariables(variables []sdk.TemplateVar, dashboard *grabana.DashboardModel) { - for _, variable := range variables { - converter.convertVariable(variable, dashboard) - } -} - -func (converter *JSON) convertVariable(variable sdk.TemplateVar, dashboard *grabana.DashboardModel) { - switch variable.Type { - case "interval": - converter.convertIntervalVar(variable, dashboard) - case "custom": - converter.convertCustomVar(variable, dashboard) - case "query": - converter.convertQueryVar(variable, dashboard) - case "const": - converter.convertConstVar(variable, dashboard) - case "datasource": - converter.convertDatasourceVar(variable, dashboard) - default: - converter.logger.Warn("unhandled variable type found: skipped", zap.String("type", variable.Type), zap.String("name", variable.Name)) - } -} - -func (converter *JSON) convertIntervalVar(variable sdk.TemplateVar, dashboard *grabana.DashboardModel) { - interval := &grabana.VariableInterval{ - Name: variable.Name, - Label: variable.Label, - Default: defaultOption(variable.Current), - Values: make([]string, 0, len(variable.Options)), - Hide: converter.convertVarHide(variable), - } - - for _, opt := range variable.Options { - interval.Values = append(interval.Values, opt.Value) - } - - dashboard.Variables = append(dashboard.Variables, grabana.DashboardVariable{Interval: interval}) -} - -func (converter *JSON) convertCustomVar(variable sdk.TemplateVar, dashboard *grabana.DashboardModel) { - custom := &grabana.VariableCustom{ - Name: variable.Name, - Label: variable.Label, - Default: defaultOption(variable.Current), - ValuesMap: make(map[string]string, len(variable.Options)), - AllValue: variable.AllValue, - IncludeAll: variable.IncludeAll, - Hide: converter.convertVarHide(variable), - } - - for _, opt := range variable.Options { - custom.ValuesMap[opt.Text] = opt.Value - } - - dashboard.Variables = append(dashboard.Variables, grabana.DashboardVariable{Custom: custom}) -} - -func (converter *JSON) convertQueryVar(variable sdk.TemplateVar, dashboard *grabana.DashboardModel) { - datasource := "" - if variable.Datasource != nil { - datasource = *variable.Datasource - } - - query := &grabana.VariableQuery{ - Name: variable.Name, - Label: variable.Label, - Datasource: datasource, - Regex: variable.Regex, - IncludeAll: variable.IncludeAll, - DefaultAll: variable.Current.Value == "$__all", - AllValue: variable.AllValue, - Hide: converter.convertVarHide(variable), - } - - if variable.Query != nil { - query.Request = variable.Query.(string) - } - - dashboard.Variables = append(dashboard.Variables, grabana.DashboardVariable{Query: query}) -} - -func (converter *JSON) convertDatasourceVar(variable sdk.TemplateVar, dashboard *grabana.DashboardModel) { - datasource := &grabana.VariableDatasource{ - Name: variable.Name, - Label: variable.Label, - Regex: variable.Regex, - IncludeAll: variable.IncludeAll, - Hide: converter.convertVarHide(variable), - } - - if variable.Query != nil { - datasource.Type = variable.Query.(string) - } - - dashboard.Variables = append(dashboard.Variables, grabana.DashboardVariable{Datasource: datasource}) -} - -func (converter *JSON) convertConstVar(variable sdk.TemplateVar, dashboard *grabana.DashboardModel) { - constant := &grabana.VariableConst{ - Name: variable.Name, - Label: variable.Label, - Default: strings.Join(variable.Current.Text.Value, ","), - ValuesMap: make(map[string]string, len(variable.Options)), - Hide: converter.convertVarHide(variable), - } - - for _, opt := range variable.Options { - constant.ValuesMap[opt.Text] = opt.Value - } - - dashboard.Variables = append(dashboard.Variables, grabana.DashboardVariable{Const: constant}) -} - -func (converter *JSON) convertVarHide(variable sdk.TemplateVar) string { - switch variable.Hide { - case 0: - return "" - case 1: - return "label" - case 2: - return "variable" - default: - converter.logger.Warn("unknown hide value for variable %s", zap.String("variable", variable.Name)) - return "" - } -} - func (converter *JSON) convertPanels(panels []*sdk.Panel, dashboard *grabana.DashboardModel) { var currentRow *grabana.DashboardRow @@ -384,6 +185,8 @@ func (converter *JSON) convertDataPanel(panel sdk.Panel) (grabana.DashboardPanel return converter.convertTable(panel), true case "text": return converter.convertText(panel), true + case "timeseries": + return converter.convertTimeSeries(panel), true default: converter.logger.Warn("unhandled panel type: skipped", zap.String("type", panel.Type), zap.String("title", panel.Title)) } @@ -391,684 +194,6 @@ func (converter *JSON) convertDataPanel(panel sdk.Panel) (grabana.DashboardPanel return grabana.DashboardPanel{}, false } -func (converter *JSON) convertRow(panel sdk.Panel) *grabana.DashboardRow { - repeat := "" - if panel.Repeat != nil { - repeat = *panel.Repeat - } - collapse := false - if panel.RowPanel != nil && panel.RowPanel.Collapsed { - collapse = true - } - - return &grabana.DashboardRow{ - Name: panel.Title, - Repeat: repeat, - Collapse: collapse, - Panels: nil, - } -} - -func (converter *JSON) convertGraph(panel sdk.Panel) grabana.DashboardPanel { - graph := &grabana.DashboardGraph{ - Title: panel.Title, - Span: panelSpan(panel), - Transparent: panel.Transparent, - Axes: &grabana.GraphAxes{ - Bottom: converter.convertAxis(panel.Xaxis), - }, - Legend: converter.convertLegend(panel.GraphPanel.Legend), - Visualization: converter.convertVisualization(panel), - Alert: converter.convertAlert(panel), - } - - if panel.Description != nil { - graph.Description = *panel.Description - } - if panel.Repeat != nil { - graph.Repeat = *panel.Repeat - } - if panel.Height != nil { - graph.Height = *(panel.Height).(*string) - } - if panel.Datasource != nil { - graph.Datasource = *panel.Datasource - } - - if len(panel.Yaxes) == 2 { - graph.Axes.Left = converter.convertAxis(panel.Yaxes[0]) - graph.Axes.Right = converter.convertAxis(panel.Yaxes[1]) - } - - for _, target := range panel.GraphPanel.Targets { - graphTarget := converter.convertTarget(target) - if graphTarget == nil { - continue - } - - graph.Targets = append(graph.Targets, *graphTarget) - } - - return grabana.DashboardPanel{Graph: graph} -} - -func (converter *JSON) convertAlert(panel sdk.Panel) *grabana.GraphAlert { - if panel.Alert == nil { - return nil - } - - sdkAlert := panel.Alert - - notifications := make([]string, 0, len(sdkAlert.Notifications)) - for _, notification := range sdkAlert.Notifications { - notifications = append(notifications, notification.UID) - } - - alert := &grabana.GraphAlert{ - Title: sdkAlert.Name, - Message: sdkAlert.Message, - EvaluateEvery: sdkAlert.Frequency, - For: sdkAlert.For, - Tags: sdkAlert.AlertRuleTags, - OnNoData: sdkAlert.NoDataState, - OnExecutionError: sdkAlert.ExecutionErrorState, - Notifications: notifications, - If: converter.convertAlertConditions(sdkAlert), - } - - return alert -} - -func (converter *JSON) convertAlertConditions(sdkAlert *sdk.Alert) []grabana.AlertCondition { - conditions := make([]grabana.AlertCondition, 0, len(sdkAlert.Conditions)) - - for _, condition := range sdkAlert.Conditions { - conditions = append(conditions, grabana.AlertCondition{ - Operand: condition.Operator.Type, - Value: grabana.AlertValue{ - Func: condition.Reducer.Type, - QueryRef: condition.Query.Params[0], - From: condition.Query.Params[1], - To: condition.Query.Params[2], - }, - Threshold: converter.convertAlertThreshold(condition), - }) - } - - return conditions -} - -func (converter *JSON) convertAlertThreshold(sdkCondition sdk.AlertCondition) grabana.AlertThreshold { - threshold := grabana.AlertThreshold{} - - switch sdkCondition.Evaluator.Type { - case "no_value": - threshold.HasNoValue = true - case "lt": - threshold.Below = &sdkCondition.Evaluator.Params[0] - case "gt": - threshold.Above = &sdkCondition.Evaluator.Params[0] - case "outside_range": - threshold.OutsideRange = [2]float64{sdkCondition.Evaluator.Params[0], sdkCondition.Evaluator.Params[1]} - case "within_range": - threshold.WithinRange = [2]float64{sdkCondition.Evaluator.Params[0], sdkCondition.Evaluator.Params[1]} - } - - return threshold -} - -func (converter *JSON) convertVisualization(panel sdk.Panel) *grabana.GraphVisualization { - graphViz := &grabana.GraphVisualization{ - NullValue: panel.GraphPanel.NullPointMode, - Staircase: panel.GraphPanel.SteppedLine, - Overrides: converter.convertGraphOverrides(panel), - } - - return graphViz -} - -func (converter *JSON) convertGraphOverrides(panel sdk.Panel) []grabana.GraphSeriesOverride { - if len(panel.GraphPanel.SeriesOverrides) == 0 { - return nil - } - - overrides := make([]grabana.GraphSeriesOverride, 0, len(panel.GraphPanel.SeriesOverrides)) - - for _, sdkOverride := range panel.GraphPanel.SeriesOverrides { - color := "" - if sdkOverride.Color != nil { - color = *sdkOverride.Color - } - - overrides = append(overrides, grabana.GraphSeriesOverride{ - Alias: sdkOverride.Alias, - Color: color, - Dashes: sdkOverride.Dashes, - Lines: sdkOverride.Lines, - Fill: sdkOverride.Fill, - LineWidth: sdkOverride.LineWidth, - }) - } - - return overrides -} - -func (converter *JSON) convertLegend(sdkLegend sdk.Legend) []string { - var legend []string - - if !sdkLegend.Show { - legend = append(legend, "hide") - } - if sdkLegend.AlignAsTable { - legend = append(legend, "as_table") - } - if sdkLegend.RightSide { - legend = append(legend, "to_the_right") - } - if sdkLegend.Min { - legend = append(legend, "min") - } - if sdkLegend.Max { - legend = append(legend, "max") - } - if sdkLegend.Avg { - legend = append(legend, "avg") - } - if sdkLegend.Current { - legend = append(legend, "current") - } - if sdkLegend.Total { - legend = append(legend, "total") - } - if sdkLegend.HideEmpty { - legend = append(legend, "no_null_series") - } - if sdkLegend.HideZero { - legend = append(legend, "no_zero_series") - } - - return legend -} - -func (converter *JSON) convertAxis(sdkAxis sdk.Axis) *grabana.GraphAxis { - hidden := !sdkAxis.Show - var min *float64 - var max *float64 - - if sdkAxis.Min != nil { - min = &sdkAxis.Min.Value - } - if sdkAxis.Max != nil { - max = &sdkAxis.Max.Value - } - - return &grabana.GraphAxis{ - Hidden: &hidden, - Label: sdkAxis.Label, - Unit: &sdkAxis.Format, - Min: min, - Max: max, - LogBase: sdkAxis.LogBase, - } -} - -func (converter *JSON) convertHeatmap(panel sdk.Panel) grabana.DashboardPanel { - heatmap := &grabana.DashboardHeatmap{ - Title: panel.Title, - Span: panelSpan(panel), - Transparent: panel.Transparent, - HideZeroBuckets: panel.HeatmapPanel.HideZeroBuckets, - HighlightCards: panel.HeatmapPanel.HighlightCards, - ReverseYBuckets: panel.HeatmapPanel.ReverseYBuckets, - Tooltip: &grabana.HeatmapTooltip{ - Show: panel.HeatmapPanel.Tooltip.Show, - ShowHistogram: panel.HeatmapPanel.Tooltip.ShowHistogram, - Decimals: &panel.HeatmapPanel.TooltipDecimals, - }, - YAxis: converter.convertHeatmapYAxis(panel), - } - - if panel.Description != nil { - heatmap.Description = *panel.Description - } - if panel.Repeat != nil { - heatmap.Repeat = *panel.Repeat - } - if panel.Height != nil { - heatmap.Height = *(panel.Height).(*string) - } - if panel.Datasource != nil { - heatmap.Datasource = *panel.Datasource - } - if panel.HeatmapPanel.DataFormat != "" { - switch panel.HeatmapPanel.DataFormat { - case "tsbuckets": - heatmap.DataFormat = "time_series_buckets" - case "time_series": - heatmap.DataFormat = "time_series" - default: - converter.logger.Warn("unknown data format: skipping heatmap", zap.String("data_format", panel.HeatmapPanel.DataFormat), zap.String("heatmap_title", panel.Title)) - } - } - - for _, target := range panel.HeatmapPanel.Targets { - heatmapTarget := converter.convertTarget(target) - if heatmapTarget == nil { - continue - } - - heatmap.Targets = append(heatmap.Targets, *heatmapTarget) - } - - return grabana.DashboardPanel{Heatmap: heatmap} -} - -func (converter *JSON) convertHeatmapYAxis(panel sdk.Panel) *grabana.HeatmapYAxis { - panelAxis := panel.HeatmapPanel.YAxis - - axis := &grabana.HeatmapYAxis{ - Decimals: panelAxis.Decimals, - Unit: panelAxis.Format, - } - - if panelAxis.Max != nil { - max, err := strconv.ParseFloat(*panelAxis.Max, 64) - if err != nil { - converter.logger.Warn("could not parse max value on heatmap Y axis %s: %s", zap.String("value", *panelAxis.Max), zap.Error(err)) - } else { - axis.Max = &max - } - } - - if panelAxis.Min != nil { - min, err := strconv.ParseFloat(*panelAxis.Min, 64) - if err != nil { - converter.logger.Warn("could not parse min value on heatmap Y axis %s: %s", zap.String("value", *panelAxis.Min), zap.Error(err)) - } else { - axis.Min = &min - } - } - - return axis -} - -func (converter *JSON) convertSingleStat(panel sdk.Panel) grabana.DashboardPanel { - singleStat := &grabana.DashboardSingleStat{ - Title: panel.Title, - Span: panelSpan(panel), - Unit: panel.SinglestatPanel.Format, - Decimals: &panel.SinglestatPanel.Decimals, - ValueType: panel.SinglestatPanel.ValueName, - Transparent: panel.Transparent, - ValueFontSize: panel.SinglestatPanel.ValueFontSize, - } - - if panel.Description != nil { - singleStat.Description = *panel.Description - } - if panel.Repeat != nil { - singleStat.Repeat = *panel.Repeat - } - if panel.Height != nil { - singleStat.Height = *(panel.Height).(*string) - } - if panel.Datasource != nil { - singleStat.Datasource = *panel.Datasource - } - - thresholds := strings.Split(panel.SinglestatPanel.Thresholds, ",") - if len(thresholds) == 2 { - singleStat.Thresholds = [2]string{thresholds[0], thresholds[1]} - } - - if len(panel.SinglestatPanel.Colors) == 3 { - singleStat.Colors = [3]string{ - panel.SinglestatPanel.Colors[0], - panel.SinglestatPanel.Colors[1], - panel.SinglestatPanel.Colors[2], - } - } - - var colorOpts []string - if panel.SinglestatPanel.ColorBackground { - colorOpts = append(colorOpts, "background") - } - if panel.SinglestatPanel.ColorValue { - colorOpts = append(colorOpts, "value") - } - if len(colorOpts) != 0 { - singleStat.Color = colorOpts - } - - if panel.SinglestatPanel.SparkLine.Show && panel.SinglestatPanel.SparkLine.Full { - singleStat.SparkLine = "full" - } - if panel.SinglestatPanel.SparkLine.Show && !panel.SinglestatPanel.SparkLine.Full { - singleStat.SparkLine = "bottom" - } - - // Font sizes - if panel.SinglestatPanel.PrefixFontSize != nil && *panel.SinglestatPanel.PrefixFontSize != "" { - singleStat.PrefixFontSize = *panel.SinglestatPanel.PrefixFontSize - } - if panel.SinglestatPanel.PostfixFontSize != nil && *panel.SinglestatPanel.PostfixFontSize != "" { - singleStat.PostfixFontSize = *panel.SinglestatPanel.PostfixFontSize - } - - // ranges to text mapping - singleStat.RangesToText = converter.convertSingleStatRangesToText(panel) - - for _, target := range panel.SinglestatPanel.Targets { - graphTarget := converter.convertTarget(target) - if graphTarget == nil { - continue - } - - singleStat.Targets = append(singleStat.Targets, *graphTarget) - } - - return grabana.DashboardPanel{SingleStat: singleStat} -} - -func (converter *JSON) convertSingleStatRangesToText(panel sdk.Panel) []singlestat.RangeMap { - if panel.SinglestatPanel.MappingType == nil || *panel.SinglestatPanel.MappingType != 2 { - return nil - } - - mappings := make([]singlestat.RangeMap, 0, len(panel.SinglestatPanel.RangeMaps)) - for _, mapping := range panel.SinglestatPanel.RangeMaps { - converted := singlestat.RangeMap{ - From: "", - To: "", - Text: "", - } - - if mapping.From != nil { - converted.From = *mapping.From - } - if mapping.To != nil { - converted.To = *mapping.To - } - if mapping.Text != nil { - converted.Text = *mapping.Text - } - - mappings = append(mappings, converted) - } - - return mappings -} - -func (converter *JSON) convertTable(panel sdk.Panel) grabana.DashboardPanel { - table := &grabana.DashboardTable{ - Title: panel.Title, - Span: panelSpan(panel), - Transparent: panel.Transparent, - } - - if panel.Description != nil { - table.Description = *panel.Description - } - if panel.Height != nil { - table.Height = *(panel.Height).(*string) - } - if panel.Datasource != nil { - table.Datasource = *panel.Datasource - } - - for _, target := range panel.TablePanel.Targets { - graphTarget := converter.convertTarget(target) - if graphTarget == nil { - continue - } - - table.Targets = append(table.Targets, *graphTarget) - } - - // hidden columns - for _, columnStyle := range panel.TablePanel.Styles { - if columnStyle.Type != "hidden" { - continue - } - - table.HiddenColumns = append(table.HiddenColumns, columnStyle.Pattern) - } - - // time series aggregations - if panel.TablePanel.Transform == "timeseries_aggregations" { - for _, column := range panel.TablePanel.Columns { - table.TimeSeriesAggregations = append(table.TimeSeriesAggregations, grabanaTable.Aggregation{ - Label: column.TextType, - Type: grabanaTable.AggregationType(column.Value), - }) - } - } else { - converter.logger.Warn("unhandled transform type: skipped", zap.String("transform", panel.TablePanel.Transform), zap.String("panel", panel.Title)) - } - - return grabana.DashboardPanel{Table: table} -} - -func (converter *JSON) convertText(panel sdk.Panel) grabana.DashboardPanel { - text := &grabana.DashboardText{ - Title: panel.Title, - Span: panelSpan(panel), - Transparent: panel.Transparent, - } - - if panel.Description != nil { - text.Description = *panel.Description - } - if panel.Height != nil { - text.Height = *(panel.Height).(*string) - } - - if panel.TextPanel.Options.Mode == "markdown" { - text.Markdown = panel.TextPanel.Options.Content - } else { - fmt.Printf("%#v\n", panel.TextPanel) - text.HTML = panel.TextPanel.Options.Content - } - - return grabana.DashboardPanel{Text: text} -} - -func (converter *JSON) convertTarget(target sdk.Target) *grabana.Target { - // looks like a prometheus target - if target.Expr != "" { - return converter.convertPrometheusTarget(target) - } - - // looks like graphite - if target.Target != "" { - return converter.convertGraphiteTarget(target) - } - - // looks like influxdb - if target.Measurement != "" { - return converter.convertInfluxDBTarget(target) - } - - // looks like stackdriver - if target.MetricType != "" { - return converter.convertStackdriverTarget(target) - } - - converter.logger.Warn("unhandled target type: skipped", zap.Any("target", target)) - - return nil -} - -func (converter *JSON) convertPrometheusTarget(target sdk.Target) *grabana.Target { - return &grabana.Target{ - Prometheus: &grabana.PrometheusTarget{ - Query: target.Expr, - Legend: target.LegendFormat, - Ref: target.RefID, - Hidden: target.Hide, - Format: target.Format, - Instant: target.Instant, - IntervalFactor: &target.IntervalFactor, - }, - } -} - -func (converter *JSON) convertGraphiteTarget(target sdk.Target) *grabana.Target { - return &grabana.Target{ - Graphite: &grabana.GraphiteTarget{ - Query: target.Target, - Ref: target.RefID, - Hidden: target.Hide, - }, - } -} - -func (converter *JSON) convertInfluxDBTarget(target sdk.Target) *grabana.Target { - return &grabana.Target{ - InfluxDB: &grabana.InfluxDBTarget{ - Query: target.Measurement, - Ref: target.RefID, - Hidden: target.Hide, - }, - } -} - -func (converter *JSON) convertStackdriverTarget(target sdk.Target) *grabana.Target { - switch strings.ToLower(target.MetricKind) { - case "cumulative": - case "gauge": - case "delta": - default: - converter.logger.Warn("unhandled stackdriver metric kind: target skipped", zap.Any("metricKind", target.MetricKind)) - return nil - } - - var aggregation string - if target.CrossSeriesReducer != "" { - aggregationMap := map[string]string{ - string(stackdriver.ReduceNone): "none", - string(stackdriver.ReduceMean): "mean", - string(stackdriver.ReduceMin): "min", - string(stackdriver.ReduceMax): "max", - string(stackdriver.ReduceSum): "sum", - string(stackdriver.ReduceStdDev): "stddev", - string(stackdriver.ReduceCount): "count", - string(stackdriver.ReduceCountTrue): "count_true", - string(stackdriver.ReduceCountFalse): "count_false", - string(stackdriver.ReduceCountFractionTrue): "fraction_true", - string(stackdriver.ReducePercentile99): "percentile_99", - string(stackdriver.ReducePercentile95): "percentile_95", - string(stackdriver.ReducePercentile50): "percentile_50", - string(stackdriver.ReducePercentile05): "percentile_05", - } - - if agg, ok := aggregationMap[target.CrossSeriesReducer]; ok { - aggregation = agg - } else { - converter.logger.Warn("unhandled stackdriver crossSeriesReducer: target skipped", zap.Any("crossSeriesReducer", target.CrossSeriesReducer)) - } - } - - var alignment *grabana.StackdriverAlignment - if target.PerSeriesAligner != "" { - alignmentMethodMap := map[string]string{ - string(stackdriver.AlignNone): "none", - string(stackdriver.AlignDelta): "delta", - string(stackdriver.AlignRate): "rate", - string(stackdriver.AlignInterpolate): "interpolate", - string(stackdriver.AlignNextOlder): "next_older", - string(stackdriver.AlignMin): "min", - string(stackdriver.AlignMax): "max", - string(stackdriver.AlignMean): "mean", - string(stackdriver.AlignCount): "count", - string(stackdriver.AlignSum): "sum", - string(stackdriver.AlignStdDev): "stddev", - string(stackdriver.AlignCountTrue): "count_true", - string(stackdriver.AlignCountFalse): "count_false", - string(stackdriver.AlignFractionTrue): "fraction_true", - string(stackdriver.AlignPercentile99): "percentile_99", - string(stackdriver.AlignPercentile95): "percentile_95", - string(stackdriver.AlignPercentile50): "percentile_50", - string(stackdriver.AlignPercentile05): "percentile_05", - string(stackdriver.AlignPercentChange): "percent_change", - } - - if method, ok := alignmentMethodMap[target.PerSeriesAligner]; ok { - alignment = &grabana.StackdriverAlignment{ - Period: target.AlignmentPeriod, - Method: method, - } - } else { - converter.logger.Warn("unhandled stackdriver perSeriesAligner: target skipped", zap.Any("perSeriesAligner", target.PerSeriesAligner)) - } - } - - return &grabana.Target{ - Stackdriver: &grabana.StackdriverTarget{ - Project: target.ProjectName, - Type: strings.ToLower(target.MetricKind), - Metric: target.MetricType, - Filters: converter.convertStackdriverFilters(target), - Aggregation: aggregation, - Alignment: alignment, - GroupBy: target.GroupBys, - Legend: target.AliasBy, - Ref: target.RefID, - Hidden: target.Hide, - }, - } -} - -func (converter *JSON) convertStackdriverFilters(target sdk.Target) grabana.StackdriverFilters { - filters := grabana.StackdriverFilters{ - Eq: map[string]string{}, - Neq: map[string]string{}, - Matches: map[string]string{}, - NotMatches: map[string]string{}, - } - - var leftOperand, rightOperand, operator *string - for i := range target.Filters { - if target.Filters[i] == "AND" { - continue - } - - if leftOperand == nil { - leftOperand = &target.Filters[i] - continue - } - if operator == nil { - operator = &target.Filters[i] - continue - } - if rightOperand == nil { - rightOperand = &target.Filters[i] - } - - if leftOperand != nil && operator != nil && rightOperand != nil { - switch *operator { - case "=": - filters.Eq[*leftOperand] = *rightOperand - case "!=": - filters.Neq[*leftOperand] = *rightOperand - case "=~": - filters.Matches[*leftOperand] = *rightOperand - case "!=~": - filters.NotMatches[*leftOperand] = *rightOperand - default: - converter.logger.Warn("unhandled stackdriver filter operator: filter skipped", zap.Any("operator", *operator)) - } - - leftOperand = nil - rightOperand = nil - operator = nil - } - } - - return filters - -} - func panelSpan(panel sdk.Panel) float32 { span := panel.Span if span == 0 && panel.GridPos.H != nil { diff --git a/internal/pkg/converter/json_test.go b/internal/pkg/converter/json_test.go index be1fb71c..c7e4f73f 100644 --- a/internal/pkg/converter/json_test.go +++ b/internal/pkg/converter/json_test.go @@ -2,7 +2,6 @@ package converter import ( "bytes" - "reflect" "testing" grabana "github.com/K-Phoen/grabana/decoder" @@ -11,14 +10,6 @@ import ( "go.uber.org/zap" ) -func defaultVar(varType string) sdk.TemplateVar { - return sdk.TemplateVar{ - Type: varType, - Name: "var", - Label: "Label", - } -} - func TestConvertInvalidJSONToYAML(t *testing.T) { req := require.New(t) @@ -98,806 +89,6 @@ func TestConvertGeneralSettings(t *testing.T) { req.True(dashboard.SharedCrosshair) } -func TestConvertUnknownVar(t *testing.T) { - req := require.New(t) - - variable := defaultVar("unknown") - - converter := NewJSON(zap.NewNop()) - - dashboard := &grabana.DashboardModel{} - converter.convertVariables([]sdk.TemplateVar{variable}, dashboard) - - req.Len(dashboard.Variables, 0) -} - -func TestConvertIntervalVar(t *testing.T) { - req := require.New(t) - - variable := defaultVar("interval") - variable.Name = "var_interval" - variable.Label = "Label interval" - variable.Hide = 2 - variable.Current = sdk.Current{Text: &sdk.StringSliceString{Value: []string{"30sec"}, Valid: true}, Value: "30s"} - variable.Options = []sdk.Option{ - {Text: "10sec", Value: "10s"}, - {Text: "30sec", Value: "30s"}, - {Text: "1min", Value: "1m"}, - } - - converter := NewJSON(zap.NewNop()) - - dashboard := &grabana.DashboardModel{} - converter.convertVariables([]sdk.TemplateVar{variable}, dashboard) - - req.Len(dashboard.Variables, 1) - req.NotNil(dashboard.Variables[0].Interval) - - interval := dashboard.Variables[0].Interval - - req.Equal("var_interval", interval.Name) - req.Equal("Label interval", interval.Label) - req.Equal("30s", interval.Default) - req.Equal("variable", interval.Hide) - req.ElementsMatch([]string{"10s", "30s", "1m"}, interval.Values) -} - -func TestConvertCustomVar(t *testing.T) { - req := require.New(t) - - variable := defaultVar("custom") - variable.Name = "var_custom" - variable.Label = "Label custom" - variable.Hide = 3 // unknown Hide value - variable.Current = sdk.Current{Text: &sdk.StringSliceString{Value: []string{"85th"}, Valid: true}, Value: "85"} - variable.Options = []sdk.Option{ - {Text: "50th", Value: "50"}, - {Text: "85th", Value: "85"}, - {Text: "99th", Value: "99"}, - } - - converter := NewJSON(zap.NewNop()) - - dashboard := &grabana.DashboardModel{} - converter.convertVariables([]sdk.TemplateVar{variable}, dashboard) - - req.Len(dashboard.Variables, 1) - req.NotNil(dashboard.Variables[0].Custom) - - custom := dashboard.Variables[0].Custom - - req.Equal("var_custom", custom.Name) - req.Equal("Label custom", custom.Label) - req.Equal("85", custom.Default) - req.Empty(custom.Hide) - req.True(reflect.DeepEqual(custom.ValuesMap, map[string]string{ - "50th": "50", - "85th": "85", - "99th": "99", - })) -} - -func TestConvertDatasourceVar(t *testing.T) { - req := require.New(t) - - variable := defaultVar("datasource") - variable.Name = "var_datasource" - variable.Label = "Label datasource" - - converter := NewJSON(zap.NewNop()) - - dashboard := &grabana.DashboardModel{} - converter.convertVariables([]sdk.TemplateVar{variable}, dashboard) - - req.Len(dashboard.Variables, 1) - req.NotNil(dashboard.Variables[0].Datasource) - - dsVar := dashboard.Variables[0].Datasource - - req.Equal("var_datasource", dsVar.Name) - req.Equal("Label datasource", dsVar.Label) - req.Empty(dsVar.Hide) - req.False(dsVar.IncludeAll) -} - -func TestConvertConstVar(t *testing.T) { - req := require.New(t) - - variable := defaultVar("const") - variable.Name = "var_const" - variable.Label = "Label const" - variable.Hide = 0 - variable.Current = sdk.Current{Text: &sdk.StringSliceString{Value: []string{"85th"}, Valid: true}, Value: "85"} - variable.Options = []sdk.Option{ - {Text: "85th", Value: "85"}, - {Text: "99th", Value: "99"}, - } - - converter := NewJSON(zap.NewNop()) - - dashboard := &grabana.DashboardModel{} - converter.convertVariables([]sdk.TemplateVar{variable}, dashboard) - - req.Len(dashboard.Variables, 1) - req.NotNil(dashboard.Variables[0].Const) - - constant := dashboard.Variables[0].Const - - req.Equal("var_const", constant.Name) - req.Equal("Label const", constant.Label) - req.Equal("85th", constant.Default) - req.Empty(constant.Hide) - req.True(reflect.DeepEqual(constant.ValuesMap, map[string]string{ - "85th": "85", - "99th": "99", - })) -} - -func TestConvertQueryVar(t *testing.T) { - req := require.New(t) - datasource := "prometheus-default" - - variable := defaultVar("query") - variable.Name = "var_query" - variable.Label = "Query" - variable.IncludeAll = true - variable.Hide = 1 - variable.Current = sdk.Current{Value: "$__all"} - variable.Datasource = &datasource - variable.Query = "prom_query" - - converter := NewJSON(zap.NewNop()) - - dashboard := &grabana.DashboardModel{} - converter.convertVariables([]sdk.TemplateVar{variable}, dashboard) - - req.Len(dashboard.Variables, 1) - req.NotNil(dashboard.Variables[0].Query) - - query := dashboard.Variables[0].Query - - req.Equal("var_query", query.Name) - req.Equal("Query", query.Label) - req.Equal(datasource, query.Datasource) - req.Equal("prom_query", query.Request) - req.Equal("label", query.Hide) - req.True(query.IncludeAll) - req.True(query.DefaultAll) -} - -func TestConvertExternalLink(t *testing.T) { - req := require.New(t) - - externalLink := sdk.Link{ - Title: "joe", - Type: "link", - Icon: strPtr("cloud"), - IncludeVars: true, - KeepTime: boolPtr(true), - TargetBlank: boolPtr(true), - Tooltip: strPtr("description"), - URL: strPtr("http://lala"), - } - dashLink := sdk.Link{ - Title: "not link", - } - - converter := NewJSON(zap.NewNop()) - - dashboard := &grabana.DashboardModel{} - converter.convertExternalLinks([]sdk.Link{externalLink, dashLink}, dashboard) - - req.Len(dashboard.ExternalLinks, 1) - - link := dashboard.ExternalLinks[0] - - req.Equal("joe", link.Title) - req.Equal("description", link.Description) - req.Equal("cloud", link.Icon) - req.Equal("http://lala", link.URL) - req.True(link.OpenInNewTab) - req.True(link.IncludeTimeRange) - req.True(link.IncludeVariableValues) -} - -func TestConvertRow(t *testing.T) { - req := require.New(t) - - converter := NewJSON(zap.NewNop()) - - row := converter.convertRow(sdk.Panel{CommonPanel: sdk.CommonPanel{Title: "Row title"}}) - - req.Equal("Row title", row.Name) -} - -func TestConvertCollapsedRow(t *testing.T) { - req := require.New(t) - - converter := NewJSON(zap.NewNop()) - - row := converter.convertRow(sdk.Panel{ - CommonPanel: sdk.CommonPanel{ - Title: "Row title", - }, - RowPanel: &sdk.RowPanel{ - Collapsed: true, - }, - }) - - req.Equal("Row title", row.Name) - req.True(row.Collapse) -} - -func TestConvertTargetFailsIfNoValidTargetIsGiven(t *testing.T) { - req := require.New(t) - converter := NewJSON(zap.NewNop()) - - convertedTarget := converter.convertTarget(sdk.Target{}) - req.Nil(convertedTarget) -} - -func TestConvertTargetWithPrometheusTarget(t *testing.T) { - req := require.New(t) - - converter := NewJSON(zap.NewNop()) - - target := sdk.Target{ - Expr: "prometheus_query", - LegendFormat: "{{ field }}", - RefID: "A", - } - - convertedTarget := converter.convertTarget(target) - - req.NotNil(convertedTarget) - req.Nil(convertedTarget.Stackdriver) - req.Equal("prometheus_query", convertedTarget.Prometheus.Query) - req.Equal("{{ field }}", convertedTarget.Prometheus.Legend) - req.Equal("A", convertedTarget.Prometheus.Ref) -} - -func TestConvertTargetWithGraphiteTarget(t *testing.T) { - req := require.New(t) - - converter := NewJSON(zap.NewNop()) - - target := sdk.Target{ - Target: "graphite_query", - RefID: "A", - Hide: true, - } - - convertedTarget := converter.convertTarget(target) - - req.NotNil(convertedTarget) - req.NotNil(convertedTarget.Graphite) - req.Equal("graphite_query", convertedTarget.Graphite.Query) - req.Equal("A", convertedTarget.Graphite.Ref) - req.True(convertedTarget.Graphite.Hidden) -} - -func TestConvertTargetWithInfluxDBTarget(t *testing.T) { - req := require.New(t) - - converter := NewJSON(zap.NewNop()) - - target := sdk.Target{ - Measurement: "influxdb_query", - RefID: "A", - Hide: true, - } - - convertedTarget := converter.convertTarget(target) - - req.NotNil(convertedTarget) - req.NotNil(convertedTarget.InfluxDB) - req.Equal("influxdb_query", convertedTarget.InfluxDB.Query) - req.Equal("A", convertedTarget.InfluxDB.Ref) - req.True(convertedTarget.InfluxDB.Hidden) -} - -func TestConvertTargetWithStackdriverTargetFailsIfNoMetricKind(t *testing.T) { - req := require.New(t) - converter := NewJSON(zap.NewNop()) - - target := sdk.Target{ - MetricType: "pubsub.googleapis.com/subscription/ack_message_count", - } - - convertedTarget := converter.convertTarget(target) - - req.Nil(convertedTarget) -} - -func TestConvertTargetWithStackdriverTargetIgnoresUnknownCrossSeriesReducer(t *testing.T) { - req := require.New(t) - converter := NewJSON(zap.NewNop()) - - target := sdk.Target{ - MetricKind: "DELTA", - MetricType: "pubsub.googleapis.com/subscription/ack_message_count", - CrossSeriesReducer: "unknown", - } - - convertedTarget := converter.convertTarget(target) - - req.NotNil(convertedTarget) - req.NotNil(convertedTarget.Stackdriver) - req.Empty(convertedTarget.Stackdriver.Aggregation) -} - -func TestConvertTargetWithStackdriverTargetIgnoresUnknownAligner(t *testing.T) { - req := require.New(t) - converter := NewJSON(zap.NewNop()) - - target := sdk.Target{ - MetricKind: "DELTA", - MetricType: "pubsub.googleapis.com/subscription/ack_message_count", - PerSeriesAligner: "unknown", - } - - convertedTarget := converter.convertTarget(target) - - req.NotNil(convertedTarget) - req.NotNil(convertedTarget.Stackdriver) - req.Empty(convertedTarget.Stackdriver.Alignment) -} - -func TestConvertTargetWithStackdriverTarget(t *testing.T) { - req := require.New(t) - - converter := NewJSON(zap.NewNop()) - - target := sdk.Target{ - MetricKind: "DELTA", - MetricType: "pubsub.googleapis.com/subscription/ack_message_count", - CrossSeriesReducer: "REDUCE_MEAN", - PerSeriesAligner: "ALIGN_DELTA", - AlignmentPeriod: "stackdriver-auto", - GroupBys: []string{"field"}, - AliasBy: "legend", - RefID: "A", - Filters: []string{ - "resource.label.subscription_id", - "=", - "subscription_name", - "AND", - "other-property", - "!=", - "other-value", - "AND", - "regex-property", - "=~", - "regex-value", - "AND", - "regex-not-property", - "!=~", - "regex-not-value", - }, - } - - convertedTarget := converter.convertTarget(target) - - req.NotNil(convertedTarget) - req.Nil(convertedTarget.Prometheus) - req.NotNil(convertedTarget.Stackdriver) - req.Equal("delta", convertedTarget.Stackdriver.Type) - req.Equal("pubsub.googleapis.com/subscription/ack_message_count", convertedTarget.Stackdriver.Metric) - req.Equal("mean", convertedTarget.Stackdriver.Aggregation) - req.Equal("stackdriver-auto", convertedTarget.Stackdriver.Alignment.Period) - req.Equal("delta", convertedTarget.Stackdriver.Alignment.Method) - req.Equal("legend", convertedTarget.Stackdriver.Legend) - req.Equal("A", convertedTarget.Stackdriver.Ref) - req.ElementsMatch([]string{"field"}, convertedTarget.Stackdriver.GroupBy) - req.EqualValues(map[string]string{"resource.label.subscription_id": "subscription_name"}, convertedTarget.Stackdriver.Filters.Eq) - req.EqualValues(map[string]string{"other-property": "other-value"}, convertedTarget.Stackdriver.Filters.Neq) - req.EqualValues(map[string]string{"regex-property": "regex-value"}, convertedTarget.Stackdriver.Filters.Matches) - req.EqualValues(map[string]string{"regex-not-property": "regex-not-value"}, convertedTarget.Stackdriver.Filters.NotMatches) -} - -func TestConvertTargetWithStackdriverGauge(t *testing.T) { - req := require.New(t) - - converter := NewJSON(zap.NewNop()) - - target := sdk.Target{ - MetricKind: "GAUGE", - MetricType: "pubsub.googleapis.com/subscription/ack_message_count", - } - - convertedTarget := converter.convertTarget(target) - - req.NotNil(convertedTarget) - req.Nil(convertedTarget.Prometheus) - req.NotNil(convertedTarget.Stackdriver) - req.Equal("gauge", convertedTarget.Stackdriver.Type) - req.Equal("pubsub.googleapis.com/subscription/ack_message_count", convertedTarget.Stackdriver.Metric) -} - -func TestConvertTargetWithStackdriverCumulative(t *testing.T) { - req := require.New(t) - - converter := NewJSON(zap.NewNop()) - - target := sdk.Target{ - MetricKind: "CUMULATIVE", - MetricType: "pubsub.googleapis.com/subscription/ack_message_count", - } - - convertedTarget := converter.convertTarget(target) - - req.NotNil(convertedTarget) - req.Nil(convertedTarget.Prometheus) - req.NotNil(convertedTarget.Stackdriver) - req.Equal("cumulative", convertedTarget.Stackdriver.Type) - req.Equal("pubsub.googleapis.com/subscription/ack_message_count", convertedTarget.Stackdriver.Metric) -} - -func TestConvertTagAnnotationIgnoresBuiltIn(t *testing.T) { - req := require.New(t) - - annotation := sdk.Annotation{Name: "Annotations & Alerts"} - dashboard := &grabana.DashboardModel{} - - NewJSON(zap.NewNop()).convertAnnotations([]sdk.Annotation{annotation}, dashboard) - - req.Len(dashboard.TagsAnnotation, 0) -} - -func TestConvertTagAnnotationIgnoresUnknownTypes(t *testing.T) { - req := require.New(t) - - annotation := sdk.Annotation{Name: "Will be ignored", Type: "dashboard"} - dashboard := &grabana.DashboardModel{} - - NewJSON(zap.NewNop()).convertAnnotations([]sdk.Annotation{annotation}, dashboard) - - req.Len(dashboard.TagsAnnotation, 0) -} - -func TestConvertTagAnnotation(t *testing.T) { - req := require.New(t) - - converter := NewJSON(zap.NewNop()) - - datasource := "-- Grafana --" - annotation := sdk.Annotation{ - Type: "tags", - Datasource: &datasource, - IconColor: "#5794F2", - Name: "Deployments", - Tags: []string{"deploy"}, - } - dashboard := &grabana.DashboardModel{} - - converter.convertAnnotations([]sdk.Annotation{annotation}, dashboard) - - req.Len(dashboard.TagsAnnotation, 1) - req.Equal("Deployments", dashboard.TagsAnnotation[0].Name) - req.ElementsMatch([]string{"deploy"}, dashboard.TagsAnnotation[0].Tags) - req.Equal("#5794F2", dashboard.TagsAnnotation[0].IconColor) - req.Equal(datasource, dashboard.TagsAnnotation[0].Datasource) -} - -func TestConvertLegend(t *testing.T) { - req := require.New(t) - - converter := NewJSON(zap.NewNop()) - - rawLegend := sdk.Legend{ - AlignAsTable: true, - Avg: true, - Current: true, - HideEmpty: true, - HideZero: true, - Max: true, - Min: true, - RightSide: true, - Show: true, - Total: true, - } - - legend := converter.convertLegend(rawLegend) - - req.ElementsMatch( - []string{"as_table", "to_the_right", "min", "max", "avg", "current", "total", "no_null_series", "no_zero_series"}, - legend, - ) -} - -func TestConvertCanHideLegend(t *testing.T) { - req := require.New(t) - converter := NewJSON(zap.NewNop()) - - legend := converter.convertLegend(sdk.Legend{Show: false}) - req.ElementsMatch([]string{"hide"}, legend) -} - -func TestConvertAxis(t *testing.T) { - req := require.New(t) - - converter := NewJSON(zap.NewNop()) - - rawAxis := sdk.Axis{ - Format: "bytes", - LogBase: 2, - Min: &sdk.FloatString{Value: 0}, - Max: &sdk.FloatString{Value: 42}, - Show: true, - Label: "Axis", - } - - axis := converter.convertAxis(rawAxis) - - req.Equal("bytes", *axis.Unit) - req.Equal("Axis", axis.Label) - req.EqualValues(0, *axis.Min) - req.EqualValues(42, *axis.Max) - req.False(*axis.Hidden) -} - -func TestConvertTextPanelWithMarkdown(t *testing.T) { - req := require.New(t) - - converter := NewJSON(zap.NewNop()) - height := "200px" - - textPanel := sdk.Panel{ - CommonPanel: sdk.CommonPanel{ - Title: "Text panel", - Transparent: true, - Height: &height, - Type: "text", - }, - TextPanel: &sdk.TextPanel{ - Options: struct { - Content string `json:"content"` - Mode string `json:"mode"` - }{Content: "# hello world", Mode: "markdown"}, - }, - } - - converted, ok := converter.convertDataPanel(textPanel) - - req.True(ok) - req.True(converted.Text.Transparent) - req.Equal("Text panel", converted.Text.Title) - req.Equal("# hello world", converted.Text.Markdown) - req.Equal(height, converted.Text.Height) -} - -func TestConvertTextPanelWithHTML(t *testing.T) { - req := require.New(t) - - converter := NewJSON(zap.NewNop()) - - textPanel := sdk.Panel{ - CommonPanel: sdk.CommonPanel{ - Title: "Text panel html", - Type: "text", - Description: strPtr("panel description"), - }, - TextPanel: &sdk.TextPanel{ - Mode: "html", - Options: struct { - Content string `json:"content"` - Mode string `json:"mode"` - }{Content: "

hello world

", Mode: "html"}, - }, - } - - converted, ok := converter.convertDataPanel(textPanel) - - req.True(ok) - req.False(converted.Text.Transparent) - req.Equal("Text panel html", converted.Text.Title) - req.Equal("panel description", converted.Text.Description) - req.Equal("

hello world

", converted.Text.HTML) -} - -func TestConvertSingleStatPanel(t *testing.T) { - req := require.New(t) - - converter := NewJSON(zap.NewNop()) - height := "200px" - datasource := "prometheus" - - singlestatPanel := sdk.Panel{ - CommonPanel: sdk.CommonPanel{ - Title: "Singlestat panel", - Description: strPtr("panel desc"), - Type: "singlestat", - Transparent: true, - Height: &height, - Datasource: &datasource, - }, - SinglestatPanel: &sdk.SinglestatPanel{ - Format: "none", - ValueName: "current", - ValueFontSize: "120%", - PrefixFontSize: strPtr("80%"), - PostfixFontSize: strPtr("80%"), - Colors: []string{"blue", "red", "green"}, - ColorBackground: true, - ColorValue: true, - }, - } - - converted, ok := converter.convertDataPanel(singlestatPanel) - - req.True(ok) - req.True(converted.SingleStat.Transparent) - req.Equal("Singlestat panel", converted.SingleStat.Title) - req.Equal("panel desc", converted.SingleStat.Description) - req.Equal("none", converted.SingleStat.Unit) - req.Equal("current", converted.SingleStat.ValueType) - req.Equal("120%", converted.SingleStat.ValueFontSize) - req.Equal("80%", converted.SingleStat.PrefixFontSize) - req.Equal("80%", converted.SingleStat.PostfixFontSize) - req.Equal(height, converted.SingleStat.Height) - req.Equal(datasource, converted.SingleStat.Datasource) - req.True(reflect.DeepEqual(converted.SingleStat.Colors, [3]string{ - "blue", "red", "green", - })) - req.True(reflect.DeepEqual(converted.SingleStat.Color, []string{ - "background", "value", - })) -} - -func TestConvertHeatmapPanel(t *testing.T) { - req := require.New(t) - - converter := NewJSON(zap.NewNop()) - height := "400px" - datasource := "prometheus" - - heatmapPanel := sdk.Panel{ - CommonPanel: sdk.CommonPanel{ - Title: "heatmap panel", - Type: "heatmap", - Description: strPtr("heatmap description"), - Transparent: true, - Height: &height, - Datasource: &datasource, - }, - HeatmapPanel: &sdk.HeatmapPanel{ - HideZeroBuckets: true, - HighlightCards: true, - ReverseYBuckets: true, - DataFormat: "tsbuckets", - }, - } - - converted, ok := converter.convertDataPanel(heatmapPanel) - - req.True(ok) - req.True(converted.Heatmap.Transparent) - req.Equal("heatmap panel", converted.Heatmap.Title) - req.Equal("heatmap description", converted.Heatmap.Description) - req.Equal(height, converted.Heatmap.Height) - req.Equal(datasource, converted.Heatmap.Datasource) - req.True(converted.Heatmap.ReverseYBuckets) - req.True(converted.Heatmap.HideZeroBuckets) - req.True(converted.Heatmap.HighlightCards) - req.Equal("time_series_buckets", converted.Heatmap.DataFormat) -} - -func TestConvertGraphPanel(t *testing.T) { - req := require.New(t) - - converter := NewJSON(zap.NewNop()) - height := "400px" - datasource := "prometheus" - - graphPanel := sdk.Panel{ - CommonPanel: sdk.CommonPanel{ - Title: "graph panel", - Type: "graph", - Description: strPtr("graph description"), - Transparent: true, - Height: &height, - Datasource: &datasource, - }, - GraphPanel: &sdk.GraphPanel{}, - } - - converted, ok := converter.convertDataPanel(graphPanel) - - req.True(ok) - req.NotNil(converted.Graph) - - graph := converted.Graph - req.True(graph.Transparent) - req.Equal("graph panel", graph.Title) - req.Equal("graph description", graph.Description) - req.Equal(height, graph.Height) - req.Equal(datasource, graph.Datasource) -} - -func TestConvertVisualization(t *testing.T) { - req := require.New(t) - converter := NewJSON(zap.NewNop()) - enabled := true - - graphPanel := sdk.Panel{ - CommonPanel: sdk.CommonPanel{ - Title: "graph panel", - Type: "graph", - }, - GraphPanel: &sdk.GraphPanel{ - NullPointMode: "connected", - SteppedLine: true, - SeriesOverrides: []sdk.SeriesOverride{ - { - Alias: "alias", - Dashes: &enabled, - }, - }, - }, - } - - visualization := converter.convertVisualization(graphPanel) - - req.True(visualization.Staircase) - req.Equal("connected", visualization.NullValue) - req.Len(visualization.Overrides, 1) -} - -func TestConvertGraphOverridesWithNoOverride(t *testing.T) { - req := require.New(t) - converter := NewJSON(zap.NewNop()) - - graphPanel := sdk.Panel{ - CommonPanel: sdk.CommonPanel{ - Title: "graph panel", - Type: "graph", - }, - GraphPanel: &sdk.GraphPanel{}, - } - - overrides := converter.convertGraphOverrides(graphPanel) - - req.Len(overrides, 0) -} - -func TestConvertGraphOverridesWithOneOverride(t *testing.T) { - req := require.New(t) - converter := NewJSON(zap.NewNop()) - color := "red" - enabled := true - number := 2 - - graphPanel := sdk.Panel{ - CommonPanel: sdk.CommonPanel{ - Title: "heatmap panel", - Type: "graph", - }, - GraphPanel: &sdk.GraphPanel{ - SeriesOverrides: []sdk.SeriesOverride{ - { - Alias: "alias", - Color: &color, - Dashes: &enabled, - Fill: &number, - Lines: &enabled, - }, - }, - }, - } - - overrides := converter.convertGraphOverrides(graphPanel) - - req.Len(overrides, 1) - - override := overrides[0] - - req.Equal("alias", override.Alias) - req.Equal(color, override.Color) - req.True(*override.Dashes) - req.True(*override.Lines) - req.Equal(number, *override.Fill) -} - func strPtr(input string) *string { return &input } diff --git a/internal/pkg/converter/row.go b/internal/pkg/converter/row.go new file mode 100644 index 00000000..46324354 --- /dev/null +++ b/internal/pkg/converter/row.go @@ -0,0 +1,24 @@ +package converter + +import ( + grabana "github.com/K-Phoen/grabana/decoder" + "github.com/K-Phoen/sdk" +) + +func (converter *JSON) convertRow(panel sdk.Panel) *grabana.DashboardRow { + repeat := "" + if panel.Repeat != nil { + repeat = *panel.Repeat + } + collapse := false + if panel.RowPanel != nil && panel.RowPanel.Collapsed { + collapse = true + } + + return &grabana.DashboardRow{ + Name: panel.Title, + Repeat: repeat, + Collapse: collapse, + Panels: nil, + } +} diff --git a/internal/pkg/converter/row_test.go b/internal/pkg/converter/row_test.go new file mode 100644 index 00000000..a74759b4 --- /dev/null +++ b/internal/pkg/converter/row_test.go @@ -0,0 +1,37 @@ +package converter + +import ( + "testing" + + "github.com/K-Phoen/sdk" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestConvertRow(t *testing.T) { + req := require.New(t) + + converter := NewJSON(zap.NewNop()) + + row := converter.convertRow(sdk.Panel{CommonPanel: sdk.CommonPanel{Title: "Row title"}}) + + req.Equal("Row title", row.Name) +} + +func TestConvertCollapsedRow(t *testing.T) { + req := require.New(t) + + converter := NewJSON(zap.NewNop()) + + row := converter.convertRow(sdk.Panel{ + CommonPanel: sdk.CommonPanel{ + Title: "Row title", + }, + RowPanel: &sdk.RowPanel{ + Collapsed: true, + }, + }) + + req.Equal("Row title", row.Name) + req.True(row.Collapse) +} diff --git a/internal/pkg/converter/single_stat.go b/internal/pkg/converter/single_stat.go new file mode 100644 index 00000000..4c284001 --- /dev/null +++ b/internal/pkg/converter/single_stat.go @@ -0,0 +1,116 @@ +package converter + +import ( + "strings" + + grabana "github.com/K-Phoen/grabana/decoder" + "github.com/K-Phoen/grabana/singlestat" + "github.com/K-Phoen/sdk" +) + +func (converter *JSON) convertSingleStat(panel sdk.Panel) grabana.DashboardPanel { + singleStat := &grabana.DashboardSingleStat{ + Title: panel.Title, + Span: panelSpan(panel), + Unit: panel.SinglestatPanel.Format, + Decimals: &panel.SinglestatPanel.Decimals, + ValueType: panel.SinglestatPanel.ValueName, + Transparent: panel.Transparent, + ValueFontSize: panel.SinglestatPanel.ValueFontSize, + } + + if panel.Description != nil { + singleStat.Description = *panel.Description + } + if panel.Repeat != nil { + singleStat.Repeat = *panel.Repeat + } + if panel.Height != nil { + singleStat.Height = *(panel.Height).(*string) + } + if panel.Datasource != nil { + singleStat.Datasource = *panel.Datasource + } + + thresholds := strings.Split(panel.SinglestatPanel.Thresholds, ",") + if len(thresholds) == 2 { + singleStat.Thresholds = [2]string{thresholds[0], thresholds[1]} + } + + if len(panel.SinglestatPanel.Colors) == 3 { + singleStat.Colors = [3]string{ + panel.SinglestatPanel.Colors[0], + panel.SinglestatPanel.Colors[1], + panel.SinglestatPanel.Colors[2], + } + } + + var colorOpts []string + if panel.SinglestatPanel.ColorBackground { + colorOpts = append(colorOpts, "background") + } + if panel.SinglestatPanel.ColorValue { + colorOpts = append(colorOpts, "value") + } + if len(colorOpts) != 0 { + singleStat.Color = colorOpts + } + + if panel.SinglestatPanel.SparkLine.Show && panel.SinglestatPanel.SparkLine.Full { + singleStat.SparkLine = "full" + } + if panel.SinglestatPanel.SparkLine.Show && !panel.SinglestatPanel.SparkLine.Full { + singleStat.SparkLine = "bottom" + } + + // Font sizes + if panel.SinglestatPanel.PrefixFontSize != nil && *panel.SinglestatPanel.PrefixFontSize != "" { + singleStat.PrefixFontSize = *panel.SinglestatPanel.PrefixFontSize + } + if panel.SinglestatPanel.PostfixFontSize != nil && *panel.SinglestatPanel.PostfixFontSize != "" { + singleStat.PostfixFontSize = *panel.SinglestatPanel.PostfixFontSize + } + + // ranges to text mapping + singleStat.RangesToText = converter.convertSingleStatRangesToText(panel) + + for _, target := range panel.SinglestatPanel.Targets { + graphTarget := converter.convertTarget(target) + if graphTarget == nil { + continue + } + + singleStat.Targets = append(singleStat.Targets, *graphTarget) + } + + return grabana.DashboardPanel{SingleStat: singleStat} +} + +func (converter *JSON) convertSingleStatRangesToText(panel sdk.Panel) []singlestat.RangeMap { + if panel.SinglestatPanel.MappingType == nil || *panel.SinglestatPanel.MappingType != 2 { + return nil + } + + mappings := make([]singlestat.RangeMap, 0, len(panel.SinglestatPanel.RangeMaps)) + for _, mapping := range panel.SinglestatPanel.RangeMaps { + converted := singlestat.RangeMap{ + From: "", + To: "", + Text: "", + } + + if mapping.From != nil { + converted.From = *mapping.From + } + if mapping.To != nil { + converted.To = *mapping.To + } + if mapping.Text != nil { + converted.Text = *mapping.Text + } + + mappings = append(mappings, converted) + } + + return mappings +} diff --git a/internal/pkg/converter/single_stat_test.go b/internal/pkg/converter/single_stat_test.go new file mode 100644 index 00000000..d2cc3799 --- /dev/null +++ b/internal/pkg/converter/single_stat_test.go @@ -0,0 +1,59 @@ +package converter + +import ( + "reflect" + "testing" + + "github.com/K-Phoen/sdk" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestConvertSingleStatPanel(t *testing.T) { + req := require.New(t) + + converter := NewJSON(zap.NewNop()) + height := "200px" + datasource := "prometheus" + + singlestatPanel := sdk.Panel{ + CommonPanel: sdk.CommonPanel{ + Title: "Singlestat panel", + Description: strPtr("panel desc"), + Type: "singlestat", + Transparent: true, + Height: &height, + Datasource: &datasource, + }, + SinglestatPanel: &sdk.SinglestatPanel{ + Format: "none", + ValueName: "current", + ValueFontSize: "120%", + PrefixFontSize: strPtr("80%"), + PostfixFontSize: strPtr("80%"), + Colors: []string{"blue", "red", "green"}, + ColorBackground: true, + ColorValue: true, + }, + } + + converted, ok := converter.convertDataPanel(singlestatPanel) + + req.True(ok) + req.True(converted.SingleStat.Transparent) + req.Equal("Singlestat panel", converted.SingleStat.Title) + req.Equal("panel desc", converted.SingleStat.Description) + req.Equal("none", converted.SingleStat.Unit) + req.Equal("current", converted.SingleStat.ValueType) + req.Equal("120%", converted.SingleStat.ValueFontSize) + req.Equal("80%", converted.SingleStat.PrefixFontSize) + req.Equal("80%", converted.SingleStat.PostfixFontSize) + req.Equal(height, converted.SingleStat.Height) + req.Equal(datasource, converted.SingleStat.Datasource) + req.True(reflect.DeepEqual(converted.SingleStat.Colors, [3]string{ + "blue", "red", "green", + })) + req.True(reflect.DeepEqual(converted.SingleStat.Color, []string{ + "background", "value", + })) +} diff --git a/internal/pkg/converter/table.go b/internal/pkg/converter/table.go new file mode 100644 index 00000000..22d5c382 --- /dev/null +++ b/internal/pkg/converter/table.go @@ -0,0 +1,58 @@ +package converter + +import ( + grabana "github.com/K-Phoen/grabana/decoder" + grabanaTable "github.com/K-Phoen/grabana/table" + "github.com/K-Phoen/sdk" + "go.uber.org/zap" +) + +func (converter *JSON) convertTable(panel sdk.Panel) grabana.DashboardPanel { + table := &grabana.DashboardTable{ + Title: panel.Title, + Span: panelSpan(panel), + Transparent: panel.Transparent, + } + + if panel.Description != nil { + table.Description = *panel.Description + } + if panel.Height != nil { + table.Height = *(panel.Height).(*string) + } + if panel.Datasource != nil { + table.Datasource = *panel.Datasource + } + + for _, target := range panel.TablePanel.Targets { + graphTarget := converter.convertTarget(target) + if graphTarget == nil { + continue + } + + table.Targets = append(table.Targets, *graphTarget) + } + + // hidden columns + for _, columnStyle := range panel.TablePanel.Styles { + if columnStyle.Type != "hidden" { + continue + } + + table.HiddenColumns = append(table.HiddenColumns, columnStyle.Pattern) + } + + // time series aggregations + if panel.TablePanel.Transform == "timeseries_aggregations" { + for _, column := range panel.TablePanel.Columns { + table.TimeSeriesAggregations = append(table.TimeSeriesAggregations, grabanaTable.Aggregation{ + Label: column.TextType, + Type: grabanaTable.AggregationType(column.Value), + }) + } + } else { + converter.logger.Warn("unhandled transform type: skipped", zap.String("transform", panel.TablePanel.Transform), zap.String("panel", panel.Title)) + } + + return grabana.DashboardPanel{Table: table} +} diff --git a/internal/pkg/converter/target.go b/internal/pkg/converter/target.go new file mode 100644 index 00000000..7edb590f --- /dev/null +++ b/internal/pkg/converter/target.go @@ -0,0 +1,205 @@ +package converter + +import ( + "strings" + + grabana "github.com/K-Phoen/grabana/decoder" + "github.com/K-Phoen/grabana/target/stackdriver" + "github.com/K-Phoen/sdk" + "go.uber.org/zap" +) + +func (converter *JSON) convertTarget(target sdk.Target) *grabana.Target { + // looks like a prometheus target + if target.Expr != "" { + return converter.convertPrometheusTarget(target) + } + + // looks like graphite + if target.Target != "" { + return converter.convertGraphiteTarget(target) + } + + // looks like influxdb + if target.Measurement != "" { + return converter.convertInfluxDBTarget(target) + } + + // looks like stackdriver + if target.MetricType != "" { + return converter.convertStackdriverTarget(target) + } + + converter.logger.Warn("unhandled target type: skipped", zap.Any("target", target)) + + return nil +} + +func (converter *JSON) convertPrometheusTarget(target sdk.Target) *grabana.Target { + return &grabana.Target{ + Prometheus: &grabana.PrometheusTarget{ + Query: target.Expr, + Legend: target.LegendFormat, + Ref: target.RefID, + Hidden: target.Hide, + Format: target.Format, + Instant: target.Instant, + IntervalFactor: &target.IntervalFactor, + }, + } +} + +func (converter *JSON) convertGraphiteTarget(target sdk.Target) *grabana.Target { + return &grabana.Target{ + Graphite: &grabana.GraphiteTarget{ + Query: target.Target, + Ref: target.RefID, + Hidden: target.Hide, + }, + } +} + +func (converter *JSON) convertInfluxDBTarget(target sdk.Target) *grabana.Target { + return &grabana.Target{ + InfluxDB: &grabana.InfluxDBTarget{ + Query: target.Measurement, + Ref: target.RefID, + Hidden: target.Hide, + }, + } +} + +func (converter *JSON) convertStackdriverTarget(target sdk.Target) *grabana.Target { + switch strings.ToLower(target.MetricKind) { + case "cumulative": + case "gauge": + case "delta": + default: + converter.logger.Warn("unhandled stackdriver metric kind: target skipped", zap.Any("metricKind", target.MetricKind)) + return nil + } + + var aggregation string + if target.CrossSeriesReducer != "" { + aggregationMap := map[string]string{ + string(stackdriver.ReduceNone): "none", + string(stackdriver.ReduceMean): "mean", + string(stackdriver.ReduceMin): "min", + string(stackdriver.ReduceMax): "max", + string(stackdriver.ReduceSum): "sum", + string(stackdriver.ReduceStdDev): "stddev", + string(stackdriver.ReduceCount): "count", + string(stackdriver.ReduceCountTrue): "count_true", + string(stackdriver.ReduceCountFalse): "count_false", + string(stackdriver.ReduceCountFractionTrue): "fraction_true", + string(stackdriver.ReducePercentile99): "percentile_99", + string(stackdriver.ReducePercentile95): "percentile_95", + string(stackdriver.ReducePercentile50): "percentile_50", + string(stackdriver.ReducePercentile05): "percentile_05", + } + + if agg, ok := aggregationMap[target.CrossSeriesReducer]; ok { + aggregation = agg + } else { + converter.logger.Warn("unhandled stackdriver crossSeriesReducer: target skipped", zap.Any("crossSeriesReducer", target.CrossSeriesReducer)) + } + } + + var alignment *grabana.StackdriverAlignment + if target.PerSeriesAligner != "" { + alignmentMethodMap := map[string]string{ + string(stackdriver.AlignNone): "none", + string(stackdriver.AlignDelta): "delta", + string(stackdriver.AlignRate): "rate", + string(stackdriver.AlignInterpolate): "interpolate", + string(stackdriver.AlignNextOlder): "next_older", + string(stackdriver.AlignMin): "min", + string(stackdriver.AlignMax): "max", + string(stackdriver.AlignMean): "mean", + string(stackdriver.AlignCount): "count", + string(stackdriver.AlignSum): "sum", + string(stackdriver.AlignStdDev): "stddev", + string(stackdriver.AlignCountTrue): "count_true", + string(stackdriver.AlignCountFalse): "count_false", + string(stackdriver.AlignFractionTrue): "fraction_true", + string(stackdriver.AlignPercentile99): "percentile_99", + string(stackdriver.AlignPercentile95): "percentile_95", + string(stackdriver.AlignPercentile50): "percentile_50", + string(stackdriver.AlignPercentile05): "percentile_05", + string(stackdriver.AlignPercentChange): "percent_change", + } + + if method, ok := alignmentMethodMap[target.PerSeriesAligner]; ok { + alignment = &grabana.StackdriverAlignment{ + Period: target.AlignmentPeriod, + Method: method, + } + } else { + converter.logger.Warn("unhandled stackdriver perSeriesAligner: target skipped", zap.Any("perSeriesAligner", target.PerSeriesAligner)) + } + } + + return &grabana.Target{ + Stackdriver: &grabana.StackdriverTarget{ + Project: target.ProjectName, + Type: strings.ToLower(target.MetricKind), + Metric: target.MetricType, + Filters: converter.convertStackdriverFilters(target), + Aggregation: aggregation, + Alignment: alignment, + GroupBy: target.GroupBys, + Legend: target.AliasBy, + Ref: target.RefID, + Hidden: target.Hide, + }, + } +} + +func (converter *JSON) convertStackdriverFilters(target sdk.Target) grabana.StackdriverFilters { + filters := grabana.StackdriverFilters{ + Eq: map[string]string{}, + Neq: map[string]string{}, + Matches: map[string]string{}, + NotMatches: map[string]string{}, + } + + var leftOperand, rightOperand, operator *string + for i := range target.Filters { + if target.Filters[i] == "AND" { + continue + } + + if leftOperand == nil { + leftOperand = &target.Filters[i] + continue + } + if operator == nil { + operator = &target.Filters[i] + continue + } + if rightOperand == nil { + rightOperand = &target.Filters[i] + } + + if leftOperand != nil && operator != nil && rightOperand != nil { + switch *operator { + case "=": + filters.Eq[*leftOperand] = *rightOperand + case "!=": + filters.Neq[*leftOperand] = *rightOperand + case "=~": + filters.Matches[*leftOperand] = *rightOperand + case "!=~": + filters.NotMatches[*leftOperand] = *rightOperand + default: + converter.logger.Warn("unhandled stackdriver filter operator: filter skipped", zap.Any("operator", *operator)) + } + + leftOperand = nil + rightOperand = nil + operator = nil + } + } + + return filters +} diff --git a/internal/pkg/converter/target_test.go b/internal/pkg/converter/target_test.go new file mode 100644 index 00000000..c53e7e5f --- /dev/null +++ b/internal/pkg/converter/target_test.go @@ -0,0 +1,214 @@ +package converter + +import ( + "testing" + + "github.com/K-Phoen/sdk" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestConvertTargetFailsIfNoValidTargetIsGiven(t *testing.T) { + req := require.New(t) + converter := NewJSON(zap.NewNop()) + + convertedTarget := converter.convertTarget(sdk.Target{}) + req.Nil(convertedTarget) +} + +func TestConvertTargetWithPrometheusTarget(t *testing.T) { + req := require.New(t) + + converter := NewJSON(zap.NewNop()) + + target := sdk.Target{ + Expr: "prometheus_query", + LegendFormat: "{{ field }}", + RefID: "A", + } + + convertedTarget := converter.convertTarget(target) + + req.NotNil(convertedTarget) + req.Nil(convertedTarget.Stackdriver) + req.Equal("prometheus_query", convertedTarget.Prometheus.Query) + req.Equal("{{ field }}", convertedTarget.Prometheus.Legend) + req.Equal("A", convertedTarget.Prometheus.Ref) +} + +func TestConvertTargetWithGraphiteTarget(t *testing.T) { + req := require.New(t) + + converter := NewJSON(zap.NewNop()) + + target := sdk.Target{ + Target: "graphite_query", + RefID: "A", + Hide: true, + } + + convertedTarget := converter.convertTarget(target) + + req.NotNil(convertedTarget) + req.NotNil(convertedTarget.Graphite) + req.Equal("graphite_query", convertedTarget.Graphite.Query) + req.Equal("A", convertedTarget.Graphite.Ref) + req.True(convertedTarget.Graphite.Hidden) +} + +func TestConvertTargetWithInfluxDBTarget(t *testing.T) { + req := require.New(t) + + converter := NewJSON(zap.NewNop()) + + target := sdk.Target{ + Measurement: "influxdb_query", + RefID: "A", + Hide: true, + } + + convertedTarget := converter.convertTarget(target) + + req.NotNil(convertedTarget) + req.NotNil(convertedTarget.InfluxDB) + req.Equal("influxdb_query", convertedTarget.InfluxDB.Query) + req.Equal("A", convertedTarget.InfluxDB.Ref) + req.True(convertedTarget.InfluxDB.Hidden) +} + +func TestConvertTargetWithStackdriverTargetFailsIfNoMetricKind(t *testing.T) { + req := require.New(t) + converter := NewJSON(zap.NewNop()) + + target := sdk.Target{ + MetricType: "pubsub.googleapis.com/subscription/ack_message_count", + } + + convertedTarget := converter.convertTarget(target) + + req.Nil(convertedTarget) +} + +func TestConvertTargetWithStackdriverTargetIgnoresUnknownCrossSeriesReducer(t *testing.T) { + req := require.New(t) + converter := NewJSON(zap.NewNop()) + + target := sdk.Target{ + MetricKind: "DELTA", + MetricType: "pubsub.googleapis.com/subscription/ack_message_count", + CrossSeriesReducer: "unknown", + } + + convertedTarget := converter.convertTarget(target) + + req.NotNil(convertedTarget) + req.NotNil(convertedTarget.Stackdriver) + req.Empty(convertedTarget.Stackdriver.Aggregation) +} + +func TestConvertTargetWithStackdriverTargetIgnoresUnknownAligner(t *testing.T) { + req := require.New(t) + converter := NewJSON(zap.NewNop()) + + target := sdk.Target{ + MetricKind: "DELTA", + MetricType: "pubsub.googleapis.com/subscription/ack_message_count", + PerSeriesAligner: "unknown", + } + + convertedTarget := converter.convertTarget(target) + + req.NotNil(convertedTarget) + req.NotNil(convertedTarget.Stackdriver) + req.Empty(convertedTarget.Stackdriver.Alignment) +} + +func TestConvertTargetWithStackdriverTarget(t *testing.T) { + req := require.New(t) + + converter := NewJSON(zap.NewNop()) + + target := sdk.Target{ + MetricKind: "DELTA", + MetricType: "pubsub.googleapis.com/subscription/ack_message_count", + CrossSeriesReducer: "REDUCE_MEAN", + PerSeriesAligner: "ALIGN_DELTA", + AlignmentPeriod: "stackdriver-auto", + GroupBys: []string{"field"}, + AliasBy: "legend", + RefID: "A", + Filters: []string{ + "resource.label.subscription_id", + "=", + "subscription_name", + "AND", + "other-property", + "!=", + "other-value", + "AND", + "regex-property", + "=~", + "regex-value", + "AND", + "regex-not-property", + "!=~", + "regex-not-value", + }, + } + + convertedTarget := converter.convertTarget(target) + + req.NotNil(convertedTarget) + req.Nil(convertedTarget.Prometheus) + req.NotNil(convertedTarget.Stackdriver) + req.Equal("delta", convertedTarget.Stackdriver.Type) + req.Equal("pubsub.googleapis.com/subscription/ack_message_count", convertedTarget.Stackdriver.Metric) + req.Equal("mean", convertedTarget.Stackdriver.Aggregation) + req.Equal("stackdriver-auto", convertedTarget.Stackdriver.Alignment.Period) + req.Equal("delta", convertedTarget.Stackdriver.Alignment.Method) + req.Equal("legend", convertedTarget.Stackdriver.Legend) + req.Equal("A", convertedTarget.Stackdriver.Ref) + req.ElementsMatch([]string{"field"}, convertedTarget.Stackdriver.GroupBy) + req.EqualValues(map[string]string{"resource.label.subscription_id": "subscription_name"}, convertedTarget.Stackdriver.Filters.Eq) + req.EqualValues(map[string]string{"other-property": "other-value"}, convertedTarget.Stackdriver.Filters.Neq) + req.EqualValues(map[string]string{"regex-property": "regex-value"}, convertedTarget.Stackdriver.Filters.Matches) + req.EqualValues(map[string]string{"regex-not-property": "regex-not-value"}, convertedTarget.Stackdriver.Filters.NotMatches) +} + +func TestConvertTargetWithStackdriverGauge(t *testing.T) { + req := require.New(t) + + converter := NewJSON(zap.NewNop()) + + target := sdk.Target{ + MetricKind: "GAUGE", + MetricType: "pubsub.googleapis.com/subscription/ack_message_count", + } + + convertedTarget := converter.convertTarget(target) + + req.NotNil(convertedTarget) + req.Nil(convertedTarget.Prometheus) + req.NotNil(convertedTarget.Stackdriver) + req.Equal("gauge", convertedTarget.Stackdriver.Type) + req.Equal("pubsub.googleapis.com/subscription/ack_message_count", convertedTarget.Stackdriver.Metric) +} + +func TestConvertTargetWithStackdriverCumulative(t *testing.T) { + req := require.New(t) + + converter := NewJSON(zap.NewNop()) + + target := sdk.Target{ + MetricKind: "CUMULATIVE", + MetricType: "pubsub.googleapis.com/subscription/ack_message_count", + } + + convertedTarget := converter.convertTarget(target) + + req.NotNil(convertedTarget) + req.Nil(convertedTarget.Prometheus) + req.NotNil(convertedTarget.Stackdriver) + req.Equal("cumulative", convertedTarget.Stackdriver.Type) + req.Equal("pubsub.googleapis.com/subscription/ack_message_count", convertedTarget.Stackdriver.Metric) +} diff --git a/internal/pkg/converter/text.go b/internal/pkg/converter/text.go new file mode 100644 index 00000000..742e5c02 --- /dev/null +++ b/internal/pkg/converter/text.go @@ -0,0 +1,29 @@ +package converter + +import ( + grabana "github.com/K-Phoen/grabana/decoder" + "github.com/K-Phoen/sdk" +) + +func (converter *JSON) convertText(panel sdk.Panel) grabana.DashboardPanel { + text := &grabana.DashboardText{ + Title: panel.Title, + Span: panelSpan(panel), + Transparent: panel.Transparent, + } + + if panel.Description != nil { + text.Description = *panel.Description + } + if panel.Height != nil { + text.Height = *(panel.Height).(*string) + } + + if panel.TextPanel.Options.Mode == "markdown" { + text.Markdown = panel.TextPanel.Options.Content + } else { + text.HTML = panel.TextPanel.Options.Content + } + + return grabana.DashboardPanel{Text: text} +} diff --git a/internal/pkg/converter/text_test.go b/internal/pkg/converter/text_test.go new file mode 100644 index 00000000..a97242fb --- /dev/null +++ b/internal/pkg/converter/text_test.go @@ -0,0 +1,68 @@ +package converter + +import ( + "testing" + + "github.com/K-Phoen/sdk" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestConvertTextPanelWithMarkdown(t *testing.T) { + req := require.New(t) + + converter := NewJSON(zap.NewNop()) + height := "200px" + + textPanel := sdk.Panel{ + CommonPanel: sdk.CommonPanel{ + Title: "Text panel", + Transparent: true, + Height: &height, + Type: "text", + }, + TextPanel: &sdk.TextPanel{ + Options: struct { + Content string `json:"content"` + Mode string `json:"mode"` + }{Content: "# hello world", Mode: "markdown"}, + }, + } + + converted, ok := converter.convertDataPanel(textPanel) + + req.True(ok) + req.True(converted.Text.Transparent) + req.Equal("Text panel", converted.Text.Title) + req.Equal("# hello world", converted.Text.Markdown) + req.Equal(height, converted.Text.Height) +} + +func TestConvertTextPanelWithHTML(t *testing.T) { + req := require.New(t) + + converter := NewJSON(zap.NewNop()) + + textPanel := sdk.Panel{ + CommonPanel: sdk.CommonPanel{ + Title: "Text panel html", + Type: "text", + Description: strPtr("panel description"), + }, + TextPanel: &sdk.TextPanel{ + Mode: "html", + Options: struct { + Content string `json:"content"` + Mode string `json:"mode"` + }{Content: "

hello world

", Mode: "html"}, + }, + } + + converted, ok := converter.convertDataPanel(textPanel) + + req.True(ok) + req.False(converted.Text.Transparent) + req.Equal("Text panel html", converted.Text.Title) + req.Equal("panel description", converted.Text.Description) + req.Equal("

hello world

", converted.Text.HTML) +} diff --git a/internal/pkg/converter/timeseries.go b/internal/pkg/converter/timeseries.go new file mode 100644 index 00000000..d749cacd --- /dev/null +++ b/internal/pkg/converter/timeseries.go @@ -0,0 +1,171 @@ +package converter + +import ( + grabana "github.com/K-Phoen/grabana/decoder" + "github.com/K-Phoen/sdk" +) + +func (converter *JSON) convertTimeSeries(panel sdk.Panel) grabana.DashboardPanel { + tsPanel := &grabana.DashboardTimeSeries{ + Title: panel.Title, + Span: panelSpan(panel), + Transparent: panel.Transparent, + Alert: converter.convertAlert(panel), + Legend: converter.convertTimeSeriesLegend(panel.TimeseriesPanel.Options.Legend), + Visualization: converter.convertTimeSeriesVisualization(panel), + Axis: converter.convertTimeSeriesAxis(panel), + } + + if panel.Description != nil { + tsPanel.Description = *panel.Description + } + if panel.Repeat != nil { + tsPanel.Repeat = *panel.Repeat + } + if panel.Height != nil { + tsPanel.Height = panel.Height.(string) + } + if panel.Datasource != nil { + tsPanel.Datasource = *panel.Datasource + } + + for _, target := range panel.TimeseriesPanel.Targets { + tsTarget := converter.convertTarget(target) + if tsTarget == nil { + continue + } + + tsPanel.Targets = append(tsPanel.Targets, *tsTarget) + } + + return grabana.DashboardPanel{TimeSeries: tsPanel} +} + +func (converter *JSON) convertTimeSeriesAxis(panel sdk.Panel) *grabana.TimeSeriesAxis { + fieldConfig := panel.TimeseriesPanel.FieldConfig + + tsAxis := &grabana.TimeSeriesAxis{ + Unit: fieldConfig.Defaults.Unit, + Label: fieldConfig.Defaults.Custom.AxisLabel, + } + + // decimals + if fieldConfig.Defaults.Decimals != nil { + tsAxis.Decimals = fieldConfig.Defaults.Decimals + } + + // boundaries + if fieldConfig.Defaults.Min != nil { + tsAxis.Min = fieldConfig.Defaults.Min + } + if fieldConfig.Defaults.Max != nil { + tsAxis.Max = fieldConfig.Defaults.Max + } + if fieldConfig.Defaults.Custom.AxisSoftMin != nil { + tsAxis.SoftMin = fieldConfig.Defaults.Custom.AxisSoftMin + } + if fieldConfig.Defaults.Custom.AxisSoftMax != nil { + tsAxis.SoftMax = fieldConfig.Defaults.Custom.AxisSoftMax + } + + // placement + switch fieldConfig.Defaults.Custom.AxisPlacement { + case "hidden": + tsAxis.Display = "hidden" + case "left": + tsAxis.Display = "left" + case "right": + tsAxis.Display = "right" + case "auto": + tsAxis.Display = "auto" + } + + // scale + switch fieldConfig.Defaults.Custom.ScaleDistribution.Type { + case "linear": + tsAxis.Scale = "linear" + case "log": + if fieldConfig.Defaults.Custom.ScaleDistribution.Log == 2 { + tsAxis.Scale = "log2" + } else { + tsAxis.Scale = "log10" + } + } + + return tsAxis +} + +func (converter *JSON) convertTimeSeriesVisualization(panel sdk.Panel) *grabana.TimeSeriesVisualization { + tsViz := &grabana.TimeSeriesVisualization{ + FillOpacity: &panel.TimeseriesPanel.FieldConfig.Defaults.Custom.FillOpacity, + PointSize: &panel.TimeseriesPanel.FieldConfig.Defaults.Custom.PointSize, + } + + // Tooltip mode + if panel.TimeseriesPanel.Options.Tooltip.Mode == "none" { + tsViz.Tooltip = "none" + } else if panel.TimeseriesPanel.Options.Tooltip.Mode == "multi" { + tsViz.Tooltip = "all_series" + } else { + tsViz.Tooltip = "single_series" + } + + // Gradient mode + if panel.TimeseriesPanel.FieldConfig.Defaults.Custom.GradientMode == "none" { + tsViz.GradientMode = "none" + } else if panel.TimeseriesPanel.FieldConfig.Defaults.Custom.GradientMode == "hue" { + tsViz.GradientMode = "hue" + } else if panel.TimeseriesPanel.FieldConfig.Defaults.Custom.GradientMode == "scheme" { + tsViz.GradientMode = "scheme" + } else { + tsViz.GradientMode = "opacity" + } + + return tsViz +} + +func (converter *JSON) convertTimeSeriesLegend(legend sdk.TimeseriesLegendOptions) []string { + options := []string{} + + // Display mode + if legend.DisplayMode == "list" { + options = append(options, "as_list") + } else if legend.DisplayMode == "hidden" { + options = append(options, "hide") + } else { + options = append(options, "as_table") + } + + // Placement + if legend.Placement == "right" { + options = append(options, "to_the_right") + } else { + options = append(options, "to_bottom") + } + + // Automatic calculations + calcs := map[string]string{ + "first": "first", + "firstNotNull": "first_non_null", + "last": "last", + "lastNotNull": "last_non_null", + + "min": "min", + "max": "max", + "mean": "avg", + + "count": "count", + "sum": "total", + "range": "range", + } + + for sdkCalc, grabanaCalc := range calcs { + if !stringInSlice(sdkCalc, legend.Calcs) { + continue + } + + options = append(options, grabanaCalc) + } + + return options +} diff --git a/internal/pkg/converter/timeseries_test.go b/internal/pkg/converter/timeseries_test.go new file mode 100644 index 00000000..4a32165e --- /dev/null +++ b/internal/pkg/converter/timeseries_test.go @@ -0,0 +1,393 @@ +package converter + +import ( + "fmt" + "testing" + + "github.com/K-Phoen/sdk" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestConvertTimeSeriesPanel(t *testing.T) { + req := require.New(t) + + converter := NewJSON(zap.NewNop()) + height := "400px" + datasource := "prometheus" + + panel := sdk.Panel{ + CommonPanel: sdk.CommonPanel{ + OfType: sdk.TimeseriesType, + Title: "test timeseries", + Type: "timeseries", + Description: strPtr("timeseries description"), + Transparent: true, + Height: height, + Datasource: &datasource, + }, + TimeseriesPanel: &sdk.TimeseriesPanel{ + Targets: []sdk.Target{ + { + Expr: "prometheus_query", + LegendFormat: "{{ field }}", + RefID: "A", + }, + }, + }, + } + + converted, ok := converter.convertDataPanel(panel) + + req.True(ok) + req.NotNil(converted.TimeSeries) + + convertedTs := converted.TimeSeries + req.True(convertedTs.Transparent) + req.Equal("test timeseries", convertedTs.Title) + req.Equal("timeseries description", convertedTs.Description) + req.Equal(height, convertedTs.Height) + req.Equal(datasource, convertedTs.Datasource) + req.Len(convertedTs.Targets, 1) +} + +func TestConvertTimeSeriesLegendDisplay(t *testing.T) { + testCases := []struct { + display string + expected string + }{ + { + display: "list", + expected: "as_list", + }, + { + display: "table", + expected: "as_table", + }, + { + display: "hidden", + expected: "hide", + }, + } + + for _, testCase := range testCases { + tc := testCase + + t.Run(tc.display, func(t *testing.T) { + req := require.New(t) + + rawLegend := sdk.TimeseriesLegendOptions{ + DisplayMode: tc.display, + } + + converter := NewJSON(zap.NewNop()) + legend := converter.convertTimeSeriesLegend(rawLegend) + + req.Contains(legend, tc.expected) + }) + } +} + +func TestConvertTimeSeriesLegendPlacement(t *testing.T) { + testCases := []struct { + placement string + expected string + }{ + { + placement: "right", + expected: "to_the_right", + }, + { + placement: "bottom", + expected: "to_bottom", + }, + } + + for _, testCase := range testCases { + tc := testCase + + t.Run(tc.placement, func(t *testing.T) { + req := require.New(t) + + rawLegend := sdk.TimeseriesLegendOptions{ + Placement: tc.placement, + } + + converter := NewJSON(zap.NewNop()) + legend := converter.convertTimeSeriesLegend(rawLegend) + + req.Contains(legend, tc.expected) + }) + } +} + +func TestConvertTimeSeriesLegendCalculations(t *testing.T) { + req := require.New(t) + + rawLegend := sdk.TimeseriesLegendOptions{ + Calcs: []string{ + "first", + "firstNotNull", + "last", + "lastNotNull", + "min", + "max", + "mean", + "count", + "sum", + "range", + }, + } + + converter := NewJSON(zap.NewNop()) + legend := converter.convertTimeSeriesLegend(rawLegend) + + expected := []string{ + "first", + "first_non_null", + "last", + "last_non_null", + "min", + "max", + "avg", + "count", + "total", + "range", + } + for _, expectedItem := range expected { + req.Contains(legend, expectedItem) + } +} + +func TestConvertTimeSeriesVisualizationGradient(t *testing.T) { + testCases := []struct { + mode string + expected string + }{ + { + mode: "none", + expected: "none", + }, + { + mode: "hue", + expected: "hue", + }, + { + mode: "opacity", + expected: "opacity", + }, + { + mode: "scheme", + expected: "scheme", + }, + } + + for _, testCase := range testCases { + tc := testCase + + t.Run(tc.mode, func(t *testing.T) { + req := require.New(t) + + panel := sdk.Panel{ + CommonPanel: sdk.CommonPanel{}, + TimeseriesPanel: &sdk.TimeseriesPanel{ + Options: sdk.TimeseriesOptions{}, + FieldConfig: sdk.FieldConfig{ + Defaults: sdk.FieldConfigDefaults{ + Custom: sdk.FieldConfigCustom{ + GradientMode: tc.mode, + }, + }, + }, + }, + } + + converter := NewJSON(zap.NewNop()) + tsViz := converter.convertTimeSeriesVisualization(panel) + + req.Equal(tc.expected, tsViz.GradientMode) + }) + } +} + +func TestConvertTimeSeriesVisualizationTooltipMode(t *testing.T) { + testCases := []struct { + mode string + expected string + }{ + { + mode: "none", + expected: "none", + }, + { + mode: "single", + expected: "single_series", + }, + { + mode: "multi", + expected: "all_series", + }, + } + + for _, testCase := range testCases { + tc := testCase + + t.Run(tc.mode, func(t *testing.T) { + req := require.New(t) + + panel := sdk.Panel{ + CommonPanel: sdk.CommonPanel{}, + TimeseriesPanel: &sdk.TimeseriesPanel{ + Options: sdk.TimeseriesOptions{ + Tooltip: sdk.TimeseriesTooltipOptions{ + Mode: tc.mode, + }, + }, + FieldConfig: sdk.FieldConfig{}, + }, + } + + converter := NewJSON(zap.NewNop()) + tsViz := converter.convertTimeSeriesVisualization(panel) + + req.Equal(tc.expected, tsViz.Tooltip) + }) + } +} + +func TestConvertTimeSeriesAxisPlacement(t *testing.T) { + testCases := []struct { + placement string + expected string + }{ + {placement: "hidden", expected: "hidden"}, + {placement: "left", expected: "left"}, + {placement: "right", expected: "right"}, + {placement: "auto", expected: "auto"}, + } + + for _, testCase := range testCases { + tc := testCase + + t.Run(tc.placement, func(t *testing.T) { + req := require.New(t) + + panel := sdk.Panel{ + CommonPanel: sdk.CommonPanel{}, + TimeseriesPanel: &sdk.TimeseriesPanel{ + FieldConfig: sdk.FieldConfig{ + Defaults: sdk.FieldConfigDefaults{ + Custom: sdk.FieldConfigCustom{ + AxisPlacement: tc.placement, + }, + }, + }, + }, + } + + converter := NewJSON(zap.NewNop()) + tsAxis := converter.convertTimeSeriesAxis(panel) + + req.Equal(tc.expected, tsAxis.Display) + }) + } +} +func TestConvertTimeSeriesAxisOptions(t *testing.T) { + req := require.New(t) + + panel := sdk.Panel{ + CommonPanel: sdk.CommonPanel{}, + TimeseriesPanel: &sdk.TimeseriesPanel{ + FieldConfig: sdk.FieldConfig{ + Defaults: sdk.FieldConfigDefaults{ + Unit: "short", + Decimals: intPtr(2), + Min: intPtr(1), + Max: intPtr(11), + Custom: sdk.FieldConfigCustom{ + AxisLabel: "label", + AxisSoftMin: intPtr(0), + AxisSoftMax: intPtr(10), + }, + }, + }, + }, + } + + converter := NewJSON(zap.NewNop()) + tsAxis := converter.convertTimeSeriesAxis(panel) + + req.Equal("label", tsAxis.Label) + req.Equal("short", tsAxis.Unit) + req.Equal(2, *tsAxis.Decimals) + req.Equal(1, *tsAxis.Min) + req.Equal(11, *tsAxis.Max) + req.Equal(0, *tsAxis.SoftMin) + req.Equal(10, *tsAxis.SoftMax) +} + +func TestConvertTimeSeriesAxisScale(t *testing.T) { + testCases := []struct { + scaleMode struct { + Type string `json:"type"` + Log int `json:"log,omitempty"` + } + expected string + }{ + { + scaleMode: struct { + Type string `json:"type"` + Log int `json:"log,omitempty"` + }{ + Type: "linear", + }, + expected: "linear", + }, + { + scaleMode: struct { + Type string `json:"type"` + Log int `json:"log,omitempty"` + }{ + Type: "log", + Log: 2, + }, + expected: "log2", + }, + { + scaleMode: struct { + Type string `json:"type"` + Log int `json:"log,omitempty"` + }{ + Type: "log", + Log: 10, + }, + expected: "log10", + }, + } + + for _, testCase := range testCases { + tc := testCase + + t.Run(fmt.Sprintf("%s %d", tc.scaleMode.Type, tc.scaleMode.Log), func(t *testing.T) { + req := require.New(t) + + panel := sdk.Panel{ + CommonPanel: sdk.CommonPanel{}, + TimeseriesPanel: &sdk.TimeseriesPanel{ + FieldConfig: sdk.FieldConfig{ + Defaults: sdk.FieldConfigDefaults{ + Custom: sdk.FieldConfigCustom{ + ScaleDistribution: tc.scaleMode, + }, + }, + }, + }, + } + + converter := NewJSON(zap.NewNop()) + tsAxis := converter.convertTimeSeriesAxis(panel) + + req.Equal(tc.expected, tsAxis.Scale) + }) + } +} diff --git a/internal/pkg/converter/utils.go b/internal/pkg/converter/utils.go new file mode 100644 index 00000000..feacbbd1 --- /dev/null +++ b/internal/pkg/converter/utils.go @@ -0,0 +1,15 @@ +package converter + +func stringInSlice(search string, haystack []string) bool { + for _, item := range haystack { + if item == search { + return true + } + } + + return false +} + +func intPtr(input int) *int { + return &input +} diff --git a/internal/pkg/converter/variable.go b/internal/pkg/converter/variable.go new file mode 100644 index 00000000..7f2459a9 --- /dev/null +++ b/internal/pkg/converter/variable.go @@ -0,0 +1,136 @@ +package converter + +import ( + "strings" + + grabana "github.com/K-Phoen/grabana/decoder" + "github.com/K-Phoen/sdk" + "go.uber.org/zap" +) + +func (converter *JSON) convertVariables(variables []sdk.TemplateVar, dashboard *grabana.DashboardModel) { + for _, variable := range variables { + converter.convertVariable(variable, dashboard) + } +} + +func (converter *JSON) convertVariable(variable sdk.TemplateVar, dashboard *grabana.DashboardModel) { + switch variable.Type { + case "interval": + converter.convertIntervalVar(variable, dashboard) + case "custom": + converter.convertCustomVar(variable, dashboard) + case "query": + converter.convertQueryVar(variable, dashboard) + case "const": + converter.convertConstVar(variable, dashboard) + case "datasource": + converter.convertDatasourceVar(variable, dashboard) + default: + converter.logger.Warn("unhandled variable type found: skipped", zap.String("type", variable.Type), zap.String("name", variable.Name)) + } +} + +func (converter *JSON) convertIntervalVar(variable sdk.TemplateVar, dashboard *grabana.DashboardModel) { + interval := &grabana.VariableInterval{ + Name: variable.Name, + Label: variable.Label, + Default: defaultOption(variable.Current), + Values: make([]string, 0, len(variable.Options)), + Hide: converter.convertVarHide(variable), + } + + for _, opt := range variable.Options { + interval.Values = append(interval.Values, opt.Value) + } + + dashboard.Variables = append(dashboard.Variables, grabana.DashboardVariable{Interval: interval}) +} + +func (converter *JSON) convertCustomVar(variable sdk.TemplateVar, dashboard *grabana.DashboardModel) { + custom := &grabana.VariableCustom{ + Name: variable.Name, + Label: variable.Label, + Default: defaultOption(variable.Current), + ValuesMap: make(map[string]string, len(variable.Options)), + AllValue: variable.AllValue, + IncludeAll: variable.IncludeAll, + Hide: converter.convertVarHide(variable), + } + + for _, opt := range variable.Options { + custom.ValuesMap[opt.Text] = opt.Value + } + + dashboard.Variables = append(dashboard.Variables, grabana.DashboardVariable{Custom: custom}) +} + +func (converter *JSON) convertQueryVar(variable sdk.TemplateVar, dashboard *grabana.DashboardModel) { + datasource := "" + if variable.Datasource != nil { + datasource = *variable.Datasource + } + + query := &grabana.VariableQuery{ + Name: variable.Name, + Label: variable.Label, + Datasource: datasource, + Regex: variable.Regex, + IncludeAll: variable.IncludeAll, + DefaultAll: variable.Current.Value == "$__all", + AllValue: variable.AllValue, + Hide: converter.convertVarHide(variable), + } + + if variable.Query != nil { + query.Request = variable.Query.(string) + } + + dashboard.Variables = append(dashboard.Variables, grabana.DashboardVariable{Query: query}) +} + +func (converter *JSON) convertDatasourceVar(variable sdk.TemplateVar, dashboard *grabana.DashboardModel) { + datasource := &grabana.VariableDatasource{ + Name: variable.Name, + Label: variable.Label, + Regex: variable.Regex, + IncludeAll: variable.IncludeAll, + Hide: converter.convertVarHide(variable), + } + + if variable.Query != nil { + datasource.Type = variable.Query.(string) + } + + dashboard.Variables = append(dashboard.Variables, grabana.DashboardVariable{Datasource: datasource}) +} + +func (converter *JSON) convertConstVar(variable sdk.TemplateVar, dashboard *grabana.DashboardModel) { + constant := &grabana.VariableConst{ + Name: variable.Name, + Label: variable.Label, + Default: strings.Join(variable.Current.Text.Value, ","), + ValuesMap: make(map[string]string, len(variable.Options)), + Hide: converter.convertVarHide(variable), + } + + for _, opt := range variable.Options { + constant.ValuesMap[opt.Text] = opt.Value + } + + dashboard.Variables = append(dashboard.Variables, grabana.DashboardVariable{Const: constant}) +} + +func (converter *JSON) convertVarHide(variable sdk.TemplateVar) string { + switch variable.Hide { + case 0: + return "" + case 1: + return "label" + case 2: + return "variable" + default: + converter.logger.Warn("unknown hide value for variable %s", zap.String("variable", variable.Name)) + return "" + } +} diff --git a/internal/pkg/converter/variable_test.go b/internal/pkg/converter/variable_test.go new file mode 100644 index 00000000..73b81be5 --- /dev/null +++ b/internal/pkg/converter/variable_test.go @@ -0,0 +1,186 @@ +package converter + +import ( + "reflect" + "testing" + + grabana "github.com/K-Phoen/grabana/decoder" + "github.com/K-Phoen/sdk" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func defaultVar(varType string) sdk.TemplateVar { + return sdk.TemplateVar{ + Type: varType, + Name: "var", + Label: "Label", + } +} + +func TestConvertUnknownVar(t *testing.T) { + req := require.New(t) + + variable := defaultVar("unknown") + + converter := NewJSON(zap.NewNop()) + + dashboard := &grabana.DashboardModel{} + converter.convertVariables([]sdk.TemplateVar{variable}, dashboard) + + req.Len(dashboard.Variables, 0) +} + +func TestConvertIntervalVar(t *testing.T) { + req := require.New(t) + + variable := defaultVar("interval") + variable.Name = "var_interval" + variable.Label = "Label interval" + variable.Hide = 2 + variable.Current = sdk.Current{Text: &sdk.StringSliceString{Value: []string{"30sec"}, Valid: true}, Value: "30s"} + variable.Options = []sdk.Option{ + {Text: "10sec", Value: "10s"}, + {Text: "30sec", Value: "30s"}, + {Text: "1min", Value: "1m"}, + } + + converter := NewJSON(zap.NewNop()) + + dashboard := &grabana.DashboardModel{} + converter.convertVariables([]sdk.TemplateVar{variable}, dashboard) + + req.Len(dashboard.Variables, 1) + req.NotNil(dashboard.Variables[0].Interval) + + interval := dashboard.Variables[0].Interval + + req.Equal("var_interval", interval.Name) + req.Equal("Label interval", interval.Label) + req.Equal("30s", interval.Default) + req.Equal("variable", interval.Hide) + req.ElementsMatch([]string{"10s", "30s", "1m"}, interval.Values) +} + +func TestConvertCustomVar(t *testing.T) { + req := require.New(t) + + variable := defaultVar("custom") + variable.Name = "var_custom" + variable.Label = "Label custom" + variable.Hide = 3 // unknown Hide value + variable.Current = sdk.Current{Text: &sdk.StringSliceString{Value: []string{"85th"}, Valid: true}, Value: "85"} + variable.Options = []sdk.Option{ + {Text: "50th", Value: "50"}, + {Text: "85th", Value: "85"}, + {Text: "99th", Value: "99"}, + } + + converter := NewJSON(zap.NewNop()) + + dashboard := &grabana.DashboardModel{} + converter.convertVariables([]sdk.TemplateVar{variable}, dashboard) + + req.Len(dashboard.Variables, 1) + req.NotNil(dashboard.Variables[0].Custom) + + custom := dashboard.Variables[0].Custom + + req.Equal("var_custom", custom.Name) + req.Equal("Label custom", custom.Label) + req.Equal("85", custom.Default) + req.Empty(custom.Hide) + req.True(reflect.DeepEqual(custom.ValuesMap, map[string]string{ + "50th": "50", + "85th": "85", + "99th": "99", + })) +} + +func TestConvertDatasourceVar(t *testing.T) { + req := require.New(t) + + variable := defaultVar("datasource") + variable.Name = "var_datasource" + variable.Label = "Label datasource" + + converter := NewJSON(zap.NewNop()) + + dashboard := &grabana.DashboardModel{} + converter.convertVariables([]sdk.TemplateVar{variable}, dashboard) + + req.Len(dashboard.Variables, 1) + req.NotNil(dashboard.Variables[0].Datasource) + + dsVar := dashboard.Variables[0].Datasource + + req.Equal("var_datasource", dsVar.Name) + req.Equal("Label datasource", dsVar.Label) + req.Empty(dsVar.Hide) + req.False(dsVar.IncludeAll) +} + +func TestConvertConstVar(t *testing.T) { + req := require.New(t) + + variable := defaultVar("const") + variable.Name = "var_const" + variable.Label = "Label const" + variable.Hide = 0 + variable.Current = sdk.Current{Text: &sdk.StringSliceString{Value: []string{"85th"}, Valid: true}, Value: "85"} + variable.Options = []sdk.Option{ + {Text: "85th", Value: "85"}, + {Text: "99th", Value: "99"}, + } + + converter := NewJSON(zap.NewNop()) + + dashboard := &grabana.DashboardModel{} + converter.convertVariables([]sdk.TemplateVar{variable}, dashboard) + + req.Len(dashboard.Variables, 1) + req.NotNil(dashboard.Variables[0].Const) + + constant := dashboard.Variables[0].Const + + req.Equal("var_const", constant.Name) + req.Equal("Label const", constant.Label) + req.Equal("85th", constant.Default) + req.Empty(constant.Hide) + req.True(reflect.DeepEqual(constant.ValuesMap, map[string]string{ + "85th": "85", + "99th": "99", + })) +} + +func TestConvertQueryVar(t *testing.T) { + req := require.New(t) + datasource := "prometheus-default" + + variable := defaultVar("query") + variable.Name = "var_query" + variable.Label = "Query" + variable.IncludeAll = true + variable.Hide = 1 + variable.Current = sdk.Current{Value: "$__all"} + variable.Datasource = &datasource + variable.Query = "prom_query" + + converter := NewJSON(zap.NewNop()) + + dashboard := &grabana.DashboardModel{} + converter.convertVariables([]sdk.TemplateVar{variable}, dashboard) + + req.Len(dashboard.Variables, 1) + req.NotNil(dashboard.Variables[0].Query) + + query := dashboard.Variables[0].Query + + req.Equal("var_query", query.Name) + req.Equal("Query", query.Label) + req.Equal(datasource, query.Datasource) + req.Equal("prom_query", query.Request) + req.Equal("label", query.Hide) + req.True(query.IncludeAll) + req.True(query.DefaultAll) +} diff --git a/vendor/github.com/K-Phoen/grabana/.golangci.yaml b/vendor/github.com/K-Phoen/grabana/.golangci.yaml index 5987273d..6f71ba05 100644 --- a/vendor/github.com/K-Phoen/grabana/.golangci.yaml +++ b/vendor/github.com/K-Phoen/grabana/.golangci.yaml @@ -17,16 +17,15 @@ linters: - gocyclo - gofmt - goimports - - interfacer - misspell - nakedret - prealloc - - scopelint + - exportloopref - stylecheck - unconvert - unparam - gosec - - golint + - revive - gochecknoglobals disable: - lll diff --git a/vendor/github.com/K-Phoen/grabana/Makefile b/vendor/github.com/K-Phoen/grabana/Makefile index e9f2156c..f4a1f2bb 100644 --- a/vendor/github.com/K-Phoen/grabana/Makefile +++ b/vendor/github.com/K-Phoen/grabana/Makefile @@ -6,14 +6,14 @@ VERSION?=$(TAG_NAME)-$(SHORT_SHA) LDFLAGS=-ldflags "-X=main.version=$(VERSION)" ifeq ($(WITH_COVERAGE),true) -GOCMD_TEST?=go test -mod=vendor -coverpkg=./... -coverprofile=coverage.txt -covermode=atomic ./... +GOCMD_TEST?=go test -coverpkg=./... -coverprofile=coverage.txt -covermode=atomic ./... else -GOCMD_TEST?=go test -mod=vendor +GOCMD_TEST?=go test endif .PHONY: lint lint: - docker run --rm -v $(shell pwd):/app -w /app golangci/golangci-lint:v1.23.1 golangci-lint run -c .golangci.yaml + docker run --rm -v $(shell pwd):/app -w /app golangci/golangci-lint:v1.43.0 golangci-lint run -c .golangci.yaml .PHONY: tests tests: @@ -60,4 +60,4 @@ down: docker rm -f grabana_prometheus build_cli: - go build -mod vendor $(LDFLAGS) -o grabana github.com/K-Phoen/grabana/cmd/cli \ No newline at end of file + go build $(LDFLAGS) -o grabana github.com/K-Phoen/grabana/cmd/cli \ No newline at end of file diff --git a/vendor/github.com/K-Phoen/grabana/decoder/alert.go b/vendor/github.com/K-Phoen/grabana/decoder/alert.go new file mode 100644 index 00000000..bb07134c --- /dev/null +++ b/vendor/github.com/K-Phoen/grabana/decoder/alert.go @@ -0,0 +1,176 @@ +package decoder + +import ( + "fmt" + + "github.com/K-Phoen/grabana/alert" +) + +var ErrNoAlertThresholdDefined = fmt.Errorf("no threshold defined") +var ErrInvalidAlertValueFunc = fmt.Errorf("invalid alert value function") + +type Alert struct { + Title string + EvaluateEvery string `yaml:"evaluate_every"` + For string + If []AlertCondition + Notify string `yaml:",omitempty"` + Notifications []string `yaml:",omitempty,flow"` + Message string `yaml:",omitempty"` + OnNoData string `yaml:"on_no_data"` + OnExecutionError string `yaml:"on_execution_error"` + Tags map[string]string `yaml:",omitempty"` +} + +func (a Alert) toOptions() ([]alert.Option, error) { + opts := []alert.Option{ + alert.EvaluateEvery(a.EvaluateEvery), + alert.For(a.For), + } + + if a.OnNoData != "" { + var mode alert.NoDataMode + + switch a.OnNoData { + case "no_data": + mode = alert.NoData + case "alerting": + mode = alert.Error + case "keep_state": + mode = alert.KeepLastState + case "ok": + mode = alert.OK + default: + return nil, fmt.Errorf("unknown on_no_data mode '%s'", a.OnNoData) + } + + opts = append(opts, alert.OnNoData(mode)) + } + if a.OnExecutionError != "" { + var mode alert.ErrorMode + + switch a.OnExecutionError { + case "alerting": + mode = alert.Alerting + case "keep_state": + mode = alert.LastState + default: + return nil, fmt.Errorf("unknown on_execution_error mode '%s'", a.OnExecutionError) + } + + opts = append(opts, alert.OnExecutionError(mode)) + } + if a.Notify != "" { + opts = append(opts, alert.NotifyChannel(a.Notify)) + } + if a.Message != "" { + opts = append(opts, alert.Message(a.Message)) + } + if len(a.Tags) != 0 { + opts = append(opts, alert.Tags(a.Tags)) + } + + for _, channel := range a.Notifications { + opts = append(opts, alert.NotifyChannel(channel)) + } + + for _, condition := range a.If { + conditionOpt, err := condition.toOption() + if err != nil { + return nil, err + } + + opts = append(opts, conditionOpt) + } + + return opts, nil +} + +type AlertThreshold struct { + HasNoValue bool `yaml:"has_no_value,omitempty"` + Above *float64 `yaml:",omitempty"` + Below *float64 `yaml:",omitempty"` + OutsideRange [2]float64 `yaml:"outside_range,omitempty,flow"` + WithinRange [2]float64 `yaml:"within_range,omitempty,flow"` +} + +func (threshold AlertThreshold) toOption() (alert.ConditionOption, error) { + if threshold.HasNoValue { + return alert.HasNoValue(), nil + } + if threshold.Above != nil { + return alert.IsAbove(*threshold.Above), nil + } + if threshold.Below != nil { + return alert.IsBelow(*threshold.Below), nil + } + if threshold.OutsideRange[0] != 0 && threshold.OutsideRange[1] != 0 { + return alert.IsOutsideRange(threshold.OutsideRange[0], threshold.OutsideRange[1]), nil + } + if threshold.WithinRange[0] != 0 && threshold.WithinRange[1] != 0 { + return alert.IsWithinRange(threshold.WithinRange[0], threshold.WithinRange[1]), nil + } + + return nil, ErrNoAlertThresholdDefined +} + +type AlertValue struct { + Func string + QueryRef string `yaml:"ref"` + From string + To string +} + +func (v AlertValue) toOption() (alert.ConditionOption, error) { + var alertFunc func(refID string, from string, to string) alert.ConditionOption + + switch v.Func { + case "avg": + alertFunc = alert.Avg + case "sum": + alertFunc = alert.Sum + case "count": + alertFunc = alert.Count + case "last": + alertFunc = alert.Last + case "min": + alertFunc = alert.Min + case "max": + alertFunc = alert.Max + case "median": + alertFunc = alert.Median + case "diff": + alertFunc = alert.Diff + case "percent_diff": + alertFunc = alert.PercentDiff + default: + return nil, ErrInvalidAlertValueFunc + } + + return alertFunc(v.QueryRef, v.From, v.To), nil +} + +type AlertCondition struct { + Operand string + Value AlertValue `yaml:",flow"` + Threshold AlertThreshold +} + +func (c AlertCondition) toOption() (alert.Option, error) { + operand := alert.And + if c.Operand == "or" { + operand = alert.Or + } + + threshold, err := c.Threshold.toOption() + if err != nil { + return nil, err + } + + value, err := c.Value.toOption() + if err != nil { + return nil, err + } + + return alert.If(operand, value, threshold), nil +} diff --git a/vendor/github.com/K-Phoen/grabana/decoder/dashboard.go b/vendor/github.com/K-Phoen/grabana/decoder/dashboard.go index b8ab2ea8..9b590939 100644 --- a/vendor/github.com/K-Phoen/grabana/decoder/dashboard.go +++ b/vendor/github.com/K-Phoen/grabana/decoder/dashboard.go @@ -18,11 +18,11 @@ type DashboardModel struct { AutoRefresh string `yaml:"auto_refresh"` Time [2]string - Timezone string + Timezone string `yaml:",omitempty"` - TagsAnnotation []dashboard.TagAnnotation `yaml:"tags_annotations"` - Variables []DashboardVariable - ExternalLinks []DashboardExternalLink `yaml:"external_links,omitempty"` + TagsAnnotation []dashboard.TagAnnotation `yaml:"tags_annotations,omitempty"` + Variables []DashboardVariable `yaml:",omitempty"` + ExternalLinks []DashboardExternalLink `yaml:"external_links,omitempty"` Rows []DashboardRow } @@ -109,12 +109,16 @@ type DashboardPanel struct { SingleStat *DashboardSingleStat `yaml:"single_stat,omitempty"` Text *DashboardText `yaml:",omitempty"` Heatmap *DashboardHeatmap `yaml:",omitempty"` + TimeSeries *DashboardTimeSeries `yaml:"timeseries,omitempty"` } func (panel DashboardPanel) toOption() (row.Option, error) { if panel.Graph != nil { return panel.Graph.toOption() } + if panel.TimeSeries != nil { + return panel.TimeSeries.toOption() + } if panel.Table != nil { return panel.Table.toOption() } diff --git a/vendor/github.com/K-Phoen/grabana/decoder/graph.go b/vendor/github.com/K-Phoen/grabana/decoder/graph.go index 556f0aa4..2f1fa688 100644 --- a/vendor/github.com/K-Phoen/grabana/decoder/graph.go +++ b/vendor/github.com/K-Phoen/grabana/decoder/graph.go @@ -3,15 +3,12 @@ package decoder import ( "fmt" - "github.com/K-Phoen/grabana/alert" "github.com/K-Phoen/grabana/axis" "github.com/K-Phoen/grabana/graph" "github.com/K-Phoen/grabana/graph/series" "github.com/K-Phoen/grabana/row" ) -var ErrNoAlertThresholdDefined = fmt.Errorf("no threshold defined") -var ErrInvalidAlertValueFunc = fmt.Errorf("invalid alert value function") var ErrInvalidLegendAttribute = fmt.Errorf("invalid legend attribute") type DashboardGraph struct { @@ -25,7 +22,7 @@ type DashboardGraph struct { Targets []Target Axes *GraphAxes `yaml:",omitempty"` Legend []string `yaml:",omitempty,flow"` - Alert *GraphAlert `yaml:",omitempty"` + Alert *Alert `yaml:",omitempty"` Visualization *GraphVisualization `yaml:",omitempty"` } @@ -135,7 +132,7 @@ func (graphViz *GraphVisualization) toOptions() []graph.Option { return nil } - var opts []graph.Option + opts := []graph.Option{} if graphViz.NullValue != "" { mode := graph.AsZero switch graphViz.NullValue { @@ -259,169 +256,3 @@ type GraphAxes struct { Right *GraphAxis `yaml:",omitempty"` Bottom *GraphAxis `yaml:",omitempty"` } - -type GraphAlert struct { - Title string - EvaluateEvery string `yaml:"evaluate_every"` - For string - If []AlertCondition - Notify string `yaml:",omitempty"` - Notifications []string `yaml:",omitempty,flow"` - Message string `yaml:",omitempty"` - OnNoData string `yaml:"on_no_data"` - OnExecutionError string `yaml:"on_execution_error"` - Tags map[string]string `yaml:",omitempty"` -} - -func (a GraphAlert) toOptions() ([]alert.Option, error) { - opts := []alert.Option{ - alert.EvaluateEvery(a.EvaluateEvery), - alert.For(a.For), - } - - if a.OnNoData != "" { - var mode alert.NoDataMode - - switch a.OnNoData { - case "no_data": - mode = alert.NoData - case "alerting": - mode = alert.Error - case "keep_state": - mode = alert.KeepLastState - case "ok": - mode = alert.OK - default: - return nil, fmt.Errorf("unknown on_no_data mode '%s'", a.OnNoData) - } - - opts = append(opts, alert.OnNoData(mode)) - } - if a.OnExecutionError != "" { - var mode alert.ErrorMode - - switch a.OnExecutionError { - case "alerting": - mode = alert.Alerting - case "keep_state": - mode = alert.LastState - default: - return nil, fmt.Errorf("unknown on_execution_error mode '%s'", a.OnExecutionError) - } - - opts = append(opts, alert.OnExecutionError(mode)) - } - if a.Notify != "" { - opts = append(opts, alert.NotifyChannel(a.Notify)) - } - if a.Message != "" { - opts = append(opts, alert.Message(a.Message)) - } - if len(a.Tags) != 0 { - opts = append(opts, alert.Tags(a.Tags)) - } - - for _, channel := range a.Notifications { - opts = append(opts, alert.NotifyChannel(channel)) - } - - for _, condition := range a.If { - conditionOpt, err := condition.toOption() - if err != nil { - return nil, err - } - - opts = append(opts, conditionOpt) - } - - return opts, nil -} - -type AlertThreshold struct { - HasNoValue bool `yaml:"has_no_value,omitempty"` - Above *float64 `yaml:",omitempty"` - Below *float64 `yaml:",omitempty"` - OutsideRange [2]float64 `yaml:"outside_range,omitempty,flow"` - WithinRange [2]float64 `yaml:"within_range,omitempty,flow"` -} - -func (threshold AlertThreshold) toOption() (alert.ConditionOption, error) { - if threshold.HasNoValue { - return alert.HasNoValue(), nil - } - if threshold.Above != nil { - return alert.IsAbove(*threshold.Above), nil - } - if threshold.Below != nil { - return alert.IsBelow(*threshold.Below), nil - } - if threshold.OutsideRange[0] != 0 && threshold.OutsideRange[1] != 0 { - return alert.IsOutsideRange(threshold.OutsideRange[0], threshold.OutsideRange[1]), nil - } - if threshold.WithinRange[0] != 0 && threshold.WithinRange[1] != 0 { - return alert.IsWithinRange(threshold.WithinRange[0], threshold.WithinRange[1]), nil - } - - return nil, ErrNoAlertThresholdDefined -} - -type AlertValue struct { - Func string - QueryRef string `yaml:"ref"` - From string - To string -} - -func (v AlertValue) toOption() (alert.ConditionOption, error) { - var alertFunc func(refID string, from string, to string) alert.ConditionOption - - switch v.Func { - case "avg": - alertFunc = alert.Avg - case "sum": - alertFunc = alert.Sum - case "count": - alertFunc = alert.Count - case "last": - alertFunc = alert.Last - case "min": - alertFunc = alert.Min - case "max": - alertFunc = alert.Max - case "median": - alertFunc = alert.Median - case "diff": - alertFunc = alert.Diff - case "percent_diff": - alertFunc = alert.PercentDiff - default: - return nil, ErrInvalidAlertValueFunc - } - - return alertFunc(v.QueryRef, v.From, v.To), nil -} - -type AlertCondition struct { - Operand string - Value AlertValue `yaml:",flow"` - Threshold AlertThreshold -} - -func (c AlertCondition) toOption() (alert.Option, error) { - operand := alert.And - if c.Operand == "or" { - operand = alert.Or - } - - threshold, err := c.Threshold.toOption() - if err != nil { - return nil, err - } - - value, err := c.Value.toOption() - if err != nil { - return nil, err - } - - return alert.If(operand, value, threshold), nil -} diff --git a/vendor/github.com/K-Phoen/grabana/decoder/timeseries.go b/vendor/github.com/K-Phoen/grabana/decoder/timeseries.go new file mode 100644 index 00000000..353b80cb --- /dev/null +++ b/vendor/github.com/K-Phoen/grabana/decoder/timeseries.go @@ -0,0 +1,338 @@ +package decoder + +import ( + "fmt" + + "github.com/K-Phoen/grabana/row" + "github.com/K-Phoen/grabana/timeseries" + "github.com/K-Phoen/grabana/timeseries/axis" +) + +var ErrInvalidGradientMode = fmt.Errorf("invalid gradient mode") +var ErrInvalidTooltipMode = fmt.Errorf("invalid tooltip mode") +var ErrInvalidAxisDisplay = fmt.Errorf("invalid axis display") +var ErrInvalidAxisScale = fmt.Errorf("invalid axis scale") + +type DashboardTimeSeries struct { + Title string + Description string `yaml:",omitempty"` + Span float32 `yaml:",omitempty"` + Height string `yaml:",omitempty"` + Transparent bool `yaml:",omitempty"` + Datasource string `yaml:",omitempty"` + Repeat string `yaml:",omitempty"` + Targets []Target + Legend []string `yaml:",omitempty,flow"` + Alert *Alert `yaml:",omitempty"` + Visualization *TimeSeriesVisualization `yaml:",omitempty"` + Axis *TimeSeriesAxis `yaml:",omitempty"` +} + +func (timeseriesPanel DashboardTimeSeries) toOption() (row.Option, error) { + opts := []timeseries.Option{} + + if timeseriesPanel.Description != "" { + opts = append(opts, timeseries.Description(timeseriesPanel.Description)) + } + if timeseriesPanel.Span != 0 { + opts = append(opts, timeseries.Span(timeseriesPanel.Span)) + } + if timeseriesPanel.Height != "" { + opts = append(opts, timeseries.Height(timeseriesPanel.Height)) + } + if timeseriesPanel.Transparent { + opts = append(opts, timeseries.Transparent()) + } + if timeseriesPanel.Datasource != "" { + opts = append(opts, timeseries.DataSource(timeseriesPanel.Datasource)) + } + if timeseriesPanel.Repeat != "" { + opts = append(opts, timeseries.Repeat(timeseriesPanel.Repeat)) + } + if len(timeseriesPanel.Legend) != 0 { + legendOpts, err := timeseriesPanel.legend() + if err != nil { + return nil, err + } + + opts = append(opts, timeseries.Legend(legendOpts...)) + } + if timeseriesPanel.Alert != nil { + alertOpts, err := timeseriesPanel.Alert.toOptions() + if err != nil { + return nil, err + } + + opts = append(opts, timeseries.Alert(timeseriesPanel.Alert.Title, alertOpts...)) + } + if timeseriesPanel.Visualization != nil { + vizOpts, err := timeseriesPanel.Visualization.toOptions() + if err != nil { + return nil, err + } + + opts = append(opts, vizOpts...) + } + if timeseriesPanel.Axis != nil { + axisOpts, err := timeseriesPanel.Axis.toOptions() + if err != nil { + return nil, err + } + + opts = append(opts, timeseries.Axis(axisOpts...)) + } + + for _, t := range timeseriesPanel.Targets { + opt, err := timeseriesPanel.target(t) + if err != nil { + return nil, err + } + + opts = append(opts, opt) + } + + return row.WithTimeSeries(timeseriesPanel.Title, opts...), nil +} + +func (timeseriesPanel DashboardTimeSeries) legend() ([]timeseries.LegendOption, error) { + opts := make([]timeseries.LegendOption, 0, len(timeseriesPanel.Legend)) + + for _, attribute := range timeseriesPanel.Legend { + var opt timeseries.LegendOption + + switch attribute { + case "hide": + opt = timeseries.Hide + case "as_table": + opt = timeseries.AsTable + case "as_list": + opt = timeseries.AsList + case "to_bottom": + opt = timeseries.Bottom + case "to_the_right": + opt = timeseries.ToTheRight + + case "min": + opt = timeseries.Min + case "max": + opt = timeseries.Max + case "avg": + opt = timeseries.Avg + + case "first": + opt = timeseries.First + case "first_non_null": + opt = timeseries.FirstNonNull + case "last": + opt = timeseries.Last + case "last_non_null": + opt = timeseries.LastNonNull + + case "count": + opt = timeseries.Count + case "total": + opt = timeseries.Total + case "range": + opt = timeseries.Range + default: + return nil, ErrInvalidLegendAttribute + } + + opts = append(opts, opt) + } + + return opts, nil +} + +func (timeseriesPanel DashboardTimeSeries) target(t Target) (timeseries.Option, error) { + if t.Prometheus != nil { + return timeseries.WithPrometheusTarget(t.Prometheus.Query, t.Prometheus.toOptions()...), nil + } + if t.Graphite != nil { + return timeseries.WithGraphiteTarget(t.Graphite.Query, t.Graphite.toOptions()...), nil + } + if t.InfluxDB != nil { + return timeseries.WithInfluxDBTarget(t.InfluxDB.Query, t.InfluxDB.toOptions()...), nil + } + if t.Stackdriver != nil { + stackdriverTarget, err := t.Stackdriver.toTarget() + if err != nil { + return nil, err + } + + return timeseries.WithStackdriverTarget(stackdriverTarget), nil + } + + return nil, ErrTargetNotConfigured +} + +type TimeSeriesVisualization struct { + GradientMode string `yaml:"gradient_mode,omitempty"` + Tooltip string `yaml:"tooltip,omitempty"` + FillOpacity *int `yaml:"fill_opacity,omitempty"` + PointSize *int `yaml:"point_size,omitempty"` + // TODO: draw: {bars: {}, lines: {}} +} + +func (timeseriesViz *TimeSeriesVisualization) toOptions() ([]timeseries.Option, error) { + if timeseriesViz == nil { + return nil, nil + } + + opts := []timeseries.Option{} + + if timeseriesViz.FillOpacity != nil { + opts = append(opts, timeseries.FillOpacity(*timeseriesViz.FillOpacity)) + } + if timeseriesViz.PointSize != nil { + opts = append(opts, timeseries.PointSize(*timeseriesViz.PointSize)) + } + if timeseriesViz.GradientMode != "" { + gradient, err := timeseriesViz.gradientModeOption() + if err != nil { + return nil, err + } + + opts = append(opts, gradient) + } + if timeseriesViz.Tooltip != "" { + gradient, err := timeseriesViz.tooltipOption() + if err != nil { + return nil, err + } + + opts = append(opts, gradient) + } + + return opts, nil +} + +func (timeseriesViz *TimeSeriesVisualization) gradientModeOption() (timeseries.Option, error) { + var mode timeseries.GradientType + switch timeseriesViz.GradientMode { + case "none": + mode = timeseries.NoGradient + case "opacity": + mode = timeseries.Opacity + case "hue": + mode = timeseries.Hue + case "scheme": + mode = timeseries.Scheme + default: + return timeseries.GradientMode(mode), ErrInvalidGradientMode + } + + return timeseries.GradientMode(mode), nil +} + +func (timeseriesViz *TimeSeriesVisualization) tooltipOption() (timeseries.Option, error) { + var mode timeseries.TooltipMode + switch timeseriesViz.Tooltip { + case "single_series": + mode = timeseries.SingleSeries + case "all_series": + mode = timeseries.AllSeries + case "none": + mode = timeseries.NoSeries + default: + return timeseries.Tooltip(mode), ErrInvalidTooltipMode + } + + return timeseries.Tooltip(mode), nil +} + +type TimeSeriesAxis struct { + SoftMin *int `yaml:"soft_min,omitempty"` + SoftMax *int `yaml:"soft_max,omitempty"` + Min *int `yaml:",omitempty"` + Max *int `yaml:",omitempty"` + + Decimals *int `yaml:",omitempty"` + + Display string `yaml:",omitempty"` + Scale string `yaml:",omitempty"` + + Unit string `yaml:",omitempty"` + Label string `yaml:",omitempty"` +} + +func (tsAxis *TimeSeriesAxis) toOptions() ([]axis.Option, error) { + opts := []axis.Option{} + + if tsAxis.SoftMin != nil { + opts = append(opts, axis.SoftMin(*tsAxis.SoftMin)) + } + if tsAxis.SoftMax != nil { + opts = append(opts, axis.SoftMax(*tsAxis.SoftMax)) + } + if tsAxis.Min != nil { + opts = append(opts, axis.Min(*tsAxis.Min)) + } + if tsAxis.Max != nil { + opts = append(opts, axis.Max(*tsAxis.Max)) + } + + if tsAxis.Decimals != nil { + opts = append(opts, axis.Decimals(*tsAxis.Decimals)) + } + + if tsAxis.Display != "" { + opt, err := tsAxis.placementOption() + if err != nil { + return nil, err + } + opts = append(opts, opt) + } + if tsAxis.Scale != "" { + opt, err := tsAxis.scaleOption() + if err != nil { + return nil, err + } + opts = append(opts, opt) + } + + if tsAxis.Unit != "" { + opts = append(opts, axis.Unit(tsAxis.Unit)) + } + if tsAxis.Label != "" { + opts = append(opts, axis.Label(tsAxis.Label)) + } + + return opts, nil +} + +func (tsAxis *TimeSeriesAxis) placementOption() (axis.Option, error) { + var placementMode axis.PlacementMode + + switch tsAxis.Display { + case "none": + placementMode = axis.Hidden + case "auto": + placementMode = axis.Auto + case "left": + placementMode = axis.Left + case "right": + placementMode = axis.Right + default: + return nil, ErrInvalidAxisDisplay + } + + return axis.Placement(placementMode), nil +} + +func (tsAxis *TimeSeriesAxis) scaleOption() (axis.Option, error) { + var scaleMode axis.ScaleMode + + switch tsAxis.Scale { + case "linear": + scaleMode = axis.Linear + case "log2": + scaleMode = axis.Log2 + case "log10": + scaleMode = axis.Log10 + default: + return nil, ErrInvalidAxisScale + } + + return axis.Scale(scaleMode), nil +} diff --git a/vendor/github.com/K-Phoen/grabana/decoder/utils.go b/vendor/github.com/K-Phoen/grabana/decoder/utils.go new file mode 100644 index 00000000..1cae4d4a --- /dev/null +++ b/vendor/github.com/K-Phoen/grabana/decoder/utils.go @@ -0,0 +1,5 @@ +package decoder + +func intPtr(input int) *int { + return &input +} diff --git a/vendor/github.com/K-Phoen/grabana/row/row.go b/vendor/github.com/K-Phoen/grabana/row/row.go index 30e163d4..096cfa0b 100644 --- a/vendor/github.com/K-Phoen/grabana/row/row.go +++ b/vendor/github.com/K-Phoen/grabana/row/row.go @@ -6,6 +6,7 @@ import ( "github.com/K-Phoen/grabana/singlestat" "github.com/K-Phoen/grabana/table" "github.com/K-Phoen/grabana/text" + "github.com/K-Phoen/grabana/timeseries" "github.com/K-Phoen/sdk" ) @@ -43,6 +44,15 @@ func WithGraph(title string, options ...graph.Option) Option { } } +// WithTimeSeries adds a "timeseries" panel in the row. +func WithTimeSeries(title string, options ...timeseries.Option) Option { + return func(row *Row) { + panel := timeseries.New(title, options...) + + row.builder.Add(panel.Builder) + } +} + // WithSingleStat adds a "single stat" panel in the row. func WithSingleStat(title string, options ...singlestat.Option) Option { return func(row *Row) { diff --git a/vendor/github.com/K-Phoen/grabana/timeseries/axis/axis.go b/vendor/github.com/K-Phoen/grabana/timeseries/axis/axis.go new file mode 100644 index 00000000..0f502c37 --- /dev/null +++ b/vendor/github.com/K-Phoen/grabana/timeseries/axis/axis.go @@ -0,0 +1,124 @@ +package axis + +import ( + "github.com/K-Phoen/sdk" +) + +// PlacementMode represents the axis display placement mode. +type PlacementMode string + +const ( + Hidden PlacementMode = "hidden" + Auto PlacementMode = "auto" + Left PlacementMode = "left" + Right PlacementMode = "right" +) + +// ScaleMode represents the axis scale distribution. +type ScaleMode uint8 + +const ( + Linear ScaleMode = iota + Log2 + Log10 +) + +// Option represents an option that can be used to configure an axis. +type Option func(axis *Axis) + +// Axis represents a visualization axis. +type Axis struct { + fieldConfig *sdk.FieldConfig +} + +// New creates a new Axis configuration. +func New(fieldConfig *sdk.FieldConfig, options ...Option) *Axis { + axis := &Axis{fieldConfig: fieldConfig} + + for _, opt := range options { + opt(axis) + } + + return axis +} + +// Placement defines how the axis should be placed in the panel. +func Placement(placement PlacementMode) Option { + return func(axis *Axis) { + axis.fieldConfig.Defaults.Custom.AxisPlacement = string(placement) + } +} + +// SoftMin defines a soft minimum value for the axis. +func SoftMin(value int) Option { + return func(axis *Axis) { + axis.fieldConfig.Defaults.Custom.AxisSoftMin = &value + } +} + +// SoftMax defines a soft maximum value for the axis. +func SoftMax(value int) Option { + return func(axis *Axis) { + axis.fieldConfig.Defaults.Custom.AxisSoftMax = &value + } +} + +// Min defines a hard minimum value for the axis. +func Min(value int) Option { + return func(axis *Axis) { + axis.fieldConfig.Defaults.Min = &value + } +} + +// SoftMax defines a hard maximum value for the axis. +func Max(value int) Option { + return func(axis *Axis) { + axis.fieldConfig.Defaults.Max = &value + } +} + +// Unit sets the unit of the data displayed in this series. +func Unit(unit string) Option { + return func(axis *Axis) { + axis.fieldConfig.Defaults.Unit = unit + } +} + +// Scale sets the scale to use for the Y-axis values.. +func Scale(mode ScaleMode) Option { + return func(axis *Axis) { + scaleConfig := struct { + Type string `json:"type"` + Log int `json:"log,omitempty"` + }{ + Type: "linear", + } + + switch mode { + case Linear: + scaleConfig.Type = "linear" + case Log2: + scaleConfig.Type = "log" + scaleConfig.Log = 2 + case Log10: + scaleConfig.Type = "log" + scaleConfig.Log = 10 + } + + axis.fieldConfig.Defaults.Custom.ScaleDistribution = scaleConfig + } +} + +// Label sets a Y-axis text label. +func Label(label string) Option { + return func(axis *Axis) { + axis.fieldConfig.Defaults.Custom.AxisLabel = label + } +} + +// Decimals sets how many decimal points should be displayed. +func Decimals(decimals int) Option { + return func(axis *Axis) { + axis.fieldConfig.Defaults.Decimals = &decimals + } +} diff --git a/vendor/github.com/K-Phoen/grabana/timeseries/targets.go b/vendor/github.com/K-Phoen/grabana/timeseries/targets.go new file mode 100644 index 00000000..1a054de0 --- /dev/null +++ b/vendor/github.com/K-Phoen/grabana/timeseries/targets.go @@ -0,0 +1,53 @@ +package timeseries + +import ( + "github.com/K-Phoen/grabana/target/graphite" + "github.com/K-Phoen/grabana/target/influxdb" + "github.com/K-Phoen/grabana/target/prometheus" + "github.com/K-Phoen/grabana/target/stackdriver" + "github.com/K-Phoen/sdk" +) + +// WithPrometheusTarget adds a prometheus query to the graph. +func WithPrometheusTarget(query string, options ...prometheus.Option) Option { + target := prometheus.New(query, options...) + + return func(graph *TimeSeries) { + graph.Builder.AddTarget(&sdk.Target{ + RefID: target.Ref, + Hide: target.Hidden, + Expr: target.Expr, + IntervalFactor: target.IntervalFactor, + Interval: target.Interval, + Step: target.Step, + LegendFormat: target.LegendFormat, + Instant: target.Instant, + Format: target.Format, + }) + } +} + +// WithGraphiteTarget adds a Graphite target to the table. +func WithGraphiteTarget(query string, options ...graphite.Option) Option { + target := graphite.New(query, options...) + + return func(graph *TimeSeries) { + graph.Builder.AddTarget(target.Builder) + } +} + +// WithInfluxDBTarget adds an InfluxDB target to the graph. +func WithInfluxDBTarget(query string, options ...influxdb.Option) Option { + target := influxdb.New(query, options...) + + return func(graph *TimeSeries) { + graph.Builder.AddTarget(target.Builder) + } +} + +// WithStackdriverTarget adds a stackdriver query to the graph. +func WithStackdriverTarget(target *stackdriver.Stackdriver) Option { + return func(graph *TimeSeries) { + graph.Builder.AddTarget(target.Builder) + } +} diff --git a/vendor/github.com/K-Phoen/grabana/timeseries/timeseries.go b/vendor/github.com/K-Phoen/grabana/timeseries/timeseries.go new file mode 100644 index 00000000..f0ace463 --- /dev/null +++ b/vendor/github.com/K-Phoen/grabana/timeseries/timeseries.go @@ -0,0 +1,307 @@ +package timeseries + +import ( + "github.com/K-Phoen/grabana/alert" + "github.com/K-Phoen/grabana/timeseries/axis" + "github.com/K-Phoen/sdk" +) + +// Option represents an option that can be used to configure a graph panel. +type Option func(timeseries *TimeSeries) + +// TooltipMode configures which series will be displayed in the tooltip. +type TooltipMode string + +const ( + // SingleSeries will only display the hovered series. + SingleSeries TooltipMode = "single" + // AllSeries will display all series. + AllSeries TooltipMode = "multi" + // NoSeries will hide the tooltip completely. + NoSeries TooltipMode = "none" +) + +// LineInterpolationMode defines how Grafana interpolates series lines when drawn as lines. +type LineInterpolationMode string + +const ( + // Points are joined by straight lines. + Linear LineInterpolationMode = "linear" + // Points are joined by curved lines resulting in smooth transitions between points. + Smooth LineInterpolationMode = "smooth" + // The line is displayed as steps between points. Points are rendered at the end of the step. + StepBefore LineInterpolationMode = "stepBefore" + // Line is displayed as steps between points. Points are rendered at the beginning of the step. + StepAfter LineInterpolationMode = "stepAfter" +) + +// BarAlignment defines how Grafana aligns bars. +type BarAlignment int + +const ( + // The bar is drawn around the point. The point is placed in the center of the bar. + AlignCenter BarAlignment = 0 + // The bar is drawn before the point. The point is placed on the trailing corner of the bar. + AlignBefore BarAlignment = -1 + // The bar is drawn after the point. The point is placed on the leading corner of the bar. + AlignAfter BarAlignment = 1 +) + +// GradientType defines the mode of the gradient fill. +type GradientType string + +const ( + // No gradient fill. + NoGradient GradientType = "none" + // Transparency of the gradient is calculated based on the values on the y-axis. + // Opacity of the fill is increasing with the values on the Y-axis. + Opacity GradientType = "opacity" + // Gradient color is generated based on the hue of the line color. + Hue GradientType = "hue" + // In this mode the whole bar will use a color gradient defined by the color scheme. + Scheme GradientType = "scheme" +) + +// LegendOption allows to configure a legend. +type LegendOption uint16 + +const ( + // Hide keeps the legend from being displayed. + Hide LegendOption = iota + // AsTable displays the legend as a table. + AsTable + // AsList displays the legend as a list. + AsList + // Bottom displays the legend below the graph. + Bottom + // ToTheRight displays the legend on the right side of the graph. + ToTheRight + + // Min displays the smallest value of the series. + Min + // Max displays the largest value of the series. + Max + // Avg displays the average of the series. + Avg + + // First displays the first value of the series. + First + // FirstNonNull displays the first non-null value of the series. + FirstNonNull + // Last displays the last value of the series. + Last + // LastNonNull displays the last non-null value of the series. + LastNonNull + + // Total displays the sum of values in the series. + Total + // Count displays the number of value in the series. + Count + // Range displays the difference between the minimum and maximum values. + Range +) + +// TimeSeries represents a time series panel. +type TimeSeries struct { + Builder *sdk.Panel +} + +// New creates a new time series panel. +func New(title string, options ...Option) *TimeSeries { + panel := &TimeSeries{Builder: sdk.NewTimeseries(title)} + panel.Builder.IsNew = false + + for _, opt := range append(defaults(), options...) { + opt(panel) + } + + return panel +} + +func defaults() []Option { + return []Option{ + Span(6), + LineWidth(1), + FillOpacity(25), + PointSize(5), + Tooltip(SingleSeries), + Legend(Bottom, AsList), + Lines(Linear), + GradientMode(Opacity), + Axis( + axis.Placement(axis.Auto), + axis.Scale(axis.Linear), + ), + } +} + +// DataSource sets the data source to be used by the graph. +func DataSource(source string) Option { + return func(timeseries *TimeSeries) { + timeseries.Builder.Datasource = &source + } +} + +// Tooltip configures the tooltip content. +func Tooltip(mode TooltipMode) Option { + return func(timeseries *TimeSeries) { + timeseries.Builder.TimeseriesPanel.Options.Tooltip.Mode = string(mode) + } +} + +// LineWidth defines the width of the line for a series (default 1, max 10, 0 is none). +func LineWidth(value int) Option { + return func(timeseries *TimeSeries) { + timeseries.Builder.TimeseriesPanel.FieldConfig.Defaults.Custom.LineWidth = value + } +} + +// FillOpacity defines the opacity level of the series. The lower the value, the more transparent. +func FillOpacity(value int) Option { + return func(timeseries *TimeSeries) { + timeseries.Builder.TimeseriesPanel.FieldConfig.Defaults.Custom.FillOpacity = value + } +} + +// PointSize adjusts the size of points. +func PointSize(value int) Option { + return func(timeseries *TimeSeries) { + timeseries.Builder.TimeseriesPanel.FieldConfig.Defaults.Custom.PointSize = value + } +} + +// Lines displays the series as lines, with a given interpolation strategy. +func Lines(mode LineInterpolationMode) Option { + return func(timeseries *TimeSeries) { + timeseries.Builder.TimeseriesPanel.FieldConfig.Defaults.Custom.LineInterpolation = string(mode) + timeseries.Builder.TimeseriesPanel.FieldConfig.Defaults.Custom.DrawStyle = "line" + timeseries.Builder.TimeseriesPanel.FieldConfig.Defaults.Custom.LineStyle = struct { + Fill string `json:"fill"` + }{ + Fill: "solid", + } + } +} + +// Bars displays the series as bars, with a given alignment strategy. +func Bars(alignment BarAlignment) Option { + return func(timeseries *TimeSeries) { + timeseries.Builder.TimeseriesPanel.FieldConfig.Defaults.Custom.BarAlignment = int(alignment) + timeseries.Builder.TimeseriesPanel.FieldConfig.Defaults.Custom.DrawStyle = "bars" + } +} + +// Points displays the series as points. +func Points() Option { + return func(timeseries *TimeSeries) { + timeseries.Builder.TimeseriesPanel.FieldConfig.Defaults.Custom.DrawStyle = "points" + } +} + +// GradientMode sets the mode of the gradient fill. +func GradientMode(mode GradientType) Option { + return func(timeseries *TimeSeries) { + timeseries.Builder.TimeseriesPanel.FieldConfig.Defaults.Custom.GradientMode = string(mode) + } +} + +// Axis configures the axis for this time series. +func Axis(options ...axis.Option) Option { + return func(timeseries *TimeSeries) { + axis.New(×eries.Builder.TimeseriesPanel.FieldConfig, options...) + } +} + +// Legend defines what should be shown in the legend. +func Legend(opts ...LegendOption) Option { + return func(timeseries *TimeSeries) { + legend := sdk.TimeseriesLegendOptions{ + DisplayMode: "list", + Placement: "bottom", + Calcs: make([]string, 0), + } + + for _, opt := range opts { + switch opt { + case Hide: + legend.DisplayMode = "hidden" + case AsList: + legend.DisplayMode = "list" + case AsTable: + legend.DisplayMode = "table" + case ToTheRight: + legend.Placement = "right" + case Bottom: + legend.Placement = "bottom" + + case First: + legend.Calcs = append(legend.Calcs, "first") + case FirstNonNull: + legend.Calcs = append(legend.Calcs, "firstNotNull") + case Last: + legend.Calcs = append(legend.Calcs, "last") + case LastNonNull: + legend.Calcs = append(legend.Calcs, "lastNotNull") + + case Min: + legend.Calcs = append(legend.Calcs, "min") + case Max: + legend.Calcs = append(legend.Calcs, "max") + case Avg: + legend.Calcs = append(legend.Calcs, "mean") + + case Count: + legend.Calcs = append(legend.Calcs, "count") + case Total: + legend.Calcs = append(legend.Calcs, "sum") + case Range: + legend.Calcs = append(legend.Calcs, "range") + } + } + + timeseries.Builder.TimeseriesPanel.Options.Legend = legend + } +} + +// Span sets the width of the panel, in grid units. Should be a positive +// number between 1 and 12. Example: 6. +func Span(span float32) Option { + return func(timeseries *TimeSeries) { + timeseries.Builder.Span = span + } +} + +// Height sets the height of the panel, in pixels. Example: "400px". +func Height(height string) Option { + return func(timeseries *TimeSeries) { + timeseries.Builder.Height = &height + } +} + +// Description annotates the current visualization with a human-readable description. +func Description(content string) Option { + return func(timeseries *TimeSeries) { + timeseries.Builder.Description = &content + } +} + +// Transparent makes the background transparent. +func Transparent() Option { + return func(timeseries *TimeSeries) { + timeseries.Builder.Transparent = true + } +} + +// Alert creates an alert for this graph. +func Alert(name string, opts ...alert.Option) Option { + return func(timeseries *TimeSeries) { + timeseries.Builder.Alert = alert.New(name, opts...).Builder + } +} + +// Repeat configures repeating a panel for a variable +func Repeat(repeat string) Option { + return func(timeseries *TimeSeries) { + timeseries.Builder.Repeat = &repeat + } +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 60e7e95d..b89c9e67 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1,7 +1,7 @@ # cloud.google.com/go v0.81.0 ## explicit; go 1.11 cloud.google.com/go/compute/metadata -# github.com/K-Phoen/grabana v0.18.1 +# github.com/K-Phoen/grabana v0.19.0 ## explicit; go 1.16 github.com/K-Phoen/grabana github.com/K-Phoen/grabana/alert @@ -20,6 +20,8 @@ github.com/K-Phoen/grabana/target/influxdb github.com/K-Phoen/grabana/target/prometheus github.com/K-Phoen/grabana/target/stackdriver github.com/K-Phoen/grabana/text +github.com/K-Phoen/grabana/timeseries +github.com/K-Phoen/grabana/timeseries/axis github.com/K-Phoen/grabana/variable/constant github.com/K-Phoen/grabana/variable/custom github.com/K-Phoen/grabana/variable/datasource