Skip to content

Commit b1fd6cb

Browse files
authored
feat(observability-lib): support AddPanelToRow to support collapsed rows (#1997)
* feat(observability-lib): support AddPanelToRow to support collapsed rows * fix(observability-lib): linting * fix(observability-lib): prevent replay built * fix(observability-lib): trigger error when no name is set * chore(observability-lib): refactor to add new panel type in unique place * chore(observability-lib): cannot add panel to row on non existent row
1 parent 9eb935b commit b1fd6cb

4 files changed

Lines changed: 510 additions & 59 deletions

File tree

observability-lib/README.md

Lines changed: 138 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,16 @@ Godoc generated documentation is available [here](https://pkg.go.dev/github.com/
2727

2828
### Creating a dashboard
2929

30-
<details><summary>main.go</summary>
30+
There are two ways to add panels to a dashboard:
31+
32+
- **`AddPanel`**: adds a panel directly to the dashboard as a top-level element. Panels appear in the order they are added.
33+
- **`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.
34+
35+
Rows without any panels added via `AddPanelToRow` remain open (not collapsed).
36+
37+
You can freely interleave `AddRow`, `AddPanel`, and `AddPanelToRow` calls — the dashboard will preserve the insertion order.
38+
39+
#### Basic dashboard with top-level panels
3140

3241
```go
3342
package main
@@ -90,7 +99,134 @@ func main() {
9099
fmt.Println(string(json))
91100
}
92101
```
93-
</details>
102+
103+
#### Dashboard with collapsed rows
104+
105+
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.
106+
107+
```go
108+
builder := grafana.NewBuilder(&grafana.BuilderOptions{
109+
Name: "Dashboard With Collapsed Rows",
110+
Tags: []string{"example"},
111+
Refresh: "30s",
112+
})
113+
114+
// A collapsed row with multiple panels nested inside
115+
builder.AddRow("Resource Usage")
116+
builder.AddPanelToRow("Resource Usage",
117+
grafana.NewTimeSeriesPanel(&grafana.TimeSeriesPanelOptions{
118+
PanelOptions: &grafana.PanelOptions{
119+
Datasource: "Prometheus",
120+
Title: grafana.Pointer("CPU Usage"),
121+
Span: 12,
122+
Height: 8,
123+
Query: []grafana.Query{
124+
{
125+
Expr: `rate(cpu_usage_seconds_total[5m])`,
126+
Legend: `{{ pod }}`,
127+
},
128+
},
129+
},
130+
}),
131+
grafana.NewStatPanel(&grafana.StatPanelOptions{
132+
PanelOptions: &grafana.PanelOptions{
133+
Datasource: "Prometheus",
134+
Title: grafana.Pointer("Memory Usage"),
135+
Span: 12,
136+
Height: 4,
137+
Query: []grafana.Query{
138+
{
139+
Expr: `memory_usage_bytes`,
140+
Legend: `{{ pod }}`,
141+
},
142+
},
143+
},
144+
}),
145+
)
146+
147+
// Another collapsed row
148+
builder.AddRow("Network")
149+
builder.AddPanelToRow("Network",
150+
grafana.NewTimeSeriesPanel(&grafana.TimeSeriesPanelOptions{
151+
PanelOptions: &grafana.PanelOptions{
152+
Datasource: "Prometheus",
153+
Title: grafana.Pointer("Network I/O"),
154+
Span: 24,
155+
Height: 8,
156+
Query: []grafana.Query{
157+
{
158+
Expr: `rate(network_bytes_total[5m])`,
159+
Legend: `{{ interface }}`,
160+
},
161+
},
162+
},
163+
}),
164+
)
165+
```
166+
167+
#### Mixing top-level panels, open rows, and collapsed rows
168+
169+
You can combine all three patterns in a single dashboard. The order of calls determines the layout.
170+
171+
```go
172+
builder := grafana.NewBuilder(&grafana.BuilderOptions{
173+
Name: "Mixed Layout Dashboard",
174+
Refresh: "30s",
175+
})
176+
177+
// Top-level panel (not inside any row)
178+
builder.AddPanel(grafana.NewStatPanel(&grafana.StatPanelOptions{
179+
PanelOptions: &grafana.PanelOptions{
180+
Title: grafana.Pointer("Health Status"),
181+
Span: 24,
182+
},
183+
}))
184+
185+
// Open row (no panels added via AddPanelToRow, so it stays expanded)
186+
builder.AddRow("Overview")
187+
188+
// Top-level panel after the open row
189+
builder.AddPanel(grafana.NewStatPanel(&grafana.StatPanelOptions{
190+
PanelOptions: &grafana.PanelOptions{
191+
Title: grafana.Pointer("Request Rate"),
192+
Span: 12,
193+
},
194+
}))
195+
196+
// Collapsed row with panels inside
197+
builder.AddRow("Details")
198+
builder.AddPanelToRow("Details",
199+
grafana.NewTablePanel(&grafana.TablePanelOptions{
200+
PanelOptions: &grafana.PanelOptions{
201+
Title: grafana.Pointer("Request Log"),
202+
Span: 24,
203+
},
204+
}),
205+
grafana.NewTimeSeriesPanel(&grafana.TimeSeriesPanelOptions{
206+
PanelOptions: &grafana.PanelOptions{
207+
Title: grafana.Pointer("Latency Over Time"),
208+
Span: 24,
209+
},
210+
}),
211+
)
212+
213+
// Another top-level panel after the collapsed row
214+
builder.AddPanel(grafana.NewStatPanel(&grafana.StatPanelOptions{
215+
PanelOptions: &grafana.PanelOptions{
216+
Title: grafana.Pointer("Error Rate"),
217+
Span: 12,
218+
},
219+
}))
220+
221+
// Resulting layout:
222+
// 1. Health Status (top-level panel)
223+
// 2. Overview (open row, expanded)
224+
// 3. Request Rate (top-level panel)
225+
// 4. Details (collapsed row, click to expand)
226+
// ├─ Request Log (nested inside row)
227+
// └─ Latency Over Time(nested inside row)
228+
// 5. Error Rate (top-level panel)
229+
```
94230

95231
## Cmd Usage
96232

observability-lib/grafana/builder.go

Lines changed: 82 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package grafana
22

33
import (
4+
"errors"
5+
"fmt"
46
"maps"
57

68
"github.com/grafana/grafana-foundation-sdk/go/alerting"
@@ -13,6 +15,20 @@ import (
1315
"github.com/smartcontractkit/chainlink-common/observability-lib/grafana/polystat"
1416
)
1517

18+
type entryKind int
19+
20+
const (
21+
entryRow entryKind = iota
22+
entryPanel
23+
entryPanelToRow
24+
)
25+
26+
type buildEntry struct {
27+
kind entryKind
28+
rowTitle string
29+
panel *Panel
30+
}
31+
1632
type Builder struct {
1733
dashboardBuilder *dashboard.DashboardBuilder
1834
alertsBuilder []*alerting.RuleBuilder
@@ -21,6 +37,9 @@ type Builder struct {
2137
notificationPoliciesBuilder []*alerting.NotificationPolicyBuilder
2238
panelCounter uint32
2339
alertsTags map[string]string
40+
rows map[string]*dashboard.RowBuilder
41+
entries []buildEntry
42+
built bool
2443
}
2544

2645
type BuilderOptions struct {
@@ -34,10 +53,6 @@ type BuilderOptions struct {
3453
AlertsTags map[string]string
3554
}
3655

37-
type RowOptions struct {
38-
Collapsed bool
39-
}
40-
4156
func NewBuilder(options *BuilderOptions) *Builder {
4257
plugins.RegisterDefaultPlugins()
4358
cog.NewRuntime().RegisterPanelcfgVariant(businessvariable.VariantConfig())
@@ -77,12 +92,13 @@ func (b *Builder) AddVars(items ...cog.Builder[dashboard.VariableModel]) {
7792
}
7893
}
7994

80-
func (b *Builder) AddRow(title string, options ...RowOptions) {
95+
func (b *Builder) AddRow(title string) {
8196
row := dashboard.NewRowBuilder(title)
82-
for _, o := range options {
83-
row.Collapsed(o.Collapsed)
97+
if b.rows == nil {
98+
b.rows = make(map[string]*dashboard.RowBuilder)
8499
}
85-
b.dashboardBuilder.WithRow(row)
100+
b.rows[title] = row
101+
b.entries = append(b.entries, buildEntry{kind: entryRow, rowTitle: title})
86102
}
87103

88104
func (b *Builder) getPanelCounter() uint32 {
@@ -91,43 +107,18 @@ func (b *Builder) getPanelCounter() uint32 {
91107
return res
92108
}
93109

94-
func (b *Builder) AddPanel(panel ...*Panel) {
110+
func (b *Builder) AddPanelToRow(rowTitle string, panel ...*Panel) {
95111
for _, item := range panel {
96-
panelID := b.getPanelCounter()
97-
if item.statPanelBuilder != nil {
98-
item.statPanelBuilder.Id(panelID)
99-
b.dashboardBuilder.WithPanel(item.statPanelBuilder)
100-
} else if item.timeSeriesPanelBuilder != nil {
101-
item.timeSeriesPanelBuilder.Id(panelID)
102-
b.dashboardBuilder.WithPanel(item.timeSeriesPanelBuilder)
103-
} else if item.barGaugePanelBuilder != nil {
104-
item.barGaugePanelBuilder.Id(panelID)
105-
b.dashboardBuilder.WithPanel(item.barGaugePanelBuilder)
106-
} else if item.gaugePanelBuilder != nil {
107-
item.gaugePanelBuilder.Id(panelID)
108-
b.dashboardBuilder.WithPanel(item.gaugePanelBuilder)
109-
} else if item.tablePanelBuilder != nil {
110-
item.tablePanelBuilder.Id(panelID)
111-
b.dashboardBuilder.WithPanel(item.tablePanelBuilder)
112-
} else if item.logPanelBuilder != nil {
113-
item.logPanelBuilder.Id(panelID)
114-
b.dashboardBuilder.WithPanel(item.logPanelBuilder)
115-
} else if item.heatmapBuilder != nil {
116-
item.heatmapBuilder.Id(panelID)
117-
b.dashboardBuilder.WithPanel(item.heatmapBuilder)
118-
} else if item.textPanelBuilder != nil {
119-
item.textPanelBuilder.Id(panelID)
120-
b.dashboardBuilder.WithPanel(item.textPanelBuilder)
121-
} else if item.histogramPanelBuilder != nil {
122-
item.histogramPanelBuilder.Id(panelID)
123-
b.dashboardBuilder.WithPanel(item.histogramPanelBuilder)
124-
} else if item.businessVariablePanelBuilder != nil {
125-
item.businessVariablePanelBuilder.Id(panelID)
126-
b.dashboardBuilder.WithPanel(item.businessVariablePanelBuilder)
127-
} else if item.polystatPanelBuilder != nil {
128-
item.polystatPanelBuilder.Id(panelID)
129-
b.dashboardBuilder.WithPanel(item.polystatPanelBuilder)
112+
b.entries = append(b.entries, buildEntry{kind: entryPanelToRow, rowTitle: rowTitle, panel: item})
113+
if len(item.alertBuilders) > 0 {
114+
b.AddAlert(item.alertBuilders...)
130115
}
116+
}
117+
}
118+
119+
func (b *Builder) AddPanel(panel ...*Panel) {
120+
for _, item := range panel {
121+
b.entries = append(b.entries, buildEntry{kind: entryPanel, panel: item})
131122
if len(item.alertBuilders) > 0 {
132123
b.AddAlert(item.alertBuilders...)
133124
}
@@ -150,10 +141,58 @@ func (b *Builder) AddNotificationPolicy(notificationPolicies ...*alerting.Notifi
150141
b.notificationPoliciesBuilder = append(b.notificationPoliciesBuilder, notificationPolicies...)
151142
}
152143

144+
// addPanelToBuilder assigns an ID and adds the panel to the dashboard builder.
145+
func (b *Builder) addPanelToBuilder(item *Panel) {
146+
if pb := item.panelBuilder(b.getPanelCounter()); pb != nil {
147+
b.dashboardBuilder.WithPanel(pb)
148+
}
149+
}
150+
151+
// addPanelToRow assigns an ID and adds the panel to a row builder.
152+
func (b *Builder) addPanelToRow(row *dashboard.RowBuilder, item *Panel) {
153+
if pb := item.panelBuilder(b.getPanelCounter()); pb != nil {
154+
row.WithPanel(pb)
155+
}
156+
}
157+
153158
func (b *Builder) Build() (*Observability, error) {
159+
if b.built {
160+
return nil, errors.New("Build() has already been called; create a new Builder for a new build")
161+
}
162+
b.built = true
163+
154164
observability := Observability{}
155165

166+
if b.dashboardBuilder == nil && len(b.entries) > 0 {
167+
return nil, errors.New("cannot add rows or panels without a dashboard; set BuilderOptions.Name to create one")
168+
}
169+
156170
if b.dashboardBuilder != nil {
171+
// First pass: attach panels to their row builders (needed before WithRow snapshots them)
172+
for _, e := range b.entries {
173+
if e.kind == entryPanelToRow {
174+
row, ok := b.rows[e.rowTitle]
175+
if !ok {
176+
return nil, fmt.Errorf("AddPanelToRow references unknown row %q; call AddRow first", e.rowTitle)
177+
}
178+
b.addPanelToRow(row, e.panel)
179+
}
180+
}
181+
182+
// Second pass: add rows and top-level panels to the dashboard in order
183+
for _, e := range b.entries {
184+
switch e.kind {
185+
case entryRow:
186+
if row, ok := b.rows[e.rowTitle]; ok {
187+
b.dashboardBuilder.WithRow(row)
188+
}
189+
case entryPanel:
190+
b.addPanelToBuilder(e.panel)
191+
default:
192+
continue
193+
}
194+
}
195+
157196
db, errBuildDashboard := b.dashboardBuilder.Build()
158197
if errBuildDashboard != nil {
159198
return nil, errBuildDashboard

0 commit comments

Comments
 (0)