diff --git a/observability-lib/grafana/builder.go b/observability-lib/grafana/builder.go index 42d65efda6..80f5398860 100644 --- a/observability-lib/grafana/builder.go +++ b/observability-lib/grafana/builder.go @@ -36,6 +36,7 @@ type Builder struct { contactPointsBuilder []*alerting.ContactPointBuilder notificationPoliciesBuilder []*alerting.NotificationPolicyBuilder panelCounter uint32 + usedPanelIDs map[uint32]struct{} alertsTags map[string]string rows map[string]*dashboard.RowBuilder entries []buildEntry @@ -141,18 +142,66 @@ func (b *Builder) AddNotificationPolicy(notificationPolicies ...*alerting.Notifi b.notificationPoliciesBuilder = append(b.notificationPoliciesBuilder, notificationPolicies...) } +func (b *Builder) reservePanelID(id uint32) error { + if b.usedPanelIDs == nil { + b.usedPanelIDs = make(map[uint32]struct{}) + } + if _, taken := b.usedPanelIDs[id]; taken { + return fmt.Errorf("duplicate panel ID %d", id) + } + b.usedPanelIDs[id] = struct{}{} + return nil +} + +func (b *Builder) nextAutoPanelID() (uint32, error) { + for { + candidate := b.getPanelCounter() + if b.usedPanelIDs != nil { + if _, taken := b.usedPanelIDs[candidate]; taken { + continue + } + } + if err := b.reservePanelID(candidate); err != nil { + return 0, err + } + return candidate, nil + } +} + +// panelID returns a pinned StableID when set; otherwise the next auto-increment ID. +// Auto-increment skips IDs already reserved by StableID panels in this build. +func (b *Builder) panelID(panel *Panel) (uint32, error) { + if panel != nil && panel.stableID > 0 { + if err := b.reservePanelID(panel.stableID); err != nil { + return 0, err + } + return panel.stableID, nil + } + return b.nextAutoPanelID() +} + // 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 { +func (b *Builder) addPanelToBuilder(item *Panel) error { + id, err := b.panelID(item) + if err != nil { + return err + } + if pb := item.panelBuilder(id); pb != nil { b.dashboardBuilder.WithPanel(pb) } + return nil } // 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 { +func (b *Builder) addPanelToRow(row *dashboard.RowBuilder, item *Panel) error { + id, err := b.panelID(item) + if err != nil { + return err + } + if pb := item.panelBuilder(id); pb != nil { row.WithPanel(pb) } + return nil } func (b *Builder) Build() (*Observability, error) { @@ -175,7 +224,9 @@ func (b *Builder) Build() (*Observability, error) { if !ok { return nil, fmt.Errorf("AddPanelToRow references unknown row %q; call AddRow first", e.rowTitle) } - b.addPanelToRow(row, e.panel) + if err := b.addPanelToRow(row, e.panel); err != nil { + return nil, err + } } } @@ -187,7 +238,9 @@ func (b *Builder) Build() (*Observability, error) { b.dashboardBuilder.WithRow(row) } case entryPanel: - b.addPanelToBuilder(e.panel) + if err := b.addPanelToBuilder(e.panel); err != nil { + return nil, err + } default: continue } diff --git a/observability-lib/grafana/dashboard.go b/observability-lib/grafana/dashboard.go index 0a5edd15ed..9fb75493fa 100644 --- a/observability-lib/grafana/dashboard.go +++ b/observability-lib/grafana/dashboard.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "reflect" + "strconv" "github.com/grafana/grafana-foundation-sdk/go/alerting" "github.com/grafana/grafana-foundation-sdk/go/dashboard" @@ -275,12 +276,9 @@ func (o *Observability) DeployToGrafana(options *DeployOptions) error { } func panelIDByTitle(db *dashboard.Dashboard, title string) string { - for _, panel := range db.Panels { - if panel.Panel != nil && panel.Panel.Title != nil && *panel.Panel.Title == title { - return fmt.Sprintf("%d", *panel.Panel.Id) - } + if id, ok := PanelIDByTitle(db, title); ok { + return strconv.FormatUint(uint64(id), 10) } - return "" } diff --git a/observability-lib/grafana/panel_ids.go b/observability-lib/grafana/panel_ids.go new file mode 100644 index 0000000000..30c06cb8b6 --- /dev/null +++ b/observability-lib/grafana/panel_ids.go @@ -0,0 +1,49 @@ +package grafana + +import ( + "github.com/grafana/grafana-foundation-sdk/go/dashboard" +) + +// PanelIDByTitle returns the panel ID for a dashboard panel matched by title. +// It searches top-level panels and panels nested inside row containers; row +// container panels themselves are not matched. +func PanelIDByTitle(db *dashboard.Dashboard, title string) (uint32, bool) { + if db == nil || title == "" { + return 0, false + } + var found uint32 + ok := false + foreachPanel(db, func(panel *dashboard.Panel) bool { + if panel.Title != nil && *panel.Title == title && panel.Id != nil { + found = *panel.Id + ok = true + return false + } + return true + }) + return found, ok +} + +func foreachPanel(db *dashboard.Dashboard, fn func(panel *dashboard.Panel) bool) { + for i := range db.Panels { + if !applyToOrRowPanel(&db.Panels[i], fn) { + return + } + } +} + +func applyToOrRowPanel(item *dashboard.PanelOrRowPanel, fn func(panel *dashboard.Panel) bool) bool { + if item.Panel != nil { + if !fn(item.Panel) { + return false + } + } + if item.RowPanel != nil { + for j := range item.RowPanel.Panels { + if !fn(&item.RowPanel.Panels[j]) { + return false + } + } + } + return true +} diff --git a/observability-lib/grafana/panel_ids_test.go b/observability-lib/grafana/panel_ids_test.go new file mode 100644 index 0000000000..51397460e6 --- /dev/null +++ b/observability-lib/grafana/panel_ids_test.go @@ -0,0 +1,97 @@ +package grafana_test + +import ( + "strconv" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/observability-lib/grafana" +) + +func TestBuilderPanelOptionsStableID(t *testing.T) { + builder := grafana.NewBuilder(&grafana.BuilderOptions{Name: "PanelOptions StableID"}) + builder.AddRow("Row") + builder.AddPanelToRow("Row", grafana.NewStatPanel(&grafana.StatPanelOptions{ + PanelOptions: &grafana.PanelOptions{ + Title: grafana.Pointer("Inside Row"), + StableID: 20127, + }, + })) + builder.AddPanel(grafana.NewStatPanel(&grafana.StatPanelOptions{ + PanelOptions: &grafana.PanelOptions{Title: grafana.Pointer("Auto Panel")}, + })) + builder.AddPanel(grafana.NewStatPanel(&grafana.StatPanelOptions{ + PanelOptions: &grafana.PanelOptions{ + Title: grafana.Pointer("Top Level Pinned"), + StableID: 20110, + }, + })) + + o, err := builder.Build() + require.NoError(t, err) + + id, ok := grafana.PanelIDByTitle(o.Dashboard, "Inside Row") + require.True(t, ok) + require.Equal(t, uint32(20127), id) + + id, ok = grafana.PanelIDByTitle(o.Dashboard, "Top Level Pinned") + require.True(t, ok) + require.Equal(t, uint32(20110), id) + + id, ok = grafana.PanelIDByTitle(o.Dashboard, "Auto Panel") + require.True(t, ok) + require.NotEqual(t, uint32(20127), id) + require.NotEqual(t, uint32(20110), id) +} + +func TestBuilderPanelOptionsStableIDDuplicate(t *testing.T) { + builder := grafana.NewBuilder(&grafana.BuilderOptions{Name: "Duplicate StableID"}) + builder.AddPanel( + grafana.NewStatPanel(&grafana.StatPanelOptions{ + PanelOptions: &grafana.PanelOptions{Title: grafana.Pointer("A"), StableID: 20101}, + }), + grafana.NewStatPanel(&grafana.StatPanelOptions{ + PanelOptions: &grafana.PanelOptions{Title: grafana.Pointer("B"), StableID: 20101}, + }), + ) + + _, err := builder.Build() + require.Error(t, err) + require.Contains(t, err.Error(), "duplicate panel ID 20101") +} + +func TestBuilderAutoPanelIDSkipsStableID(t *testing.T) { + builder := grafana.NewBuilder(&grafana.BuilderOptions{Name: "Skip Stable ID"}) + builder.AddPanel(grafana.NewStatPanel(&grafana.StatPanelOptions{ + PanelOptions: &grafana.PanelOptions{ + Title: grafana.Pointer("Pinned Low"), + StableID: 5, + }, + })) + for i := 1; i <= 5; i++ { + builder.AddPanel(grafana.NewStatPanel(&grafana.StatPanelOptions{ + PanelOptions: &grafana.PanelOptions{ + Title: grafana.Pointer("Auto " + strconv.Itoa(i)), + }, + })) + } + + o, err := builder.Build() + require.NoError(t, err) + + pinned, ok := grafana.PanelIDByTitle(o.Dashboard, "Pinned Low") + require.True(t, ok) + require.Equal(t, uint32(5), pinned) + + for i := 1; i <= 5; i++ { + title := "Auto " + strconv.Itoa(i) + id, found := grafana.PanelIDByTitle(o.Dashboard, title) + require.True(t, found, "panel %q", title) + require.NotEqual(t, uint32(5), id, "auto panel %q should skip reserved stable id", title) + } + + sixth, found := grafana.PanelIDByTitle(o.Dashboard, "Auto 5") + require.True(t, found) + require.Equal(t, uint32(6), sixth) +} diff --git a/observability-lib/grafana/panels.go b/observability-lib/grafana/panels.go index 45d1915bba..f5bbb8b45c 100644 --- a/observability-lib/grafana/panels.go +++ b/observability-lib/grafana/panels.go @@ -178,9 +178,12 @@ func newToolTip(options *ToolTipOptions) *common.VizTooltipOptionsBuilder { } type PanelOptions struct { - Datasource string - Title *string - Description string + Datasource string + Title *string + Description string + // StableID pins the Grafana panel ID at build time. Use high ID blocks (e.g. 20100+) + // to avoid colliding with auto-assigned sequential IDs. Zero means auto-increment. + StableID uint32 Transparent bool Span uint32 Height uint32 @@ -212,6 +215,15 @@ type Panel struct { businessVariablePanelBuilder *businessvariable.PanelBuilder polystatPanelBuilder *polystat.PanelBuilder alertBuilders []*alerting.RuleBuilder + stableID uint32 +} + +func attachPanelMeta(panel *Panel, panelOptions *PanelOptions) *Panel { + if panel == nil || panelOptions == nil { + return panel + } + panel.stableID = panelOptions.StableID + return panel } // panelBuilder sets the panel ID and returns the underlying builder as a cog.Builder[dashboard.Panel]. @@ -385,9 +397,9 @@ func NewStatPanel(options *StatPanelOptions) *Panel { newPanel.ColorScheme(dashboard.NewFieldColorBuilder().Mode(options.ColorScheme)) } - return &Panel{ + return attachPanelMeta(&Panel{ statPanelBuilder: newPanel, - } + }, options.PanelOptions) } type TimeSeriesPanelOptions struct { @@ -526,10 +538,10 @@ func NewTimeSeriesPanel(options *TimeSeriesPanelOptions) *Panel { } } - return &Panel{ + return attachPanelMeta(&Panel{ timeSeriesPanelBuilder: newPanel, alertBuilders: alertBuilders, - } + }, options.PanelOptions) } type BarGaugePanelOptions struct { @@ -605,9 +617,9 @@ func NewBarGaugePanel(options *BarGaugePanelOptions) *Panel { newPanel.Orientation(options.Orientation) } - return &Panel{ + return attachPanelMeta(&Panel{ barGaugePanelBuilder: newPanel, - } + }, options.PanelOptions) } type GaugePanelOptions struct { @@ -673,9 +685,9 @@ func NewGaugePanel(options *GaugePanelOptions) *Panel { } } - return &Panel{ + return attachPanelMeta(&Panel{ gaugePanelBuilder: newPanel, - } + }, options.PanelOptions) } type SortByOptions struct { @@ -791,9 +803,9 @@ func NewTablePanel(options *TablePanelOptions) *Panel { newPanel.SortBy(sortBy) } - return &Panel{ + return attachPanelMeta(&Panel{ tablePanelBuilder: newPanel, - } + }, options.PanelOptions) } type LogPanelOptions struct { @@ -879,9 +891,9 @@ func NewLogPanel(options *LogPanelOptions) *Panel { newPanel.ColorScheme(dashboard.NewFieldColorBuilder().Mode(options.ColorScheme)) } - return &Panel{ + return attachPanelMeta(&Panel{ logPanelBuilder: newPanel, - } + }, options.PanelOptions) } type HeatmapPanelOptions struct { @@ -943,9 +955,9 @@ func NewHeatmapPanel(options *HeatmapPanelOptions) *Panel { newPanel.ColorScheme(dashboard.NewFieldColorBuilder().Mode(options.ColorScheme)) } - return &Panel{ + return attachPanelMeta(&Panel{ heatmapBuilder: newPanel, - } + }, options.PanelOptions) } type TextPanelOptions struct { @@ -974,9 +986,9 @@ func NewTextPanel(options *TextPanelOptions) *Panel { newPanel.Interval(options.Interval) } - return &Panel{ + return attachPanelMeta(&Panel{ textPanelBuilder: newPanel, - } + }, options.PanelOptions) } type HistogramPanelOptions struct { @@ -1141,9 +1153,9 @@ func NewHistogramPanel(options *HistogramPanelOptions) *Panel { newPanel.AxisWidth(*options.AxisWidth) } - return &Panel{ + return attachPanelMeta(&Panel{ histogramPanelBuilder: newPanel, - } + }, options.PanelOptions) } type BusinessVariablePanelOptions struct { @@ -1171,9 +1183,9 @@ func NewBusinessVariablePanel(options *BusinessVariablePanelOptions) *Panel { newPanel.Padding(*options.Padding) } - return &Panel{ + return attachPanelMeta(&Panel{ businessVariablePanelBuilder: newPanel, - } + }, options.PanelOptions) } type PolystatPanelOptions struct { @@ -1294,7 +1306,7 @@ func NewPolystatPanel(options *PolystatPanelOptions) *Panel { newPanel.WithTarget(newQuery(query)) } - return &Panel{ + return attachPanelMeta(&Panel{ polystatPanelBuilder: newPanel, - } + }, options.PanelOptions) }