diff --git a/README.md b/README.md index 8739b0bb..5e9acb55 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ for you. Dashboard configuration: ```go -dashboard := grabana.NewDashboardBuilder( +builder := dashboard.New( "Awesome dashboard", grabana.AutoRefresh("5s"), grabana.Tags([]string{"generated"}), @@ -45,7 +45,7 @@ Dashboard creation: ```go ctx := context.Background() -client := grabana.NewClient(&http.Client{}, os.Args[1], os.Args[2]) +client := grabana.NewClient(&http.Client{}, grafanaHost, grafanaAPIToken) // create the folder holding the dashboard for the service folder, err := client.GetFolderByTitle(ctx, "Test Folder") @@ -63,7 +63,7 @@ if folder == nil { fmt.Printf("Folder created (id: %d, uid: %s)\n", folder.ID, folder.UID) } -if _, err := client.UpsertDashboard(ctx, folder, dashboard); err != nil { +if _, err := client.UpsertDashboard(ctx, folder, builder); err != nil { fmt.Printf("Could not create dashboard: %s\n", err) os.Exit(1) } diff --git a/client.go b/client.go index f16e00e0..f651449c 100644 --- a/client.go +++ b/client.go @@ -12,7 +12,7 @@ import ( "strings" "github.com/K-Phoen/grabana/alert" - + "github.com/K-Phoen/grabana/dashboard" "github.com/grafana-tools/sdk" ) @@ -155,13 +155,13 @@ func (client *Client) GetAlertChannelByName(ctx context.Context, name string) (* } // UpsertDashboard creates or replaces a dashboard, in the given folder. -func (client *Client) UpsertDashboard(ctx context.Context, folder *Folder, builder DashboardBuilder) (*Dashboard, error) { +func (client *Client) UpsertDashboard(ctx context.Context, folder *Folder, builder dashboard.Builder) (*Dashboard, error) { buf, err := json.Marshal(struct { Dashboard *sdk.Board `json:"dashboard"` FolderID uint `json:"folderId"` Overwrite bool `json:"overwrite"` }{ - Dashboard: builder.board, + Dashboard: builder.Internal(), FolderID: folder.ID, Overwrite: true, }) @@ -185,12 +185,12 @@ func (client *Client) UpsertDashboard(ctx context.Context, folder *Folder, build return nil, fmt.Errorf("could not create dashboard: %s", body) } - var dashboard Dashboard - if err := decodeJSON(resp.Body, &dashboard); err != nil { + var model Dashboard + if err := decodeJSON(resp.Body, &model); err != nil { return nil, err } - return &dashboard, nil + return &model, nil } func (client Client) postJSON(ctx context.Context, path string, body []byte) (*http.Response, error) { diff --git a/client_test.go b/client_test.go index 89f5d345..801a7472 100644 --- a/client_test.go +++ b/client_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + builder "github.com/K-Phoen/grabana/dashboard" "github.com/stretchr/testify/require" ) @@ -208,7 +209,7 @@ func TestGetAlertChannelByNameCanFail(t *testing.T) { func TestDashboardsCanBeCreated(t *testing.T) { req := require.New(t) - dashboard := NewDashboardBuilder("Dashboard name") + dashboard := builder.New("Dashboard name") ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, `{ "id": 1, @@ -231,7 +232,7 @@ func TestDashboardsCanBeCreated(t *testing.T) { func TestDashboardsCreationCanFail(t *testing.T) { req := require.New(t) - dashboard := NewDashboardBuilder("Dashboard name") + dashboard := builder.New("Dashboard name") ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) fmt.Fprintln(w, `{ diff --git a/cmd/example/main.go b/cmd/example/main.go index 3b443576..868cb47a 100644 --- a/cmd/example/main.go +++ b/cmd/example/main.go @@ -9,6 +9,7 @@ import ( "github.com/K-Phoen/grabana" "github.com/K-Phoen/grabana/alert" "github.com/K-Phoen/grabana/axis" + "github.com/K-Phoen/grabana/dashboard" "github.com/K-Phoen/grabana/graph" "github.com/K-Phoen/grabana/row" "github.com/K-Phoen/grabana/singlestat" @@ -52,27 +53,27 @@ func main() { os.Exit(1) } - dashboard := grabana.NewDashboardBuilder( + builder := dashboard.New( "Awesome dashboard", - grabana.AutoRefresh("5s"), - grabana.Tags([]string{"generated"}), - grabana.TagsAnnotation(grabana.TagAnnotation{ + dashboard.AutoRefresh("5s"), + dashboard.Tags([]string{"generated"}), + dashboard.TagsAnnotation(dashboard.TagAnnotation{ Name: "Deployments", Datasource: "-- Grafana --", IconColor: "#5794F2", Tags: []string{"deploy", "production"}, }), - grabana.VariableAsInterval( + dashboard.VariableAsInterval( "interval", interval.Values([]string{"30s", "1m", "5m", "10m", "30m", "1h", "6h", "12h"}), ), - grabana.VariableAsQuery( + dashboard.VariableAsQuery( "status", query.DataSource("prometheus-default"), query.Request("label_values(prometheus_http_requests_total, code)"), query.Sort(query.NumericalAsc), ), - grabana.VariableAsConst( + dashboard.VariableAsConst( "percentile", constant.Label("Percentile"), constant.Values(map[string]string{ @@ -86,7 +87,7 @@ func main() { }), constant.Default("80"), ), - grabana.VariableAsCustom( + dashboard.VariableAsCustom( "vX", custom.Multi(), custom.IncludeAll(), @@ -96,7 +97,7 @@ func main() { }), custom.Default("v2"), ), - grabana.Row( + dashboard.Row( "Prometheus", row.WithGraph( "HTTP Rate", @@ -145,7 +146,7 @@ func main() { singlestat.Thresholds([2]string{"26000000", "28000000"}), ), ), - grabana.Row( + dashboard.Row( "Some text, because it might be useful", row.WithText( "Some awesome text?", @@ -153,11 +154,11 @@ func main() { ), row.WithText( "Some awesome html?", - text.HTML("lalalala"), + text.HTML("Some awesome html?"), ), ), ) - if _, err := client.UpsertDashboard(ctx, folder, dashboard); err != nil { + if _, err := client.UpsertDashboard(ctx, folder, builder); err != nil { fmt.Printf("Could not create dashboard: %s\n", err) os.Exit(1) } diff --git a/cmd/yaml/example.yaml b/cmd/yaml/example.yaml new file mode 100644 index 00000000..8d0c7270 --- /dev/null +++ b/cmd/yaml/example.yaml @@ -0,0 +1,92 @@ +title: Awesome dashboard + +editable: true +shared_crosshair: true +tags: [generated, yaml] +auto_refresh: 10s + +tags_annotations: + - name: Deployments + datasource: "-- Grafana --" + color: "#5794F2" + tags: ["deploy", "production"] + +variables: + - interval: + name: interval + label: Interval + values: ["30s", "1m", "5m", "10m", "30m", "1h", "6h", "12h"] + - query: + name: status + label: HTTP status + datasource: prometheus-default + request: "label_values(prometheus_http_requests_total, code)" + - const: + name: percentile + label: Percentile + default: 80 + values_map: + 50th: "50" + 75th: "75" + 80th: "80" + 85th: "85" + 90th: "90" + 95th: "95" + 99th: "99" + - custom: + name: vX + default: v2 + values_map: + v1: v1 + v2: v2 + +rows: + - name: Prometheus + panels: + - graph: + title: HTTP Rate + height: 400px + datasource: prometheus-default + targets: + - prometheus: + query: "rate(promhttp_metric_handler_requests_total[$interval])" + legend: "{{handler}} - {{ code }}" + - graph: + title: Heap allocations + height: 400px + datasource: prometheus-default + targets: + - prometheus: + query: "go_memstats_heap_alloc_bytes" + legend: "{{job}}" + ref: A + - table: + title: Threads + datasource: prometheus-default + targets: + - prometheus: + query: "go_threads" + hidden_columns: ["Time"] + time_series_aggregations: + - label: AVG + type: avg + - label: Current + type: current + - single_stat: + title: Heap Allocations + datasource: prometheus-default + targets: + - prometheus: + query: 'go_memstats_heap_alloc_bytes{job="prometheus"}' + unit: bytes + thresholds: ["26000000", "28000000"] + color: ["value"] + + - name: "Some text, because it might be useful" + panels: + - text: + title: Some awesome text? + markdown: "Markdown syntax help: [commonmark.org/help](https://commonmark.org/help/)\n${percentile}" + - text: + title: Some awesome html? + html: "Some awesome html?" diff --git a/cmd/yaml/main.go b/cmd/yaml/main.go new file mode 100644 index 00000000..93b09afe --- /dev/null +++ b/cmd/yaml/main.go @@ -0,0 +1,58 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "io/ioutil" + "net/http" + "os" + + "github.com/K-Phoen/grabana" + "github.com/K-Phoen/grabana/decoder" +) + +func main() { + if len(os.Args) != 4 { + fmt.Fprint(os.Stderr, "Usage: go run -mod=vendor main.go file http://grafana-host:3000 api-key\n") + os.Exit(1) + } + + content, err := ioutil.ReadFile(os.Args[1]) + if err != nil { + fmt.Fprintf(os.Stderr, "Could not read file: %s\n", err) + os.Exit(1) + } + + dashboard, err := decoder.UnmarshalYAML(bytes.NewBuffer(content)) + if err != nil { + fmt.Fprintf(os.Stderr, "Could not parse file: %s\n", err) + os.Exit(1) + } + + ctx := context.Background() + client := grabana.NewClient(&http.Client{}, os.Args[2], os.Args[3]) + + // create the folder holding the dashboard for the service + folder, err := client.GetFolderByTitle(ctx, "Test Folder") + if err != nil && err != grabana.ErrFolderNotFound { + fmt.Printf("Could not create folder: %s\n", err) + os.Exit(1) + } + if folder == nil { + folder, err = client.CreateFolder(ctx, "Test Folder") + if err != nil { + fmt.Printf("Could not create folder: %s\n", err) + os.Exit(1) + } + + fmt.Printf("Folder created (id: %d, uid: %s)\n", folder.ID, folder.UID) + } + + if _, err := client.UpsertDashboard(ctx, folder, dashboard); err != nil { + fmt.Printf("Could not create dashboard: %s\n", err) + os.Exit(1) + } + + fmt.Println("The deed is done.") +} diff --git a/dashboard.go b/dashboard/dashboard.go similarity index 64% rename from dashboard.go rename to dashboard/dashboard.go index 775e7132..198dd2a5 100644 --- a/dashboard.go +++ b/dashboard/dashboard.go @@ -1,4 +1,4 @@ -package grabana +package dashboard import ( "encoding/json" @@ -16,44 +16,44 @@ import ( type TagAnnotation struct { Name string Datasource string - IconColor string + IconColor string `yaml:"color"` Tags []string } -// DashboardBuilderOption represents an option that can be used to configure a +// Option represents an option that can be used to configure a // dashboard. -type DashboardBuilderOption func(dashboard *DashboardBuilder) +type Option func(dashboard *Builder) -// DashboardBuilder is the main builder used to configure dashboards. -type DashboardBuilder struct { +// Builder is the main builder used to configure dashboards. +type Builder struct { board *sdk.Board } -// NewDashboardBuilder creates a new dashboard builder. -func NewDashboardBuilder(title string, options ...DashboardBuilderOption) DashboardBuilder { +// New creates a new dashboard builder. +func New(title string, options ...Option) Builder { board := sdk.NewBoard(title) board.ID = 0 board.Timezone = "" - builder := &DashboardBuilder{board: board} + builder := &Builder{board: board} - for _, opt := range append(dashboardDefaults(), options...) { + for _, opt := range append(defaults(), options...) { opt(builder) } return *builder } -func dashboardDefaults() []DashboardBuilderOption { - return []DashboardBuilderOption{ +func defaults() []Option { + return []Option{ defaultTimePicker(), defaultTime(), SharedCrossHair(), } } -func defaultTime() DashboardBuilderOption { - return func(builder *DashboardBuilder) { +func defaultTime() Option { + return func(builder *Builder) { builder.board.Time = sdk.Time{ From: "now-3h", To: "now", @@ -61,8 +61,8 @@ func defaultTime() DashboardBuilderOption { } } -func defaultTimePicker() DashboardBuilderOption { - return func(builder *DashboardBuilder) { +func defaultTimePicker() Option { + return func(builder *Builder) { builder.board.Timepicker = sdk.Timepicker{ RefreshIntervals: []string{"5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"}, TimeOptions: []string{"5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"}, @@ -76,15 +76,20 @@ func defaultTimePicker() DashboardBuilderOption { // which your configuration management tool of choice can then feed into // Grafana's dashboard via its provisioning support. // See https://grafana.com/docs/grafana/latest/administration/provisioning/#dashboards -func (builder *DashboardBuilder) MarshalJSON() ([]byte, error) { +func (builder *Builder) MarshalJSON() ([]byte, error) { return json.Marshal(builder.board) } +// Internal. +func (builder *Builder) Internal() *sdk.Board { + return builder.board +} + // VariableAsConst adds a templated variable, defined as a set of constant // values. // See https://grafana.com/docs/grafana/latest/reference/templating/#variable-types -func VariableAsConst(name string, options ...constant.Option) DashboardBuilderOption { - return func(builder *DashboardBuilder) { +func VariableAsConst(name string, options ...constant.Option) Option { + return func(builder *Builder) { templatedVar := constant.New(name, options...) builder.board.Templating.List = append(builder.board.Templating.List, templatedVar.Builder) @@ -94,8 +99,8 @@ func VariableAsConst(name string, options ...constant.Option) DashboardBuilderOp // VariableAsCustom adds a templated variable, defined as a set of custom // values. // See https://grafana.com/docs/grafana/latest/reference/templating/#variable-types -func VariableAsCustom(name string, options ...custom.Option) DashboardBuilderOption { - return func(builder *DashboardBuilder) { +func VariableAsCustom(name string, options ...custom.Option) Option { + return func(builder *Builder) { templatedVar := custom.New(name, options...) builder.board.Templating.List = append(builder.board.Templating.List, templatedVar.Builder) @@ -104,8 +109,8 @@ func VariableAsCustom(name string, options ...custom.Option) DashboardBuilderOpt // VariableAsInterval adds a templated variable, defined as an interval. // See https://grafana.com/docs/grafana/latest/reference/templating/#variable-types -func VariableAsInterval(name string, options ...interval.Option) DashboardBuilderOption { - return func(builder *DashboardBuilder) { +func VariableAsInterval(name string, options ...interval.Option) Option { + return func(builder *Builder) { templatedVar := interval.New(name, options...) builder.board.Templating.List = append(builder.board.Templating.List, templatedVar.Builder) @@ -114,8 +119,8 @@ func VariableAsInterval(name string, options ...interval.Option) DashboardBuilde // VariableAsQuery adds a templated variable, defined as a query. // See https://grafana.com/docs/grafana/latest/reference/templating/#variable-types -func VariableAsQuery(name string, options ...query.Option) DashboardBuilderOption { - return func(builder *DashboardBuilder) { +func VariableAsQuery(name string, options ...query.Option) Option { + return func(builder *Builder) { templatedVar := query.New(name, options...) builder.board.Templating.List = append(builder.board.Templating.List, templatedVar.Builder) @@ -123,15 +128,15 @@ func VariableAsQuery(name string, options ...query.Option) DashboardBuilderOptio } // Row adds a row to the dashboard. -func Row(title string, options ...row.Option) DashboardBuilderOption { - return func(builder *DashboardBuilder) { +func Row(title string, options ...row.Option) Option { + return func(builder *Builder) { row.New(builder.board, title, options...) } } // TagsAnnotation adds a new source of annotation for the dashboard. -func TagsAnnotation(annotation TagAnnotation) DashboardBuilderOption { - return func(builder *DashboardBuilder) { +func TagsAnnotation(annotation TagAnnotation) Option { + return func(builder *Builder) { builder.board.Annotations.List = append(builder.board.Annotations.List, sdk.Annotation{ Name: annotation.Name, Datasource: &annotation.Datasource, @@ -144,43 +149,43 @@ func TagsAnnotation(annotation TagAnnotation) DashboardBuilderOption { } // Editable marks the dashboard as editable. -func Editable() DashboardBuilderOption { - return func(builder *DashboardBuilder) { +func Editable() Option { + return func(builder *Builder) { builder.board.Editable = true } } // ReadOnly marks the dashboard as non-editable. -func ReadOnly() DashboardBuilderOption { - return func(builder *DashboardBuilder) { +func ReadOnly() Option { + return func(builder *Builder) { builder.board.Editable = false } } // SharedCrossHair configures the graph tooltip to be shared across panels. -func SharedCrossHair() DashboardBuilderOption { - return func(builder *DashboardBuilder) { +func SharedCrossHair() Option { + return func(builder *Builder) { builder.board.SharedCrosshair = true } } // DefaultTooltip configures the graph tooltip NOT to be shared across panels. -func DefaultTooltip() DashboardBuilderOption { - return func(builder *DashboardBuilder) { +func DefaultTooltip() Option { + return func(builder *Builder) { builder.board.SharedCrosshair = false } } // Tags adds the given set of tags to the dashboard. -func Tags(tags []string) DashboardBuilderOption { - return func(builder *DashboardBuilder) { +func Tags(tags []string) Option { + return func(builder *Builder) { builder.board.Tags = tags } } // AutoRefresh defines the auto-refresh interval for the dashboard. -func AutoRefresh(interval string) DashboardBuilderOption { - return func(builder *DashboardBuilder) { +func AutoRefresh(interval string) Option { + return func(builder *Builder) { builder.board.Refresh = &sdk.BoolString{Flag: true, Value: interval} } } diff --git a/dashboard_test.go b/dashboard/dashboard_test.go similarity index 75% rename from dashboard_test.go rename to dashboard/dashboard_test.go index c1986854..03c1d676 100644 --- a/dashboard_test.go +++ b/dashboard/dashboard_test.go @@ -1,4 +1,4 @@ -package grabana +package dashboard import ( "encoding/json" @@ -17,7 +17,7 @@ func requireJSON(t *testing.T, payload []byte) { func TestNewDashboardsCanBeCreated(t *testing.T) { req := require.New(t) - panel := NewDashboardBuilder("My dashboard") + panel := New("My dashboard") req.Equal(uint(0), panel.board.ID) req.Equal("My dashboard", panel.board.Title) @@ -32,7 +32,7 @@ func TestNewDashboardsCanBeCreated(t *testing.T) { func TestDashboardCanBeMarshalledIntoJSON(t *testing.T) { req := require.New(t) - builder := NewDashboardBuilder("Awesome dashboard") + builder := New("Awesome dashboard") dashboardJSON, err := builder.MarshalJSON() req.NoError(err) @@ -42,7 +42,7 @@ func TestDashboardCanBeMarshalledIntoJSON(t *testing.T) { func TestDashboardCanBeMadeEditable(t *testing.T) { req := require.New(t) - panel := NewDashboardBuilder("", Editable()) + panel := New("", Editable()) req.True(panel.board.Editable) } @@ -50,7 +50,7 @@ func TestDashboardCanBeMadeEditable(t *testing.T) { func TestDashboardCanBeMadeReadOnly(t *testing.T) { req := require.New(t) - panel := NewDashboardBuilder("", ReadOnly()) + panel := New("", ReadOnly()) req.False(panel.board.Editable) } @@ -58,7 +58,7 @@ func TestDashboardCanBeMadeReadOnly(t *testing.T) { func TestDashboardCanHaveASharedCrossHair(t *testing.T) { req := require.New(t) - panel := NewDashboardBuilder("", SharedCrossHair()) + panel := New("", SharedCrossHair()) req.True(panel.board.SharedCrosshair) } @@ -66,7 +66,7 @@ func TestDashboardCanHaveASharedCrossHair(t *testing.T) { func TestDashboardCanHaveADefaultTooltip(t *testing.T) { req := require.New(t) - panel := NewDashboardBuilder("", DefaultTooltip()) + panel := New("", DefaultTooltip()) req.False(panel.board.SharedCrosshair) } @@ -74,7 +74,7 @@ func TestDashboardCanHaveADefaultTooltip(t *testing.T) { func TestDashboardCanBeAutoRefreshed(t *testing.T) { req := require.New(t) - panel := NewDashboardBuilder("", AutoRefresh("5s")) + panel := New("", AutoRefresh("5s")) req.True(panel.board.Refresh.Flag) req.Equal("5s", panel.board.Refresh.Value) @@ -84,7 +84,7 @@ func TestDashboardCanHaveTags(t *testing.T) { req := require.New(t) tags := []string{"generated", "grabana"} - panel := NewDashboardBuilder("", Tags(tags)) + panel := New("", Tags(tags)) req.Len(panel.board.Tags, 2) req.ElementsMatch(tags, panel.board.Tags) @@ -93,7 +93,7 @@ func TestDashboardCanHaveTags(t *testing.T) { func TestDashboardCanHaveVariablesAsConstants(t *testing.T) { req := require.New(t) - panel := NewDashboardBuilder("", VariableAsConst("percentile")) + panel := New("", VariableAsConst("percentile")) req.Len(panel.board.Templating.List, 1) } @@ -101,7 +101,7 @@ func TestDashboardCanHaveVariablesAsConstants(t *testing.T) { func TestDashboardCanHaveVariablesAsCustom(t *testing.T) { req := require.New(t) - panel := NewDashboardBuilder("", VariableAsCustom("vX")) + panel := New("", VariableAsCustom("vX")) req.Len(panel.board.Templating.List, 1) } @@ -109,7 +109,7 @@ func TestDashboardCanHaveVariablesAsCustom(t *testing.T) { func TestDashboardCanHaveVariablesAsInterval(t *testing.T) { req := require.New(t) - panel := NewDashboardBuilder("", VariableAsInterval("interval")) + panel := New("", VariableAsInterval("interval")) req.Len(panel.board.Templating.List, 1) } @@ -117,7 +117,7 @@ func TestDashboardCanHaveVariablesAsInterval(t *testing.T) { func TestDashboardCanHaveVariablesAsQuery(t *testing.T) { req := require.New(t) - panel := NewDashboardBuilder("", VariableAsQuery("status")) + panel := New("", VariableAsQuery("status")) req.Len(panel.board.Templating.List, 1) } @@ -125,7 +125,7 @@ func TestDashboardCanHaveVariablesAsQuery(t *testing.T) { func TestDashboardCanHaveRows(t *testing.T) { req := require.New(t) - panel := NewDashboardBuilder("", Row("Prometheus")) + panel := New("", Row("Prometheus")) req.Len(panel.board.Rows, 1) } @@ -133,7 +133,7 @@ func TestDashboardCanHaveRows(t *testing.T) { func TestDashboardCanHaveAnnotationsFromTags(t *testing.T) { req := require.New(t) - panel := NewDashboardBuilder("", TagsAnnotation(TagAnnotation{})) + panel := New("", TagsAnnotation(TagAnnotation{})) req.Len(panel.board.Annotations.List, 1) } diff --git a/decoder/dashboard.go b/decoder/dashboard.go new file mode 100644 index 00000000..96baac59 --- /dev/null +++ b/decoder/dashboard.go @@ -0,0 +1,103 @@ +package decoder + +import ( + "fmt" + + "github.com/K-Phoen/grabana/dashboard" + "github.com/K-Phoen/grabana/row" +) + +var ErrPanelNotConfigured = fmt.Errorf("panel not configured") + +type dashboardModel struct { + Title string + Editable bool + SharedCrosshair bool `yaml:"shared_crosshair"` + Tags []string + AutoRefresh string `yaml:"auto_refresh"` + + TagsAnnotation []dashboard.TagAnnotation `yaml:"tags_annotations"` + Variables []dashboardVariable + + Rows []dashboardRow +} + +func (d *dashboardModel) toDashboardBuilder() (dashboard.Builder, error) { + emptyDashboard := dashboard.Builder{} + opts := []dashboard.Option{ + d.editable(), + d.sharedCrossHair(), + } + + if len(d.Tags) != 0 { + opts = append(opts, dashboard.Tags(d.Tags)) + } + + if d.AutoRefresh != "" { + opts = append(opts, dashboard.AutoRefresh(d.AutoRefresh)) + } + + for _, tagAnnotation := range d.TagsAnnotation { + opts = append(opts, dashboard.TagsAnnotation(tagAnnotation)) + } + + for _, variable := range d.Variables { + opt, err := variable.toOption() + if err != nil { + return emptyDashboard, err + } + + opts = append(opts, opt) + } + + for _, r := range d.Rows { + opt, err := r.toOption() + if err != nil { + return emptyDashboard, err + } + + opts = append(opts, opt) + } + + return dashboard.New(d.Title, opts...), nil +} + +func (d *dashboardModel) sharedCrossHair() dashboard.Option { + if d.SharedCrosshair { + return dashboard.SharedCrossHair() + } + + return dashboard.DefaultTooltip() +} + +func (d *dashboardModel) editable() dashboard.Option { + if d.Editable { + return dashboard.Editable() + } + + return dashboard.ReadOnly() +} + +type dashboardPanel struct { + Graph *dashboardGraph + Table *dashboardTable + SingleStat *dashboardSingleStat `yaml:"single_stat"` + Text *dashboardText +} + +func (panel dashboardPanel) toOption() (row.Option, error) { + if panel.Graph != nil { + return panel.Graph.toOption() + } + if panel.Table != nil { + return panel.Table.toOption() + } + if panel.SingleStat != nil { + return panel.SingleStat.toOption() + } + if panel.Text != nil { + return panel.Text.toOption(), nil + } + + return nil, ErrPanelNotConfigured +} diff --git a/decoder/dashboard_test.go b/decoder/dashboard_test.go new file mode 100644 index 00000000..b43913dd --- /dev/null +++ b/decoder/dashboard_test.go @@ -0,0 +1,889 @@ +package decoder + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" +) + +type testCase struct { + name string + yaml string + expectedGrafanaJSON string +} + +func TestUnmarshalYAMLWithInvalidInput(t *testing.T) { + _, err := UnmarshalYAML(bytes.NewBufferString("")) + + require.Error(t, err) +} + +func TestUnmarshalYAML(t *testing.T) { + testCases := []struct { + name string + yaml string + expectedGrafanaJSON string + }{ + generalOptions(), + tagAnnotations(), + variables(), + textPanel(), + graphPanel(), + singleStatPanel(), + tablePanel(), + } + + for _, testCase := range testCases { + tc := testCase + + t.Run(tc.name, func(t *testing.T) { + req := require.New(t) + + builder, err := UnmarshalYAML(bytes.NewBufferString(tc.yaml)) + req.NoError(err) + + json, err := builder.MarshalJSON() + req.NoError(err) + + req.JSONEq(tc.expectedGrafanaJSON, string(json)) + }) + } +} + +func TestUnmarshalYAMLWithInvalidPanel(t *testing.T) { + payload := ` +rows: + - name: Prometheus + panels: + - {}` + + _, err := UnmarshalYAML(bytes.NewBufferString(payload)) + + require.Error(t, err) + require.Equal(t, ErrPanelNotConfigured, err) +} + +func TestUnmarshalYAMLWithInvalidVariable(t *testing.T) { + payload := ` +variables: + - {}` + + _, err := UnmarshalYAML(bytes.NewBufferString(payload)) + + require.Error(t, err) + require.Equal(t, ErrVariableNotConfigured, err) +} + +func TestUnmarshalYAMLWithNoTargetTable(t *testing.T) { + payload := ` +rows: + - name: Prometheus + panels: + - table: + title: Threads + targets: + - {} +` + + _, err := UnmarshalYAML(bytes.NewBufferString(payload)) + + require.Error(t, err) + require.Equal(t, ErrTargetNotConfigured, err) +} + +func TestUnmarshalYAMLWithNoTargetSingleStat(t *testing.T) { + payload := ` +rows: + - name: Prometheus + panels: + - single_stat: + title: Threads + targets: + - {} +` + + _, err := UnmarshalYAML(bytes.NewBufferString(payload)) + + require.Error(t, err) + require.Equal(t, ErrTargetNotConfigured, err) +} + +func TestUnmarshalYAMLWithNoTInvalidSparklineModeSingleStat(t *testing.T) { + payload := ` +rows: + - name: Prometheus + panels: + - single_stat: + title: Threads + sparkline: unknown-mode +` + + _, err := UnmarshalYAML(bytes.NewBufferString(payload)) + + require.Error(t, err) + require.Equal(t, ErrInvalidSparkLineMode, err) +} + +func TestUnmarshalYAMLWithSingleStatAndInvalidColoringTarget(t *testing.T) { + payload := ` +rows: + - name: Prometheus + panels: + - single_stat: + title: Heap Allocations + datasource: prometheus-default + targets: + - prometheus: + query: 'go_memstats_heap_alloc_bytes{job="prometheus"}' + unit: bytes + thresholds: ["26000000", "28000000"] + color: ["value", "invalid target"] +` + + _, err := UnmarshalYAML(bytes.NewBufferString(payload)) + + require.Error(t, err) + require.Equal(t, ErrInvalidColoringTarget, err) +} + +func TestUnmarshalYAMLWithNoTargetSingleGraph(t *testing.T) { + payload := ` +rows: + - name: Prometheus + panels: + - graph: + title: Threads + targets: + - {} +` + + _, err := UnmarshalYAML(bytes.NewBufferString(payload)) + + require.Error(t, err) + require.Equal(t, ErrTargetNotConfigured, err) +} + +func generalOptions() testCase { + yaml := `title: Awesome dashboard + +editable: true +shared_crosshair: true +tags: [generated, yaml] +auto_refresh: 10s +` + json := `{ + "slug": "", + "title": "Awesome dashboard", + "originalTitle": "", + "tags": ["generated", "yaml"], + "style": "dark", + "timezone": "", + "editable": true, + "hideControls": false, + "sharedCrosshair": true, + "templating": {"list": null}, + "annotations": {"list": null}, + "links": null, + "panels": null, + "rows": [], + "refresh": "10s", + "time": {"from": "now-3h", "to": "now"}, + "timepicker": { + "refresh_intervals": ["5s","10s","30s","1m","5m","15m","30m","1h","2h","1d"], + "time_options": ["5m","15m","1h","6h","12h","24h","2d","7d","30d"] + }, + "schemaVersion": 0, + "version": 0 +}` + + return testCase{ + name: "general options", + yaml: yaml, + expectedGrafanaJSON: json, + } +} + +func tagAnnotations() testCase { + yaml := `title: Awesome dashboard + +tags_annotations: + - name: Deployments + datasource: "-- Grafana --" + color: "#5794F2" + tags: ["deploy", "production"] +` + json := `{ + "slug": "", + "title": "Awesome dashboard", + "originalTitle": "", + "tags": null, + "style": "dark", + "timezone": "", + "editable": false, + "hideControls": false, + "sharedCrosshair": false, + "templating": { + "list": null + }, + "annotations": { + "list": [{ + "datasource": "-- Grafana --", + "enable": true, + "iconColor": "#5794F2", + "iconSize": 0, + "name": "Deployments", + "query": "", + "showLine": false, + "lineColor": "", + "tags": ["deploy", "production"], + "tagsField": "", + "textField": "", + "type": "tags" + }] + }, + "links": null, + "panels": null, + "rows": [], + "time": {"from": "now-3h", "to": "now"}, + "timepicker": { + "refresh_intervals": ["5s","10s","30s","1m","5m","15m","30m","1h","2h","1d"], + "time_options": ["5m","15m","1h","6h","12h","24h","2d","7d","30d"] + }, + "schemaVersion": 0, + "version": 0 +}` + + return testCase{ + name: "tag annotations", + yaml: yaml, + expectedGrafanaJSON: json, + } +} + +func variables() testCase { + yaml := `title: Awesome dashboard + +variables: + - interval: + name: interval + label: Interval + default: 30s + values: ["30s", "1m", "5m", "10m", "30m", "1h", "6h", "12h"] + - query: + name: status + label: HTTP status + datasource: prometheus-default + include_all: true + default_all: true + request: "label_values(prometheus_http_requests_total, code)" + - const: + name: percentile + label: Percentile + default: 50 + values_map: + 50th: "50" + - custom: + name: vX + label: vX + default: v1 + values_map: + v1: v1 +` + json := `{ + "slug": "", + "title": "Awesome dashboard", + "originalTitle": "", + "tags": null, + "style": "dark", + "timezone": "", + "editable": false, + "hideControls": false, + "sharedCrosshair": false, + "templating": { + "list": [ + { + "name": "interval", + "type": "interval", + "datasource": null, + "refresh": false, + "options": null, + "includeAll": false, + "allFormat": "", + "allValue": "", + "multi": false, + "multiFormat": "", + "query": "10m,12h,1h,1m,30m,30s,5m,6h", + "regex": "", + "current": { + "text": "30s", + "value": "30s" + }, + "label": "Interval", + "hide": 0, + "sort": 0 + }, + { + "name": "status", + "type": "query", + "datasource": "prometheus-default", + "refresh": 1, + "options": [ + { + "text": "All", + "value": "$__all", + "selected": false + } + ], + "includeAll": true, + "allFormat": "", + "allValue": "", + "multi": false, + "multiFormat": "", + "query": "label_values(prometheus_http_requests_total, code)", + "regex": "", + "current": { + "text": "All", + "value": "$__all" + }, + "label": "HTTP status", + "hide": 0, + "sort": 0 + }, + { + "name": "percentile", + "type": "constant", + "datasource": null, + "refresh": false, + "options": [ + { + "selected": false, + "text": "50th", + "value": "50" + } + ], + "includeAll": false, + "allFormat": "", + "allValue": "", + "multi": false, + "multiFormat": "", + "query": "50", + "regex": "", + "current": { + "text": "50th", + "value": "50" + }, + "label": "Percentile", + "hide": 0, + "sort": 0 + }, + { + "name": "vX", + "type": "custom", + "datasource": null, + "refresh": false, + "options": [ + { + "text": "v1", + "value": "v1", + "selected": false + } + ], + "includeAll": false, + "allFormat": "", + "allValue": "", + "multi": false, + "multiFormat": "", + "query": "v1", + "regex": "", + "current": { + "text": "v1", + "value": "v1" + }, + "label": "vX", + "hide": 0, + "sort": 0 + } + ] + }, + "annotations": {"list": null}, + "links": null, + "panels": null, + "rows": [], + "time": {"from": "now-3h", "to": "now"}, + "timepicker": { + "refresh_intervals": ["5s","10s","30s","1m","5m","15m","30m","1h","2h","1d"], + "time_options": ["5m","15m","1h","6h","12h","24h","2d","7d","30d"] + }, + "schemaVersion": 0, + "version": 0 +}` + + return testCase{ + name: "variables", + yaml: yaml, + expectedGrafanaJSON: json, + } +} + +func textPanel() testCase { + yaml := `title: Awesome dashboard + +rows: + - name: Test row + panels: + - text: + height: 400px + span: 6 + title: Some markdown? + markdown: "*markdown*" + - text: + height: 400px + span: 6 + title: Some html? + html: "Some awesome html" +` + json := `{ + "slug": "", + "title": "Awesome dashboard", + "originalTitle": "", + "tags": null, + "style": "dark", + "timezone": "", + "editable": false, + "hideControls": false, + "sharedCrosshair": false, + "templating": {"list": null}, + "annotations": {"list": null}, + "links": null, + "panels": null, + "rows": [ + { + "title": "Test row", + "collapse": false, + "editable": true, + "height": "250px", + "repeat": null, + "showTitle": true, + "panels": [ + { + "type": "text", + "mode": "markdown", + "content": "*markdown*", + "editable": false, + "error": false, + "gridPos": {}, + "id": 1, + "isNew": false, + "pageSize": 0, + "scroll": false, + "renderer": "flot", + "showHeader": false, + "sort": {"col": 0, "desc": false}, + "span": 6, + "height": "400px", + "styles": null, + "title": "Some markdown?", + "transparent": false + }, + { + "type": "text", + "mode": "html", + "content": "Some awesome html", + "editable": false, + "error": false, + "gridPos": {}, + "id": 2, + "isNew": false, + "scroll": false, + "pageSize": 0, + "renderer": "flot", + "showHeader": false, + "sort": {"col": 0, "desc": false}, + "span": 6, + "height": "400px", + "styles": null, + "title": "Some html?", + "transparent": false + } + ] + } + ], + "time": {"from": "now-3h", "to": "now"}, + "timepicker": { + "refresh_intervals": ["5s","10s","30s","1m","5m","15m","30m","1h","2h","1d"], + "time_options": ["5m","15m","1h","6h","12h","24h","2d","7d","30d"] + }, + "schemaVersion": 0, + "version": 0 +}` + + return testCase{ + name: "single row with text panels", + yaml: yaml, + expectedGrafanaJSON: json, + } +} + +func graphPanel() testCase { + yaml := `title: Awesome dashboard + +rows: + - name: Test row + panels: + - graph: + title: Heap allocations + height: 400px + span: 4 + datasource: prometheus-default + targets: + - prometheus: + query: "go_memstats_heap_alloc_bytes" + legend: "{{job}}" + ref: A +` + json := `{ + "slug": "", + "title": "Awesome dashboard", + "originalTitle": "", + "tags": null, + "style": "dark", + "timezone": "", + "editable": false, + "hideControls": false, + "sharedCrosshair": false, + "templating": {"list": null}, + "annotations": {"list": null}, + "links": null, + "panels": null, + "rows": [ + { + "title": "Test row", + "collapse": false, + "editable": true, + "height": "250px", + "repeat": null, + "showTitle": true, + "panels": [ + { + "type": "graph", + "datasource": "prometheus-default", + "editable": true, + "error": false, + "height": "400px", + "gridPos": {}, + "id": 3, + "isNew": false, + "renderer": "flot", + "span": 4, + "fill": 1, + "title": "Heap allocations", + "aliasColors": {}, + "bars": false, + "points": false, + "stack": false, + "steppedLine": false, + "lines": true, + "linewidth": 1, + "pointradius": 5, + "percentage": false, + "nullPointMode": "null as zero", + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "hideEmpty": true, + "hideZero": true, + "max": false, + "min": false, + "rightSide": false, + "show": true, + "total": false, + "values": false + }, + "targets": [ + { + "refId": "A", + "expr": "go_memstats_heap_alloc_bytes", + "legendFormat": "{{job}}", + "format": "time_series" + } + ], + "tooltip": { + "shared": true, + "value_type": "", + "sort": 2 + }, + "x-axis": true, + "y-axis": true, + "xaxis": { + "format": "time", + "logBase": 1, + "show": true + }, + "yaxes": [ + { + "format": "short", + "logBase": 1, + "show": true + }, + { + "format": "short", + "logBase": 1, + "show": false + } + ], + "transparent": false + } + ] + } + ], + "time": {"from": "now-3h", "to": "now"}, + "timepicker": { + "refresh_intervals": ["5s","10s","30s","1m","5m","15m","30m","1h","2h","1d"], + "time_options": ["5m","15m","1h","6h","12h","24h","2d","7d","30d"] + }, + "schemaVersion": 0, + "version": 0 +}` + + return testCase{ + name: "single row with single graph panel", + yaml: yaml, + expectedGrafanaJSON: json, + } +} + +func singleStatPanel() testCase { + yaml := `title: Awesome dashboard + +rows: + - name: Test row + panels: + - single_stat: + title: Heap Allocations + height: 400px + span: 4 + datasource: prometheus-default + targets: + - prometheus: + query: 'go_memstats_heap_alloc_bytes{job="prometheus"}' + unit: bytes + sparkline: bottom + thresholds: ["26000000", "28000000"] + color: ["value", "background"] + colors: ["green", "yellow", "red"] +` + json := `{ + "slug": "", + "title": "Awesome dashboard", + "originalTitle": "", + "tags": null, + "style": "dark", + "timezone": "", + "editable": false, + "hideControls": false, + "sharedCrosshair": false, + "templating": {"list": null}, + "annotations": {"list": null}, + "links": null, + "panels": null, + "rows": [ + { + "title": "Test row", + "collapse": false, + "editable": true, + "height": "250px", + "repeat": null, + "showTitle": true, + "panels": [ + { + "datasource": "prometheus-default", + "editable": true, + "error": false, + "gridPos": {}, + "id": 4, + "isNew": false, + "renderer": "flot", + "span": 4, + "height": "400px", + "title": "Heap Allocations", + "transparent": false, + "type": "singlestat", + "colors": [ + "green", + "yellow", + "red" + ], + "colorValue": true, + "colorBackground": true, + "decimals": 0, + "format": "bytes", + "gauge": { + "maxValue": 0, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": false + }, + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "nullPointMode": "", + "sparkline": { + "show": true, + "fillColor": "rgba(31, 118, 189, 0.18)", + "lineColor": "rgb(31, 120, 193)" + }, + "targets": [ + { + "refId": "", + "expr": "go_memstats_heap_alloc_bytes{job=\"prometheus\"}", + "format": "time_series" + } + ], + "thresholds": "26000000,28000000", + "valueFontSize": "100%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "avg" + } + ] + } + ], + "time": {"from": "now-3h", "to": "now"}, + "timepicker": { + "refresh_intervals": ["5s","10s","30s","1m","5m","15m","30m","1h","2h","1d"], + "time_options": ["5m","15m","1h","6h","12h","24h","2d","7d","30d"] + }, + "schemaVersion": 0, + "version": 0 +}` + + return testCase{ + name: "single row with single graph panel", + yaml: yaml, + expectedGrafanaJSON: json, + } +} + +func tablePanel() testCase { + yaml := `title: Awesome dashboard + +rows: + - name: Test row + panels: + - table: + title: Threads + height: 400px + span: 4 + datasource: prometheus-default + targets: + - prometheus: + query: "go_threads" + hidden_columns: ["Time"] + time_series_aggregations: + - label: AVG + type: avg + - label: Current + type: current +` + json := `{ + "slug": "", + "title": "Awesome dashboard", + "originalTitle": "", + "tags": null, + "style": "dark", + "timezone": "", + "editable": false, + "hideControls": false, + "sharedCrosshair": false, + "templating": {"list": null}, + "annotations": {"list": null}, + "links": null, + "panels": null, + "rows": [ + { + "title": "Test row", + "collapse": false, + "editable": true, + "height": "250px", + "repeat": null, + "showTitle": true, + "panels": [ + { + "datasource": "prometheus-default", + "editable": true, + "error": false, + "gridPos": {}, + "height": "400px", + "id": 5, + "isNew": false, + "renderer": "flot", + "span": 4, + "title": "Threads", + "transparent": false, + "type": "table", + "columns": [ + { + "text": "AVG", + "value": "avg" + }, + { + "text": "Current", + "value": "current" + } + ], + "styles": [ + { + "alias": "", + "pattern": "/.*/", + "type": "string" + }, + { + "alias": null, + "pattern": "Time", + "type": "hidden" + } + ], + "transform": "timeseries_aggregations", + "targets": [ + { + "refId": "", + "expr": "go_threads", + "format": "time_series" + } + ], + "scroll": false + } + ] + } + ], + "time": {"from": "now-3h", "to": "now"}, + "timepicker": { + "refresh_intervals": ["5s","10s","30s","1m","5m","15m","30m","1h","2h","1d"], + "time_options": ["5m","15m","1h","6h","12h","24h","2d","7d","30d"] + }, + "schemaVersion": 0, + "version": 0 +}` + + return testCase{ + name: "single row with single graph panel", + yaml: yaml, + expectedGrafanaJSON: json, + } +} diff --git a/decoder/graph.go b/decoder/graph.go new file mode 100644 index 00000000..354a4ce5 --- /dev/null +++ b/decoder/graph.go @@ -0,0 +1,47 @@ +package decoder + +import ( + "github.com/K-Phoen/grabana/graph" + "github.com/K-Phoen/grabana/row" +) + +type dashboardGraph struct { + Title string + Span float32 + Height string + Datasource string + Targets []target +} + +func (graphPanel dashboardGraph) toOption() (row.Option, error) { + opts := []graph.Option{} + + if graphPanel.Span != 0 { + opts = append(opts, graph.Span(graphPanel.Span)) + } + if graphPanel.Height != "" { + opts = append(opts, graph.Height(graphPanel.Height)) + } + if graphPanel.Datasource != "" { + opts = append(opts, graph.DataSource(graphPanel.Datasource)) + } + + for _, t := range graphPanel.Targets { + opt, err := graphPanel.target(t) + if err != nil { + return nil, err + } + + opts = append(opts, opt) + } + + return row.WithGraph(graphPanel.Title, opts...), nil +} + +func (graphPanel *dashboardGraph) target(t target) (graph.Option, error) { + if t.Prometheus != nil { + return graph.WithPrometheusTarget(t.Prometheus.Query, t.Prometheus.toOptions()...), nil + } + + return nil, ErrTargetNotConfigured +} diff --git a/decoder/row.go b/decoder/row.go new file mode 100644 index 00000000..e80b8ed1 --- /dev/null +++ b/decoder/row.go @@ -0,0 +1,26 @@ +package decoder + +import ( + "github.com/K-Phoen/grabana/dashboard" + "github.com/K-Phoen/grabana/row" +) + +type dashboardRow struct { + Name string + Panels []dashboardPanel +} + +func (r dashboardRow) toOption() (dashboard.Option, error) { + opts := []row.Option{} + + for _, panel := range r.Panels { + opt, err := panel.toOption() + if err != nil { + return nil, err + } + + opts = append(opts, opt) + } + + return dashboard.Row(r.Name, opts...), nil +} diff --git a/decoder/singlestat.go b/decoder/singlestat.go new file mode 100644 index 00000000..15f0f091 --- /dev/null +++ b/decoder/singlestat.go @@ -0,0 +1,87 @@ +package decoder + +import ( + "fmt" + + "github.com/K-Phoen/grabana/row" + "github.com/K-Phoen/grabana/singlestat" +) + +var ErrInvalidColoringTarget = fmt.Errorf("invalid coloring target") +var ErrInvalidSparkLineMode = fmt.Errorf("invalid sparkline mode") + +type dashboardSingleStat struct { + Title string + Span float32 + Height string + Datasource string + Unit string + SparkLine string `yaml:"sparkline"` + Targets []target + Thresholds [2]string + Colors [3]string + Color []string +} + +func (singleStatPanel dashboardSingleStat) toOption() (row.Option, error) { + opts := []singlestat.Option{} + + if singleStatPanel.Span != 0 { + opts = append(opts, singlestat.Span(singleStatPanel.Span)) + } + if singleStatPanel.Height != "" { + opts = append(opts, singlestat.Height(singleStatPanel.Height)) + } + if singleStatPanel.Datasource != "" { + opts = append(opts, singlestat.DataSource(singleStatPanel.Datasource)) + } + if singleStatPanel.Unit != "" { + opts = append(opts, singlestat.Unit(singleStatPanel.Unit)) + } + if singleStatPanel.Thresholds[0] != "" { + opts = append(opts, singlestat.Thresholds(singleStatPanel.Thresholds)) + } + if singleStatPanel.Colors[0] != "" { + opts = append(opts, singlestat.Colors(singleStatPanel.Colors)) + } + + switch singleStatPanel.SparkLine { + case "bottom": + opts = append(opts, singlestat.SparkLine()) + case "full": + opts = append(opts, singlestat.FullSparkLine()) + case "": + default: + return nil, ErrInvalidSparkLineMode + } + + for _, colorTarget := range singleStatPanel.Color { + switch colorTarget { + case "value": + opts = append(opts, singlestat.ColorValue()) + case "background": + opts = append(opts, singlestat.ColorBackground()) + default: + return nil, ErrInvalidColoringTarget + } + } + + for _, t := range singleStatPanel.Targets { + opt, err := singleStatPanel.target(t) + if err != nil { + return nil, err + } + + opts = append(opts, opt) + } + + return row.WithSingleStat(singleStatPanel.Title, opts...), nil +} + +func (singleStatPanel dashboardSingleStat) target(t target) (singlestat.Option, error) { + if t.Prometheus != nil { + return singlestat.WithPrometheusTarget(t.Prometheus.Query, t.Prometheus.toOptions()...), nil + } + + return nil, ErrTargetNotConfigured +} diff --git a/decoder/table.go b/decoder/table.go new file mode 100644 index 00000000..6c49b4a5 --- /dev/null +++ b/decoder/table.go @@ -0,0 +1,57 @@ +package decoder + +import ( + "github.com/K-Phoen/grabana/row" + "github.com/K-Phoen/grabana/table" +) + +type dashboardTable struct { + Title string + Span float32 + Height string + Datasource string + Targets []target + HiddenColumns []string `yaml:"hidden_columns"` + TimeSeriesAggregations []table.Aggregation `yaml:"time_series_aggregations"` +} + +func (tablePanel dashboardTable) toOption() (row.Option, error) { + opts := []table.Option{} + + if tablePanel.Span != 0 { + opts = append(opts, table.Span(tablePanel.Span)) + } + if tablePanel.Height != "" { + opts = append(opts, table.Height(tablePanel.Height)) + } + if tablePanel.Datasource != "" { + opts = append(opts, table.DataSource(tablePanel.Datasource)) + } + + for _, t := range tablePanel.Targets { + opt, err := tablePanel.target(t) + if err != nil { + return nil, err + } + + opts = append(opts, opt) + } + + for _, column := range tablePanel.HiddenColumns { + opts = append(opts, table.HideColumn(column)) + } + + if len(tablePanel.TimeSeriesAggregations) != 0 { + opts = append(opts, table.AsTimeSeriesAggregations(tablePanel.TimeSeriesAggregations)) + } + + return row.WithTable(tablePanel.Title, opts...), nil +} + +func (tablePanel *dashboardTable) target(t target) (table.Option, error) { + if t.Prometheus != nil { + return table.WithPrometheusTarget(t.Prometheus.Query, t.Prometheus.toOptions()...), nil + } + + return nil, ErrTargetNotConfigured +} diff --git a/decoder/target.go b/decoder/target.go new file mode 100644 index 00000000..2211bd86 --- /dev/null +++ b/decoder/target.go @@ -0,0 +1,32 @@ +package decoder + +import ( + "fmt" + + "github.com/K-Phoen/grabana/target/prometheus" +) + +var ErrTargetNotConfigured = fmt.Errorf("target not configured") + +type target struct { + Prometheus *prometheusTarget +} + +type prometheusTarget struct { + Query string + Legend string + Ref string +} + +func (t prometheusTarget) toOptions() []prometheus.Option { + var opts []prometheus.Option + + if t.Legend != "" { + opts = append(opts, prometheus.Legend(t.Legend)) + } + if t.Ref != "" { + opts = append(opts, prometheus.Ref(t.Ref)) + } + + return opts +} diff --git a/decoder/text.go b/decoder/text.go new file mode 100644 index 00000000..206e0c3c --- /dev/null +++ b/decoder/text.go @@ -0,0 +1,33 @@ +package decoder + +import ( + "github.com/K-Phoen/grabana/row" + "github.com/K-Phoen/grabana/text" +) + +type dashboardText struct { + Title string + Span float32 + Height string + HTML string + Markdown string +} + +func (textPanel dashboardText) toOption() row.Option { + opts := []text.Option{} + + if textPanel.Span != 0 { + opts = append(opts, text.Span(textPanel.Span)) + } + if textPanel.Height != "" { + opts = append(opts, text.Height(textPanel.Height)) + } + if textPanel.Markdown != "" { + opts = append(opts, text.Markdown(textPanel.Markdown)) + } + if textPanel.HTML != "" { + opts = append(opts, text.HTML(textPanel.HTML)) + } + + return row.WithText(textPanel.Title, opts...) +} diff --git a/decoder/variables.go b/decoder/variables.go new file mode 100644 index 00000000..500286d1 --- /dev/null +++ b/decoder/variables.go @@ -0,0 +1,136 @@ +package decoder + +import ( + "fmt" + + "github.com/K-Phoen/grabana/variable/interval" + + "github.com/K-Phoen/grabana/dashboard" + "github.com/K-Phoen/grabana/variable/constant" + "github.com/K-Phoen/grabana/variable/custom" + "github.com/K-Phoen/grabana/variable/query" +) + +var ErrVariableNotConfigured = fmt.Errorf("variable not configured") + +type dashboardVariable struct { + Interval *variableInterval + Custom *variableCustom + Query *variableQuery + Const *variableConst +} + +func (variable *dashboardVariable) toOption() (dashboard.Option, error) { + if variable.Query != nil { + return variable.Query.toOption(), nil + } + if variable.Interval != nil { + return variable.Interval.toOption(), nil + } + if variable.Const != nil { + return variable.Const.toOption(), nil + } + if variable.Custom != nil { + return variable.Custom.toOption(), nil + } + + return nil, ErrVariableNotConfigured +} + +type variableInterval struct { + Name string + Label string + Default string + Values []string +} + +func (variable *variableInterval) toOption() dashboard.Option { + opts := []interval.Option{ + interval.Values(variable.Values), + } + + if variable.Label != "" { + opts = append(opts, interval.Label(variable.Label)) + } + if variable.Default != "" { + opts = append(opts, interval.Default(variable.Default)) + } + + return dashboard.VariableAsInterval(variable.Name, opts...) +} + +type variableCustom struct { + Name string + Label string + Default string + ValuesMap map[string]string `yaml:"values_map"` +} + +func (variable *variableCustom) toOption() dashboard.Option { + opts := []custom.Option{ + custom.Values(variable.ValuesMap), + } + + if variable.Default != "" { + opts = append(opts, custom.Default(variable.Default)) + } + if variable.Label != "" { + opts = append(opts, custom.Label(variable.Label)) + } + + return dashboard.VariableAsCustom(variable.Name, opts...) +} + +type variableConst struct { + Name string + Label string + Default string + ValuesMap map[string]string `yaml:"values_map"` +} + +func (variable *variableConst) toOption() dashboard.Option { + opts := []constant.Option{ + constant.Values(variable.ValuesMap), + } + + if variable.Default != "" { + opts = append(opts, constant.Default(variable.Default)) + } + if variable.Label != "" { + opts = append(opts, constant.Label(variable.Label)) + } + + return dashboard.VariableAsConst(variable.Name, opts...) +} + +type variableQuery struct { + Name string + Label string + + Datasource string + Request string + + IncludeAll bool `yaml:"include_all"` + DefaultAll bool `yaml:"default_all"` +} + +func (variable *variableQuery) toOption() dashboard.Option { + opts := []query.Option{ + query.Request(variable.Request), + } + + if variable.Datasource != "" { + opts = append(opts, query.DataSource(variable.Datasource)) + } + if variable.Label != "" { + opts = append(opts, query.Label(variable.Label)) + } + if variable.IncludeAll { + opts = append(opts, query.IncludeAll()) + } + if variable.DefaultAll { + opts = append(opts, query.DefaultAll()) + } + + return dashboard.VariableAsQuery(variable.Name, opts...) +} diff --git a/decoder/yaml.go b/decoder/yaml.go new file mode 100644 index 00000000..7d91b44b --- /dev/null +++ b/decoder/yaml.go @@ -0,0 +1,20 @@ +package decoder + +import ( + "io" + + builder "github.com/K-Phoen/grabana/dashboard" + "gopkg.in/yaml.v2" +) + +func UnmarshalYAML(input io.Reader) (builder.Builder, error) { + decoder := yaml.NewDecoder(input) + decoder.SetStrict(true) + + parsed := &dashboardModel{} + if err := decoder.Decode(parsed); err != nil { + return builder.Builder{}, err + } + + return parsed.toDashboardBuilder() +} diff --git a/doc.go b/doc.go index dbc19844..5f6baeed 100644 --- a/doc.go +++ b/doc.go @@ -5,7 +5,7 @@ If you are looking for a way to version your dashboards configuration or automate tedious and error-prone creation of dashboards, this library is meant for you. - dashboard := grabana.NewDashboardBuilder( + builder := dashboard.New( "Awesome dashboard", grabana.VariableAsInterval( "interval", diff --git a/go.mod b/go.mod index c6b6a056..7d2b7491 100644 --- a/go.mod +++ b/go.mod @@ -5,4 +5,5 @@ go 1.13 require ( github.com/grafana-tools/sdk v0.0.0-20200127194913-bdcab199ffde github.com/stretchr/testify v1.4.0 + gopkg.in/yaml.v2 v2.2.2 ) diff --git a/go.sum b/go.sum index 749e6c7f..d3b48c54 100644 --- a/go.sum +++ b/go.sum @@ -11,6 +11,7 @@ github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDF github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/variable/constant/constant.go b/variable/constant/constant.go index 34236d41..f4d453c8 100644 --- a/variable/constant/constant.go +++ b/variable/constant/constant.go @@ -1,6 +1,7 @@ package constant import ( + "sort" "strings" "github.com/grafana-tools/sdk" @@ -19,6 +20,8 @@ func (values ValuesMap) asQuery() string { valuesList = append(valuesList, value) } + sort.Strings(valuesList) + return strings.Join(valuesList, ",") } diff --git a/variable/custom/custom.go b/variable/custom/custom.go index 37e6dfb9..d90e624d 100644 --- a/variable/custom/custom.go +++ b/variable/custom/custom.go @@ -1,6 +1,7 @@ package custom import ( + "sort" "strings" "github.com/grafana-tools/sdk" @@ -19,6 +20,8 @@ func (values ValuesMap) asQuery() string { valuesList = append(valuesList, value) } + sort.Strings(valuesList) + return strings.Join(valuesList, ",") } diff --git a/variable/interval/interval.go b/variable/interval/interval.go index 09186796..adad05bb 100644 --- a/variable/interval/interval.go +++ b/variable/interval/interval.go @@ -1,6 +1,7 @@ package interval import ( + "sort" "strings" "github.com/grafana-tools/sdk" @@ -35,6 +36,8 @@ func New(name string, options ...Option) *Interval { // Values sets the possible values for the variable. func Values(values ValuesList) Option { return func(interval *Interval) { + sort.Strings(values) + interval.Builder.Query = strings.Join(values, ",") } } diff --git a/variable/interval/interval_test.go b/variable/interval/interval_test.go index f8af4129..768b993a 100644 --- a/variable/interval/interval_test.go +++ b/variable/interval/interval_test.go @@ -31,7 +31,7 @@ func TestValuesCanBeSet(t *testing.T) { panel := New("", Values(values)) - req.Equal("30s,1m,5m,10m,30m,1h,6h,12h", panel.Builder.Query) + req.Equal("10m,12h,1h,1m,30m,30s,5m,6h", panel.Builder.Query) } func TestDefaultValueCanBeSet(t *testing.T) { diff --git a/variable/query/query.go b/variable/query/query.go index 2d3409d8..7061aa46 100644 --- a/variable/query/query.go +++ b/variable/query/query.go @@ -146,6 +146,6 @@ func IncludeAll() Option { // DefaultAll selects "All" values by default. func DefaultAll() Option { return func(query *Query) { - query.Builder.Current = sdk.Current{Text: "All", Value: "$_all"} + query.Builder.Current = sdk.Current{Text: "All", Value: "$__all"} } } diff --git a/variable/query/query_test.go b/variable/query/query_test.go index 49972bf8..604718f0 100644 --- a/variable/query/query_test.go +++ b/variable/query/query_test.go @@ -63,7 +63,7 @@ func TestAnAllValuesCanBeTheDefault(t *testing.T) { panel := New("", DefaultAll()) req.Equal("All", panel.Builder.Current.Text) - req.Equal("$_all", panel.Builder.Current.Value) + req.Equal("$__all", panel.Builder.Current.Value) } func TestValuesCanBeFilteredByRegex(t *testing.T) { diff --git a/vendor/github.com/stretchr/testify/suite/doc.go b/vendor/github.com/stretchr/testify/suite/doc.go deleted file mode 100644 index f91a245d..00000000 --- a/vendor/github.com/stretchr/testify/suite/doc.go +++ /dev/null @@ -1,65 +0,0 @@ -// Package suite contains logic for creating testing suite structs -// and running the methods on those structs as tests. The most useful -// piece of this package is that you can create setup/teardown methods -// on your testing suites, which will run before/after the whole suite -// or individual tests (depending on which interface(s) you -// implement). -// -// A testing suite is usually built by first extending the built-in -// suite functionality from suite.Suite in testify. Alternatively, -// you could reproduce that logic on your own if you wanted (you -// just need to implement the TestingSuite interface from -// suite/interfaces.go). -// -// After that, you can implement any of the interfaces in -// suite/interfaces.go to add setup/teardown functionality to your -// suite, and add any methods that start with "Test" to add tests. -// Methods that do not match any suite interfaces and do not begin -// with "Test" will not be run by testify, and can safely be used as -// helper methods. -// -// Once you've built your testing suite, you need to run the suite -// (using suite.Run from testify) inside any function that matches the -// identity that "go test" is already looking for (i.e. -// func(*testing.T)). -// -// Regular expression to select test suites specified command-line -// argument "-run". Regular expression to select the methods -// of test suites specified command-line argument "-m". -// Suite object has assertion methods. -// -// A crude example: -// // Basic imports -// import ( -// "testing" -// "github.com/stretchr/testify/assert" -// "github.com/stretchr/testify/suite" -// ) -// -// // Define the suite, and absorb the built-in basic suite -// // functionality from testify - including a T() method which -// // returns the current testing context -// type ExampleTestSuite struct { -// suite.Suite -// VariableThatShouldStartAtFive int -// } -// -// // Make sure that VariableThatShouldStartAtFive is set to five -// // before each test -// func (suite *ExampleTestSuite) SetupTest() { -// suite.VariableThatShouldStartAtFive = 5 -// } -// -// // All methods that begin with "Test" are run as tests within a -// // suite. -// func (suite *ExampleTestSuite) TestExample() { -// assert.Equal(suite.T(), 5, suite.VariableThatShouldStartAtFive) -// suite.Equal(5, suite.VariableThatShouldStartAtFive) -// } -// -// // In order for 'go test' to run this suite, we need to create -// // a normal test function and pass our suite to suite.Run -// func TestExampleTestSuite(t *testing.T) { -// suite.Run(t, new(ExampleTestSuite)) -// } -package suite diff --git a/vendor/github.com/stretchr/testify/suite/interfaces.go b/vendor/github.com/stretchr/testify/suite/interfaces.go deleted file mode 100644 index b37cb040..00000000 --- a/vendor/github.com/stretchr/testify/suite/interfaces.go +++ /dev/null @@ -1,46 +0,0 @@ -package suite - -import "testing" - -// TestingSuite can store and return the current *testing.T context -// generated by 'go test'. -type TestingSuite interface { - T() *testing.T - SetT(*testing.T) -} - -// SetupAllSuite has a SetupSuite method, which will run before the -// tests in the suite are run. -type SetupAllSuite interface { - SetupSuite() -} - -// SetupTestSuite has a SetupTest method, which will run before each -// test in the suite. -type SetupTestSuite interface { - SetupTest() -} - -// TearDownAllSuite has a TearDownSuite method, which will run after -// all the tests in the suite have been run. -type TearDownAllSuite interface { - TearDownSuite() -} - -// TearDownTestSuite has a TearDownTest method, which will run after -// each test in the suite. -type TearDownTestSuite interface { - TearDownTest() -} - -// BeforeTest has a function to be executed right before the test -// starts and receives the suite and test names as input -type BeforeTest interface { - BeforeTest(suiteName, testName string) -} - -// AfterTest has a function to be executed right after the test -// finishes and receives the suite and test names as input -type AfterTest interface { - AfterTest(suiteName, testName string) -} diff --git a/vendor/github.com/stretchr/testify/suite/suite.go b/vendor/github.com/stretchr/testify/suite/suite.go deleted file mode 100644 index d708d7d7..00000000 --- a/vendor/github.com/stretchr/testify/suite/suite.go +++ /dev/null @@ -1,166 +0,0 @@ -package suite - -import ( - "flag" - "fmt" - "os" - "reflect" - "regexp" - "runtime/debug" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -var allTestsFilter = func(_, _ string) (bool, error) { return true, nil } -var matchMethod = flag.String("testify.m", "", "regular expression to select tests of the testify suite to run") - -// Suite is a basic testing suite with methods for storing and -// retrieving the current *testing.T context. -type Suite struct { - *assert.Assertions - require *require.Assertions - t *testing.T -} - -// T retrieves the current *testing.T context. -func (suite *Suite) T() *testing.T { - return suite.t -} - -// SetT sets the current *testing.T context. -func (suite *Suite) SetT(t *testing.T) { - suite.t = t - suite.Assertions = assert.New(t) - suite.require = require.New(t) -} - -// Require returns a require context for suite. -func (suite *Suite) Require() *require.Assertions { - if suite.require == nil { - suite.require = require.New(suite.T()) - } - return suite.require -} - -// Assert returns an assert context for suite. Normally, you can call -// `suite.NoError(expected, actual)`, but for situations where the embedded -// methods are overridden (for example, you might want to override -// assert.Assertions with require.Assertions), this method is provided so you -// can call `suite.Assert().NoError()`. -func (suite *Suite) Assert() *assert.Assertions { - if suite.Assertions == nil { - suite.Assertions = assert.New(suite.T()) - } - return suite.Assertions -} - -func failOnPanic(t *testing.T) { - r := recover() - if r != nil { - t.Errorf("test panicked: %v\n%s", r, debug.Stack()) - t.FailNow() - } -} - -// Run provides suite functionality around golang subtests. It should be -// called in place of t.Run(name, func(t *testing.T)) in test suite code. -// The passed-in func will be executed as a subtest with a fresh instance of t. -// Provides compatibility with go test pkg -run TestSuite/TestName/SubTestName. -func (suite *Suite) Run(name string, subtest func()) bool { - oldT := suite.T() - defer suite.SetT(oldT) - return oldT.Run(name, func(t *testing.T) { - suite.SetT(t) - subtest() - }) -} - -// Run takes a testing suite and runs all of the tests attached -// to it. -func Run(t *testing.T, suite TestingSuite) { - suite.SetT(t) - defer failOnPanic(t) - - suiteSetupDone := false - - methodFinder := reflect.TypeOf(suite) - tests := []testing.InternalTest{} - for index := 0; index < methodFinder.NumMethod(); index++ { - method := methodFinder.Method(index) - ok, err := methodFilter(method.Name) - if err != nil { - fmt.Fprintf(os.Stderr, "testify: invalid regexp for -m: %s\n", err) - os.Exit(1) - } - if !ok { - continue - } - if !suiteSetupDone { - if setupAllSuite, ok := suite.(SetupAllSuite); ok { - setupAllSuite.SetupSuite() - } - defer func() { - if tearDownAllSuite, ok := suite.(TearDownAllSuite); ok { - tearDownAllSuite.TearDownSuite() - } - }() - suiteSetupDone = true - } - test := testing.InternalTest{ - Name: method.Name, - F: func(t *testing.T) { - parentT := suite.T() - suite.SetT(t) - defer failOnPanic(t) - - if setupTestSuite, ok := suite.(SetupTestSuite); ok { - setupTestSuite.SetupTest() - } - if beforeTestSuite, ok := suite.(BeforeTest); ok { - beforeTestSuite.BeforeTest(methodFinder.Elem().Name(), method.Name) - } - defer func() { - if afterTestSuite, ok := suite.(AfterTest); ok { - afterTestSuite.AfterTest(methodFinder.Elem().Name(), method.Name) - } - if tearDownTestSuite, ok := suite.(TearDownTestSuite); ok { - tearDownTestSuite.TearDownTest() - } - suite.SetT(parentT) - }() - method.Func.Call([]reflect.Value{reflect.ValueOf(suite)}) - }, - } - tests = append(tests, test) - } - runTests(t, tests) -} - -func runTests(t testing.TB, tests []testing.InternalTest) { - r, ok := t.(runner) - if !ok { // backwards compatibility with Go 1.6 and below - if !testing.RunTests(allTestsFilter, tests) { - t.Fail() - } - return - } - - for _, test := range tests { - r.Run(test.Name, test.F) - } -} - -// Filtering method according to set regular expression -// specified command-line argument -m -func methodFilter(name string) (bool, error) { - if ok, _ := regexp.MatchString("^Test", name); !ok { - return false, nil - } - return regexp.MatchString(*matchMethod, name) -} - -type runner interface { - Run(name string, f func(t *testing.T)) bool -} diff --git a/vendor/modules.txt b/vendor/modules.txt index 7fb12945..99e65346 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -11,6 +11,5 @@ github.com/rainycape/unidecode # github.com/stretchr/testify v1.4.0 github.com/stretchr/testify/assert github.com/stretchr/testify/require -github.com/stretchr/testify/suite # gopkg.in/yaml.v2 v2.2.2 gopkg.in/yaml.v2