diff --git a/observability-lib/README.md b/observability-lib/README.md index 8e60212cde..2217201366 100644 --- a/observability-lib/README.md +++ b/observability-lib/README.md @@ -27,7 +27,16 @@ Godoc generated documentation is available [here](https://pkg.go.dev/github.com/ ### Creating a dashboard -
main.go +There are two ways to add panels to a dashboard: + +- **`AddPanel`**: adds a panel directly to the dashboard as a top-level element. Panels appear in the order they are added. +- **`AddPanelToRow`**: adds a panel inside a row. Rows with panels are automatically **collapsed** in Grafana, meaning their panels are nested and hidden until the user expands the row. + +Rows without any panels added via `AddPanelToRow` remain open (not collapsed). + +You can freely interleave `AddRow`, `AddPanel`, and `AddPanelToRow` calls — the dashboard will preserve the insertion order. + +#### Basic dashboard with top-level panels ```go package main @@ -90,7 +99,134 @@ func main() { fmt.Println(string(json)) } ``` -
+ +#### Dashboard with collapsed rows + +Use `AddPanelToRow` to nest panels inside a row. The row will be automatically collapsed in Grafana, so users can expand it to see the panels inside. + +```go +builder := grafana.NewBuilder(&grafana.BuilderOptions{ + Name: "Dashboard With Collapsed Rows", + Tags: []string{"example"}, + Refresh: "30s", +}) + +// A collapsed row with multiple panels nested inside +builder.AddRow("Resource Usage") +builder.AddPanelToRow("Resource Usage", + grafana.NewTimeSeriesPanel(&grafana.TimeSeriesPanelOptions{ + PanelOptions: &grafana.PanelOptions{ + Datasource: "Prometheus", + Title: grafana.Pointer("CPU Usage"), + Span: 12, + Height: 8, + Query: []grafana.Query{ + { + Expr: `rate(cpu_usage_seconds_total[5m])`, + Legend: `{{ pod }}`, + }, + }, + }, + }), + grafana.NewStatPanel(&grafana.StatPanelOptions{ + PanelOptions: &grafana.PanelOptions{ + Datasource: "Prometheus", + Title: grafana.Pointer("Memory Usage"), + Span: 12, + Height: 4, + Query: []grafana.Query{ + { + Expr: `memory_usage_bytes`, + Legend: `{{ pod }}`, + }, + }, + }, + }), +) + +// Another collapsed row +builder.AddRow("Network") +builder.AddPanelToRow("Network", + grafana.NewTimeSeriesPanel(&grafana.TimeSeriesPanelOptions{ + PanelOptions: &grafana.PanelOptions{ + Datasource: "Prometheus", + Title: grafana.Pointer("Network I/O"), + Span: 24, + Height: 8, + Query: []grafana.Query{ + { + Expr: `rate(network_bytes_total[5m])`, + Legend: `{{ interface }}`, + }, + }, + }, + }), +) +``` + +#### Mixing top-level panels, open rows, and collapsed rows + +You can combine all three patterns in a single dashboard. The order of calls determines the layout. + +```go +builder := grafana.NewBuilder(&grafana.BuilderOptions{ + Name: "Mixed Layout Dashboard", + Refresh: "30s", +}) + +// Top-level panel (not inside any row) +builder.AddPanel(grafana.NewStatPanel(&grafana.StatPanelOptions{ + PanelOptions: &grafana.PanelOptions{ + Title: grafana.Pointer("Health Status"), + Span: 24, + }, +})) + +// Open row (no panels added via AddPanelToRow, so it stays expanded) +builder.AddRow("Overview") + +// Top-level panel after the open row +builder.AddPanel(grafana.NewStatPanel(&grafana.StatPanelOptions{ + PanelOptions: &grafana.PanelOptions{ + Title: grafana.Pointer("Request Rate"), + Span: 12, + }, +})) + +// Collapsed row with panels inside +builder.AddRow("Details") +builder.AddPanelToRow("Details", + grafana.NewTablePanel(&grafana.TablePanelOptions{ + PanelOptions: &grafana.PanelOptions{ + Title: grafana.Pointer("Request Log"), + Span: 24, + }, + }), + grafana.NewTimeSeriesPanel(&grafana.TimeSeriesPanelOptions{ + PanelOptions: &grafana.PanelOptions{ + Title: grafana.Pointer("Latency Over Time"), + Span: 24, + }, + }), +) + +// Another top-level panel after the collapsed row +builder.AddPanel(grafana.NewStatPanel(&grafana.StatPanelOptions{ + PanelOptions: &grafana.PanelOptions{ + Title: grafana.Pointer("Error Rate"), + Span: 12, + }, +})) + +// Resulting layout: +// 1. Health Status (top-level panel) +// 2. Overview (open row, expanded) +// 3. Request Rate (top-level panel) +// 4. Details (collapsed row, click to expand) +// ├─ Request Log (nested inside row) +// └─ Latency Over Time(nested inside row) +// 5. Error Rate (top-level panel) +``` ## Cmd Usage diff --git a/observability-lib/grafana/builder.go b/observability-lib/grafana/builder.go index 93f559c8fe..42d65efda6 100644 --- a/observability-lib/grafana/builder.go +++ b/observability-lib/grafana/builder.go @@ -1,6 +1,8 @@ package grafana import ( + "errors" + "fmt" "maps" "github.com/grafana/grafana-foundation-sdk/go/alerting" @@ -13,6 +15,20 @@ import ( "github.com/smartcontractkit/chainlink-common/observability-lib/grafana/polystat" ) +type entryKind int + +const ( + entryRow entryKind = iota + entryPanel + entryPanelToRow +) + +type buildEntry struct { + kind entryKind + rowTitle string + panel *Panel +} + type Builder struct { dashboardBuilder *dashboard.DashboardBuilder alertsBuilder []*alerting.RuleBuilder @@ -21,6 +37,9 @@ type Builder struct { notificationPoliciesBuilder []*alerting.NotificationPolicyBuilder panelCounter uint32 alertsTags map[string]string + rows map[string]*dashboard.RowBuilder + entries []buildEntry + built bool } type BuilderOptions struct { @@ -34,10 +53,6 @@ type BuilderOptions struct { AlertsTags map[string]string } -type RowOptions struct { - Collapsed bool -} - func NewBuilder(options *BuilderOptions) *Builder { plugins.RegisterDefaultPlugins() cog.NewRuntime().RegisterPanelcfgVariant(businessvariable.VariantConfig()) @@ -77,12 +92,13 @@ func (b *Builder) AddVars(items ...cog.Builder[dashboard.VariableModel]) { } } -func (b *Builder) AddRow(title string, options ...RowOptions) { +func (b *Builder) AddRow(title string) { row := dashboard.NewRowBuilder(title) - for _, o := range options { - row.Collapsed(o.Collapsed) + if b.rows == nil { + b.rows = make(map[string]*dashboard.RowBuilder) } - b.dashboardBuilder.WithRow(row) + b.rows[title] = row + b.entries = append(b.entries, buildEntry{kind: entryRow, rowTitle: title}) } func (b *Builder) getPanelCounter() uint32 { @@ -91,43 +107,18 @@ func (b *Builder) getPanelCounter() uint32 { return res } -func (b *Builder) AddPanel(panel ...*Panel) { +func (b *Builder) AddPanelToRow(rowTitle string, panel ...*Panel) { for _, item := range panel { - panelID := b.getPanelCounter() - if item.statPanelBuilder != nil { - item.statPanelBuilder.Id(panelID) - b.dashboardBuilder.WithPanel(item.statPanelBuilder) - } else if item.timeSeriesPanelBuilder != nil { - item.timeSeriesPanelBuilder.Id(panelID) - b.dashboardBuilder.WithPanel(item.timeSeriesPanelBuilder) - } else if item.barGaugePanelBuilder != nil { - item.barGaugePanelBuilder.Id(panelID) - b.dashboardBuilder.WithPanel(item.barGaugePanelBuilder) - } else if item.gaugePanelBuilder != nil { - item.gaugePanelBuilder.Id(panelID) - b.dashboardBuilder.WithPanel(item.gaugePanelBuilder) - } else if item.tablePanelBuilder != nil { - item.tablePanelBuilder.Id(panelID) - b.dashboardBuilder.WithPanel(item.tablePanelBuilder) - } else if item.logPanelBuilder != nil { - item.logPanelBuilder.Id(panelID) - b.dashboardBuilder.WithPanel(item.logPanelBuilder) - } else if item.heatmapBuilder != nil { - item.heatmapBuilder.Id(panelID) - b.dashboardBuilder.WithPanel(item.heatmapBuilder) - } else if item.textPanelBuilder != nil { - item.textPanelBuilder.Id(panelID) - b.dashboardBuilder.WithPanel(item.textPanelBuilder) - } else if item.histogramPanelBuilder != nil { - item.histogramPanelBuilder.Id(panelID) - b.dashboardBuilder.WithPanel(item.histogramPanelBuilder) - } else if item.businessVariablePanelBuilder != nil { - item.businessVariablePanelBuilder.Id(panelID) - b.dashboardBuilder.WithPanel(item.businessVariablePanelBuilder) - } else if item.polystatPanelBuilder != nil { - item.polystatPanelBuilder.Id(panelID) - b.dashboardBuilder.WithPanel(item.polystatPanelBuilder) + b.entries = append(b.entries, buildEntry{kind: entryPanelToRow, rowTitle: rowTitle, panel: item}) + if len(item.alertBuilders) > 0 { + b.AddAlert(item.alertBuilders...) } + } +} + +func (b *Builder) AddPanel(panel ...*Panel) { + for _, item := range panel { + b.entries = append(b.entries, buildEntry{kind: entryPanel, panel: item}) if len(item.alertBuilders) > 0 { b.AddAlert(item.alertBuilders...) } @@ -150,10 +141,58 @@ func (b *Builder) AddNotificationPolicy(notificationPolicies ...*alerting.Notifi b.notificationPoliciesBuilder = append(b.notificationPoliciesBuilder, notificationPolicies...) } +// addPanelToBuilder assigns an ID and adds the panel to the dashboard builder. +func (b *Builder) addPanelToBuilder(item *Panel) { + if pb := item.panelBuilder(b.getPanelCounter()); pb != nil { + b.dashboardBuilder.WithPanel(pb) + } +} + +// addPanelToRow assigns an ID and adds the panel to a row builder. +func (b *Builder) addPanelToRow(row *dashboard.RowBuilder, item *Panel) { + if pb := item.panelBuilder(b.getPanelCounter()); pb != nil { + row.WithPanel(pb) + } +} + func (b *Builder) Build() (*Observability, error) { + if b.built { + return nil, errors.New("Build() has already been called; create a new Builder for a new build") + } + b.built = true + observability := Observability{} + if b.dashboardBuilder == nil && len(b.entries) > 0 { + return nil, errors.New("cannot add rows or panels without a dashboard; set BuilderOptions.Name to create one") + } + if b.dashboardBuilder != nil { + // First pass: attach panels to their row builders (needed before WithRow snapshots them) + for _, e := range b.entries { + if e.kind == entryPanelToRow { + row, ok := b.rows[e.rowTitle] + if !ok { + return nil, fmt.Errorf("AddPanelToRow references unknown row %q; call AddRow first", e.rowTitle) + } + b.addPanelToRow(row, e.panel) + } + } + + // Second pass: add rows and top-level panels to the dashboard in order + for _, e := range b.entries { + switch e.kind { + case entryRow: + if row, ok := b.rows[e.rowTitle]; ok { + b.dashboardBuilder.WithRow(row) + } + case entryPanel: + b.addPanelToBuilder(e.panel) + default: + continue + } + } + db, errBuildDashboard := b.dashboardBuilder.Build() if errBuildDashboard != nil { return nil, errBuildDashboard diff --git a/observability-lib/grafana/builder_test.go b/observability-lib/grafana/builder_test.go index 0e48806823..210cc2cdc0 100644 --- a/observability-lib/grafana/builder_test.go +++ b/observability-lib/grafana/builder_test.go @@ -139,6 +139,255 @@ func TestNewBuilder(t *testing.T) { require.Empty(t, o.ContactPoints) require.NotEmpty(t, o.NotificationPolicies) }) + + t.Run("NewBuilder builds a dashboard with row and panels inside", func(t *testing.T) { + builder := grafana.NewBuilder(&grafana.BuilderOptions{ + Name: "Dashboard Name", + }) + builder.AddRow("Row Title") + builder.AddPanelToRow("Row Title", grafana.NewStatPanel(&grafana.StatPanelOptions{ + PanelOptions: &grafana.PanelOptions{ + Title: grafana.Pointer("Panel Title"), + }, + })) + + o, err := builder.Build() + if err != nil { + t.Errorf("Error during build: %v", err) + } + require.NotEmpty(t, o.Dashboard) + require.Len(t, o.Dashboard.Panels, 1) + rowPanel := o.Dashboard.Panels[0] + require.IsType(t, dashboard.RowPanel{}, *rowPanel.RowPanel) + require.True(t, rowPanel.RowPanel.Collapsed) + require.Len(t, rowPanel.RowPanel.Panels, 1) + }) + + t.Run("NewBuilder builds a dashboard with row and multiple panels inside", func(t *testing.T) { + builder := grafana.NewBuilder(&grafana.BuilderOptions{ + Name: "Dashboard Name", + }) + builder.AddRow("Row Title") + builder.AddPanelToRow("Row Title", + grafana.NewStatPanel(&grafana.StatPanelOptions{ + PanelOptions: &grafana.PanelOptions{ + Title: grafana.Pointer("Stat Panel"), + }, + }), + grafana.NewTimeSeriesPanel(&grafana.TimeSeriesPanelOptions{ + PanelOptions: &grafana.PanelOptions{ + Title: grafana.Pointer("TimeSeries Panel"), + }, + }), + grafana.NewTablePanel(&grafana.TablePanelOptions{ + PanelOptions: &grafana.PanelOptions{ + Title: grafana.Pointer("Table Panel"), + }, + }), + ) + + o, err := builder.Build() + require.NoError(t, err) + require.Len(t, o.Dashboard.Panels, 1) + rowPanel := o.Dashboard.Panels[0] + require.NotNil(t, rowPanel.RowPanel) + require.True(t, rowPanel.RowPanel.Collapsed) + require.Len(t, rowPanel.RowPanel.Panels, 3) + }) + + t.Run("NewBuilder preserves order with interleaved AddPanel and AddRow", func(t *testing.T) { + // Layout: top-level panel, then a row, then another top-level panel + builder := grafana.NewBuilder(&grafana.BuilderOptions{ + Name: "Dashboard Name", + }) + builder.AddPanel(grafana.NewStatPanel(&grafana.StatPanelOptions{ + PanelOptions: &grafana.PanelOptions{ + Title: grafana.Pointer("Top Panel 1"), + }, + })) + builder.AddRow("Row A") + builder.AddPanel(grafana.NewStatPanel(&grafana.StatPanelOptions{ + PanelOptions: &grafana.PanelOptions{ + Title: grafana.Pointer("Top Panel 2"), + }, + })) + + o, err := builder.Build() + require.NoError(t, err) + require.Len(t, o.Dashboard.Panels, 3) + // First: top-level panel + require.NotNil(t, o.Dashboard.Panels[0].Panel) + require.Equal(t, "Top Panel 1", *o.Dashboard.Panels[0].Panel.Title) + // Second: row + require.NotNil(t, o.Dashboard.Panels[1].RowPanel) + require.Equal(t, "Row A", *o.Dashboard.Panels[1].RowPanel.Title) + // Third: top-level panel + require.NotNil(t, o.Dashboard.Panels[2].Panel) + require.Equal(t, "Top Panel 2", *o.Dashboard.Panels[2].Panel.Title) + }) + + t.Run("NewBuilder mixed rows with and without panels preserve order", func(t *testing.T) { + // Layout: row without panels, top-level panel, row with 2 panels, top-level panel + builder := grafana.NewBuilder(&grafana.BuilderOptions{ + Name: "Dashboard Name", + }) + builder.AddRow("Open Row") + builder.AddPanel(grafana.NewStatPanel(&grafana.StatPanelOptions{ + PanelOptions: &grafana.PanelOptions{ + Title: grafana.Pointer("Panel After Open Row"), + }, + })) + builder.AddRow("Row With Panels") + builder.AddPanelToRow("Row With Panels", + grafana.NewStatPanel(&grafana.StatPanelOptions{ + PanelOptions: &grafana.PanelOptions{ + Title: grafana.Pointer("Inside Row 1"), + }, + }), + grafana.NewGaugePanel(&grafana.GaugePanelOptions{ + PanelOptions: &grafana.PanelOptions{ + Title: grafana.Pointer("Inside Row 2"), + }, + }), + ) + builder.AddPanel(grafana.NewStatPanel(&grafana.StatPanelOptions{ + PanelOptions: &grafana.PanelOptions{ + Title: grafana.Pointer("Panel After Row With Panels"), + }, + })) + + o, err := builder.Build() + require.NoError(t, err) + // Expected top-level: Open Row, Panel After Open Row, Row With Panels, Panel After Row With Panels + require.Len(t, o.Dashboard.Panels, 4) + + // 1. Row without panels + require.NotNil(t, o.Dashboard.Panels[0].RowPanel) + require.Equal(t, "Open Row", *o.Dashboard.Panels[0].RowPanel.Title) + require.False(t, o.Dashboard.Panels[0].RowPanel.Collapsed) + + // 2. Top-level panel after the open row + require.NotNil(t, o.Dashboard.Panels[1].Panel) + require.Equal(t, "Panel After Open Row", *o.Dashboard.Panels[1].Panel.Title) + + // 3. Row with its 2 panels nested inside (automatically collapsed) + require.NotNil(t, o.Dashboard.Panels[2].RowPanel) + require.Equal(t, "Row With Panels", *o.Dashboard.Panels[2].RowPanel.Title) + require.True(t, o.Dashboard.Panels[2].RowPanel.Collapsed) + require.Len(t, o.Dashboard.Panels[2].RowPanel.Panels, 2) + + // 4. Top-level panel after the row with panels + require.NotNil(t, o.Dashboard.Panels[3].Panel) + require.Equal(t, "Panel After Row With Panels", *o.Dashboard.Panels[3].Panel.Title) + }) + + t.Run("NewBuilder multiple rows each with their own panels", func(t *testing.T) { + builder := grafana.NewBuilder(&grafana.BuilderOptions{ + Name: "Dashboard Name", + }) + builder.AddRow("Row A") + builder.AddPanelToRow("Row A", grafana.NewStatPanel(&grafana.StatPanelOptions{ + PanelOptions: &grafana.PanelOptions{ + Title: grafana.Pointer("Panel in A"), + }, + })) + builder.AddRow("Row B") + builder.AddPanelToRow("Row B", + grafana.NewStatPanel(&grafana.StatPanelOptions{ + PanelOptions: &grafana.PanelOptions{ + Title: grafana.Pointer("Panel in B1"), + }, + }), + grafana.NewTimeSeriesPanel(&grafana.TimeSeriesPanelOptions{ + PanelOptions: &grafana.PanelOptions{ + Title: grafana.Pointer("Panel in B2"), + }, + }), + ) + + o, err := builder.Build() + require.NoError(t, err) + require.Len(t, o.Dashboard.Panels, 2) + + // First row + require.NotNil(t, o.Dashboard.Panels[0].RowPanel) + require.Equal(t, "Row A", *o.Dashboard.Panels[0].RowPanel.Title) + require.Len(t, o.Dashboard.Panels[0].RowPanel.Panels, 1) + + // Second row + require.NotNil(t, o.Dashboard.Panels[1].RowPanel) + require.Equal(t, "Row B", *o.Dashboard.Panels[1].RowPanel.Title) + require.Len(t, o.Dashboard.Panels[1].RowPanel.Panels, 2) + }) + + t.Run("NewBuilder row without panels is not collapsed", func(t *testing.T) { + builder := grafana.NewBuilder(&grafana.BuilderOptions{ + Name: "Dashboard Name", + }) + builder.AddRow("Open Row") + + o, err := builder.Build() + require.NoError(t, err) + require.Len(t, o.Dashboard.Panels, 1) + require.NotNil(t, o.Dashboard.Panels[0].RowPanel) + require.False(t, o.Dashboard.Panels[0].RowPanel.Collapsed) + require.Empty(t, o.Dashboard.Panels[0].RowPanel.Panels) + }) +} + +func TestBuilder_BuildOnce(t *testing.T) { + t.Run("Build returns error on second call", func(t *testing.T) { + builder := grafana.NewBuilder(&grafana.BuilderOptions{ + Name: "Dashboard Name", + }) + + _, err := builder.Build() + require.NoError(t, err) + + _, err = builder.Build() + require.Error(t, err) + require.Contains(t, err.Error(), "already been called") + }) + + t.Run("Build returns error when panels added without dashboard name", func(t *testing.T) { + builder := grafana.NewBuilder(&grafana.BuilderOptions{}) + builder.AddPanel(grafana.NewStatPanel(&grafana.StatPanelOptions{ + PanelOptions: &grafana.PanelOptions{ + Title: grafana.Pointer("Panel Title"), + }, + })) + + _, err := builder.Build() + require.Error(t, err) + require.Contains(t, err.Error(), "cannot add rows or panels without a dashboard") + }) + + t.Run("Build returns error when AddPanelToRow references unknown row", func(t *testing.T) { + builder := grafana.NewBuilder(&grafana.BuilderOptions{ + Name: "Dashboard Name", + }) + builder.AddPanelToRow("NonExistent Row", grafana.NewStatPanel(&grafana.StatPanelOptions{ + PanelOptions: &grafana.PanelOptions{ + Title: grafana.Pointer("Panel Title"), + }, + })) + + _, err := builder.Build() + require.Error(t, err) + require.Contains(t, err.Error(), `unknown row "NonExistent Row"`) + }) + + t.Run("Build succeeds for alerts-only without dashboard name", func(t *testing.T) { + builder := grafana.NewBuilder(&grafana.BuilderOptions{}) + builder.AddAlert(grafana.NewAlertRule(&grafana.AlertOptions{ + Title: "Alert Title", + })) + + o, err := builder.Build() + require.NoError(t, err) + require.Empty(t, o.Dashboard) + require.Len(t, o.Alerts, 1) + }) } func TestBuilder_AddVars(t *testing.T) { @@ -209,20 +458,6 @@ func TestBuilder_AddRow(t *testing.T) { } require.IsType(t, dashboard.RowPanel{}, *o.Dashboard.Panels[0].RowPanel) }) - - t.Run("AddRow adds a collapsed row to the dashboard", func(t *testing.T) { - builder := grafana.NewBuilder(&grafana.BuilderOptions{ - Name: "Dashboard Name", - }) - - builder.AddRow("Row Title", grafana.RowOptions{Collapsed: true}) - o, err := builder.Build() - if err != nil { - t.Errorf("Error building dashboard: %v", err) - } - require.IsType(t, dashboard.RowPanel{}, *o.Dashboard.Panels[0].RowPanel) - require.True(t, o.Dashboard.Panels[0].RowPanel.Collapsed) - }) } func TestBuilder_AddPanel(t *testing.T) { diff --git a/observability-lib/grafana/panels.go b/observability-lib/grafana/panels.go index 172865b74a..45d1915bba 100644 --- a/observability-lib/grafana/panels.go +++ b/observability-lib/grafana/panels.go @@ -214,6 +214,47 @@ type Panel struct { alertBuilders []*alerting.RuleBuilder } +// panelBuilder sets the panel ID and returns the underlying builder as a cog.Builder[dashboard.Panel]. +func (p *Panel) panelBuilder(id uint32) cog.Builder[dashboard.Panel] { + switch { + case p.statPanelBuilder != nil: + p.statPanelBuilder.Id(id) + return p.statPanelBuilder + case p.timeSeriesPanelBuilder != nil: + p.timeSeriesPanelBuilder.Id(id) + return p.timeSeriesPanelBuilder + case p.barGaugePanelBuilder != nil: + p.barGaugePanelBuilder.Id(id) + return p.barGaugePanelBuilder + case p.gaugePanelBuilder != nil: + p.gaugePanelBuilder.Id(id) + return p.gaugePanelBuilder + case p.tablePanelBuilder != nil: + p.tablePanelBuilder.Id(id) + return p.tablePanelBuilder + case p.logPanelBuilder != nil: + p.logPanelBuilder.Id(id) + return p.logPanelBuilder + case p.heatmapBuilder != nil: + p.heatmapBuilder.Id(id) + return p.heatmapBuilder + case p.textPanelBuilder != nil: + p.textPanelBuilder.Id(id) + return p.textPanelBuilder + case p.histogramPanelBuilder != nil: + p.histogramPanelBuilder.Id(id) + return p.histogramPanelBuilder + case p.businessVariablePanelBuilder != nil: + p.businessVariablePanelBuilder.Id(id) + return p.businessVariablePanelBuilder + case p.polystatPanelBuilder != nil: + p.polystatPanelBuilder.Id(id) + return p.polystatPanelBuilder + default: + return nil + } +} + // panel defaults func setDefaults(options *PanelOptions) { if options.Datasource == "" {