diff --git a/internal/docs/dashboards.go b/internal/docs/dashboards.go
new file mode 100644
index 0000000000..c30a8c150f
--- /dev/null
+++ b/internal/docs/dashboards.go
@@ -0,0 +1,91 @@
+// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+// or more contributor license agreements. Licensed under the Elastic License;
+// you may not use this file except in compliance with the Elastic License.
+
+package docs
+
+import (
+ "encoding/json"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+type dashboard struct {
+ Attributes struct {
+ Title string
+ Description string
+ }
+}
+
+func renderDashboards(packageRoot string) (string, error) {
+ dashboardsDir := filepath.Join(packageRoot, "kibana", "dashboard")
+
+ if _, err := os.Stat(dashboardsDir); os.IsNotExist(err) {
+ return "", nil
+ }
+
+ var dashboards []dashboard
+
+ err := filepath.WalkDir(dashboardsDir, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+
+ if path == dashboardsDir {
+ return nil
+ }
+
+ if d.IsDir() {
+ return filepath.SkipDir
+ }
+
+ if filepath.Ext(d.Name()) != ".json" {
+ return nil
+ }
+
+ rawDashboard, err := os.ReadFile(path)
+ if err != nil {
+ return fmt.Errorf("failed to read dashboard file: %w", err)
+ }
+
+ var dash dashboard
+ if err := json.Unmarshal(rawDashboard, &dash); err != nil {
+ return fmt.Errorf("failed to unmarshal dashboard JSON: %w", err)
+ }
+
+ dashboards = append(dashboards, dash)
+ return nil
+ })
+
+ if err != nil {
+ return "", fmt.Errorf("parsing dashboards failed: %w", err)
+ }
+
+ var builder strings.Builder
+
+ if len(dashboards) != 0 {
+ builder.WriteString("**The following dashboards are available:**\n\n")
+ renderDashboardsCollapsibleTable(&builder, dashboards)
+ builder.WriteString("\n")
+ }
+
+ return builder.String(), nil
+}
+
+func renderDashboardsCollapsibleTable(builder *strings.Builder, dashboards []dashboard) {
+ builder.WriteString("\n")
+ builder.WriteString("View the dashboards
\n\n")
+ builder.WriteString("| Dashboard | Description |\n")
+ builder.WriteString("|---|---|\n")
+ for _, d := range dashboards {
+ title := strings.TrimSpace(d.Attributes.Title)
+ description := strings.TrimSpace(strings.ReplaceAll(d.Attributes.Description, "\n", " "))
+ fmt.Fprintf(builder, "| **%s** | %s |\n",
+ escaper.Replace(title),
+ escaper.Replace(description))
+ }
+ builder.WriteString("\n \n")
+}
diff --git a/internal/docs/dashboards_test.go b/internal/docs/dashboards_test.go
new file mode 100644
index 0000000000..b94b80a77c
--- /dev/null
+++ b/internal/docs/dashboards_test.go
@@ -0,0 +1,263 @@
+// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+// or more contributor license agreements. Licensed under the Elastic License;
+// you may not use this file except in compliance with the Elastic License.
+
+package docs
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRenderDashboards(t *testing.T) {
+ cases := []struct {
+ name string
+ setupFunc func(t *testing.T) string
+ expectError bool
+ expectEmpty bool
+ validateFunc func(t *testing.T, result string)
+ }{
+ {
+ name: "no dashboards directory",
+ setupFunc: func(t *testing.T) string {
+ return t.TempDir()
+ },
+ expectError: false,
+ expectEmpty: true,
+ },
+ {
+ name: "empty dashboards directory",
+ setupFunc: func(t *testing.T) string {
+ tmpDir := t.TempDir()
+ dashboardsDir := filepath.Join(tmpDir, "kibana", "dashboard")
+ require.NoError(t, os.MkdirAll(dashboardsDir, 0o755))
+ return tmpDir
+ },
+ expectError: false,
+ expectEmpty: true,
+ },
+ {
+ name: "single valid dashboard",
+ setupFunc: func(t *testing.T) string {
+ tmpDir := t.TempDir()
+ dashboardsDir := filepath.Join(tmpDir, "kibana", "dashboard")
+ require.NoError(t, os.MkdirAll(dashboardsDir, 0o755))
+
+ dash := `{
+ "attributes": {
+ "title": "[PostgreSQL OTel Copy] Overview",
+ "description": "Overview of PostgreSQL health and golden signals."
+ }
+ }`
+ dashFile := filepath.Join(dashboardsDir, "overview.json")
+ require.NoError(t, os.WriteFile(dashFile, []byte(dash), 0o644))
+ return tmpDir
+ },
+ expectError: false,
+ expectEmpty: false,
+ validateFunc: func(t *testing.T, result string) {
+ assert.Contains(t, result, "**The following dashboards are available:**")
+ assert.Contains(t, result, "")
+ assert.Contains(t, result, "View the dashboards
")
+ assert.Contains(t, result, " ")
+ assert.Contains(t, result, "| Dashboard | Description |")
+ assert.Contains(t, result, "|---|---|")
+ assert.Contains(t, result, "| **[PostgreSQL OTel Copy] Overview** | Overview of PostgreSQL health and golden signals. |")
+ },
+ },
+ {
+ name: "multiple valid dashboards",
+ setupFunc: func(t *testing.T) string {
+ tmpDir := t.TempDir()
+ dashboardsDir := filepath.Join(tmpDir, "kibana", "dashboard")
+ require.NoError(t, os.MkdirAll(dashboardsDir, 0o755))
+
+ dash1 := `{
+ "attributes": {
+ "title": "First Dashboard",
+ "description": "First description"
+ }
+ }`
+ dash2 := `{
+ "attributes": {
+ "title": "Second Dashboard",
+ "description": "Second description"
+ }
+ }`
+ require.NoError(t, os.WriteFile(filepath.Join(dashboardsDir, "d1.json"), []byte(dash1), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(dashboardsDir, "d2.json"), []byte(dash2), 0o644))
+ return tmpDir
+ },
+ expectError: false,
+ expectEmpty: false,
+ validateFunc: func(t *testing.T, result string) {
+ assert.Contains(t, result, "")
+ assert.Contains(t, result, "View the dashboards
")
+ assert.Contains(t, result, " ")
+ assert.Contains(t, result, "| Dashboard | Description |")
+ assert.Contains(t, result, "|---|---|")
+ assert.Contains(t, result, "| **First Dashboard** | First description |")
+ assert.Contains(t, result, "| **Second Dashboard** | Second description |")
+ },
+ },
+ {
+ name: "skip non-json files",
+ setupFunc: func(t *testing.T) string {
+ tmpDir := t.TempDir()
+ dashboardsDir := filepath.Join(tmpDir, "kibana", "dashboard")
+ require.NoError(t, os.MkdirAll(dashboardsDir, 0o755))
+
+ dash := `{
+ "attributes": {
+ "title": "Valid Dashboard",
+ "description": "Valid description"
+ }
+ }`
+ require.NoError(t, os.WriteFile(filepath.Join(dashboardsDir, "valid.json"), []byte(dash), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(dashboardsDir, "ignore.txt"), []byte("ignored"), 0o644))
+ require.NoError(t, os.WriteFile(filepath.Join(dashboardsDir, "README.md"), []byte("# readme"), 0o644))
+ return tmpDir
+ },
+ expectError: false,
+ expectEmpty: false,
+ validateFunc: func(t *testing.T, result string) {
+ assert.Contains(t, result, "| **Valid Dashboard** | Valid description |")
+ assert.NotContains(t, result, "ignored")
+ assert.NotContains(t, result, "readme")
+ },
+ },
+ {
+ name: "skip subdirectories",
+ setupFunc: func(t *testing.T) string {
+ tmpDir := t.TempDir()
+ dashboardsDir := filepath.Join(tmpDir, "kibana", "dashboard")
+ require.NoError(t, os.MkdirAll(dashboardsDir, 0o755))
+
+ dash := `{
+ "attributes": {
+ "title": "Root Dashboard",
+ "description": "Root description"
+ }
+ }`
+ require.NoError(t, os.WriteFile(filepath.Join(dashboardsDir, "root.json"), []byte(dash), 0o644))
+
+ subDir := filepath.Join(dashboardsDir, "subdir")
+ require.NoError(t, os.MkdirAll(subDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(subDir, "nested.json"), []byte(dash), 0o644))
+ return tmpDir
+ },
+ expectError: false,
+ expectEmpty: false,
+ validateFunc: func(t *testing.T, result string) {
+ assert.Equal(t, 1, strings.Count(result, "Root Dashboard"))
+ },
+ },
+ {
+ name: "unreadable file",
+ setupFunc: func(t *testing.T) string {
+ tmpDir := t.TempDir()
+ dashboardsDir := filepath.Join(tmpDir, "kibana", "dashboard")
+ require.NoError(t, os.MkdirAll(dashboardsDir, 0o755))
+
+ unreadableFile := filepath.Join(dashboardsDir, "unreadable.json")
+ require.NoError(t, os.WriteFile(unreadableFile, []byte("content"), 0o000))
+ return tmpDir
+ },
+ expectError: true,
+ expectEmpty: false,
+ },
+ {
+ name: "invalid json file",
+ setupFunc: func(t *testing.T) string {
+ tmpDir := t.TempDir()
+ dashboardsDir := filepath.Join(tmpDir, "kibana", "dashboard")
+ require.NoError(t, os.MkdirAll(dashboardsDir, 0o755))
+
+ invalidJSON := `{ "attributes": { "title": "Invalid" }`
+ require.NoError(t, os.WriteFile(filepath.Join(dashboardsDir, "invalid.json"), []byte(invalidJSON), 0o644))
+ return tmpDir
+ },
+ expectError: true,
+ expectEmpty: false,
+ },
+ {
+ name: "special characters are escaped",
+ setupFunc: func(t *testing.T) string {
+ tmpDir := t.TempDir()
+ dashboardsDir := filepath.Join(tmpDir, "kibana", "dashboard")
+ require.NoError(t, os.MkdirAll(dashboardsDir, 0o755))
+
+ dash := `{
+ "attributes": {
+ "title": "Dashboard with *bold* and {braces}",
+ "description": "Description with and {curly} brackets"
+ }
+ }`
+ require.NoError(t, os.WriteFile(filepath.Join(dashboardsDir, "special.json"), []byte(dash), 0o644))
+ return tmpDir
+ },
+ expectError: false,
+ expectEmpty: false,
+ validateFunc: func(t *testing.T, result string) {
+ assert.Contains(t, result, `\*bold\*`)
+ assert.Contains(t, result, `\{braces\}`)
+ assert.Contains(t, result, `\`)
+ assert.Contains(t, result, `\{curly\}`)
+ assert.NotContains(t, result, "*bold*")
+ assert.NotContains(t, result, "{braces}")
+ assert.NotContains(t, result, "")
+ },
+ },
+ {
+ name: "newlines in description are flattened",
+ setupFunc: func(t *testing.T) string {
+ tmpDir := t.TempDir()
+ dashboardsDir := filepath.Join(tmpDir, "kibana", "dashboard")
+ require.NoError(t, os.MkdirAll(dashboardsDir, 0o755))
+
+ dash := `{
+ "attributes": {
+ "title": "Multiline",
+ "description": "Line one.\nLine two."
+ }
+ }`
+ require.NoError(t, os.WriteFile(filepath.Join(dashboardsDir, "ml.json"), []byte(dash), 0o644))
+ return tmpDir
+ },
+ expectError: false,
+ expectEmpty: false,
+ validateFunc: func(t *testing.T, result string) {
+ assert.Contains(t, result, "| **Multiline** | Line one. Line two. |")
+ },
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ packageRoot := tc.setupFunc(t)
+
+ result, err := renderDashboards(packageRoot)
+
+ if tc.expectError {
+ assert.Error(t, err)
+ return
+ }
+
+ require.NoError(t, err)
+
+ if tc.expectEmpty {
+ assert.Empty(t, result)
+ } else {
+ assert.NotEmpty(t, result)
+ if tc.validateFunc != nil {
+ tc.validateFunc(t, result)
+ }
+ }
+ })
+ }
+}
diff --git a/internal/docs/readme.go b/internal/docs/readme.go
index 3140d42d7c..6d2b307422 100644
--- a/internal/docs/readme.go
+++ b/internal/docs/readme.go
@@ -240,6 +240,9 @@ func renderReadme(repositoryRoot *os.Root, fileName, packageRoot, templatePath s
"sloTemplates": func() (string, error) {
return renderSloTemplates(packageRoot, linksMap)
},
+ "dashboards": func() (string, error) {
+ return renderDashboards(packageRoot)
+ },
}).ParseFiles(templatePath)
if err != nil {
return nil, fmt.Errorf("parsing README template failed (path: %s): %w", templatePath, err)
diff --git a/test/packages/other/dashboards/_dev/build/build.yml b/test/packages/other/dashboards/_dev/build/build.yml
new file mode 100644
index 0000000000..002aa15659
--- /dev/null
+++ b/test/packages/other/dashboards/_dev/build/build.yml
@@ -0,0 +1,3 @@
+dependencies:
+ ecs:
+ reference: git@1.10
diff --git a/test/packages/other/dashboards/_dev/build/docs/README.md b/test/packages/other/dashboards/_dev/build/docs/README.md
new file mode 100644
index 0000000000..f49379c6e8
--- /dev/null
+++ b/test/packages/other/dashboards/_dev/build/docs/README.md
@@ -0,0 +1,5 @@
+# Readme
+
+## Dashboards
+
+{{ dashboards }}
\ No newline at end of file
diff --git a/test/packages/other/dashboards/changelog.yml b/test/packages/other/dashboards/changelog.yml
new file mode 100644
index 0000000000..e00f881335
--- /dev/null
+++ b/test/packages/other/dashboards/changelog.yml
@@ -0,0 +1,6 @@
+# newer versions go on top
+- version: "0.0.1"
+ changes:
+ - description: Initial draft of the package
+ type: enhancement
+ link: https://github.com/elastic/integrations/pull/1
diff --git a/test/packages/other/dashboards/docs/README.md b/test/packages/other/dashboards/docs/README.md
new file mode 100644
index 0000000000..b7dcdfb8af
--- /dev/null
+++ b/test/packages/other/dashboards/docs/README.md
@@ -0,0 +1,15 @@
+# Readme
+
+## Dashboards
+
+**The following dashboards are available:**
+
+
+View the dashboards
+
+| Dashboard | Description |
+|---|---|
+| **[Example] Dashboard** | An example dashboard for testing the rendering of dashboards in the package README. |
+
+
+
diff --git a/test/packages/other/dashboards/kibana/dashboard/dashboards-example.json b/test/packages/other/dashboards/kibana/dashboard/dashboards-example.json
new file mode 100644
index 0000000000..8d0d6f0821
--- /dev/null
+++ b/test/packages/other/dashboards/kibana/dashboard/dashboards-example.json
@@ -0,0 +1,17 @@
+{
+ "attributes": {
+ "description": "An example dashboard for testing the rendering of dashboards in the package README.",
+ "kibanaSavedObjectMeta": {
+ "searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"kuery\"}}"
+ },
+ "optionsJSON": "{\"useMargins\":true,\"hidePanelTitles\":false}",
+ "panelsJSON": "[]",
+ "timeRestore": false,
+ "title": "[Example] Dashboard"
+ },
+ "coreMigrationVersion": "8.8.0",
+ "id": "dashboards-example",
+ "references": [],
+ "type": "dashboard",
+ "typeMigrationVersion": "10.3.0"
+}
diff --git a/test/packages/other/dashboards/manifest.yml b/test/packages/other/dashboards/manifest.yml
new file mode 100644
index 0000000000..49033afa2d
--- /dev/null
+++ b/test/packages/other/dashboards/manifest.yml
@@ -0,0 +1,14 @@
+format_version: 3.4.0
+name: dashboards
+title: "Dashboards"
+version: 0.0.1
+description: "This is a test of rendering dashboards in README."
+type: integration
+categories:
+ - custom
+conditions:
+ kibana:
+ version: "^9.0.0"
+owner:
+ github: elastic/integrations
+ type: elastic