Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
91 changes: 91 additions & 0 deletions internal/docs/dashboards.go
Original file line number Diff line number Diff line change
@@ -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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium docs/dashboards.go:78

The escaper replaces *, {, }, <, > but not |, so pipe characters in dashboard titles or descriptions break the markdown table. A title like "CPU | Memory" produces an extra table column, corrupting the layout. Add | to the escaper replacement list.

 var escaper = strings.NewReplacer("*", "\\*", "{", "\\{", "}", "\\}", "<", "\\<", ">", "\\>")
🤖 Copy this AI Prompt to have your agent fix this:
In file internal/docs/dashboards.go around line 78:

The `escaper` replaces `*`, `{`, `}`, `<`, `>` but not `|`, so pipe characters in dashboard titles or descriptions break the markdown table. A title like "CPU | Memory" produces an extra table column, corrupting the layout. Add `|` to the escaper replacement list.

Evidence trail:
internal/docs/exported_fields.go:25 (escaper definition: `strings.NewReplacer("*", "\\*", "{", "\\{", "}", "\\}", "<", "\\<", ">", "\\>")` — no `|`), internal/docs/dashboards.go:78-88 (markdown table rendering using `escaper.Replace(title)` and `escaper.Replace(description)` within pipe-delimited rows)

builder.WriteString("<details>\n")
builder.WriteString("<summary>View the dashboards</summary>\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</details>\n")
}
263 changes: 263 additions & 0 deletions internal/docs/dashboards_test.go
Original file line number Diff line number Diff line change
@@ -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, "<details>")
assert.Contains(t, result, "<summary>View the dashboards</summary>")
assert.Contains(t, result, "</details>")
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, "<details>")
assert.Contains(t, result, "<summary>View the dashboards</summary>")
assert.Contains(t, result, "</details>")
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 <angle> 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, `\<angle\>`)
assert.Contains(t, result, `\{curly\}`)
assert.NotContains(t, result, "*bold*")
assert.NotContains(t, result, "{braces}")
assert.NotContains(t, result, "<angle>")
},
},
{
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)
}
}
})
}
}
3 changes: 3 additions & 0 deletions internal/docs/readme.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions test/packages/other/dashboards/_dev/build/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
dependencies:
ecs:
reference: git@1.10
5 changes: 5 additions & 0 deletions test/packages/other/dashboards/_dev/build/docs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Readme

## Dashboards

{{ dashboards }}
6 changes: 6 additions & 0 deletions test/packages/other/dashboards/changelog.yml
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions test/packages/other/dashboards/docs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Readme

## Dashboards

**The following dashboards are available:**

<details>
<summary>View the dashboards</summary>

| Dashboard | Description |
|---|---|
| **[Example] Dashboard** | An example dashboard for testing the rendering of dashboards in the package README. |

</details>

Loading