Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 138 additions & 2 deletions observability-lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,16 @@ Godoc generated documentation is available [here](https://pkg.go.dev/github.com/

### Creating a dashboard

<details><summary>main.go</summary>
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
Expand Down Expand Up @@ -90,7 +99,134 @@ func main() {
fmt.Println(string(json))
}
```
</details>

#### 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

Expand Down
125 changes: 82 additions & 43 deletions observability-lib/grafana/builder.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package grafana

import (
"errors"
"fmt"
"maps"

"github.com/grafana/grafana-foundation-sdk/go/alerting"
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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())
Expand Down Expand Up @@ -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})
Comment thread
Atrax1 marked this conversation as resolved.
}

func (b *Builder) getPanelCounter() uint32 {
Expand All @@ -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...)
}
Expand All @@ -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)
Comment thread
Atrax1 marked this conversation as resolved.
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)
}
Comment thread
Atrax1 marked this conversation as resolved.
}

// 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
}
Comment thread
Atrax1 marked this conversation as resolved.
}

db, errBuildDashboard := b.dashboardBuilder.Build()
if errBuildDashboard != nil {
return nil, errBuildDashboard
Expand Down
Loading
Loading